feat: initial implementation of container management platform

This commit is contained in:
Tomas Dvorak
2026-02-16 10:18:05 +01:00
commit ffa5489dc1
167 changed files with 55910 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
# Git
.git
.gitignore
# Documentation
README.md
README_BACKEND.md
*.md
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
build
*.exe
*.exe~
*.dll
*.so
*.dylib
# Go specific
*.test
*.prof
vendor/
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# Temporary folders
tmp/
temp/
# Docker
Dockerfile*
docker-compose*.yml
docker-compose*.yaml
traefik*.yml
.traefik/
# Data directories
data/
postgres_data/
redis_data/
letsencrypt/
# Development
.eslintrc*
.prettierrc*
+36
View File
@@ -0,0 +1,36 @@
# Domain Configuration
DOMAIN=yourdomain.com
ACME_EMAIL=[email protected]
# Database Configuration
POSTGRES_DB=containr
POSTGRES_USER=containr_user
POSTGRES_PASSWORD=your_secure_postgres_password
# Redis Configuration
REDIS_PASSWORD=your_secure_redis_password
# Application Configuration
JWT_SECRET=your_very_secure_jwt_secret_key_here
CORS_ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
# Traefik Authentication (Basic Auth for dashboard)
# Generate with: htpasswd -nb username password
TRAEFIK_AUTH=admin:$apr1$b8mh8c8v$KkR8hQZQZQZQZQZQZQZQZ/
# Optional: Cloudflare Tunnel (alternative to domain)
# Get token from: https://dash.cloudflare.com/argotunnel
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
+104
View File
@@ -0,0 +1,104 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build outputs
dist/
dist-ssr/
build/
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs/
*.log
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS generated files
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
# Local development files
*.local
.history/
# Test files
test-results/
playwright-report/
test-results.xml
+288
View File
@@ -0,0 +1,288 @@
# Autoscaling with Cloudflare Tunnel
## Overview
This document explains how autoscaling works when using Cloudflare Tunnel with the Containr application.
## Architecture
```
Internet → Cloudflare Edge → Cloudflare Tunnel → Traefik → Backend Services
```
## Autoscaling Considerations
### 1. Cloudflare Tunnel Limitations
**Cloudflare Tunnel itself does NOT provide autoscaling.** It's a secure tunneling service that:
- Creates a persistent connection between your infrastructure and Cloudflare's edge
- Routes traffic through Cloudflare's global network
- Provides DDoS protection and CDN features
### 2. Where Autoscaling Happens
Autoscaling must be implemented at different layers:
#### A. Container Level (Docker Swarm/Kubernetes)
```yaml
# Example with Docker Swarm
backend:
image: containr-backend
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
```
#### B. Application Level (Load Balancing)
Traefik automatically load balances between multiple backend instances:
```yaml
# Multiple backend containers
backend-1:
# ... backend config
labels:
- "traefik.http.services.backend.loadbalancer.server.port=8080"
backend-2:
# ... backend config
labels:
- "traefik.http.services.backend.loadbalancer.server.port=8080"
```
#### C. Cloud Level (Cloudflare Load Balancer - Paid Feature)
For true autoscaling, you'd need:
- Multiple deployments in different regions
- Cloudflare Load Balancer ($$$/month)
- Health checks and failover
## Implementation Options
### Option 1: Docker Swarm (Recommended for Single Host)
```bash
# Initialize Docker Swarm
docker swarm init
# Deploy with autoscaling
docker stack deploy -c docker-compose.yml containr
```
### Option 2: Kubernetes
```yaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 3
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: containr-backend
ports:
- containerPort: 8080
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: backend-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: backend
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
```
### Option 3: Manual Scaling with Scripts
```bash
#!/bin/bash
# scale-backend.sh
scale_up() {
local current=$(docker ps --filter "name=containr-backend" --format "table {{.Names}}" | wc -l)
local target=$((current + 1))
echo "Scaling backend to $target instances..."
for i in $(seq 1 $target); do
docker run -d \
--name containr-backend-$i \
--network containr_containr-network \
-e DATABASE_URL="..." \
-e REDIS_URL="..." \
containr-backend
done
}
scale_down() {
local current=$(docker ps --filter "name=containr-backend" --format "table {{.Names}}" | wc -l)
local target=$((current - 1))
if [ $target -lt 1 ]; then
echo "Cannot scale below 1 instance"
exit 1
fi
echo "Scaling backend to $target instances..."
docker stop containr-backend-$target
docker rm containr-backend-$target
}
case "$1" in
up) scale_up ;;
down) scale_down ;;
*) echo "Usage: $0 [up|down]" ;;
esac
```
## Monitoring and Metrics
### Health Checks
All services include health checks:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
```
### Metrics Collection
Traefik provides Prometheus metrics:
```yaml
# In docker-compose.yml
command:
- "--metrics.prometheus=true"
- "--metrics.prometheus.addentrypointslabels=true"
- "--metrics.prometheus.addserviceslabels=true"
```
### Scaling Triggers
Monitor these metrics for scaling decisions:
- CPU usage (> 70%)
- Memory usage (> 80%)
- Response time (> 500ms)
- Error rate (> 5%)
- Queue depth (if using message queues)
## Production Recommendations
### 1. Use Docker Swarm or Kubernetes
- Better orchestration
- Built-in load balancing
- Health management
- Rolling updates
### 2. Implement Horizontal Pod Autoscaler (HPA)
- Automatic scaling based on metrics
- Min/max replica limits
- Configurable thresholds
### 3. Use Cloudflare Load Balancer (if budget allows)
- Geographic distribution
- Advanced health checks
- Traffic steering
- DDoS protection
### 4. Monitoring and Alerting
- Prometheus + Grafana
- Alertmanager
- Log aggregation (ELK stack)
## Example: Complete Autoscaling Setup
```yaml
# docker-compose.autoscale.yml
version: '3.8'
services:
traefik:
image: traefik:v3.2
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.swarmMode=true"
- "--metrics.prometheus=true"
deploy:
replicas: 1
placement:
constraints:
- node.role == manager
backend:
image: containr-backend
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
labels:
- "traefik.http.services.backend.loadbalancer.server.port=8080"
- "traefik.http.routers.backend.rule=Host(`api.${DOMAIN}`)"
- "traefik.enable=true"
prometheus:
image: prom/prometheus
deploy:
replicas: 1
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana
deploy:
replicas: 1
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
```
## Summary
1. **Cloudflare Tunnel ≠ Autoscaling** - It's for secure connectivity
2. **Autoscaling happens at container/orchestration level**
3. **Traefik provides load balancing between instances**
4. **Use Docker Swarm or Kubernetes for production autoscaling**
5. **Monitor metrics and implement HPA for automatic scaling**
6. **Consider Cloudflare Load Balancer for multi-region setups**
## Quick Start Commands
```bash
# Start with autoscaling (Docker Swarm)
docker swarm init
docker stack deploy -c docker-compose.autoscale.yml containr
# Scale manually
docker service scale containr_backend=5
# Check status
docker service ls
docker service ps containr_backend
# View logs
docker service logs containr_backend
```
+124
View File
@@ -0,0 +1,124 @@
# Cloudflare Tunnel Setup
This guide explains how to set up Cloudflare tunnel for Containr, allowing you to expose your services without configuring a domain.
## Prerequisites
1. A Cloudflare account
2. A domain (any domain, even a free one)
## Setup Steps
### 1. Create a Cloudflare Tunnel
1. Log in to your [Cloudflare Dashboard](https://dash.cloudflare.com/)
2. Go to **Zero Trust****Networks****Tunnels**
3. Click **Create tunnel**
4. Choose **Cloudflared** and click **Next**
5. Give your tunnel a name (e.g., "containr-tunnel")
6. Click **Save tunnel**
### 2. Get Your Tunnel Token
After creating the tunnel, Cloudflare will show you a command like:
```bash
cloudflared tunnel run <tunnel-id>
```
Copy the token from the `.cloudflared/config.yml` file or use the token provided by Cloudflare.
### 3. Configure Containr
1. Copy `.env.example` to `.env` if you haven't already:
```bash
cp .env.example .env
```
2. Edit `.env` and add your Cloudflare tunnel token:
```env
CLOUDFLARED_TOKEN=your_copied_tunnel_token_here
```
### 4. Start Services with Cloudflare Tunnel
```bash
# Start all services including cloudflared
make cloudflared-up
# Or start manually
docker-compose --profile cloudflared up -d
```
### 5. Configure Tunnel Routes
In your Cloudflare dashboard:
1. Go to your tunnel settings
2. Add the following public hostnames:
- `your-domain.com` → `http://traefik:80` (for frontend)
- `api.your-domain.com` → `http://traefik:80` (for backend)
- `traefik.your-domain.com` → `http://traefik:80` (for dashboard)
### 6. Access Your Services
Once configured, you can access:
- Frontend: `https://your-domain.com`
- API: `https://api.your-domain.com`
- Traefik Dashboard: `https://traefik.your-domain.com`
## Management Commands
```bash
# Start with Cloudflare tunnel
make cloudflared-up
# Stop Cloudflare tunnel
make cloudflared-down
# View logs
docker-compose logs -f cloudflared
# Check status
docker-compose ps
```
## Benefits
- **No domain configuration required** in `.env`
- **Automatic SSL** through Cloudflare
- **DDoS protection** and security features
- **Easy setup** - just need a tunnel token
- **Works anywhere** - no port forwarding needed
## Troubleshooting
### Tunnel Not Connecting
- Verify your `CLOUDFLARED_TOKEN` is correct
- Check cloudflared logs: `docker-compose logs cloudflared`
- Ensure your tunnel is active in Cloudflare dashboard
### Services Not Accessible
- Verify you've configured the public hostnames in Cloudflare
- Check that all services are running: `docker-compose ps`
- Ensure the tunnel routes point to `http://traefik:80`
### Token Issues
- Regenerate your tunnel token in Cloudflare dashboard
- Make sure there are no extra spaces or newlines in the token
## Alternative: Domain Mode
If you prefer traditional domain setup instead of Cloudflare tunnel:
1. Configure your domain in `.env`:
```env
DOMAIN=yourdomain.com
[email protected]
```
2. Use regular commands:
```bash
make prod
```
This will use Let's Encrypt certificates instead of Cloudflare tunnel.
+231
View File
@@ -0,0 +1,231 @@
# Docker Setup with Traefik
This guide will help you set up Containr with Docker, Traefik reverse proxy, and automatic SSL certificates.
## Prerequisites
- Docker and Docker Compose installed
- A domain name pointing to your server's IP address
- Port 80 and 443 open on your firewall
## Quick Start
1. **Clone and prepare the environment:**
```bash
git clone <your-repo>
cd containr
cp .env.example .env
```
2. **Configure your environment:**
Edit `.env` file with your settings:
```bash
nano .env
```
Required changes:
- `DOMAIN=yourdomain.com` - Your actual domain
- `ACME_EMAIL=admin@yourdomain.com` - Email for SSL certificates
- `POSTGRES_PASSWORD` - Set a secure password
- `REDIS_PASSWORD` - Set a secure password
- `JWT_SECRET` - Generate a secure random string
- `TRAEFIK_AUTH` - Generate basic auth for dashboard
3. **Generate Traefik authentication:**
```bash
# Install apache2-utils if needed
sudo apt-get install apache2-utils
# Generate username:password hash
htpasswd -nb admin yourpassword
# Update TRAEFIK_AUTH in .env with the output
```
4. **Create necessary directories:**
```bash
mkdir -p data/letsencrypt
chmod 600 data/letsencrypt/acme.json
```
5. **Start the services:**
```bash
docker-compose up -d
```
## Services and URLs
After deployment, your services will be available at:
- **Frontend**: `https://yourdomain.com`
- **Backend API**: `https://api.yourdomain.com`
- **Traefik Dashboard**: `https://traefik.yourdomain.com`
## Architecture
```
Internet → Traefik (Port 80/443)
├── Frontend (React/Nginx)
├── Backend (Go API)
├── PostgreSQL (Database)
└── Redis (Cache)
```
## Configuration Files
### Docker Compose
- `docker-compose.yml` - Main orchestration file
- Defines all services, networks, and volumes
- Configures Traefik with automatic SSL
### Traefik Configuration
- `traefik.yml` - Static configuration
- `traefik-dynamic.yml` - Dynamic routing rules
- Automatic HTTP to HTTPS redirection
- Security headers and rate limiting
### Dockerfiles
- `Dockerfile.backend` - Go backend with multi-stage build
- `Dockerfile.frontend` - React frontend with Nginx
- Both use non-root users for security
## Security Features
- **Automatic SSL** via Let's Encrypt
- **HTTP to HTTPS** redirection
- **Security headers** (HSTS, XSS protection, etc.)
- **Rate limiting** on API endpoints
- **Basic authentication** on Traefik dashboard
- **Non-root containers** for all services
- **Health checks** for all services
## Monitoring and Logs
### Traefik Dashboard
Access at `https://traefik.yourdomain.com` with your configured credentials.
### Logs
```bash
# View all logs
docker-compose logs -f
# View specific service logs
docker-compose logs -f traefik
docker-compose logs -f backend
docker-compose logs -f frontend
```
### Health Checks
All services include health checks:
```bash
# Check service status
docker-compose ps
```
## Maintenance
### Updates
```bash
# Pull latest images
docker-compose pull
# Recreate services with new images
docker-compose up -d --force-recreate
```
### Backups
```bash
# Backup PostgreSQL
docker-compose exec postgres pg_dump -U containr_user containr > backup.sql
# Backup Redis
docker-compose exec redis redis-cli --rdb /data/dump.rdb
```
### SSL Certificates
Let's Encrypt certificates are automatically renewed. Manual renewal:
```bash
docker-compose exec traefik traefik api check-letsencrypt
```
## Development Mode
For local development without SSL:
```bash
# Create development override
cat > docker-compose.override.yml << EOF
version: '3.8'
services:
traefik:
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
- "--log.level=DEBUG"
ports:
- "80:80"
- "8080:8080"
labels:
- "traefik.http.routers.traefik.rule=Host(`localhost`)"
- "traefik.http.routers.traefik.entrypoints=web"
- "traefik.http.routers.traefik.service=api@internal"
EOF
# Start with override
docker-compose up -d
```
## Troubleshooting
### Common Issues
1. **SSL Certificate Issues**
```bash
# Check acme.json permissions
ls -la data/letsencrypt/acme.json
# Reset certificates
rm data/letsencrypt/acme.json
docker-compose restart traefik
```
2. **Port Conflicts**
```bash
# Check what's using ports
sudo netstat -tlnp | grep :80
sudo netstat -tlnp | grep :443
```
3. **Database Connection**
```bash
# Test database connection
docker-compose exec backend ping postgres
```
4. **Permission Issues**
```bash
# Fix volume permissions
sudo chown -R 1001:1001 data/
```
### Performance Tuning
1. **Nginx Caching** - Already configured in `nginx.conf`
2. **Redis Caching** - Configure in your application
3. **Database Pooling** - Adjust connection limits in Go app
## Production Tips
1. **Monitoring** - Set up Prometheus/Grafana
2. **Alerting** - Configure alerts for service failures
3. **Backup Strategy** - Automated database backups
4. **Load Testing** - Test before production deployment
5. **Security Audit** - Regular security scans
## Support
For issues:
1. Check logs: `docker-compose logs`
2. Verify configuration: `docker-compose config`
3. Check service status: `docker-compose ps`
4. Review Traefik dashboard for routing issues
+54
View File
@@ -0,0 +1,54 @@
# 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"]
+53
View File
@@ -0,0 +1,53 @@
# 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;"]
+158
View File
@@ -0,0 +1,158 @@
# Containr - Container Management Platform
A modern container management platform with built-in Cloudflare tunnel support for easy deployment without domain configuration.
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Node.js (for development)
- Go (for backend development)
### Installation
1. **Clone and setup:**
```bash
git clone <repository-url>
cd Containr
```
2. **Configure environment:**
```bash
cp .env.example .env
# Edit .env with your configuration
```
### Running the Application
**Single Docker Compose File - Multiple Environments**
```bash
# Development mode with hot reload
./start-unified.sh dev
# Production mode (requires DOMAIN env var)
export DOMAIN=yourdomain.com
./start-unified.sh prod
# Production with Cloudflare tunnel (requires CLOUDFLARED_TOKEN)
export CLOUDFLARED_TOKEN=your_token_here
./start-unified.sh cloudflare
# Stop all services
./start-unified.sh stop
# View logs
./start-unified.sh logs
# Check status
./start-unified.sh status
# Clean everything
./start-unified.sh clean
```
### Access Points
**Development:**
- Frontend: http://localhost
- API: http://api.localhost
- Traefik Dashboard: http://localhost:8080
**Production:**
- Frontend: https://your-domain.com
- API: https://api.your-domain.com
- Traefik Dashboard: https://traefik.your-domain.com
**Cloudflare Tunnel:**
- Check your Cloudflare dashboard for tunnel URLs
### Management Commands
```bash
./start.sh logs # View logs
./start.sh status # Check service status
./start.sh stop # Stop all services
./start.sh clean # Clean up containers and volumes
./start.sh help # Show all commands
```
## Features
- 🐳 **Container Management** - Deploy and manage containers
- ☁️ **Cloudflare Tunnel** - Zero-config deployment with automatic SSL
- 🌐 **Web Interface** - Modern React-based UI
- 🔧 **Settings Panel** - Visual configuration management
- 📊 **Infrastructure Monitoring** - Real-time resource monitoring
- 🔐 **Security** - Built-in authentication and authorization
## Cloudflare Setup
1. **Create a tunnel** in your [Cloudflare Dashboard](https://dash.cloudflare.com/argotunnel)
2. **Copy the tunnel token**
3. **Add to .env:**
```env
CLOUDFLARED_TOKEN=your_tunnel_token_here
```
4. **Start with tunnel:**
```bash
./start.sh cloudflare
```
## Development
### Frontend Development
```bash
cd frontend
npm install
npm run dev
```
### Backend Development
```bash
cd backend
go mod download
go run main.go
```
## Configuration
### Environment Variables
Key configuration options in `.env`:
```env
# Domain Configuration (optional if using Cloudflare)
DOMAIN=yourdomain.com
[email protected]
# Database
POSTGRES_DB=containr
POSTGRES_USER=containr_user
POSTGRES_PASSWORD=secure_password
# Cloudflare Tunnel (alternative to domain)
CLOUDFLARED_TOKEN=your_tunnel_token_here
# Application
JWT_SECRET=your_jwt_secret
CORS_ALLOWED_ORIGINS=https://yourdomain.com
```
## Architecture
- **Frontend**: React + TypeScript + Vite + TailwindCSS
- **Backend**: Go + Gin + PostgreSQL + Redis
- **Proxy**: Traefik with automatic SSL
- **Tunnel**: Cloudflare Argo Tunnel (optional)
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
## License
MIT License - see LICENSE file for details
+131
View File
@@ -0,0 +1,131 @@
# Containr Backend Setup
## Quick Start
### Prerequisites
- Go 1.21+
- PostgreSQL 12+
- Redis (optional)
### Environment Variables
Create a `.env` file or set these environment variables:
```bash
# Database
DATABASE_URL=postgres://containr:password@localhost:5432/containr?sslmode=disable
REDIS_URL=redis://localhost:6379
# Server
PORT=8080
ENVIRONMENT=development
# Security
JWT_SECRET=your-secret-key-change-in-production
# Frontend (for CORS)
FRONTEND_URL=http://localhost:3000
```
### Database Setup
1. Create PostgreSQL database:
```sql
CREATE DATABASE containr;
CREATE USER containr WITH PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE containr TO containr;
```
2. Run migrations (handled automatically on server start)
### Running the Server
```bash
# Build
go build -o bin/server cmd/server/main.go
# Run
./bin/server
```
The server will start on `http://localhost:8080`
### API Endpoints
#### Health Check
- `GET /health` - Server health status
#### Authentication
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/register` - User registration
#### User Profile
- `GET /api/v1/user/profile` - Get user profile (requires auth)
- `PUT /api/v1/user/profile` - Update user profile (requires auth)
#### Projects
- `GET /api/v1/projects` - List user projects (requires auth)
- `POST /api/v1/projects` - Create project (requires auth)
- `GET /api/v1/projects/:id` - Get project details (requires auth)
- `PUT /api/v1/projects/:id` - Update project (requires auth)
- `DELETE /api/v1/projects/:id` - Delete project (requires auth)
### Authentication
Include JWT token in Authorization header:
```
Authorization: Bearer <your-jwt-token>
```
### Example Usage
1. Register a user:
```bash
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "password123",
"name": "Test User"
}'
```
2. Login:
```bash
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "password123"
}'
```
3. Create a project (using token from login):
```bash
curl -X POST http://localhost:8080/api/v1/projects \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"name": "My Project",
"description": "A test project"
}'
```
## Development
### Project Structure
```
cmd/server/ - Main application entry point
internal/
├── api/ - HTTP handlers and routes
├── config/ - Configuration management
├── database/ - Database connections and migrations
└── middleware/ - HTTP middleware
migrations/ - SQL migration files
```
### Adding New Endpoints
1. Create handler functions in `internal/api/`
2. Add routes in `internal/api/routes.go`
3. Update database schema if needed in `migrations/`
Executable
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

+13
View File
@@ -0,0 +1,13 @@
# Cloudflare Tunnel Configuration
# This file provides additional configuration for cloudflared
# You can customize this for more advanced tunnel setups
tunnel: auto
logfile: /var/log/cloudflared.log
loglevel: info
# Optional: Ingress rules for more control
# ingress:
# - hostname: your-domain.com
# service: http://traefik:80
# - service: http_status:404
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"containr/internal/cli"
"fmt"
"os"
)
func main() {
if err := cli.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"containr/internal/api"
"containr/internal/config"
"containr/internal/database"
"containr/internal/middleware"
"github.com/gin-gonic/gin"
"github.com/rs/cors"
)
func main() {
// Load configuration
cfg := config.Load()
// Initialize database
db, err := database.NewConnection(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
// Run migrations
if err := db.Migrate("migrations"); err != nil {
log.Printf("Warning: Failed to run migrations: %v", err)
}
// Seed data for development
if cfg.Environment == "development" {
if err := db.SeedData(); err != nil {
log.Printf("Warning: Failed to seed data: %v", err)
}
}
// Initialize Redis
redis := database.NewRedis(cfg.RedisURL)
// Setup Gin router
if cfg.Environment == "production" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
// Add middleware
router.Use(middleware.Logger())
router.Use(middleware.Recovery())
router.Use(middleware.RequestID())
// CORS setup
c := cors.New(cors.Options{
AllowedOrigins: cfg.CORS.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposedHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 86400,
})
// Wrap Gin router with CORS
handler := c.Handler(router)
// Initialize API routes
api.SetupRoutes(router, db, redis, cfg)
// Create HTTP server
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server in a goroutine
go func() {
log.Printf("Server starting on port %s", cfg.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Create a deadline for shutdown
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Attempt graceful shutdown
if err := server.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "src/components",
"utils": "src/lib/utils"
}
}
+385
View File
@@ -0,0 +1,385 @@
1️⃣ What the Railway Dashboard Is
Railway is a PaaS (Platform as a Service).
The dashboard is where you:
Create services (apps, APIs, workers)
Add databases
Connect GitHub repos
Configure env variables
View logs & metrics
Define networking between services
Instead of showing raw Kubernetes or Docker Compose, Railway abstracts everything into a visual canvas.
2️⃣ What This “Canvas” Actually Is
The key part in your HTML:
<div class="react-flow ...">
That tells us immediately:
👉 Theyre using React Flow — a React library for building node-based editors.
React Flow powers:
Node graphs
Drag & drop canvases
Visual connections
Zoom/pan
Edges with arrows
So this canvas = a React Flow graph editor customized to represent infrastructure.
3️⃣ Architecture of the Canvas
Heres whats happening internally:
A) Root Layout
stack-container
nested-canvas
canvas-container
This is:
Grid layout
Responsive behavior
Sidebar + center canvas layout
Mobile floating button
Theyre using:
Tailwind CSS
CSS Grid
Dark mode variants
Utility classes
Very modern stack.
B) The Graph Engine (React Flow)
This part:
<div class="react-flow__viewport"
style="transform: translate(306px, 279px) scale(1);">
This means:
The canvas supports panning (translate X/Y)
It supports zooming (scale)
Entire graph is rendered inside a transform container
So when you drag around → it updates the transform.
When you zoom → scale changes.
Classic infinite canvas technique.
C) Nodes
Example:
<div class="react-flow__node react-flow__node-empty">
Nodes represent:
GitHub repo
Database
Docker image
Function
Bucket
Empty service
Each node = React component.
Internally Railway likely stores something like:
{
id: "service_123",
type: "github",
position: { x: 120, y: 400 },
data: {
repo: "...",
env: {...}
}
}
React Flow renders this declaratively.
D) Edges (Connections)
This part:
Edges are rendered in SVG.
You also saw:
<marker id="arrowhead">
That defines arrowheads for:
Normal connection
Staged
Selected
Network
Alternative
So edges are dynamic SVG paths with markers attached.
4️⃣ What the Command Palette Is
This section:
<input placeholder="What would you like to create?" cmdk-input>
This is a command palette UI.
Looks like they use:
👉 cmdk (a React command menu library)
So when you type:
“github”
“database”
“docker”
It filters options.
Selecting one → creates a new node in the graph.
This is very similar to:
Linear
Vercel
Raycast UI pattern
5️⃣ How the Dashboard Works Conceptually
The visual canvas is just a frontend representation of backend state.
Behind the scenes:
Step 1 — You Add a Service
→ Frontend sends API request:
POST /services
Step 2 — Railway Backend
Likely:
Creates container definition
Assigns internal network
Stores graph structure in DB
Triggers deployment pipeline
Step 3 — Infra Layer
Under the hood Railway probably uses:
Containers (Docker)
Orchestration (likely Kubernetes or custom scheduler)
Internal networking mesh
Volume storage
Load balancing
The canvas is just a visual abstraction over:
Containers + Networks + Volumes + Environment Variables
6️⃣ What “Stack Container” Means
id="stack-container-root"
A “stack” in Railway terms =
A collection of services that form one project.
Like:
API
Database
Redis
Worker
Cron
All connected together.
This is similar to:
Docker Compose stack
Kubernetes namespace
Vercel project
7️⃣ The Networking Animation
You saw:
@keyframes networkFlowEgress
@keyframes networkFlowIngress
Thats animated edge flow.
Meaning:
When traffic flows between services
They animate dots moving across edges
This gives visual feedback like:
“API → Database”
Very clean UX detail.
8️⃣ What the Floating Button Is
<button class="sm:hidden fixed right-4 bottom-4">
Thats the mobile "Add Service" button.
On small screens:
Canvas hides
Floating action button appears
Material-inspired design pattern.
9️⃣ So What Is This Canvas Technically?
It is:
A React Flowpowered infrastructure graph editor that maps frontend node/edge state to backend service configuration.
Or simpler:
👉 A visual Docker/Kubernetes orchestrator UI.
🔟 If You Built This Yourself
Given your experience (Go backend + Railway + infra + React):
Youd need:
Frontend:
React
React Flow
Tailwind
Command palette (cmdk)
State manager (Zustand likely)
Backend:
Service registry
Deployment engine
Container runtime control
Network layer
Logs streaming
Optional:
WebSocket for real-time deployment status
Event-driven state updates
⚙️ Why This Design Is Smart
Non-dev friendly
Infrastructure becomes visual
Encourages service-based architecture
Makes networking intuitive
Looks “modern infra SaaS”
Railway basically turned:
docker-compose.yml
into:
a draggable visual graph
🚀 Big Picture
Railway dashboard =
React app
React Flow canvas
Command palette-driven node creation
SVG-based edge system
Tailwind-based design
Backend infra orchestration API
Its a visual abstraction over cloud container orchestration.
View File
+170
View File
@@ -0,0 +1,170 @@
services:
# Traefik Reverse Proxy
traefik:
image: traefik:v3.2
container_name: containr-traefik
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.myresolver.acme.httpchallenge=true"
- "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.myresolver.acme.email=${ACME_EMAIL:-admin@localhost}"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
- "--log.level=${LOG_LEVEL:-INFO}"
- "--accesslog=true"
- "--metrics.prometheus=true"
- "--ping=true"
ports:
- "80:80"
- "443:443"
- "8080:8080" # Traefik dashboard
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./data/letsencrypt:/letsencrypt
networks:
- containr-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN:-localhost}`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.tls.certresolver=myresolver"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=traefik-auth"
- "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_AUTH:-admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/}"
restart: unless-stopped
healthcheck:
test: ["CMD", "traefik", "ping"]
interval: 30s
timeout: 10s
retries: 3
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: containr-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-containr}
POSTGRES_USER: ${POSTGRES_USER:-containr_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev_password_123}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d
networks:
- containr-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-containr_user} -d ${POSTGRES_DB:-containr}"]
interval: 30s
timeout: 10s
retries: 3
# Redis Cache
redis:
image: redis:7-alpine
container_name: containr-redis
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123}
volumes:
- redis_data:/data
networks:
- containr-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 30s
timeout: 10s
retries: 3
# Backend API
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: containr-backend
environment:
- DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
- REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379
- PORT=8080
- ENVIRONMENT=${ENVIRONMENT:-production}
- JWT_SECRET=${JWT_SECRET:-dev_jwt_secret_key_change_in_production}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost,http://localhost:3000}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro # For Docker API access
# Development volumes (only mounted in dev mode)
- ${DEV_MODE:-./internal}:/app/internal
networks:
- containr-network
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.backend.rule=Host(`api.${DOMAIN:-localhost}`)"
- "traefik.http.routers.backend.entrypoints=websecure"
- "traefik.http.routers.backend.tls.certresolver=myresolver"
- "traefik.http.services.backend.loadbalancer.server.port=8080"
- "traefik.http.routers.backend.middlewares=cors-headers"
- "traefik.http.middlewares.cors-headers.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS"
- "traefik.http.middlewares.cors-headers.headers.accesscontrolalloworiginlist=${CORS_ALLOWED_ORIGINS:-http://localhost,http://localhost:3000}"
- "traefik.http.middlewares.cors-headers.headers.accesscontrolallowcredentials=true"
- "traefik.http.middlewares.cors-headers.headers.accesscontrolallowheaders=Content-Type,Authorization"
# Frontend
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
container_name: containr-frontend
environment:
- VITE_API_URL=${VITE_API_URL:-http://api.localhost}
- VITE_ENVIRONMENT=${ENVIRONMENT:-production}
volumes:
# Development volumes (only mounted in dev mode)
- ${DEV_MODE:-./src}:/app/src
- ${DEV_MODE:-./public}:/app/public
networks:
- containr-network
depends_on:
- backend
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.frontend.rule=Host(`${DOMAIN:-localhost}`)"
- "traefik.http.routers.frontend.entrypoints=websecure"
- "traefik.http.routers.frontend.tls.certresolver=myresolver"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
- "traefik.http.routers.frontend-redirect.rule=Host(`${DOMAIN:-localhost}`)"
- "traefik.http.routers.frontend-redirect.entrypoints=web"
- "traefik.http.routers.frontend-redirect.middlewares=redirect-to-https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
# Cloudflared Tunnel (optional)
cloudflared:
image: cloudflare/cloudflared:latest
container_name: containr-cloudflared
command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TOKEN}
networks:
- containr-network
restart: unless-stopped
depends_on:
- traefik
profiles:
- cloudflared
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks:
containr-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
File diff suppressed because it is too large Load Diff
+553
View File
@@ -0,0 +1,553 @@
# Developer Onboarding Guide
Welcome to the Containr development team! This guide will help you get set up and contribute effectively to the project.
## Table of Contents
- [Prerequisites](#prerequisites)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Development Workflow](#development-workflow)
- [Testing](#testing)
- [Code Style](#code-style)
- [Debugging](#debugging)
- [Contributing](#contributing)
- [Resources](#resources)
## Prerequisites
Before you begin, ensure you have the following installed:
### Required Tools
- **Go 1.24+** - Backend development
- **Node.js 18+** - Frontend development
- **Docker & Docker Compose** - Container development
- **Git** - Version control
- **Make** - Build automation
### Recommended Tools
- **VS Code** - Code editor with extensions
- **Postman** - API testing
- **Docker Desktop** - Container management
- **PostgreSQL Client** - Database management
### VS Code Extensions
```json
{
"recommendations": [
"golang.go",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"ms-vscode.vscode-typescript-next",
"ms-vscode-remote.remote-containers",
"ms-kubernetes-tools.vscode-kubernetes-tools"
]
}
```
## Development Setup
### 1. Clone the Repository
```bash
git clone https://github.com/your-org/containr.git
cd containr
```
### 2. Environment Setup
```bash
# Copy environment template
cp .env.example .env
# Edit environment variables
nano .env
```
Key environment variables:
```bash
# Development settings
DOMAIN=localhost
ENVIRONMENT=development
# Database
POSTGRES_USER=containr
POSTGRES_PASSWORD=containr
POSTGRES_DB=containr
# Redis
REDIS_PASSWORD=containr
# JWT
JWT_SECRET=your-super-secret-jwt-key-here
# Proxmox (optional)
PROXMOX_BASE_URL=https://your-proxmox:8006
PROXMOX_USERNAME=root@pam
PROXMOX_PASSWORD=your-proxmox-password
```
### 3. Initialize Development Environment
```bash
# Initialize all services
make init
# Start development mode
make dev
```
This will start:
- **Frontend**: http://localhost (React/Vite)
- **API**: http://api.localhost (Go backend)
- **Traefik Dashboard**: http://localhost:8080
- **Database**: PostgreSQL on port 5432
- **Redis**: Cache on port 6379
### 4. Verify Setup
```bash
# Check service status
make status
# View logs
make logs
# Test API
curl http://api.localhost/health
```
## Project Structure
```
containr/
├── cmd/ # Application entry points
│ ├── server/ # Main server application
│ └── cli/ # CLI tool
├── internal/ # Private application code
│ ├── api/ # HTTP handlers and routes
│ ├── build/ # Build system (Nixpacks, Docker)
│ ├── cli/ # CLI commands and utilities
│ ├── config/ # Configuration management
│ ├── database/ # Database operations
│ ├── deployment/ # Deployment engine
│ ├── docker/ # Docker client wrapper
│ ├── metrics/ # Metrics collection
│ ├── middleware/ # HTTP middleware
│ ├── proxmox/ # Proxmox integration
│ ├── scaling/ # Auto-scaling engine
│ └── security/ # Security scanning
├── migrations/ # Database migrations
├── pkg/ # Public library code
├── scripts/ # Build and utility scripts
├── src/ # Frontend source code
│ ├── components/ # React components
│ ├── hooks/ # Custom React hooks
│ ├── contexts/ # React contexts
│ └── utils/ # Frontend utilities
├── docs/ # Documentation
├── tests/ # Test files
└── docker-compose.yml # Development environment
```
## Development Workflow
### 1. Create a Feature Branch
```bash
git checkout -b feature/your-feature-name
```
### 2. Make Your Changes
#### Backend Changes
- Add new API endpoints in `internal/api/`
- Update database schema in `migrations/`
- Add tests in `tests/`
- Update documentation
#### Frontend Changes
- Create components in `src/components/`
- Add hooks in `src/hooks/`
- Update routing in `src/App.tsx`
- Follow existing patterns
### 3. Test Your Changes
```bash
# Run backend tests
go test ./...
# Run frontend tests
cd src && npm test
# Test full integration
make up
```
### 4. Commit Your Changes
```bash
# Stage changes
git add .
# Commit with conventional message
git commit -m "feat: add user authentication"
# Push to your fork
git push origin feature/your-feature-name
```
### 5. Create a Pull Request
1. Go to GitHub repository
2. Click "New Pull Request"
3. Select your branch
4. Fill out PR template
5. Request review from team members
## Testing
### Backend Testing
#### Unit Tests
```bash
# Run all tests
go test ./...
# Run specific package tests
go test ./internal/api
# Run with coverage
go test -cover ./...
# Generate coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
```
#### Integration Tests
```bash
# Run integration tests
go test -tags=integration ./tests/integration/...
# Test with real database
TEST_DB_URL=postgres://... go test ./tests/integration/...
```
#### API Testing
```bash
# Start API server
make dev
# Test endpoints
curl http://api.localhost/health
curl -X POST http://api.localhost/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password"}'
```
### Frontend Testing
#### Unit Tests
```bash
cd src
npm test
npm run test:coverage
```
#### E2E Tests
```bash
cd src
npm run test:e2e
```
#### Manual Testing
1. Open http://localhost
2. Test all user flows
3. Check responsive design
4. Verify accessibility
## Code Style
### Go Backend
#### Formatting
```bash
# Format code
go fmt ./...
# Import organization
goimports -w .
# Lint code
golangci-lint run
```
#### Naming Conventions
- **Packages**: `lowercase`, `snake_case`
- **Variables**: `camelCase` for local, `PascalCase` for exported
- **Functions**: `PascalCase` for exported, `camelCase` for private
- **Constants**: `UPPER_SNAKE_CASE`
- **Files**: `snake_case.go`
#### Example
```go
package api
import (
"context"
"net/http"
)
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) {
user := &User{
Name: req.Name,
Email: req.Email,
}
return s.repo.Create(ctx, user)
}
```
### TypeScript Frontend
#### Formatting
```bash
cd src
npm run format
npm run lint
npm run type-check
```
#### Naming Conventions
- **Components**: `PascalCase`
- **Hooks**: `camelCase` starting with `use`
- **Variables**: `camelCase`
- **Constants**: `UPPER_SNAKE_CASE`
- **Files**: `PascalCase.tsx` for components, `camelCase.ts` for utilities
#### Example
```tsx
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
interface User {
id: string;
name: string;
email: string;
}
export const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false));
}, [userId]);
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<Button onClick={() => console.log('Edit user')}>
Edit Profile
</Button>
</div>
);
};
```
## Debugging
### Backend Debugging
#### VS Code Debugging
Create `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Backend",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/server",
"env": {
"ENVIRONMENT": "development"
},
"console": "integratedTerminal"
}
]
}
```
#### Logging
```go
import "log"
// Standard logging
log.Printf("Processing user: %s", userID)
// Structured logging (if using logrus/zap)
logger.WithFields(logrus.Fields{
"user_id": userID,
"action": "create",
}).Info("User created successfully")
```
#### Database Debugging
```bash
# Connect to database
make shell-postgres
# View tables
\dt
# Query users
SELECT * FROM users LIMIT 10;
```
### Frontend Debugging
#### React DevTools
1. Install React DevTools browser extension
2. Open developer tools
3. Inspect component state and props
#### Network Debugging
```tsx
// Add request interceptor for debugging
axios.interceptors.request.use(request => {
console.log('API Request:', request);
return request;
});
axios.interceptors.response.use(response => {
console.log('API Response:', response);
return response;
});
```
## Contributing
### Types of Contributions
#### Bug Fixes
1. Create issue describing the bug
2. Create branch with fix
3. Add tests to prevent regression
4. Submit pull request
#### Features
1. Create issue for discussion
2. Get approval from maintainers
3. Implement feature with tests
4. Update documentation
5. Submit pull request
#### Documentation
1. Fix typos and improve clarity
2. Add examples and tutorials
3. Update API documentation
4. Submit pull request
### Pull Request Process
#### PR Template
```markdown
## Description
Brief description of changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
- [ ] Unit tests pass
- [ ] Integration tests pass
- [ ] Manual testing completed
## Checklist
- [ ] Code follows style guidelines
- [ ] Self-review completed
- [ ] Documentation updated
- [ ] Tests added/updated
```
#### Review Guidelines
1. **Code Quality**: Is the code clean and maintainable?
2. **Testing**: Are there adequate tests?
3. **Documentation**: Is the documentation updated?
4. **Performance**: Will this impact performance?
5. **Security**: Are there security implications?
## Resources
### Documentation
- [API Documentation](../api/openapi.yaml)
- [User Guides](../guides/)
- [Architecture Overview](../../README.md)
### Tools and Links
- [Go Documentation](https://golang.org/doc/)
- [React Documentation](https://react.dev/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Docker Documentation](https://docs.docker.com/)
### Team Communication
- **Slack**: #containr-dev
- **Discord**: Development channel
- **Email**: dev-team@containr.dev
### Getting Help
1. **Check Documentation**: Look for existing solutions
2. **Search Issues**: Check if problem is already reported
3. **Ask in Slack**: Get help from team members
4. **Create Issue**: Report bugs or request features
## Development Tips
### Productivity Tips
1. **Use Make Commands**: Leverage `make help` for available commands
2. **Hot Reload**: Frontend auto-reloads on file changes
3. **Database Migrations**: Run `make migrate` for schema changes
4. **Environment Variables**: Use `.env` for local development
### Common Issues
1. **Port Conflicts**: Change ports in `.env` if needed
2. **Permission Issues**: Use `sudo` for Docker commands
3. **Dependency Issues**: Run `go mod tidy` and `npm install`
4. **Cache Issues**: Clear Docker cache with `make clean`
### Best Practices
1. **Small Commits**: Keep changes focused and atomic
2. **Test Coverage**: Aim for >80% coverage
3. **Documentation**: Document public APIs and complex logic
4. **Security**: Never commit secrets or sensitive data
## Next Steps
Once you're comfortable with the basics:
1. **Explore Advanced Features**: Learn about scaling, monitoring, and security
2. **Contribute to Core**: Work on core platform features
3. **Mentor Others**: Help new developers get started
4. **Share Knowledge**: Write blog posts or give talks
Welcome aboard! 🚀 We're excited to have you on the team.
+627
View File
@@ -0,0 +1,627 @@
# Advanced Configuration Guide
This guide covers advanced configuration options for optimizing your Containr deployments.
## Table of Contents
- [Build Configuration](#build-configuration)
- [Environment Management](#environment-management)
- [Resource Allocation](#resource-allocation)
- [Networking](#networking)
- [Security](#security)
- [Scaling Policies](#scaling-policies)
- [Custom Domains and SSL](#custom-domains-and-ssl)
- [Health Checks](#health-checks)
## Build Configuration
### Nixpacks Configuration
Nixpacks is the default build system. You can customize it using a `nixpacks.toml` file:
```toml
# nixpacks.toml
[phases.setup]
nixPkgs = ["...", "nodejs", "npm"]
[phases.build]
cmds = ["npm run build"]
[start]
cmd = "npm start"
[variables]
NODE_ENV = "production"
PORT = "8080"
```
### Custom Dockerfile
For complex applications, use a custom Dockerfile:
```dockerfile
# Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:18-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 8080
USER node
CMD ["npm", "start"]
```
### Build Caching
Enable build caching for faster deployments:
1. Go to service settings
2. Enable "Build Caching"
3. Configure cache settings:
- **Cache TTL**: Time to keep cached layers (default: 7 days)
- **Max Cache Size**: Maximum cache size per service
### Multi-Stage Builds
Optimize image size with multi-stage builds:
```dockerfile
# Build stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o app
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/app .
EXPOSE 8080
CMD ["./app"]
```
## Environment Management
### Environment Scopes
Variables can be scoped at different levels:
```bash
# Project-level (shared across all services)
DATABASE_URL=postgresql://...
# Service-level (specific to one service)
API_KEY=service-specific-key
# Environment-specific (production only)
NODE_ENV=production
DEBUG=false
```
### Variable Types
#### Plain Text Variables
```bash
API_KEY=your-api-key-here
```
#### Secret Variables
```bash
# Marked as secret in UI - masked in logs
DATABASE_PASSWORD=super-secret-password
```
#### File Variables
For configuration files:
```bash
# Upload .env file as variable
ENV_FILE_CONTENT=KEY1=value1\nKEY2=value2
```
### Dynamic Variables
Use Containr's built-in variables:
```bash
# Auto-populated by Containr
PORT=8080 # Assigned port
CONTAINR_SERVICE_NAME=my-app # Service name
CONTAINR_PROJECT_NAME=my-proj # Project name
CONTAINR_DEPLOYMENT_ID=abc123 # Deployment ID
```
## Resource Allocation
### CPU Allocation
Configure CPU limits and requests:
```yaml
# Service resource configuration
resources:
cpu:
request: "100m" # Minimum CPU (0.1 core)
limit: "1000m" # Maximum CPU (1 core)
memory:
request: "128Mi" # Minimum memory
limit: "512Mi" # Maximum memory
```
### Memory Allocation
Memory configuration examples:
```yaml
# Small service (API, worker)
resources:
memory: "256Mi"
# Medium service (web app)
resources:
memory: "512Mi"
# Large service (database, analytics)
resources:
memory: "2Gi"
```
### Storage Allocation
Persistent storage for databases:
```yaml
# Database service
resources:
storage:
size: "10Gi"
type: "ssd"
backup: true
```
## Networking
### Service-to-Service Communication
Services can communicate internally:
```bash
# Environment variables for internal URLs
DATABASE_URL=postgresql://my-db:5432/myapp
REDIS_URL=redis://my-redis:6379
API_URL=http://my-api:8080
```
### Port Configuration
Configure service ports:
```yaml
# Service configuration
networking:
port: 8080 # Internal port
public: true # Expose publicly
health_check: "/health" # Health check endpoint
```
### Custom Domains
Set up custom domains:
1. Go to service settings → "Domains"
2. Add your domain: `app.yourdomain.com`
3. Configure DNS:
```
CNAME app.yourdomain.com -> your-service.containr.app
```
4. Wait for SSL certificate provisioning
### Load Balancing
Configure load balancing strategies:
```yaml
# Load balancer configuration
load_balancer:
strategy: "round_robin" # round_robin, least_connections, ip_hash
health_check:
path: "/health"
interval: "30s"
timeout: "5s"
retries: 3
```
## Security
### Security Scans
Enable automated security scanning:
1. Go to project settings → "Security"
2. Enable "Security Scanning"
3. Configure scan frequency:
- **On every deployment**
- **Daily**
- **Weekly**
### Vulnerability Management
Review and manage vulnerabilities:
```bash
# Security scan results
Critical: 2 vulnerabilities
High: 5 vulnerabilities
Medium: 12 vulnerabilities
Low: 8 vulnerabilities
```
### Network Policies
Restrict network access:
```yaml
# Network policy example
network_policy:
egress:
- allow:
- dns: true
- http: ["api.example.com"]
- https: ["github.com", "registry.npmjs.org"]
ingress:
- allow:
- port: 8080
from: ["load-balancer"]
```
### Secrets Management
Best practices for secrets:
1. **Never commit secrets** to version control
2. **Use Containr secrets** for sensitive data
3. **Rotate secrets regularly**
4. **Use least privilege** principle
```bash
# Good: Use environment variables
DATABASE_URL=${DATABASE_URL}
# Bad: Hardcoded secrets
DATABASE_URL=postgresql://user:password@host:5432/db
```
## Scaling Policies
### Manual Scaling
Set replica count manually:
```yaml
# Manual scaling configuration
scaling:
replicas: 3 # Run 3 instances
```
### Auto-Scaling
Configure automatic scaling:
```yaml
# Auto-scaling configuration
scaling:
min_replicas: 1
max_replicas: 10
metrics:
- type: "cpu"
target: "70%" # Scale when CPU > 70%
- type: "memory"
target: "80%" # Scale when memory > 80%
- type: "requests_per_second"
target: 100 # Scale when RPS > 100
policies:
scale_up_cooldown: "60s" # Wait 60s between scale-ups
scale_down_cooldown: "300s" # Wait 5m between scale-downs
```
### Scaling Strategies
Different scaling strategies for different needs:
#### CPU-Based Scaling
```yaml
scaling:
metrics:
- type: "cpu"
target: "70%"
```
#### Memory-Based Scaling
```yaml
scaling:
metrics:
- type: "memory"
target: "80%"
```
#### Request-Based Scaling
```yaml
scaling:
metrics:
- type: "requests_per_second"
target: 100
```
#### Custom Metrics
```yaml
scaling:
metrics:
- type: "custom"
name: "queue_length"
target: 50
```
## Custom Domains and SSL
### Domain Configuration
Set up custom domains with advanced options:
```yaml
# Domain configuration
domains:
- name: "app.yourdomain.com"
type: "primary"
ssl: true
redirect_www: true
- name: "api.yourdomain.com"
type: "api"
ssl: true
- name: "cdn.yourdomain.com"
type: "cdn"
ssl: true
cache_ttl: "1h"
```
### SSL Certificates
SSL is automatically configured:
1. **Automatic provisioning**: Let's Encrypt certificates
2. **Auto-renewal**: Certificates renewed 30 days before expiry
3. **Wildcard support**: For `*.yourdomain.com`
4. **Custom certificates**: Upload your own certificates
### CDN Integration
Configure CDN for static assets:
```yaml
# CDN configuration
cdn:
enabled: true
provider: "cloudflare" # cloudflare, fastly, custom
cache_ttl: "1d"
compression: true
minify: true
```
## Health Checks
### HTTP Health Checks
Configure HTTP health checks:
```yaml
# HTTP health check
health_check:
type: "http"
path: "/health"
method: "GET"
expected_status: 200
headers:
User-Agent: "Containr-Health-Check"
timeout: "10s"
interval: "30s"
retries: 3
start_period: "60s"
```
### TCP Health Checks
For services that don't have HTTP endpoints:
```yaml
# TCP health check
health_check:
type: "tcp"
port: 8080
timeout: "5s"
interval: "30s"
retries: 3
```
### Custom Health Checks
Implement custom health check logic:
```javascript
// Express.js example
app.get('/health', (req, res) => {
const health = {
status: 'ok',
timestamp: new Date().toISOString(),
checks: {
database: checkDatabase(),
redis: checkRedis(),
memory: checkMemory()
}
};
const isHealthy = Object.values(health.checks).every(check => check.status === 'ok');
res.status(isHealthy ? 200 : 503).json(health);
});
function checkDatabase() {
// Check database connection
return { status: 'ok', latency: '5ms' };
}
function checkRedis() {
// Check Redis connection
return { status: 'ok', latency: '2ms' };
}
function checkMemory() {
const usage = process.memoryUsage();
const isHealthy = usage.heapUsed < 512 * 1024 * 1024; // 512MB
return {
status: isHealthy ? 'ok' : 'error',
usage: `${Math.round(usage.heapUsed / 1024 / 1024)}MB`
};
}
```
## Advanced Examples
### Microservices Architecture
Example of a microservices setup:
```yaml
# API Gateway
services:
- name: "api-gateway"
type: "web"
resources:
cpu: "500m"
memory: "256Mi"
scaling:
min_replicas: 2
max_replicas: 5
domains:
- name: "api.yourdomain.com"
# User Service
- name: "user-service"
type: "web"
resources:
cpu: "200m"
memory: "128Mi"
scaling:
min_replicas: 1
max_replicas: 3
# Database
- name: "user-db"
type: "database"
database_type: "postgresql"
resources:
storage: "20Gi"
```
### Full-Stack Application
Example with frontend, backend, and database:
```yaml
# Frontend (React/Vue)
services:
- name: "frontend"
type: "web"
build:
builder: "nixpacks"
build_command: "npm run build"
start_command: "npm run start"
domains:
- name: "app.yourdomain.com"
# Backend (Node.js)
- name: "backend"
type: "web"
build:
builder: "nixpacks"
start_command: "npm start"
environment:
DATABASE_URL: "postgresql://user-db:5432/myapp"
REDIS_URL: "redis://redis:6379"
scaling:
min_replicas: 1
max_replicas: 5
# Database
- name: "user-db"
type: "database"
database_type: "postgresql"
resources:
storage: "10Gi"
# Cache
- name: "redis"
type: "database"
database_type: "redis"
resources:
memory: "256Mi"
```
## Troubleshooting
### Common Issues
#### Build Failures
- Check build logs for specific errors
- Verify `nixpacks.toml` or `Dockerfile` syntax
- Ensure all dependencies are declared
#### Runtime Errors
- Check environment variables
- Verify health check endpoints
- Review resource limits
#### Scaling Issues
- Monitor metrics during scaling events
- Check scaling policies and thresholds
- Review resource allocation
### Debug Mode
Enable debug mode for troubleshooting:
```yaml
# Debug configuration
debug:
enabled: true
log_level: "debug"
verbose_logs: true
profile_requests: true
```
## Best Practices
1. **Start small** and scale based on metrics
2. **Monitor resource usage** regularly
3. **Use health checks** for all services
4. **Implement proper logging** and monitoring
5. **Secure your applications** with proper secrets management
6. **Test scaling policies** in staging environment
7. **Regular security scans** and updates
## Next Steps
- Learn about [Monitoring and Alerts](./monitoring.md)
- Explore [CI/CD Integration](./cicd.md)
- Understand [Backup and Recovery](./backup.md)
- Review [Performance Optimization](./performance.md)
+260
View File
@@ -0,0 +1,260 @@
# Getting Started with Containr
Welcome to Containr! This guide will walk you through setting up your first project and deploying your first application.
## Prerequisites
Before you begin, make sure you have:
- A Containr instance running (either self-hosted or using our hosted version)
- A Git repository with your application code
- Basic knowledge of Docker and containerization concepts
## Step 1: Create Your Account
1. Navigate to your Containr instance URL
2. Click "Sign Up" in the top-right corner
3. Fill in your email, name, and password
4. Verify your email address (if required)
5. Log in to your new account
## Step 2: Create Your First Project
1. From the dashboard, click "New Project"
2. Enter a project name (e.g., "my-awesome-app")
3. Add an optional description
4. Click "Create Project"
You'll be taken to your project's main canvas view.
## Step 3: Connect Your Git Repository
### Option A: Using GitHub (Recommended)
1. Click "Add Service" → "Connect Git Repository"
2. Select "GitHub" as the provider
3. Click "Connect to GitHub" and authorize Containr
4. Browse your repositories and select the one you want to deploy
5. Choose the branch (usually `main` or `master`)
6. Select the root directory if your app isn't in the root
7. Click "Connect Repository"
### Option B: Using Other Git Providers
Containr also supports GitLab and Bitbucket:
1. Click "Add Service" → "Connect Git Repository"
2. Select your preferred provider
3. Follow the authorization flow
4. Select your repository and branch
## Step 4: Configure Your Service
After connecting your repository, Containr will:
1. **Auto-detect your stack** (Node.js, Python, Go, etc.)
2. **Generate a build plan** using Nixpacks
3. **Show you the configuration**
### Basic Configuration
- **Service Name**: Give your service a descriptive name
- **Build Command**: Auto-detected, but you can customize
- **Start Command**: Auto-detected, but you can customize
- **Environment Variables**: Add any needed variables
### Example: Node.js Application
For a typical Node.js app:
```bash
# Build Command (auto-detected)
npm install && npm run build
# Start Command (auto-detected)
npm start
```
### Example: Go Application
For a Go application:
```bash
# Build Command (auto-detected)
go build -o app .
# Start Command (auto-detected)
./app
```
## Step 5: Add Environment Variables
Most applications need environment variables:
1. Go to the "Variables" tab in your service
2. Add your variables as key-value pairs:
- `NODE_ENV=production`
- `DATABASE_URL=postgresql://...`
- `API_KEY=your-secret-key`
3. Variables are automatically encrypted and injected at runtime
## Step 6: Deploy Your Application
1. Click "Deploy" in the top-right corner
2. Choose your deployment method:
- **Deploy Latest Commit**: Deploy the latest commit from your branch
- **Manual Deploy**: Specify a commit SHA
3. Click "Start Deployment"
Containr will:
1. Pull your code from Git
2. Build a Docker image using Nixpacks
3. Start the container
4. Assign a public URL
## Step 7: Monitor Your Deployment
While deploying, you can:
- **View Build Logs**: See the build process in real-time
- **Check Status**: Monitor deployment progress
- **Get Notifications**: Receive alerts on success or failure
Once deployed, you'll see:
- **Public URL**: Your application is live at `https://your-service-name.containr.app`
- **Status**: Green checkmark if running successfully
- **Metrics**: CPU, memory, and network usage
## Step 8: Add a Database (Optional)
Most applications need a database:
1. Click "Add Service" → "Add Database"
2. Choose your database type:
- **PostgreSQL**: For relational data
- **Redis**: For caching and sessions
- **MySQL**: For MySQL compatibility
3. Configure:
- Database name
- Version
- Size allocation
4. Click "Create Database"
### Connect Your Application
Once the database is created:
1. Go to your service's "Variables" tab
2. Add the database connection URL:
```
DATABASE_URL=postgresql://username:password@hostname:port/database_name
```
3. Redeploy your application
## Step 9: Set Up Preview Environments
Enable automatic preview environments for pull requests:
1. Go to project settings
2. Enable "Preview Environments"
3. Configure:
- Automatic cleanup (e.g., 7 days)
- Branch patterns (e.g., `feature/*`, `fix/*`)
4. Save settings
Now when you create a pull request:
- A preview environment is automatically created
- You get a unique URL for testing
- Environment is cleaned up after merging
## Step 10: Configure Custom Domain (Optional)
To use your own domain:
1. Go to your service's "Settings" tab
2. Click "Add Custom Domain"
3. Enter your domain (e.g., `app.yourdomain.com`)
4. Configure DNS:
```
CNAME app.yourdomain.com -> your-service-name.containr.app
```
5. Wait for SSL certificate provisioning
## Troubleshooting Common Issues
### Build Failures
**Problem**: Build fails during deployment
**Solutions**:
1. Check build logs for specific error messages
2. Verify your `package.json`, `go.mod`, or `requirements.txt` is valid
3. Ensure all dependencies are properly declared
4. Check for syntax errors in your code
### Runtime Errors
**Problem**: Application starts but crashes
**Solutions**:
1. Check runtime logs in the "Logs" tab
2. Verify environment variables are correctly set
3. Ensure your application listens on the correct port (default: `$PORT`)
4. Check database connection strings
### Port Issues
**Problem**: Application can't be accessed
**Solutions**:
1. Ensure your application listens on the `$PORT` environment variable
2. Don't hardcode ports in your application
3. Example for Node.js:
```javascript
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
```
## Best Practices
### Security
1. **Never commit secrets** to your repository
2. **Use environment variables** for all sensitive data
3. **Enable HTTPS** (automatic on Containr)
4. **Keep dependencies updated**
### Performance
1. **Optimize Docker images** by using `.dockerignore`
2. **Enable caching** for faster builds
3. **Monitor resource usage** and scale as needed
4. **Use CDN** for static assets
### Development Workflow
1. **Use feature branches** for new features
2. **Enable preview environments** for testing
3. **Write tests** and run them in CI/CD
4. **Monitor deployments** and set up alerts
## Next Steps
Now that you have your first application deployed:
- Explore the [Advanced Configuration](./advanced-configuration.md) guide
- Learn about [Scaling and Load Balancing](./scaling.md)
- Set up [Monitoring and Alerts](./monitoring.md)
- Join our [Community](https://github.com/your-org/containr/discussions)
## Need Help?
- **Documentation**: [containr.dev/docs](https://containr.dev/docs)
- **Community**: [GitHub Discussions](https://github.com/your-org/containr/discussions)
- **Support**: support@containr.dev
- **Status**: [status.containr.dev](https://status.containr.dev)
Happy deploying! 🚀
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+93
View File
@@ -0,0 +1,93 @@
module containr
go 1.24.0
require (
github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.9.1
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
github.com/rs/cors v1.10.1
golang.org/x/crypto v0.47.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
+246
View File
@@ -0,0 +1,246 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>containr</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

+63
View File
@@ -0,0 +1,63 @@
# 🚆 What the Railway Dashboard Is
The Railway dashboard is the main web interface for managing your Railway cloud projects. Its essentially your control panel where you can see, organize, configure, and monitor everything youre hosting on Railway — services, deployments, databases, environments, logs, metrics, and more.
You typically land on the dashboard after signing into your Railway account. Its the central hub that gives you a high-level view of all your projects and infrastructure.
## 🧠 What It Lets You Do
### 📦 1. See and Manage Projects
The dashboard shows all projects you own or belong to, sorted by recent activity.
Each project can contain multiple services (like a web app, API, worker, database).
From the dashboard you can click into each project to manage details.
### ⚙️ 2. Configure and Deploy Services
Within a project, you can link code repositories (GitHub) or container images.
Railway handles build and deploy processes automatically — including automatic configuration for many languages/frameworks.
Deployments are shown with statuses (building, deployed, errors).
### 📊 3. Monitoring & Observability
View logs and performance metrics (CPU, memory, network, disk usage) per service.
You can create dashboards with charts and custom widgets for things like request rates or error logs.
Set alerts for thresholds (like high CPU or errors) and receive notifications via Slack, Discord, email, and more.
### 🔐 4. Manage Secrets & Environment Variables
Set configuration variables (like API keys or database URLs) that your services use.
Variables can be scoped to services or shared across the entire project.
### 🔁 5. Staging & Environments
Railway supports multiple environments (dev, staging, prod).
You can preview changes in separate environments and promote or rollback deployments with a click.
### 📚 6. Templates & One-Click Deploy
Access a library of templates to quickly deploy common stacks (e.g., databases with backend, analytics tools).
Templates auto-provision services and databases as a project.
## 🧑‍💻 How It Works (Behind the Scenes)
Railways dashboard directly reflects the state of your projects by talking to its API backend (the same API used by their CLI). It pulls data like:
Project list and status
Deployment logs and history
Resources (CPU, memory, traffic)
Environment and configuration values
Changes you make in the dashboard (like redeploying or updating variables) are sent back to the Railway API and applied to your infrastructure.
+254
View File
@@ -0,0 +1,254 @@
# 🧩 What the Project Page Is
The Project page represents one isolated application environment on Railway.
Think of a project as:
> A self-contained box that holds all services, databases, configs, deployments, logs, metrics, and environments for one product or system.
### Example Structure
```
One project = spark-screen
├── Go backend
├── Frontend (Vite/Solid)
├── PostgreSQL
├── Redis
├── Worker / cron
└── Preview + Production environments
```
## 🗺️ Main Layout of the Project Page
Visually, the project page is built around a canvas + side panels model.
### 1️⃣ Project Canvas (Center)
This is the graph view you see first.
### What it shows:
- **Each service as a card** (app, db, worker, etc.)
- **Lines showing relationships** (e.g. `backend → database`)
- **Deployment status per service**
### Each card usually displays:
- **Service name**
- **Source** (GitHub repo, Dockerfile, template)
- **Current deployment status** (running, building, failed)
- **Quick access to logs & settings**
> 👉 This canvas is not just visual — it reflects actual runtime dependencies.
### 2️⃣ Services (Core Units)
Each box on the canvas is a Service.
A service can be:
- **Web app** (Go, Node, Bun, Python, etc.)
- **Background worker**
- **Cron job**
- **Database** (Postgres, Redis, MySQL)
- **Custom Docker container**
Clicking a service opens its Service Detail Panel.
## 🔍 Inside a Service (Very Important)
When you click a service, you get several tabs:
### ⚙️ Settings
This is the brain of the service.
You configure:
- **Source**
- GitHub repo (via GitHub App)
- Branch
- Root directory
- **Build**
- Build command
- Start command
- Dockerfile (optional)
- **Resources**
- CPU / RAM limits
- **Networking**
- Public domain
- Internal service-to-service URLs
> **How it works:** Any change here triggers a new deployment. Railway stores this config and applies it at runtime.
### 🔐 Variables (Environment Variables)
This is where Railway shines.
Keyvalue environment variables
Can be:
- **Service-specific**
- **Shared across the project**
- **Environment-scoped** (prod / preview)
### Examples:
```
DATABASE_URL
REDIS_URL
JWT_SECRET
PORT
```
> **Behind the scenes:**
>
> - Injected into the container at runtime
> - Never hardcoded into images
> - Securely stored and masked in UI
### 📦 Deployments
This tab shows:
- **Full deployment history**
- **Commit hash** (if GitHub)
- **Build logs**
- **Runtime logs**
- **Rollback button**
> **How it works:**
>
> 1. Git push (or manual deploy)
> 2. Railway pulls code
> 3. Builds container (Railpack or Docker)
> 4. Starts new instance
> 5. Old one is replaced (zero-downtime when possible)
### 📜 Logs
### Real-time logs:
- **stdout / stderr**
- **Build logs**
- **Runtime errors**
Logs are:
- **Per-service**
- **Per-deployment**
- **Streamed live from containers**
> This is basically `docker logs` but cloud-native.
### 📊 Metrics
### Per-service metrics:
- **CPU usage**
- **Memory usage**
- **Network traffic**
- **Disk usage** (for DBs)
This helps you:
- **Detect leaks**
- **Decide when to optimize**
- **Understand cost drivers**
## 🧱 Databases Inside a Project
Databases are first-class services, not addons.
When you add:
- **PostgreSQL**
- **Redis**
- **MySQL**
Railway:
- **Provisions it**
- **Injects connection URLs**
- **Handles backups** (plan-dependent)
Databases:
- **Appear on the same canvas**
- **Can be linked to multiple services**
- **Expose internal connection strings only**
### Database Benefits
- **Easy setup**
- **Managed backups**
- **Internal connection strings**
## 🌍 Environments (Prod, Preview, Dev)
Each project can have multiple environments.
### Common setup:
- **Production** live app
- **Preview** per-PR deployments
- **Development** testing
### What changes per environment:
- **Variables**
- **Domains**
- **Deployments**
- **Scaling**
> This is not separate projects — it's logical isolation inside one project.
## 👥 Project-Level Controls
At the project level (not service-level):
### 👤 Members & Permissions
- **Invite teammates**
- **Control access** (admin / developer)
### 🔔 Notifications
- **Deploy failures**
- **Usage alerts**
- **Service crashes**
### 💳 Usage & Billing
- **CPU hours**
- **RAM hours**
- **Network**
- **Cost per service**
## 🧠 How the Project Page Works Internally
Under the hood:
- **The UI talks to the Railway API**
- **Every service = container + config**
- **Every deploy = immutable container version**
- **Variables = injected at runtime**
- **Services communicate via private internal network**
> So the project page is essentially: a visual orchestrator for containers, deployments, configs, and environments
## 🧠 Mental Model (Best Way to Think About It)
If Docker Compose had:
- **A UI**
- **GitHub auto-deploys**
- **Built-in databases**
- **Metrics**
- **Logs**
- **Secrets**
- **Environments**
> 👉 That's the Railway project page.
+679
View File
@@ -0,0 +1,679 @@
package api
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
// NodeAgent represents a container orchestration agent
type NodeAgent struct {
ID string `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Hostname string `json:"hostname" gorm:"not null"`
IPAddress string `json:"ip_address" gorm:"not null"`
Port int `json:"port" gorm:"not null"`
Status string `json:"status" gorm:"default:'offline'"`
Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"`
Resources NodeResources `json:"resources" gorm:"serializer:json"`
LastHeartbeat time.Time `json:"last_heartbeat"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata" gorm:"serializer:json"`
}
// AgentCapabilities defines what the agent can do
type AgentCapabilities 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"`
}
// NodeResources represents the agent's available resources
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"` // percentage
Usage float64 `json:"usage"` // current usage percentage
}
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"` // bytes per second
Outbound int `json:"outbound"` // bytes per second
}
// ContainerInstance represents a container running on an agent
type ContainerInstance struct {
ID string `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Image string `json:"image" gorm:"not null"`
ProjectID string `json:"project_id" gorm:"not null"`
ServiceID string `json:"service_id" gorm:"not null"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
Status ContainerStatus `json:"status" gorm:"serializer:json"`
Resources ContainerResources `json:"resources" gorm:"serializer:json"`
Ports []PortMapping `json:"ports" gorm:"serializer:json"`
Environment map[string]string `json:"environment" gorm:"serializer:json"`
Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"`
Networks []string `json:"networks" gorm:"serializer:json"`
RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"`
HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ContainerStatus struct {
State string `json:"state"`
Health string `json:"health"`
ExitCode *int `json:"exit_code"`
Error *string `json:"error"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
type ContainerResources struct {
CPULimit int `json:"cpu_limit"`
CPUReservation int `json:"cpu_reservation"`
MemoryLimit int `json:"memory_limit"`
MemoryReservation int `json:"memory_reservation"`
DiskLimit *int `json:"disk_limit"`
}
type PortMapping struct {
ContainerPort int `json:"container_port"`
HostPort *int `json:"host_port"`
Protocol string `json:"protocol"`
Published bool `json:"published"`
}
type VolumeMount struct {
Name string `json:"name"`
Source string `json:"source"`
Target string `json:"target"`
Type string `json:"type"`
ReadOnly bool `json:"read_only"`
}
type RestartPolicy struct {
Name string `json:"name"`
MaximumRetryCount *int `json:"maximum_retry_count"`
}
type HealthCheck struct {
Test []string `json:"test"`
Interval int `json:"interval"`
Timeout int `json:"timeout"`
Retries int `json:"retries"`
StartPeriod int `json:"start_period"`
}
// AgentCommand represents a command sent to an agent
type AgentCommand struct {
ID string `json:"id" gorm:"primaryKey"`
Type string `json:"type" gorm:"not null"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
ContainerID *string `json:"container_id"`
Payload map[string]interface{} `json:"payload" gorm:"serializer:json"`
Status string `json:"status" gorm:"default:'pending'"`
Result *string `json:"result"`
Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at"`
}
// AgentHeartbeat represents a heartbeat message from an agent
type AgentHeartbeat struct {
NodeAgentID string `json:"node_agent_id"`
Timestamp time.Time `json:"timestamp"`
Status string `json:"status"`
Resources NodeResources `json:"resources"`
ContainerCount int `json:"container_count"`
SystemLoad SystemLoad `json:"system_load"`
Uptime int64 `json:"uptime"`
Version string `json:"version"`
}
type SystemLoad struct {
Load1M float64 `json:"load_1m"`
Load5M float64 `json:"load_5m"`
Load15M float64 `json:"load_15m"`
}
// NodeAgentHandler handles agent-related endpoints
type NodeAgentHandler struct {
db *gorm.DB
}
func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler {
return &NodeAgentHandler{db: db}
}
// RegisterAgent handles agent registration
func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Hostname string `json:"hostname" binding:"required"`
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"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate auth token (in a real implementation, this would be more sophisticated)
if req.AuthToken != "valid-token" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
return
}
// Check if agent already exists
var existingAgent NodeAgent
if err := h.db.Where("hostname = ? AND ip_address = ?", req.Hostname, req.IPAddress).First(&existingAgent).Error; err == nil {
// Update existing agent
existingAgent.Name = req.Name
existingAgent.Port = req.Port
existingAgent.Capabilities = req.Capabilities
existingAgent.Status = "connecting"
existingAgent.LastHeartbeat = time.Now()
if err := h.db.Save(&existingAgent).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.JSON(http.StatusOK, gin.H{
"agent_id": existingAgent.ID,
"auth_token": req.AuthToken,
"status": "updated",
})
return
}
// Create new agent
agent := NodeAgent{
ID: uuid.New().String(),
Name: req.Name,
Hostname: req.Hostname,
IPAddress: req.IPAddress,
Port: req.Port,
Status: "connecting",
Capabilities: req.Capabilities,
Resources: NodeResources{
CPU: CPUResources{
Cores: 4,
Allocation: 0,
Usage: 0,
},
Memory: MemoryResources{
Total: 8 * 1024 * 1024 * 1024, // 8GB
Allocated: 0,
Used: 0,
Available: 8 * 1024 * 1024 * 1024,
},
Storage: StorageResources{
Total: 100 * 1024 * 1024 * 1024, // 100GB
Allocated: 0,
Used: 0,
Available: 100 * 1024 * 1024 * 1024,
},
Network: NetworkResources{
Interfaces: []NetworkInterface{
{
Name: "eth0",
IPAddress: req.IPAddress,
MACAddress: "00:00:00:00:00:00",
Speed: 1000,
Status: "up",
},
},
Bandwidth: BandwidthInfo{
Inbound: 0,
Outbound: 0,
},
},
},
LastHeartbeat: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Metadata: make(map[string]interface{}),
}
if err := h.db.Create(&agent).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
return
}
c.JSON(http.StatusCreated, gin.H{
"agent_id": agent.ID,
"auth_token": req.AuthToken,
"status": "registered",
})
}
// GetAgents returns all registered agents
func (h *NodeAgentHandler) GetAgents(c *gin.Context) {
var agents []NodeAgent
if err := h.db.Find(&agents).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"})
return
}
c.JSON(http.StatusOK, gin.H{"agents": agents})
}
// GetAgent returns a specific agent
func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
id := c.Param("id")
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
c.JSON(http.StatusOK, gin.H{"agent": agent})
}
// UpdateAgent updates an agent's information
func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
id := c.Param("id")
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.Model(&agent).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.JSON(http.StatusOK, gin.H{"agent": agent})
}
// DeleteAgent removes an agent
func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"})
return
}
c.Status(http.StatusNoContent)
}
// SendHeartbeat handles heartbeat messages from agents
func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
var heartbeat AgentHeartbeat
if err := c.ShouldBindJSON(&heartbeat); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
// Update agent status and resources
agent.Status = heartbeat.Status
agent.Resources = heartbeat.Resources
agent.LastHeartbeat = heartbeat.Timestamp
agent.UpdatedAt = time.Now()
if err := h.db.Save(&agent).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.Status(http.StatusOK)
}
// GetAgentContainers returns containers running on a specific agent
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
agentID := c.Param("id")
var containers []ContainerInstance
if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"})
return
}
c.JSON(http.StatusOK, gin.H{"containers": containers})
}
// CreateContainer creates a new container on an agent
func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
agentID := c.Param("id")
var req struct {
Name string `json:"name" binding:"required"`
Image string `json:"image" binding:"required"`
ProjectID string `json:"project_id" binding:"required"`
ServiceID string `json:"service_id" binding:"required"`
Resources ContainerResources `json:"resources" binding:"required"`
Ports []PortMapping `json:"ports"`
Environment map[string]string `json:"environment"`
Volumes []VolumeMount `json:"volumes"`
Networks []string `json:"networks"`
RestartPolicy RestartPolicy `json:"restart_policy"`
HealthCheck *HealthCheck `json:"health_check"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify agent exists
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
container := ContainerInstance{
ID: uuid.New().String(),
Name: req.Name,
Image: req.Image,
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
NodeAgentID: agentID,
Status: ContainerStatus{
State: "created",
Health: "none",
},
Resources: req.Resources,
Ports: req.Ports,
Environment: req.Environment,
Volumes: req.Volumes,
Networks: req.Networks,
RestartPolicy: req.RestartPolicy,
HealthCheck: req.HealthCheck,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&container).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"})
return
}
// Create command to start container on agent
command := AgentCommand{
ID: uuid.New().String(),
Type: "create_container",
NodeAgentID: agentID,
ContainerID: &container.ID,
Payload: map[string]interface{}{
"container": container,
},
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"})
return
}
c.JSON(http.StatusCreated, gin.H{"container": container})
}
// ExecuteCommand executes a command on an agent
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
agentID := c.Param("id")
var req struct {
Type string `json:"type" binding:"required"`
Payload map[string]interface{} `json:"payload"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
command := AgentCommand{
ID: uuid.New().String(),
Type: req.Type,
NodeAgentID: agentID,
Payload: req.Payload,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
c.JSON(http.StatusCreated, gin.H{"command": command})
}
// GetAgentCommands returns commands for an agent
func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) {
agentID := c.Param("id")
var commands []AgentCommand
if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").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})
}
// GetCommandStatus returns the status of a specific command
func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
agentID := c.Param("id")
commandID := c.Param("commandId")
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
}
c.JSON(http.StatusOK, gin.H{"command": command})
}
// ContainerAction handles container lifecycle actions
func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
agentID := c.Param("id")
containerID := c.Param("containerId")
action := c.Param("action")
// Validate action
validActions := map[string]bool{
"start": true,
"stop": true,
"restart": true,
"remove": true,
}
if !validActions[action] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
return
}
// Verify container exists
var container ContainerInstance
if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch container"})
return
}
// Create command for the action
command := AgentCommand{
ID: uuid.New().String(),
Type: fmt.Sprintf("%s_container", action),
NodeAgentID: agentID,
ContainerID: &container.ID,
Payload: map[string]interface{}{
"container_id": containerID,
},
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Container %s action initiated", action)})
}
// GetAgentMetrics returns metrics for an agent
func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
_ = c.Param("id") // Use the parameter to avoid unused variable error
timeRange := c.Query("time_range")
if timeRange == "" {
timeRange = "1h" // default to 1 hour
}
// Parse time range
duration, err := time.ParseDuration(timeRange)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid time range"})
return
}
// For now, return empty metrics - in a real implementation, this would query a metrics database
metrics := []map[string]interface{}{
{
"timestamp": time.Now().Add(-duration).Format(time.RFC3339),
"cpu": map[string]interface{}{
"usage": 25.5,
"usage_percent": 25.5,
},
"memory": map[string]interface{}{
"usage": 2 * 1024 * 1024 * 1024, // 2GB
"usage_percent": 25.0,
"limit": 8 * 1024 * 1024 * 1024, // 8GB
},
},
{
"timestamp": time.Now().Format(time.RFC3339),
"cpu": map[string]interface{}{
"usage": 30.2,
"usage_percent": 30.2,
},
"memory": map[string]interface{}{
"usage": 2.5 * 1024 * 1024 * 1024, // 2.5GB
"usage_percent": 31.25,
"limit": 8 * 1024 * 1024 * 1024, // 8GB
},
},
}
c.JSON(http.StatusOK, gin.H{"metrics": metrics})
}
// SetupRoutes registers the agent routes
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
agents := router.Group("/agents")
{
agents.POST("/register", h.RegisterAgent)
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)
agents.POST("/:id/containers/:containerId/start", h.ContainerAction)
agents.POST("/:id/containers/:containerId/stop", h.ContainerAction)
agents.POST("/:id/containers/:containerId/restart", h.ContainerAction)
agents.DELETE("/:id/containers/:containerId", h.ContainerAction)
agents.GET("/:id/metrics", h.GetAgentMetrics)
agents.POST("/:id/commands", h.ExecuteCommand)
agents.GET("/:id/commands", h.GetAgentCommands)
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
}
}
+220
View File
@@ -0,0 +1,220 @@
package api
import (
"containr/internal/database"
"database/sql"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Name string `json:"name" binding:"required,min=2"`
}
type AuthResponse struct {
Token string `json:"token"`
User interface{} `json:"user"`
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"`
CreatedAt string `json:"created_at"`
}
func handleLogin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db := c.MustGet("db").(*database.DB)
jwtSecret := c.MustGet("jwt_secret").(string)
// Find user by email
var user User
var hashedPassword string
err := db.QueryRow(`
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
FROM users
WHERE email = $1
`, 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
} 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
}
// Generate JWT token
token, err := generateJWT(user.ID, user.Email, jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, AuthResponse{
Token: token,
User: user,
})
}
func handleRegister(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// Create user
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.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
// Generate JWT token
token, err := generateJWT(user.ID, user.Email, jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusCreated, AuthResponse{
Token: token,
User: user,
})
}
func handleGetProfile(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var user User
err := db.QueryRow(`
SELECT id, email, name, COALESCE(avatar_url, ''), created_at
FROM users
WHERE id = $1
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, user)
}
func handleUpdateProfile(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req struct {
Name string `json:"name,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update user profile
_, err := db.Exec(`
UPDATE users
SET name = COALESCE($1, name), avatar_url = COALESCE($2, avatar_url)
WHERE id = $3
`, req.Name, req.AvatarURL, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
return
}
// Return updated user
handleGetProfile(c)
}
func generateJWT(userID, email, secret string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID,
"email": email,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
})
return token.SignedString([]byte(secret))
}
// ValidateJWT validates a JWT token and returns the claims
func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrInvalidKey
}
+345
View File
@@ -0,0 +1,345 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"containr/internal/build"
"containr/internal/docker"
"containr/internal/types"
"github.com/gin-gonic/gin"
)
// BuildHandler handles build-related API endpoints
type BuildHandler struct {
buildManager *build.BuildManager
dockerClient *docker.Client
}
// NewBuildHandler creates a new build handler
func NewBuildHandler(buildManager *build.BuildManager, dockerClient *docker.Client) *BuildHandler {
return &BuildHandler{
buildManager: buildManager,
dockerClient: dockerClient,
}
}
// BuildRequest represents the request body for starting a build
type BuildRequest struct {
BuildType string `json:"build_type" binding:"required"`
SourcePath string `json:"source_path"`
PrebuiltImage string `json:"prebuilt_image"`
ImageName string `json:"image_name" binding:"required"`
ImageTag string `json:"image_tag" binding:"required"`
RegistryURL string `json:"registry_url"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
Environment map[string]string `json:"environment"`
BuildArgs map[string]string `json:"build_args"`
Labels map[string]string `json:"labels"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
Branch string `json:"branch"`
Commit string `json:"commit"`
}
// BuildResponse represents the response for a build operation
type BuildResponse struct {
ID string `json:"id"`
Status string `json:"status"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Digest string `json:"digest"`
BuildTime time.Time `json:"build_time"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// BuildStatusResponse represents the response for build status
type BuildStatusResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
Status string `json:"status"`
Progress int `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error string `json:"error,omitempty"`
Log string `json:"log"`
Metadata map[string]string `json:"metadata"`
}
// BuildListResponse represents the response for listing builds
type BuildListResponse struct {
Builds []BuildStatusResponse `json:"builds"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// StartBuild starts a new build
// @Summary Start a build
// @Description Starts a new build process for the given configuration
// @Tags builds
// @Accept json
// @Produce json
// @Param request body BuildRequest true "Build request"
// @Success 200 {object} BuildResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds [post]
func (h *BuildHandler) StartBuild(c *gin.Context) {
var req BuildRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert to internal build request
buildReq := &types.BuildRequest{
BuildType: req.BuildType,
SourcePath: req.SourcePath,
PrebuiltImage: req.PrebuiltImage,
ImageName: req.ImageName,
ImageTag: req.ImageTag,
RegistryURL: req.RegistryURL,
BuildCommand: req.BuildCommand,
StartCommand: req.StartCommand,
Environment: req.Environment,
BuildArgs: req.BuildArgs,
Labels: req.Labels,
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
TriggeredBy: "api",
Branch: req.Branch,
Commit: req.Commit,
}
// Validate build request
if err := h.buildManager.ValidateBuildRequest(c.Request.Context(), buildReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Start build (this would be async in production)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
_, err := h.buildManager.Build(ctx, buildReq)
if err != nil {
// Log error or update build status in database
return
}
}()
// For now, return a mock response
// In production, this would return the actual build ID and status
c.JSON(http.StatusOK, BuildResponse{
ID: "build-" + strconv.FormatInt(time.Now().Unix(), 10),
Status: "pending",
ImageName: req.ImageName,
ImageTag: req.ImageTag,
Success: true,
})
}
// GetBuildStatus gets the status of a build
// @Summary Get build status
// @Description Gets the current status of a build
// @Tags builds
// @Produce json
// @Param id path string true "Build ID"
// @Success 200 {object} BuildStatusResponse
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/{id} [get]
func (h *BuildHandler) GetBuildStatus(c *gin.Context) {
buildID := c.Param("id")
// For now, return a mock response
// In production, this would query the database for the actual build status
c.JSON(http.StatusOK, BuildStatusResponse{
ID: buildID,
Status: "completed",
Progress: 100,
StartedAt: time.Now().Add(-10 * time.Minute),
ImageName: "example-app",
ImageTag: "latest",
Size: 1024 * 1024 * 100, // 100MB
})
}
// ListBuilds lists all builds
// @Summary List builds
// @Description Lists all builds with optional filtering
// @Tags builds
// @Produce json
// @Param project_id query string false "Filter by project ID"
// @Param service_id query string false "Filter by service ID"
// @Param status query string false "Filter by status"
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Items per page" default(20)
// @Success 200 {object} BuildListResponse
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds [get]
func (h *BuildHandler) ListBuilds(c *gin.Context) {
projectID := c.Query("project_id")
serviceID := c.Query("service_id")
status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
// For now, return mock data
// In production, this would query the database with filters
builds := []BuildStatusResponse{
{
ID: "build-1",
ProjectID: projectID,
ServiceID: serviceID,
Status: status,
Progress: 100,
StartedAt: time.Now().Add(-1 * time.Hour),
ImageName: "example-app",
ImageTag: "latest",
Size: 1024 * 1024 * 100,
},
}
c.JSON(http.StatusOK, BuildListResponse{
Builds: builds,
Total: len(builds),
Page: page,
Limit: limit,
})
}
// CancelBuild cancels a running build
// @Summary Cancel build
// @Description Cancels a running build
// @Tags builds
// @Produce json
// @Param id path string true "Build ID"
// @Success 200 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/{id}/cancel [post]
func (h *BuildHandler) CancelBuild(c *gin.Context) {
buildID := c.Param("id")
// For now, just return success
// In production, this would actually cancel the build process
c.JSON(http.StatusOK, gin.H{
"message": "Build " + buildID + " cancelled",
})
}
// GetBuildLogs gets the logs for a build
// @Summary Get build logs
// @Description Gets the build logs for a specific build
// @Tags builds
// @Produce text
// @Param id path string true "Build ID"
// @Param follow query bool false "Follow logs" default(false)
// @Success 200 {string} string "Build logs"
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/{id}/logs [get]
func (h *BuildHandler) GetBuildLogs(c *gin.Context) {
buildID := c.Param("id")
follow := c.DefaultQuery("follow", "false") == "true"
// For now, return mock logs
// In production, this would stream actual build logs
logs := "Build " + buildID + " started\n"
logs += "Detecting runtime...\n"
logs += "Runtime detected: node\n"
logs += "Building image...\n"
logs += "Build completed successfully\n"
if follow {
// In production, this would use Server-Sent Events to stream logs
c.Header("Content-Type", "text/plain")
c.String(http.StatusOK, logs)
} else {
c.Header("Content-Type", "text/plain")
c.String(http.StatusOK, logs)
}
}
// GetBuildPlan gets the build plan for a service
// @Summary Get build plan
// @Description Gets the build plan for a service without actually building
// @Tags builds
// @Produce json
// @Param request body BuildRequest true "Build request"
// @Success 200 {object} build.BuildPlan
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/plan [post]
func (h *BuildHandler) GetBuildPlan(c *gin.Context) {
var req BuildRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert to internal build request
buildReq := &types.BuildRequest{
BuildType: req.BuildType,
SourcePath: req.SourcePath,
PrebuiltImage: req.PrebuiltImage,
ImageName: req.ImageName,
ImageTag: req.ImageTag,
BuildCommand: req.BuildCommand,
StartCommand: req.StartCommand,
Environment: req.Environment,
BuildArgs: req.BuildArgs,
Labels: req.Labels,
}
// Get build plan
plan, err := h.buildManager.GetBuildPlan(c.Request.Context(), buildReq)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, plan)
}
// DetectBuildType detects the build type for a given repository
// @Summary Detect build type
// @Description Detects the build type based on repository contents
// @Tags builds
// @Produce json
// @Param source_path query string true "Source path"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/detect [get]
func (h *BuildHandler) DetectBuildType(c *gin.Context) {
sourcePath := c.Query("source_path")
if sourcePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "source_path is required"})
return
}
buildType, err := h.buildManager.DetectBuildType(c.Request.Context(), sourcePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"build_type": string(buildType),
})
}
+543
View File
@@ -0,0 +1,543 @@
package api
import (
"database/sql"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
_ "github.com/lib/pq"
)
// DatabaseService represents a managed database service
type DatabaseService struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // postgresql, redis, mysql
Status string `json:"status" db:"status"` // running, stopped, building, error
Version string `json:"version" db:"version"`
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
Region string `json:"region" db:"region"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ConnectionURL string `json:"connection_url"`
Metrics DatabaseMetrics `json:"metrics"`
Backups DatabaseBackupConfig `json:"backups"`
Settings DatabaseSettings `json:"settings"`
}
// DatabaseMetrics represents database performance metrics
type DatabaseMetrics struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Storage float64 `json:"storage"`
Connections int `json:"connections"`
ReadIOPS int `json:"read_iops"`
WriteIOPS int `json:"write_iops"`
NetworkIn float64 `json:"network_in"`
NetworkOut float64 `json:"network_out"`
}
// DatabaseBackupConfig represents backup configuration
type DatabaseBackupConfig struct {
Enabled bool `json:"enabled"`
LastBackup *time.Time `json:"last_backup,omitempty"`
Retention int `json:"retention"` // days
NextBackup *time.Time `json:"next_backup,omitempty"`
Backups []DatabaseBackup `json:"backups"`
}
// DatabaseBackup represents a single backup
type DatabaseBackup struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Size string `json:"size"`
Status string `json:"status"` // completed, failed, in_progress
}
// DatabaseSettings represents database configuration
type DatabaseSettings struct {
MaxConnections int `json:"max_connections"`
Timeout int `json:"timeout"` // seconds
SSL bool `json:"ssl"`
Logging bool `json:"logging"`
}
// DatabaseCreateRequest represents a request to create a new database
type DatabaseCreateRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required,oneof=postgresql redis mysql"`
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
Region string `json:"region" binding:"required"`
}
// DatabaseUpdateRequest represents a request to update a database
type DatabaseUpdateRequest struct {
Name string `json:"name,omitempty"`
Plan string `json:"plan,omitempty"`
}
// DatabaseActionRequest represents a request to perform database actions
type DatabaseActionRequest struct {
Action string `json:"action" binding:"required,oneof=start stop restart"`
}
// DatabaseBackupRequest represents a request to create a backup
type DatabaseBackupRequest struct {
DatabaseID string `json:"database_id" binding:"required"`
}
// DatabaseRestoreRequest represents a request to restore from backup
type DatabaseRestoreRequest struct {
DatabaseID string `json:"database_id" binding:"required"`
BackupID string `json:"backup_id" binding:"required"`
}
// DatabaseHandler handles database service operations
type DatabaseHandler struct {
db *sql.DB
}
// NewDatabaseHandler creates a new database handler
func NewDatabaseHandler(db *sql.DB) *DatabaseHandler {
return &DatabaseHandler{db: db}
}
// GetDatabases returns all database services for a user
func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
userID := c.GetString("userID")
query := `
SELECT id, name, type, status, version, plan, region, created_at, updated_at
FROM database_services
WHERE user_id = $1
ORDER BY created_at DESC
`
rows, err := h.db.Query(query, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
return
}
defer rows.Close()
var databases []DatabaseService
for rows.Next() {
var db DatabaseService
err := rows.Scan(
&db.ID, &db.Name, &db.Type, &db.Status, &db.Version,
&db.Plan, &db.Region, &db.CreatedAt, &db.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan database"})
return
}
// Add mock metrics and configuration
db.Metrics = h.generateMockMetrics()
db.Backups = h.generateMockBackupConfig()
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.generateConnectionURL(db)
databases = append(databases, db)
}
c.JSON(http.StatusOK, gin.H{"databases": databases})
}
// GetDatabase returns a specific database service
func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
query := `
SELECT id, name, type, status, version, plan, region, created_at, updated_at
FROM database_services
WHERE id = $1 AND user_id = $2
`
var db DatabaseService
err := h.db.QueryRow(query, databaseID, userID).Scan(
&db.ID, &db.Name, &db.Type, &db.Status, &db.Version,
&db.Plan, &db.Region, &db.CreatedAt, &db.UpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
return
}
// Add detailed metrics and configuration
db.Metrics = h.generateMockMetrics()
db.Backups = h.generateMockBackupConfig()
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.generateConnectionURL(db)
c.JSON(http.StatusOK, db)
}
// CreateDatabase creates a new database service
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
userID := c.GetString("userID")
var req DatabaseCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate database ID
databaseID := fmt.Sprintf("db_%d_%s", time.Now().Unix(), req.Name)
// Insert database into database
query := `
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
now := time.Now()
version := h.getDefaultVersion(req.Type)
_, err := h.db.Exec(query, databaseID, userID, req.Name, req.Type, "building", version, req.Plan, req.Region, now, now)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database"})
return
}
// In a real implementation, this would trigger the actual database provisioning
// For now, we'll simulate it by updating the status to "running"
go h.provisionDatabase(databaseID)
c.JSON(http.StatusCreated, gin.H{
"id": databaseID,
"message": "Database provisioning started",
"status": "building",
})
}
// UpdateDatabase updates a database service
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
var req DatabaseUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build dynamic update query
setParts := []string{}
args := []interface{}{}
argIndex := 1
if req.Name != "" {
setParts = append(setParts, fmt.Sprintf("name = $%d", argIndex))
args = append(args, req.Name)
argIndex++
}
if req.Plan != "" {
setParts = append(setParts, fmt.Sprintf("plan = $%d", argIndex))
args = append(args, req.Plan)
argIndex++
}
if len(setParts) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
return
}
setParts = append(setParts, fmt.Sprintf("updated_at = $%d", argIndex))
args = append(args, time.Now())
argIndex++
args = append(args, databaseID, userID)
query := fmt.Sprintf(`
UPDATE database_services
SET %s
WHERE id = $%d AND user_id = $%d
`, fmt.Sprintf("%s", setParts), argIndex, argIndex+1)
_, err := h.db.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"})
}
// DeleteDatabase deletes a database service
func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
// Check if database exists and belongs to user
var exists bool
checkQuery := "SELECT EXISTS(SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2)"
err := h.db.QueryRow(checkQuery, databaseID, userID).Scan(&exists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
// In a real implementation, this would trigger the actual database deprovisioning
// For now, we'll just delete the record
deleteQuery := "DELETE FROM database_services WHERE id = $1 AND user_id = $2"
_, err = h.db.Exec(deleteQuery, databaseID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete database"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database deleted successfully"})
}
// PerformDatabaseAction performs actions on a database (start, stop, restart)
func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
var req DatabaseActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if database exists and belongs to user
var exists bool
checkQuery := "SELECT EXISTS(SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2)"
err := h.db.QueryRow(checkQuery, databaseID, userID).Scan(&exists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
// Update database status based on action
var newStatus string
switch req.Action {
case "start":
newStatus = "running"
case "stop":
newStatus = "stopped"
case "restart":
newStatus = "building" // Will be updated to running after restart
go h.restartDatabase(databaseID)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
return
}
updateQuery := "UPDATE database_services SET status = $1, updated_at = $2 WHERE id = $3 AND user_id = $4"
_, err = h.db.Exec(updateQuery, newStatus, time.Now(), databaseID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database status"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Database %s initiated", req.Action),
"status": newStatus,
})
}
// CreateBackup creates a backup of a database
func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
// Check if database exists and belongs to user
var exists bool
checkQuery := "SELECT EXISTS(SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2)"
err := h.db.QueryRow(checkQuery, databaseID, userID).Scan(&exists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
// Generate backup ID
backupID := fmt.Sprintf("backup_%d_%s", time.Now().Unix(), databaseID)
// In a real implementation, this would trigger the actual backup process
// For now, we'll simulate it
go h.createBackupProcess(databaseID, backupID)
c.JSON(http.StatusCreated, gin.H{
"backup_id": backupID,
"message": "Backup creation started",
"status": "in_progress",
})
}
// RestoreBackup restores a database from a backup
func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
var req DatabaseRestoreRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if database exists and belongs to user
var exists bool
checkQuery := "SELECT EXISTS(SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2)"
err := h.db.QueryRow(checkQuery, databaseID, userID).Scan(&exists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
// In a real implementation, this would trigger the actual restore process
// For now, we'll simulate it
go h.restoreBackupProcess(databaseID, req.BackupID)
c.JSON(http.StatusOK, gin.H{
"message": "Database restore started",
"status": "in_progress",
})
}
// Helper functions for mock data generation
func (h *DatabaseHandler) generateMockMetrics() DatabaseMetrics {
return DatabaseMetrics{
CPU: 25.0 + (float64(time.Now().Unix() % 50)),
Memory: 60.0 + (float64(time.Now().Unix() % 30)),
Storage: 45.0 + (float64(time.Now().Unix() % 40)),
Connections: 10 + (int(time.Now().Unix() % 20)),
ReadIOPS: 150 + (int(time.Now().Unix() % 100)),
WriteIOPS: 80 + (int(time.Now().Unix() % 50)),
NetworkIn: 2.5 + (float64(time.Now().Unix()%10))/10,
NetworkOut: 1.8 + (float64(time.Now().Unix()%8))/10,
}
}
func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig {
return DatabaseBackupConfig{
Enabled: true,
LastBackup: &time.Time{},
Retention: 30,
NextBackup: &time.Time{},
Backups: []DatabaseBackup{
{
ID: "backup_1",
CreatedAt: time.Now().Add(-6 * time.Hour),
Size: "245 MB",
Status: "completed",
},
{
ID: "backup_2",
CreatedAt: time.Now().Add(-24 * time.Hour),
Size: "238 MB",
Status: "completed",
},
{
ID: "backup_3",
CreatedAt: time.Now().Add(-48 * time.Hour),
Size: "241 MB",
Status: "completed",
},
},
}
}
func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
return DatabaseSettings{
MaxConnections: 100,
Timeout: 30,
SSL: true,
Logging: true,
}
}
func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string {
switch db.Type {
case "postgresql":
return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", db.Name, db.Name)
case "redis":
return fmt.Sprintf("redis://%s.containr.local:6379", db.Name)
case "mysql":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", db.Name, db.Name)
default:
return ""
}
}
func (h *DatabaseHandler) getDefaultVersion(dbType string) string {
switch dbType {
case "postgresql":
return "15.4"
case "redis":
return "7.2"
case "mysql":
return "8.0"
default:
return "latest"
}
}
// Mock provisioning functions (in real implementation, these would interact with container orchestration)
func (h *DatabaseHandler) provisionDatabase(databaseID string) {
// Simulate provisioning time
time.Sleep(30 * time.Second)
// Update status to running
query := "UPDATE database_services SET status = 'running', updated_at = $1 WHERE id = $2"
h.db.Exec(query, time.Now(), databaseID)
}
func (h *DatabaseHandler) restartDatabase(databaseID string) {
// Simulate restart time
time.Sleep(10 * time.Second)
// Update status to running
query := "UPDATE database_services SET status = 'running', updated_at = $1 WHERE id = $2"
h.db.Exec(query, time.Now(), databaseID)
}
func (h *DatabaseHandler) createBackupProcess(databaseID, backupID string) {
// Simulate backup creation time
time.Sleep(5 * time.Minute)
// In a real implementation, this would store backup metadata
// For now, we'll just log it
fmt.Printf("Backup %s created for database %s\n", backupID, databaseID)
}
func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string) {
// Simulate restore time
time.Sleep(10 * time.Minute)
// In a real implementation, this would restore the database
// For now, we'll just log it
fmt.Printf("Database %s restored from backup %s\n", databaseID, backupID)
}
+458
View File
@@ -0,0 +1,458 @@
package api
import (
"containr/internal/database"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// GitProvider represents a Git provider (GitHub, GitLab, Bitbucket)
type GitProvider struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"` // github, gitlab, bitbucket
DisplayName string `json:"display_name" db:"display_name"`
APIUrl string `json:"api_url" db:"api_url"`
WebhookUrl string `json:"webhook_url" db:"webhook_url"`
UserID string `json:"user_id" db:"user_id"`
AccessToken string `json:"-" db:"access_token"` // Hidden in JSON responses
CreatedAt string `json:"created_at" db:"created_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}
// GitRepository represents a connected Git repository
type GitRepository struct {
ID string `json:"id" db:"id"`
ProviderID string `json:"provider_id" db:"provider_id"`
Name string `json:"name" db:"name"`
FullName string `json:"full_name" db:"full_name"`
Description string `json:"description" db:"description"`
CloneURL string `json:"clone_url" db:"clone_url"`
WebhookURL string `json:"webhook_url" db:"webhook_url"`
DefaultBranch string `json:"default_branch" db:"default_branch"`
IsPrivate bool `json:"is_private" db:"is_private"`
UserID string `json:"user_id" db:"user_id"`
CreatedAt string `json:"created_at" db:"created_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}
// GitWebhook represents a webhook configuration
type GitWebhook struct {
ID string `json:"id" db:"id"`
RepoID string `json:"repo_id" db:"repo_id"`
ProviderID string `json:"provider_id" db:"provider_id"`
Events string `json:"events" db:"events"` // JSON array of events
Secret string `json:"-" db:"webhook_secret"` // Hidden in JSON responses
Active bool `json:"active" db:"active"`
CreatedAt string `json:"created_at" db:"created_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}
// CreateGitProviderRequest represents a request to create a Git provider
type CreateGitProviderRequest struct {
Name string `json:"name" binding:"required,oneof=github gitlab bitbucket"`
DisplayName string `json:"display_name" binding:"required"`
AccessToken string `json:"access_token" binding:"required"`
}
// CreateGitRepoRequest represents a request to connect a Git repository
type CreateGitRepoRequest struct {
ProviderID string `json:"provider_id" binding:"required"`
RepoFullName string `json:"repo_full_name" binding:"required"`
}
// CreateWebhookRequest represents a request to create a webhook
type CreateWebhookRequest struct {
RepoID string `json:"repo_id" binding:"required"`
Events []string `json:"events" binding:"required"`
Branch string `json:"branch"`
}
func handleGetGitProviders(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
rows, err := db.Query(`
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
FROM git_providers
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
var providers []GitProvider
for rows.Next() {
var provider GitProvider
if err := rows.Scan(&provider.ID, &provider.Name, &provider.DisplayName, &provider.APIUrl,
&provider.WebhookUrl, &provider.UserID, &provider.CreatedAt, &provider.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providers = append(providers, provider)
}
c.JSON(http.StatusOK, gin.H{"providers": providers})
}
func handleCreateGitProvider(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateGitProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate the access token by making a test API call
if !validateGitToken(req.Name, req.AccessToken) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid access token for " + req.Name})
return
}
provider := GitProvider{
ID: uuid.New().String(),
Name: req.Name,
DisplayName: req.DisplayName,
AccessToken: req.AccessToken,
UserID: userID,
}
// Set provider-specific URLs
switch req.Name {
case "github":
provider.APIUrl = "https://api.github.com"
provider.WebhookUrl = "https://api.github.com"
case "gitlab":
provider.APIUrl = "https://gitlab.com/api/v4"
provider.WebhookUrl = "https://gitlab.com"
case "bitbucket":
provider.APIUrl = "https://api.bitbucket.org/2.0"
provider.WebhookUrl = "https://api.bitbucket.org/2.0"
}
_, err := db.Exec(`
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl,
provider.WebhookUrl, provider.AccessToken, provider.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Git provider"})
return
}
// Return provider without access token
provider.AccessToken = ""
c.JSON(http.StatusCreated, provider)
}
func handleGetGitRepositories(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
providerID := c.Param("providerId")
// Validate UUID
if _, err := uuid.Parse(providerID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
// Get provider info
var provider GitProvider
err := db.QueryRow(`
SELECT id, name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`, providerID, userID).Scan(&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Git provider not found"})
return
}
// Fetch repositories from the Git provider
repos, err := fetchGitRepositories(provider.Name, provider.AccessToken, provider.APIUrl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repositories"})
return
}
c.JSON(http.StatusOK, gin.H{"repositories": repos})
}
func handleConnectGitRepository(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateGitRepoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate UUID
if _, err := uuid.Parse(req.ProviderID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
// Get provider info
var provider GitProvider
err := db.QueryRow(`
SELECT id, name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`, req.ProviderID, userID).Scan(&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Git provider not found"})
return
}
// Fetch repository details from Git provider
repoDetails, err := fetchGitRepositoryDetails(provider.Name, req.RepoFullName, provider.AccessToken, provider.APIUrl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repository details"})
return
}
// Check if repository is already connected
var existingID string
err = db.QueryRow(`
SELECT id FROM git_repositories
WHERE provider_id = $1 AND full_name = $2
`, req.ProviderID, req.RepoFullName).Scan(&existingID)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Repository already connected", "repository_id": existingID})
return
}
// Create repository record
repo := GitRepository{
ID: uuid.New().String(),
ProviderID: req.ProviderID,
Name: repoDetails["name"].(string),
FullName: req.RepoFullName,
Description: repoDetails["description"].(string),
CloneURL: repoDetails["clone_url"].(string),
DefaultBranch: repoDetails["default_branch"].(string),
IsPrivate: repoDetails["private"].(bool),
UserID: userID,
}
_, err = db.Exec(`
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url,
default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
`, repo.ID, repo.ProviderID, repo.Name, repo.FullName, repo.Description,
repo.CloneURL, repo.DefaultBranch, repo.IsPrivate, repo.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect repository"})
return
}
c.JSON(http.StatusCreated, repo)
}
func handleCreateWebhook(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate UUIDs
if _, err := uuid.Parse(req.RepoID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository ID"})
return
}
// Get repository and provider info
var repo GitRepository
var provider GitProvider
err := db.QueryRow(`
SELECT r.id, r.provider_id, r.full_name, r.user_id,
p.id, p.name, p.access_token, p.webhook_url
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.id = $1 AND r.user_id = $2
`, req.RepoID, userID).Scan(&repo.ID, &repo.ProviderID, &repo.FullName, &repo.UserID,
&provider.ID, &provider.Name, &provider.AccessToken, &provider.WebhookUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
return
}
// Convert events to JSON
eventsJSON, _ := json.Marshal(req.Events)
webhookSecret := generateWebhookSecret()
// Create webhook on Git provider
webhookURL := fmt.Sprintf("%s/api/v1/webhooks/git/%s", "https://your-domain.com", req.RepoID)
remoteWebhookID, err := createGitWebhook(provider.Name, repo.FullName, provider.AccessToken,
provider.WebhookUrl, webhookURL, req.Events, webhookSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook on Git provider"})
return
}
// Create webhook record
webhook := GitWebhook{
ID: uuid.New().String(),
RepoID: req.RepoID,
ProviderID: provider.ID,
Events: string(eventsJSON),
Secret: webhookSecret,
Active: true,
}
_, err = db.Exec(`
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
`, webhook.ID, webhook.RepoID, webhook.ProviderID, webhook.Events, webhook.Secret, webhook.Active)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook"})
return
}
c.JSON(http.StatusCreated, gin.H{
"webhook": webhook,
"remote_webhook_id": remoteWebhookID,
})
}
func handleGetConnectedRepositories(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
offset := (page - 1) * limit
rows, err := db.Query(`
SELECT r.id, r.provider_id, r.name, r.full_name, r.description, r.clone_url,
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
p.name as provider_name, p.display_name
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.user_id = $1
ORDER BY r.updated_at DESC
LIMIT $2 OFFSET $3
`, userID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
var repositories []map[string]interface{}
for rows.Next() {
var repo GitRepository
var providerName, providerDisplayName string
if err := rows.Scan(&repo.ID, &repo.ProviderID, &repo.Name, &repo.FullName, &repo.Description,
&repo.CloneURL, &repo.DefaultBranch, &repo.IsPrivate, &repo.UserID, &repo.CreatedAt, &repo.UpdatedAt,
&providerName, &providerDisplayName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories = append(repositories, map[string]interface{}{
"id": repo.ID,
"provider_id": repo.ProviderID,
"name": repo.Name,
"full_name": repo.FullName,
"description": repo.Description,
"clone_url": repo.CloneURL,
"default_branch": repo.DefaultBranch,
"is_private": repo.IsPrivate,
"created_at": repo.CreatedAt,
"updated_at": repo.UpdatedAt,
"provider": map[string]string{
"name": providerName,
"display_name": providerDisplayName,
},
})
}
// Get total count
var total int
err = db.QueryRow(`
SELECT COUNT(*) FROM git_repositories WHERE user_id = $1
`, userID).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, gin.H{
"repositories": repositories,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
},
})
}
// Helper functions (these would need to be implemented with actual Git provider API calls)
func validateGitToken(provider, token string) bool {
// TODO: Implement actual validation with Git provider APIs
// For now, just check if token is not empty
return token != ""
}
func fetchGitRepositories(provider, token, apiUrl string) ([]map[string]interface{}, error) {
// TODO: Implement actual API calls to fetch repositories
// For now, return mock data
return []map[string]interface{}{
{
"name": "example-repo",
"full_name": "user/example-repo",
"description": "An example repository",
"clone_url": "https://github.com/user/example-repo.git",
"default_branch": "main",
"private": false,
},
}, nil
}
func fetchGitRepositoryDetails(provider, repoFullName, token, apiUrl string) (map[string]interface{}, error) {
// TODO: Implement actual API call to fetch repository details
return map[string]interface{}{
"name": "example-repo",
"description": "An example repository",
"clone_url": "https://github.com/user/example-repo.git",
"default_branch": "main",
"private": false,
}, nil
}
func createGitWebhook(provider, repoFullName, token, webhookUrl, apiUrl string, events []string, secret string) (string, error) {
// TODO: Implement actual webhook creation
return uuid.New().String(), nil
}
func generateWebhookSecret() string {
// TODO: Generate a proper secret
return "webhook-secret-" + uuid.New().String()
}
+607
View File
@@ -0,0 +1,607 @@
package api
import (
"net/http"
"strconv"
"time"
"containr/internal/ha"
"github.com/gin-gonic/gin"
)
// HAManager handles high availability API endpoints
type HAManager struct {
haManager *ha.HighAvailabilityManager
}
// NewHAManager creates a new HA manager handler
func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager {
return &HAManager{
haManager: haManager,
}
}
// RegisterRoutes registers HA routes
func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.GET("/status", h.GetHAStatus)
ha.POST("/enable", h.EnableHA)
ha.POST("/disable", h.DisableHA)
ha.POST("/failover", h.TriggerFailover)
// Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.POST("/failover/policies", h.SetFailoverPolicy)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
// Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.POST("/health/checks", h.AddHealthCheck)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.POST("/alerts/rules", h.AddAlertRule)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
// Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.POST("/notifiers", h.AddNotifier)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
}
}
// GetHAStatus returns the overall HA status
func (h *HAManager) GetHAStatus(c *gin.Context) {
status := h.haManager.GetHealthStatus()
c.JSON(http.StatusOK, gin.H{
"status": status,
})
}
// EnableHA enables the HA manager
func (h *HAManager) EnableHA(c *gin.Context) {
h.haManager.Enable()
c.JSON(http.StatusOK, gin.H{
"message": "High availability manager enabled",
"enabled": true,
})
}
// DisableHA disables the HA manager
func (h *HAManager) DisableHA(c *gin.Context) {
h.haManager.Disable()
c.JSON(http.StatusOK, gin.H{
"message": "High availability manager disabled",
"enabled": false,
})
}
// TriggerFailover manually triggers a failover
func (h *HAManager) TriggerFailover(c *gin.Context) {
var request struct {
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
reason := request.Reason
if reason == "" {
reason = "Manual trigger"
}
if err := h.haManager.TriggerFailover(c.Request.Context(), reason); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Failover triggered successfully",
"reason": reason,
})
}
// GetFailoverPolicies returns all failover policies
func (h *HAManager) GetFailoverPolicies(c *gin.Context) {
// TODO: Implement getting all policies
// For now, return mock data
policies := []map[string]interface{}{
{
"service_id": "web-service",
"enabled": true,
"min_healthy_nodes": 2,
"max_failures": 3,
"failover_timeout": "30s",
"recovery_timeout": "5m",
"failover_strategy": "active_passive",
"backup_nodes": []string{"node-2", "node-3"},
},
}
c.JSON(http.StatusOK, gin.H{
"policies": policies,
"count": len(policies),
})
}
// SetFailoverPolicy creates or updates a failover policy
func (h *HAManager) SetFailoverPolicy(c *gin.Context) {
var policy ha.FailoverPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.haManager.SetFailoverPolicy(&policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Failover policy set successfully",
"policy": policy,
})
}
// GetFailoverPolicy returns a specific failover policy
func (h *HAManager) GetFailoverPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
policy, err := h.haManager.GetFailoverPolicy(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"policy": policy,
})
}
// UpdateFailoverPolicy updates an existing failover policy
func (h *HAManager) UpdateFailoverPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
var policy ha.FailoverPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure the service ID matches
policy.ServiceID = serviceID
if err := h.haManager.SetFailoverPolicy(&policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Failover policy updated successfully",
"policy": policy,
})
}
// DeleteFailoverPolicy removes a failover policy
func (h *HAManager) DeleteFailoverPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
// Set policy to disabled instead of deleting
policy, err := h.haManager.GetFailoverPolicy(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
policy.Enabled = false
if err := h.haManager.SetFailoverPolicy(policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Failover policy disabled successfully",
})
}
// GetHealthChecks returns all health checks
func (h *HAManager) GetHealthChecks(c *gin.Context) {
// TODO: Implement getting health checks from the health checker
// For now, return mock data
checks := []map[string]interface{}{
{
"id": "check-1",
"service_id": "web-service",
"node_id": "node-1",
"type": "http",
"config": map[string]interface{}{
"interval": "30s",
"timeout": "5s",
"unhealthy_threshold": 3,
"healthy_threshold": 2,
"path": "/health",
"port": 8080,
"protocol": "HTTP",
},
"last_check": time.Now().Add(-30 * time.Second),
"status": "healthy",
},
}
c.JSON(http.StatusOK, gin.H{
"checks": checks,
"count": len(checks),
})
}
// AddHealthCheck adds a new health check
func (h *HAManager) AddHealthCheck(c *gin.Context) {
var check ha.HealthCheck
if err := c.ShouldBindJSON(&check); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Add health check to the health checker
// For now, just return success
c.JSON(http.StatusCreated, gin.H{
"message": "Health check added successfully",
"check": check,
})
}
// GetHealthCheck returns a specific health check
func (h *HAManager) GetHealthCheck(c *gin.Context) {
_ = c.Param("checkId") // Use the parameter to avoid unused variable error
// TODO: Implement getting specific health check
// For now, return mock data
check := map[string]interface{}{
"id": "check-1",
"service_id": "web-service",
"node_id": "node-1",
"type": "http",
"status": "healthy",
}
c.JSON(http.StatusOK, gin.H{"check": check})
}
// UpdateHealthCheck updates an existing health check
func (h *HAManager) UpdateHealthCheck(c *gin.Context) {
checkID := c.Param("checkId")
var check ha.HealthCheck
if err := c.ShouldBindJSON(&check); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure the check ID matches
check.ID = checkID
// TODO: Update health check in the health checker
c.JSON(http.StatusOK, gin.H{
"message": "Health check updated successfully",
"check": check,
})
}
// DeleteHealthCheck removes a health check
func (h *HAManager) DeleteHealthCheck(c *gin.Context) {
_ = c.Param("checkId") // Use the parameter to avoid unused variable error
// TODO: Remove health check from the health checker
c.JSON(http.StatusOK, gin.H{
"message": "Health check deleted successfully",
})
}
// GetHealthResults returns all health check results
func (h *HAManager) GetHealthResults(c *gin.Context) {
// TODO: Implement getting health check results
// For now, return mock data
results := []map[string]interface{}{
{
"check_id": "check-1",
"status": "healthy",
"message": "Service is healthy",
"latency": "15ms",
"timestamp": time.Now().Add(-30 * time.Second),
},
{
"check_id": "check-2",
"status": "unhealthy",
"message": "Connection timeout",
"latency": "5000ms",
"timestamp": time.Now().Add(-25 * time.Second),
"error_code": "TIMEOUT",
},
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"count": len(results),
})
}
// GetAlertRules returns all alert rules
func (h *HAManager) GetAlertRules(c *gin.Context) {
// TODO: Implement getting alert rules
// For now, return mock data
rules := []map[string]interface{}{
{
"id": "rule-1",
"name": "High CPU Usage",
"description": "Alert when CPU usage is above 90%",
"enabled": true,
"condition": map[string]interface{}{
"metric": "cpu_usage",
"operator": ">",
"threshold": 90.0,
"duration": "5m",
},
"severity": "warning",
"labels": map[string]string{
"service": "web-service",
"team": "backend",
},
"notifiers": []string{"email", "slack"},
"cooldown": "10m",
},
}
c.JSON(http.StatusOK, gin.H{
"rules": rules,
"count": len(rules),
})
}
// AddAlertRule adds a new alert rule
func (h *HAManager) AddAlertRule(c *gin.Context) {
var rule ha.AlertRule
if err := c.ShouldBindJSON(&rule); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Add alert rule to the alert manager
// For now, just return success
c.JSON(http.StatusCreated, gin.H{
"message": "Alert rule added successfully",
"rule": rule,
})
}
// GetAlertRule returns a specific alert rule
func (h *HAManager) GetAlertRule(c *gin.Context) {
_ = c.Param("ruleId") // Use the parameter to avoid unused variable error
// TODO: Implement getting specific alert rule
// For now, return mock data
rule := map[string]interface{}{
"id": "rule-1",
"name": "High CPU Usage",
"description": "Alert when CPU usage is above 90%",
"enabled": true,
"severity": "warning",
}
c.JSON(http.StatusOK, gin.H{
"rule": rule,
})
}
// UpdateAlertRule updates an existing alert rule
func (h *HAManager) UpdateAlertRule(c *gin.Context) {
ruleID := c.Param("ruleId")
var rule ha.AlertRule
if err := c.ShouldBindJSON(&rule); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure the rule ID matches
rule.ID = ruleID
// TODO: Update alert rule in the alert manager
c.JSON(http.StatusOK, gin.H{
"message": "Alert rule updated successfully",
"rule": rule,
})
}
// DeleteAlertRule removes an alert rule
func (h *HAManager) DeleteAlertRule(c *gin.Context) {
// TODO: Remove alert rule from the alert manager
c.JSON(http.StatusOK, gin.H{
"message": "Alert rule deleted successfully",
})
}
// GetActiveAlerts returns all active alerts
func (h *HAManager) GetActiveAlerts(c *gin.Context) {
// Parse query parameters
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 50
}
// TODO: Implement getting active alerts from the alert manager
// For now, return mock data
alerts := []map[string]interface{}{
{
"id": "alert-1",
"rule_id": "rule-1",
"status": "firing",
"severity": "warning",
"message": "CPU usage is above 90%",
"labels": map[string]string{
"service": "web-service",
"team": "backend",
},
"starts_at": time.Now().Add(-10 * time.Minute),
"updated_at": time.Now().Add(-2 * time.Minute),
},
{
"id": "alert-2",
"rule_id": "rule-2",
"status": "firing",
"severity": "critical",
"message": "Service is down",
"labels": map[string]string{
"service": "api-service",
"team": "backend",
},
"starts_at": time.Now().Add(-5 * time.Minute),
"updated_at": time.Now().Add(-1 * time.Minute),
},
}
// Limit results
if len(alerts) > limit {
alerts = alerts[:limit]
}
c.JSON(http.StatusOK, gin.H{
"alerts": alerts,
"count": len(alerts),
"limit": limit,
})
}
// ResolveAlert resolves an alert
func (h *HAManager) ResolveAlert(c *gin.Context) {
alertID := c.Param("alertId")
// TODO: Resolve alert in the alert manager
c.JSON(http.StatusOK, gin.H{
"message": "Alert resolved successfully",
"alert_id": alertID,
})
}
// GetNotifiers returns all notifiers
func (h *HAManager) GetNotifiers(c *gin.Context) {
// TODO: Implement getting notifiers
// For now, return mock data
notifiers := []map[string]interface{}{
{
"id": "email",
"type": "email",
"config": map[string]interface{}{
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"from": "[email protected]",
"to": []string{"[email protected]"},
},
},
{
"id": "slack",
"type": "slack",
"config": map[string]interface{}{
"webhook_url": "https://hooks.slack.com/...",
"channel": "#alerts",
},
},
}
c.JSON(http.StatusOK, gin.H{
"notifiers": notifiers,
"count": len(notifiers),
})
}
// AddNotifier adds a new notifier
func (h *HAManager) AddNotifier(c *gin.Context) {
var request struct {
ID string `json:"id"`
Type string `json:"type"`
Config map[string]interface{} `json:"config"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create notifier based on type
switch request.Type {
case "email":
_ = &ha.EmailNotifier{} // Create but don't use for now
case "slack":
_ = &ha.SlackNotifier{} // Create but don't use for now
case "webhook":
_ = &ha.WebhookNotifier{} // Create but don't use for now
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notifier type"})
return
}
// TODO: Add notifier to the alert manager
c.JSON(http.StatusCreated, gin.H{
"message": "Notifier added successfully",
"id": request.ID,
"type": request.Type,
})
}
// GetNotifier returns a specific notifier
func (h *HAManager) GetNotifier(c *gin.Context) {
_ = c.Param("notifierId") // Use the parameter to avoid unused variable error
// TODO: Implement getting specific notifier
// For now, return mock data
notifier := map[string]interface{}{
"id": "email",
"type": "email",
"config": map[string]interface{}{
"smtp_host": "smtp.example.com",
"smtp_port": 587,
},
}
c.JSON(http.StatusOK, gin.H{
"notifier": notifier,
})
}
// DeleteNotifier removes a notifier
func (h *HAManager) DeleteNotifier(c *gin.Context) {
_ = c.Param("notifierId") // Use the parameter to avoid unused variable error
// TODO: Remove notifier from the alert manager
// For now, just return success
c.JSON(http.StatusOK, gin.H{
"message": "Notifier deleted successfully",
})
}
+588
View File
@@ -0,0 +1,588 @@
package api
import (
"containr/internal/database"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// PreviewEnvironment represents a preview environment
type PreviewEnvironment struct {
ID uuid.UUID `json:"id" db:"id"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
BranchName string `json:"branch_name" db:"branch_name"`
PRNumber *int `json:"pr_number" db:"pr_number"`
Environment string `json:"environment" db:"environment"` // preview-{branch}-{timestamp}
Status string `json:"status" db:"status"` // building, running, failed, stopped, expired
URL string `json:"url" db:"url"`
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Related data
Service *Service `json:"service,omitempty"`
DeploymentID *uuid.UUID `json:"deployment_id,omitempty"`
}
// CreatePreviewEnvironmentRequest represents a request to create a preview environment
type CreatePreviewEnvironmentRequest struct {
ProjectID uuid.UUID `json:"project_id" binding:"required"`
ServiceID uuid.UUID `json:"service_id" binding:"required"`
BranchName string `json:"branch_name" binding:"required"`
PRNumber *int `json:"pr_number"`
TTLHours int `json:"ttl_hours" binding:"min=1,max=168"` // 1 hour to 7 days
}
// UpdatePreviewEnvironmentRequest represents a request to update a preview environment
type UpdatePreviewEnvironmentRequest struct {
Status string `json:"status" binding:"omitempty,oneof=building running failed stopped expired"`
URL string `json:"url"`
ExpiresAt *time.Time `json:"expires_at"`
TTLHours int `json:"ttl_hours" binding:"omitempty,min=1,max=168"`
}
// PromotePreviewEnvironmentRequest represents a request to promote a preview environment
type PromotePreviewEnvironmentRequest struct {
TargetEnvironment string `json:"target_environment" binding:"required,oneof=production development"`
CreateBackup bool `json:"create_backup"`
}
// handleGetPreviewEnvironments retrieves all preview environments for a project
func handleGetPreviewEnvironments(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectIDStr := c.Param("project_id")
projectID, err := uuid.Parse(projectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Get preview environments for the project with service info
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
WHERE pe.project_id = $1
ORDER BY pe.created_at DESC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve preview environments"})
return
}
defer rows.Close()
var environments []PreviewEnvironment
for rows.Next() {
var env PreviewEnvironment
var service Service
err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&service.ID, &service.Name, &service.Type,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan preview environment"})
return
}
if service.ID != uuid.Nil {
env.Service = &service
}
environments = append(environments, env)
}
c.JSON(http.StatusOK, gin.H{"preview_environments": environments})
}
// handleCreatePreviewEnvironment creates a new preview environment
func handleCreatePreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
var req CreatePreviewEnvironmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if project exists and user has access
var project Project
err := db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
req.ProjectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Check if service exists and belongs to the project
var service Service
err = db.(*database.DB).QueryRow(
"SELECT id, name, type FROM services WHERE id = $1 AND project_id = $2",
req.ServiceID, req.ProjectID,
).Scan(&service.ID, &service.Name, &service.Type)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found or doesn't belong to this project"})
return
}
// Check if preview environment already exists for this branch and service
var count int
err = db.(*database.DB).QueryRow(
"SELECT COUNT(*) FROM preview_environments WHERE service_id = $1 AND branch_name = $2 AND status NOT IN ('expired', 'stopped')",
req.ServiceID, req.BranchName,
).Scan(&count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing preview environment"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Preview environment already exists for this branch and service"})
return
}
// Set default TTL if not provided
ttlHours := req.TTLHours
if ttlHours == 0 {
ttlHours = 24 // Default 24 hours
}
// Create preview environment
env := PreviewEnvironment{
ID: uuid.New(),
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
BranchName: req.BranchName,
PRNumber: req.PRNumber,
Environment: generatePreviewEnvironmentName(req.BranchName),
Status: "building",
ExpiresAt: &[]time.Time{time.Now().Add(time.Duration(ttlHours) * time.Hour)}[0],
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Insert preview environment into database
_, err = db.(*database.DB).Exec(
`INSERT INTO preview_environments
(id, project_id, service_id, branch_name, pr_number, environment,
status, url, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber,
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"})
return
}
// TODO: Trigger deployment pipeline for preview environment
// This would integrate with the existing deployment engine
c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
}
// handleGetPreviewEnvironment retrieves a specific preview environment
func handleGetPreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
envIDStr := c.Param("id")
envID, err := uuid.Parse(envIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get preview environment with project ownership check
var env PreviewEnvironment
var serviceName, serviceType string
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&env.ServiceID, &serviceName, &serviceType,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Populate service info if available
if env.ServiceID != uuid.Nil {
env.Service = &Service{
ID: env.ServiceID,
Name: serviceName,
Type: serviceType,
}
}
c.JSON(http.StatusOK, gin.H{"preview_environment": env})
}
// handleUpdatePreviewEnvironment updates a preview environment
func handleUpdatePreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
envIDStr := c.Param("id")
envID, err := uuid.Parse(envIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
return
}
var req UpdatePreviewEnvironmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if preview environment exists and user has access
var existingEnv PreviewEnvironment
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
).Scan(
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
&existingEnv.ExpiresAt, &existingEnv.CreatedAt, &existingEnv.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Update fields if provided
if req.Status != "" {
existingEnv.Status = req.Status
}
if req.URL != "" {
existingEnv.URL = req.URL
}
if req.ExpiresAt != nil {
existingEnv.ExpiresAt = req.ExpiresAt
}
if req.TTLHours > 0 {
newExpiresAt := time.Now().Add(time.Duration(req.TTLHours) * time.Hour)
existingEnv.ExpiresAt = &newExpiresAt
}
existingEnv.UpdatedAt = time.Now()
// Update preview environment in database
_, err = db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = $1, url = $2, expires_at = $3, updated_at = $4
WHERE id = $5`,
existingEnv.Status, existingEnv.URL, existingEnv.ExpiresAt, existingEnv.UpdatedAt, existingEnv.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment"})
return
}
c.JSON(http.StatusOK, gin.H{"preview_environment": existingEnv})
}
// handleDeletePreviewEnvironment deletes a preview environment
func handleDeletePreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
envIDStr := c.Param("id")
envID, err := uuid.Parse(envIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if preview environment exists and user has access
var projectOwnerID string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1`,
envID,
).Scan(&projectOwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Check if user owns the project
if projectOwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// TODO: Clean up deployment and resources associated with this preview environment
// This would integrate with the deployment engine to stop containers, clean up resources, etc.
// Delete preview environment
_, err = db.(*database.DB).Exec(
"DELETE FROM preview_environments WHERE id = $1",
envID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete preview environment"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Preview environment deleted successfully"})
}
// handlePromotePreviewEnvironment promotes a preview environment to production/development
func handlePromotePreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
envIDStr := c.Param("id")
envID, err := uuid.Parse(envIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
return
}
var req PromotePreviewEnvironmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get preview environment details
var env PreviewEnvironment
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Check if preview environment is in a state that can be promoted
if env.Status != "running" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Preview environment must be running to promote"})
return
}
// TODO: Implement promotion logic
// 1. Create backup of target environment if requested
// 2. Deploy preview environment code to target environment
// 3. Update service configuration
// 4. Trigger deployment pipeline
// For now, just return success with promotion details
c.JSON(http.StatusOK, gin.H{
"message": "Preview environment promotion initiated",
"promotion": map[string]interface{}{
"preview_environment_id": env.ID,
"target_environment": req.TargetEnvironment,
"branch_name": env.BranchName,
"create_backup": req.CreateBackup,
"status": "initiated",
},
})
}
// generatePreviewEnvironmentName generates a unique environment name for preview
func generatePreviewEnvironmentName(branchName string) string {
timestamp := time.Now().Format("20060102-150405")
// Sanitize branch name
sanitizedBranch := strings.ReplaceAll(branchName, "/", "-")
sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-")
return fmt.Sprintf("preview-%s-%s", sanitizedBranch, timestamp)
}
// handleCleanupExpiredPreviewEnvironments cleans up expired preview environments
func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Find expired preview environments for user's projects
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
return
}
defer rows.Close()
var expiredEnvs []PreviewEnvironment
for rows.Next() {
var env PreviewEnvironment
err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment,
)
if err != nil {
continue
}
expiredEnvs = append(expiredEnvs, env)
}
// Mark expired environments as expired and trigger cleanup
cleanupCount := 0
for _, env := range expiredEnvs {
// Update status to expired
_, err := db.(*database.DB).Exec(
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
env.ID,
)
if err != nil {
continue
}
// TODO: Trigger cleanup of deployment resources
// This would stop containers, clean up resources, etc.
cleanupCount++
}
c.JSON(http.StatusOK, gin.H{
"message": "Cleanup completed",
"cleaned_count": cleanupCount,
"expired_environments": expiredEnvs,
})
}
+396
View File
@@ -0,0 +1,396 @@
package api
import (
"containr/internal/database"
"context"
"database/sql"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ProjectStats struct {
ServiceCount int `json:"service_count"`
DeploymentCount int `json:"deployment_count"`
RunningServices int `json:"running_services"`
LastDeployment *string `json:"last_deployment"`
}
type ProjectWithStats struct {
Project
Stats ProjectStats `json:"stats"`
}
type CreateProjectRequest struct {
Name string `json:"name" binding:"required,min=2"`
Description string `json:"description"`
}
type UpdateProjectRequest struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}
func handleGetProjects(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
// Get pagination parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
search := c.DefaultQuery("search", "")
// Validate and limit pagination
if page < 1 {
page = 1
}
if limit > 100 || limit < 1 {
limit = 10
}
offset := (page - 1) * limit
// Use the optimized view for better performance
var query string
var args []interface{}
if search != "" {
// Search query with pattern matching
query = `
SELECT id, name, description, owner_id, created_at, updated_at
FROM project_stats
WHERE (owner_id = $1 OR id IN (
SELECT DISTINCT project_id FROM project_members WHERE user_id = $1
)) AND (name ILIKE $2 OR description ILIKE $2)
ORDER BY updated_at DESC
LIMIT $3 OFFSET $4
`
args = []interface{}{userID, "%" + search + "%", limit, offset}
} else {
// Optimized query using the view
query = `
SELECT id, name, description, owner_id, created_at, updated_at
FROM project_stats
WHERE owner_id = $1 OR id IN (
SELECT DISTINCT project_id FROM project_members WHERE user_id = $1
)
ORDER BY updated_at DESC
LIMIT $2 OFFSET $3
`
args = []interface{}{userID, limit, offset}
}
// Execute query with timeout context
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database query error"})
return
}
defer rows.Close()
var projects []ProjectWithStats
for rows.Next() {
var project ProjectWithStats
if err := rows.Scan(&project.ID, &project.Name, &project.Description, &project.OwnerID, &project.CreatedAt, &project.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database scan error"})
return
}
projects = append(projects, project)
}
// Get total count with optimized query
var totalQuery string
var totalArgs []interface{}
if search != "" {
totalQuery = `
SELECT COUNT(DISTINCT id)
FROM project_stats
WHERE (owner_id = $1 OR id IN (
SELECT DISTINCT project_id FROM project_members WHERE user_id = $1
)) AND (name ILIKE $2 OR description ILIKE $2)
`
totalArgs = []interface{}{userID, "%" + search + "%"}
} else {
totalQuery = `
SELECT COUNT(DISTINCT id)
FROM project_stats
WHERE owner_id = $1 OR id IN (
SELECT DISTINCT project_id FROM project_members WHERE user_id = $1
)
`
totalArgs = []interface{}{userID}
}
var total int
err = db.QueryRowContext(ctx, totalQuery, totalArgs...).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
return
}
// Batch fetch stats for all projects
if len(projects) > 0 {
projectIDs := make([]string, len(projects))
for i, p := range projects {
projectIDs[i] = p.ID
}
statsMap := getBatchProjectStats(ctx, db, projectIDs)
for i := range projects {
if stats, exists := statsMap[projects[i].ID]; exists {
projects[i].Stats = stats
}
}
}
c.JSON(http.StatusOK, gin.H{
"projects": projects,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"pages": (total + limit - 1) / limit,
},
})
}
// getBatchProjectStats fetches stats for multiple projects efficiently
func getBatchProjectStats(ctx context.Context, db *database.DB, projectIDs []string) map[string]ProjectStats {
if len(projectIDs) == 0 {
return make(map[string]ProjectStats)
}
// Create placeholders for IN clause
placeholders := make([]string, len(projectIDs))
args := make([]interface{}, len(projectIDs))
for i, id := range projectIDs {
placeholders[i] = "$" + strconv.Itoa(i+1)
args[i] = id
}
query := `
SELECT
project_id,
COUNT(DISTINCT id) as service_count,
COUNT(DISTINCT deployment_id) as deployment_count,
COUNT(DISTINCT CASE WHEN status = 'running' THEN id END) as running_services,
MAX(last_deployment) as last_deployment
FROM (
SELECT
s.project_id,
s.id,
d.id as deployment_id,
s.status,
d.created_at as last_deployment
FROM services s
LEFT JOIN deployments d ON s.id = d.service_id
WHERE s.project_id IN (` + strings.Join(placeholders, ",") + `)
) sub
GROUP BY project_id
`
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return make(map[string]ProjectStats)
}
defer rows.Close()
statsMap := make(map[string]ProjectStats)
for rows.Next() {
var projectID string
var stats ProjectStats
var lastDeployment sql.NullTime
err := rows.Scan(&projectID, &stats.ServiceCount, &stats.DeploymentCount, &stats.RunningServices, &lastDeployment)
if err != nil {
continue
}
if lastDeployment.Valid {
deploymentStr := lastDeployment.Time.Format(time.RFC3339)
stats.LastDeployment = &deploymentStr
}
statsMap[projectID] = stats
}
return statsMap
}
func handleCreateProject(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var project Project
err := db.QueryRow(`
INSERT INTO projects (name, description, owner_id)
VALUES ($1, $2, $3)
RETURNING id, name, description, owner_id, created_at, updated_at
`, req.Name, req.Description, userID).Scan(&project.ID, &project.Name, &project.Description, &project.OwnerID, &project.CreatedAt, &project.UpdatedAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
return
}
// Create default environments
environments := []string{"production", "preview", "development"}
for _, env := range environments {
_, err = db.Exec(`
INSERT INTO environments (name, project_id)
VALUES ($1, $2)
`, env, project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create environments"})
return
}
}
c.JSON(http.StatusCreated, project)
}
func handleGetProject(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
projectID := c.Param("id")
// Validate UUID
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
var project Project
err := db.QueryRow(`
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
FROM projects p
WHERE p.id = $1 AND (p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
`, projectID, userID).Scan(&project.ID, &project.Name, &project.Description, &project.OwnerID, &project.CreatedAt, &project.UpdatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, project)
}
func handleUpdateProject(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
projectID := c.Param("id")
// Validate UUID
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
// Check if user is owner or admin
var role string
err := db.QueryRow(`
SELECT CASE
WHEN p.owner_id = $1 THEN 'owner'
ELSE pm.role
END as role
FROM projects p
LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1
WHERE p.id = $2
`, userID, projectID).Scan(&role)
if err == sql.ErrNoRows || role == "" || role == "viewer" {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var req UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err = db.Exec(`
UPDATE projects
SET name = COALESCE($1, name), description = COALESCE($2, description)
WHERE id = $3
`, req.Name, req.Description, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"})
return
}
// Return updated project
handleGetProject(c)
}
func handleDeleteProject(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
projectID := c.Param("id")
// Validate UUID
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
// Check if user is owner
var ownerID string
err := db.QueryRow("SELECT owner_id FROM projects WHERE id = $1", projectID).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
return
}
// Delete project (cascading deletes will handle related records)
_, err = db.Exec("DELETE FROM projects WHERE id = $1", projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"})
}
+480
View File
@@ -0,0 +1,480 @@
package api
import (
"net/http"
"strconv"
"containr/internal/proxmox"
"github.com/gin-gonic/gin"
)
// ProxmoxHandler handles Proxmox-related API endpoints
type ProxmoxHandler struct {
service *proxmox.Service
}
// NewProxmoxHandler creates a new Proxmox handler
func NewProxmoxHandler(service *proxmox.Service) *ProxmoxHandler {
return &ProxmoxHandler{
service: service,
}
}
// RegisterProxmoxRoutes registers Proxmox API routes
func RegisterProxmoxRoutes(router *gin.Engine, service *proxmox.Service) {
handler := NewProxmoxHandler(service)
proxmox := router.Group("/api/proxmox")
{
// Cluster and node management
proxmox.GET("/cluster/status", handler.getClusterStatus)
proxmox.GET("/nodes", handler.getNodes)
proxmox.GET("/nodes/:nodeName/stats", handler.getNodeStats)
proxmox.GET("/nodes/:nodeName/templates", handler.getTemplates)
// VM management
proxmox.GET("/vms", handler.getAllVMs)
proxmox.GET("/vms/:vmid/status", handler.getVMStatus)
proxmox.POST("/vms", handler.createVM)
proxmox.POST("/vms/:vmid/start", handler.startVM)
proxmox.POST("/vms/:vmid/stop", handler.stopVM)
proxmox.DELETE("/vms/:vmid", handler.deleteVM)
// Container management
proxmox.GET("/containers", handler.getAllContainers)
proxmox.POST("/containers", handler.createContainer)
proxmox.POST("/containers/:vmid/start", handler.startContainer)
proxmox.POST("/containers/:vmid/stop", handler.stopContainer)
proxmox.DELETE("/containers/:vmid", handler.deleteContainer)
// Resource management
proxmox.GET("/resources/usage", handler.getResourceUsage)
proxmox.GET("/health", handler.healthCheck)
}
}
// getClusterStatus returns the overall cluster status
func (h *ProxmoxHandler) getClusterStatus(c *gin.Context) {
status, err := h.service.GetClusterStatus()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": status})
}
// getNodes returns all nodes in the cluster
func (h *ProxmoxHandler) getNodes(c *gin.Context) {
nodes, err := h.service.GetAllNodes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": nodes})
}
// getNodeStats returns detailed statistics for a specific node
func (h *ProxmoxHandler) getNodeStats(c *gin.Context) {
nodeName := c.Param("nodeName")
stats, err := h.service.GetNodeStats(nodeName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": stats})
}
// getTemplates returns available VM and container templates
func (h *ProxmoxHandler) getTemplates(c *gin.Context) {
nodeName := c.Param("nodeName")
templates, err := h.service.GetAvailableTemplates(nodeName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": templates})
}
// getAllVMs returns all VMs across all nodes
func (h *ProxmoxHandler) getAllVMs(c *gin.Context) {
vms, err := h.service.GetAllVMs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": vms})
}
// getVMStatus returns the status of a specific VM
func (h *ProxmoxHandler) getVMStatus(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid VM ID"})
return
}
// For now, we'll need to determine the node - this could be improved
// by maintaining a VM-to-node mapping
nodes, err := h.service.GetAllNodes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Try each node until we find the VM
for _, node := range nodes {
if node.Status == "online" {
status, err := h.service.GetInstanceStatus(node.Node, vmid, "qemu")
if err == nil {
c.JSON(http.StatusOK, gin.H{"data": status})
return
}
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "VM not found"})
}
// createVM creates a new VM
func (h *ProxmoxHandler) createVM(c *gin.Context) {
var config proxmox.ServiceVMConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// For now, use the first available online node
// In a production system, you'd want smarter node selection
nodes, err := h.service.GetAllNodes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var targetNode string
for _, node := range nodes {
if node.Status == "online" {
targetNode = node.Node
break
}
}
if targetNode == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "No online nodes available"})
return
}
vm, err := h.service.CreateServiceVM(targetNode, config)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": vm})
}
// startVM starts a VM
func (h *ProxmoxHandler) startVM(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid VM ID"})
return
}
// Find which node the VM is on
vms, err := h.service.GetAllVMs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, vm := range vms {
if vm.VMID == vmid {
nodeName = vm.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "VM not found"})
return
}
err = h.service.StartInstance(nodeName, vmid, "qemu")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "VM started successfully"})
}
// stopVM stops a VM
func (h *ProxmoxHandler) stopVM(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid VM ID"})
return
}
// Find which node the VM is on
vms, err := h.service.GetAllVMs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, vm := range vms {
if vm.VMID == vmid {
nodeName = vm.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "VM not found"})
return
}
err = h.service.StopInstance(nodeName, vmid, "qemu")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "VM stopped successfully"})
}
// deleteVM deletes a VM
func (h *ProxmoxHandler) deleteVM(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid VM ID"})
return
}
// Find which node the VM is on
vms, err := h.service.GetAllVMs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, vm := range vms {
if vm.VMID == vmid {
nodeName = vm.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "VM not found"})
return
}
err = h.service.DeleteInstance(nodeName, vmid, "qemu")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "VM deleted successfully"})
}
// getAllContainers returns all containers across all nodes
func (h *ProxmoxHandler) getAllContainers(c *gin.Context) {
containers, err := h.service.GetAllContainers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": containers})
}
// createContainer creates a new container
func (h *ProxmoxHandler) createContainer(c *gin.Context) {
var config proxmox.ServiceContainerConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// For now, use the first available online node
nodes, err := h.service.GetAllNodes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var targetNode string
for _, node := range nodes {
if node.Status == "online" {
targetNode = node.Node
break
}
}
if targetNode == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "No online nodes available"})
return
}
container, err := h.service.CreateServiceContainer(targetNode, config)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": container})
}
// startContainer starts a container
func (h *ProxmoxHandler) startContainer(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid container ID"})
return
}
// Find which node the container is on
containers, err := h.service.GetAllContainers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, container := range containers {
if container.VMID == vmid {
nodeName = container.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
err = h.service.StartInstance(nodeName, vmid, "lxc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Container started successfully"})
}
// stopContainer stops a container
func (h *ProxmoxHandler) stopContainer(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid container ID"})
return
}
// Find which node the container is on
containers, err := h.service.GetAllContainers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, container := range containers {
if container.VMID == vmid {
nodeName = container.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
err = h.service.StopInstance(nodeName, vmid, "lxc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Container stopped successfully"})
}
// deleteContainer deletes a container
func (h *ProxmoxHandler) deleteContainer(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid container ID"})
return
}
// Find which node the container is on
containers, err := h.service.GetAllContainers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, container := range containers {
if container.VMID == vmid {
nodeName = container.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
err = h.service.DeleteInstance(nodeName, vmid, "lxc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Container deleted successfully"})
}
// getResourceUsage returns resource usage across the cluster
func (h *ProxmoxHandler) getResourceUsage(c *gin.Context) {
usage, err := h.service.GetResourceUsage()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": usage})
}
// healthCheck validates the connection to Proxmox
func (h *ProxmoxHandler) healthCheck(c *gin.Context) {
err := h.service.ValidateConnection()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unhealthy",
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"message": "Proxmox connection is working",
})
}
+233
View File
@@ -0,0 +1,233 @@
package api
import (
"containr/internal/build"
"containr/internal/config"
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/docker"
"containr/internal/metrics"
"containr/internal/middleware"
"containr/internal/proxmox"
"containr/internal/scaling"
"github.com/gin-gonic/gin"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
// Initialize Docker client
dockerClient, err := docker.NewClient()
if err != nil {
panic("Failed to initialize Docker client: " + err.Error())
}
// Initialize build manager
buildManager := build.NewBuildManager("/tmp/containr-builds", dockerClient)
// Initialize build handler
buildHandler := NewBuildHandler(buildManager, dockerClient)
// Initialize scheduler and metrics systems
scheduler := deployment.NewScheduler()
metricsStorage := metrics.NewInMemoryMetricsStorage() // Use in-memory for now
metricsCollector := metrics.NewMetricsCollector(scheduler, metricsStorage)
autoScaler := scaling.NewAutoScaler(scheduler, metricsCollector)
// Initialize scaling handler
scalingHandler := NewScalingHandler(autoScaler)
// Initialize GORM for agent system
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
if err != nil {
panic("Failed to initialize GORM: " + err.Error())
}
// Initialize agent handler
agentHandler := NewNodeAgentHandler(gormDB)
// Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB)
// Initialize security handler
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
// Initialize Proxmox service if configured
var proxmoxService *proxmox.Service
if cfg.Proxmox.BaseURL != "" {
proxmoxConfig := proxmox.Config{
BaseURL: cfg.Proxmox.BaseURL,
Username: cfg.Proxmox.Username,
Password: cfg.Proxmox.Password,
TokenID: cfg.Proxmox.TokenID,
Token: cfg.Proxmox.Token,
}
proxmoxService = proxmox.NewService(proxmoxConfig)
// Register Proxmox routes
RegisterProxmoxRoutes(router, proxmoxService)
}
// Add database and JWT secret to gin context for handlers
router.Use(func(c *gin.Context) {
c.Set("db", db)
c.Set("redis", redis)
c.Set("jwt_secret", cfg.JWTSecret)
c.Set("docker_client", dockerClient)
c.Set("build_manager", buildManager)
c.Set("scheduler", scheduler)
c.Set("metrics_collector", metricsCollector)
c.Set("auto_scaler", autoScaler)
c.Set("scaling_handler", scalingHandler)
c.Set("gorm_db", gormDB)
if proxmoxService != nil {
c.Set("proxmox", proxmoxService)
}
c.Next()
})
// Health check endpoint
router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"service": "containr-api",
})
})
// API v1 routes
v1 := router.Group("/api/v1")
{
// Public routes (no authentication required)
public := v1.Group("/")
{
public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister)
}
// Protected routes (authentication required)
protected := v1.Group("/")
protected.Use(middleware.Auth(cfg.JWTSecret))
{
// User routes
protected.GET("/user/profile", handleGetProfile)
protected.PUT("/user/profile", handleUpdateProfile)
// Project routes
protected.GET("/projects", handleGetProjects)
protected.POST("/projects", handleCreateProject)
protected.GET("/projects/:id", handleGetProject)
protected.PUT("/projects/:id", handleUpdateProject)
protected.DELETE("/projects/:id", handleDeleteProject)
// Service routes
protected.GET("/projects/:project_id/services", handleGetServices)
protected.POST("/projects/:project_id/services", handleCreateService)
protected.GET("/services/:id", handleGetService)
protected.PUT("/services/:id", handleUpdateService)
protected.DELETE("/services/:id", handleDeleteService)
// Deployment routes
protected.GET("/services/:service_id/deployments", handleGetDeployments)
protected.POST("/services/:service_id/deployments", handleCreateDeployment)
protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
// Environment variables routes
protected.GET("/services/:service_id/variables", handleGetVariables)
protected.PUT("/services/:service_id/variables", handleUpdateVariables)
// Logs routes
protected.GET("/services/:service_id/logs", handleGetLogs)
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// Git integration routes
protected.GET("/git/providers", handleGetGitProviders)
protected.POST("/git/providers", handleCreateGitProvider)
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
protected.POST("/git/repositories/connect", handleConnectGitRepository)
protected.GET("/git/repositories", handleGetConnectedRepositories)
protected.POST("/git/webhooks", handleCreateWebhook)
// Build routes
protected.POST("/builds", buildHandler.StartBuild)
protected.GET("/builds", buildHandler.ListBuilds)
protected.GET("/builds/:id", buildHandler.GetBuildStatus)
protected.POST("/builds/:id/cancel", buildHandler.CancelBuild)
protected.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
protected.GET("/builds/detect", buildHandler.DetectBuildType)
// Scaling routes
scalingHandler.RegisterRoutes(protected)
// Database routes
protected.GET("/databases", databaseHandler.GetDatabases)
protected.POST("/databases", databaseHandler.CreateDatabase)
protected.GET("/databases/:id", databaseHandler.GetDatabase)
protected.PUT("/databases/:id", databaseHandler.UpdateDatabase)
protected.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
// Node Agent routes
api := router.Group("/api")
agentHandler.SetupRoutes(api)
// Preview Environments routes
protected.GET("/projects/:project_id/preview-environments", handleGetPreviewEnvironments)
protected.POST("/projects/:project_id/preview-environments", handleCreatePreviewEnvironment)
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
protected.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
protected.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// Security routes
protected.POST("/security/scans", securityHandler.StartSecurityScan)
protected.GET("/security/scans/:scanId", securityHandler.GetSecurityScan)
protected.GET("/projects/:projectId/security/history", securityHandler.GetProjectSecurityHistory)
protected.GET("/projects/:projectId/vulnerabilities", securityHandler.GetVulnerabilities)
protected.PUT("/vulnerabilities/:vulnId", securityHandler.UpdateVulnerability)
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
protected.GET("/security/compliance/reports/:reportId", securityHandler.GetComplianceReport)
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
protected.GET("/projects/:projectId/security/metrics", securityHandler.GetSecurityMetrics)
protected.GET("/projects/:projectId/security/audit-logs", securityHandler.GetAuditLogs)
}
}
}
func handleGetDeployments(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleCreateDeployment(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetDeployment(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleRollbackDeployment(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetVariables(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleUpdateVariables(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetLogs(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
func handleGetDeploymentLogs(c *gin.Context) {
c.JSON(501, gin.H{"error": "Not implemented yet"})
}
+455
View File
@@ -0,0 +1,455 @@
package api
import (
"net/http"
"strconv"
"time"
"containr/internal/scaling"
"github.com/gin-gonic/gin"
)
// ScalingHandler handles scaling-related API endpoints
type ScalingHandler struct {
autoScaler *scaling.AutoScaler
}
// NewScalingHandler creates a new scaling handler
func NewScalingHandler(autoScaler *scaling.AutoScaler) *ScalingHandler {
return &ScalingHandler{
autoScaler: autoScaler,
}
}
// RegisterRoutes registers scaling routes
func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.GET("/policies", h.GetScalingPolicies)
scaling.POST("/policies", h.SetScalingPolicy)
scaling.GET("/policies/:serviceId", h.GetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.GET("/services", h.GetServiceStates)
scaling.GET("/services/:serviceId", h.GetServiceState)
scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.GET("/status", h.GetScalingStatus)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
scaling.GET("/metrics", h.GetScalingMetrics)
scaling.GET("/events", h.GetScalingEvents)
}
}
// GetScalingPolicies returns all scaling policies
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
states := h.autoScaler.GetAllServiceStates()
policies := make([]*scaling.ScalingPolicy, 0)
for _, state := range states {
if state.Policy != nil {
policies = append(policies, state.Policy)
}
}
c.JSON(http.StatusOK, gin.H{
"policies": policies,
"count": len(policies),
})
}
// SetScalingPolicy creates or updates a scaling policy
func (h *ScalingHandler) SetScalingPolicy(c *gin.Context) {
var policy scaling.ScalingPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.autoScaler.SetScalingPolicy(&policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Scaling policy set successfully",
"policy": policy,
})
}
// GetScalingPolicy returns a specific scaling policy
func (h *ScalingHandler) GetScalingPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
policy, err := h.autoScaler.GetScalingPolicy(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"policy": policy,
})
}
// UpdateScalingPolicy updates an existing scaling policy
func (h *ScalingHandler) UpdateScalingPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
var policy scaling.ScalingPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure the service ID matches
policy.ServiceID = serviceID
if err := h.autoScaler.SetScalingPolicy(&policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Scaling policy updated successfully",
"policy": policy,
})
}
// DeleteScalingPolicy removes a scaling policy
func (h *ScalingHandler) DeleteScalingPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
// Set policy to disabled instead of deleting
policy, err := h.autoScaler.GetScalingPolicy(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
policy.Enabled = false
if err := h.autoScaler.SetScalingPolicy(policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Scaling policy disabled successfully",
})
}
// GetServiceStates returns all service scaling states
func (h *ScalingHandler) GetServiceStates(c *gin.Context) {
states := h.autoScaler.GetAllServiceStates()
c.JSON(http.StatusOK, gin.H{
"services": states,
"count": len(states),
})
}
// GetServiceState returns a specific service's scaling state
func (h *ScalingHandler) GetServiceState(c *gin.Context) {
serviceID := c.Param("serviceId")
state, err := h.autoScaler.GetServiceState(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"state": state,
})
}
// GetScalingHistory returns scaling history for a service
func (h *ScalingHandler) GetScalingHistory(c *gin.Context) {
serviceID := c.Param("serviceId")
// TODO: Implement scaling history retrieval from database
// For now, return mock data
history := []map[string]interface{}{
{
"timestamp": time.Now().Add(-2 * time.Hour),
"action": "scale_up",
"from": 2,
"to": 3,
"reason": "CPU usage above target",
},
{
"timestamp": time.Now().Add(-1 * time.Hour),
"action": "scale_down",
"from": 3,
"to": 2,
"reason": "CPU usage below target",
},
}
c.JSON(http.StatusOK, gin.H{
"service_id": serviceID,
"history": history,
"count": len(history),
})
}
// ManualScale performs manual scaling of a service
func (h *ScalingHandler) ManualScale(c *gin.Context) {
serviceID := c.Param("serviceId")
var request struct {
Replicas int `json:"replicas" binding:"required,min=1,max=20"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Implement manual scaling
// This would bypass the auto-scaler and directly scale the service
c.JSON(http.StatusAccepted, gin.H{
"message": "Manual scaling initiated",
"service_id": serviceID,
"replicas": request.Replicas,
"reason": request.Reason,
})
}
// GetScalingStatus returns the overall status of the auto-scaler
func (h *ScalingHandler) GetScalingStatus(c *gin.Context) {
summary := h.autoScaler.GetScalingSummary()
c.JSON(http.StatusOK, gin.H{
"status": "active",
"summary": summary,
"enabled": h.autoScaler.IsEnabled(),
})
}
// EnableAutoScaler enables the auto-scaler
func (h *ScalingHandler) EnableAutoScaler(c *gin.Context) {
h.autoScaler.Enable()
c.JSON(http.StatusOK, gin.H{
"message": "Auto-scaler enabled",
"enabled": true,
})
}
// DisableAutoScaler disables the auto-scaler
func (h *ScalingHandler) DisableAutoScaler(c *gin.Context) {
h.autoScaler.Disable()
c.JSON(http.StatusOK, gin.H{
"message": "Auto-scaler disabled",
"enabled": false,
})
}
// GetScalingMetrics returns scaling-related metrics
func (h *ScalingHandler) GetScalingMetrics(c *gin.Context) {
summary := h.autoScaler.GetScalingSummary()
// Add additional metrics
metrics := map[string]interface{}{
"total_services": summary["total_services"],
"enabled_services": summary["enabled_services"],
"total_replicas": summary["total_replicas"],
"services_scaling_up": summary["scaling_up"],
"services_scaling_down": summary["scaling_down"],
"auto_scaler_enabled": summary["enabled"],
"check_interval_seconds": summary["check_interval"],
"timestamp": time.Now(),
}
c.JSON(http.StatusOK, gin.H{
"metrics": metrics,
})
}
// GetScalingEvents returns recent scaling events
func (h *ScalingHandler) GetScalingEvents(c *gin.Context) {
// Parse query parameters
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 50
}
// TODO: Implement scaling events retrieval from database
// For now, return mock data
events := []map[string]interface{}{
{
"id": "evt_1",
"service_id": "web-service",
"action": "scale_up",
"from": 2,
"to": 3,
"reason": "CPU usage (85%) above target (70%)",
"timestamp": time.Now().Add(-30 * time.Minute),
"cost_impact": 0.01,
},
{
"id": "evt_2",
"service_id": "api-service",
"action": "scale_down",
"from": 5,
"to": 3,
"reason": "Low request rate (10/s)",
"timestamp": time.Now().Add(-1 * time.Hour),
"cost_impact": -0.02,
},
}
// Limit results
if len(events) > limit {
events = events[:limit]
}
c.JSON(http.StatusOK, gin.H{
"events": events,
"count": len(events),
"limit": limit,
})
}
// ScalingPolicyTemplate represents a template for creating scaling policies
type ScalingPolicyTemplate struct {
Name string `json:"name"`
Description string `json:"description"`
Template scaling.ScalingPolicy `json:"template"`
}
// GetScalingPolicyTemplates returns predefined scaling policy templates
func (h *ScalingHandler) GetScalingPolicyTemplates(c *gin.Context) {
templates := []ScalingPolicyTemplate{
{
Name: "Web Application",
Description: "Standard scaling policy for web applications",
Template: scaling.ScalingPolicy{
MinReplicas: 2,
MaxReplicas: 10,
TargetCPU: 70.0,
TargetMemory: 80.0,
ScaleUpCooldown: 3 * time.Minute,
ScaleDownCooldown: 5 * time.Minute,
ScaleUpStep: 1,
ScaleDownStep: 1,
Metrics: []string{"cpu", "memory", "requests_per_second"},
Enabled: true,
CostOptimization: &scaling.CostOptimization{
MaxCostPerHour: 1.0,
PreferEfficiency: true,
IdleTimeout: 10 * time.Minute,
},
},
},
{
Name: "API Service",
Description: "Aggressive scaling for API services",
Template: scaling.ScalingPolicy{
MinReplicas: 1,
MaxReplicas: 20,
TargetCPU: 60.0,
TargetMemory: 75.0,
ScaleUpCooldown: 1 * time.Minute,
ScaleDownCooldown: 3 * time.Minute,
ScaleUpStep: 2,
ScaleDownStep: 1,
Metrics: []string{"cpu", "memory", "requests_per_second", "error_rate"},
Enabled: true,
CostOptimization: &scaling.CostOptimization{
MaxCostPerHour: 2.0,
PreferEfficiency: false,
IdleTimeout: 5 * time.Minute,
},
},
},
{
Name: "Background Worker",
Description: "Conservative scaling for background workers",
Template: scaling.ScalingPolicy{
MinReplicas: 1,
MaxReplicas: 5,
TargetCPU: 80.0,
TargetMemory: 85.0,
ScaleUpCooldown: 5 * time.Minute,
ScaleDownCooldown: 10 * time.Minute,
ScaleUpStep: 1,
ScaleDownStep: 1,
Metrics: []string{"cpu", "memory"},
Enabled: true,
CostOptimization: &scaling.CostOptimization{
MaxCostPerHour: 0.5,
PreferEfficiency: true,
IdleTimeout: 15 * time.Minute,
},
},
},
}
c.JSON(http.StatusOK, gin.H{
"templates": templates,
"count": len(templates),
})
}
// ValidateScalingPolicy validates a scaling policy
func (h *ScalingHandler) ValidateScalingPolicy(c *gin.Context) {
var policy scaling.ScalingPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
errors := []string{}
warnings := []string{}
// Validate policy
if policy.MinReplicas < 1 {
errors = append(errors, "min_replicas must be at least 1")
}
if policy.MaxReplicas < policy.MinReplicas {
errors = append(errors, "max_replicas must be greater than or equal to min_replicas")
}
if policy.TargetCPU <= 0 || policy.TargetCPU > 100 {
errors = append(errors, "target_cpu must be between 0 and 100")
}
if policy.TargetMemory <= 0 || policy.TargetMemory > 100 {
errors = append(errors, "target_memory must be between 0 and 100")
}
if policy.ScaleUpStep < 1 {
errors = append(errors, "scale_up_step must be at least 1")
}
if policy.ScaleDownStep < 1 {
errors = append(errors, "scale_down_step must be at least 1")
}
// Warnings
if policy.MaxReplicas > 20 {
warnings = append(warnings, "max_replicas greater than 20 may be costly")
}
if policy.ScaleUpCooldown < 1*time.Minute {
warnings = append(warnings, "scale_up_cooldown less than 1 minute may cause thrashing")
}
if policy.ScaleDownCooldown < 2*time.Minute {
warnings = append(warnings, "scale_down_cooldown less than 2 minutes may cause thrashing")
}
valid := len(errors) == 0
c.JSON(http.StatusOK, gin.H{
"valid": valid,
"errors": errors,
"warnings": warnings,
})
}
+423
View File
@@ -0,0 +1,423 @@
package api
import (
"containr/internal/database"
"containr/internal/security"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// SecurityHandler handles security-related API endpoints
type SecurityHandler struct {
db *database.DB
scanner *security.Scanner
complianceManager *security.ComplianceManager
encryptionManager *security.EncryptionManager
dataRetentionManager *security.DataRetentionManager
auditLogger *security.AuditLogger
}
// NewSecurityHandler creates a new security handler
func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler {
encryptionManager, _ := security.NewEncryptionManager(encryptionKey)
return &SecurityHandler{
db: db,
scanner: security.NewScanner(db),
complianceManager: security.NewComplianceManager(db),
encryptionManager: encryptionManager,
dataRetentionManager: security.NewDataRetentionManager(encryptionManager),
auditLogger: security.NewAuditLogger(encryptionManager),
}
}
// StartSecurityScan starts a new security scan
func (sh *SecurityHandler) StartSecurityScan(c *gin.Context) {
var req struct {
ProjectID string `json:"project_id" binding:"required"`
ServiceID string `json:"service_id,omitempty"`
ScanType string `json:"scan_type" binding:"required,oneof=dependency configuration comprehensive"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.MustGet("user_id").(string)
// Log audit event
sh.auditLogger.LogSecurityEvent(userID, "security_scan_started", "project",
map[string]interface{}{
"project_id": req.ProjectID,
"service_id": req.ServiceID,
"scan_type": req.ScanType,
}, c.ClientIP(), c.GetHeader("User-Agent"), true)
scan, err := sh.scanner.StartSecurityScan(req.ProjectID, req.ServiceID, req.ScanType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start security scan"})
return
}
c.JSON(http.StatusAccepted, scan)
}
// GetSecurityScan retrieves a security scan
func (sh *SecurityHandler) GetSecurityScan(c *gin.Context) {
scanID := c.Param("scanId")
scan, err := sh.scanner.GetSecurityScan(scanID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"})
return
}
c.JSON(http.StatusOK, scan)
}
// GetProjectSecurityHistory retrieves security scan history for a project
func (sh *SecurityHandler) GetProjectSecurityHistory(c *gin.Context) {
projectID := c.Param("projectId")
limit := 10
if limitStr := c.Query("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
}
}
scans, err := sh.scanner.GetProjectSecurityHistory(projectID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get security history"})
return
}
c.JSON(http.StatusOK, gin.H{"scans": scans})
}
// GetVulnerabilities retrieves vulnerabilities for a project
func (sh *SecurityHandler) GetVulnerabilities(c *gin.Context) {
projectID := c.Param("projectId")
// Query vulnerabilities
rows, err := sh.db.Query(`
SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at
FROM vulnerabilities
WHERE project_id = $1
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
found_at DESC
`, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerabilities"})
return
}
defer rows.Close()
var vulnerabilities []security.Vulnerability
for rows.Next() {
var vuln security.Vulnerability
var resolvedAt *time.Time
err := rows.Scan(&vuln.ID, &vuln.Type, &vuln.Severity, &vuln.Title, &vuln.Description,
&vuln.ServiceID, &vuln.Status, &vuln.FoundAt, &resolvedAt)
if err != nil {
continue
}
vuln.ResolvedAt = resolvedAt
vulnerabilities = append(vulnerabilities, vuln)
}
c.JSON(http.StatusOK, gin.H{"vulnerabilities": vulnerabilities})
}
// UpdateVulnerability updates a vulnerability status
func (sh *SecurityHandler) UpdateVulnerability(c *gin.Context) {
vulnID := c.Param("vulnId")
userID := c.MustGet("user_id").(string)
var req struct {
Status string `json:"status" binding:"required,oneof=open resolved ignored"`
Notes string `json:"notes,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var resolvedAt *time.Time
if req.Status == "resolved" {
now := time.Now()
resolvedAt = &now
}
_, err := sh.db.Exec(`
UPDATE vulnerabilities
SET status = $1, resolved_at = $2
WHERE id = $3
`, req.Status, resolvedAt, vulnID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update vulnerability"})
return
}
// Log audit event
sh.auditLogger.LogSecurityEvent(userID, "vulnerability_updated", "vulnerability",
map[string]interface{}{
"vulnerability_id": vulnID,
"new_status": req.Status,
"notes": req.Notes,
}, c.ClientIP(), c.GetHeader("User-Agent"), true)
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
// StartComplianceAssessment starts a new compliance assessment
func (sh *SecurityHandler) StartComplianceAssessment(c *gin.Context) {
var req struct {
ProjectID string `json:"project_id" binding:"required"`
FrameworkID string `json:"framework_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.MustGet("user_id").(string)
// Log audit event
sh.auditLogger.LogSecurityEvent(userID, "compliance_assessment_started", "project",
map[string]interface{}{
"project_id": req.ProjectID,
"framework_id": req.FrameworkID,
}, c.ClientIP(), c.GetHeader("User-Agent"), true)
report, err := sh.complianceManager.AssessCompliance(req.ProjectID, req.FrameworkID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start compliance assessment"})
return
}
c.JSON(http.StatusAccepted, report)
}
// GetComplianceReport retrieves a compliance report
func (sh *SecurityHandler) GetComplianceReport(c *gin.Context) {
reportID := c.Param("reportId")
report, err := sh.complianceManager.GetComplianceReport(reportID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"})
return
}
c.JSON(http.StatusOK, report)
}
// GetComplianceFrameworks retrieves available compliance frameworks
func (sh *SecurityHandler) GetComplianceFrameworks(c *gin.Context) {
rows, err := sh.db.Query(`
SELECT id, name, description, version, enabled, created_at
FROM compliance_frameworks
WHERE enabled = true
ORDER BY name
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance frameworks"})
return
}
defer rows.Close()
var frameworks []security.ComplianceFramework
for rows.Next() {
var framework security.ComplianceFramework
err := rows.Scan(&framework.ID, &framework.Name, &framework.Description,
&framework.Version, &framework.Enabled, &framework.CreatedAt)
if err != nil {
continue
}
frameworks = append(frameworks, framework)
}
c.JSON(http.StatusOK, gin.H{"frameworks": frameworks})
}
// InitializeGDPRFramework initializes the GDPR compliance framework
func (sh *SecurityHandler) InitializeGDPRFramework(c *gin.Context) {
userID := c.MustGet("user_id").(string)
err := sh.complianceManager.InitializeGDPRFramework()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize GDPR framework"})
return
}
// Log audit event
sh.auditLogger.LogSecurityEvent(userID, "gdpr_framework_initialized", "compliance",
map[string]interface{}{}, c.ClientIP(), c.GetHeader("User-Agent"), true)
c.JSON(http.StatusOK, gin.H{"status": "initialized"})
}
// GetSecurityMetrics retrieves security metrics for a project
func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
projectID := c.Param("projectId")
// Get vulnerability counts
var vulnMetrics struct {
Total int `json:"total"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Open int `json:"open"`
Resolved int `json:"resolved"`
}
sh.db.QueryRow(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE severity = 'critical') as critical,
COUNT(*) FILTER (WHERE severity = 'high') as high,
COUNT(*) FILTER (WHERE severity = 'medium') as medium,
COUNT(*) FILTER (WHERE severity = 'low') as low,
COUNT(*) FILTER (WHERE status = 'open') as open,
COUNT(*) FILTER (WHERE status = 'resolved') as resolved
FROM vulnerabilities
WHERE project_id = $1
`, projectID).Scan(&vulnMetrics.Total, &vulnMetrics.Critical, &vulnMetrics.High,
&vulnMetrics.Medium, &vulnMetrics.Low, &vulnMetrics.Open, &vulnMetrics.Resolved)
// Get latest scan
var latestScan struct {
ID string `json:"id"`
Score int `json:"score"`
ScannedAt time.Time `json:"scanned_at"`
Status string `json:"status"`
}
err := sh.db.QueryRow(`
SELECT id, score, started_at as scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT 1
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
if err != nil {
latestScan = struct {
ID string `json:"id"`
Score int `json:"score"`
ScannedAt time.Time `json:"scanned_at"`
Status string `json:"status"`
}{ID: "", Score: 0, ScannedAt: time.Time{}, Status: "never_scanned"}
}
// Get compliance status
var complianceStatus struct {
OverallStatus string `json:"overall_status"`
Score int `json:"score"`
LastAssessed *time.Time `json:"last_assessed"`
}
err = sh.db.QueryRow(`
SELECT overall_status, score, assessment_date
FROM compliance_reports
WHERE project_id = $1
ORDER BY assessment_date DESC
LIMIT 1
`, projectID).Scan(&complianceStatus.OverallStatus, &complianceStatus.Score, &complianceStatus.LastAssessed)
if err != nil {
complianceStatus = struct {
OverallStatus string `json:"overall_status"`
Score int `json:"score"`
LastAssessed *time.Time `json:"last_assessed"`
}{OverallStatus: "not_assessed", Score: 0, LastAssessed: nil}
}
metrics := gin.H{
"vulnerabilities": vulnMetrics,
"latest_scan": latestScan,
"compliance": complianceStatus,
"security_score": sh.calculateOverallSecurityScore(struct{ Total, Critical, High, Medium, Low, Open, Resolved int }{
Total: vulnMetrics.Total, Critical: vulnMetrics.Critical, High: vulnMetrics.High,
Medium: vulnMetrics.Medium, Low: vulnMetrics.Low, Open: vulnMetrics.Open, Resolved: vulnMetrics.Resolved,
}, latestScan.Score, complianceStatus.Score),
}
c.JSON(http.StatusOK, metrics)
}
// calculateOverallSecurityScore calculates an overall security score
func (sh *SecurityHandler) calculateOverallSecurityScore(vulnMetrics struct {
Total, Critical, High, Medium, Low, Open, Resolved int
}, scanScore, complianceScore int) int {
// Weight the different components
vulnScore := 100
if vulnMetrics.Total > 0 {
deduction := (vulnMetrics.Critical * 25) + (vulnMetrics.High * 15) + (vulnMetrics.Medium * 8) + (vulnMetrics.Low * 3)
vulnScore = max(0, 100-deduction)
}
// Calculate weighted average
overallScore := (vulnScore*40 + scanScore*30 + complianceScore*30) / 100
return overallScore
}
// GetAuditLogs retrieves audit logs for security events
func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
_ = c.Param("projectId") // projectID is available but not used in this implementation
limit := 50
if limitStr := c.Query("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 1000 {
limit = parsedLimit
}
}
// In a real implementation, this would query the audit database
// For now, return a placeholder response
c.JSON(http.StatusOK, gin.H{
"audit_logs": []gin.H{
{
"id": uuid.New().String(),
"timestamp": time.Now(),
"user_id": c.MustGet("user_id").(string),
"action": "security_scan_started",
"resource": "project",
"ip_address": c.ClientIP(),
"success": true,
},
},
"total": 1,
"limit": limit,
})
}
// max helper function
func max(a, b int) int {
if a > b {
return a
}
return b
}
+444
View File
@@ -0,0 +1,444 @@
package api
import (
"containr/internal/database"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Service represents a service in the system
type Service struct {
ID uuid.UUID `json:"id" db:"id"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // web, worker, database, etc.
Status string `json:"status" db:"status"` // building, running, failed, stopped
Image string `json:"image" db:"image"`
Command string `json:"command" db:"command"`
Environment string `json:"environment" db:"environment"` // production, preview, development
GitRepo string `json:"git_repo" db:"git_repo"`
GitBranch string `json:"git_branch" db:"git_branch"`
BuildPath string `json:"build_path" db:"build_path"`
CPU string `json:"cpu" db:"cpu"`
Memory string `json:"memory" db:"memory"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateServiceRequest represents a request to create a service
type CreateServiceRequest struct {
ProjectID uuid.UUID `json:"project_id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=255"`
Type string `json:"type" binding:"required,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"required,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
}
// UpdateServiceRequest represents a request to update a service
type UpdateServiceRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=255"`
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
}
// handleGetServices retrieves all services for a project
func handleGetServices(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectIDStr := c.Param("project_id")
projectID, err := uuid.Parse(projectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// 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
FROM services
WHERE project_id = $1
ORDER BY created_at DESC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve services"})
return
}
defer rows.Close()
var services []Service
for rows.Next() {
var service Service
err := rows.Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
return
}
services = append(services, service)
}
c.JSON(http.StatusOK, gin.H{"services": services})
}
// handleCreateService creates a new service
func handleCreateService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
var req CreateServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if project exists and user has access
var project Project
err := db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
req.ProjectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Check if service name already exists in the project
var count int
err = db.(*database.DB).QueryRow(
"SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2",
req.ProjectID, req.Name,
).Scan(&count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check service name"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in this project"})
return
}
// Create new service
service := Service{
ID: uuid.New(),
ProjectID: req.ProjectID,
Name: req.Name,
Type: req.Type,
Status: "stopped", // Initial status
Image: req.Image,
Command: req.Command,
Environment: req.Environment,
GitRepo: req.GitRepo,
GitBranch: req.GitBranch,
BuildPath: req.BuildPath,
CPU: req.CPU,
Memory: req.Memory,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Set default values if not provided
if service.CPU == "" {
service.CPU = "0.5"
}
if service.Memory == "" {
service.Memory = "512Mi"
}
// Insert service into database
_, err = db.(*database.DB).Exec(
`INSERT INTO services
(id, project_id, name, 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,
service.CreatedAt, service.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service"})
return
}
c.JSON(http.StatusCreated, gin.H{"service": service})
}
// handleGetService retrieves a specific service
func handleGetService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// 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,
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
c.JSON(http.StatusOK, gin.H{"service": service})
}
// handleUpdateService updates a service
func handleUpdateService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
var req UpdateServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// 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,
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
).Scan(
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
&existingService.Status, &existingService.Image, &existingService.Command,
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
&existingService.CreatedAt, &existingService.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
// Update fields if provided
if req.Name != "" {
existingService.Name = req.Name
}
if req.Type != "" {
existingService.Type = req.Type
}
if req.Image != "" {
existingService.Image = req.Image
}
if req.Command != "" {
existingService.Command = req.Command
}
if req.Environment != "" {
existingService.Environment = req.Environment
}
if req.GitRepo != "" {
existingService.GitRepo = req.GitRepo
}
if req.GitBranch != "" {
existingService.GitBranch = req.GitBranch
}
if req.BuildPath != "" {
existingService.BuildPath = req.BuildPath
}
if req.CPU != "" {
existingService.CPU = req.CPU
}
if req.Memory != "" {
existingService.Memory = req.Memory
}
existingService.UpdatedAt = time.Now()
// Update service in database
_, err = db.(*database.DB).Exec(
`UPDATE services
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, updated_at = $11
WHERE id = $12`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
existingService.BuildPath, existingService.CPU, existingService.Memory,
existingService.UpdatedAt, existingService.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service"})
return
}
c.JSON(http.StatusOK, gin.H{"service": existingService})
}
// handleDeleteService deletes a service
func handleDeleteService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if service exists and user has access
var projectOwnerID string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&projectOwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
// Check if user owns the project
if projectOwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Delete service (cascade will handle related records)
_, err = db.(*database.DB).Exec(
"DELETE FROM services WHERE id = $1",
serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete service"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"})
}
+103
View File
@@ -0,0 +1,103 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// AuthCmd represents the auth command
var AuthCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate with Containr API",
Long: `Manage authentication with the Containr API.
You can login, logout, and check your current authentication status.`,
}
// loginCmd represents the login command
var loginCmd = &cobra.Command{
Use: "login [token]",
Short: "Login to Containr",
Long: `Login to Containr using your API token.
You can get your token from the Containr web interface.`,
Args: cobra.MaximumNArgs(1),
RunE: runLogin,
}
// logoutCmd represents the logout command
var logoutCmd = &cobra.Command{
Use: "logout",
Short: "Logout from Containr",
Long: `Remove stored authentication credentials.`,
RunE: runLogout,
}
// statusCmd represents the status command
var statusCmd = &cobra.Command{
Use: "status",
Short: "Check authentication status",
Long: `Check if you are currently authenticated with Containr.`,
RunE: runStatus,
}
func init() {
AuthCmd.AddCommand(loginCmd)
AuthCmd.AddCommand(logoutCmd)
AuthCmd.AddCommand(statusCmd)
}
func runLogin(cmd *cobra.Command, args []string) error {
var token string
if len(args) > 0 {
token = args[0]
} else {
// Prompt for token
fmt.Print("Enter your Containr API token: ")
fmt.Scanln(&token)
}
if token == "" {
return fmt.Errorf("token is required")
}
// Store token in config
viper.Set("token", token)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to save token: %w", err)
}
fmt.Println("✓ Successfully logged in to Containr")
return nil
}
func runLogout(cmd *cobra.Command, args []string) error {
// Remove token from config
viper.Set("token", "")
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to remove token: %w", err)
}
fmt.Println("✓ Successfully logged out from Containr")
return nil
}
func runStatus(cmd *cobra.Command, args []string) error {
token := viper.GetString("token")
if token == "" {
fmt.Println("❌ Not authenticated")
fmt.Println("Run 'containr auth login <token>' to authenticate")
return nil
}
fmt.Println("✓ Authenticated with Containr")
// TODO: Verify token with API
apiURL := viper.GetString("api-url")
if apiURL != "" {
fmt.Printf("API URL: %s\n", apiURL)
}
return nil
}
+234
View File
@@ -0,0 +1,234 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// Project represents a Containr project
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ProjectsCmd represents the projects command
var ProjectsCmd = &cobra.Command{
Use: "projects",
Short: "Manage projects",
Long: `Manage your Containr projects.
You can list, create, update, and delete projects.`,
}
// listProjectsCmd represents the list command
var listProjectsCmd = &cobra.Command{
Use: "list",
Short: "List all projects",
Long: `List all your Containr projects.`,
RunE: runListProjects,
}
// createProjectCmd represents the create command
var createProjectCmd = &cobra.Command{
Use: "create [name]",
Short: "Create a new project",
Long: `Create a new Containr project.
Provide a name and optional description.`,
Args: cobra.ExactArgs(1),
RunE: runCreateProject,
}
// deleteProjectCmd represents the delete command
var deleteProjectCmd = &cobra.Command{
Use: "delete [project-id]",
Short: "Delete a project",
Long: `Delete a Containr project by ID.`,
Args: cobra.ExactArgs(1),
RunE: runDeleteProject,
}
var projectDescription string
// getAPIURL constructs the full API URL for a given endpoint
func getAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1" // Default for development
}
// Ensure baseURL doesn't end with / and endpoint starts with /
baseURL = strings.TrimSuffix(baseURL, "/")
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
return baseURL + endpoint
}
// formatTime formats a time string for display
func formatTime(timeStr string) string {
if timeStr == "" {
return "Unknown"
}
// Parse the time and format it nicely
t, err := time.Parse(time.RFC3339, timeStr)
if err != nil {
return timeStr // Return original if parsing fails
}
return t.Format("2006-01-02 15:04:05")
}
func init() {
ProjectsCmd.AddCommand(listProjectsCmd)
ProjectsCmd.AddCommand(createProjectCmd)
ProjectsCmd.AddCommand(deleteProjectCmd)
// Add flags
createProjectCmd.Flags().StringVarP(&projectDescription, "description", "d", "", "Project description")
}
func runListProjects(cmd *cobra.Command, args []string) error {
apiURL := getAPIURL("/projects")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var projects []Project
if err := json.Unmarshal(body, &projects); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if len(projects) == 0 {
fmt.Println("No projects found")
return nil
}
fmt.Println("Your Projects:")
fmt.Println()
for _, project := range projects {
fmt.Printf("📦 %s (%s)\n", project.Name, project.ID)
if project.Description != "" {
fmt.Printf(" %s\n", project.Description)
}
fmt.Printf(" Created: %s\n", formatTime(project.CreatedAt))
fmt.Println()
}
return nil
}
func runCreateProject(cmd *cobra.Command, args []string) error {
name := args[0]
projectData := map[string]interface{}{
"name": name,
}
if projectDescription != "" {
projectData["description"] = projectDescription
}
jsonData, err := json.Marshal(projectData)
if err != nil {
return fmt.Errorf("failed to marshal project data: %w", err)
}
apiURL := getAPIURL("/projects")
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var project Project
if err := json.Unmarshal(body, &project); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
fmt.Printf("✓ Project '%s' created successfully!\n", project.Name)
fmt.Printf("ID: %s\n", project.ID)
return nil
}
func runDeleteProject(cmd *cobra.Command, args []string) error {
projectID := args[0]
apiURL := getAPIURL("/projects/" + projectID)
req, err := http.NewRequest("DELETE", apiURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+viper.GetString("token"))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed: %s - %s", resp.Status, string(body))
}
fmt.Printf("✓ Project '%s' deleted successfully!\n", projectID)
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package cli
import (
"fmt"
"os"
"containr/internal/cli/commands"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "containr",
Short: "Containr CLI - Manage your self-hosted PaaS",
Long: `Containr CLI is a command-line interface for managing your Containr platform.
You can manage projects, services, deployments, databases, and more from your terminal.`,
Version: "1.0.0",
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
cobra.CheckErr(rootCmd.Execute())
}
func init() {
cobra.OnInitialize(initConfig)
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.containr.yaml)")
rootCmd.PersistentFlags().String("api-url", "", "Containr API URL (default is https://api.containr.dev)")
rootCmd.PersistentFlags().String("token", "", "Authentication token")
// Bind flags to viper
viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url"))
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
// Add command groups
rootCmd.AddCommand(commands.AuthCmd)
rootCmd.AddCommand(commands.ProjectsCmd)
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find home directory.
home, err := os.UserHomeDir()
cobra.CheckErr(err)
// Search config in home directory with name ".containr" (without extension).
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".containr")
}
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
// Run executes the CLI
func Run() error {
rootCmd.Execute()
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package cli
import (
"strings"
"time"
"github.com/spf13/viper"
)
// getAPIURL constructs the full API URL for a given endpoint
func getAPIURL(endpoint string) string {
baseURL := viper.GetString("api-url")
if baseURL == "" {
baseURL = "http://localhost:8080/api/v1" // Default for development
}
// Ensure baseURL doesn't end with / and endpoint starts with /
if strings.HasSuffix(baseURL, "/") {
baseURL = baseURL[:len(baseURL)-1]
}
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
return baseURL + endpoint
}
// formatTime formats a time string for display
func formatTime(timeStr string) string {
if timeStr == "" {
return "Unknown"
}
// Parse the time and format it nicely
t, err := time.Parse(time.RFC3339, timeStr)
if err != nil {
return timeStr // Return original if parsing fails
}
return t.Format("2006-01-02 15:04:05")
}
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"os"
"strconv"
)
type Config struct {
Environment string
Port string
DatabaseURL string
RedisURL string
JWTSecret string
CORS CORSConfig
Proxmox ProxmoxConfig
}
type CORSConfig struct {
AllowedOrigins []string
}
type ProxmoxConfig struct {
BaseURL string
Username string
Password string
TokenID string
Token string
}
func Load() *Config {
cfg := &Config{
Environment: getEnv("ENVIRONMENT", "development"),
Port: getEnv("PORT", "8080"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://containr:password@localhost:5432/containr?sslmode=disable"),
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
CORS: CORSConfig{
AllowedOrigins: []string{
"http://localhost:3000", // Vite dev server
"http://localhost:5173", // Alternative Vite port
},
},
Proxmox: ProxmoxConfig{
BaseURL: getEnv("PROXMOX_BASE_URL", ""),
Username: getEnv("PROXMOX_USERNAME", ""),
Password: getEnv("PROXMOX_PASSWORD", ""),
TokenID: getEnv("PROXMOX_TOKEN_ID", ""),
Token: getEnv("PROXMOX_TOKEN", ""),
},
}
// Add production origins if in production
if cfg.Environment == "production" {
cfg.CORS.AllowedOrigins = append(cfg.CORS.AllowedOrigins,
getEnv("FRONTEND_URL", "https://your-domain.com"))
}
return cfg
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvAsBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
if parsed, err := strconv.ParseBool(value); err == nil {
return parsed
}
}
return defaultValue
}
func getEnvAsInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
return parsed
}
}
return defaultValue
}
+155
View File
@@ -0,0 +1,155 @@
package database
import (
"context"
"fmt"
"io/ioutil"
"log"
"path/filepath"
"sort"
"strings"
)
// Migrate runs all migration files in the migrations directory
func (db *DB) Migrate(migrationsDir string) error {
// Create migrations table if it doesn't exist
if err := db.createMigrationsTable(); err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get list of migration files
files, err := ioutil.ReadDir(migrationsDir)
if err != nil {
return fmt.Errorf("failed to read migrations directory: %w", err)
}
// Sort files by name to ensure proper order
var migrationFiles []string
for _, file := range files {
if !file.IsDir() && strings.HasSuffix(file.Name(), ".sql") {
migrationFiles = append(migrationFiles, file.Name())
}
}
sort.Strings(migrationFiles)
// Run each migration that hasn't been run yet
for _, fileName := range migrationFiles {
if err := db.runMigration(migrationsDir, fileName); err != nil {
return fmt.Errorf("failed to run migration %s: %w", fileName, err)
}
}
log.Println("All migrations completed successfully")
return nil
}
func (db *DB) createMigrationsTable() error {
query := `
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`
_, err := db.Exec(query)
return err
}
func (db *DB) runMigration(migrationsDir, fileName string) error {
// Check if migration has already been run
var count int
err := db.QueryRow("SELECT COUNT(*) FROM migrations WHERE filename = $1", fileName).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
log.Printf("Migration %s already executed, skipping", fileName)
return nil
}
// Read migration file
filePath := filepath.Join(migrationsDir, fileName)
content, err := ioutil.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read migration file %s: %w", fileName, err)
}
// Execute migration in a transaction
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Execute migration SQL
_, err = tx.Exec(string(content))
if err != nil {
return fmt.Errorf("failed to execute migration %s: %w", fileName, err)
}
// Record that migration was executed
_, err = tx.Exec("INSERT INTO migrations (filename) VALUES ($1)", fileName)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", fileName, err)
}
// Commit transaction
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit migration %s: %w", fileName, err)
}
log.Printf("Successfully executed migration: %s", fileName)
return nil
}
// SeedData inserts initial data for development
func (db *DB) SeedData() error {
// Check if we already have users
var count int
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if err != nil {
return fmt.Errorf("failed to check existing users: %w", err)
}
if count > 0 {
log.Println("Database already has data, skipping seed")
return nil
}
// Insert demo user
hashedPassword := "$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi" // "password"
_, err = db.Exec(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
`, "[email protected]", hashedPassword, "Demo User")
if err != nil {
return fmt.Errorf("failed to create demo user: %w", err)
}
// Insert demo project
var projectID string
err = db.QueryRow(`
INSERT INTO projects (name, description, owner_id)
VALUES ($1, $2, (SELECT id FROM users WHERE email = $3))
RETURNING id
`, "Demo Project", "A sample project to showcase Containr features", "[email protected]").Scan(&projectID)
if err != nil {
return fmt.Errorf("failed to create demo project: %w", err)
}
// Insert environments
environments := []string{"production", "preview", "development"}
for _, env := range environments {
_, err = db.Exec(`
INSERT INTO environments (name, project_id)
VALUES ($1, $2)
`, env, projectID)
if err != nil {
return fmt.Errorf("failed to create environment %s: %w", env, err)
}
}
log.Println("Database seeded successfully")
return nil
}
+59
View File
@@ -0,0 +1,59 @@
package database
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/lib/pq"
)
type DB struct {
*sql.DB
}
type DBConfig struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
}
func NewConnection(databaseURL string) (*DB, error) {
return NewConnectionWithConfig(databaseURL, DBConfig{
MaxOpenConns: 25,
MaxIdleConns: 25,
ConnMaxLifetime: 5 * time.Minute,
ConnMaxIdleTime: 5 * time.Minute,
})
}
func NewConnectionWithConfig(databaseURL string, config DBConfig) (*DB, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("unable to open database: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(config.MaxOpenConns)
db.SetMaxIdleConns(config.MaxIdleConns)
db.SetConnMaxLifetime(config.ConnMaxLifetime)
db.SetConnMaxIdleTime(config.ConnMaxIdleTime)
// Test the connection
if err := db.PingContext(context.Background()); err != nil {
return nil, fmt.Errorf("unable to ping database: %w", err)
}
return &DB{DB: db}, nil
}
func (db *DB) Health(ctx context.Context) error {
return db.PingContext(ctx)
}
// Stats returns connection pool statistics for monitoring
func (db *DB) Stats() sql.DBStats {
return db.Stats()
}
+54
View File
@@ -0,0 +1,54 @@
package database
import (
"context"
"time"
"github.com/go-redis/redis/v8"
)
type Redis struct {
Client *redis.Client
}
func NewRedis(redisURL string) *Redis {
opt, err := redis.ParseURL(redisURL)
if err != nil {
// Fallback to default Redis options if URL parsing fails
opt = &redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
}
}
client := redis.NewClient(opt)
return &Redis{Client: client}
}
func (r *Redis) Close() error {
return r.Client.Close()
}
func (r *Redis) Health(ctx context.Context) error {
_, err := r.Client.Ping(ctx).Result()
return err
}
func (r *Redis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return r.Client.Set(ctx, key, value, expiration).Err()
}
func (r *Redis) Get(ctx context.Context, key string) (string, error) {
return r.Client.Get(ctx, key).Result()
}
func (r *Redis) Del(ctx context.Context, keys ...string) error {
return r.Client.Del(ctx, keys...).Err()
}
func (r *Redis) Exists(ctx context.Context, key string) (bool, error) {
result, err := r.Client.Exists(ctx, key).Result()
return result > 0, err
}
+490
View File
@@ -0,0 +1,490 @@
package deployment
import (
"context"
"fmt"
"log"
"time"
"containr/internal/build"
"containr/internal/docker"
"containr/internal/types"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat"
)
type DeploymentEngine struct {
buildManager *build.BuildManager
dockerClient *docker.Client
scheduler *Scheduler
deployments map[string]*Deployment
deploymentLog chan *DeploymentEvent
}
type Deployment struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
Status string `json:"status"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Environment string `json:"environment"`
Replicas int `json:"replicas"`
Config ServiceConfig `json:"config"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Containers []ContainerInfo `json:"containers"`
BuildLog string `json:"build_log"`
DeployLog string `json:"deploy_log"`
Error string `json:"error,omitempty"`
Metadata map[string]string `json:"metadata"`
}
type ServiceConfig struct {
Name string `json:"name"`
Image string `json:"image"`
Command []string `json:"command,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
RestartPolicy string `json:"restart_policy"`
PortMappings []PortMapping `json:"port_mappings,omitempty"`
VolumeMounts []VolumeMount `json:"volume_mounts,omitempty"`
Networks []string `json:"networks,omitempty"`
Resources ResourceLimits `json:"resources,omitempty"`
HealthCheck *HealthCheck `json:"health_check,omitempty"`
Replicas int `json:"replicas"`
}
type PortMapping struct {
ContainerPort int32 `json:"container_port"`
HostPort int32 `json:"host_port,omitempty"`
Protocol string `json:"protocol"`
HostIP string `json:"host_ip,omitempty"`
}
type VolumeMount struct {
Type string `json:"type"`
Source string `json:"source"`
Destination string `json:"destination"`
ReadOnly bool `json:"read_only,omitempty"`
}
type ResourceLimits struct {
MemoryBytes int64 `json:"memory_bytes,omitempty"`
CPUQuota int64 `json:"cpu_quota,omitempty"`
CPUPeriod int64 `json:"cpu_period,omitempty"`
CPUShares int64 `json:"cpu_shares,omitempty"`
}
type HealthCheck struct {
Test []string `json:"test"`
Interval time.Duration `json:"interval"`
Timeout time.Duration `json:"timeout"`
Retries int `json:"retries"`
StartPeriod time.Duration `json:"start_period"`
}
type ContainerInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
StartedAt time.Time `json:"started_at"`
Ports []PortInfo `json:"ports,omitempty"`
Resources ResourceUsage `json:"resources"`
Health *HealthStatus `json:"health,omitempty"`
}
type PortInfo struct {
ContainerPort int32 `json:"container_port"`
HostPort int32 `json:"host_port,omitempty"`
HostIP string `json:"host_ip"`
Protocol string `json:"protocol"`
}
type ResourceUsage struct {
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
NetworkRx int64 `json:"network_rx"`
NetworkTx int64 `json:"network_tx"`
}
type HealthStatus struct {
Status string `json:"status"`
FailingStreak int `json:"failing_streak"`
LastCheck time.Time `json:"last_check"`
}
type DeploymentEvent struct {
Type string `json:"type"`
Deployment *Deployment `json:"deployment"`
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
}
type DeploymentRequest struct {
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
Environment string `json:"environment"`
Config ServiceConfig `json:"config"`
BuildConfig *BuildConfig `json:"build_config,omitempty"`
Trigger TriggerConfig `json:"trigger"`
}
type BuildConfig struct {
BuildType string `json:"build_type"`
SourcePath string `json:"source_path"`
PrebuiltImage string `json:"prebuilt_image"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
Environment map[string]string `json:"environment"`
Branch string `json:"branch"`
Commit string `json:"commit"`
}
type TriggerConfig struct {
Type string `json:"type"` // webhook, manual, api, scheduled
Source string `json:"source"` // Source of trigger
User string `json:"user"` // User who triggered
Data map[string]string `json:"data"` // Trigger-specific data
Timestamp time.Time `json:"timestamp"` // When trigger occurred
}
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
return &DeploymentEngine{
buildManager: buildManager,
dockerClient: dockerClient,
scheduler: NewScheduler(),
deployments: make(map[string]*Deployment),
deploymentLog: make(chan *DeploymentEvent, 1000),
}
}
// Deploy starts a new deployment
func (de *DeploymentEngine) Deploy(ctx context.Context, req *DeploymentRequest) (*Deployment, error) {
deployment := &Deployment{
ID: generateDeploymentID(),
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
Status: "pending",
Environment: req.Environment,
Config: req.Config,
CreatedAt: time.Now(),
Metadata: map[string]string{
"trigger_type": req.Trigger.Type,
"trigger_source": req.Trigger.Source,
"branch": req.BuildConfig.Branch,
"commit": req.BuildConfig.Commit,
},
}
// Store deployment
de.deployments[deployment.ID] = deployment
// Log deployment start
de.logEvent(&DeploymentEvent{
Type: "deployment_started",
Deployment: deployment,
Timestamp: time.Now(),
Message: fmt.Sprintf("Deployment started for service %s", req.ServiceID),
})
// Start deployment in background
go de.executeDeployment(ctx, deployment, req)
return deployment, nil
}
// executeDeployment executes the deployment process
func (de *DeploymentEngine) executeDeployment(ctx context.Context, deployment *Deployment, req *DeploymentRequest) {
deployment.Status = "building"
deployment.StartedAt = &[]time.Time{time.Now()}[0]
de.logEvent(&DeploymentEvent{
Type: "build_started",
Deployment: deployment,
Timestamp: time.Now(),
Message: "Build process started",
})
// Step 1: Build the image
imageName, err := de.buildImage(ctx, deployment, req.BuildConfig)
if err != nil {
deployment.Status = "failed"
deployment.Error = fmt.Sprintf("Build failed: %v", err)
deployment.CompletedAt = &[]time.Time{time.Now()}[0]
de.logEvent(&DeploymentEvent{
Type: "build_failed",
Deployment: deployment,
Timestamp: time.Now(),
Message: deployment.Error,
})
return
}
deployment.ImageName = imageName
deployment.Status = "deploying"
de.logEvent(&DeploymentEvent{
Type: "build_completed",
Deployment: deployment,
Timestamp: time.Now(),
Message: fmt.Sprintf("Build completed successfully: %s", imageName),
})
// Step 2: Deploy the service
err = de.deployService(ctx, deployment)
if err != nil {
deployment.Status = "failed"
deployment.Error = fmt.Sprintf("Deployment failed: %v", err)
deployment.CompletedAt = &[]time.Time{time.Now()}[0]
de.logEvent(&DeploymentEvent{
Type: "deployment_failed",
Deployment: deployment,
Timestamp: time.Now(),
Message: deployment.Error,
})
return
}
deployment.Status = "running"
deployment.CompletedAt = &[]time.Time{time.Now()}[0]
de.logEvent(&DeploymentEvent{
Type: "deployment_completed",
Deployment: deployment,
Timestamp: time.Now(),
Message: "Deployment completed successfully",
})
}
// buildImage builds the container image
func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployment, buildConfig *BuildConfig) (string, error) {
if buildConfig == nil {
return "", fmt.Errorf("build config is required")
}
buildReq := &types.BuildRequest{
BuildType: buildConfig.BuildType,
SourcePath: buildConfig.SourcePath,
PrebuiltImage: buildConfig.PrebuiltImage,
ImageName: fmt.Sprintf("containr-%s-%s", deployment.ServiceID, deployment.Environment),
ImageTag: deployment.ID,
BuildCommand: buildConfig.BuildCommand,
StartCommand: buildConfig.StartCommand,
Environment: buildConfig.Environment,
ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID,
DeploymentID: deployment.ID,
TriggeredBy: "deployment_engine",
Branch: buildConfig.Branch,
Commit: buildConfig.Commit,
}
response, err := de.buildManager.Build(ctx, buildReq)
if err != nil {
return "", err
}
deployment.BuildLog = response.BuildLog
return response.ImageName, nil
}
// deployService deploys the service using the built image
func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deployment) error {
// Convert service config to Docker container config
containerConfig := &docker.ContainerConfig{
Name: fmt.Sprintf("containr-%s-%s", deployment.ServiceID, deployment.ID),
Image: deployment.ImageName,
Cmd: deployment.Config.Command,
Labels: deployment.Config.Labels,
Networks: make(map[string]*network.EndpointSettings),
}
// Set environment variables
for k, v := range deployment.Config.Environment {
containerConfig.Env = append(containerConfig.Env, fmt.Sprintf("%s=%s", k, v))
}
// Set restart policy
containerConfig.RestartPolicy = deployment.Config.RestartPolicy
// Configure port mappings
portBindings := make(nat.PortMap)
for _, pm := range deployment.Config.PortMappings {
port := nat.Port(fmt.Sprintf("%d/%s", pm.ContainerPort, pm.Protocol))
if pm.HostPort > 0 {
portBindings[port] = []nat.PortBinding{
{
HostIP: pm.HostIP,
HostPort: fmt.Sprintf("%d", pm.HostPort),
},
}
}
}
containerConfig.PortBindings = portBindings
// Configure resource limits
if deployment.Config.Resources.MemoryBytes > 0 {
containerConfig.Memory = deployment.Config.Resources.MemoryBytes
}
if deployment.Config.Resources.CPUQuota > 0 {
containerConfig.NanoCPUs = deployment.Config.Resources.CPUQuota
}
// Configure volume mounts
for _, vm := range deployment.Config.VolumeMounts {
mount := mount.Mount{
Type: mount.Type(vm.Type),
Source: vm.Source,
Target: vm.Destination,
ReadOnly: vm.ReadOnly,
}
containerConfig.Mounts = append(containerConfig.Mounts, mount)
}
// Create containers based on replica count
deployment.Containers = make([]ContainerInfo, deployment.Config.Replicas)
for i := 0; i < deployment.Config.Replicas; i++ {
containerName := fmt.Sprintf("%s-%d", containerConfig.Name, i)
// Create container
containerID, err := de.dockerClient.CreateContainer(ctx, *containerConfig)
if err != nil {
return fmt.Errorf("failed to create container %d: %w", i, err)
}
// Start container
err = de.dockerClient.StartContainer(ctx, containerID)
if err != nil {
return fmt.Errorf("failed to start container %d: %w", i, err)
}
// Get container info
_, err = de.dockerClient.GetContainer(ctx, containerID)
if err != nil {
log.Printf("Failed to get container info for %s: %v", containerID, err)
}
deployment.Containers[i] = ContainerInfo{
ID: containerID,
Name: containerName,
Status: "running",
CreatedAt: time.Now(),
StartedAt: time.Now(),
}
}
return nil
}
// GetDeployment gets a deployment by ID
func (de *DeploymentEngine) GetDeployment(id string) (*Deployment, error) {
deployment, exists := de.deployments[id]
if !exists {
return nil, fmt.Errorf("deployment not found: %s", id)
}
return deployment, nil
}
// ListDeployments lists all deployments
func (de *DeploymentEngine) ListDeployments(projectID, serviceID string) ([]*Deployment, error) {
var deployments []*Deployment
for _, deployment := range de.deployments {
if projectID != "" && deployment.ProjectID != projectID {
continue
}
if serviceID != "" && deployment.ServiceID != serviceID {
continue
}
deployments = append(deployments, deployment)
}
return deployments, nil
}
// CancelDeployment cancels a running deployment
func (de *DeploymentEngine) CancelDeployment(ctx context.Context, id string) error {
deployment, exists := de.deployments[id]
if !exists {
return fmt.Errorf("deployment not found: %s", id)
}
if deployment.Status == "completed" || deployment.Status == "failed" {
return fmt.Errorf("cannot cancel completed deployment: %s", id)
}
// Stop all containers
for _, container := range deployment.Containers {
err := de.dockerClient.StopContainer(ctx, container.ID, nil)
if err != nil {
log.Printf("Failed to stop container %s: %v", container.ID, err)
}
}
deployment.Status = "cancelled"
deployment.CompletedAt = &[]time.Time{time.Now()}[0]
de.logEvent(&DeploymentEvent{
Type: "deployment_cancelled",
Deployment: deployment,
Timestamp: time.Now(),
Message: "Deployment was cancelled",
})
return nil
}
// GetDeploymentLogs gets the logs for a deployment
func (de *DeploymentEngine) GetDeploymentLogs(ctx context.Context, id string) (string, error) {
deployment, exists := de.deployments[id]
if !exists {
return "", fmt.Errorf("deployment not found: %s", id)
}
logs := deployment.BuildLog
logs += "\n" + deployment.DeployLog
// Add container logs
for _, container := range deployment.Containers {
containerLogs, err := de.dockerClient.GetContainerLogs(ctx, container.ID, docker.LogOptions{
Stdout: true,
Stderr: true,
})
if err != nil {
log.Printf("Failed to get logs for container %s: %v", container.ID, err)
continue
}
logs += fmt.Sprintf("\n=== Container %s Logs ===\n%s", container.Name, containerLogs)
}
return logs, nil
}
// WatchDeploymentEvents returns a channel of deployment events
func (de *DeploymentEngine) WatchDeploymentEvents() <-chan *DeploymentEvent {
return de.deploymentLog
}
// logEvent logs a deployment event
func (de *DeploymentEngine) logEvent(event *DeploymentEvent) {
select {
case de.deploymentLog <- event:
default:
// Channel is full, drop the event
log.Printf("Deployment event channel is full, dropping event: %s", event.Type)
}
}
// generateDeploymentID generates a unique deployment ID
func generateDeploymentID() string {
return fmt.Sprintf("deploy-%d", time.Now().UnixNano())
}
+718
View File
@@ -0,0 +1,718 @@
package deployment
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
type HistoryManager struct {
storagePath string
mu sync.RWMutex
deployments map[string]*DeploymentRecord
}
type DeploymentRecord struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
Environment string `json:"environment"`
Status string `json:"status"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Config ServiceConfig `json:"config"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Duration time.Duration `json:"duration"`
Containers []ContainerRecord `json:"containers"`
BuildLog string `json:"build_log"`
DeployLog string `json:"deploy_log"`
Error string `json:"error,omitempty"`
Metadata map[string]string `json:"metadata"`
Trigger TriggerRecord `json:"trigger"`
RollbackFrom *string `json:"rollback_from,omitempty"`
Rollbacks []string `json:"rollbacks"`
Tags []string `json:"tags"`
Annotations map[string]interface{} `json:"annotations"`
}
type ContainerRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
StartedAt time.Time `json:"started_at"`
StoppedAt *time.Time `json:"stopped_at,omitempty"`
Ports []PortRecord `json:"ports,omitempty"`
Resources ResourceRecord `json:"resources"`
Health *HealthRecord `json:"health,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
type PortRecord struct {
ContainerPort int32 `json:"container_port"`
HostPort int32 `json:"host_port,omitempty"`
HostIP string `json:"host_ip"`
Protocol string `json:"protocol"`
}
type ResourceRecord struct {
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
NetworkRx int64 `json:"network_rx"`
NetworkTx int64 `json:"network_tx"`
PidsCurrent uint64 `json:"pids_current"`
PidsLimit uint64 `json:"pids_limit"`
}
type HealthRecord struct {
Status string `json:"status"`
FailingStreak int `json:"failing_streak"`
LastCheck time.Time `json:"last_check"`
Output string `json:"output,omitempty"`
}
type TriggerRecord struct {
Type string `json:"type"` // webhook, manual, api, scheduled
Source string `json:"source"` // Source of trigger
User string `json:"user"` // User who triggered
Data map[string]string `json:"data"` // Trigger-specific data
Timestamp time.Time `json:"timestamp"` // When trigger occurred
}
type DeploymentFilter struct {
ProjectID string `json:"project_id,omitempty"`
ServiceID string `json:"service_id,omitempty"`
Environment string `json:"environment,omitempty"`
Status string `json:"status,omitempty"`
TriggerType string `json:"trigger_type,omitempty"`
User string `json:"user,omitempty"`
From time.Time `json:"from,omitempty"`
To time.Time `json:"to,omitempty"`
Tags []string `json:"tags,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
SortBy string `json:"sort_by,omitempty"` // created_at, started_at, completed_at, duration
SortOrder string `json:"sort_order,omitempty"` // asc, desc
}
type DeploymentStats struct {
TotalDeployments int `json:"total_deployments"`
SuccessfulDeployments int `json:"successful_deployments"`
FailedDeployments int `json:"failed_deployments"`
AverageDuration time.Duration `json:"average_duration"`
DeploymentsByStatus map[string]int `json:"deployments_by_status"`
DeploymentsByEnv map[string]int `json:"deployments_by_env"`
DeploymentsByDay map[string]int `json:"deployments_by_day"`
RecentActivity []DeploymentRecord `json:"recent_activity"`
TopServices []ServiceDeploymentStats `json:"top_services"`
TopUsers []UserDeploymentStats `json:"top_users"`
}
type ServiceDeploymentStats struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
DeploymentCount int `json:"deployment_count"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
SuccessRate float64 `json:"success_rate"`
AverageDuration time.Duration `json:"average_duration"`
LastDeployment time.Time `json:"last_deployment"`
}
type UserDeploymentStats struct {
User string `json:"user"`
DeploymentCount int `json:"deployment_count"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
SuccessRate float64 `json:"success_rate"`
AverageDuration time.Duration `json:"average_duration"`
LastDeployment time.Time `json:"last_deployment"`
}
func NewHistoryManager(storagePath string) *HistoryManager {
return &HistoryManager{
storagePath: storagePath,
deployments: make(map[string]*DeploymentRecord),
}
}
// RecordDeployment records a deployment in history
func (hm *HistoryManager) RecordDeployment(deployment *Deployment) error {
hm.mu.Lock()
defer hm.mu.Unlock()
record := hm.convertToRecord(deployment)
hm.deployments[record.ID] = record
// Save to storage
return hm.saveDeployment(record)
}
// GetDeployment gets a deployment record by ID
func (hm *HistoryManager) GetDeployment(id string) (*DeploymentRecord, error) {
hm.mu.RLock()
defer hm.mu.RUnlock()
record, exists := hm.deployments[id]
if !exists {
return nil, fmt.Errorf("deployment not found: %s", id)
}
return record, nil
}
// ListDeployments lists deployments with filtering
func (hm *HistoryManager) ListDeployments(filter DeploymentFilter) ([]*DeploymentRecord, error) {
hm.mu.RLock()
defer hm.mu.RUnlock()
var deployments []*DeploymentRecord
for _, record := range hm.deployments {
if hm.matchesFilter(record, filter) {
deployments = append(deployments, record)
}
}
// Sort deployments
hm.sortDeployments(deployments, filter.SortBy, filter.SortOrder)
// Apply pagination
if filter.Limit > 0 {
start := filter.Offset
if start >= len(deployments) {
return []*DeploymentRecord{}, nil
}
end := start + filter.Limit
if end > len(deployments) {
end = len(deployments)
}
deployments = deployments[start:end]
}
return deployments, nil
}
// RollbackDeployment creates a rollback deployment
func (hm *HistoryManager) RollbackDeployment(ctx context.Context, deploymentID, reason string, userID string) (*DeploymentRecord, error) {
hm.mu.RLock()
originalDeployment, exists := hm.deployments[deploymentID]
hm.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("deployment not found: %s", deploymentID)
}
// Create rollback deployment record
rollbackRecord := &DeploymentRecord{
ID: generateDeploymentID(),
ProjectID: originalDeployment.ProjectID,
ServiceID: originalDeployment.ServiceID,
Environment: originalDeployment.Environment,
Status: "pending",
ImageName: originalDeployment.ImageName,
ImageTag: originalDeployment.ImageTag,
Config: originalDeployment.Config,
CreatedAt: time.Now(),
Metadata: map[string]string{
"rollback_from": deploymentID,
"rollback_reason": reason,
},
Trigger: TriggerRecord{
Type: "rollback",
Source: "deployment_history",
User: userID,
Data: map[string]string{
"original_deployment": deploymentID,
"reason": reason,
},
Timestamp: time.Now(),
},
RollbackFrom: &deploymentID,
Tags: append(originalDeployment.Tags, "rollback"),
}
// Record the rollback
err := hm.RecordDeployment(&Deployment{
ID: rollbackRecord.ID,
ProjectID: rollbackRecord.ProjectID,
ServiceID: rollbackRecord.ServiceID,
Environment: rollbackRecord.Environment,
Status: rollbackRecord.Status,
ImageName: rollbackRecord.ImageName,
ImageTag: rollbackRecord.ImageTag,
Config: rollbackRecord.Config,
CreatedAt: rollbackRecord.CreatedAt,
Metadata: rollbackRecord.Metadata,
})
if err != nil {
return nil, fmt.Errorf("failed to record rollback: %w", err)
}
// Update original deployment to track rollbacks
hm.mu.Lock()
if original, exists := hm.deployments[deploymentID]; exists {
original.Rollbacks = append(original.Rollbacks, rollbackRecord.ID)
hm.saveDeployment(original)
}
hm.mu.Unlock()
return rollbackRecord, nil
}
// GetDeploymentHistory gets the deployment history for a service
func (hm *HistoryManager) GetDeploymentHistory(serviceID, environment string, limit int) ([]*DeploymentRecord, error) {
filter := DeploymentFilter{
ServiceID: serviceID,
Environment: environment,
Limit: limit,
SortBy: "created_at",
SortOrder: "desc",
}
return hm.ListDeployments(filter)
}
// GetDeploymentStats gets deployment statistics
func (hm *HistoryManager) GetDeploymentStats(projectID string) (*DeploymentStats, error) {
hm.mu.RLock()
defer hm.mu.RUnlock()
stats := &DeploymentStats{
DeploymentsByStatus: make(map[string]int),
DeploymentsByEnv: make(map[string]int),
DeploymentsByDay: make(map[string]int),
}
var totalDuration time.Duration
var successfulDeployments int
for _, record := range hm.deployments {
if projectID != "" && record.ProjectID != projectID {
continue
}
stats.TotalDeployments++
// Count by status
stats.DeploymentsByStatus[record.Status]++
// Count by environment
stats.DeploymentsByEnv[record.Environment]++
// Count by day
day := record.CreatedAt.Format("2006-01-02")
stats.DeploymentsByDay[day]++
// Calculate success metrics
if record.Status == "running" || record.Status == "completed" {
successfulDeployments++
stats.SuccessfulDeployments++
} else if record.Status == "failed" {
stats.FailedDeployments++
}
// Calculate duration
if record.Duration > 0 {
totalDuration += record.Duration
}
}
// Calculate average duration
if stats.TotalDeployments > 0 {
stats.AverageDuration = totalDuration / time.Duration(stats.TotalDeployments)
}
// Get recent activity
stats.RecentActivity = hm.getRecentActivity(projectID, 10)
// Get top services and users
stats.TopServices = hm.getTopServices(projectID, 5)
stats.TopUsers = hm.getTopUsers(projectID, 5)
return stats, nil
}
// DeleteDeployment removes a deployment from history
func (hm *HistoryManager) DeleteDeployment(id string) error {
hm.mu.Lock()
defer hm.mu.Unlock()
if _, exists := hm.deployments[id]; !exists {
return fmt.Errorf("deployment not found: %s", id)
}
delete(hm.deployments, id)
// Remove from storage
return hm.deleteDeploymentFile(id)
}
// convertToRecord converts a Deployment to DeploymentRecord
func (hm *HistoryManager) convertToRecord(deployment *Deployment) *DeploymentRecord {
record := &DeploymentRecord{
ID: deployment.ID,
ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID,
Environment: deployment.Environment,
Status: deployment.Status,
ImageName: deployment.ImageName,
ImageTag: deployment.ImageTag,
Config: deployment.Config,
CreatedAt: deployment.CreatedAt,
StartedAt: deployment.StartedAt,
CompletedAt: deployment.CompletedAt,
BuildLog: deployment.BuildLog,
DeployLog: deployment.DeployLog,
Error: deployment.Error,
Metadata: deployment.Metadata,
Tags: []string{},
Annotations: make(map[string]interface{}),
}
// Calculate duration
if deployment.StartedAt != nil && deployment.CompletedAt != nil {
record.Duration = deployment.CompletedAt.Sub(*deployment.StartedAt)
}
// Convert containers
for _, container := range deployment.Containers {
containerRecord := ContainerRecord{
ID: container.ID,
Name: container.Name,
Status: container.Status,
CreatedAt: container.CreatedAt,
StartedAt: container.StartedAt,
Resources: ResourceRecord{
CPUPercent: container.Resources.CPUPercent,
MemoryUsage: container.Resources.MemoryUsage,
MemoryLimit: container.Resources.MemoryLimit,
NetworkRx: container.Resources.NetworkRx,
NetworkTx: container.Resources.NetworkTx,
},
}
if container.Health != nil {
containerRecord.Health = &HealthRecord{
Status: container.Health.Status,
FailingStreak: container.Health.FailingStreak,
LastCheck: container.Health.LastCheck,
}
}
record.Containers = append(record.Containers, containerRecord)
}
return record
}
// matchesFilter checks if a deployment record matches the filter
func (hm *HistoryManager) matchesFilter(record *DeploymentRecord, filter DeploymentFilter) bool {
if filter.ProjectID != "" && record.ProjectID != filter.ProjectID {
return false
}
if filter.ServiceID != "" && record.ServiceID != filter.ServiceID {
return false
}
if filter.Environment != "" && record.Environment != filter.Environment {
return false
}
if filter.Status != "" && record.Status != filter.Status {
return false
}
if filter.TriggerType != "" && record.Trigger.Type != filter.TriggerType {
return false
}
if filter.User != "" && record.Trigger.User != filter.User {
return false
}
if !filter.From.IsZero() && record.CreatedAt.Before(filter.From) {
return false
}
if !filter.To.IsZero() && record.CreatedAt.After(filter.To) {
return false
}
if len(filter.Tags) > 0 {
hasTag := false
for _, tag := range filter.Tags {
for _, recordTag := range record.Tags {
if recordTag == tag {
hasTag = true
break
}
}
if hasTag {
break
}
}
if !hasTag {
return false
}
}
return true
}
// sortDeployments sorts deployments based on the specified criteria
func (hm *HistoryManager) sortDeployments(deployments []*DeploymentRecord, sortBy, sortOrder string) {
if sortBy == "" {
sortBy = "created_at"
}
if sortOrder == "" {
sortOrder = "desc"
}
sort.Slice(deployments, func(i, j int) bool {
var less bool
switch sortBy {
case "created_at":
less = deployments[i].CreatedAt.Before(deployments[j].CreatedAt)
case "started_at":
if deployments[i].StartedAt == nil {
less = true
} else if deployments[j].StartedAt == nil {
less = false
} else {
less = deployments[i].StartedAt.Before(*deployments[j].StartedAt)
}
case "completed_at":
if deployments[i].CompletedAt == nil {
less = true
} else if deployments[j].CompletedAt == nil {
less = false
} else {
less = deployments[i].CompletedAt.Before(*deployments[j].CompletedAt)
}
case "duration":
less = deployments[i].Duration < deployments[j].Duration
default:
less = deployments[i].ID < deployments[j].ID
}
if sortOrder == "desc" {
return !less
}
return less
})
}
// getRecentActivity gets recent deployment activity
func (hm *HistoryManager) getRecentActivity(projectID string, limit int) []DeploymentRecord {
var deployments []DeploymentRecord
for _, record := range hm.deployments {
if projectID != "" && record.ProjectID != projectID {
continue
}
deployments = append(deployments, *record)
}
// Sort by created_at desc
sort.Slice(deployments, func(i, j int) bool {
return deployments[i].CreatedAt.After(deployments[j].CreatedAt)
})
if len(deployments) > limit {
deployments = deployments[:limit]
}
return deployments
}
// getTopServices gets top services by deployment count
func (hm *HistoryManager) getTopServices(projectID string, limit int) []ServiceDeploymentStats {
serviceStats := make(map[string]*ServiceDeploymentStats)
for _, record := range hm.deployments {
if projectID != "" && record.ProjectID != projectID {
continue
}
stats, exists := serviceStats[record.ServiceID]
if !exists {
stats = &ServiceDeploymentStats{
ServiceID: record.ServiceID,
}
serviceStats[record.ServiceID] = stats
}
stats.DeploymentCount++
stats.LastDeployment = record.CreatedAt
if record.Status == "running" || record.Status == "completed" {
stats.SuccessCount++
} else if record.Status == "failed" {
stats.FailureCount++
}
if record.Duration > 0 {
// Simple moving average for duration
if stats.AverageDuration == 0 {
stats.AverageDuration = record.Duration
} else {
stats.AverageDuration = (stats.AverageDuration + record.Duration) / 2
}
}
}
// Calculate success rates
for _, stats := range serviceStats {
if stats.DeploymentCount > 0 {
stats.SuccessRate = float64(stats.SuccessCount) / float64(stats.DeploymentCount) * 100
}
}
// Convert to slice and sort
var topServices []ServiceDeploymentStats
for _, stats := range serviceStats {
topServices = append(topServices, *stats)
}
sort.Slice(topServices, func(i, j int) bool {
return topServices[i].DeploymentCount > topServices[j].DeploymentCount
})
if len(topServices) > limit {
topServices = topServices[:limit]
}
return topServices
}
// getTopUsers gets top users by deployment count
func (hm *HistoryManager) getTopUsers(projectID string, limit int) []UserDeploymentStats {
userStats := make(map[string]*UserDeploymentStats)
for _, record := range hm.deployments {
if projectID != "" && record.ProjectID != projectID {
continue
}
user := record.Trigger.User
if user == "" {
continue
}
stats, exists := userStats[user]
if !exists {
stats = &UserDeploymentStats{
User: user,
}
userStats[user] = stats
}
stats.DeploymentCount++
stats.LastDeployment = record.CreatedAt
if record.Status == "running" || record.Status == "completed" {
stats.SuccessCount++
} else if record.Status == "failed" {
stats.FailureCount++
}
if record.Duration > 0 {
if stats.AverageDuration == 0 {
stats.AverageDuration = record.Duration
} else {
stats.AverageDuration = (stats.AverageDuration + record.Duration) / 2
}
}
}
// Calculate success rates
for _, stats := range userStats {
if stats.DeploymentCount > 0 {
stats.SuccessRate = float64(stats.SuccessCount) / float64(stats.DeploymentCount) * 100
}
}
// Convert to slice and sort
var topUsers []UserDeploymentStats
for _, stats := range userStats {
topUsers = append(topUsers, *stats)
}
sort.Slice(topUsers, func(i, j int) bool {
return topUsers[i].DeploymentCount > topUsers[j].DeploymentCount
})
if len(topUsers) > limit {
topUsers = topUsers[:limit]
}
return topUsers
}
// saveDeployment saves a deployment record to storage
func (hm *HistoryManager) saveDeployment(record *DeploymentRecord) error {
if err := os.MkdirAll(hm.storagePath, 0755); err != nil {
return err
}
filename := filepath.Join(hm.storagePath, record.ID+".json")
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return err
}
return os.WriteFile(filename, data, 0644)
}
// deleteDeploymentFile removes a deployment file from storage
func (hm *HistoryManager) deleteDeploymentFile(id string) error {
filename := filepath.Join(hm.storagePath, id+".json")
return os.Remove(filename)
}
// loadDeployments loads all deployments from storage
func (hm *HistoryManager) loadDeployments() error {
if _, err := os.Stat(hm.storagePath); os.IsNotExist(err) {
return nil // Storage doesn't exist yet
}
files, err := os.ReadDir(hm.storagePath)
if err != nil {
return err
}
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".json") {
continue
}
filename := filepath.Join(hm.storagePath, file.Name())
data, err := os.ReadFile(filename)
if err != nil {
continue // Skip files that can't be read
}
var record DeploymentRecord
if err := json.Unmarshal(data, &record); err != nil {
continue // Skip invalid files
}
hm.deployments[record.ID] = &record
}
return nil
}
+379
View File
@@ -0,0 +1,379 @@
package deployment
import (
"context"
"fmt"
"sort"
"sync"
"time"
"containr/internal/docker"
)
type Scheduler struct {
nodes map[string]*Node
mu sync.RWMutex
dockerClient *docker.Client
schedulingAlg SchedulingAlgorithm
}
type Node struct {
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Status string `json:"status"`
Capacity ResourceCapacity `json:"capacity"`
Usage NodeResourceUsage `json:"usage"`
Labels map[string]string `json:"labels"`
LastHeartbeat time.Time `json:"last_heartbeat"`
Containers []string `json:"containers"`
}
type ResourceCapacity struct {
CPU int64 `json:"cpu"` // CPU cores in nanoseconds
Memory int64 `json:"memory"` // Memory in bytes
Storage int64 `json:"storage"` // Storage in bytes
Network int64 `json:"network"` // Network bandwidth in bytes per second
}
type NodeResourceUsage struct {
CPU float64 `json:"cpu"` // CPU usage percentage
Memory int64 `json:"memory"` // Memory usage in bytes
Storage int64 `json:"storage"` // Storage usage in bytes
Network int64 `json:"network"` // Network usage in bytes per second
}
type SchedulingAlgorithm string
const (
SchedulingAlgorithmRoundRobin SchedulingAlgorithm = "round_robin"
SchedulingAlgorithmLeastLoaded SchedulingAlgorithm = "least_loaded"
SchedulingAlgorithmBestFit SchedulingAlgorithm = "best_fit"
SchedulingAlgorithmRandom SchedulingAlgorithm = "random"
)
type SchedulingDecision struct {
NodeID string `json:"node_id"`
Reason string `json:"reason"`
Score float64 `json:"score"`
Alternatives []NodeScore `json:"alternatives"`
}
type NodeScore struct {
NodeID string `json:"node_id"`
Score float64 `json:"score"`
Reason string `json:"reason"`
}
func NewScheduler() *Scheduler {
return &Scheduler{
nodes: make(map[string]*Node),
schedulingAlg: SchedulingAlgorithmLeastLoaded,
}
}
// RegisterNode registers a new node in the scheduler
func (s *Scheduler) RegisterNode(node *Node) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.nodes[node.ID]; exists {
return fmt.Errorf("node already registered: %s", node.ID)
}
node.Status = "ready"
node.LastHeartbeat = time.Now()
s.nodes[node.ID] = node
return nil
}
// UnregisterNode removes a node from the scheduler
func (s *Scheduler) UnregisterNode(nodeID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.nodes[nodeID]; !exists {
return fmt.Errorf("node not found: %s", nodeID)
}
delete(s.nodes, nodeID)
return nil
}
// UpdateNode updates node information
func (s *Scheduler) UpdateNode(node *Node) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.nodes[node.ID]; !exists {
return fmt.Errorf("node not found: %s", node.ID)
}
node.LastHeartbeat = time.Now()
s.nodes[node.ID] = node
return nil
}
// GetNodes returns all registered nodes
func (s *Scheduler) GetNodes() []*Node {
s.mu.RLock()
defer s.mu.RUnlock()
nodes := make([]*Node, 0, len(s.nodes))
for _, node := range s.nodes {
nodes = append(nodes, node)
}
return nodes
}
// GetReadyNodes returns only nodes that are ready for scheduling
func (s *Scheduler) GetReadyNodes() []*Node {
s.mu.RLock()
defer s.mu.RUnlock()
nodes := make([]*Node, 0, len(s.nodes))
for _, node := range s.nodes {
if node.Status == "ready" && s.isNodeHealthy(node) {
nodes = append(nodes, node)
}
}
return nodes
}
// ScheduleContainer schedules a container to run on the best available node
func (s *Scheduler) ScheduleContainer(ctx context.Context, requirements ResourceCapacity) (*SchedulingDecision, error) {
readyNodes := s.GetReadyNodes()
if len(readyNodes) == 0 {
return nil, fmt.Errorf("no ready nodes available")
}
var decision *SchedulingDecision
switch s.schedulingAlg {
case SchedulingAlgorithmRoundRobin:
decision = s.scheduleRoundRobin(readyNodes, requirements)
case SchedulingAlgorithmLeastLoaded:
decision = s.scheduleLeastLoaded(readyNodes, requirements)
case SchedulingAlgorithmBestFit:
decision = s.scheduleBestFit(readyNodes, requirements)
case SchedulingAlgorithmRandom:
decision = s.scheduleRandom(readyNodes, requirements)
default:
return nil, fmt.Errorf("unknown scheduling algorithm: %s", s.schedulingAlg)
}
if decision == nil {
return nil, fmt.Errorf("failed to schedule container")
}
return decision, nil
}
// scheduleRoundRobin schedules containers in a round-robin fashion
func (s *Scheduler) scheduleRoundRobin(nodes []*Node, requirements ResourceCapacity) *SchedulingDecision {
// Find the node with the fewest containers
var selectedNode *Node
minContainers := int(^uint(0) >> 1) // Max int
for _, node := range nodes {
if len(node.Containers) < minContainers && s.canFitRequirements(node, requirements) {
selectedNode = node
minContainers = len(node.Containers)
}
}
if selectedNode == nil {
return nil
}
return &SchedulingDecision{
NodeID: selectedNode.ID,
Reason: "Round-robin scheduling",
Score: 1.0,
}
}
// scheduleLeastLoaded schedules containers on the least loaded node
func (s *Scheduler) scheduleLeastLoaded(nodes []*Node, requirements ResourceCapacity) *SchedulingDecision {
var scores []NodeScore
for _, node := range nodes {
if !s.canFitRequirements(node, requirements) {
continue
}
score := s.calculateLoadScore(node)
scores = append(scores, NodeScore{
NodeID: node.ID,
Score: score,
Reason: "Load-based score",
})
}
if len(scores) == 0 {
return nil
}
// Sort by score (highest first)
sort.Slice(scores, func(i, j int) bool {
return scores[i].Score > scores[j].Score
})
selected := scores[0]
return &SchedulingDecision{
NodeID: selected.NodeID,
Reason: selected.Reason,
Score: selected.Score,
Alternatives: scores[1:],
}
}
// scheduleBestFit schedules containers on the node with the best resource fit
func (s *Scheduler) scheduleBestFit(nodes []*Node, requirements ResourceCapacity) *SchedulingDecision {
var scores []NodeScore
for _, node := range nodes {
if !s.canFitRequirements(node, requirements) {
continue
}
score := s.calculateFitScore(node, requirements)
scores = append(scores, NodeScore{
NodeID: node.ID,
Score: score,
Reason: "Best-fit score",
})
}
if len(scores) == 0 {
return nil
}
// Sort by score (highest first)
sort.Slice(scores, func(i, j int) bool {
return scores[i].Score > scores[j].Score
})
selected := scores[0]
return &SchedulingDecision{
NodeID: selected.NodeID,
Reason: selected.Reason,
Score: selected.Score,
Alternatives: scores[1:],
}
}
// scheduleRandom schedules containers on a random available node
func (s *Scheduler) scheduleRandom(nodes []*Node, requirements ResourceCapacity) *SchedulingDecision {
var availableNodes []*Node
for _, node := range nodes {
if s.canFitRequirements(node, requirements) {
availableNodes = append(availableNodes, node)
}
}
if len(availableNodes) == 0 {
return nil
}
// Simple random selection (in production, use proper random)
selectedNode := availableNodes[0] // For simplicity, just pick the first one
return &SchedulingDecision{
NodeID: selectedNode.ID,
Reason: "Random selection",
Score: 1.0,
}
}
// canFitRequirements checks if a node can accommodate the resource requirements
func (s *Scheduler) canFitRequirements(node *Node, requirements ResourceCapacity) bool {
availableCPU := node.Capacity.CPU - int64(node.Usage.CPU*float64(node.Capacity.CPU)/100)
availableMemory := node.Capacity.Memory - node.Usage.Memory
return availableCPU >= requirements.CPU && availableMemory >= requirements.Memory
}
// calculateLoadScore calculates a score based on node load
func (s *Scheduler) calculateLoadScore(node *Node) float64 {
// Lower load = higher score
cpuLoad := node.Usage.CPU / 100.0
memoryLoad := float64(node.Usage.Memory) / float64(node.Capacity.Memory)
containerLoad := float64(len(node.Containers)) / 10.0 // Assume max 10 containers
// Combined load score (0-1, where 0 is no load and 1 is full load)
combinedLoad := (cpuLoad + memoryLoad + containerLoad) / 3.0
// Convert to score where higher is better (1 - load)
return 1.0 - combinedLoad
}
// calculateFitScore calculates how well the requirements fit the node
func (s *Scheduler) calculateFitScore(node *Node, requirements ResourceCapacity) float64 {
availableCPU := node.Capacity.CPU - int64(node.Usage.CPU*float64(node.Capacity.CPU)/100)
availableMemory := node.Capacity.Memory - node.Usage.Memory
// Calculate utilization after placing this container
newCPUUtilization := float64(node.Capacity.CPU-availableCPU+requirements.CPU) / float64(node.Capacity.CPU)
newMemoryUtilization := float64(node.Capacity.Memory-availableMemory+requirements.Memory) / float64(node.Capacity.Memory)
// Prefer moderate utilization (not too low, not too high)
cpuScore := 1.0 - abs(newCPUUtilization-0.7)
memoryScore := 1.0 - abs(newMemoryUtilization-0.7)
return (cpuScore + memoryScore) / 2.0
}
// isNodeHealthy checks if a node is healthy based on heartbeat
func (s *Scheduler) isNodeHealthy(node *Node) bool {
return time.Since(node.LastHeartbeat) < 30*time.Second
}
// abs returns the absolute value of a float64
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
// SetSchedulingAlgorithm sets the scheduling algorithm
func (s *Scheduler) SetSchedulingAlgorithm(alg SchedulingAlgorithm) {
s.mu.Lock()
defer s.mu.Unlock()
s.schedulingAlg = alg
}
// GetNodeStats returns statistics about nodes
func (s *Scheduler) GetNodeStats() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
totalNodes := len(s.nodes)
readyNodes := 0
unhealthyNodes := 0
for _, node := range s.nodes {
if node.Status == "ready" {
if s.isNodeHealthy(node) {
readyNodes++
} else {
unhealthyNodes++
}
}
}
return map[string]interface{}{
"total_nodes": totalNodes,
"ready_nodes": readyNodes,
"unhealthy_nodes": unhealthyNodes,
"scheduling_alg": string(s.schedulingAlg),
}
}
+363
View File
@@ -0,0 +1,363 @@
package docker
import (
"context"
"fmt"
"io"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
)
// Client wraps the Docker client with additional functionality
type Client struct {
cli *client.Client
}
// NewClient creates a new Docker client
func NewClient() (*Client, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
// Test connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = cli.Ping(ctx)
if err != nil {
return nil, fmt.Errorf("failed to connect to Docker daemon: %w", err)
}
return &Client{cli: cli}, nil
}
// ListContainers returns all containers
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
return c.cli.ContainerList(ctx, container.ListOptions{
All: all,
})
}
// GetContainer returns detailed information about a specific container
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
return c.cli.ContainerInspect(ctx, containerID)
}
// CreateContainer creates a new container
func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (string, error) {
containerConfig := &container.Config{
Image: config.Image,
Cmd: config.Cmd,
Env: config.Env,
Labels: config.Labels,
}
hostConfig := &container.HostConfig{
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyMode(config.RestartPolicy),
},
PortBindings: config.PortBindings,
Mounts: config.Mounts,
Resources: container.Resources{
Memory: config.Memory,
NanoCPUs: config.NanoCPUs,
},
NetworkMode: container.NetworkMode(config.NetworkMode),
}
networkingConfig := &network.NetworkingConfig{
EndpointsConfig: config.Networks,
}
resp, err := c.cli.ContainerCreate(
ctx,
containerConfig,
hostConfig,
networkingConfig,
nil,
config.Name,
)
if err != nil {
return "", fmt.Errorf("failed to create container: %w", err)
}
return resp.ID, nil
}
// StartContainer starts a container
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
return c.cli.ContainerStart(ctx, containerID, container.StartOptions{})
}
// StopContainer stops a container
func (c *Client) StopContainer(ctx context.Context, containerID string, timeout *time.Duration) error {
var timeoutInt *int
if timeout != nil {
t := int(timeout.Seconds())
timeoutInt = &t
}
return c.cli.ContainerStop(ctx, containerID, container.StopOptions{
Timeout: timeoutInt,
})
}
// RestartContainer restarts a container
func (c *Client) RestartContainer(ctx context.Context, containerID string, timeout *time.Duration) error {
var timeoutInt *int
if timeout != nil {
t := int(timeout.Seconds())
timeoutInt = &t
}
return c.cli.ContainerRestart(ctx, containerID, container.StopOptions{
Timeout: timeoutInt,
})
}
// RemoveContainer removes a container
func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error {
return c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
Force: force,
})
}
// GetContainerLogs returns logs for a container
func (c *Client) GetContainerLogs(ctx context.Context, containerID string, options LogOptions) (io.ReadCloser, error) {
return c.cli.ContainerLogs(ctx, containerID, container.LogsOptions{
ShowStdout: options.Stdout,
ShowStderr: options.Stderr,
Follow: options.Follow,
Tail: options.Tail,
Timestamps: options.Timestamps,
})
}
// GetContainerStats returns real-time resource usage statistics for a container
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
if err != nil {
return nil, err
}
return &resp, nil
}
// ListImages returns all images
func (c *Client) ListImages(ctx context.Context, all bool) ([]image.Summary, error) {
return c.cli.ImageList(ctx, image.ListOptions{
All: all,
})
}
// PullImage pulls an image from a registry
func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) {
authStr, _ := registry.EncodeAuthConfig(auth)
return c.cli.ImagePull(ctx, ref, image.PullOptions{
RegistryAuth: authStr,
})
}
// BuildImage builds an image from a Dockerfile
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
Dockerfile: options.Dockerfile,
Tags: options.Tags,
BuildArgs: options.BuildArgs,
Labels: options.Labels,
Remove: options.Remove,
})
}
// RemoveImage removes an image
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{
Force: force,
})
}
// TagImage tags an image
func (c *Client) TagImage(ctx context.Context, imageID, ref string) error {
return c.cli.ImageTag(ctx, imageID, ref)
}
// ListNetworks returns all networks
func (c *Client) ListNetworks(ctx context.Context) ([]network.Summary, error) {
return c.cli.NetworkList(ctx, network.ListOptions{})
}
// CreateNetwork creates a new network
func (c *Client) CreateNetwork(ctx context.Context, config NetworkConfig) (string, error) {
resp, err := c.cli.NetworkCreate(ctx, config.Name, network.CreateOptions{
Driver: config.Driver,
Internal: config.Internal,
Labels: config.Labels,
})
if err != nil {
return "", err
}
return resp.ID, nil
}
// RemoveNetwork removes a network
func (c *Client) RemoveNetwork(ctx context.Context, networkID string) error {
return c.cli.NetworkRemove(ctx, networkID)
}
// ConnectNetwork connects a container to a network
func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error {
return c.cli.NetworkConnect(ctx, networkID, containerID, &config)
}
// DisconnectNetwork disconnects a container from a network
func (c *Client) DisconnectNetwork(ctx context.Context, networkID, containerID string, force bool) error {
return c.cli.NetworkDisconnect(ctx, networkID, containerID, force)
}
// ListVolumes returns all volumes
func (c *Client) ListVolumes(ctx context.Context) (volume.ListResponse, error) {
return c.cli.VolumeList(ctx, volume.ListOptions{})
}
// CreateVolume creates a new volume
func (c *Client) CreateVolume(ctx context.Context, config VolumeConfig) (volume.Volume, error) {
return c.cli.VolumeCreate(ctx, volume.CreateOptions{
Name: config.Name,
Driver: config.Driver,
Labels: config.Labels,
DriverOpts: config.DriverOpts,
})
}
// RemoveVolume removes a volume
func (c *Client) RemoveVolume(ctx context.Context, volumeID string, force bool) error {
return c.cli.VolumeRemove(ctx, volumeID, force)
}
// GetSystemInfo returns system-wide information
func (c *Client) GetSystemInfo(ctx context.Context) (system.Info, error) {
return c.cli.Info(ctx)
}
// GetDiskUsage returns Docker disk usage information
func (c *Client) GetDiskUsage(ctx context.Context) (types.DiskUsage, error) {
return c.cli.DiskUsage(ctx, types.DiskUsageOptions{})
}
// GetEvents returns Docker events
func (c *Client) GetEvents(ctx context.Context, options EventOptions) (io.ReadCloser, error) {
resp, errChan := c.cli.Events(ctx, events.ListOptions{
Since: options.Since,
Until: options.Until,
Filters: options.Filters,
})
// Convert the channel to a reader
r, w := io.Pipe()
go func() {
defer w.Close()
for {
select {
case event, ok := <-resp:
if !ok {
return
}
// Write event data to pipe
w.Write([]byte(fmt.Sprintf("%v\n", event)))
case err := <-errChan:
if err != nil {
w.CloseWithError(err)
return
}
case <-ctx.Done():
return
}
}
}()
return r, nil
}
// ExecCreate creates an exec instance in a container
func (c *Client) ExecCreate(ctx context.Context, containerID string, config ExecConfig) (types.IDResponse, error) {
return c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: config.Cmd,
Env: config.Env,
WorkingDir: config.WorkingDir,
User: config.User,
AttachStdin: config.AttachStdin,
AttachStdout: config.AttachStdout,
AttachStderr: config.AttachStderr,
Tty: config.Tty,
})
}
// ExecStart starts an exec instance
func (c *Client) ExecStart(ctx context.Context, execID string, config ExecStartConfig) error {
return c.cli.ContainerExecStart(ctx, execID, container.ExecStartOptions{
Detach: config.Detach,
Tty: config.Tty,
})
}
// ExecInspect returns information about an exec instance
func (c *Client) ExecInspect(ctx context.Context, execID string) (container.ExecInspect, error) {
return c.cli.ContainerExecInspect(ctx, execID)
}
// GetImageInfo returns information about a Docker image
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
images, err := c.cli.ImageList(ctx, image.ListOptions{})
if err != nil {
return nil, err
}
for _, img := range images {
for _, tag := range img.RepoTags {
if tag == imageName || tag == imageName+":latest" {
return &ImageInfo{
ID: img.ID,
RepoTags: img.RepoTags,
Size: img.Size,
Created: img.Created,
Labels: img.Labels,
RepoDigests: img.RepoDigests,
Digest: getDigestFromRepoTags(img.RepoDigests),
}, nil
}
}
}
return nil, fmt.Errorf("image not found: %s", imageName)
}
// PushImage pushes an image to a registry
func (c *Client) PushImage(ctx context.Context, imageName, registryURL string) error {
auth := registry.AuthConfig{}
authStr, _ := registry.EncodeAuthConfig(auth)
_, err := c.cli.ImagePush(ctx, imageName, image.PushOptions{
RegistryAuth: authStr,
})
return err
}
// Close closes the Docker client connection
func (c *Client) Close() error {
return c.cli.Close()
}
// Helper function to extract digest from repo digests
func getDigestFromRepoTags(digests []string) string {
if len(digests) > 0 {
return digests[0]
}
return ""
}
+281
View File
@@ -0,0 +1,281 @@
package docker
import (
"time"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat"
)
// ContainerConfig represents the configuration for creating a container
type ContainerConfig struct {
Name string
Image string
Cmd []string
Env []string
Labels map[string]string
RestartPolicy string
PortBindings nat.PortMap
Mounts []mount.Mount
Memory int64
NanoCPUs int64
NetworkMode string
Networks map[string]*network.EndpointSettings
}
// LogOptions represents options for retrieving container logs
type LogOptions struct {
Stdout bool
Stderr bool
Follow bool
Tail string
Timestamps bool
}
// BuildOptions represents options for building an image
type BuildOptions struct {
Dockerfile string
Tags []string
BuildArgs map[string]*string
Labels map[string]string
Remove bool
}
// NetworkConfig represents the configuration for creating a network
type NetworkConfig struct {
Name string
CheckDuplicate bool
Driver string
Internal bool
Labels map[string]string
}
// VolumeConfig represents the configuration for creating a volume
type VolumeConfig struct {
Name string
Driver string
Labels map[string]string
DriverOpts map[string]string
}
// EventOptions represents options for filtering Docker events
type EventOptions struct {
Since string
Until string
Filters filters.Args
}
// ExecConfig represents the configuration for creating an exec instance
type ExecConfig struct {
Cmd []string
Env []string
WorkingDir string
User string
AttachStdin bool
AttachStdout bool
AttachStderr bool
Tty bool
}
// ExecStartConfig represents the configuration for starting an exec instance
type ExecStartConfig struct {
Detach bool
Tty bool
}
// ServiceConfig represents a service configuration for deployment
type ServiceConfig struct {
Name string `json:"name"`
Image string `json:"image"`
Command []string `json:"command,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
RestartPolicy string `json:"restart_policy"`
PortMappings []PortMapping `json:"port_mappings,omitempty"`
VolumeMounts []VolumeMount `json:"volume_mounts,omitempty"`
Networks []string `json:"networks,omitempty"`
Resources ResourceLimits `json:"resources,omitempty"`
HealthCheck *HealthCheck `json:"health_check,omitempty"`
}
// PortMapping represents a port mapping configuration
type PortMapping struct {
ContainerPort int32 `json:"container_port"`
HostPort int32 `json:"host_port,omitempty"`
Protocol string `json:"protocol"` // tcp or udp
HostIP string `json:"host_ip,omitempty"`
}
// VolumeMount represents a volume mount configuration
type VolumeMount struct {
Type string `json:"type"` // bind, volume, tmpfs
Source string `json:"source"`
Destination string `json:"destination"`
ReadOnly bool `json:"read_only,omitempty"`
Consistency string `json:"consistency,omitempty"`
}
// ResourceLimits represents resource limits for a container
type ResourceLimits struct {
MemoryBytes int64 `json:"memory_bytes,omitempty"`
CPUQuota int64 `json:"cpu_quota,omitempty"`
CPUPeriod int64 `json:"cpu_period,omitempty"`
CPUShares int64 `json:"cpu_shares,omitempty"`
}
// HealthCheck represents a health check configuration
type HealthCheck struct {
Test []string `json:"test"`
Interval time.Duration `json:"interval"`
Timeout time.Duration `json:"timeout"`
Retries int `json:"retries"`
StartPeriod time.Duration `json:"start_period"`
}
// ServiceStatus represents the status of a service
type ServiceStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
Ports []PortInfo `json:"ports,omitempty"`
Networks []NetworkInfo `json:"networks,omitempty"`
Mounts []MountInfo `json:"mounts,omitempty"`
Resources ResourceUsage `json:"resources"`
Health *HealthStatus `json:"health,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// PortInfo represents port information for a running container
type PortInfo struct {
ContainerPort int32 `json:"container_port"`
HostPort int32 `json:"host_port,omitempty"`
HostIP string `json:"host_ip"`
Protocol string `json:"protocol"`
}
// NetworkInfo represents network information for a container
type NetworkInfo struct {
Name string `json:"name"`
NetworkID string `json:"network_id"`
IPAddress string `json:"ip_address"`
Gateway string `json:"gateway,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
}
// MountInfo represents mount information for a container
type MountInfo struct {
Type string `json:"type"`
Source string `json:"source"`
Destination string `json:"destination"`
ReadOnly bool `json:"read_only"`
}
// ResourceUsage represents resource usage for a container
type ResourceUsage struct {
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
NetworkRx int64 `json:"network_rx"`
NetworkTx int64 `json:"network_tx"`
BlockRead int64 `json:"block_read"`
BlockWrite int64 `json:"block_write"`
PidsCurrent uint64 `json:"pids_current"`
PidsLimit uint64 `json:"pids_limit"`
}
// HealthStatus represents the health status of a container
type HealthStatus struct {
Status string `json:"status"`
FailingStreak int `json:"failing_streak"`
LastCheck time.Time `json:"last_check"`
}
// RegistryConfig represents Docker registry configuration
type RegistryConfig struct {
URL string `json:"url"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Auth string `json:"auth,omitempty"`
Email string `json:"email,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// ImageInfo represents information about a Docker image
type ImageInfo struct {
ID string `json:"id"`
RepoTags []string `json:"repo_tags"`
Size int64 `json:"size"`
Created int64 `json:"created"`
Labels map[string]string `json:"labels"`
RepoDigests []string `json:"repo_digests"`
Digest string `json:"digest"`
}
// BuildContext represents a build context for Docker images
type BuildContext struct {
Dockerfile string `json:"dockerfile"`
Context string `json:"context"`
Tags []string `json:"tags"`
BuildArgs map[string]string `json:"build_args,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Target string `json:"target,omitempty"`
NoCache bool `json:"no_cache,omitempty"`
Remove bool `json:"remove,omitempty"`
ForceRm bool `json:"force_rm,omitempty"`
Pull bool `json:"pull,omitempty"`
}
// DeploymentConfig represents a deployment configuration
type DeploymentConfig struct {
Service ServiceConfig `json:"service"`
Replicas int `json:"replicas"`
Update UpdateConfig `json:"update,omitempty"`
Rollback RollbackConfig `json:"rollback,omitempty"`
Networks []NetworkConfig `json:"networks,omitempty"`
Volumes []VolumeConfig `json:"volumes,omitempty"`
Secrets []SecretConfig `json:"secrets,omitempty"`
Configs []ConfigFile `json:"configs,omitempty"`
}
// UpdateConfig represents update configuration for deployments
type UpdateConfig struct {
Parallelism uint `json:"parallelism"`
Delay time.Duration `json:"delay"`
FailureAction string `json:"failure_action"`
Monitor time.Duration `json:"monitor"`
MaxFailureRatio float64 `json:"max_failure_ratio"`
Order string `json:"order"`
}
// RollbackConfig represents rollback configuration
type RollbackConfig struct {
Parallelism uint `json:"parallelism"`
Delay time.Duration `json:"delay"`
FailureAction string `json:"failure_action"`
Monitor time.Duration `json:"monitor"`
MaxFailureRatio float64 `json:"max_failure_ratio"`
Order string `json:"order"`
}
// SecretConfig represents a secret configuration
type SecretConfig struct {
Name string `json:"name"`
Data string `json:"data"`
Labels map[string]string `json:"labels,omitempty"`
Driver string `json:"driver,omitempty"`
Template string `json:"template,omitempty"`
}
// ConfigFile represents a configuration file
type ConfigFile struct {
Name string `json:"name"`
File string `json:"file"`
Content string `json:"content"`
Labels map[string]string `json:"labels,omitempty"`
Template string `json:"template,omitempty"`
}
+736
View File
@@ -0,0 +1,736 @@
package ha
import (
"context"
"fmt"
"log"
"sync"
"time"
"containr/internal/deployment"
"containr/internal/metrics"
)
// HighAvailabilityManager manages high availability features
type HighAvailabilityManager struct {
scheduler *deployment.Scheduler
metricsCollector *metrics.MetricsCollector
failoverManager *FailoverManager
healthChecker *HealthChecker
alertManager *AlertManager
mu sync.RWMutex
enabled bool
checkInterval time.Duration
failoverThreshold int
}
// FailoverManager handles service failover operations
type FailoverManager struct {
scheduler *deployment.Scheduler
failoverPolicies map[string]*FailoverPolicy
mu sync.RWMutex
}
// FailoverPolicy defines failover behavior for a service
type FailoverPolicy struct {
ServiceID string `json:"service_id"`
Enabled bool `json:"enabled"`
MinHealthyNodes int `json:"min_healthy_nodes"`
MaxFailures int `json:"max_failures"`
FailoverTimeout time.Duration `json:"failover_timeout"`
RecoveryTimeout time.Duration `json:"recovery_timeout"`
FailoverStrategy FailoverStrategy `json:"failover_strategy"`
BackupNodes []string `json:"backup_nodes"`
HealthCheckConfig *HealthCheckConfig `json:"health_check_config"`
}
// FailoverStrategy defines how failover is performed
type FailoverStrategy string
const (
FailoverStrategyActivePassive FailoverStrategy = "active_passive"
FailoverStrategyActiveActive FailoverStrategy = "active_active"
FailoverStrategyGraceful FailoverStrategy = "graceful"
)
// HealthCheckConfig defines health check parameters
type HealthCheckConfig struct {
Interval time.Duration `json:"interval"`
Timeout time.Duration `json:"timeout"`
UnhealthyThreshold int `json:"unhealthy_threshold"`
HealthyThreshold int `json:"healthy_threshold"`
Path string `json:"path"`
Port int `json:"port"`
Protocol string `json:"protocol"`
}
// HealthChecker performs health checks on services and nodes
type HealthChecker struct {
scheduler *deployment.Scheduler
checks map[string]*HealthCheck
results map[string]*HealthCheckResult
mu sync.RWMutex
checkInterval time.Duration
}
// HealthCheck represents a health check configuration
type HealthCheck struct {
ID string `json:"id"`
ServiceID string `json:"service_id"`
NodeID string `json:"node_id"`
Type HealthCheckType `json:"type"`
Config HealthCheckConfig `json:"config"`
LastCheck time.Time `json:"last_check"`
Status HealthStatus `json:"status"`
}
// HealthCheckType represents the type of health check
type HealthCheckType string
const (
HealthCheckTypeHTTP HealthCheckType = "http"
HealthCheckTypeTCP HealthCheckType = "tcp"
HealthCheckTypeCommand HealthCheckType = "command"
)
// HealthStatus represents the health status
type HealthStatus string
const (
HealthStatusHealthy HealthStatus = "healthy"
HealthStatusUnhealthy HealthStatus = "unhealthy"
HealthStatusUnknown HealthStatus = "unknown"
)
// HealthCheckResult represents the result of a health check
type HealthCheckResult struct {
CheckID string `json:"check_id"`
Status HealthStatus `json:"status"`
Message string `json:"message"`
Latency time.Duration `json:"latency"`
Timestamp time.Time `json:"timestamp"`
ErrorCode string `json:"error_code,omitempty"`
}
// AlertManager handles alerting and notifications
type AlertManager struct {
rules map[string]*AlertRule
activeAlerts map[string]*Alert
notifiers map[string]Notifier
mu sync.RWMutex
}
// AlertRule defines when alerts should be triggered
type AlertRule struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Condition AlertCondition `json:"condition"`
Severity AlertSeverity `json:"severity"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
Notifiers []string `json:"notifiers"`
Cooldown time.Duration `json:"cooldown"`
}
// AlertCondition defines the condition for triggering an alert
type AlertCondition struct {
Metric string `json:"metric"`
Operator string `json:"operator"` // >, <, >=, <=, ==, !=
Threshold float64 `json:"threshold"`
Duration time.Duration `json:"duration"`
}
// AlertSeverity represents the severity level of an alert
type AlertSeverity string
const (
AlertSeverityCritical AlertSeverity = "critical"
AlertSeverityWarning AlertSeverity = "warning"
AlertSeverityInfo AlertSeverity = "info"
)
// Alert represents an active alert
type Alert struct {
ID string `json:"id"`
RuleID string `json:"rule_id"`
Status AlertStatus `json:"status"`
Severity AlertSeverity `json:"severity"`
Message string `json:"message"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// AlertStatus represents the status of an alert
type AlertStatus string
const (
AlertStatusFiring AlertStatus = "firing"
AlertStatusResolved AlertStatus = "resolved"
)
// Notifier sends alert notifications
type Notifier interface {
Send(ctx context.Context, alert *Alert) error
Type() string
}
// NewHighAvailabilityManager creates a new HA manager
func NewHighAvailabilityManager(scheduler *deployment.Scheduler, metricsCollector *metrics.MetricsCollector) *HighAvailabilityManager {
failoverManager := &FailoverManager{
scheduler: scheduler,
failoverPolicies: make(map[string]*FailoverPolicy),
}
healthChecker := &HealthChecker{
scheduler: scheduler,
checks: make(map[string]*HealthCheck),
results: make(map[string]*HealthCheckResult),
checkInterval: 30 * time.Second,
}
alertManager := &AlertManager{
rules: make(map[string]*AlertRule),
activeAlerts: make(map[string]*Alert),
notifiers: make(map[string]Notifier),
}
return &HighAvailabilityManager{
scheduler: scheduler,
metricsCollector: metricsCollector,
failoverManager: failoverManager,
healthChecker: healthChecker,
alertManager: alertManager,
enabled: true,
checkInterval: 30 * time.Second,
failoverThreshold: 3,
}
}
// Start starts the HA management process
func (ha *HighAvailabilityManager) Start(ctx context.Context) error {
ticker := time.NewTicker(ha.checkInterval)
defer ticker.Stop()
log.Printf("HighAvailabilityManager started with check interval: %v", ha.checkInterval)
// Start health checker
go ha.healthChecker.Start(ctx)
// Start alert manager
go ha.alertManager.Start(ctx)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if ha.enabled {
if err := ha.checkHighAvailability(ctx); err != nil {
log.Printf("Error during HA check: %v", err)
}
}
}
}
}
// checkHighAvailability performs HA checks and takes action if needed
func (ha *HighAvailabilityManager) checkHighAvailability(ctx context.Context) error {
// Check node health
nodes := ha.scheduler.GetNodes()
unhealthyNodes := 0
for _, node := range nodes {
if !ha.isNodeHealthy(node) {
unhealthyNodes++
log.Printf("Node %s is unhealthy", node.ID)
}
}
// Trigger failover if too many nodes are unhealthy
if unhealthyNodes >= ha.failoverThreshold {
log.Printf("Failover threshold reached: %d unhealthy nodes", unhealthyNodes)
if err := ha.failoverManager.TriggerFailover(ctx, "node_failure"); err != nil {
return fmt.Errorf("failed to trigger failover: %w", err)
}
}
return nil
}
// isNodeHealthy checks if a node is healthy
func (ha *HighAvailabilityManager) isNodeHealthy(node *deployment.Node) bool {
// Check if node is ready
if node.Status != "ready" {
return false
}
// Check heartbeat
if time.Since(node.LastHeartbeat) > 2*time.Minute {
return false
}
// Check resource usage
if node.Usage.CPU > 95 || node.Usage.Memory > int64(float64(node.Capacity.Memory)*0.95) {
return false
}
return true
}
// SetFailoverPolicy sets or updates a failover policy
func (ha *HighAvailabilityManager) SetFailoverPolicy(policy *FailoverPolicy) error {
ha.mu.Lock()
defer ha.mu.Unlock()
ha.failoverManager.SetFailoverPolicy(policy)
return nil
}
// GetFailoverPolicy returns a failover policy
func (ha *HighAvailabilityManager) GetFailoverPolicy(serviceID string) (*FailoverPolicy, error) {
ha.mu.RLock()
defer ha.mu.RUnlock()
return ha.failoverManager.GetFailoverPolicy(serviceID)
}
// TriggerFailover manually triggers a failover
func (ha *HighAvailabilityManager) TriggerFailover(ctx context.Context, reason string) error {
return ha.failoverManager.TriggerFailover(ctx, reason)
}
// GetHealthStatus returns the health status of all services and nodes
func (ha *HighAvailabilityManager) GetHealthStatus() map[string]interface{} {
ha.mu.RLock()
defer ha.mu.RUnlock()
nodes := ha.scheduler.GetNodes()
healthyNodes := 0
unhealthyNodes := 0
for _, node := range nodes {
if ha.isNodeHealthy(node) {
healthyNodes++
} else {
unhealthyNodes++
}
}
healthChecks := ha.healthChecker.GetAllHealthChecks()
healthyChecks := 0
unhealthyChecks := 0
for _, result := range ha.healthChecker.GetAllResults() {
if result.Status == HealthStatusHealthy {
healthyChecks++
} else {
unhealthyChecks++
}
}
activeAlerts := ha.alertManager.GetActiveAlerts()
return map[string]interface{}{
"nodes": map[string]interface{}{
"total": len(nodes),
"healthy": healthyNodes,
"unhealthy": unhealthyNodes,
},
"health_checks": map[string]interface{}{
"total": len(healthChecks),
"healthy": healthyChecks,
"unhealthy": unhealthyChecks,
},
"alerts": map[string]interface{}{
"active": len(activeAlerts),
},
"enabled": ha.enabled,
}
}
// Enable enables the HA manager
func (ha *HighAvailabilityManager) Enable() {
ha.mu.Lock()
defer ha.mu.Unlock()
ha.enabled = true
}
// Disable disables the HA manager
func (ha *HighAvailabilityManager) Disable() {
ha.mu.Lock()
defer ha.mu.Unlock()
ha.enabled = false
}
// IsEnabled returns whether the HA manager is enabled
func (ha *HighAvailabilityManager) IsEnabled() bool {
ha.mu.RLock()
defer ha.mu.RUnlock()
return ha.enabled
}
// FailoverManager methods
// SetFailoverPolicy sets a failover policy
func (fm *FailoverManager) SetFailoverPolicy(policy *FailoverPolicy) {
fm.mu.Lock()
defer fm.mu.Unlock()
fm.failoverPolicies[policy.ServiceID] = policy
}
// GetFailoverPolicy returns a failover policy
func (fm *FailoverManager) GetFailoverPolicy(serviceID string) (*FailoverPolicy, error) {
fm.mu.RLock()
defer fm.mu.RUnlock()
policy, exists := fm.failoverPolicies[serviceID]
if !exists {
return nil, fmt.Errorf("no failover policy found for service: %s", serviceID)
}
return policy, nil
}
// TriggerFailover triggers a failover for affected services
func (fm *FailoverManager) TriggerFailover(ctx context.Context, reason string) error {
fm.mu.RLock()
policies := make([]*FailoverPolicy, 0, len(fm.failoverPolicies))
for _, policy := range fm.failoverPolicies {
if policy.Enabled {
policies = append(policies, policy)
}
}
fm.mu.RUnlock()
for _, policy := range policies {
if err := fm.performFailover(ctx, policy, reason); err != nil {
log.Printf("Failed to perform failover for service %s: %v", policy.ServiceID, err)
}
}
return nil
}
// performFailover performs failover for a specific service
func (fm *FailoverManager) performFailover(ctx context.Context, policy *FailoverPolicy, reason string) error {
log.Printf("Performing failover for service %s: %s", policy.ServiceID, reason)
// In a real implementation, this would:
// 1. Identify healthy backup nodes
// 2. Start new instances on backup nodes
// 3. Update DNS/load balancer to point to new instances
// 4. Wait for health checks to pass
// 5. Shut down unhealthy instances
// For now, we'll just log the action
log.Printf("Failover completed for service %s", policy.ServiceID)
return nil
}
// HealthChecker methods
// Start starts the health checker
func (hc *HealthChecker) Start(ctx context.Context) error {
ticker := time.NewTicker(hc.checkInterval)
defer ticker.Stop()
log.Printf("HealthChecker started with check interval: %v", hc.checkInterval)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := hc.performHealthChecks(ctx); err != nil {
log.Printf("Error during health checks: %v", err)
}
}
}
}
// performHealthChecks performs all configured health checks
func (hc *HealthChecker) performHealthChecks(ctx context.Context) error {
hc.mu.RLock()
checks := make([]*HealthCheck, 0, len(hc.checks))
for _, check := range hc.checks {
checks = append(checks, check)
}
hc.mu.RUnlock()
for _, check := range checks {
result := hc.performHealthCheck(ctx, check)
hc.mu.Lock()
hc.results[check.ID] = result
hc.mu.Unlock()
}
return nil
}
// performHealthCheck performs a single health check
func (hc *HealthChecker) performHealthCheck(ctx context.Context, check *HealthCheck) *HealthCheckResult {
start := time.Now()
result := &HealthCheckResult{
CheckID: check.ID,
Timestamp: start,
Status: HealthStatusUnknown,
}
// In a real implementation, this would perform actual health checks
// For now, we'll simulate the check
time.Sleep(10 * time.Millisecond) // Simulate network latency
// Simulate healthy/unhealthy based on some logic
if time.Now().Unix()%10 == 0 { // 10% chance of being unhealthy
result.Status = HealthStatusUnhealthy
result.Message = "Service not responding"
result.ErrorCode = "TIMEOUT"
} else {
result.Status = HealthStatusHealthy
result.Message = "Service is healthy"
}
result.Latency = time.Since(start)
return result
}
// AddHealthCheck adds a new health check
func (hc *HealthChecker) AddHealthCheck(check *HealthCheck) {
hc.mu.Lock()
defer hc.mu.Unlock()
hc.checks[check.ID] = check
}
// RemoveHealthCheck removes a health check
func (hc *HealthChecker) RemoveHealthCheck(checkID string) {
hc.mu.Lock()
defer hc.mu.Unlock()
delete(hc.checks, checkID)
delete(hc.results, checkID)
}
// GetAllHealthChecks returns all health checks
func (hc *HealthChecker) GetAllHealthChecks() map[string]*HealthCheck {
hc.mu.RLock()
defer hc.mu.RUnlock()
result := make(map[string]*HealthCheck)
for id, check := range hc.checks {
result[id] = check
}
return result
}
// GetAllResults returns all health check results
func (hc *HealthChecker) GetAllResults() map[string]*HealthCheckResult {
hc.mu.RLock()
defer hc.mu.RUnlock()
result := make(map[string]*HealthCheckResult)
for id, checkResult := range hc.results {
result[id] = checkResult
}
return result
}
// AlertManager methods
// Start starts the alert manager
func (am *AlertManager) Start(ctx context.Context) error {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
log.Printf("AlertManager started")
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := am.evaluateAlertRules(ctx); err != nil {
log.Printf("Error evaluating alert rules: %v", err)
}
}
}
}
// evaluateAlertRules evaluates all alert rules and triggers alerts if needed
func (am *AlertManager) evaluateAlertRules(ctx context.Context) error {
am.mu.RLock()
rules := make([]*AlertRule, 0, len(am.rules))
for _, rule := range am.rules {
if rule.Enabled {
rules = append(rules, rule)
}
}
am.mu.RUnlock()
for _, rule := range rules {
if am.shouldTriggerAlert(rule) {
alert := am.createAlert(rule)
if err := am.triggerAlert(ctx, alert); err != nil {
log.Printf("Failed to trigger alert: %v", err)
}
}
}
return nil
}
// shouldTriggerAlert checks if an alert should be triggered
func (am *AlertManager) shouldTriggerAlert(rule *AlertRule) bool {
// In a real implementation, this would query metrics and evaluate the condition
// For now, we'll simulate based on time
return time.Now().Unix()%20 == 0 // 5% chance of triggering
}
// createAlert creates an alert from a rule
func (am *AlertManager) createAlert(rule *AlertRule) *Alert {
return &Alert{
ID: fmt.Sprintf("alert_%s_%d", rule.ID, time.Now().Unix()),
RuleID: rule.ID,
Status: AlertStatusFiring,
Severity: rule.Severity,
Message: fmt.Sprintf("Alert triggered: %s", rule.Name),
Labels: rule.Labels,
Annotations: rule.Annotations,
StartsAt: time.Now(),
UpdatedAt: time.Now(),
}
}
// triggerAlert triggers an alert
func (am *AlertManager) triggerAlert(ctx context.Context, alert *Alert) error {
am.mu.Lock()
am.activeAlerts[alert.ID] = alert
am.mu.Unlock()
// Send notifications
for _, notifierID := range am.getAlertRule(alert.RuleID).Notifiers {
if notifier, exists := am.notifiers[notifierID]; exists {
if err := notifier.Send(ctx, alert); err != nil {
log.Printf("Failed to send notification via %s: %v", notifierID, err)
}
}
}
log.Printf("Alert triggered: %s", alert.ID)
return nil
}
// getAlertRule returns the rule for an alert
func (am *AlertManager) getAlertRule(ruleID string) *AlertRule {
am.mu.RLock()
defer am.mu.RUnlock()
return am.rules[ruleID]
}
// AddAlertRule adds a new alert rule
func (am *AlertManager) AddAlertRule(rule *AlertRule) {
am.mu.Lock()
defer am.mu.Unlock()
am.rules[rule.ID] = rule
}
// RemoveAlertRule removes an alert rule
func (am *AlertManager) RemoveAlertRule(ruleID string) {
am.mu.Lock()
defer am.mu.Unlock()
delete(am.rules, ruleID)
}
// AddNotifier adds a new notifier
func (am *AlertManager) AddNotifier(id string, notifier Notifier) {
am.mu.Lock()
defer am.mu.Unlock()
am.notifiers[id] = notifier
}
// GetActiveAlerts returns all active alerts
func (am *AlertManager) GetActiveAlerts() map[string]*Alert {
am.mu.RLock()
defer am.mu.RUnlock()
result := make(map[string]*Alert)
for id, alert := range am.activeAlerts {
result[id] = alert
}
return result
}
// ResolveAlert resolves an alert
func (am *AlertManager) ResolveAlert(alertID string) {
am.mu.Lock()
defer am.mu.Unlock()
if alert, exists := am.activeAlerts[alertID]; exists {
now := time.Now()
alert.Status = AlertStatusResolved
alert.EndsAt = &now
alert.UpdatedAt = now
}
delete(am.activeAlerts, alertID)
}
// Mock Notifier implementations
// EmailNotifier sends alerts via email
type EmailNotifier struct {
SMTPHost string
SMTPPort int
Username string
Password string
From string
To []string
}
func (n *EmailNotifier) Send(ctx context.Context, alert *Alert) error {
log.Printf("Sending email alert: %s", alert.Message)
// In a real implementation, this would send an actual email
return nil
}
func (n *EmailNotifier) Type() string {
return "email"
}
// SlackNotifier sends alerts to Slack
type SlackNotifier struct {
WebhookURL string
Channel string
}
func (n *SlackNotifier) Send(ctx context.Context, alert *Alert) error {
log.Printf("Sending Slack alert: %s", alert.Message)
// In a real implementation, this would send to Slack webhook
return nil
}
func (n *SlackNotifier) Type() string {
return "slack"
}
// WebhookNotifier sends alerts via webhook
type WebhookNotifier struct {
URL string
}
func (n *WebhookNotifier) Send(ctx context.Context, alert *Alert) error {
log.Printf("Sending webhook alert: %s", alert.Message)
// In a real implementation, this would send HTTP request
return nil
}
func (n *WebhookNotifier) Type() string {
return "webhook"
}
+473
View File
@@ -0,0 +1,473 @@
package metrics
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"containr/internal/deployment"
)
// MetricsCollector collects and aggregates metrics from nodes and services
type MetricsCollector struct {
nodes map[string]*NodeMetrics
services map[string]*ServiceMetrics
scheduler *deployment.Scheduler
mu sync.RWMutex
collectInterval time.Duration
storage MetricsStorage
}
// NodeMetrics represents metrics for a node
type NodeMetrics struct {
NodeID string `json:"node_id"`
Timestamp time.Time `json:"timestamp"`
CPU CPUMetrics `json:"cpu"`
Memory MemoryMetrics `json:"memory"`
Storage StorageMetrics `json:"storage"`
Network NetworkMetrics `json:"network"`
Containers []ContainerMetrics `json:"containers"`
System SystemMetrics `json:"system"`
}
// ServiceMetrics represents metrics for a service
type ServiceMetrics struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
Timestamp time.Time `json:"timestamp"`
Instances []InstanceMetrics `json:"instances"`
Requests RequestMetrics `json:"requests"`
Errors ErrorMetrics `json:"errors"`
Performance PerformanceMetrics `json:"performance"`
Resources ResourceMetrics `json:"resources"`
}
// InstanceMetrics represents metrics for a service instance
type InstanceMetrics struct {
InstanceID string `json:"instance_id"`
NodeID string `json:"node_id"`
Status string `json:"status"`
CPU float64 `json:"cpu"` // CPU usage percentage
Memory int64 `json:"memory"` // Memory usage in bytes
Network NetworkMetrics `json:"network"`
StartTime time.Time `json:"start_time"`
LastSeen time.Time `json:"last_seen"`
Health HealthMetrics `json:"health"`
}
// CPUMetrics represents CPU metrics
type CPUMetrics struct {
UsagePercent float64 `json:"usage_percent"`
UsageCores float64 `json:"usage_cores"`
LoadAverage1 float64 `json:"load_average_1"`
LoadAverage5 float64 `json:"load_average_5"`
LoadAverage15 float64 `json:"load_average_15"`
}
// MemoryMetrics represents memory metrics
type MemoryMetrics struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
Available int64 `json:"available"`
UsagePercent float64 `json:"usage_percent"`
SwapTotal int64 `json:"swap_total"`
SwapUsed int64 `json:"swap_used"`
}
// StorageMetrics represents storage metrics
type StorageMetrics struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
Available int64 `json:"available"`
UsagePercent float64 `json:"usage_percent"`
IOPS int64 `json:"iops"`
Throughput int64 `json:"throughput"`
}
// NetworkMetrics represents network metrics
type NetworkMetrics struct {
BytesIn int64 `json:"bytes_in"`
BytesOut int64 `json:"bytes_out"`
PacketsIn int64 `json:"packets_in"`
PacketsOut int64 `json:"packets_out"`
ConnectionsIn int64 `json:"connections_in"`
ConnectionsOut int64 `json:"connections_out"`
ErrorsIn int64 `json:"errors_in"`
ErrorsOut int64 `json:"errors_out"`
}
// ContainerMetrics represents metrics for containers
type ContainerMetrics struct {
ContainerID string `json:"container_id"`
Name string `json:"name"`
State string `json:"state"`
CPU float64 `json:"cpu"`
Memory int64 `json:"memory"`
Network NetworkMetrics `json:"network"`
StartTime time.Time `json:"start_time"`
}
// SystemMetrics represents system-level metrics
type SystemMetrics struct {
Uptime time.Duration `json:"uptime"`
Processes int `json:"processes"`
OS string `json:"os"`
Kernel string `json:"kernel"`
Architecture string `json:"architecture"`
}
// RequestMetrics represents HTTP/request metrics
type RequestMetrics struct {
Total int64 `json:"total"`
Success int64 `json:"success"`
Errors int64 `json:"errors"`
AvgLatency float64 `json:"avg_latency"`
P95Latency float64 `json:"p95_latency"`
P99Latency float64 `json:"p99_latency"`
Throughput float64 `json:"throughput"`
}
// ErrorMetrics represents error metrics
type ErrorMetrics struct {
Total int64 `json:"total"`
ByType map[string]int64 `json:"by_type"`
ByStatusCode map[string]int64 `json:"by_status_code"`
Rate float64 `json:"rate"`
}
// PerformanceMetrics represents performance metrics
type PerformanceMetrics struct {
ResponseTime float64 `json:"response_time"`
Throughput float64 `json:"throughput"`
Concurrency int64 `json:"concurrency"`
Saturation float64 `json:"saturation"`
Utilization float64 `json:"utilization"`
}
// ResourceMetrics represents resource utilization metrics
type ResourceMetrics struct {
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage int64 `json:"memory_usage"`
StorageUsage int64 `json:"storage_usage"`
NetworkUsage int64 `json:"network_usage"`
ResourceScore float64 `json:"resource_score"`
}
// HealthMetrics represents health metrics
type HealthMetrics struct {
Status string `json:"status"`
LastCheck time.Time `json:"last_check"`
CheckCount int `json:"check_count"`
FailureCount int `json:"failure_count"`
Uptime time.Duration `json:"uptime"`
}
// MetricsStorage defines the interface for metrics storage
type MetricsStorage interface {
StoreNodeMetrics(ctx context.Context, metrics *NodeMetrics) error
StoreServiceMetrics(ctx context.Context, metrics *ServiceMetrics) error
GetNodeMetrics(ctx context.Context, nodeID string, from, to time.Time) ([]*NodeMetrics, error)
GetServiceMetrics(ctx context.Context, serviceID string, from, to time.Time) ([]*ServiceMetrics, error)
GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error)
}
// MetricsQuery represents a query for aggregated metrics
type MetricsQuery struct {
Type string `json:"type"` // node, service, project
ID string `json:"id"` // node_id, service_id, project_id
Metrics []string `json:"metrics"` // cpu, memory, network, etc.
From time.Time `json:"from"`
To time.Time `json:"to"`
Interval time.Duration `json:"interval"`
GroupBy []string `json:"group_by"`
Filters map[string]string `json:"filters"`
}
// AggregatedMetrics represents aggregated metrics data
type AggregatedMetrics struct {
Query MetricsQuery `json:"query"`
TimeSeries []TimeSeriesPoint `json:"time_series"`
Summary map[string]MetricSummary `json:"summary"`
}
// TimeSeriesPoint represents a point in a time series
type TimeSeriesPoint struct {
Timestamp time.Time `json:"timestamp"`
Values map[string]float64 `json:"values"`
}
// MetricSummary represents summary statistics for a metric
type MetricSummary struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
Avg float64 `json:"avg"`
P50 float64 `json:"p50"`
P95 float64 `json:"p95"`
P99 float64 `json:"p99"`
Count int64 `json:"count"`
}
// NewMetricsCollector creates a new metrics collector
func NewMetricsCollector(scheduler *deployment.Scheduler, storage MetricsStorage) *MetricsCollector {
return &MetricsCollector{
nodes: make(map[string]*NodeMetrics),
services: make(map[string]*ServiceMetrics),
scheduler: scheduler,
collectInterval: 30 * time.Second,
storage: storage,
}
}
// Start starts the metrics collection process
func (mc *MetricsCollector) Start(ctx context.Context) error {
ticker := time.NewTicker(mc.collectInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := mc.collectMetrics(ctx); err != nil {
fmt.Printf("Error collecting metrics: %v\n", err)
}
}
}
}
// collectMetrics collects metrics from all nodes and services
func (mc *MetricsCollector) collectMetrics(ctx context.Context) error {
// Collect node metrics
nodes := mc.scheduler.GetNodes()
for _, node := range nodes {
metrics, err := mc.collectNodeMetrics(ctx, node)
if err != nil {
fmt.Printf("Error collecting metrics for node %s: %v\n", node.ID, err)
continue
}
mc.mu.Lock()
mc.nodes[node.ID] = metrics
mc.mu.Unlock()
// Store metrics
if err := mc.storage.StoreNodeMetrics(ctx, metrics); err != nil {
fmt.Printf("Error storing node metrics: %v\n", err)
}
}
// TODO: Collect service metrics
// This would involve querying service instances and collecting their metrics
return nil
}
// collectNodeMetrics collects metrics from a specific node
func (mc *MetricsCollector) collectNodeMetrics(ctx context.Context, node *deployment.Node) (*NodeMetrics, error) {
// In a real implementation, this would collect actual metrics from the node
// For now, we'll simulate metrics collection
now := time.Now()
metrics := &NodeMetrics{
NodeID: node.ID,
Timestamp: now,
CPU: CPUMetrics{
UsagePercent: node.Usage.CPU,
UsageCores: node.Usage.CPU * float64(node.Capacity.CPU) / 100,
LoadAverage1: 1.5,
LoadAverage5: 1.8,
LoadAverage15: 2.1,
},
Memory: MemoryMetrics{
Total: node.Capacity.Memory,
Used: node.Usage.Memory,
Available: node.Capacity.Memory - node.Usage.Memory,
UsagePercent: float64(node.Usage.Memory) / float64(node.Capacity.Memory) * 100,
SwapTotal: 1024 * 1024 * 1024, // 1GB
SwapUsed: 512 * 1024 * 1024, // 512MB
},
Storage: StorageMetrics{
Total: node.Capacity.Storage,
Used: node.Usage.Storage,
Available: node.Capacity.Storage - node.Usage.Storage,
UsagePercent: float64(node.Usage.Storage) / float64(node.Capacity.Storage) * 100,
IOPS: 1000,
Throughput: 1024 * 1024 * 100, // 100MB/s
},
Network: NetworkMetrics{
BytesIn: node.Usage.Network,
BytesOut: node.Usage.Network,
PacketsIn: 10000,
PacketsOut: 8000,
ConnectionsIn: 50,
ConnectionsOut: 30,
ErrorsIn: 0,
ErrorsOut: 0,
},
Containers: []ContainerMetrics{},
System: SystemMetrics{
Uptime: time.Since(node.LastHeartbeat),
Processes: 150,
OS: "linux",
Kernel: "5.15.0",
Architecture: "x86_64",
},
}
// Collect container metrics for this node
for _, containerID := range node.Containers {
containerMetrics := mc.collectContainerMetrics(containerID)
metrics.Containers = append(metrics.Containers, containerMetrics)
}
return metrics, nil
}
// collectContainerMetrics collects metrics for a specific container
func (mc *MetricsCollector) collectContainerMetrics(containerID string) ContainerMetrics {
// In a real implementation, this would query Docker/container runtime
return ContainerMetrics{
ContainerID: containerID,
Name: fmt.Sprintf("container-%s", containerID[:8]),
State: "running",
CPU: 25.5,
Memory: 512 * 1024 * 1024, // 512MB
Network: NetworkMetrics{
BytesIn: 1024 * 1024 * 10, // 10MB
BytesOut: 1024 * 1024 * 8, // 8MB
PacketsIn: 1000,
PacketsOut: 800,
},
StartTime: time.Now().Add(-1 * time.Hour),
}
}
// GetNodeMetrics returns the latest metrics for a node
func (mc *MetricsCollector) GetNodeMetrics(nodeID string) (*NodeMetrics, error) {
mc.mu.RLock()
defer mc.mu.RUnlock()
metrics, exists := mc.nodes[nodeID]
if !exists {
return nil, fmt.Errorf("no metrics found for node: %s", nodeID)
}
return metrics, nil
}
// GetAllNodeMetrics returns metrics for all nodes
func (mc *MetricsCollector) GetAllNodeMetrics() map[string]*NodeMetrics {
mc.mu.RLock()
defer mc.mu.RUnlock()
// Return a copy to avoid race conditions
result := make(map[string]*NodeMetrics)
for id, metrics := range mc.nodes {
result[id] = metrics
}
return result
}
// GetServiceMetrics returns the latest metrics for a service
func (mc *MetricsCollector) GetServiceMetrics(serviceID string) (*ServiceMetrics, error) {
mc.mu.RLock()
defer mc.mu.RUnlock()
metrics, exists := mc.services[serviceID]
if !exists {
return nil, fmt.Errorf("no metrics found for service: %s", serviceID)
}
return metrics, nil
}
// GetAggregatedMetrics returns aggregated metrics based on a query
func (mc *MetricsCollector) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) {
return mc.storage.GetAggregatedMetrics(ctx, query)
}
// GetMetricsSummary returns a summary of all metrics
func (mc *MetricsCollector) GetMetricsSummary() map[string]interface{} {
mc.mu.RLock()
defer mc.mu.RUnlock()
totalNodes := len(mc.nodes)
totalServices := len(mc.services)
healthyNodes := 0
totalCPU := 0.0
totalMemory := int64(0)
for _, metrics := range mc.nodes {
if metrics.CPU.UsagePercent < 80 {
healthyNodes++
}
totalCPU += metrics.CPU.UsagePercent
totalMemory += metrics.Memory.Used
}
avgCPU := float64(0)
if totalNodes > 0 {
avgCPU = totalCPU / float64(totalNodes)
}
return map[string]interface{}{
"total_nodes": totalNodes,
"healthy_nodes": healthyNodes,
"total_services": totalServices,
"avg_cpu_usage": avgCPU,
"total_memory": totalMemory,
"collect_interval": mc.collectInterval.String(),
"last_collection": time.Now().Format(time.RFC3339),
}
}
// ExportMetrics exports metrics in various formats
func (mc *MetricsCollector) ExportMetrics(format string) ([]byte, error) {
mc.mu.RLock()
defer mc.mu.RUnlock()
data := map[string]interface{}{
"nodes": mc.nodes,
"services": mc.services,
"timestamp": time.Now(),
}
switch format {
case "json":
return json.MarshalIndent(data, "", " ")
case "prometheus":
return mc.exportPrometheusFormat()
default:
return nil, fmt.Errorf("unsupported export format: %s", format)
}
}
// exportPrometheusFormat exports metrics in Prometheus format
func (mc *MetricsCollector) exportPrometheusFormat() ([]byte, error) {
var output []string
for nodeID, metrics := range mc.nodes {
// Node CPU metrics
output = append(output, fmt.Sprintf("# HELP node_cpu_usage_percent CPU usage percentage for node"))
output = append(output, fmt.Sprintf("# TYPE node_cpu_usage_percent gauge"))
output = append(output, fmt.Sprintf("node_cpu_usage_percent{node=\"%s\"} %f", nodeID, metrics.CPU.UsagePercent))
// Node memory metrics
output = append(output, fmt.Sprintf("# HELP node_memory_usage_bytes Memory usage in bytes for node"))
output = append(output, fmt.Sprintf("# TYPE node_memory_usage_bytes gauge"))
output = append(output, fmt.Sprintf("node_memory_usage_bytes{node=\"%s\"} %d", nodeID, metrics.Memory.Used))
// Node network metrics
output = append(output, fmt.Sprintf("# HELP node_network_bytes_in Total bytes received for node"))
output = append(output, fmt.Sprintf("# TYPE node_network_bytes_in counter"))
output = append(output, fmt.Sprintf("node_network_bytes_in{node=\"%s\"} %d", nodeID, metrics.Network.BytesIn))
}
result := []byte(strings.Join(output, "\n"))
return result, nil
}
+553
View File
@@ -0,0 +1,553 @@
package metrics
import (
"context"
"database/sql"
"fmt"
"sync"
"time"
_ "github.com/lib/pq"
)
// PostgreSQLMetricsStorage implements MetricsStorage using PostgreSQL
type PostgreSQLMetricsStorage struct {
db *sql.DB
}
// NewPostgreSQLMetricsStorage creates a new PostgreSQL metrics storage
func NewPostgreSQLMetricsStorage(db *sql.DB) *PostgreSQLMetricsStorage {
return &PostgreSQLMetricsStorage{db: db}
}
// StoreNodeMetrics stores node metrics in the database
func (s *PostgreSQLMetricsStorage) StoreNodeMetrics(ctx context.Context, metrics *NodeMetrics) error {
query := `
INSERT INTO node_metrics (
node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)
ON CONFLICT (node_id, timestamp) DO UPDATE SET
cpu_usage = EXCLUDED.cpu_usage,
cpu_cores = EXCLUDED.cpu_cores,
load_avg_1 = EXCLUDED.load_avg_1,
load_avg_5 = EXCLUDED.load_avg_5,
load_avg_15 = EXCLUDED.load_avg_15,
memory_total = EXCLUDED.memory_total,
memory_used = EXCLUDED.memory_used,
memory_available = EXCLUDED.memory_available,
memory_usage_percent = EXCLUDED.memory_usage_percent,
storage_total = EXCLUDED.storage_total,
storage_used = EXCLUDED.storage_used,
storage_available = EXCLUDED.storage_available,
storage_usage_percent = EXCLUDED.storage_usage_percent,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
uptime = EXCLUDED.uptime,
processes = EXCLUDED.processes,
os = EXCLUDED.os,
kernel = EXCLUDED.kernel,
architecture = EXCLUDED.architecture
`
_, err := s.db.ExecContext(ctx, query,
metrics.NodeID, metrics.Timestamp, metrics.CPU.UsagePercent, metrics.CPU.UsageCores,
metrics.CPU.LoadAverage1, metrics.CPU.LoadAverage5, metrics.CPU.LoadAverage15,
metrics.Memory.Total, metrics.Memory.Used, metrics.Memory.Available, metrics.Memory.UsagePercent,
metrics.Storage.Total, metrics.Storage.Used, metrics.Storage.Available, metrics.Storage.UsagePercent,
metrics.Network.BytesIn, metrics.Network.BytesOut, metrics.Network.PacketsIn, metrics.Network.PacketsOut,
metrics.Network.ConnectionsIn, metrics.Network.ConnectionsOut, metrics.Network.ErrorsIn, metrics.Network.ErrorsOut,
metrics.System.Uptime, metrics.System.Processes, metrics.System.OS, metrics.System.Kernel, metrics.System.Architecture,
)
if err != nil {
return fmt.Errorf("failed to store node metrics: %w", err)
}
// Store container metrics
for _, container := range metrics.Containers {
if err := s.storeContainerMetrics(ctx, metrics.NodeID, metrics.Timestamp, container); err != nil {
return fmt.Errorf("failed to store container metrics: %w", err)
}
}
return nil
}
// StoreServiceMetrics stores service metrics in the database
func (s *PostgreSQLMetricsStorage) StoreServiceMetrics(ctx context.Context, metrics *ServiceMetrics) error {
query := `
INSERT INTO service_metrics (
service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)
ON CONFLICT (service_id, timestamp) DO UPDATE SET
requests_total = EXCLUDED.requests_total,
requests_success = EXCLUDED.requests_success,
requests_errors = EXCLUDED.requests_errors,
requests_avg_latency = EXCLUDED.requests_avg_latency,
requests_p95_latency = EXCLUDED.requests_p95_latency,
requests_p99_latency = EXCLUDED.requests_p99_latency,
requests_throughput = EXCLUDED.requests_throughput,
errors_total = EXCLUDED.errors_total,
errors_rate = EXCLUDED.errors_rate,
performance_response_time = EXCLUDED.performance_response_time,
performance_throughput = EXCLUDED.performance_throughput,
performance_concurrency = EXCLUDED.performance_concurrency,
performance_saturation = EXCLUDED.performance_saturation,
performance_utilization = EXCLUDED.performance_utilization,
resource_cpu_usage = EXCLUDED.resource_cpu_usage,
resource_memory_usage = EXCLUDED.resource_memory_usage,
resource_storage_usage = EXCLUDED.resource_storage_usage,
resource_network_usage = EXCLUDED.resource_network_usage,
resource_score = EXCLUDED.resource_score
`
_, err := s.db.ExecContext(ctx, query,
metrics.ServiceID, metrics.ServiceName, metrics.ProjectID, metrics.Timestamp,
metrics.Requests.Total, metrics.Requests.Success, metrics.Requests.Errors,
metrics.Requests.AvgLatency, metrics.Requests.P95Latency, metrics.Requests.P99Latency,
metrics.Requests.Throughput, metrics.Errors.Total, metrics.Errors.Rate,
metrics.Performance.ResponseTime, metrics.Performance.Throughput,
metrics.Performance.Concurrency, metrics.Performance.Saturation, metrics.Performance.Utilization,
metrics.Resources.CPUUsage, metrics.Resources.MemoryUsage, metrics.Resources.StorageUsage,
metrics.Resources.NetworkUsage, metrics.Resources.ResourceScore,
)
if err != nil {
return fmt.Errorf("failed to store service metrics: %w", err)
}
// Store instance metrics
for _, instance := range metrics.Instances {
if err := s.storeInstanceMetrics(ctx, metrics.ServiceID, metrics.Timestamp, instance); err != nil {
return fmt.Errorf("failed to store instance metrics: %w", err)
}
}
return nil
}
// GetNodeMetrics retrieves node metrics from the database
func (s *PostgreSQLMetricsStorage) GetNodeMetrics(ctx context.Context, nodeID string, from, to time.Time) ([]*NodeMetrics, error) {
query := `
SELECT node_id, timestamp, cpu_usage, cpu_cores, load_avg_1, load_avg_5, load_avg_15,
memory_total, memory_used, memory_available, memory_usage_percent,
storage_total, storage_used, storage_available, storage_usage_percent,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
uptime, processes, os, kernel, architecture
FROM node_metrics
WHERE node_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
rows, err := s.db.QueryContext(ctx, query, nodeID, from, to)
if err != nil {
return nil, fmt.Errorf("failed to query node metrics: %w", err)
}
defer rows.Close()
var metrics []*NodeMetrics
for rows.Next() {
var m NodeMetrics
err := rows.Scan(
&m.NodeID, &m.Timestamp, &m.CPU.UsagePercent, &m.CPU.UsageCores,
&m.CPU.LoadAverage1, &m.CPU.LoadAverage5, &m.CPU.LoadAverage15,
&m.Memory.Total, &m.Memory.Used, &m.Memory.Available, &m.Memory.UsagePercent,
&m.Storage.Total, &m.Storage.Used, &m.Storage.Available, &m.Storage.UsagePercent,
&m.Network.BytesIn, &m.Network.BytesOut, &m.Network.PacketsIn, &m.Network.PacketsOut,
&m.Network.ConnectionsIn, &m.Network.ConnectionsOut, &m.Network.ErrorsIn, &m.Network.ErrorsOut,
&m.System.Uptime, &m.System.Processes, &m.System.OS, &m.System.Kernel, &m.System.Architecture,
)
if err != nil {
return nil, fmt.Errorf("failed to scan node metrics: %w", err)
}
// Get container metrics for this timestamp
containers, err := s.getContainerMetrics(ctx, nodeID, m.Timestamp)
if err != nil {
return nil, fmt.Errorf("failed to get container metrics: %w", err)
}
m.Containers = containers
metrics = append(metrics, &m)
}
return metrics, nil
}
// GetServiceMetrics retrieves service metrics from the database
func (s *PostgreSQLMetricsStorage) GetServiceMetrics(ctx context.Context, serviceID string, from, to time.Time) ([]*ServiceMetrics, error) {
query := `
SELECT service_id, service_name, project_id, timestamp,
requests_total, requests_success, requests_errors, requests_avg_latency,
requests_p95_latency, requests_p99_latency, requests_throughput,
errors_total, errors_rate, performance_response_time, performance_throughput,
performance_concurrency, performance_saturation, performance_utilization,
resource_cpu_usage, resource_memory_usage, resource_storage_usage,
resource_network_usage, resource_score
FROM service_metrics
WHERE service_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp ASC
`
rows, err := s.db.QueryContext(ctx, query, serviceID, from, to)
if err != nil {
return nil, fmt.Errorf("failed to query service metrics: %w", err)
}
defer rows.Close()
var metrics []*ServiceMetrics
for rows.Next() {
var m ServiceMetrics
err := rows.Scan(
&m.ServiceID, &m.ServiceName, &m.ProjectID, &m.Timestamp,
&m.Requests.Total, &m.Requests.Success, &m.Requests.Errors,
&m.Requests.AvgLatency, &m.Requests.P95Latency, &m.Requests.P99Latency,
&m.Requests.Throughput, &m.Errors.Total, &m.Errors.Rate,
&m.Performance.ResponseTime, &m.Performance.Throughput,
&m.Performance.Concurrency, &m.Performance.Saturation, &m.Performance.Utilization,
&m.Resources.CPUUsage, &m.Resources.MemoryUsage, &m.Resources.StorageUsage,
&m.Resources.NetworkUsage, &m.Resources.ResourceScore,
)
if err != nil {
return nil, fmt.Errorf("failed to scan service metrics: %w", err)
}
// Get instance metrics for this timestamp
instances, err := s.getInstanceMetrics(ctx, serviceID, m.Timestamp)
if err != nil {
return nil, fmt.Errorf("failed to get instance metrics: %w", err)
}
m.Instances = instances
metrics = append(metrics, &m)
}
return metrics, nil
}
// GetAggregatedMetrics retrieves aggregated metrics based on a query
func (s *PostgreSQLMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) {
// This is a simplified implementation
// In a real system, you'd build dynamic SQL based on the query
var timeSeries []TimeSeriesPoint
var summary map[string]MetricSummary
switch query.Type {
case "node":
// Aggregate node metrics
nodeQuery := `
SELECT
time_bucket($1, timestamp) AS bucket,
AVG(cpu_usage) as avg_cpu,
AVG(memory_usage_percent) as avg_memory,
AVG(storage_usage_percent) as avg_storage
FROM node_metrics
WHERE node_id = $2 AND timestamp BETWEEN $3 AND $4
GROUP BY bucket
ORDER BY bucket ASC
`
rows, err := s.db.QueryContext(ctx, nodeQuery, query.Interval, query.ID, query.From, query.To)
if err != nil {
return nil, fmt.Errorf("failed to query aggregated node metrics: %w", err)
}
defer rows.Close()
for rows.Next() {
var bucket time.Time
var avgCPU, avgMemory, avgStorage float64
if err := rows.Scan(&bucket, &avgCPU, &avgMemory, &avgStorage); err != nil {
return nil, fmt.Errorf("failed to scan aggregated metrics: %w", err)
}
point := TimeSeriesPoint{
Timestamp: bucket,
Values: map[string]float64{
"cpu_usage": avgCPU,
"memory_usage": avgMemory,
"storage_usage": avgStorage,
},
}
timeSeries = append(timeSeries, point)
}
// Calculate summary statistics
summary = map[string]MetricSummary{
"cpu_usage": calculateSummary(timeSeries, "cpu_usage"),
"memory_usage": calculateSummary(timeSeries, "memory_usage"),
"storage_usage": calculateSummary(timeSeries, "storage_usage"),
}
}
return &AggregatedMetrics{
Query: query,
TimeSeries: timeSeries,
Summary: summary,
}, nil
}
// Helper methods
func (s *PostgreSQLMetricsStorage) storeContainerMetrics(ctx context.Context, nodeID string, timestamp time.Time, container ContainerMetrics) error {
query := `
INSERT INTO container_metrics (
node_id, timestamp, container_id, name, state, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, start_time
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (node_id, timestamp, container_id) DO UPDATE SET
name = EXCLUDED.name,
state = EXCLUDED.state,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
start_time = EXCLUDED.start_time
`
_, err := s.db.ExecContext(ctx, query,
nodeID, timestamp, container.ContainerID, container.Name, container.State,
container.CPU, container.Memory, container.Network.BytesIn, container.Network.BytesOut,
container.Network.PacketsIn, container.Network.PacketsOut, container.StartTime,
)
return err
}
func (s *PostgreSQLMetricsStorage) storeInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time, instance InstanceMetrics) error {
query := `
INSERT INTO instance_metrics (
service_id, timestamp, instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
ON CONFLICT (service_id, timestamp, instance_id) DO UPDATE SET
node_id = EXCLUDED.node_id,
status = EXCLUDED.status,
cpu = EXCLUDED.cpu,
memory = EXCLUDED.memory,
network_bytes_in = EXCLUDED.network_bytes_in,
network_bytes_out = EXCLUDED.network_bytes_out,
network_packets_in = EXCLUDED.network_packets_in,
network_packets_out = EXCLUDED.network_packets_out,
network_connections_in = EXCLUDED.network_connections_in,
network_connections_out = EXCLUDED.network_connections_out,
network_errors_in = EXCLUDED.network_errors_in,
network_errors_out = EXCLUDED.network_errors_out,
start_time = EXCLUDED.start_time,
last_seen = EXCLUDED.last_seen,
health_status = EXCLUDED.health_status,
health_last_check = EXCLUDED.health_last_check,
health_check_count = EXCLUDED.health_check_count,
health_failure_count = EXCLUDED.health_failure_count
`
_, err := s.db.ExecContext(ctx, query,
serviceID, timestamp, instance.InstanceID, instance.NodeID, instance.Status,
instance.CPU, instance.Memory, instance.Network.BytesIn, instance.Network.BytesOut,
instance.Network.PacketsIn, instance.Network.PacketsOut, instance.Network.ConnectionsIn,
instance.Network.ConnectionsOut, instance.Network.ErrorsIn, instance.Network.ErrorsOut,
instance.StartTime, instance.LastSeen, instance.Health.Status, instance.Health.LastCheck,
instance.Health.CheckCount, instance.Health.FailureCount,
)
return err
}
func (s *PostgreSQLMetricsStorage) getContainerMetrics(ctx context.Context, nodeID string, timestamp time.Time) ([]ContainerMetrics, error) {
query := `
SELECT container_id, name, state, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out, start_time
FROM container_metrics
WHERE node_id = $1 AND timestamp = $2
`
rows, err := s.db.QueryContext(ctx, query, nodeID, timestamp)
if err != nil {
return nil, err
}
defer rows.Close()
var containers []ContainerMetrics
for rows.Next() {
var c ContainerMetrics
err := rows.Scan(
&c.ContainerID, &c.Name, &c.State, &c.CPU, &c.Memory,
&c.Network.BytesIn, &c.Network.BytesOut, &c.Network.PacketsIn, &c.Network.PacketsOut, &c.StartTime,
)
if err != nil {
return nil, err
}
containers = append(containers, c)
}
return containers, nil
}
func (s *PostgreSQLMetricsStorage) getInstanceMetrics(ctx context.Context, serviceID string, timestamp time.Time) ([]InstanceMetrics, error) {
query := `
SELECT instance_id, node_id, status, cpu, memory,
network_bytes_in, network_bytes_out, network_packets_in, network_packets_out,
network_connections_in, network_connections_out, network_errors_in, network_errors_out,
start_time, last_seen, health_status, health_last_check, health_check_count, health_failure_count
FROM instance_metrics
WHERE service_id = $1 AND timestamp = $2
`
rows, err := s.db.QueryContext(ctx, query, serviceID, timestamp)
if err != nil {
return nil, err
}
defer rows.Close()
var instances []InstanceMetrics
for rows.Next() {
var i InstanceMetrics
err := rows.Scan(
&i.InstanceID, &i.NodeID, &i.Status, &i.CPU, &i.Memory,
&i.Network.BytesIn, &i.Network.BytesOut, &i.Network.PacketsIn, &i.Network.PacketsOut,
&i.Network.ConnectionsIn, &i.Network.ConnectionsOut, &i.Network.ErrorsIn, &i.Network.ErrorsOut,
&i.StartTime, &i.LastSeen, &i.Health.Status, &i.Health.LastCheck, &i.Health.CheckCount, &i.Health.FailureCount,
)
if err != nil {
return nil, err
}
instances = append(instances, i)
}
return instances, nil
}
func calculateSummary(timeSeries []TimeSeriesPoint, metricName string) MetricSummary {
if len(timeSeries) == 0 {
return MetricSummary{}
}
var values []float64
for _, point := range timeSeries {
if val, exists := point.Values[metricName]; exists {
values = append(values, val)
}
}
if len(values) == 0 {
return MetricSummary{}
}
// Simple calculation - in production, use proper statistics
min := values[0]
max := values[0]
sum := 0.0
for _, val := range values {
if val < min {
min = val
}
if val > max {
max = val
}
sum += val
}
avg := sum / float64(len(values))
return MetricSummary{
Min: min,
Max: max,
Avg: avg,
Count: int64(len(values)),
// P50, P95, P99 would require sorting and percentile calculation
P50: avg,
P95: avg,
P99: avg,
}
}
// InMemoryMetricsStorage provides an in-memory implementation for testing
type InMemoryMetricsStorage struct {
nodeMetrics map[string][]*NodeMetrics
serviceMetrics map[string][]*ServiceMetrics
mu sync.RWMutex
}
// NewInMemoryMetricsStorage creates a new in-memory metrics storage
func NewInMemoryMetricsStorage() *InMemoryMetricsStorage {
return &InMemoryMetricsStorage{
nodeMetrics: make(map[string][]*NodeMetrics),
serviceMetrics: make(map[string][]*ServiceMetrics),
}
}
func (s *InMemoryMetricsStorage) StoreNodeMetrics(ctx context.Context, metrics *NodeMetrics) error {
s.mu.Lock()
defer s.mu.Unlock()
s.nodeMetrics[metrics.NodeID] = append(s.nodeMetrics[metrics.NodeID], metrics)
return nil
}
func (s *InMemoryMetricsStorage) StoreServiceMetrics(ctx context.Context, metrics *ServiceMetrics) error {
s.mu.Lock()
defer s.mu.Unlock()
s.serviceMetrics[metrics.ServiceID] = append(s.serviceMetrics[metrics.ServiceID], metrics)
return nil
}
func (s *InMemoryMetricsStorage) GetNodeMetrics(ctx context.Context, nodeID string, from, to time.Time) ([]*NodeMetrics, error) {
s.mu.RLock()
defer s.mu.RUnlock()
metrics := s.nodeMetrics[nodeID]
var result []*NodeMetrics
for _, m := range metrics {
if m.Timestamp.After(from) && m.Timestamp.Before(to) {
result = append(result, m)
}
}
return result, nil
}
func (s *InMemoryMetricsStorage) GetServiceMetrics(ctx context.Context, serviceID string, from, to time.Time) ([]*ServiceMetrics, error) {
s.mu.RLock()
defer s.mu.RUnlock()
metrics := s.serviceMetrics[serviceID]
var result []*ServiceMetrics
for _, m := range metrics {
if m.Timestamp.After(from) && m.Timestamp.Before(to) {
result = append(result, m)
}
}
return result, nil
}
func (s *InMemoryMetricsStorage) GetAggregatedMetrics(ctx context.Context, query MetricsQuery) (*AggregatedMetrics, error) {
// Simplified implementation
return &AggregatedMetrics{
Query: query,
TimeSeries: []TimeSeriesPoint{},
Summary: map[string]MetricSummary{},
}, nil
}
+126
View File
@@ -0,0 +1,126 @@
package middleware
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// Logger middleware
func Logger() gin.HandlerFunc {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return fmt.Sprintf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n",
param.ClientIP,
param.TimeStamp.Format(time.RFC1123),
param.Method,
param.Path,
param.Request.Proto,
param.StatusCode,
param.Latency,
param.Request.UserAgent(),
param.ErrorMessage,
)
})
}
// Recovery middleware
func Recovery() gin.HandlerFunc {
return gin.Recovery()
}
// RequestID middleware adds a unique request ID to each request
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = uuid.New().String()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}
// Auth middleware for JWT authentication
func Auth(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Abort()
return
}
bearerToken := strings.Split(authHeader, " ")
if len(bearerToken) != 2 || bearerToken[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
c.Abort()
return
}
tokenString := bearerToken[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(jwtSecret), nil
})
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
c.Set("user_id", claims["user_id"])
c.Set("email", claims["email"])
c.Next()
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
}
}
}
// ErrorHandler middleware for consistent error handling
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// Check if there are any errors
if len(c.Errors) > 0 {
err := c.Errors.Last()
log.Printf("Request error: %v", err)
// Return JSON error response
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error",
"code": "INTERNAL_ERROR",
})
}
}
}
// CORSMiddleware for CORS handling
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
+360
View File
@@ -0,0 +1,360 @@
package networking
import (
"context"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
// DNSServer provides internal DNS resolution for services
type DNSServer struct {
server *dns.Server
serviceDiscovery *ServiceDiscovery
domain string
addresses []string
mu sync.RWMutex
}
// DNSConfig holds DNS server configuration
type DNSConfig struct {
Domain string `json:"domain"`
Addresses []string `json:"addresses"`
Port int `json:"port"`
Upstream []string `json:"upstream"`
}
// NewDNSServer creates a new DNS server
func NewDNSServer(config DNSConfig, serviceDiscovery *ServiceDiscovery) *DNSServer {
return &DNSServer{
domain: config.Domain,
addresses: config.Addresses,
serviceDiscovery: serviceDiscovery,
}
}
// Start starts the DNS server
func (d *DNSServer) Start(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
// Create DNS handler
handler := dns.NewServeMux()
handler.HandleFunc(d.domain, d.handleServiceRequest)
handler.HandleFunc("in-addr.arpa.", d.handleReverseRequest)
// Create server
d.server = &dns.Server{
Addr: ":53",
Net: "udp",
Handler: handler,
}
// Start server in goroutine
go func() {
if err := d.server.ListenAndServe(); err != nil {
fmt.Printf("DNS server error: %v\n", err)
}
}()
return nil
}
// Stop stops the DNS server
func (d *DNSServer) Stop() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.server != nil {
return d.server.Shutdown()
}
return nil
}
// handleServiceRequest handles DNS requests for services
func (d *DNSServer) handleServiceRequest(w dns.ResponseWriter, r *dns.Msg) {
msg := new(dns.Msg)
msg.SetReply(r)
msg.Authoritative = true
for _, question := range r.Question {
if question.Qtype == dns.TypeA {
// Extract service name from query
serviceName := d.extractServiceName(question.Name)
if serviceName == "" {
continue
}
// Resolve service
ips, err := d.serviceDiscovery.ResolveService(context.Background(), serviceName, "")
if err != nil {
continue
}
// Create A records
for _, ip := range ips {
rr, err := dns.NewRR(fmt.Sprintf("%s 30 IN A %s", question.Name, ip))
if err == nil {
msg.Answer = append(msg.Answer, rr)
}
}
} else if question.Qtype == dns.TypeSRV {
// Handle SRV requests for service discovery
serviceName := d.extractServiceName(question.Name)
if serviceName == "" {
continue
}
// Get service instances
instances, err := d.serviceDiscovery.DiscoverService(context.Background(), serviceName, "")
if err != nil {
continue
}
// Create SRV records
for _, instance := range instances {
target := fmt.Sprintf("%s.%s", instance.ServiceName, d.domain)
srv := fmt.Sprintf("%s 30 IN SRV 10 5 %d %s", question.Name, instance.Port, target)
rr, err := dns.NewRR(srv)
if err == nil {
msg.Answer = append(msg.Answer, rr)
}
}
}
}
w.WriteMsg(msg)
}
// handleReverseRequest handles reverse DNS lookups
func (d *DNSServer) handleReverseRequest(w dns.ResponseWriter, r *dns.Msg) {
msg := new(dns.Msg)
msg.SetReply(r)
msg.Authoritative = true
for _, question := range r.Question {
if question.Qtype == dns.TypePTR {
// Extract IP from reverse query
ip := d.extractIPFromReverse(question.Name)
if ip == "" {
continue
}
// Find service by IP
var serviceName string
for _, instance := range d.serviceDiscovery.services {
if instance.IPAddress == ip {
serviceName = instance.ServiceName
break
}
}
if serviceName != "" {
ptr := fmt.Sprintf("%s 30 IN PTR %s.%s", question.Name, serviceName, d.domain)
rr, err := dns.NewRR(ptr)
if err == nil {
msg.Answer = append(msg.Answer, rr)
}
}
}
}
w.WriteMsg(msg)
}
// extractServiceName extracts service name from DNS query
func (d *DNSServer) extractServiceName(query string) string {
// Remove domain suffix
if strings.HasSuffix(query, d.domain) {
name := strings.TrimSuffix(query, d.domain)
name = strings.Trim(name, ".")
return name
}
return ""
}
// extractIPFromReverse extracts IP from reverse DNS query
func (d *DNSServer) extractIPFromReverse(reverse string) string {
// Handle IPv4 reverse lookup
if strings.HasSuffix(reverse, "in-addr.arpa.") {
parts := strings.Split(reverse, ".")
if len(parts) >= 4 {
// Reverse the first 4 parts to get IP
ip := fmt.Sprintf("%s.%s.%s.%s", parts[3], parts[2], parts[1], parts[0])
return ip
}
}
return ""
}
// DNSClient provides DNS resolution utilities
type DNSClient struct {
servers []string
timeout time.Duration
}
// NewDNSClient creates a new DNS client
func NewDNSClient(servers []string) *DNSClient {
return &DNSClient{
servers: servers,
timeout: 5 * time.Second,
}
}
// ResolveService resolves a service name using DNS
func (c *DNSClient) ResolveService(serviceName, domain string) ([]string, error) {
fqdn := fmt.Sprintf("%s.%s", serviceName, domain)
// Create DNS message
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(fqdn), dns.TypeA)
msg.RecursionDesired = true
// Try each DNS server
for _, server := range c.servers {
client := &dns.Client{Timeout: c.timeout}
response, _, err := client.Exchange(msg, server+":53")
if err != nil {
continue
}
if len(response.Answer) > 0 {
var ips []string
for _, answer := range response.Answer {
if a, ok := answer.(*dns.A); ok {
ips = append(ips, a.A.String())
}
}
return ips, nil
}
}
return nil, fmt.Errorf("failed to resolve service: %s", serviceName)
}
// ResolveSRV resolves SRV records for a service
func (c *DNSClient) ResolveSRV(serviceName, domain string) ([]*SRVRecord, error) {
fqdn := fmt.Sprintf("_%s._tcp.%s", serviceName, domain)
// Create DNS message
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(fqdn), dns.TypeSRV)
msg.RecursionDesired = true
// Try each DNS server
for _, server := range c.servers {
client := &dns.Client{Timeout: c.timeout}
response, _, err := client.Exchange(msg, server+":53")
if err != nil {
continue
}
if len(response.Answer) > 0 {
var records []*SRVRecord
for _, answer := range response.Answer {
if srv, ok := answer.(*dns.SRV); ok {
record := &SRVRecord{
Priority: srv.Priority,
Weight: srv.Weight,
Port: srv.Port,
Target: srv.Target,
}
records = append(records, record)
}
}
return records, nil
}
}
return nil, fmt.Errorf("failed to resolve SRV record for service: %s", serviceName)
}
// SRVRecord represents an SRV record
type SRVRecord struct {
Priority uint16
Weight uint16
Port uint16
Target string
}
// NetworkUtils provides network utility functions
type NetworkUtils struct{}
// NewNetworkUtils creates a new network utils instance
func NewNetworkUtils() *NetworkUtils {
return &NetworkUtils{}
}
// GetLocalIP returns the local IP address
func (nu *NetworkUtils) GetLocalIP() (string, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String(), nil
}
}
}
return "", fmt.Errorf("no local IP address found")
}
// IsPortOpen checks if a port is open on a host
func (nu *NetworkUtils) IsPortOpen(host string, port int, timeout time.Duration) bool {
address := fmt.Sprintf("%s:%d", host, port)
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return false
}
conn.Close()
return true
}
// WaitForPort waits for a port to become available
func (nu *NetworkUtils) WaitForPort(host string, port int, timeout time.Duration) error {
start := time.Now()
for time.Since(start) < timeout {
if nu.IsPortOpen(host, port, 1*time.Second) {
return nil
}
time.Sleep(1 * time.Second)
}
return fmt.Errorf("port %d on %s did not become available within %v", port, host, timeout)
}
// GenerateSubnet generates a subnet for a project
func (nu *NetworkUtils) GenerateSubnet(projectID string) string {
// Simple hash-based subnet generation
hash := 0
for _, c := range projectID {
hash = hash*31 + int(c)
}
if hash < 0 {
hash = -hash
}
// Generate 10.x.y.0/24 subnet
octet2 := (hash % 254) + 1
octet3 := ((hash / 254) % 254) + 1
return fmt.Sprintf("10.%d.%d.0/24", octet2, octet3)
}
// GetAvailablePort finds an available port in a range
func (nu *NetworkUtils) GetAvailablePort(start, end int) (int, error) {
for port := start; port <= end; port++ {
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err == nil {
listener.Close()
return port, nil
}
}
return 0, fmt.Errorf("no available ports in range %d-%d", start, end)
}
+446
View File
@@ -0,0 +1,446 @@
package networking
import (
"context"
"fmt"
"net"
"sync"
"time"
"containr/internal/deployment"
)
// ServiceDiscovery handles service registration, discovery, and DNS resolution
type ServiceDiscovery struct {
services map[string]*ServiceInstance
instances map[string][]*ServiceInstance
mu sync.RWMutex
scheduler *deployment.Scheduler
dnsDomain string
loadBalancer *LoadBalancer
}
// ServiceInstance represents a running instance of a service
type ServiceInstance struct {
ID string `json:"id"`
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
ProjectID string `json:"project_id"`
NodeID string `json:"node_id"`
IPAddress string `json:"ip_address"`
Port int `json:"port"`
Status string `json:"status"`
Health HealthStatus `json:"health"`
Labels map[string]string `json:"labels"`
Metadata map[string]string `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
LastSeen time.Time `json:"last_seen"`
}
// HealthStatus represents the health status of a service instance
type HealthStatus struct {
Status string `json:"status"` // healthy, unhealthy, unknown
LastCheck time.Time `json:"last_check"`
CheckCount int `json:"check_count"`
FailureCount int `json:"failure_count"`
Message string `json:"message"`
}
// ServiceRegistry represents the service registry
type ServiceRegistry struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Instances []*ServiceInstance `json:"instances"`
Selector map[string]string `json:"selector"`
Ports []ServicePort `json:"ports"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ServicePort represents a service port configuration
type ServicePort struct {
Name string `json:"name"`
Port int `json:"port"`
TargetPort int `json:"target_port"`
Protocol string `json:"protocol"`
}
// LoadBalancer handles load balancing across service instances
type LoadBalancer struct {
strategy LoadBalancingStrategy
mu sync.RWMutex
}
type LoadBalancingStrategy string
const (
StrategyRoundRobin LoadBalancingStrategy = "round_robin"
StrategyLeastConnections LoadBalancingStrategy = "least_connections"
StrategyIPHash LoadBalancingStrategy = "ip_hash"
StrategyRandom LoadBalancingStrategy = "random"
)
// DNSRecord represents a DNS record for a service
type DNSRecord struct {
Name string `json:"name"`
Type string `json:"type"` // A, SRV, CNAME
TTL int `json:"ttl"`
Records []string `json:"records"` // IP addresses or hostnames
Priority int `json:"priority"` // For SRV records
Weight int `json:"weight"` // For SRV records
Port int `json:"port"` // For SRV records
}
// NewServiceDiscovery creates a new service discovery instance
func NewServiceDiscovery(scheduler *deployment.Scheduler, dnsDomain string) *ServiceDiscovery {
return &ServiceDiscovery{
services: make(map[string]*ServiceInstance),
instances: make(map[string][]*ServiceInstance),
scheduler: scheduler,
dnsDomain: dnsDomain,
loadBalancer: NewLoadBalancer(StrategyRoundRobin),
}
}
// RegisterService registers a new service instance
func (sd *ServiceDiscovery) RegisterService(ctx context.Context, instance *ServiceInstance) error {
sd.mu.Lock()
defer sd.mu.Unlock()
// Validate instance
if instance.ServiceName == "" || instance.IPAddress == "" {
return fmt.Errorf("service name and IP address are required")
}
// Set defaults
if instance.Status == "" {
instance.Status = "starting"
}
if instance.Health.Status == "" {
instance.Health.Status = "unknown"
}
instance.CreatedAt = time.Now()
instance.LastSeen = time.Now()
// Store instance
sd.services[instance.ID] = instance
// Add to service instances map
serviceKey := sd.getServiceKey(instance.ServiceName, instance.ProjectID)
sd.instances[serviceKey] = append(sd.instances[serviceKey], instance)
// Start health checking
go sd.startHealthCheck(instance)
return nil
}
// UnregisterService removes a service instance
func (sd *ServiceDiscovery) UnregisterService(ctx context.Context, instanceID string) error {
sd.mu.Lock()
defer sd.mu.Unlock()
instance, exists := sd.services[instanceID]
if !exists {
return fmt.Errorf("service instance not found: %s", instanceID)
}
// Remove from services map
delete(sd.services, instanceID)
// Remove from instances map
serviceKey := sd.getServiceKey(instance.ServiceName, instance.ProjectID)
instances := sd.instances[serviceKey]
for i, inst := range instances {
if inst.ID == instanceID {
sd.instances[serviceKey] = append(instances[:i], instances[i+1:]...)
break
}
}
return nil
}
// DiscoverService finds service instances by name and project
func (sd *ServiceDiscovery) DiscoverService(ctx context.Context, serviceName, projectID string) ([]*ServiceInstance, error) {
sd.mu.RLock()
defer sd.mu.RUnlock()
serviceKey := sd.getServiceKey(serviceName, projectID)
instances, exists := sd.instances[serviceKey]
if !exists {
return nil, fmt.Errorf("service not found: %s", serviceName)
}
// Filter healthy instances only
var healthyInstances []*ServiceInstance
for _, instance := range instances {
if instance.Health.Status == "healthy" && instance.Status == "running" {
healthyInstances = append(healthyInstances, instance)
}
}
if len(healthyInstances) == 0 {
return nil, fmt.Errorf("no healthy instances found for service: %s", serviceName)
}
return healthyInstances, nil
}
// GetServiceEndpoints returns all endpoints for a service
func (sd *ServiceDiscovery) GetServiceEndpoints(ctx context.Context, serviceName, projectID string) ([]string, error) {
instances, err := sd.DiscoverService(ctx, serviceName, projectID)
if err != nil {
return nil, err
}
var endpoints []string
for _, instance := range instances {
endpoint := fmt.Sprintf("%s:%d", instance.IPAddress, instance.Port)
endpoints = append(endpoints, endpoint)
}
return endpoints, nil
}
// ResolveService resolves a service name to IP addresses (DNS-like functionality)
func (sd *ServiceDiscovery) ResolveService(ctx context.Context, serviceName, projectID string) ([]string, error) {
instances, err := sd.DiscoverService(ctx, serviceName, projectID)
if err != nil {
return nil, err
}
var ips []string
for _, instance := range instances {
ips = append(ips, instance.IPAddress)
}
return ips, nil
}
// GetDNSRecords generates DNS records for all services
func (sd *ServiceDiscovery) GetDNSRecords(ctx context.Context) ([]*DNSRecord, error) {
sd.mu.RLock()
defer sd.mu.RUnlock()
var records []*DNSRecord
// Group instances by service
serviceGroups := make(map[string][]*ServiceInstance)
for _, instance := range sd.services {
if instance.Health.Status == "healthy" && instance.Status == "running" {
serviceGroups[instance.ServiceName] = append(serviceGroups[instance.ServiceName], instance)
}
}
// Create A records for each service
for serviceName, instances := range serviceGroups {
var ips []string
for _, instance := range instances {
ips = append(ips, instance.IPAddress)
}
if len(ips) > 0 {
// Create A record
fqdn := fmt.Sprintf("%s.%s", serviceName, sd.dnsDomain)
record := &DNSRecord{
Name: fqdn,
Type: "A",
TTL: 30,
Records: ips,
}
records = append(records, record)
// Create SRV record for services with ports
if len(instances) > 0 && instances[0].Port > 0 {
srvRecord := &DNSRecord{
Name: fmt.Sprintf("_%s._tcp.%s", serviceName, sd.dnsDomain),
Type: "SRV",
TTL: 30,
Port: instances[0].Port,
Records: []string{fqdn},
Priority: 10,
Weight: 5,
}
records = append(records, srvRecord)
}
}
}
return records, nil
}
// UpdateServiceHealth updates the health status of a service instance
func (sd *ServiceDiscovery) UpdateServiceHealth(ctx context.Context, instanceID string, health HealthStatus) error {
sd.mu.Lock()
defer sd.mu.Unlock()
instance, exists := sd.services[instanceID]
if !exists {
return fmt.Errorf("service instance not found: %s", instanceID)
}
instance.Health = health
instance.LastSeen = time.Now()
return nil
}
// GetServiceStats returns statistics about services
func (sd *ServiceDiscovery) GetServiceStats(ctx context.Context) map[string]interface{} {
sd.mu.RLock()
defer sd.mu.RUnlock()
totalServices := len(sd.instances)
healthyInstances := 0
unhealthyInstances := 0
for _, instance := range sd.services {
if instance.Health.Status == "healthy" {
healthyInstances++
} else if instance.Health.Status == "unhealthy" {
unhealthyInstances++
}
}
return map[string]interface{}{
"total_services": totalServices,
"healthy_instances": healthyInstances,
"unhealthy_instances": unhealthyInstances,
"dns_domain": sd.dnsDomain,
"load_balancer": string(sd.loadBalancer.strategy),
}
}
// getServiceKey creates a unique key for a service
func (sd *ServiceDiscovery) getServiceKey(serviceName, projectID string) string {
return fmt.Sprintf("%s:%s", projectID, serviceName)
}
// startHealthCheck starts periodic health checking for a service instance
func (sd *ServiceDiscovery) startHealthCheck(instance *ServiceInstance) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
healthy := sd.checkInstanceHealth(ctx, instance)
health := HealthStatus{
LastCheck: time.Now(),
}
if healthy {
health.Status = "healthy"
health.CheckCount++
health.FailureCount = 0
health.Message = "Health check passed"
} else {
health.Status = "unhealthy"
health.CheckCount++
health.FailureCount++
health.Message = "Health check failed"
}
sd.UpdateServiceHealth(ctx, instance.ID, health)
cancel()
}
}
}
// checkInstanceHealth performs a health check on a service instance
func (sd *ServiceDiscovery) checkInstanceHealth(ctx context.Context, instance *ServiceInstance) bool {
// Simple TCP connection check
if instance.Port > 0 {
address := fmt.Sprintf("%s:%d", instance.IPAddress, instance.Port)
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
if err != nil {
return false
}
conn.Close()
return true
}
// If no port specified, assume healthy
return true
}
// NewLoadBalancer creates a new load balancer
func NewLoadBalancer(strategy LoadBalancingStrategy) *LoadBalancer {
return &LoadBalancer{
strategy: strategy,
}
}
// SelectInstance selects an instance using the configured load balancing strategy
func (lb *LoadBalancer) SelectInstance(instances []*ServiceInstance, clientIP string) *ServiceInstance {
lb.mu.RLock()
defer lb.mu.RUnlock()
if len(instances) == 0 {
return nil
}
switch lb.strategy {
case StrategyRoundRobin:
return lb.roundRobinSelect(instances)
case StrategyLeastConnections:
return lb.leastConnectionsSelect(instances)
case StrategyIPHash:
return lb.ipHashSelect(instances, clientIP)
case StrategyRandom:
return lb.randomSelect(instances)
default:
return instances[0]
}
}
// roundRobinSelect implements round-robin load balancing
func (lb *LoadBalancer) roundRobinSelect(instances []*ServiceInstance) *ServiceInstance {
// Simple implementation - in production, maintain round-robin state
return instances[0]
}
// leastConnectionsSelect selects instance with least connections
func (lb *LoadBalancer) leastConnectionsSelect(instances []*ServiceInstance) *ServiceInstance {
var selected *ServiceInstance
minConnections := int(^uint(0) >> 1) // Max int
for _, instance := range instances {
// In a real implementation, track actual connections
connections := 0 // Placeholder
if connections < minConnections {
selected = instance
minConnections = connections
}
}
return selected
}
// ipHashSelect selects instance based on client IP hash
func (lb *LoadBalancer) ipHashSelect(instances []*ServiceInstance, clientIP string) *ServiceInstance {
if clientIP == "" {
return instances[0]
}
hash := 0
for _, c := range clientIP {
hash = hash*31 + int(c)
}
if hash < 0 {
hash = -hash
}
index := hash % len(instances)
return instances[index]
}
// randomSelect selects a random instance
func (lb *LoadBalancer) randomSelect(instances []*ServiceInstance) *ServiceInstance {
// Simple implementation - in production, use proper random
return instances[0]
}
+334
View File
@@ -0,0 +1,334 @@
package proxmox
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client represents a Proxmox API client
type Client struct {
baseURL string
username string
password string
tokenID string
token string
httpClient *http.Client
ticket string
csrfToken string
}
// NewClient creates a new Proxmox API client
func NewClient(baseURL, username, password string) *Client {
return &Client{
baseURL: strings.TrimSuffix(baseURL, "/"),
username: username,
password: password,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // Proxmox typically uses self-signed certs
},
},
},
}
}
// NewClientWithToken creates a new Proxmox API client using API token
func NewClientWithToken(baseURL, tokenID, token string) *Client {
return &Client{
baseURL: strings.TrimSuffix(baseURL, "/"),
tokenID: tokenID,
token: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
},
}
}
// Login authenticates with Proxmox and stores session tokens
func (c *Client) Login() error {
data := url.Values{}
data.Set("username", c.username)
data.Set("password", c.password)
resp, err := c.httpClient.PostForm(c.baseURL+"/api2/json/access/ticket", data)
if err != nil {
return fmt.Errorf("failed to login: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("login failed with status: %s", resp.Status)
}
var result struct {
Data struct {
Ticket string `json:"ticket"`
CSRFPreventionToken string `json:"CSRFPreventionToken"`
Username string `json:"username"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode login response: %w", err)
}
c.ticket = result.Data.Ticket
c.csrfToken = result.Data.CSRFPreventionToken
return nil
}
// makeRequest makes an authenticated request to Proxmox API
func (c *Client) makeRequest(method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, c.baseURL+path, body)
if err != nil {
return nil, err
}
// Use token authentication if available
if c.tokenID != "" && c.token != "" {
req.Header.Set("Authorization", "PVEAPIToken="+c.tokenID+"="+c.token)
} else {
// Use session ticket authentication
if c.ticket == "" {
if err := c.Login(); err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
}
req.AddCookie(&http.Cookie{
Name: "PVEAuthCookie",
Value: c.ticket,
})
}
req.Header.Set("Content-Type", "application/json")
// Add CSRF token for state-changing requests
if method != "GET" && method != "HEAD" && c.csrfToken != "" {
req.Header.Set("CSRFPreventionToken", c.csrfToken)
}
return c.httpClient.Do(req)
}
// GetNodes retrieves all nodes in the Proxmox cluster
func (c *Client) GetNodes() ([]Node, error) {
resp, err := c.makeRequest("GET", "/api2/json/nodes", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Node `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode nodes response: %w", err)
}
return result.Data, nil
}
// GetVMs retrieves all VMs/LXCs on a specific node
func (c *Client) GetVMs(node string) ([]VM, error) {
resp, err := c.makeRequest("GET", fmt.Sprintf("/api2/json/nodes/%s/qemu", node), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []VM `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode VMs response: %w", err)
}
return result.Data, nil
}
// GetContainers retrieves all LXC containers on a specific node
func (c *Client) GetContainers(node string) ([]Container, error) {
resp, err := c.makeRequest("GET", fmt.Sprintf("/api2/json/nodes/%s/lxc", node), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Container `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode containers response: %w", err)
}
return result.Data, nil
}
// CreateVM creates a new VM on the specified node
func (c *Client) CreateVM(node string, config VMConfig) (string, error) {
data := url.Values{}
// Basic VM configuration
data.Set("vmid", fmt.Sprintf("%d", config.VMID))
data.Set("name", config.Name)
data.Set("memory", fmt.Sprintf("%d", config.Memory))
data.Set("cores", fmt.Sprintf("%d", config.Cores))
if config.Template != "" {
data.Set("template", config.Template)
}
if config.Storage != "" {
data.Set("scsi0", fmt.Sprintf("%s:%d", config.Storage, config.DiskSize))
}
if config.NetworkBridge != "" {
data.Set("net0", fmt.Sprintf("model=virtio,bridge=%s", config.NetworkBridge))
}
resp, err := c.makeRequest("POST", fmt.Sprintf("/api2/json/nodes/%s/qemu", node), strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("failed to create VM: %s - %s", resp.Status, string(body))
}
var result struct {
Data string `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode create VM response: %w", err)
}
return result.Data, nil
}
// CreateContainer creates a new LXC container on the specified node
func (c *Client) CreateContainer(node string, config ContainerConfig) (string, error) {
data := url.Values{}
// Basic container configuration
data.Set("vmid", fmt.Sprintf("%d", config.VMID))
data.Set("hostname", config.Hostname)
data.Set("memory", fmt.Sprintf("%d", config.Memory))
data.Set("cores", fmt.Sprintf("%d", config.Cores))
if config.Template != "" {
data.Set("ostemplate", config.Template)
}
if config.Storage != "" {
data.Set("rootfs", fmt.Sprintf("%s:%d", config.Storage, config.DiskSize))
}
if config.NetworkBridge != "" {
data.Set("net0", fmt.Sprintf("name=eth0,bridge=%s", config.NetworkBridge))
}
resp, err := c.makeRequest("POST", fmt.Sprintf("/api2/json/nodes/%s/lxc", node), strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("failed to create container: %s - %s", resp.Status, string(body))
}
var result struct {
Data string `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode create container response: %w", err)
}
return result.Data, nil
}
// StartVM starts a VM
func (c *Client) StartVM(node string, vmid int) error {
resp, err := c.makeRequest("POST", fmt.Sprintf("/api2/json/nodes/%s/qemu/%d/status/start", node, vmid), nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to start VM: %s", resp.Status)
}
return nil
}
// StopVM stops a VM
func (c *Client) StopVM(node string, vmid int) error {
resp, err := c.makeRequest("POST", fmt.Sprintf("/api2/json/nodes/%s/qemu/%d/status/stop", node, vmid), nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to stop VM: %s", resp.Status)
}
return nil
}
// DeleteVM deletes a VM
func (c *Client) DeleteVM(node string, vmid int) error {
resp, err := c.makeRequest("DELETE", fmt.Sprintf("/api2/json/nodes/%s/qemu/%d", node, vmid), nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to delete VM: %s", resp.Status)
}
return nil
}
// GetVMStatus retrieves the status of a VM
func (c *Client) GetVMStatus(node string, vmid int) (*VMStatus, error) {
resp, err := c.makeRequest("GET", fmt.Sprintf("/api2/json/nodes/%s/qemu/%d/status/current", node, vmid), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data VMStatus `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode VM status response: %w", err)
}
return &result.Data, nil
}
+456
View File
@@ -0,0 +1,456 @@
package proxmox
import (
"fmt"
"log"
"sync"
"time"
)
// Service manages Proxmox operations
type Service struct {
client *Client
nodeCache map[string]*NodeStats
cacheMu sync.RWMutex
config Config
}
// Config holds Proxmox configuration
type Config struct {
BaseURL string `json:"base_url"`
Username string `json:"username"`
Password string `json:"password"`
TokenID string `json:"token_id"`
Token string `json:"token"`
}
// NewService creates a new Proxmox service
func NewService(config Config) *Service {
var client *Client
if config.TokenID != "" && config.Token != "" {
client = NewClientWithToken(config.BaseURL, config.TokenID, config.Token)
} else {
client = NewClient(config.BaseURL, config.Username, config.Password)
}
return &Service{
client: client,
nodeCache: make(map[string]*NodeStats),
config: config,
}
}
// GetClusterStatus returns the overall cluster status
func (s *Service) GetClusterStatus() (*ClusterInfo, error) {
// This would require additional API endpoints for cluster info
// For now, return basic cluster information
nodes, err := s.client.GetNodes()
if err != nil {
return nil, fmt.Errorf("failed to get cluster nodes: %w", err)
}
activeNodes := 0
for _, node := range nodes {
if node.Status == "online" {
activeNodes++
}
}
return &ClusterInfo{
Name: "containr-cluster",
Version: "7.x", // This should be dynamically retrieved
Nodes: len(nodes),
Quorate: activeNodes > 0,
}, nil
}
// GetAllNodes returns all nodes with their current status
func (s *Service) GetAllNodes() ([]Node, error) {
return s.client.GetNodes()
}
// GetNodeStats returns detailed statistics for a specific node
func (s *Service) GetNodeStats(nodeName string) (*NodeStats, error) {
s.cacheMu.RLock()
if stats, exists := s.nodeCache[nodeName]; exists {
s.cacheMu.RUnlock()
return stats, nil
}
s.cacheMu.RUnlock()
// Fetch fresh data
nodes, err := s.client.GetNodes()
if err != nil {
return nil, fmt.Errorf("failed to get nodes: %w", err)
}
var targetNode *Node
for _, node := range nodes {
if node.Node == nodeName {
targetNode = &node
break
}
}
if targetNode == nil {
return nil, fmt.Errorf("node %s not found", nodeName)
}
stats := &NodeStats{
Node: targetNode.Node,
Status: targetNode.Status,
CPU: targetNode.CPU,
MemoryTotal: targetNode.MaxMemory,
MemoryUsed: targetNode.MemoryUsed,
MemoryFree: targetNode.MaxMemory - targetNode.MemoryUsed,
DiskTotal: targetNode.MaxDisk,
DiskUsed: targetNode.DiskUsed,
DiskFree: targetNode.MaxDisk - targetNode.DiskUsed,
Uptime: targetNode.Uptime,
LastUpdate: time.Now(),
}
// Update cache
s.cacheMu.Lock()
s.nodeCache[nodeName] = stats
s.cacheMu.Unlock()
return stats, nil
}
// GetAllVMs returns all VMs across all nodes
func (s *Service) GetAllVMs() ([]VM, error) {
nodes, err := s.client.GetNodes()
if err != nil {
return nil, fmt.Errorf("failed to get nodes: %w", err)
}
var allVMs []VM
for _, node := range nodes {
if node.Status == "online" {
vms, err := s.client.GetVMs(node.Node)
if err != nil {
log.Printf("Failed to get VMs for node %s: %v", node.Node, err)
continue
}
allVMs = append(allVMs, vms...)
}
}
return allVMs, nil
}
// GetAllContainers returns all containers across all nodes
func (s *Service) GetAllContainers() ([]Container, error) {
nodes, err := s.client.GetNodes()
if err != nil {
return nil, fmt.Errorf("failed to get nodes: %w", err)
}
var allContainers []Container
for _, node := range nodes {
if node.Status == "online" {
containers, err := s.client.GetContainers(node.Node)
if err != nil {
log.Printf("Failed to get containers for node %s: %v", node.Node, err)
continue
}
allContainers = append(allContainers, containers...)
}
}
return allContainers, nil
}
// CreateServiceVM creates a new VM optimized for running services
func (s *Service) CreateServiceVM(nodeName string, config ServiceVMConfig) (*VM, error) {
// Find the next available VMID
vmid, err := s.getNextAvailableVMID(nodeName)
if err != nil {
return nil, fmt.Errorf("failed to get next VMID: %w", err)
}
vmConfig := VMConfig{
VMID: vmid,
Name: config.Name,
Memory: config.Memory,
Cores: config.Cores,
DiskSize: config.DiskSize,
Storage: config.Storage,
NetworkBridge: config.NetworkBridge,
Template: config.Template,
}
taskID, err := s.client.CreateVM(nodeName, vmConfig)
if err != nil {
return nil, fmt.Errorf("failed to create VM: %w", err)
}
log.Printf("VM creation started with task ID: %s", taskID)
// Wait for VM to be created and get its status
time.Sleep(5 * time.Second) // Give Proxmox time to process
vms, err := s.client.GetVMs(nodeName)
if err != nil {
return nil, fmt.Errorf("failed to get VM status after creation: %w", err)
}
for _, vm := range vms {
if vm.VMID == vmid {
return &vm, nil
}
}
return nil, fmt.Errorf("VM %d not found after creation", vmid)
}
// CreateServiceContainer creates a new LXC container optimized for running services
func (s *Service) CreateServiceContainer(nodeName string, config ServiceContainerConfig) (*Container, error) {
// Find the next available VMID
vmid, err := s.getNextAvailableVMID(nodeName)
if err != nil {
return nil, fmt.Errorf("failed to get next VMID: %w", err)
}
containerConfig := ContainerConfig{
VMID: vmid,
Hostname: config.Hostname,
Memory: config.Memory,
Cores: config.Cores,
DiskSize: config.DiskSize,
Storage: config.Storage,
NetworkBridge: config.NetworkBridge,
Template: config.Template,
}
taskID, err := s.client.CreateContainer(nodeName, containerConfig)
if err != nil {
return nil, fmt.Errorf("failed to create container: %w", err)
}
log.Printf("Container creation started with task ID: %s", taskID)
// Wait for container to be created and get its status
time.Sleep(5 * time.Second) // Give Proxmox time to process
containers, err := s.client.GetContainers(nodeName)
if err != nil {
return nil, fmt.Errorf("failed to get container status after creation: %w", err)
}
for _, container := range containers {
if container.VMID == vmid {
return &container, nil
}
}
return nil, fmt.Errorf("Container %d not found after creation", vmid)
}
// StartInstance starts a VM or container
func (s *Service) StartInstance(nodeName string, vmid int, instanceType string) error {
switch instanceType {
case "qemu":
return s.client.StartVM(nodeName, vmid)
case "lxc":
// Implement container start
return fmt.Errorf("container start not yet implemented")
default:
return fmt.Errorf("unknown instance type: %s", instanceType)
}
}
// StopInstance stops a VM or container
func (s *Service) StopInstance(nodeName string, vmid int, instanceType string) error {
switch instanceType {
case "qemu":
return s.client.StopVM(nodeName, vmid)
case "lxc":
// Implement container stop
return fmt.Errorf("container stop not yet implemented")
default:
return fmt.Errorf("unknown instance type: %s", instanceType)
}
}
// DeleteInstance deletes a VM or container
func (s *Service) DeleteInstance(nodeName string, vmid int, instanceType string) error {
switch instanceType {
case "qemu":
return s.client.DeleteVM(nodeName, vmid)
case "lxc":
// Implement container delete
return fmt.Errorf("container delete not yet implemented")
default:
return fmt.Errorf("unknown instance type: %s", instanceType)
}
}
// GetInstanceStatus returns the status of a VM or container
func (s *Service) GetInstanceStatus(nodeName string, vmid int, instanceType string) (interface{}, error) {
switch instanceType {
case "qemu":
return s.client.GetVMStatus(nodeName, vmid)
case "lxc":
// Implement container status
return nil, fmt.Errorf("container status not yet implemented")
default:
return nil, fmt.Errorf("unknown instance type: %s", instanceType)
}
}
// getNextAvailableVMID finds the next available VM ID on the specified node
func (s *Service) getNextAvailableVMID(nodeName string) (int, error) {
vms, err := s.client.GetVMs(nodeName)
if err != nil {
return 0, err
}
containers, err := s.client.GetContainers(nodeName)
if err != nil {
return 0, err
}
usedIDs := make(map[int]bool)
for _, vm := range vms {
usedIDs[vm.VMID] = true
}
for _, container := range containers {
usedIDs[container.VMID] = true
}
// Start from 1000 and find the first available ID
for vmid := 1000; vmid < 9999; vmid++ {
if !usedIDs[vmid] {
return vmid, nil
}
}
return 0, fmt.Errorf("no available VM IDs found")
}
// ServiceVMConfig represents configuration for creating a service VM
type ServiceVMConfig struct {
Name string `json:"name"`
Memory int `json:"memory"`
Cores int `json:"cores"`
DiskSize int `json:"disk_size"` // in GB
Storage string `json:"storage"`
NetworkBridge string `json:"network_bridge"`
Template string `json:"template"`
}
// ServiceContainerConfig represents configuration for creating a service container
type ServiceContainerConfig struct {
Hostname string `json:"hostname"`
Memory int `json:"memory"`
Cores int `json:"cores"`
DiskSize int `json:"disk_size"` // in GB
Storage string `json:"storage"`
NetworkBridge string `json:"network_bridge"`
Template string `json:"template"`
}
// GetResourceUsage returns resource usage across the cluster
func (s *Service) GetResourceUsage() (map[string]interface{}, error) {
nodes, err := s.client.GetNodes()
if err != nil {
return nil, fmt.Errorf("failed to get nodes: %w", err)
}
var totalCPU, usedCPU float64
var totalMemory, usedMemory, totalDisk, usedDisk int64
var onlineNodes int
for _, node := range nodes {
if node.Status == "online" {
onlineNodes++
totalCPU += 1.0 // Assuming 1 CPU per node for simplicity
usedCPU += node.CPU
totalMemory += int64(node.MaxMemory)
usedMemory += int64(node.MemoryUsed)
totalDisk += int64(node.MaxDisk)
usedDisk += int64(node.DiskUsed)
}
}
return map[string]interface{}{
"total_nodes": len(nodes),
"online_nodes": onlineNodes,
"cpu_usage": map[string]interface{}{
"total": totalCPU,
"used": usedCPU,
"free": totalCPU - usedCPU,
},
"memory_usage": map[string]interface{}{
"total": totalMemory,
"used": usedMemory,
"free": totalMemory - usedMemory,
},
"disk_usage": map[string]interface{}{
"total": totalDisk,
"used": usedDisk,
"free": totalDisk - usedDisk,
},
}, nil
}
// ValidateConnection tests the connection to Proxmox
func (s *Service) ValidateConnection() error {
_, err := s.client.GetNodes()
if err != nil {
return fmt.Errorf("failed to connect to Proxmox: %w", err)
}
return nil
}
// GetAvailableTemplates returns a list of available VM and container templates
func (s *Service) GetAvailableTemplates(nodeName string) (map[string]interface{}, error) {
vms, err := s.client.GetVMs(nodeName)
if err != nil {
return nil, fmt.Errorf("failed to get VMs: %w", err)
}
containers, err := s.client.GetContainers(nodeName)
if err != nil {
return nil, fmt.Errorf("failed to get containers: %w", err)
}
var vmTemplates []VMTemplate
for _, vm := range vms {
if vm.Template {
vmTemplates = append(vmTemplates, VMTemplate{
VMID: vm.VMID,
Name: vm.Name,
Node: vm.Node,
Storage: "local", // This should be dynamically retrieved
CPU: 2, // Default values
Memory: 2048,
DiskSize: 20,
})
}
}
var containerTemplates []ContainerTemplate
for _, container := range containers {
if container.Template {
containerTemplates = append(containerTemplates, ContainerTemplate{
VMID: container.VMID,
Name: container.Name,
Node: container.Node,
Storage: "local", // This should be dynamically retrieved
CPU: 1,
Memory: 512,
DiskSize: 8,
OSTemplate: "ubuntu-22.04-standard", // Default template
})
}
}
return map[string]interface{}{
"vm_templates": vmTemplates,
"container_templates": containerTemplates,
}, nil
}
+262
View File
@@ -0,0 +1,262 @@
package proxmox
import "time"
// Node represents a Proxmox cluster node
type Node struct {
Node string `json:"node"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
Memory int `json:"mem"`
MemoryUsed int `json:"mem"`
MaxMemory int `json:"maxmem"`
Disk int `json:"disk"`
DiskUsed int `json:"diskused"`
MaxDisk int `json:"maxdisk"`
Uptime int `json:"uptime"`
Level string `json:"level"`
ID string `json:"id"`
Type string `json:"type"`
}
// VM represents a virtual machine in Proxmox
type VM struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
Memory int `json:"mem"`
MemoryUsed int `json:"maxmem"`
Disk int `json:"disk"`
DiskUsed int `json:"maxdisk"`
Uptime int `json:"uptime"`
Template bool `json:"template"`
Node string `json:"node"`
Type string `json:"type"`
NetIn int64 `json:"netin"`
NetOut int64 `json:"netout"`
DiskRead int64 `json:"diskread"`
DiskWrite int64 `json:"diskwrite"`
CPUUsage float64 `json:"cpuusage"`
}
// Container represents an LXC container in Proxmox
type Container struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
Memory int `json:"mem"`
MemoryUsed int `json:"maxmem"`
Disk int `json:"disk"`
DiskUsed int `json:"maxdisk"`
Uptime int `json:"uptime"`
Template bool `json:"template"`
Node string `json:"node"`
Type string `json:"type"`
NetIn int64 `json:"netin"`
NetOut int64 `json:"netout"`
DiskRead int64 `json:"diskread"`
DiskWrite int64 `json:"diskwrite"`
CPUUsage float64 `json:"cpuusage"`
}
// VMConfig represents the configuration for creating a VM
type VMConfig struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Memory int `json:"memory"`
Cores int `json:"cores"`
DiskSize int `json:"disk_size"` // in GB
Storage string `json:"storage"`
NetworkBridge string `json:"network_bridge"`
Template string `json:"template,omitempty"`
}
// ContainerConfig represents the configuration for creating an LXC container
type ContainerConfig struct {
VMID int `json:"vmid"`
Hostname string `json:"hostname"`
Memory int `json:"memory"`
Cores int `json:"cores"`
DiskSize int `json:"disk_size"` // in GB
Storage string `json:"storage"`
NetworkBridge string `json:"network_bridge"`
Template string `json:"template"`
}
// VMStatus represents the current status of a VM
type VMStatus struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
Memory int `json:"mem"`
MemoryUsed int `json:"maxmem"`
Disk int `json:"disk"`
DiskUsed int `json:"maxdisk"`
Uptime int `json:"uptime"`
Lock string `json:"lock,omitempty"`
HA bool `json:"ha"`
QMPStatus string `json:"qmpstatus"`
Spice bool `json:"spice"`
Template bool `json:"template"`
Agent bool `json:"agent"`
}
// ContainerStatus represents the current status of a container
type ContainerStatus struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
Memory int `json:"mem"`
MemoryUsed int `json:"maxmem"`
Disk int `json:"disk"`
DiskUsed int `json:"maxdisk"`
Uptime int `json:"uptime"`
Lock string `json:"lock,omitempty"`
HA bool `json:"ha"`
Template bool `json:"template"`
}
// NodeStats represents detailed statistics for a node
type NodeStats struct {
Node string `json:"node"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
MemoryTotal int `json:"memory_total"`
MemoryUsed int `json:"memory_used"`
MemoryFree int `json:"memory_free"`
DiskTotal int `json:"disk_total"`
DiskUsed int `json:"disk_used"`
DiskFree int `json:"disk_free"`
Uptime int `json:"uptime"`
LoadAverage []float64 `json:"load_average"`
NetworkIn int64 `json:"network_in"`
NetworkOut int64 `json:"network_out"`
LastUpdate time.Time `json:"last_update"`
}
// VMTemplate represents a VM template that can be cloned
type VMTemplate struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Node string `json:"node"`
Storage string `json:"storage"`
Size int `json:"size"`
CPU int `json:"cpu"`
Memory int `json:"memory"`
DiskSize int `json:"disk_size"`
}
// ContainerTemplate represents an LXC template that can be cloned
type ContainerTemplate struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Node string `json:"node"`
Storage string `json:"storage"`
Size int `json:"size"`
CPU int `json:"cpu"`
Memory int `json:"memory"`
DiskSize int `json:"disk_size"`
OSTemplate string `json:"os_template"`
}
// StorageInfo represents storage information on a node
type StorageInfo struct {
Storage string `json:"storage"`
Node string `json:"node"`
Type string `json:"type"`
Total int `json:"total"`
Used int `json:"used"`
Available int `json:"avail"`
Shared bool `json:"shared"`
Content string `json:"content"`
Active bool `json:"active"`
Enabled bool `json:"enabled"`
ReadOnly bool `json:"read_only"`
}
// NetworkInfo represents network interface information
type NetworkInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Active bool `json:"active"`
MACAddress string `json:"mac"`
Bridge string `json:"bridge"`
IP string `json:"ip"`
CIDR string `json:"cidr"`
Gateway string `json:"gateway"`
DNS string `json:"dns"`
}
// TaskInfo represents a task running on Proxmox
type TaskInfo struct {
UPID string `json:"upid"`
Node string `json:"node"`
Type string `json:"type"`
Status string `json:"status"`
User string `json:"user"`
StartTime time.Time `json:"starttime"`
EndTime time.Time `json:"endtime"`
Duration string `json:"duration"`
PID int `json:"pid"`
}
// ClusterInfo represents cluster information
type ClusterInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Nodes int `json:"nodes"`
Quorate bool `json:"quorate"`
Links int `json:"links"`
Messages string `json:"messages"`
}
// Resource represents a generic resource in Proxmox
type Resource struct {
ID string `json:"id"`
Type string `json:"type"`
Node string `json:"node"`
Name string `json:"name"`
Status string `json:"status"`
Level string `json:"level"`
}
// Pool represents a resource pool
type Pool struct {
PoolID string `json:"poolid"`
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
}
// User represents a Proxmox user
type User struct {
UserID string `json:"userid"`
Realm string `json:"realm"`
Enabled bool `json:"enabled"`
Email string `json:"email,omitempty"`
FirstName string `json:"firstname,omitempty"`
LastName string `json:"lastname,omitempty"`
Groups []string `json:"groups,omitempty"`
Comment string `json:"comment,omitempty"`
Expire int `json:"expire,omitempty"`
LastLogin int64 `json:"last_login,omitempty"`
}
// Role represents a Proxmox user role
type Role struct {
RoleID string `json:"roleid"`
Privs []string `json:"privs,omitempty"`
Special bool `json:"special,omitempty"`
}
// Permission represents a permission in Proxmox
type Permission struct {
Path string `json:"path"`
Role string `json:"role"`
User string `json:"user,omitempty"`
Group string `json:"group,omitempty"`
Realm string `json:"realm,omitempty"`
}
+581
View File
@@ -0,0 +1,581 @@
package scaling
import (
"context"
"fmt"
"log"
"math"
"sync"
"time"
"containr/internal/deployment"
"containr/internal/metrics"
)
// AutoScaler manages automatic scaling of services
type AutoScaler struct {
scheduler *deployment.Scheduler
metricsCollector *metrics.MetricsCollector
policies map[string]*ScalingPolicy
services map[string]*ServiceScalingState
mu sync.RWMutex
checkInterval time.Duration
cooldownPeriod time.Duration
enabled bool
}
// ScalingPolicy defines how a service should scale
type ScalingPolicy struct {
ServiceID string `json:"service_id"`
MinReplicas int `json:"min_replicas"`
MaxReplicas int `json:"max_replicas"`
TargetCPU float64 `json:"target_cpu"` // Target CPU utilization percentage
TargetMemory float64 `json:"target_memory"` // Target memory utilization percentage
ScaleUpCooldown time.Duration `json:"scale_up_cooldown"`
ScaleDownCooldown time.Duration `json:"scale_down_cooldown"`
ScaleUpStep int `json:"scale_up_step"` // How many replicas to add when scaling up
ScaleDownStep int `json:"scale_down_step"` // How many replicas to remove when scaling down
Metrics []string `json:"metrics"` // Which metrics to consider
Thresholds map[string]float64 `json:"thresholds"` // Custom thresholds for metrics
Enabled bool `json:"enabled"`
CostOptimization *CostOptimization `json:"cost_optimization"`
}
// CostOptimization defines cost-related scaling parameters
type CostOptimization struct {
MaxCostPerHour float64 `json:"max_cost_per_hour"`
PreferEfficiency bool `json:"prefer_efficiency"`
IdleTimeout time.Duration `json:"idle_timeout"`
}
// ServiceScalingState tracks the current scaling state of a service
type ServiceScalingState struct {
ServiceID string
CurrentReplicas int
DesiredReplicas int
LastScaleAction time.Time
LastScaleDirection string // "up" or "down"
ScaleUpCooldown time.Time
ScaleDownCooldown time.Time
MetricsHistory []MetricsSnapshot
Policy *ScalingPolicy
}
// MetricsSnapshot captures metrics at a point in time
type MetricsSnapshot struct {
Timestamp time.Time
CPU float64
Memory float64
Requests float64
Errors float64
}
// ScaleEvent represents a scaling action
type ScaleEvent struct {
ServiceID string `json:"service_id"`
Action string `json:"action"` // "scale_up" or "scale_down"
FromReplicas int `json:"from_replicas"`
ToReplicas int `json:"to_replicas"`
Reason string `json:"reason"`
Timestamp time.Time `json:"timestamp"`
Metrics map[string]float64 `json:"metrics"`
CostImpact float64 `json:"cost_impact"`
}
// ScalingDecision contains the decision made by the autoscaler
type ScalingDecision struct {
ShouldScale bool `json:"should_scale"`
Action string `json:"action"`
CurrentReplicas int `json:"current_replicas"`
DesiredReplicas int `json:"desired_replicas"`
Reason string `json:"reason"`
Metrics map[string]float64 `json:"metrics"`
CostEstimate float64 `json:"cost_estimate"`
}
// NewAutoScaler creates a new auto-scaler
func NewAutoScaler(scheduler *deployment.Scheduler, metricsCollector *metrics.MetricsCollector) *AutoScaler {
return &AutoScaler{
scheduler: scheduler,
metricsCollector: metricsCollector,
policies: make(map[string]*ScalingPolicy),
services: make(map[string]*ServiceScalingState),
checkInterval: 30 * time.Second,
cooldownPeriod: 5 * time.Minute,
enabled: true,
}
}
// Start begins the auto-scaling process
func (as *AutoScaler) Start(ctx context.Context) error {
ticker := time.NewTicker(as.checkInterval)
defer ticker.Stop()
log.Printf("AutoScaler started with check interval: %v", as.checkInterval)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if as.enabled {
if err := as.checkAndScale(ctx); err != nil {
log.Printf("Error during auto-scaling check: %v", err)
}
}
}
}
}
// checkAndScale evaluates all services and scales if necessary
func (as *AutoScaler) checkAndScale(ctx context.Context) error {
as.mu.RLock()
servicesToCheck := make([]*ServiceScalingState, 0, len(as.services))
for _, state := range as.services {
if state.Policy != nil && state.Policy.Enabled {
servicesToCheck = append(servicesToCheck, state)
}
}
as.mu.RUnlock()
for _, state := range servicesToCheck {
decision, err := as.evaluateScaling(ctx, state)
if err != nil {
log.Printf("Error evaluating scaling for service %s: %v", state.ServiceID, err)
continue
}
if decision.ShouldScale {
if err := as.executeScaling(ctx, state, decision); err != nil {
log.Printf("Error executing scaling for service %s: %v", state.ServiceID, err)
}
}
}
return nil
}
// evaluateScaling determines if a service needs to scale
func (as *AutoScaler) evaluateScaling(ctx context.Context, state *ServiceScalingState) (*ScalingDecision, error) {
policy := state.Policy
now := time.Now()
// Check cooldowns
if now.Before(state.ScaleUpCooldown) && now.Before(state.ScaleDownCooldown) {
return &ScalingDecision{
ShouldScale: false,
CurrentReplicas: state.CurrentReplicas,
DesiredReplicas: state.CurrentReplicas,
Reason: "In cooldown period",
}, nil
}
// Get current metrics
metrics, err := as.getServiceMetrics(ctx, state.ServiceID)
if err != nil {
return nil, fmt.Errorf("failed to get service metrics: %w", err)
}
// Calculate desired replicas based on metrics
desiredReplicas := as.calculateDesiredReplicas(state, metrics, policy)
// Ensure within bounds
if desiredReplicas < policy.MinReplicas {
desiredReplicas = policy.MinReplicas
}
if desiredReplicas > policy.MaxReplicas {
desiredReplicas = policy.MaxReplicas
}
// Check if scaling is needed
if desiredReplicas == state.CurrentReplicas {
return &ScalingDecision{
ShouldScale: false,
CurrentReplicas: state.CurrentReplicas,
DesiredReplicas: desiredReplicas,
Reason: "No scaling needed",
Metrics: metrics,
}, nil
}
// Determine action and check cooldowns
action := "scale_down"
if desiredReplicas > state.CurrentReplicas {
action = "scale_up"
if now.Before(state.ScaleUpCooldown) {
return &ScalingDecision{
ShouldScale: false,
CurrentReplicas: state.CurrentReplicas,
DesiredReplicas: desiredReplicas,
Reason: "Scale up cooldown active",
Metrics: metrics,
}, nil
}
} else {
if now.Before(state.ScaleDownCooldown) {
return &ScalingDecision{
ShouldScale: false,
CurrentReplicas: state.CurrentReplicas,
DesiredReplicas: desiredReplicas,
Reason: "Scale down cooldown active",
Metrics: metrics,
}, nil
}
}
// Apply scaling steps
if action == "scale_up" {
maxStep := policy.ScaleUpStep
if maxStep <= 0 {
maxStep = 1
}
if desiredReplicas-state.CurrentReplicas > maxStep {
desiredReplicas = state.CurrentReplicas + maxStep
}
} else {
maxStep := policy.ScaleDownStep
if maxStep <= 0 {
maxStep = 1
}
if state.CurrentReplicas-desiredReplicas > maxStep {
desiredReplicas = state.CurrentReplicas - maxStep
}
}
// Cost optimization check
if policy.CostOptimization != nil {
costEstimate := as.estimateScalingCost(state, desiredReplicas)
if costEstimate > policy.CostOptimization.MaxCostPerHour {
return &ScalingDecision{
ShouldScale: false,
CurrentReplicas: state.CurrentReplicas,
DesiredReplicas: state.CurrentReplicas,
Reason: fmt.Sprintf("Cost estimate %.2f exceeds maximum %.2f", costEstimate, policy.CostOptimization.MaxCostPerHour),
Metrics: metrics,
CostEstimate: costEstimate,
}, nil
}
}
reason := as.generateScalingReason(state, metrics, desiredReplicas)
return &ScalingDecision{
ShouldScale: true,
Action: action,
CurrentReplicas: state.CurrentReplicas,
DesiredReplicas: desiredReplicas,
Reason: reason,
Metrics: metrics,
CostEstimate: as.estimateScalingCost(state, desiredReplicas),
}, nil
}
// calculateDesiredReplicas calculates the desired number of replicas based on metrics
func (as *AutoScaler) calculateDesiredReplicas(state *ServiceScalingState, metrics map[string]float64, policy *ScalingPolicy) int {
currentReplicas := state.CurrentReplicas
desiredReplicas := currentReplicas
// CPU-based scaling
if cpuUsage, ok := metrics["cpu"]; ok && policy.TargetCPU > 0 {
cpuRatio := cpuUsage / policy.TargetCPU
if cpuRatio > 1.2 { // Scale up if CPU is 20% above target
desiredReplicas = int(math.Ceil(float64(currentReplicas) * cpuRatio))
} else if cpuRatio < 0.8 { // Scale down if CPU is 20% below target
desiredReplicas = int(math.Floor(float64(currentReplicas) * cpuRatio))
}
}
// Memory-based scaling
if memoryUsage, ok := metrics["memory"]; ok && policy.TargetMemory > 0 {
memoryRatio := memoryUsage / policy.TargetMemory
if memoryRatio > 1.2 {
memDesired := int(math.Ceil(float64(currentReplicas) * memoryRatio))
if memDesired > desiredReplicas {
desiredReplicas = memDesired
}
} else if memoryUsage < 0.8 {
memDesired := int(math.Floor(float64(currentReplicas) * memoryRatio))
if memDesired < desiredReplicas {
desiredReplicas = memDesired
}
}
}
// Request rate scaling
if requestRate, ok := metrics["requests_per_second"]; ok {
// Simple heuristic: scale based on request rate per replica
// Assume each replica can handle ~100 requests per second
requestsPerReplica := 100.0
requestDesired := int(math.Ceil(requestRate / requestsPerReplica))
if requestDesired > desiredReplicas {
desiredReplicas = requestDesired
}
}
// Error rate scaling (scale up if error rate is high)
if errorRate, ok := metrics["error_rate"]; ok && errorRate > 0.05 { // 5% error rate
errorDesired := currentReplicas + 1
if errorDesired > desiredReplicas {
desiredReplicas = errorDesired
}
}
return desiredReplicas
}
// getServiceMetrics gets current metrics for a service
func (as *AutoScaler) getServiceMetrics(ctx context.Context, serviceID string) (map[string]float64, error) {
// Get service metrics from the metrics collector
serviceMetrics, err := as.metricsCollector.GetServiceMetrics(serviceID)
if err != nil {
// If no metrics available, return empty map
return make(map[string]float64), nil
}
metrics := make(map[string]float64)
// Calculate average metrics across instances
if len(serviceMetrics.Instances) > 0 {
var totalCPU, totalMemory, totalRequests float64
var totalErrors int64
for _, instance := range serviceMetrics.Instances {
totalCPU += instance.CPU
totalMemory += float64(instance.Memory)
totalRequests += serviceMetrics.Requests.Throughput
totalErrors += serviceMetrics.Errors.Total
}
instanceCount := float64(len(serviceMetrics.Instances))
metrics["cpu"] = totalCPU / instanceCount
metrics["memory"] = totalMemory / instanceCount / (1024 * 1024 * 1024) // Convert to GB
metrics["requests_per_second"] = totalRequests
if serviceMetrics.Requests.Total > 0 {
metrics["error_rate"] = float64(totalErrors) / float64(serviceMetrics.Requests.Total)
} else {
metrics["error_rate"] = 0
}
}
return metrics, nil
}
// executeScaling performs the actual scaling action
func (as *AutoScaler) executeScaling(ctx context.Context, state *ServiceScalingState, decision *ScalingDecision) error {
serviceID := state.ServiceID
fromReplicas := state.CurrentReplicas
toReplicas := decision.DesiredReplicas
log.Printf("Executing scaling for service %s: %d -> %d replicas (%s)",
serviceID, fromReplicas, toReplicas, decision.Reason)
// In a real implementation, this would call the deployment engine
// to scale the service (add/remove containers)
// Update state
as.mu.Lock()
state.CurrentReplicas = toReplicas
state.DesiredReplicas = toReplicas
state.LastScaleAction = time.Now()
state.LastScaleDirection = decision.Action
// Set cooldowns
if decision.Action == "scale_up" {
state.ScaleUpCooldown = time.Now().Add(state.Policy.ScaleUpCooldown)
} else {
state.ScaleDownCooldown = time.Now().Add(state.Policy.ScaleDownCooldown)
}
as.mu.Unlock()
// Record the scaling event
event := &ScaleEvent{
ServiceID: serviceID,
Action: decision.Action,
FromReplicas: fromReplicas,
ToReplicas: toReplicas,
Reason: decision.Reason,
Timestamp: time.Now(),
Metrics: decision.Metrics,
CostImpact: decision.CostEstimate,
}
// TODO: Store scaling event in database
log.Printf("Scaling event: %+v", event)
return nil
}
// generateScalingReason creates a human-readable reason for scaling
func (as *AutoScaler) generateScalingReason(state *ServiceScalingState, metrics map[string]float64, desiredReplicas int) string {
var reasons []string
if cpuUsage, ok := metrics["cpu"]; ok {
if cpuUsage > state.Policy.TargetCPU*1.2 {
reasons = append(reasons, fmt.Sprintf("CPU usage %.1f%% above target %.1f%%", cpuUsage, state.Policy.TargetCPU))
} else if cpuUsage < state.Policy.TargetCPU*0.8 {
reasons = append(reasons, fmt.Sprintf("CPU usage %.1f%% below target %.1f%%", cpuUsage, state.Policy.TargetCPU))
}
}
if memoryUsage, ok := metrics["memory"]; ok && state.Policy.TargetMemory > 0 {
if memoryUsage > state.Policy.TargetMemory*1.2 {
reasons = append(reasons, fmt.Sprintf("Memory usage %.1fGB above target %.1fGB", memoryUsage, state.Policy.TargetMemory))
}
}
if requestRate, ok := metrics["requests_per_second"]; ok {
reasons = append(reasons, fmt.Sprintf("Request rate %.0f/s requires %d replicas", requestRate, desiredReplicas))
}
if len(reasons) == 0 {
return "Automatic scaling based on metrics"
}
return fmt.Sprintf("Scale %s: %v", state.LastScaleDirection, reasons)
}
// estimateScalingCost estimates the cost impact of scaling
func (as *AutoScaler) estimateScalingCost(state *ServiceScalingState, replicas int) float64 {
// Simple cost model: $0.01 per replica per hour
// In a real implementation, this would consider actual instance costs
baseCost := 0.01
return float64(replicas) * baseCost
}
// SetScalingPolicy sets or updates a scaling policy for a service
func (as *AutoScaler) SetScalingPolicy(policy *ScalingPolicy) error {
as.mu.Lock()
defer as.mu.Unlock()
// Set default values if not specified
if policy.ScaleUpCooldown == 0 {
policy.ScaleUpCooldown = 3 * time.Minute
}
if policy.ScaleDownCooldown == 0 {
policy.ScaleDownCooldown = 5 * time.Minute
}
if policy.ScaleUpStep == 0 {
policy.ScaleUpStep = 1
}
if policy.ScaleDownStep == 0 {
policy.ScaleDownStep = 1
}
if policy.MinReplicas == 0 {
policy.MinReplicas = 1
}
if policy.MaxReplicas == 0 {
policy.MaxReplicas = 10
}
as.policies[policy.ServiceID] = policy
// Initialize service state if not exists
if _, exists := as.services[policy.ServiceID]; !exists {
as.services[policy.ServiceID] = &ServiceScalingState{
ServiceID: policy.ServiceID,
CurrentReplicas: policy.MinReplicas,
DesiredReplicas: policy.MinReplicas,
Policy: policy,
MetricsHistory: make([]MetricsSnapshot, 0),
}
} else {
as.services[policy.ServiceID].Policy = policy
}
return nil
}
// GetScalingPolicy returns the scaling policy for a service
func (as *AutoScaler) GetScalingPolicy(serviceID string) (*ScalingPolicy, error) {
as.mu.RLock()
defer as.mu.RUnlock()
policy, exists := as.policies[serviceID]
if !exists {
return nil, fmt.Errorf("no scaling policy found for service: %s", serviceID)
}
return policy, nil
}
// GetServiceState returns the current scaling state of a service
func (as *AutoScaler) GetServiceState(serviceID string) (*ServiceScalingState, error) {
as.mu.RLock()
defer as.mu.RUnlock()
state, exists := as.services[serviceID]
if !exists {
return nil, fmt.Errorf("no scaling state found for service: %s", serviceID)
}
return state, nil
}
// GetAllServiceStates returns all service scaling states
func (as *AutoScaler) GetAllServiceStates() map[string]*ServiceScalingState {
as.mu.RLock()
defer as.mu.RUnlock()
result := make(map[string]*ServiceScalingState)
for id, state := range as.services {
result[id] = state
}
return result
}
// Enable enables the auto-scaler
func (as *AutoScaler) Enable() {
as.mu.Lock()
defer as.mu.Unlock()
as.enabled = true
}
// Disable disables the auto-scaler
func (as *AutoScaler) Disable() {
as.mu.Lock()
defer as.mu.Unlock()
as.enabled = false
}
// IsEnabled returns whether the auto-scaler is enabled
func (as *AutoScaler) IsEnabled() bool {
as.mu.RLock()
defer as.mu.RUnlock()
return as.enabled
}
// GetScalingSummary returns a summary of scaling activities
func (as *AutoScaler) GetScalingSummary() map[string]interface{} {
as.mu.RLock()
defer as.mu.RUnlock()
totalServices := len(as.services)
enabledServices := 0
totalReplicas := 0
scalingUp := 0
scalingDown := 0
for _, state := range as.services {
if state.Policy != nil && state.Policy.Enabled {
enabledServices++
}
totalReplicas += state.CurrentReplicas
if state.LastScaleDirection == "scale_up" && time.Since(state.LastScaleAction) < time.Hour {
scalingUp++
} else if state.LastScaleDirection == "scale_down" && time.Since(state.LastScaleAction) < time.Hour {
scalingDown++
}
}
return map[string]interface{}{
"total_services": totalServices,
"enabled_services": enabledServices,
"total_replicas": totalReplicas,
"scaling_up": scalingUp,
"scaling_down": scalingDown,
"enabled": as.enabled,
"check_interval": as.checkInterval.String(),
}
}
+488
View File
@@ -0,0 +1,488 @@
package security
import (
"containr/internal/database"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
)
// ComplianceFramework represents a compliance framework
type ComplianceFramework struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
// ComplianceControl represents a compliance control
type ComplianceControl struct {
ID string `json:"id"`
FrameworkID string `json:"framework_id"`
Code string `json:"code"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
Requirement string `json:"requirement"`
TestProcedure string `json:"test_procedure"`
Status string `json:"status"` // "compliant", "non_compliant", "not_applicable", "pending"
LastAssessed *time.Time `json:"last_assessed,omitempty"`
Evidence string `json:"evidence"`
Metadata string `json:"metadata"`
}
// ComplianceReport represents a compliance assessment report
type ComplianceReport struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
FrameworkID string `json:"framework_id"`
AssessmentDate time.Time `json:"assessment_date"`
Assessor string `json:"assessor"`
OverallStatus string `json:"overall_status"`
Score int `json:"score"` // 0-100
Controls []ComplianceControl `json:"controls"`
Risks []ComplianceRisk `json:"risks"`
Recommendations []string `json:"recommendations"`
}
// ComplianceRisk represents a compliance risk
type ComplianceRisk struct {
ID string `json:"id"`
ControlID string `json:"control_id"`
Title string `json:"title"`
Description string `json:"description"`
Impact string `json:"impact"` // "high", "medium", "low"
Likelihood string `json:"likelihood"` // "high", "medium", "low"
Mitigation string `json:"mitigation"`
}
// ComplianceManager handles compliance operations
type ComplianceManager struct {
db *database.DB
}
// NewComplianceManager creates a new compliance manager
func NewComplianceManager(db *database.DB) *ComplianceManager {
return &ComplianceManager{db: db}
}
// InitializeGDPRFramework initializes GDPR compliance framework
func (cm *ComplianceManager) InitializeGDPRFramework() error {
framework := ComplianceFramework{
ID: uuid.New().String(),
Name: "GDPR",
Description: "General Data Protection Regulation compliance framework",
Version: "1.0",
Enabled: true,
CreatedAt: time.Now(),
}
// Insert framework
_, err := cm.db.Exec(`
INSERT INTO compliance_frameworks (id, name, description, version, enabled, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (name) DO UPDATE SET version = $4, enabled = $5
`, framework.ID, framework.Name, framework.Description, framework.Version, framework.Enabled, framework.CreatedAt)
if err != nil {
return fmt.Errorf("failed to create GDPR framework: %w", err)
}
// Add GDPR controls
controls := []ComplianceControl{
{
ID: uuid.New().String(),
FrameworkID: framework.ID,
Code: "GDPR-Art-32",
Title: "Security of Processing",
Description: "Technical and organizational measures to ensure data security",
Category: "Security",
Requirement: "Implement appropriate technical and organizational measures to ensure a level of security appropriate to the risk",
TestProcedure: "Review security controls, encryption policies, access controls, and incident response procedures",
Status: "pending",
Evidence: "",
Metadata: `{"risk_level": "high", "review_frequency": "quarterly"}`,
},
{
ID: uuid.New().String(),
FrameworkID: framework.ID,
Code: "GDPR-Art-25",
Title: "Data Protection by Design and by Default",
Description: "Implement data protection measures in system design",
Category: "Privacy by Design",
Requirement: "Implement data protection principles in system design and default settings",
TestProcedure: "Review system architecture, privacy settings, and data minimization practices",
Status: "pending",
Evidence: "",
Metadata: `{"risk_level": "medium", "review_frequency": "biannual"}`,
},
{
ID: uuid.New().String(),
FrameworkID: framework.ID,
Code: "GDPR-Art-24",
Title: "Responsibility of the Controller",
Description: "Data controller responsibility and compliance demonstration",
Category: "Governance",
Requirement: "Implement measures to ensure and demonstrate compliance with GDPR",
TestProcedure: "Review governance policies, documentation, and compliance monitoring",
Status: "pending",
Evidence: "",
Metadata: `{"risk_level": "medium", "review_frequency": "annual"}`,
},
{
ID: uuid.New().String(),
FrameworkID: framework.ID,
Code: "GDPR-Art-33",
Title: "Notification of Personal Data Breach",
Description: "Procedures for notifying data breaches to authorities",
Category: "Incident Response",
Requirement: "Implement procedures for notifying personal data breaches within 72 hours",
TestProcedure: "Review incident response procedures, notification templates, and breach detection mechanisms",
Status: "pending",
Evidence: "",
Metadata: `{"risk_level": "high", "review_frequency": "quarterly"}`,
},
}
for _, control := range controls {
_, err := cm.db.Exec(`
INSERT INTO compliance_controls (id, framework_id, code, title, description, category, requirement, test_procedure, status, last_assessed, evidence, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NULL, $10, $11)
ON CONFLICT (framework_id, code) DO UPDATE SET title = $4, description = $5, requirement = $7, test_procedure = $8
`, control.ID, control.FrameworkID, control.Code, control.Title, control.Description,
control.Category, control.Requirement, control.TestProcedure, control.Status, control.Evidence, control.Metadata)
if err != nil {
log.Printf("Failed to insert GDPR control %s: %v", control.Code, err)
}
}
return nil
}
// AssessCompliance performs a compliance assessment
func (cm *ComplianceManager) AssessCompliance(projectID, frameworkID, assessor string) (*ComplianceReport, error) {
reportID := uuid.New().String()
report := &ComplianceReport{
ID: reportID,
ProjectID: projectID,
FrameworkID: frameworkID,
AssessmentDate: time.Now(),
Assessor: assessor,
OverallStatus: "in_progress",
Score: 0,
Controls: []ComplianceControl{},
Risks: []ComplianceRisk{},
Recommendations: []string{},
}
// Insert report record
_, err := cm.db.Exec(`
INSERT INTO compliance_reports (id, project_id, framework_id, assessment_date, assessor, overall_status, score)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, report.ID, report.ProjectID, report.FrameworkID, report.AssessmentDate, report.Assessor, report.OverallStatus, report.Score)
if err != nil {
return nil, fmt.Errorf("failed to create compliance report: %w", err)
}
// Start assessment in background
go cm.performAssessment(report)
return report, nil
}
// performAssessment executes the compliance assessment
func (cm *ComplianceManager) performAssessment(report *ComplianceReport) {
ctx := context.Background()
// Get framework controls
controls, err := cm.getFrameworkControls(report.FrameworkID)
if err != nil {
log.Printf("Failed to get framework controls: %v", err)
return
}
var assessedControls []ComplianceControl
var risks []ComplianceRisk
var recommendations []string
compliantCount := 0
for _, control := range controls {
assessedControl := cm.assessControl(ctx, report.ProjectID, control)
assessedControls = append(assessedControls, assessedControl)
if assessedControl.Status == "compliant" {
compliantCount++
} else if assessedControl.Status == "non_compliant" {
// Generate risk for non-compliant controls
risk := ComplianceRisk{
ID: uuid.New().String(),
ControlID: assessedControl.ID,
Title: fmt.Sprintf("Non-compliance: %s", assessedControl.Title),
Description: fmt.Sprintf("Control %s is not compliant", assessedControl.Code),
Impact: cm.getRiskImpact(assessedControl),
Likelihood: cm.getRiskLikelihood(assessedControl),
Mitigation: cm.generateMitigation(assessedControl),
}
risks = append(risks, risk)
// Generate recommendation
rec := fmt.Sprintf("Implement controls to achieve compliance for %s: %s", assessedControl.Code, assessedControl.Title)
recommendations = append(recommendations, rec)
}
// Update control status in database
_, err := cm.db.Exec(`
UPDATE compliance_controls
SET status = $1, last_assessed = $2, evidence = $3
WHERE id = $4
`, assessedControl.Status, assessedControl.LastAssessed, assessedControl.Evidence, assessedControl.ID)
if err != nil {
log.Printf("Failed to update control %s: %v", assessedControl.ID, err)
}
}
// Calculate overall score
score := int((float64(compliantCount) / float64(len(controls))) * 100)
// Determine overall status
overallStatus := "non_compliant"
if score >= 90 {
overallStatus = "compliant"
} else if score >= 70 {
overallStatus = "partially_compliant"
}
// Update report with results
_, err = cm.db.Exec(`
UPDATE compliance_reports
SET overall_status = $1, score = $2
WHERE id = $3
`, overallStatus, score, report.ID)
if err != nil {
log.Printf("Failed to update compliance report %s: %v", report.ID, err)
return
}
// Store risks and recommendations
for _, risk := range risks {
_, err := cm.db.Exec(`
INSERT INTO compliance_risks (id, report_id, control_id, title, description, impact, likelihood, mitigation)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, risk.ID, report.ID, risk.ControlID, risk.Title, risk.Description, risk.Impact, risk.Likelihood, risk.Mitigation)
if err != nil {
log.Printf("Failed to store risk %s: %v", risk.ID, err)
}
}
}
// getFrameworkControls retrieves all controls for a framework
func (cm *ComplianceManager) getFrameworkControls(frameworkID string) ([]ComplianceControl, error) {
rows, err := cm.db.Query(`
SELECT id, framework_id, code, title, description, category, requirement, test_procedure, status, last_assessed, evidence, metadata
FROM compliance_controls WHERE framework_id = $1
`, frameworkID)
if err != nil {
return nil, err
}
defer rows.Close()
var controls []ComplianceControl
for rows.Next() {
var control ComplianceControl
var lastAssessed sql.NullTime
err := rows.Scan(&control.ID, &control.FrameworkID, &control.Code, &control.Title, &control.Description,
&control.Category, &control.Requirement, &control.TestProcedure, &control.Status, &lastAssessed, &control.Evidence, &control.Metadata)
if err != nil {
continue
}
if lastAssessed.Valid {
control.LastAssessed = &lastAssessed.Time
}
controls = append(controls, control)
}
return controls, nil
}
// assessControl assesses a single compliance control
func (cm *ComplianceManager) assessControl(ctx context.Context, projectID string, control ComplianceControl) ComplianceControl {
assessed := control
now := time.Now()
assessed.LastAssessed = &now
// Simulate assessment logic (in real implementation, this would check actual configurations)
switch control.Code {
case "GDPR-Art-32":
// Check security measures
hasEncryption := cm.checkDataEncryption(projectID)
hasAccessControl := cm.checkAccessControl(projectID)
hasIncidentResponse := cm.checkIncidentResponse(projectID)
if hasEncryption && hasAccessControl && hasIncidentResponse {
assessed.Status = "compliant"
assessed.Evidence = "Encryption enabled, access controls configured, incident response procedures documented"
} else {
assessed.Status = "non_compliant"
missing := []string{}
if !hasEncryption {
missing = append(missing, "data encryption")
}
if !hasAccessControl {
missing = append(missing, "access controls")
}
if !hasIncidentResponse {
missing = append(missing, "incident response procedures")
}
assessed.Evidence = fmt.Sprintf("Missing controls: %s", strings.Join(missing, ", "))
}
case "GDPR-Art-25":
// Check privacy by design
hasDataMinimization := cm.checkDataMinimization(projectID)
hasPrivacySettings := cm.checkPrivacySettings(projectID)
if hasDataMinimization && hasPrivacySettings {
assessed.Status = "compliant"
assessed.Evidence = "Privacy by design principles implemented, data minimization configured"
} else {
assessed.Status = "non_compliant"
assessed.Evidence = "Privacy by design principles not fully implemented"
}
default:
// Default assessment for other controls
assessed.Status = "pending"
assessed.Evidence = "Assessment pending manual review"
}
return assessed
}
// Helper functions for assessment checks (simulated)
func (cm *ComplianceManager) checkDataEncryption(projectID string) bool {
// Simulate checking encryption settings
// In real implementation, this would check actual configurations
return true
}
func (cm *ComplianceManager) checkAccessControl(projectID string) bool {
// Simulate checking access control
return true
}
func (cm *ComplianceManager) checkIncidentResponse(projectID string) bool {
// Simulate checking incident response procedures
return false // Simulate missing for demo
}
func (cm *ComplianceManager) checkDataMinimization(projectID string) bool {
return true
}
func (cm *ComplianceManager) checkPrivacySettings(projectID string) bool {
return false // Simulate missing for demo
}
func (cm *ComplianceManager) getRiskImpact(control ComplianceControl) string {
// Extract impact from metadata or default based on category
var metadata map[string]interface{}
json.Unmarshal([]byte(control.Metadata), &metadata)
if impact, ok := metadata["risk_level"].(string); ok {
return impact
}
// Default impact based on category
switch control.Category {
case "Security", "Incident Response":
return "high"
case "Privacy by Design":
return "medium"
default:
return "low"
}
}
func (cm *ComplianceManager) getRiskLikelihood(control ComplianceControl) string {
// Default likelihood based on control complexity
if strings.Contains(control.Requirement, "implement") || strings.Contains(control.Requirement, "procedures") {
return "medium"
}
return "low"
}
func (cm *ComplianceManager) generateMitigation(control ComplianceControl) string {
return fmt.Sprintf("Implement and document controls for %s as specified in the requirements", control.Title)
}
// GetComplianceReport retrieves a compliance report by ID
func (cm *ComplianceManager) GetComplianceReport(reportID string) (*ComplianceReport, error) {
var report ComplianceReport
err := cm.db.QueryRow(`
SELECT id, project_id, framework_id, assessment_date, assessor, overall_status, score
FROM compliance_reports WHERE id = $1
`, reportID).Scan(&report.ID, &report.ProjectID, &report.FrameworkID, &report.AssessmentDate, &report.Assessor, &report.OverallStatus, &report.Score)
if err != nil {
return nil, err
}
// Load controls
controls, err := cm.getFrameworkControls(report.FrameworkID)
if err == nil {
report.Controls = controls
}
// Load risks
risks, err := cm.getReportRisks(report.ID)
if err == nil {
report.Risks = risks
}
return &report, nil
}
// getReportRisks retrieves risks for a compliance report
func (cm *ComplianceManager) getReportRisks(reportID string) ([]ComplianceRisk, error) {
rows, err := cm.db.Query(`
SELECT id, control_id, title, description, impact, likelihood, mitigation
FROM compliance_risks WHERE report_id = $1
`, reportID)
if err != nil {
return nil, err
}
defer rows.Close()
var risks []ComplianceRisk
for rows.Next() {
var risk ComplianceRisk
err := rows.Scan(&risk.ID, &risk.ControlID, &risk.Title, &risk.Description, &risk.Impact, &risk.Likelihood, &risk.Mitigation)
if err != nil {
continue
}
risks = append(risks, risk)
}
return risks, nil
}
+358
View File
@@ -0,0 +1,358 @@
package security
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"strings"
"time"
)
// EncryptionManager handles data encryption and decryption
type EncryptionManager struct {
gcm cipher.AEAD
}
// NewEncryptionManager creates a new encryption manager
func NewEncryptionManager(key string) (*EncryptionManager, error) {
// Convert key to 32 bytes for AES-256
keyHash := sha256.Sum256([]byte(key))
block, err := aes.NewCipher(keyHash[:])
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
return &EncryptionManager{gcm: gcm}, nil
}
// Encrypt encrypts data using AES-256 GCM
func (em *EncryptionManager) Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
nonce := make([]byte, em.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
ciphertext := em.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts data using AES-256 GCM
func (em *EncryptionManager) Decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", fmt.Errorf("failed to decode base64: %w", err)
}
nonceSize := em.gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext_bytes := data[:nonceSize], data[nonceSize:]
plaintext, err := em.gcm.Open(nil, nonce, ciphertext_bytes, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt: %w", err)
}
return string(plaintext), nil
}
// EncryptSensitiveData encrypts sensitive data fields
func (em *EncryptionManager) EncryptSensitiveData(data map[string]interface{}) (map[string]interface{}, error) {
result := make(map[string]interface{})
for key, value := range data {
if em.isSensitiveField(key) {
strValue, ok := value.(string)
if ok {
encrypted, err := em.Encrypt(strValue)
if err != nil {
return nil, fmt.Errorf("failed to encrypt field %s: %w", key, err)
}
result[key] = encrypted
} else {
result[key] = value
}
} else {
result[key] = value
}
}
return result, nil
}
// DecryptSensitiveData decrypts sensitive data fields
func (em *EncryptionManager) DecryptSensitiveData(data map[string]interface{}) (map[string]interface{}, error) {
result := make(map[string]interface{})
for key, value := range data {
if em.isSensitiveField(key) {
strValue, ok := value.(string)
if ok {
decrypted, err := em.Decrypt(strValue)
if err != nil {
return nil, fmt.Errorf("failed to decrypt field %s: %w", key, err)
}
result[key] = decrypted
} else {
result[key] = value
}
} else {
result[key] = value
}
}
return result, nil
}
// isSensitiveField determines if a field contains sensitive data
func (em *EncryptionManager) isSensitiveField(fieldName string) bool {
sensitiveFields := []string{
"password", "secret", "token", "key", "api_key", "private_key",
"database_url", "connection_string", "credit_card", "ssn",
"social_security", "bank_account", "auth_token", "jwt_secret",
"encryption_key", "webhook_secret", "oauth_secret", "access_token",
"refresh_token", "client_secret", "private", "confidential",
}
fieldName = strings.ToLower(fieldName)
for _, sensitive := range sensitiveFields {
if strings.Contains(fieldName, sensitive) {
return true
}
}
return false
}
// DataRetentionManager handles data retention policies
type DataRetentionManager struct {
encryptionManager *EncryptionManager
}
// NewDataRetentionManager creates a new data retention manager
func NewDataRetentionManager(encryptionManager *EncryptionManager) *DataRetentionManager {
return &DataRetentionManager{
encryptionManager: encryptionManager,
}
}
// RetentionPolicy defines data retention rules
type RetentionPolicy struct {
ID string `json:"id"`
Name string `json:"name"`
DataType string `json:"data_type"`
RetentionPeriod time.Duration `json:"retention_period"`
Action string `json:"action"` // "delete", "anonymize", "archive"
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
// AnonymizedData represents anonymized user data
type AnonymizedData struct {
OriginalID string `json:"original_id"`
AnonymizedID string `json:"anonymized_id"`
DataType string `json:"data_type"`
AnonymizedAt time.Time `json:"anonymized_at"`
RetainedData string `json:"retained_data"` // Encrypted non-sensitive data
}
// AnonymizeUserData anonymizes user data for GDPR compliance
func (drm *DataRetentionManager) AnonymizeUserData(userData map[string]interface{}) (*AnonymizedData, error) {
anonymizedID := fmt.Sprintf("anon_%d", time.Now().UnixNano())
// Separate sensitive and non-sensitive data
sensitiveData := make(map[string]interface{})
nonSensitiveData := make(map[string]interface{})
for key, value := range userData {
if drm.isPersonalData(key) {
sensitiveData[key] = value
} else {
nonSensitiveData[key] = value
}
}
// Encrypt non-sensitive data for retention
nonSensitiveJSON, _ := json.Marshal(nonSensitiveData)
encryptedRetainedData, err := drm.encryptionManager.Encrypt(string(nonSensitiveJSON))
if err != nil {
return nil, fmt.Errorf("failed to encrypt retained data: %w", err)
}
// Create anonymized record
anonymized := &AnonymizedData{
OriginalID: fmt.Sprintf("%v", userData["id"]),
AnonymizedID: anonymizedID,
DataType: "user",
AnonymizedAt: time.Now(),
RetainedData: encryptedRetainedData,
}
return anonymized, nil
}
// isPersonalData determines if data is personal information under GDPR
func (drm *DataRetentionManager) isPersonalData(fieldName string) bool {
personalDataFields := []string{
"name", "email", "phone", "address", "birthdate", "gender",
"ip_address", "user_agent", "location", "biometric", "health",
"political", "religious", "sexual", "criminal", "financial",
"education", "employment", "family", "social", "behavioral",
"identifier", "cookie", "tracking", "profile", "preferences",
}
fieldName = strings.ToLower(fieldName)
for _, personal := range personalDataFields {
if strings.Contains(fieldName, personal) {
return true
}
}
return false
}
// ApplyRetentionPolicy applies retention policies to data
func (drm *DataRetentionManager) ApplyRetentionPolicy(dataType string, dataTimestamp time.Time, policy RetentionPolicy) string {
if !policy.Enabled {
return "retain"
}
expiryDate := dataTimestamp.Add(policy.RetentionPeriod)
if time.Now().Before(expiryDate) {
return "retain"
}
return policy.Action
}
// GenerateDataSubjectReport generates a report of all data held about a user
func (drm *DataRetentionManager) GenerateDataSubjectReport(userID string, userData map[string]interface{}) (map[string]interface{}, error) {
report := map[string]interface{}{
"user_id": userID,
"report_generated": time.Now(),
"data_categories": drm.categorizeUserData(userData),
"retention_policies": drm.getApplicablePolicies(userData),
"data_sources": []string{"database", "logs", "analytics"},
}
return report, nil
}
// categorizeUserData categorizes user data by type
func (drm *DataRetentionManager) categorizeUserData(userData map[string]interface{}) map[string][]string {
categories := map[string][]string{
"identity": {},
"contact": {},
"technical": {},
"behavioral": {},
"preferences": {},
}
for key := range userData {
lowerKey := strings.ToLower(key)
switch {
case strings.Contains(lowerKey, "name") || strings.Contains(lowerKey, "id"):
categories["identity"] = append(categories["identity"], key)
case strings.Contains(lowerKey, "email") || strings.Contains(lowerKey, "phone"):
categories["contact"] = append(categories["contact"], key)
case strings.Contains(lowerKey, "ip") || strings.Contains(lowerKey, "agent"):
categories["technical"] = append(categories["technical"], key)
case strings.Contains(lowerKey, "activity") || strings.Contains(lowerKey, "behavior"):
categories["behavioral"] = append(categories["behavioral"], key)
case strings.Contains(lowerKey, "preference") || strings.Contains(lowerKey, "setting"):
categories["preferences"] = append(categories["preferences"], key)
}
}
return categories
}
// getApplicablePolicies returns applicable retention policies
func (drm *DataRetentionManager) getApplicablePolicies(userData map[string]interface{}) []string {
policies := []string{
"user_data_2_years",
"analytics_data_6_months",
"logs_data_90_days",
"deleted_users_30_days",
}
return policies
}
// AuditLogger handles security audit logging
type AuditLogger struct {
encryptionManager *EncryptionManager
}
// NewAuditLogger creates a new audit logger
func NewAuditLogger(encryptionManager *EncryptionManager) *AuditLogger {
return &AuditLogger{
encryptionManager: encryptionManager,
}
}
// AuditEvent represents a security audit event
type AuditEvent struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
UserID string `json:"user_id,omitempty"`
Action string `json:"action"`
Resource string `json:"resource"`
Details map[string]interface{} `json:"details"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Success bool `json:"success"`
}
// LogAuditEvent logs a security audit event
func (al *AuditLogger) LogAuditEvent(event AuditEvent) error {
event.ID = fmt.Sprintf("audit_%d", time.Now().UnixNano())
event.Timestamp = time.Now()
// Encrypt sensitive details
if event.Details != nil {
encryptedDetails, err := al.encryptionManager.EncryptSensitiveData(event.Details)
if err != nil {
return fmt.Errorf("failed to encrypt audit details: %w", err)
}
event.Details = encryptedDetails
}
// In a real implementation, this would be stored in a secure audit database
// For now, we'll just return success
return nil
}
// LogSecurityEvent logs security-related events
func (al *AuditLogger) LogSecurityEvent(userID, action, resource string, details map[string]interface{}, ipAddress, userAgent string, success bool) error {
event := AuditEvent{
UserID: userID,
Action: action,
Resource: resource,
Details: details,
IPAddress: ipAddress,
UserAgent: userAgent,
Success: success,
}
return al.LogAuditEvent(event)
}
+404
View File
@@ -0,0 +1,404 @@
package security
import (
"containr/internal/database"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"time"
"github.com/google/uuid"
)
// Vulnerability represents a security vulnerability
type Vulnerability struct {
ID string `json:"id"`
Type string `json:"type"` // "dependency", "configuration", "code"
Severity string `json:"severity"` // "critical", "high", "medium", "low"
Title string `json:"title"`
Description string `json:"description"`
ServiceID string `json:"service_id"`
ProjectID string `json:"project_id"`
Status string `json:"status"` // "open", "resolved", "ignored"
FoundAt time.Time `json:"found_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
Metadata string `json:"metadata"` // JSON string for additional data
}
// SecurityScan represents a security scan result
type SecurityScan struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
ServiceID *string `json:"service_id,omitempty"`
ScanType string `json:"scan_type"` // "dependency", "configuration", "comprehensive"
Status string `json:"status"` // "running", "completed", "failed"
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Vulnerabilities []Vulnerability `json:"vulnerabilities"`
Summary ScanSummary `json:"summary"`
}
// ScanSummary provides a summary of scan results
type ScanSummary struct {
Total int `json:"total"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Score int `json:"score"` // 0-100 security score
}
// Scanner handles security scanning operations
type Scanner struct {
db *database.DB
}
// NewScanner creates a new security scanner
func NewScanner(db *database.DB) *Scanner {
return &Scanner{db: db}
}
// StartSecurityScan initiates a security scan
func (s *Scanner) StartSecurityScan(projectID, serviceID, scanType string) (*SecurityScan, error) {
scanID := uuid.New().String()
scan := &SecurityScan{
ID: scanID,
ProjectID: projectID,
ScanType: scanType,
Status: "running",
StartedAt: time.Now(),
Summary: ScanSummary{},
}
if serviceID != "" {
scan.ServiceID = &serviceID
}
// Insert scan record
_, err := s.db.Exec(`
INSERT INTO security_scans (id, project_id, service_id, scan_type, status, started_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, scan.ID, scan.ProjectID, scan.ServiceID, scan.ScanType, scan.Status, scan.StartedAt)
if err != nil {
return nil, fmt.Errorf("failed to create security scan: %w", err)
}
// Start scan in background
go s.performScan(scan)
return scan, nil
}
// performScan executes the actual security scan
func (s *Scanner) performScan(scan *SecurityScan) {
ctx := context.Background()
var vulnerabilities []Vulnerability
switch scan.ScanType {
case "dependency":
vulnerabilities = s.scanDependencies(ctx, scan)
case "configuration":
vulnerabilities = s.scanConfiguration(ctx, scan)
case "comprehensive":
vulnerabilities = s.scanComprehensive(ctx, scan)
default:
vulnerabilities = []Vulnerability{}
}
// Calculate summary
summary := s.calculateSummary(vulnerabilities)
// Update scan with results
completedAt := time.Now()
_, err := s.db.Exec(`
UPDATE security_scans
SET status = $1, completed_at = $2, summary = $3
WHERE id = $4
`, "completed", completedAt, summaryToJSON(summary), scan.ID)
if err != nil {
log.Printf("Failed to update security scan %s: %v", scan.ID, err)
return
}
// Store vulnerabilities
for _, vuln := range vulnerabilities {
_, err := s.db.Exec(`
INSERT INTO vulnerabilities (id, type, severity, title, description, service_id, project_id, status, found_at, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, vuln.ID, vuln.Type, vuln.Severity, vuln.Title, vuln.Description, vuln.ServiceID, vuln.ProjectID, vuln.Status, vuln.FoundAt, vuln.Metadata)
if err != nil {
log.Printf("Failed to store vulnerability %s: %v", vuln.ID, err)
}
}
}
// scanDependencies scans for known vulnerable dependencies
func (s *Scanner) scanDependencies(ctx context.Context, scan *SecurityScan) []Vulnerability {
var vulnerabilities []Vulnerability
// Get project services
rows, err := s.db.Query(`
SELECT id, name FROM services WHERE project_id = $1
`, scan.ProjectID)
if err != nil {
log.Printf("Failed to query services for scan: %v", err)
return vulnerabilities
}
defer rows.Close()
for rows.Next() {
var serviceID, serviceName string
if err := rows.Scan(&serviceID, &serviceName); err != nil {
continue
}
// Simulate dependency scanning (in real implementation, this would check package.json, go.mod, etc.)
serviceVulns := s.simulateDependencyScan(serviceID, serviceName)
vulnerabilities = append(vulnerabilities, serviceVulns...)
}
return vulnerabilities
}
// simulateDependencyScan simulates scanning for vulnerable dependencies
func (s *Scanner) simulateDependencyScan(serviceID, serviceName string) []Vulnerability {
var vulns []Vulnerability
// Simulate finding some common vulnerabilities
commonVulns := []struct {
title string
description string
severity string
}{
{"Outdated OpenSSL version", "Service uses OpenSSL version with known vulnerabilities", "high"},
{"Vulnerable npm package", "Package 'lodash' version < 4.17.21 has prototype pollution vulnerability", "medium"},
{"Outdated Go module", "Go module 'net/http' version has security issues", "low"},
}
for i, vuln := range commonVulns {
vulns = append(vulns, Vulnerability{
ID: uuid.New().String(),
Type: "dependency",
Severity: vuln.severity,
Title: vuln.title,
Description: vuln.description,
ServiceID: serviceID,
ProjectID: "", // Will be filled by caller
Status: "open",
FoundAt: time.Now(),
Metadata: fmt.Sprintf(`{"service": "%s", "package": "example-package-%d"}`, serviceName, i+1),
})
}
return vulns
}
// scanConfiguration scans for security configuration issues
func (s *Scanner) scanConfiguration(ctx context.Context, scan *SecurityScan) []Vulnerability {
var vulnerabilities []Vulnerability
// Check for common configuration issues
configIssues := []struct {
title string
description string
severity string
}{
{"Debug mode enabled", "Application is running in debug mode in production", "high"},
{"No rate limiting", "API endpoints lack rate limiting protection", "medium"},
{"CORS too permissive", "CORS configuration allows all origins", "medium"},
{"Missing security headers", "Security headers (CSP, HSTS) not configured", "low"},
}
for _, issue := range configIssues {
vulnerabilities = append(vulnerabilities, Vulnerability{
ID: uuid.New().String(),
Type: "configuration",
Severity: issue.severity,
Title: issue.title,
Description: issue.description,
ServiceID: "", // Project-level issue
ProjectID: scan.ProjectID,
Status: "open",
FoundAt: time.Now(),
Metadata: "{}",
})
}
return vulnerabilities
}
// scanComprehensive performs a comprehensive security scan
func (s *Scanner) scanComprehensive(ctx context.Context, scan *SecurityScan) []Vulnerability {
var allVulnerabilities []Vulnerability
// Run all scan types
allVulnerabilities = append(allVulnerabilities, s.scanDependencies(ctx, scan)...)
allVulnerabilities = append(allVulnerabilities, s.scanConfiguration(ctx, scan)...)
return allVulnerabilities
}
// calculateSummary calculates scan summary from vulnerabilities
func (s *Scanner) calculateSummary(vulnerabilities []Vulnerability) ScanSummary {
summary := ScanSummary{
Total: len(vulnerabilities),
}
for _, vuln := range vulnerabilities {
switch vuln.Severity {
case "critical":
summary.Critical++
case "high":
summary.High++
case "medium":
summary.Medium++
case "low":
summary.Low++
}
}
// Calculate security score (0-100, higher is better)
if summary.Total == 0 {
summary.Score = 100
} else {
deduction := (summary.Critical * 25) + (summary.High * 15) + (summary.Medium * 8) + (summary.Low * 3)
summary.Score = max(0, 100-deduction)
}
return summary
}
// GetSecurityScan retrieves a security scan by ID
func (s *Scanner) GetSecurityScan(scanID string) (*SecurityScan, error) {
var scan SecurityScan
var summaryJSON sql.NullString
var completedAt sql.NullTime
err := s.db.QueryRow(`
SELECT id, project_id, service_id, scan_type, status, started_at, completed_at, summary
FROM security_scans WHERE id = $1
`, scanID).Scan(&scan.ID, &scan.ProjectID, &scan.ServiceID, &scan.ScanType, &scan.Status, &scan.StartedAt, &completedAt, &summaryJSON)
if err != nil {
return nil, err
}
if completedAt.Valid {
scan.CompletedAt = &completedAt.Time
}
if summaryJSON.Valid {
scan.Summary = jsonToSummary(summaryJSON.String)
}
// Load vulnerabilities
vulns, err := s.getVulnerabilitiesForScan(scan.ID)
if err == nil {
scan.Vulnerabilities = vulns
}
return &scan, nil
}
// getVulnerabilitiesForScan retrieves vulnerabilities for a scan
func (s *Scanner) getVulnerabilitiesForScan(scanID string) ([]Vulnerability, error) {
rows, err := s.db.Query(`
SELECT id, type, severity, title, description, service_id, project_id, status, found_at, resolved_at, metadata
FROM vulnerabilities WHERE project_id = (SELECT project_id FROM security_scans WHERE id = $1)
ORDER BY severity DESC, found_at DESC
`, scanID)
if err != nil {
return nil, err
}
defer rows.Close()
var vulnerabilities []Vulnerability
for rows.Next() {
var vuln Vulnerability
var resolvedAt sql.NullTime
err := rows.Scan(&vuln.ID, &vuln.Type, &vuln.Severity, &vuln.Title, &vuln.Description,
&vuln.ServiceID, &vuln.ProjectID, &vuln.Status, &vuln.FoundAt, &resolvedAt, &vuln.Metadata)
if err != nil {
continue
}
if resolvedAt.Valid {
vuln.ResolvedAt = &resolvedAt.Time
}
vulnerabilities = append(vulnerabilities, vuln)
}
return vulnerabilities, nil
}
// GetProjectSecurityHistory retrieves security scan history for a project
func (s *Scanner) GetProjectSecurityHistory(projectID string, limit int) ([]SecurityScan, error) {
rows, err := s.db.Query(`
SELECT id, project_id, service_id, scan_type, status, started_at, completed_at, summary
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT $2
`, projectID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var scans []SecurityScan
for rows.Next() {
var scan SecurityScan
var summaryJSON sql.NullString
var completedAt sql.NullTime
err := rows.Scan(&scan.ID, &scan.ProjectID, &scan.ServiceID, &scan.ScanType, &scan.Status,
&scan.StartedAt, &completedAt, &summaryJSON)
if err != nil {
continue
}
if completedAt.Valid {
scan.CompletedAt = &completedAt.Time
}
if summaryJSON.Valid {
scan.Summary = jsonToSummary(summaryJSON.String)
}
scans = append(scans, scan)
}
return scans, nil
}
// Helper functions
func summaryToJSON(summary ScanSummary) string {
data, _ := json.Marshal(summary)
return string(data)
}
func jsonToSummary(jsonStr string) ScanSummary {
var summary ScanSummary
json.Unmarshal([]byte(jsonStr), &summary)
return summary
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+140
View File
@@ -0,0 +1,140 @@
package types
import "time"
// BuildRequest represents a request to build a container image
type BuildRequest struct {
// Build configuration
BuildType string `json:"build_type"` // nixpacks, dockerfile, prebuilt
SourcePath string `json:"source_path"` // Path to source code
PrebuiltImage string `json:"prebuilt_image"` // Prebuilt image name
ImageName string `json:"image_name"` // Output image name
ImageTag string `json:"image_tag"` // Output image tag
RegistryURL string `json:"registry_url"` // Registry URL for pushing
// Build commands
BuildCommand string `json:"build_command"` // Custom build command
StartCommand string `json:"start_command"` // Custom start command
// Environment and configuration
Environment map[string]string `json:"environment"` // Build environment variables
BuildArgs map[string]string `json:"build_args"` // Docker build args
Labels map[string]string `json:"labels"` // Image labels
// Context
ProjectID string `json:"project_id"` // Project ID
ServiceID string `json:"service_id"` // Service ID
DeploymentID string `json:"deployment_id"` // Deployment ID
TriggeredBy string `json:"triggered_by"` // Who triggered the build
Branch string `json:"branch"` // Git branch
Commit string `json:"commit"` // Git commit SHA
}
// BuildResponse represents the response from a build operation
type BuildResponse struct {
ImageName string `json:"image_name"` // Built image name
ImageTag string `json:"image_tag"` // Image tag
Size int64 `json:"size"` // Image size in bytes
Digest string `json:"digest"` // Image digest
BuildTime time.Time `json:"build_time"` // When build completed
BuildLog string `json:"build_log"` // Build logs
Success bool `json:"success"` // Whether build succeeded
Error string `json:"error"` // Error message if failed
}
// BuildStatus represents the status of a build
type BuildStatus struct {
ID string `json:"id"` // Build ID
ProjectID string `json:"project_id"` // Project ID
ServiceID string `json:"service_id"` // Service ID
DeploymentID string `json:"deployment_id"` // Deployment ID
Status string `json:"status"` // pending, building, success, failed
Progress int `json:"progress"` // Build progress 0-100
StartedAt time.Time `json:"started_at"` // When build started
CompletedAt *time.Time `json:"completed_at"` // When build completed
ImageName string `json:"image_name"` // Built image name
ImageTag string `json:"image_tag"` // Image tag
Size int64 `json:"size"` // Image size in bytes
Error string `json:"error"` // Error message if failed
Log string `json:"log"` // Build logs
Metadata map[string]string `json:"metadata"` // Additional metadata
}
// BuildPlan represents a build plan for inspection
type BuildPlan struct {
BuildType string `json:"build_type"` // Type of build
Runtime string `json:"runtime"` // Detected runtime
Builder string `json:"builder"` // Builder to use
Steps []BuildStep `json:"steps"` // Build steps
Environment map[string]string `json:"environment"` // Environment variables
Dependencies []string `json:"dependencies"` // Dependencies
Estimate BuildEstimate `json:"estimate"` // Build time/size estimate
}
// BuildStep represents a single step in the build process
type BuildStep struct {
Name string `json:"name"` // Step name
Command string `json:"command"` // Command to run
Args []string `json:"args"` // Command arguments
Environment map[string]string `json:"environment"` // Step-specific environment
Timeout time.Duration `json:"timeout"` // Step timeout
Critical bool `json:"critical"` // Whether step is critical
}
// BuildEstimate provides estimates for build time and size
type BuildEstimate struct {
Duration time.Duration `json:"duration"` // Estimated build duration
ImageSize int64 `json:"image_size"` // Estimated image size
Confidence float64 `json:"confidence"` // Confidence in estimate (0-1)
}
// BuildCache represents build cache information
type BuildCache struct {
Key string `json:"key"` // Cache key
Path string `json:"path"` // Cache path
Size int64 `json:"size"` // Cache size in bytes
CreatedAt time.Time `json:"created_at"` // When cache was created
AccessedAt time.Time `json:"accessed_at"` // When cache was last accessed
Metadata map[string]string `json:"metadata"` // Cache metadata
}
// BuildTrigger represents what triggered a build
type BuildTrigger struct {
Type string `json:"type"` // webhook, manual, api, scheduled
Source string `json:"source"` // Source of trigger
User string `json:"user"` // User who triggered (if applicable)
Data map[string]string `json:"data"` // Trigger-specific data
Timestamp time.Time `json:"timestamp"` // When trigger occurred
}
// BuildConfig represents global build configuration
type BuildConfig struct {
DefaultBuilder string `json:"default_builder"` // Default builder type
CacheEnabled bool `json:"cache_enabled"` // Whether build caching is enabled
CacheSizeLimit int64 `json:"cache_size_limit"` // Cache size limit in bytes
ParallelBuilds int `json:"parallel_builds"` // Max parallel builds
Timeout time.Duration `json:"timeout"` // Default build timeout
Registry string `json:"registry"` // Default registry
Environment map[string]string `json:"environment"` // Default environment variables
AllowedRegistries []string `json:"allowed_registries"` // Allowed container registries
}
// BuildMetric represents build metrics for monitoring
type BuildMetric struct {
Timestamp time.Time `json:"timestamp"` // When metric was recorded
BuildCount int `json:"build_count"` // Number of builds
SuccessCount int `json:"success_count"` // Number of successful builds
FailureCount int `json:"failure_count"` // Number of failed builds
AvgDuration time.Duration `json:"avg_duration"` // Average build duration
AvgSize int64 `json:"avg_size"` // Average image size
}
// BuildQueue represents the build queue status
type BuildQueue struct {
Running int `json:"running"` // Currently running builds
Pending int `json:"pending"` // Pending builds in queue
Completed int `json:"completed"` // Completed builds
Failed int `json:"failed"` // Failed builds
Capacity int `json:"capacity"` // Queue capacity
WaitTime time.Duration `json:"wait_time"` // Average wait time
}
+142
View File
@@ -0,0 +1,142 @@
-- Initial schema for Containr platform
-- This migration creates the core tables for users, projects, services, and deployments
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
avatar_url VARCHAR(500),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Projects table
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Environments table
CREATE TABLE IF NOT EXISTS environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL, -- 'production', 'preview', 'development'
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(name, project_id)
);
-- Services table
CREATE TABLE IF NOT EXISTS services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
service_type VARCHAR(50) NOT NULL, -- 'web', 'worker', 'database', 'cron'
source_type VARCHAR(50) NOT NULL, -- 'github', 'dockerfile', 'image', 'template'
source_url VARCHAR(500),
image_name VARCHAR(500),
build_command TEXT,
start_command TEXT,
cpu_limit INTEGER,
memory_limit INTEGER, -- in MB
public_url VARCHAR(500),
health_check_url VARCHAR(500),
status VARCHAR(50) DEFAULT 'created', -- 'created', 'building', 'running', 'stopped', 'failed'
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Environment variables table
CREATE TABLE IF NOT EXISTS environment_variables (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
key VARCHAR(255) NOT NULL,
value TEXT,
is_secret BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(service_id, key)
);
-- Deployments table
CREATE TABLE IF NOT EXISTS deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
version VARCHAR(100) NOT NULL,
commit_hash VARCHAR(100),
image_digest VARCHAR(500),
status VARCHAR(50) DEFAULT 'created', -- 'created', 'building', 'deploying', 'running', 'failed', 'rolled_back'
build_log TEXT,
deployment_log TEXT,
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Service dependencies table (for service-to-service relationships)
CREATE TABLE IF NOT EXISTS service_dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
depends_on_service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(service_id, depends_on_service_id),
CHECK (service_id != depends_on_service_id)
);
-- Project members table (for collaboration)
CREATE TABLE IF NOT EXISTS project_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL DEFAULT 'developer', -- 'owner', 'admin', 'developer', 'viewer'
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(project_id, user_id)
);
-- Indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_projects_owner_id ON projects(owner_id);
CREATE INDEX IF NOT EXISTS idx_services_project_id ON services(project_id);
CREATE INDEX IF NOT EXISTS idx_services_environment_id ON services(environment_id);
CREATE INDEX IF NOT EXISTS idx_deployments_service_id ON deployments(service_id);
CREATE INDEX IF NOT EXISTS idx_deployments_status ON deployments(status);
CREATE INDEX IF NOT EXISTS idx_environment_variables_service_id ON environment_variables(service_id);
CREATE INDEX IF NOT EXISTS idx_project_members_project_id ON project_members(project_id);
CREATE INDEX IF NOT EXISTS idx_project_members_user_id ON project_members(user_id);
-- Update timestamp trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Add update triggers to tables with updated_at columns
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_environments_updated_at BEFORE UPDATE ON environments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_services_updated_at BEFORE UPDATE ON services
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_environment_variables_updated_at BEFORE UPDATE ON environment_variables
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_deployments_updated_at BEFORE UPDATE ON deployments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+123
View File
@@ -0,0 +1,123 @@
-- Git integration schema for Containr platform
-- This migration adds tables for Git providers, repositories, and webhooks
-- Git providers table (GitHub, GitLab, Bitbucket accounts)
CREATE TABLE IF NOT EXISTS git_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL, -- 'github', 'gitlab', 'bitbucket'
display_name VARCHAR(255) NOT NULL, -- User-friendly name for the account
api_url VARCHAR(500) NOT NULL,
webhook_url VARCHAR(500) NOT NULL,
access_token TEXT NOT NULL, -- Encrypted in production
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(name, user_id)
);
-- Git repositories table (connected repositories)
CREATE TABLE IF NOT EXISTS git_repositories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES git_providers(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
full_name VARCHAR(500) NOT NULL, -- e.g., "owner/repo-name"
description TEXT,
clone_url VARCHAR(500) NOT NULL,
webhook_url VARCHAR(500), -- Webhook URL on the Git provider
default_branch VARCHAR(100) DEFAULT 'main',
is_private BOOLEAN DEFAULT false,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(provider_id, full_name)
);
-- Git webhooks table (webhook configurations)
CREATE TABLE IF NOT EXISTS git_webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
repo_id UUID NOT NULL REFERENCES git_repositories(id) ON DELETE CASCADE,
provider_id UUID NOT NULL REFERENCES git_providers(id) ON DELETE CASCADE,
events TEXT NOT NULL, -- JSON array of webhook events
webhook_secret TEXT NOT NULL, -- Secret for validating webhook payloads
remote_webhook_id VARCHAR(255), -- Webhook ID on the Git provider
active BOOLEAN DEFAULT true,
branch_filter VARCHAR(100), -- Optional branch filter for deployments
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(repo_id, provider_id)
);
-- Git branches table (for tracking branch-specific deployments)
CREATE TABLE IF NOT EXISTS git_branches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
repo_id UUID NOT NULL REFERENCES git_repositories(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, -- Branch name
last_commit_hash VARCHAR(100),
last_commit_message TEXT,
last_commit_author VARCHAR(255),
last_commit_date TIMESTAMP WITH TIME ZONE,
is_protected BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(repo_id, name)
);
-- Git deployment triggers table (links webhooks to services)
CREATE TABLE IF NOT EXISTS git_deployment_triggers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id UUID NOT NULL REFERENCES git_webhooks(id) ON DELETE CASCADE,
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
branch VARCHAR(255) NOT NULL, -- Branch to trigger deployment for
environment VARCHAR(50) NOT NULL, -- Target environment
auto_deploy BOOLEAN DEFAULT false, -- Whether to auto-deploy on push
build_command TEXT,
start_command TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(webhook_id, service_id, branch)
);
-- Indexes for better performance
CREATE INDEX IF NOT EXISTS idx_git_providers_user_id ON git_providers(user_id);
CREATE INDEX IF NOT EXISTS idx_git_providers_name ON git_providers(name);
CREATE INDEX IF NOT EXISTS idx_git_repositories_provider_id ON git_repositories(provider_id);
CREATE INDEX IF NOT EXISTS idx_git_repositories_user_id ON git_repositories(user_id);
CREATE INDEX IF NOT EXISTS idx_git_repositories_full_name ON git_repositories(full_name);
CREATE INDEX IF NOT EXISTS idx_git_webhooks_repo_id ON git_webhooks(repo_id);
CREATE INDEX IF NOT EXISTS idx_git_webhooks_provider_id ON git_webhooks(provider_id);
CREATE INDEX IF NOT EXISTS idx_git_webhooks_active ON git_webhooks(active);
CREATE INDEX IF NOT EXISTS idx_git_branches_repo_id ON git_branches(repo_id);
CREATE INDEX IF NOT EXISTS idx_git_branches_name ON git_branches(name);
CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_webhook_id ON git_deployment_triggers(webhook_id);
CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_service_id ON git_deployment_triggers(service_id);
CREATE INDEX IF NOT EXISTS idx_git_deployment_triggers_branch ON git_deployment_triggers(branch);
-- Add update triggers to new tables
CREATE TRIGGER update_git_providers_updated_at BEFORE UPDATE ON git_providers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_git_repositories_updated_at BEFORE UPDATE ON git_repositories
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_git_webhooks_updated_at BEFORE UPDATE ON git_webhooks
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_git_branches_updated_at BEFORE UPDATE ON git_branches
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_git_deployment_triggers_updated_at BEFORE UPDATE ON git_deployment_triggers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Add foreign key constraint for project_members table if not exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'project_members_user_id_fkey'
) THEN
ALTER TABLE project_members
ADD CONSTRAINT project_members_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
END IF;
END
$$;
+178
View File
@@ -0,0 +1,178 @@
-- Agent system migration for Phase 3: Node Agent System
-- Create node_agents table
CREATE TABLE IF NOT EXISTS node_agents (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
hostname VARCHAR(255) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
port INTEGER NOT NULL,
status VARCHAR(50) DEFAULT 'offline',
version VARCHAR(50),
capabilities JSONB,
resources JSONB,
last_heartbeat TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
metadata JSONB
);
-- Create container_instances table
CREATE TABLE IF NOT EXISTS container_instances (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
image VARCHAR(255) NOT NULL,
project_id VARCHAR(255) NOT NULL,
service_id VARCHAR(255) NOT NULL,
node_agent_id VARCHAR(255) NOT NULL,
status JSONB,
resources JSONB,
ports JSONB,
environment JSONB,
volumes JSONB,
networks JSONB,
restart_policy JSONB,
health_check JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
started_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (node_agent_id) REFERENCES node_agents(id) ON DELETE CASCADE
);
-- Create agent_commands table
CREATE TABLE IF NOT EXISTS agent_commands (
id VARCHAR(255) PRIMARY KEY,
type VARCHAR(100) NOT NULL,
node_agent_id VARCHAR(255) NOT NULL,
container_id VARCHAR(255),
payload JSONB,
status VARCHAR(50) DEFAULT 'pending',
result TEXT,
error TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE,
FOREIGN KEY (node_agent_id) REFERENCES node_agents(id) ON DELETE CASCADE,
FOREIGN KEY (container_id) REFERENCES container_instances(id) ON DELETE CASCADE
);
-- Create node_clusters table
CREATE TABLE IF NOT EXISTS node_clusters (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'active',
total_resources JSONB,
used_resources JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create cluster_agents table to link clusters and agents
CREATE TABLE IF NOT EXISTS cluster_agents (
cluster_id VARCHAR(255) NOT NULL,
agent_id VARCHAR(255) NOT NULL,
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (cluster_id, agent_id),
FOREIGN KEY (cluster_id) REFERENCES node_clusters(id) ON DELETE CASCADE,
FOREIGN KEY (agent_id) REFERENCES node_agents(id) ON DELETE CASCADE
);
-- Create scheduling_rules table
CREATE TABLE IF NOT EXISTS scheduling_rules (
id VARCHAR(255) PRIMARY KEY,
cluster_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
selector JSONB,
weight INTEGER DEFAULT 1,
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (cluster_id) REFERENCES node_clusters(id) ON DELETE CASCADE
);
-- Create container_metrics table for monitoring
CREATE TABLE IF NOT EXISTS container_metrics (
id SERIAL PRIMARY KEY,
container_id VARCHAR(255) NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
cpu_usage DECIMAL(5,2),
cpu_usage_percent DECIMAL(5,2),
memory_usage BIGINT,
memory_usage_percent DECIMAL(5,2),
memory_limit BIGINT,
network_rx_bytes BIGINT,
network_tx_bytes BIGINT,
network_rx_packets BIGINT,
network_tx_packets BIGINT,
block_read_bytes BIGINT,
block_write_bytes BIGINT,
pids_current INTEGER,
pids_limit INTEGER,
FOREIGN KEY (container_id) REFERENCES container_instances(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_node_agents_status ON node_agents(status);
CREATE INDEX IF NOT EXISTS idx_node_agents_hostname ON node_agents(hostname);
CREATE INDEX IF NOT EXISTS idx_node_agents_ip_address ON node_agents(ip_address);
CREATE INDEX IF NOT EXISTS idx_node_agents_last_heartbeat ON node_agents(last_heartbeat);
CREATE INDEX IF NOT EXISTS idx_container_instances_project_id ON container_instances(project_id);
CREATE INDEX IF NOT EXISTS idx_container_instances_service_id ON container_instances(service_id);
CREATE INDEX IF NOT EXISTS idx_container_instances_node_agent_id ON container_instances(node_agent_id);
CREATE INDEX IF NOT EXISTS idx_container_instances_status ON container_instances USING GIN(status);
CREATE INDEX IF NOT EXISTS idx_agent_commands_node_agent_id ON agent_commands(node_agent_id);
CREATE INDEX IF NOT EXISTS idx_agent_commands_status ON agent_commands(status);
CREATE INDEX IF NOT EXISTS idx_agent_commands_type ON agent_commands(type);
CREATE INDEX IF NOT EXISTS idx_agent_commands_created_at ON agent_commands(created_at);
CREATE INDEX IF NOT EXISTS idx_container_metrics_container_id ON container_metrics(container_id);
CREATE INDEX IF NOT EXISTS idx_container_metrics_timestamp ON container_metrics(timestamp);
-- Create updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create triggers for updated_at
CREATE TRIGGER update_node_agents_updated_at BEFORE UPDATE ON node_agents
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_container_instances_updated_at BEFORE UPDATE ON container_instances
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_agent_commands_updated_at BEFORE UPDATE ON agent_commands
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_node_clusters_updated_at BEFORE UPDATE ON node_clusters
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_scheduling_rules_updated_at BEFORE UPDATE ON scheduling_rules
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Insert default cluster
INSERT INTO node_clusters (id, name, description, status, total_resources, used_resources)
VALUES (
'default-cluster',
'Default Cluster',
'Default cluster for all node agents',
'active',
'{"cpu": {"cores": 0, "allocation": 0, "usage": 0}, "memory": {"total": 0, "allocated": 0, "used": 0, "available": 0}, "storage": {"total": 0, "allocated": 0, "used": 0, "available": 0}, "network": {"interfaces": [], "bandwidth": {"inbound": 0, "outbound": 0}}}',
'{"cpu": {"cores": 0, "allocation": 0, "usage": 0}, "memory": {"total": 0, "allocated": 0, "used": 0, "available": 0}, "storage": {"total": 0, "allocated": 0, "used": 0, "available": 0}, "network": {"interfaces": [], "bandwidth": {"inbound": 0, "outbound": 0}}}'
) ON CONFLICT (id) DO NOTHING;
-- Add comment to tables
COMMENT ON TABLE node_agents IS 'Container orchestration agents that manage containers on nodes';
COMMENT ON TABLE container_instances IS 'Container instances running on node agents';
COMMENT ON TABLE agent_commands IS 'Commands sent to node agents for execution';
COMMENT ON TABLE node_clusters IS 'Clusters of node agents for resource pooling';
COMMENT ON TABLE cluster_agents IS 'Many-to-many relationship between clusters and agents';
COMMENT ON TABLE scheduling_rules IS 'Rules for scheduling containers on agents';
COMMENT ON TABLE container_metrics IS 'Metrics collected from running containers';
+333
View File
@@ -0,0 +1,333 @@
-- Metrics Schema Migration
-- This migration creates tables for storing system and service metrics
-- Enable TimescaleDB extension for time-series data (optional)
CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;
-- Node metrics table
CREATE TABLE IF NOT EXISTS node_metrics (
node_id VARCHAR(255) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
cpu_usage DECIMAL(5,2),
cpu_cores DECIMAL(10,2),
load_avg_1 DECIMAL(5,2),
load_avg_5 DECIMAL(5,2),
load_avg_15 DECIMAL(5,2),
memory_total BIGINT,
memory_used BIGINT,
memory_available BIGINT,
memory_usage_percent DECIMAL(5,2),
storage_total BIGINT,
storage_used BIGINT,
storage_available BIGINT,
storage_usage_percent DECIMAL(5,2),
network_bytes_in BIGINT,
network_bytes_out BIGINT,
network_packets_in BIGINT,
network_packets_out BIGINT,
network_connections_in INTEGER,
network_connections_out INTEGER,
network_errors_in BIGINT,
network_errors_out BIGINT,
uptime INTERVAL,
processes INTEGER,
os VARCHAR(50),
kernel VARCHAR(50),
architecture VARCHAR(20),
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (node_id, timestamp)
);
-- Create index for time-series queries
CREATE INDEX IF NOT EXISTS idx_node_metrics_timestamp ON node_metrics (timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_node_metrics_node_timestamp ON node_metrics (node_id, timestamp DESC);
-- Container metrics table
CREATE TABLE IF NOT EXISTS container_metrics (
node_id VARCHAR(255) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
container_id VARCHAR(255) NOT NULL,
name VARCHAR(255),
state VARCHAR(50),
cpu DECIMAL(5,2),
memory BIGINT,
network_bytes_in BIGINT,
network_bytes_out BIGINT,
network_packets_in BIGINT,
network_packets_out BIGINT,
start_time TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (node_id, timestamp, container_id),
FOREIGN KEY (node_id, timestamp) REFERENCES node_metrics (node_id, timestamp) ON DELETE CASCADE
);
-- Service metrics table
CREATE TABLE IF NOT EXISTS service_metrics (
service_id VARCHAR(255) NOT NULL,
service_name VARCHAR(255) NOT NULL,
project_id VARCHAR(255) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
requests_total BIGINT DEFAULT 0,
requests_success BIGINT DEFAULT 0,
requests_errors BIGINT DEFAULT 0,
requests_avg_latency DECIMAL(10,3),
requests_p95_latency DECIMAL(10,3),
requests_p99_latency DECIMAL(10,3),
requests_throughput DECIMAL(10,3),
errors_total BIGINT DEFAULT 0,
errors_rate DECIMAL(5,4),
performance_response_time DECIMAL(10,3),
performance_throughput DECIMAL(10,3),
performance_concurrency BIGINT,
performance_saturation DECIMAL(5,2),
performance_utilization DECIMAL(5,2),
resource_cpu_usage DECIMAL(5,2),
resource_memory_usage BIGINT,
resource_storage_usage BIGINT,
resource_network_usage BIGINT,
resource_score DECIMAL(5,2),
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (service_id, timestamp)
);
-- Create indexes for service metrics
CREATE INDEX IF NOT EXISTS idx_service_metrics_timestamp ON service_metrics (timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_service_metrics_service_timestamp ON service_metrics (service_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_service_metrics_project_timestamp ON service_metrics (project_id, timestamp DESC);
-- Instance metrics table
CREATE TABLE IF NOT EXISTS instance_metrics (
service_id VARCHAR(255) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
instance_id VARCHAR(255) NOT NULL,
node_id VARCHAR(255),
status VARCHAR(50),
cpu DECIMAL(5,2),
memory BIGINT,
network_bytes_in BIGINT,
network_bytes_out BIGINT,
network_packets_in BIGINT,
network_packets_out BIGINT,
network_connections_in INTEGER,
network_connections_out INTEGER,
network_errors_in BIGINT,
network_errors_out BIGINT,
start_time TIMESTAMPTZ,
last_seen TIMESTAMPTZ,
health_status VARCHAR(20),
health_last_check TIMESTAMPTZ,
health_check_count INTEGER DEFAULT 0,
health_failure_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (service_id, timestamp, instance_id),
FOREIGN KEY (service_id, timestamp) REFERENCES service_metrics (service_id, timestamp) ON DELETE CASCADE
);
-- Service discovery table
CREATE TABLE IF NOT EXISTS service_discovery (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
service_id VARCHAR(255) NOT NULL,
service_name VARCHAR(255) NOT NULL,
project_id VARCHAR(255) NOT NULL,
instance_id VARCHAR(255) NOT NULL,
node_id VARCHAR(255),
ip_address INET NOT NULL,
port INTEGER,
status VARCHAR(50) DEFAULT 'unknown',
health_status VARCHAR(20) DEFAULT 'unknown',
labels JSONB DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_seen TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(service_id, instance_id)
);
-- Create indexes for service discovery
CREATE INDEX IF NOT EXISTS idx_service_discovery_service ON service_discovery (service_id);
CREATE INDEX IF NOT EXISTS idx_service_discovery_project ON service_discovery (project_id);
CREATE INDEX IF NOT EXISTS idx_service_discovery_name ON service_discovery (service_name);
CREATE INDEX IF NOT EXISTS idx_service_discovery_status ON service_discovery (status);
CREATE INDEX IF NOT EXISTS idx_service_discovery_ip ON service_discovery (ip_address);
CREATE INDEX IF NOT EXISTS idx_service_discovery_labels ON service_discovery USING GIN (labels);
-- DNS records table
CREATE TABLE IF NOT EXISTS dns_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
type VARCHAR(10) NOT NULL, -- A, SRV, CNAME, etc.
ttl INTEGER DEFAULT 300,
records JSONB NOT NULL, -- Array of records
priority INTEGER,
weight INTEGER,
port INTEGER,
service_id VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for DNS records
CREATE INDEX IF NOT EXISTS idx_dns_records_name ON dns_records (name);
CREATE INDEX IF NOT EXISTS idx_dns_records_type ON dns_records (type);
CREATE INDEX IF NOT EXISTS idx_dns_records_service ON dns_records (service_id);
-- Metrics aggregation rules table
CREATE TABLE IF NOT EXISTS metrics_aggregation_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL UNIQUE,
metric_type VARCHAR(50) NOT NULL, -- node, service, container
aggregation_function VARCHAR(50) NOT NULL, -- avg, sum, min, max, count
interval INTERVAL NOT NULL, -- 1m, 5m, 1h, etc.
retention_period INTERVAL DEFAULT '30 days',
fields JSONB NOT NULL, -- Which fields to aggregate
filters JSONB DEFAULT '{}', -- Optional filters
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for aggregation rules
CREATE INDEX IF NOT EXISTS idx_metrics_aggregation_rules_type ON metrics_aggregation_rules (metric_type);
-- Alert rules table
CREATE TABLE IF NOT EXISTS alert_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
metric_type VARCHAR(50) NOT NULL,
metric_field VARCHAR(100) NOT NULL,
condition VARCHAR(20) NOT NULL, -- gt, lt, eq, gte, lte
threshold DECIMAL(15,4) NOT NULL,
duration INTERVAL DEFAULT '5 minutes',
severity VARCHAR(20) DEFAULT 'warning', -- critical, warning, info
enabled BOOLEAN DEFAULT true,
filters JSONB DEFAULT '{}',
notification_channels JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for alert rules
CREATE INDEX IF NOT EXISTS idx_alert_rules_type ON alert_rules (metric_type);
CREATE INDEX IF NOT EXISTS idx_alert_rules_enabled ON alert_rules (enabled);
-- Alert incidents table
CREATE TABLE IF NOT EXISTS alert_incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_id UUID NOT NULL REFERENCES alert_rules (id) ON DELETE CASCADE,
metric_type VARCHAR(50) NOT NULL,
metric_field VARCHAR(100) NOT NULL,
current_value DECIMAL(15,4) NOT NULL,
threshold DECIMAL(15,4) NOT NULL,
severity VARCHAR(20) NOT NULL,
status VARCHAR(20) DEFAULT 'firing', -- firing, resolved
started_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
duration INTERVAL,
description TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for alert incidents
CREATE INDEX IF NOT EXISTS idx_alert_incidents_rule ON alert_incidents (rule_id);
CREATE INDEX IF NOT EXISTS idx_alert_incidents_status ON alert_incidents (status);
CREATE INDEX IF NOT EXISTS idx_alert_incidents_started ON alert_incidents (started_at DESC);
-- Create TimescaleDB hypertables if TimescaleDB is available
DO $$
BEGIN
-- Only create hypertables if TimescaleDB extension is available
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
PERFORM create_hypertable('node_metrics', 'timestamp', chunk_time_interval => INTERVAL '1 hour');
PERFORM create_hypertable('service_metrics', 'timestamp', chunk_time_interval => INTERVAL '1 hour');
PERFORM create_hypertable('container_metrics', 'timestamp', chunk_time_interval => INTERVAL '1 hour');
PERFORM create_hypertable('instance_metrics', 'timestamp', chunk_time_interval => INTERVAL '1 hour');
-- Create compression policies for older data
PERFORM add_compression_policy('node_metrics', INTERVAL '7 days');
PERFORM add_compression_policy('service_metrics', INTERVAL '7 days');
PERFORM add_compression_policy('container_metrics', INTERVAL '7 days');
PERFORM add_compression_policy('instance_metrics', INTERVAL '7 days');
-- Create retention policies
PERFORM add_retention_policy('node_metrics', INTERVAL '90 days');
PERFORM add_retention_policy('service_metrics', INTERVAL '90 days');
PERFORM add_retention_policy('container_metrics', INTERVAL '90 days');
PERFORM add_retention_policy('instance_metrics', INTERVAL '90 days');
END IF;
END $$;
-- Create updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create triggers for updated_at columns
CREATE TRIGGER update_service_discovery_updated_at BEFORE UPDATE ON service_discovery FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_dns_records_updated_at BEFORE UPDATE ON dns_records FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_metrics_aggregation_rules_updated_at BEFORE UPDATE ON metrics_aggregation_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_alert_rules_updated_at BEFORE UPDATE ON alert_rules FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Insert default aggregation rules
INSERT INTO metrics_aggregation_rules (name, metric_type, aggregation_function, interval, fields) VALUES
('node_cpu_1m', 'node', 'avg', INTERVAL '1 minute', '{"cpu_usage": true, "memory_usage_percent": true}'),
('node_cpu_5m', 'node', 'avg', INTERVAL '5 minutes', '{"cpu_usage": true, "memory_usage_percent": true}'),
('node_cpu_1h', 'node', 'avg', INTERVAL '1 hour', '{"cpu_usage": true, "memory_usage_percent": true, "storage_usage_percent": true}'),
('service_requests_1m', 'service', 'sum', INTERVAL '1 minute', '{"requests_total": true, "requests_success": true, "requests_errors": true}'),
('service_requests_5m', 'service', 'sum', INTERVAL '5 minutes', '{"requests_total": true, "requests_success": true, "requests_errors": true}'),
('service_performance_5m', 'service', 'avg', INTERVAL '5 minutes', '{"requests_avg_latency": true, "requests_p95_latency": true, "requests_throughput": true}')
ON CONFLICT (name) DO NOTHING;
-- Insert default alert rules
INSERT INTO alert_rules (name, description, metric_type, metric_field, condition, threshold, severity) VALUES
('High CPU Usage', 'Node CPU usage is above 80%', 'node', 'cpu_usage', 'gt', 80.0, 'warning'),
('Critical CPU Usage', 'Node CPU usage is above 95%', 'node', 'cpu_usage', 'gt', 95.0, 'critical'),
('High Memory Usage', 'Node memory usage is above 85%', 'node', 'memory_usage_percent', 'gt', 85.0, 'warning'),
('Critical Memory Usage', 'Node memory usage is above 95%', 'node', 'memory_usage_percent', 'gt', 95.0, 'critical'),
('High Error Rate', 'Service error rate is above 10%', 'service', 'errors_rate', 'gt', 0.10, 'warning'),
('Critical Error Rate', 'Service error rate is above 25%', 'service', 'errors_rate', 'gt', 0.25, 'critical'),
('High Latency', 'Service P95 latency is above 1000ms', 'service', 'requests_p95_latency', 'gt', 1000.0, 'warning'),
('Critical Latency', 'Service P95 latency is above 5000ms', 'service', 'requests_p95_latency', 'gt', 5000.0, 'critical')
ON CONFLICT (name) DO NOTHING;
-- Create views for common queries
CREATE OR REPLACE VIEW node_metrics_summary AS
SELECT
node_id,
timestamp,
cpu_usage,
memory_usage_percent,
storage_usage_percent,
network_bytes_in + network_bytes_out as total_network_bytes,
load_avg_1,
uptime
FROM node_metrics
ORDER BY timestamp DESC;
CREATE OR REPLACE VIEW service_metrics_summary AS
SELECT
service_id,
service_name,
project_id,
timestamp,
requests_total,
requests_success,
requests_errors,
CASE WHEN requests_total > 0 THEN (requests_errors::DECIMAL / requests_total) ELSE 0 END as error_rate,
requests_avg_latency,
requests_p95_latency,
requests_throughput,
resource_cpu_usage,
resource_memory_usage
FROM service_metrics
ORDER BY timestamp DESC;
-- Grant permissions (adjust as needed for your setup)
-- GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO containr_app;
-- GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO containr_app;
COMMIT;
+255
View File
@@ -0,0 +1,255 @@
-- Database Services Migration
-- This migration creates tables for managed database services
-- Database Services table
CREATE TABLE IF NOT EXISTS database_services (
id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('postgresql', 'redis', 'mysql')),
status VARCHAR(50) NOT NULL DEFAULT 'building' CHECK (status IN ('running', 'stopped', 'building', 'error')),
version VARCHAR(50) NOT NULL,
plan VARCHAR(50) NOT NULL CHECK (plan IN ('hobby', 'starter', 'standard', 'business')),
region VARCHAR(50) NOT NULL,
connection_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT fk_database_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Database Backups table
CREATE TABLE IF NOT EXISTS database_backups (
id VARCHAR(255) PRIMARY KEY,
database_id VARCHAR(255) NOT NULL,
size VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'in_progress' CHECK (status IN ('completed', 'failed', 'in_progress')),
backup_path TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT fk_backup_database FOREIGN KEY (database_id) REFERENCES database_services(id) ON DELETE CASCADE
);
-- Database Settings table
CREATE TABLE IF NOT EXISTS database_settings (
database_id VARCHAR(255) PRIMARY KEY,
max_connections INTEGER DEFAULT 100,
timeout INTEGER DEFAULT 30,
ssl_enabled BOOLEAN DEFAULT true,
logging_enabled BOOLEAN DEFAULT true,
retention_days INTEGER DEFAULT 30,
backup_enabled BOOLEAN DEFAULT true,
next_backup_time TIMESTAMP WITH TIME ZONE,
last_backup_time TIMESTAMP WITH TIME ZONE,
CONSTRAINT fk_settings_database FOREIGN KEY (database_id) REFERENCES database_services(id) ON DELETE CASCADE
);
-- Database Metrics table for storing historical metrics
CREATE TABLE IF NOT EXISTS database_metrics (
id SERIAL PRIMARY KEY,
database_id VARCHAR(255) NOT NULL,
cpu_usage DECIMAL(5,2),
memory_usage DECIMAL(5,2),
storage_usage DECIMAL(5,2),
active_connections INTEGER,
read_iops INTEGER,
write_iops INTEGER,
network_in_mbps DECIMAL(8,2),
network_out_mbps DECIMAL(8,2),
recorded_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT fk_metrics_database FOREIGN KEY (database_id) REFERENCES database_services(id) ON DELETE CASCADE
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_database_services_user_id ON database_services(user_id);
CREATE INDEX IF NOT EXISTS idx_database_services_type ON database_services(type);
CREATE INDEX IF NOT EXISTS idx_database_services_status ON database_services(status);
CREATE INDEX IF NOT EXISTS idx_database_services_created_at ON database_services(created_at);
CREATE INDEX IF NOT EXISTS idx_database_backups_database_id ON database_backups(database_id);
CREATE INDEX IF NOT EXISTS idx_database_backups_created_at ON database_backups(created_at);
CREATE INDEX IF NOT EXISTS idx_database_backups_status ON database_backups(status);
CREATE INDEX IF NOT EXISTS idx_database_metrics_database_id ON database_metrics(database_id);
CREATE INDEX IF NOT EXISTS idx_database_metrics_recorded_at ON database_metrics(recorded_at);
-- Create trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_database_services_updated_at
BEFORE UPDATE ON database_services
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Insert default settings for existing databases (if any)
INSERT INTO database_settings (database_id)
SELECT id FROM database_services
WHERE id NOT IN (SELECT database_id FROM database_settings);
-- Create view for database statistics
CREATE OR REPLACE VIEW database_stats AS
SELECT
ds.id,
ds.name,
ds.type,
ds.status,
ds.plan,
ds.region,
ds.created_at,
ds.updated_at,
COUNT(db.id) as backup_count,
MAX(db.created_at) as last_backup_time,
dm.cpu_usage as latest_cpu,
dm.memory_usage as latest_memory,
dm.storage_usage as latest_storage,
dm.active_connections as latest_connections,
dm.recorded_at as metrics_updated_at
FROM database_services ds
LEFT JOIN database_backups db ON ds.id = db.database_id AND db.status = 'completed'
LEFT JOIN database_metrics dm ON ds.id = dm.database_id
LEFT JOIN LATERAL (
SELECT cpu_usage, memory_usage, storage_usage, active_connections, recorded_at
FROM database_metrics
WHERE database_id = ds.id
ORDER BY recorded_at DESC
LIMIT 1
) dm ON true
GROUP BY ds.id, ds.name, ds.type, ds.status, ds.plan, ds.region, ds.created_at, ds.updated_at,
dm.cpu_usage, dm.memory_usage, dm.storage_usage, dm.active_connections, dm.recorded_at;
-- Add RLS (Row Level Security) policies
ALTER TABLE database_services ENABLE ROW LEVEL SECURITY;
ALTER TABLE database_backups ENABLE ROW LEVEL SECURITY;
ALTER TABLE database_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE database_metrics ENABLE ROW LEVEL SECURITY;
-- Policy for database services - users can only see their own databases
CREATE POLICY "Users can view their own database services" ON database_services
FOR SELECT USING (user_id = current_setting('app.current_user_id', true)::VARCHAR);
CREATE POLICY "Users can insert their own database services" ON database_services
FOR INSERT WITH CHECK (user_id = current_setting('app.current_user_id', true)::VARCHAR);
CREATE POLICY "Users can update their own database services" ON database_services
FOR UPDATE USING (user_id = current_setting('app.current_user_id', true)::VARCHAR);
CREATE POLICY "Users can delete their own database services" ON database_services
FOR DELETE USING (user_id = current_setting('app.current_user_id', true)::VARCHAR);
-- Policies for backups (inherited from database services)
CREATE POLICY "Users can view backups of their own databases" ON database_backups
FOR SELECT USING (
database_id IN (
SELECT id FROM database_services
WHERE user_id = current_setting('app.current_user_id', true)::VARCHAR
)
);
CREATE POLICY "Users can insert backups for their own databases" ON database_backups
FOR INSERT WITH CHECK (
database_id IN (
SELECT id FROM database_services
WHERE user_id = current_setting('app.current_user_id', true)::VARCHAR
)
);
-- Policies for settings (inherited from database services)
CREATE POLICY "Users can view settings of their own databases" ON database_settings
FOR SELECT USING (
database_id IN (
SELECT id FROM database_services
WHERE user_id = current_setting('app.current_user_id', true)::VARCHAR
)
);
CREATE POLICY "Users can update settings of their own databases" ON database_settings
FOR UPDATE USING (
database_id IN (
SELECT id FROM database_services
WHERE user_id = current_setting('app.current_user_id', true)::VARCHAR
)
);
-- Policies for metrics (inherited from database services)
CREATE POLICY "Users can view metrics of their own databases" ON database_metrics
FOR SELECT USING (
database_id IN (
SELECT id FROM database_services
WHERE user_id = current_setting('app.current_user_id', true)::VARCHAR
)
);
CREATE POLICY "Users can insert metrics for their own databases" ON database_metrics
FOR INSERT WITH CHECK (
database_id IN (
SELECT id FROM database_services
WHERE user_id = current_setting('app.current_user_id', true)::VARCHAR
)
);
-- Grant permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON database_services TO authenticated_users;
GRANT SELECT, INSERT, UPDATE ON database_backups TO authenticated_users;
GRANT SELECT, UPDATE ON database_settings TO authenticated_users;
GRANT SELECT, INSERT ON database_metrics TO authenticated_users;
GRANT SELECT ON database_stats TO authenticated_users;
-- Create function to clean up old metrics (older than 30 days)
CREATE OR REPLACE FUNCTION cleanup_old_metrics()
RETURNS void AS $$
BEGIN
DELETE FROM database_metrics
WHERE recorded_at < NOW() - INTERVAL '30 days';
END;
$$ LANGUAGE plpgsql;
-- Create function to schedule next backup
CREATE OR REPLACE FUNCTION schedule_next_backup(database_id_param VARCHAR(255))
RETURNS void AS $$
BEGIN
UPDATE database_settings
SET next_backup_time = NOW() + INTERVAL '24 hours'
WHERE database_id = database_id_param;
END;
$$ LANGUAGE plpgsql;
-- Create function to update backup status and schedule next backup
CREATE OR REPLACE FUNCTION complete_backup(backup_id_param VARCHAR(255, success_param BOOLEAN))
RETURNS void AS $$
DECLARE
db_id VARCHAR(255);
BEGIN
-- Get database_id from backup
SELECT database_id INTO db_id FROM database_backups WHERE id = backup_id_param;
IF db_id IS NOT NULL THEN
-- Update backup completion time if successful
IF success_param THEN
UPDATE database_backups
SET status = 'completed', completed_at = NOW()
WHERE id = backup_id_param;
-- Update last_backup_time in settings
UPDATE database_settings
SET last_backup_time = NOW()
WHERE database_id = db_id;
-- Schedule next backup
PERFORM schedule_next_backup(db_id);
ELSE
UPDATE database_backups
SET status = 'failed'
WHERE id = backup_id_param;
END IF;
END IF;
END;
$$ LANGUAGE plpgsql;
+54
View File
@@ -0,0 +1,54 @@
-- Add preview environments table
CREATE TABLE IF NOT EXISTS preview_environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
branch_name VARCHAR(255) NOT NULL,
pr_number INTEGER, -- Optional: Pull request number if applicable
environment VARCHAR(255) NOT NULL UNIQUE, -- e.g., preview-feature-branch-20240101-120000
status VARCHAR(50) NOT NULL DEFAULT 'building' CHECK (status IN ('building', 'running', 'failed', 'stopped', 'expired')),
url TEXT, -- Preview environment URL
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_preview_environments_project_id ON preview_environments(project_id);
CREATE INDEX IF NOT EXISTS idx_preview_environments_service_id ON preview_environments(service_id);
CREATE INDEX IF NOT EXISTS idx_preview_environments_branch_name ON preview_environments(branch_name);
CREATE INDEX IF NOT EXISTS idx_preview_environments_status ON preview_environments(status);
CREATE INDEX IF NOT EXISTS idx_preview_environments_expires_at ON preview_environments(expires_at);
-- Add trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_preview_environments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER preview_environments_updated_at
BEFORE UPDATE ON preview_environments
FOR EACH ROW
EXECUTE FUNCTION update_preview_environments_updated_at();
-- Add unique constraint to prevent duplicate preview environments for same service and branch
CREATE UNIQUE INDEX IF NOT EXISTS idx_preview_environments_unique_active
ON preview_environments(service_id, branch_name)
WHERE status NOT IN ('expired', 'stopped');
-- Add comments for documentation
COMMENT ON TABLE preview_environments IS 'Preview environments for branch-based deployments';
COMMENT ON COLUMN preview_environments.id IS 'Unique identifier for the preview environment';
COMMENT ON COLUMN preview_environments.project_id IS 'Reference to the project';
COMMENT ON COLUMN preview_environments.service_id IS 'Reference to the service';
COMMENT ON COLUMN preview_environments.branch_name IS 'Git branch name';
COMMENT ON COLUMN preview_environments.pr_number IS 'Pull request number (optional)';
COMMENT ON COLUMN preview_environments.environment IS 'Environment name (e.g., preview-feature-branch-20240101-120000)';
COMMENT ON COLUMN preview_environments.status IS 'Current status of the preview environment';
COMMENT ON COLUMN preview_environments.url IS 'URL where the preview environment is accessible';
COMMENT ON COLUMN preview_environments.expires_at IS 'When the preview environment expires';
COMMENT ON COLUMN preview_environments.created_at IS 'When the preview environment was created';
COMMENT ON COLUMN preview_environments.updated_at IS 'When the preview environment was last updated';
+224
View File
@@ -0,0 +1,224 @@
-- Security Features Migration
-- This migration adds tables for security scanning, compliance, and audit logging
-- Security scans table
CREATE TABLE IF NOT EXISTS security_scans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
service_id UUID REFERENCES services(id) ON DELETE CASCADE,
scan_type VARCHAR(50) NOT NULL CHECK (scan_type IN ('dependency', 'configuration', 'comprehensive')),
status VARCHAR(50) NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed')),
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE,
summary JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Vulnerabilities table
CREATE TABLE IF NOT EXISTS vulnerabilities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL CHECK (type IN ('dependency', 'configuration', 'code')),
severity VARCHAR(20) NOT NULL CHECK (severity IN ('critical', 'high', 'medium', 'low')),
title VARCHAR(255) NOT NULL,
description TEXT,
service_id UUID REFERENCES services(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'ignored')),
found_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMP WITH TIME ZONE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Compliance frameworks table
CREATE TABLE IF NOT EXISTS compliance_frameworks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
version VARCHAR(20) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Compliance controls table
CREATE TABLE IF NOT EXISTS compliance_controls (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
framework_id UUID NOT NULL REFERENCES compliance_frameworks(id) ON DELETE CASCADE,
code VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100),
requirement TEXT,
test_procedure TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('compliant', 'non_compliant', 'not_applicable', 'pending')),
last_assessed TIMESTAMP WITH TIME ZONE,
evidence TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(framework_id, code)
);
-- Compliance reports table
CREATE TABLE IF NOT EXISTS compliance_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
framework_id UUID NOT NULL REFERENCES compliance_frameworks(id) ON DELETE CASCADE,
assessment_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
assessor VARCHAR(255),
overall_status VARCHAR(50) NOT NULL CHECK (overall_status IN ('compliant', 'partially_compliant', 'non_compliant', 'in_progress')),
score INTEGER NOT NULL DEFAULT 0 CHECK (score >= 0 AND score <= 100),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Compliance risks table
CREATE TABLE IF NOT EXISTS compliance_risks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_id UUID NOT NULL REFERENCES compliance_reports(id) ON DELETE CASCADE,
control_id UUID NOT NULL REFERENCES compliance_controls(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
impact VARCHAR(20) NOT NULL CHECK (impact IN ('high', 'medium', 'low')),
likelihood VARCHAR(20) NOT NULL CHECK (likelihood IN ('high', 'medium', 'low')),
mitigation TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Audit logs table
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource VARCHAR(100) NOT NULL,
resource_id UUID,
details JSONB DEFAULT '{}',
ip_address INET,
user_agent TEXT,
success BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Data retention policies table
CREATE TABLE IF NOT EXISTS data_retention_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
data_type VARCHAR(100) NOT NULL,
retention_period INTERVAL NOT NULL,
action VARCHAR(50) NOT NULL CHECK (action IN ('delete', 'anonymize', 'archive')),
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Anonymized data table
CREATE TABLE IF NOT EXISTS anonymized_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
original_id UUID NOT NULL,
anonymized_id VARCHAR(255) NOT NULL UNIQUE,
data_type VARCHAR(100) NOT NULL,
anonymized_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
retained_data TEXT, -- Encrypted non-sensitive data
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_security_scans_project_id ON security_scans(project_id);
CREATE INDEX IF NOT EXISTS idx_security_scans_status ON security_scans(status);
CREATE INDEX IF NOT EXISTS idx_security_scans_started_at ON security_scans(started_at);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_service_id ON vulnerabilities(service_id);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_severity ON vulnerabilities(severity);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_status ON vulnerabilities(status);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_found_at ON vulnerabilities(found_at);
CREATE INDEX IF NOT EXISTS idx_compliance_controls_framework_id ON compliance_controls(framework_id);
CREATE INDEX IF NOT EXISTS idx_compliance_controls_status ON compliance_controls(status);
CREATE INDEX IF NOT EXISTS idx_compliance_controls_last_assessed ON compliance_controls(last_assessed);
CREATE INDEX IF NOT EXISTS idx_compliance_reports_project_id ON compliance_reports(project_id);
CREATE INDEX IF NOT EXISTS idx_compliance_reports_framework_id ON compliance_reports(framework_id);
CREATE INDEX IF NOT EXISTS idx_compliance_reports_assessment_date ON compliance_reports(assessment_date);
CREATE INDEX IF NOT EXISTS idx_compliance_risks_report_id ON compliance_risks(report_id);
CREATE INDEX IF NOT EXISTS idx_compliance_risks_control_id ON compliance_risks(control_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON audit_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource);
CREATE INDEX IF NOT EXISTS idx_anonymized_data_original_id ON anonymized_data(original_id);
CREATE INDEX IF NOT EXISTS idx_anonymized_data_anonymized_id ON anonymized_data(anonymized_id);
CREATE INDEX IF NOT EXISTS idx_anonymized_data_data_type ON anonymized_data(data_type);
-- Insert default data retention policies
INSERT INTO data_retention_policies (name, data_type, retention_period, action, enabled) VALUES
('User Data Retention', 'user_data', INTERVAL '2 years', 'anonymize', true),
('Analytics Data Retention', 'analytics_data', INTERVAL '6 months', 'delete', true),
('Log Data Retention', 'log_data', INTERVAL '90 days', 'delete', true),
('Deleted User Data', 'deleted_user_data', INTERVAL '30 days', 'delete', true),
('Audit Log Retention', 'audit_log', INTERVAL '1 year', 'archive', true)
ON CONFLICT (name) DO NOTHING;
-- Insert default GDPR framework
INSERT INTO compliance_frameworks (id, name, description, version, enabled) VALUES
('gen_random_uuid()', 'GDPR', 'General Data Protection Regulation compliance framework', '1.0', true)
ON CONFLICT (name) DO UPDATE SET version = '1.0', enabled = true;
-- Update updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create triggers for updated_at
CREATE TRIGGER update_security_scans_updated_at BEFORE UPDATE ON security_scans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_vulnerabilities_updated_at BEFORE UPDATE ON vulnerabilities FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_compliance_frameworks_updated_at BEFORE UPDATE ON compliance_frameworks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_compliance_controls_updated_at BEFORE UPDATE ON compliance_controls FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_compliance_reports_updated_at BEFORE UPDATE ON compliance_reports FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_compliance_risks_updated_at BEFORE UPDATE ON compliance_risks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_data_retention_policies_updated_at BEFORE UPDATE ON data_retention_policies FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Row Level Security (RLS) for audit logs
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
-- Policy for audit logs - users can only see their own audit logs
CREATE POLICY audit_logs_user_policy ON audit_logs
FOR SELECT USING (user_id = current_setting('app.current_user_id')::UUID);
-- Policy for audit logs - no direct inserts (only through application)
CREATE POLICY audit_logs_insert_policy ON audit_logs
FOR INSERT WITH CHECK (false);
-- Policy for audit logs - no direct updates (audit logs are immutable)
CREATE POLICY audit_logs_update_policy ON audit_logs
FOR UPDATE WITH CHECK (false);
-- Row Level Security for anonymized data
ALTER TABLE anonymized_data ENABLE ROW LEVEL SECURITY;
-- Policy for anonymized data - restricted access
CREATE POLICY anonymized_data_policy ON anonymized_data
FOR SELECT USING (current_setting('app.is_admin', true)::BOOLEAN);
-- Add comments for documentation
COMMENT ON TABLE security_scans IS 'Security scan records for vulnerability assessment';
COMMENT ON TABLE vulnerabilities IS 'Security vulnerabilities found during scans';
COMMENT ON TABLE compliance_frameworks IS 'Compliance frameworks like GDPR, SOC2, etc.';
COMMENT ON TABLE compliance_controls IS 'Individual controls within compliance frameworks';
COMMENT ON TABLE compliance_reports IS 'Compliance assessment reports';
COMMENT ON TABLE compliance_risks IS 'Risk assessments for compliance gaps';
COMMENT ON TABLE audit_logs IS 'Security audit trail for all sensitive operations';
COMMENT ON TABLE data_retention_policies IS 'Data retention and deletion policies';
COMMENT ON TABLE anonymized_data IS 'Anonymized user data for privacy compliance';
@@ -0,0 +1,81 @@
-- Performance Optimization Migration
-- This migration adds additional indexes and optimizations for better query performance
-- Composite indexes for common query patterns
CREATE INDEX IF NOT EXISTS idx_projects_owner_updated ON projects(owner_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_services_project_env ON services(project_id, environment_id);
CREATE INDEX IF NOT EXISTS idx_services_status_project ON services(status, project_id);
CREATE INDEX IF NOT EXISTS idx_deployments_service_status_created ON deployments(service_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_deployments_service_created ON deployments(service_id, created_at DESC);
-- Partial indexes for better performance on filtered queries
CREATE INDEX IF NOT EXISTS idx_active_deployments ON deployments(service_id, created_at DESC)
WHERE status IN ('running', 'deploying');
CREATE INDEX IF NOT EXISTS idx_running_services ON services(project_id, updated_at DESC)
WHERE status = 'running';
-- Environment variables optimization
CREATE INDEX IF NOT EXISTS idx_env_vars_service_key ON environment_variables(service_id, key);
-- Service dependencies optimization
CREATE INDEX IF NOT EXISTS idx_service_deps_service ON service_dependencies(service_id);
CREATE INDEX IF NOT EXISTS idx_service_deps_depends_on ON service_dependencies(depends_on_service_id);
-- Project members optimization for role-based queries
CREATE INDEX IF NOT EXISTS idx_project_members_role ON project_members(project_id, role);
-- Add table statistics for better query planning
ANALYZE projects;
ANALYZE services;
ANALYZE deployments;
ANALYZE environment_variables;
ANALYZE service_dependencies;
ANALYZE project_members;
ANALYZE users;
ANALYZE environments;
-- Create a view for project statistics to optimize dashboard queries
CREATE OR REPLACE VIEW project_stats AS
SELECT
p.id,
p.name,
p.description,
p.owner_id,
p.created_at,
p.updated_at,
COUNT(DISTINCT s.id) as service_count,
COUNT(DISTINCT d.id) as deployment_count,
COUNT(DISTINCT CASE WHEN s.status = 'running' THEN s.id END) as running_services,
MAX(d.created_at) as last_deployment
FROM projects p
LEFT JOIN services s ON p.id = s.project_id
LEFT JOIN deployments d ON s.id = d.service_id
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at;
-- Create index on the view for better performance
CREATE INDEX IF NOT EXISTS idx_project_stats_id ON project_stats(id);
-- Function to get project statistics efficiently
CREATE OR REPLACE FUNCTION get_project_stats(project_uuid UUID)
RETURNS TABLE(
service_count BIGINT,
deployment_count BIGINT,
running_services BIGINT,
last_deployment TIMESTAMP WITH TIME ZONE
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(DISTINCT s.id),
COUNT(DISTINCT d.id),
COUNT(DISTINCT CASE WHEN s.status = 'running' THEN s.id END),
MAX(d.created_at)
FROM services s
LEFT JOIN deployments d ON s.id = d.service_id
WHERE s.project_id = project_uuid;
END;
$$ LANGUAGE plpgsql;
-- Add comment for documentation
COMMENT ON MIGRATION IS 'Performance optimization with additional indexes and statistics views';
+74
View File
@@ -0,0 +1,74 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
}
# API proxy (if needed for development)
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Static assets caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
+5035
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
{
"name": "containr",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:check": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.6",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-navigation-menu": "^1.2.6",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.1.6",
"@radix-ui/react-tabs": "^1.1.6",
"@radix-ui/react-toast": "^1.2.15",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0",
"@types/react-router-dom": "^5.3.3",
"@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.58.0",
"react-router-dom": "^7.13.0",
"reactflow": "^11.11.4",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tanstack-query": "^1.0.0",
"vaul": "^1.1.2",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
+263
View File
@@ -0,0 +1,263 @@
# Self-Hosted PaaS on Proxmox (Railway-like Platform)
A fully self-hosted, open-source Platform-as-a-Service designed to run on top of **Proxmox infrastructure**, providing a Railway-like developer experience: Git-based deployments, container orchestration, automatic scaling, load balancing, and observability — all under your control.
---
## 🎯 Goals
- Railway-like UX, fully self-hosted
- Built primarily on **Proxmox + Docker**
- Horizontal scaling across multiple VMs/LXCs
- Agent-based orchestration (no Kubernetes required)
- Git-driven deploys
- Production-grade networking, metrics, and reliability
---
## 🧱 Core Architecture
- **Control Plane**: Central API + scheduler
- **Node Agents**: Lightweight daemons running on each VM/LXC
- **Execution Layer**: Docker containers
- **Networking**: Traefik reverse proxy & load balancer
- **State Layer**: Externalized databases and caches
---
## 🧠 Technology Stack
### Backend / Control Plane
- **Go (primary language)**
- Scheduler
- Orchestration logic
- API server
- Scaling engine
- **Python (auxiliary tooling)**
- Metrics processing
- Background jobs
- Scripts where Go is impractical
- **Rust (performance-critical components)**
- Node agent (optional)
- Low-level system monitoring
- High-throughput data collectors
---
### Frontend / Dashboard
- **Bun**
- Package manager
- Runtime for tooling & scripts
- [Documentation](https://bun.sh/)
- **Vite**
- Fast frontend build system
- [Documentation](https://vitejs.dev/)
- **React 18**
- Modern UI framework with hooks and concurrent features
- [React Documentation](https://react.dev/)
- **TypeScript (TS / TSX)**
- Strict typing
- Shared contracts with backend
- [Documentation](https://www.typescriptlang.org/)
- **Tailwind CSS**
- Utility-first CSS framework
- Rapid UI development with design tokens
- [Documentation](https://tailwindcss.com/)
- **shadcn/ui**
- Beautiful, accessible component library built on Radix UI
- [Documentation](https://ui.shadcn.com/)
- **Lucide React**
- Beautiful icon library for React
- [Documentation](https://lucide.dev/)
**Documentation:**
- [React 18](https://react.dev/)
- [Vite](https://vitejs.dev/)
- [Tailwind CSS](https://tailwindcss.com/)
- [shadcn/ui](https://ui.shadcn.com/)
- [Lucide React](https://lucide.dev/)
- [React Hook Form](https://react-hook-form.com/)
- [TanStack Query](https://tanstack.com/query/latest)
---
### Infrastructure & Runtime
- **Docker**
- Container runtime
- Image builds & execution
- **Docker Compose / Custom Orchestrator**
- Service definitions
- Replica management
- **Traefik**
- Reverse proxy
- Automatic service discovery
- TLS & routing
- **Proxmox**
- VM & LXC orchestration
- Compute isolation
- Infrastructure backbone
---
### Data & State
- **PostgreSQL**
- Persistent platform state
- Users, apps, deployments, scaling rules
- **Redis**
- Caching
- Session storage
- Queues & pub/sub
---
## 🏗️ Build System & Deployment
### Builder Types (Railway-inspired)
**1️⃣ Nixpacks (Primary, Default Builder) ⭐**
Auto-detection build system based on Nix for reproducible builds.
- **How it works**: Scans repo for `go.mod`, `package.json`, `requirements.txt`, etc.
- **Supports**: Go, Node.js, Bun, Python, Ruby, PHP, Rust, Deno, Static sites
- **Advantages**: No Dockerfile needed, fast builds, reproducible, easy overrides
- **Default choice**: Used when no other builder is specified
**2️⃣ Dockerfile Builder**
Standard Docker build using your existing Dockerfile.
- **When to use**: Complex builds, custom system dependencies, non-standard runtimes
- **Control**: Full control over base image, OS packages, multi-stage builds
- **Tradeoff**: Slower than Nixpacks, more responsibility
**3️⃣ Prebuilt Image Deployment**
Deploy existing container images from Docker Hub, GHCR, or any OCI registry.
- **Use cases**: Workers, infrastructure tools, third-party services, optimized custom images
- **Process**: No build step - Railway pulls and runs the image directly
**4️⃣ Platform Templates**
Predefined service configurations that wrap around the above builders.
- **Function**: Predefine services, choose builder type (usually Nixpacks), preconfigure variables & databases
- **Underlying**: Still uses Nixpacks, Dockerfile, or prebuilt images
### Builder Selection Logic
```
Dockerfile present? → Docker builder
Else if image specified? → Image deploy
Else → Nixpacks (default)
```
### Build Output
Regardless of builder type, the result is always:
- Container image
- Versioned per deployment
- Immutable
- Rollback-able
### Stack Mapping
- **Go backend** → Nixpacks (perfect fit)
- **Vite/React/Bun** → Nixpacks
- **Rust helpers** → Nixpacks or Dockerfile
- **Custom system deps** → Dockerfile
- **Workers/cron** → Prebuilt image or Nixpacks
---
## 🔁 Scaling Model
- Horizontal scaling via container replicas
- Node-aware scheduling across Proxmox VMs/LXCs
- Metrics-based auto-scaling:
- CPU
- Memory
- Request rate
- Stateless application containers
- Externalized state (DB, cache, storage)
---
## 📦 Deployment Flow
1. Git push / webhook trigger
2. Backend schedules build
3. Docker image is built & stored
4. Scheduler selects optimal nodes
5. Node agents start containers
6. Traefik automatically routes traffic
7. Metrics collected → scaling decisions applied
---
## Analytics & Monitoring
### Umami Integration
- **Privacy-focused web analytics** for user insights
- **Self-hosted analytics dashboard** for application monitoring
- **Real-time visitor tracking** with geographic and device data
- **Performance metrics** including bounce rates and session duration
- **Traffic source analysis** and top page tracking
- **GDPR compliant** with no personal data collection
- **Integration points**:
- Dashboard analytics overview
- Project-specific metrics
- Service performance tracking
- User behavior analysis
### Built-in Monitoring
- **Real-time metrics** with custom visualizations
- **Service health monitoring** with status indicators
- **Resource usage tracking** (CPU, memory, storage, network)
- **Geographic service distribution** with interactive globe
- **Performance graphs** and data visualizations
- **Build logs integration** with step-by-step progress tracking
- **Deployment history** with rollback capabilities
---
## 🔐 Networking & Security
- TLS termination via Traefik
- Internal service networking
- Agent authentication via tokens / mTLS
- No direct Docker socket exposure to UI
---
## 🧩 Design Principles
- Infrastructure-first (Proxmox as the cloud)
- Stateless by default
- Explicit over magical
- Horizontal scalability
- Fail-fast & observable
- Open-source friendly
---
## 🚀 Long-Term Vision
- Multi-cluster support
- Cloud bursting (home → cloud nodes)
- Preview environments per Git branch
- Resource-based billing (optional)
- Plugin system for runtimes & buildpacks
---
## 📄 License
MIT / Apache-2.0 (TBD)
---
## 🛠️ Status
Early architecture & design phase.
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# Build CLI script for Containr
set -e
echo "🔨 Building Containr CLI..."
# Get version from git tag or use default
VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
# Build flags
LDFLAGS="-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${COMMIT}"
# Build for multiple platforms
PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64"
for platform in $PLATFORMS; do
GOOS=${platform%/*}
GOARCH=${platform#*/}
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT_NAME="${OUTPUT_NAME}.exe"
fi
echo "📦 Building for ${platform}..."
GOOS=$GOOS GOARCH=$GOARCH go build \
-ldflags "$LDFLAGS" \
-o "bin/${OUTPUT_NAME}" \
./cmd/cli
echo "✅ Built bin/${OUTPUT_NAME}"
done
echo "🎉 CLI build complete!"
echo ""
echo "Available binaries:"
ls -la bin/containr-*
# Create a symlink for the current platform
CURRENT_OS=$(uname -s | tr '[:upper:]' '[:lower:]')
CURRENT_ARCH=$(uname -m | sed 's/x86_64/amd64/' | sed 's/arm64/arm64/')
if [ -f "bin/containr-${CURRENT_OS}-${CURRENT_ARCH}" ]; then
ln -sf "containr-${CURRENT_OS}-${CURRENT_ARCH}" bin/containr
echo "🔗 Created symlink: bin/containr -> containr-${CURRENT_OS}-${CURRENT_ARCH}"
fi
echo ""
echo "To install CLI:"
echo " sudo cp bin/containr /usr/local/bin/"
echo " # or"
echo " export PATH=\$PWD/bin:\$PATH"
Executable
BIN
View File
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+58
View File
@@ -0,0 +1,58 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, useAuth } from './hooks/useAuth';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
import Analytics from './pages/Analytics';
import GitIntegration from './pages/GitIntegration';
import Infrastructure from './pages/Infrastructure';
import NodeAgents from './pages/NodeAgents';
import DatabaseServices from './pages/DatabaseServices';
import Settings from './pages/Settings';
import Login from './pages/Login';
function AppContent() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<Routes>
<Route path="/login" element={!isAuthenticated ? <Login /> : <Navigate to="/" />} />
<Route path="/" element={isAuthenticated ? <Layout /> : <Navigate to="/login" />}>
<Route index element={<Dashboard />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:projectId" element={<ProjectDetail />} />
<Route path="analytics" element={<Analytics />} />
<Route path="git" element={<GitIntegration />} />
<Route path="infrastructure" element={<Infrastructure />} />
<Route path="agents" element={<NodeAgents />} />
<Route path="databases" element={<DatabaseServices />} />
<Route path="settings" element={<Settings />} />
{/* Add more routes here as we create them */}
</Route>
</Routes>
);
}
function App() {
return (
<ThemeProvider>
<AuthProvider>
<Router>
<AppContent />
</Router>
</AuthProvider>
</ThemeProvider>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+267
View File
@@ -0,0 +1,267 @@
import React, { useCallback, useRef, useEffect, useState } from 'react';
import { useTheme } from '../contexts/ThemeContext';
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
ReactFlowProvider,
} from '@xyflow/react';
import type { Node as ReactFlowNode } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useCanvasStore } from '../store/canvasStore';
import ServiceNodeComponent from './nodes/ServiceNode';
import EmptyCanvasNode from './nodes/EmptyCanvasNode';
import AnimatedEdge from './edges/AnimatedEdge';
import CanvasContextMenu from './CanvasContextMenu';
const nodeTypes = {
service: ServiceNodeComponent,
empty: EmptyCanvasNode,
};
const edgeTypes = {
animated: AnimatedEdge,
};
function CanvasContent() {
const containerRef = useRef<HTMLDivElement>(null);
const [isOverUI, setIsOverUI] = useState(false);
const { resolvedTheme } = useTheme();
const {
nodes,
edges,
setNodes,
setEdges,
onConnect,
setSelectedNode,
} = useCanvasStore();
const [internalNodes, setInternalNodes, onNodesChange] = useNodesState(nodes);
const [internalEdges, setInternalEdges, onEdgesChange] = useEdgesState(edges);
// Sync internal state with store
React.useEffect(() => {
setInternalNodes(nodes);
}, [nodes, setInternalNodes]);
React.useEffect(() => {
setInternalEdges(edges);
}, [edges, setInternalEdges]);
// Global hover detection for UI elements
useEffect(() => {
const handleMouseEnter = (e: MouseEvent) => {
const target = e.target as Element;
// Check if hovering over any UI element that should disable canvas interactions
if (target && target.closest && (
target.closest('[data-ui-element="true"]') ||
target.closest('.react-flow__node') ||
target.closest('[role="menu"]') ||
target.closest('[role="dialog"]') ||
target.closest('[data-cmdk-list]'))) {
setIsOverUI(true);
}
};
const handleMouseLeave = (e: MouseEvent) => {
const target = e.target as Element;
// Check if leaving UI elements
if (target && target.closest && (
target.closest('[data-ui-element="true"]') ||
target.closest('.react-flow__node') ||
target.closest('[role="menu"]') ||
target.closest('[role="dialog"]') ||
target.closest('[data-cmdk-list]'))) {
setIsOverUI(false);
}
};
const handleGlobalMouseMove = (e: MouseEvent) => {
const target = e.target as Element;
// Check if currently over any UI element
if (target && target.closest) {
const overUI = target.closest('[data-ui-element="true"]') ||
target.closest('.react-flow__node') ||
target.closest('[role="menu"]') ||
target.closest('[role="dialog"]') ||
target.closest('[data-cmdk-list]');
setIsOverUI(!!overUI);
// Debug logging
if (overUI) {
console.log('Over UI element:', overUI);
}
}
};
// Prevent wheel events at document level when over UI
const handleDocumentWheel = (e: WheelEvent) => {
if (isOverUI) {
// When hovering over UI elements, prevent canvas zoom/scroll
// regardless of what the wheel event target is
console.log('Preventing canvas wheel event while over UI');
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return false;
}
};
document.addEventListener('mouseenter', handleMouseEnter, true);
document.addEventListener('mouseleave', handleMouseLeave, true);
document.addEventListener('mousemove', handleGlobalMouseMove, true);
document.addEventListener('wheel', handleDocumentWheel, { passive: false, capture: true });
return () => {
document.removeEventListener('mouseenter', handleMouseEnter, true);
document.removeEventListener('mouseleave', handleMouseLeave, true);
document.removeEventListener('mousemove', handleGlobalMouseMove, true);
document.removeEventListener('wheel', handleDocumentWheel, { capture: true } as any);
};
}, [isOverUI]);
// Ensure container has proper dimensions
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current && containerRef.current.parentElement) {
const parent = containerRef.current.parentElement;
const rect = parent.getBoundingClientRect();
// Only set dimensions if they're valid and different from current
if (rect.width > 0 && rect.height > 0) {
const currentWidth = containerRef.current.style.width;
const currentHeight = containerRef.current.style.height;
const newWidth = `${rect.width}px`;
const newHeight = `${rect.height}px`;
if (currentWidth !== newWidth || currentHeight !== newHeight) {
containerRef.current.style.width = newWidth;
containerRef.current.style.height = newHeight;
}
} else {
// Fallback dimensions if parent has no size
containerRef.current.style.width = '100vw';
containerRef.current.style.height = 'calc(100vh - 56px)';
}
}
};
// Set dimensions immediately
updateDimensions();
// Also try after a short delay
const timeout1 = setTimeout(updateDimensions, 10);
const timeout2 = setTimeout(updateDimensions, 100);
// Use ResizeObserver for reliable dimension tracking
let resizeObserver: ResizeObserver | null = null;
if (containerRef.current?.parentElement && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(updateDimensions);
resizeObserver.observe(containerRef.current.parentElement);
}
return () => {
clearTimeout(timeout1);
clearTimeout(timeout2);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, []);
const handleNodesChange = useCallback((changes: any) => {
onNodesChange(changes);
setNodes(internalNodes);
}, [onNodesChange, setNodes, internalNodes]);
const handleEdgesChange = useCallback((changes: any) => {
onEdgesChange(changes);
setEdges(internalEdges);
}, [onEdgesChange, setEdges, internalEdges]);
const onNodeDragStop = useCallback(
(_event: React.MouseEvent, node: ReactFlowNode) => {
console.log('Node moved:', node.id, node.position);
setNodes(internalNodes);
},
[setNodes, internalNodes]
);
const onNodeClick = useCallback((_event: React.MouseEvent, node: ReactFlowNode) => {
setSelectedNode(node.id);
}, [setSelectedNode]);
const handleCanvasWheel = useCallback((e: React.WheelEvent) => {
console.log('Canvas wheel event, isOverUI:', isOverUI);
if (isOverUI) {
console.log('Preventing canvas zoom/scroll');
e.stopPropagation();
e.preventDefault();
}
}, [isOverUI]);
return (
<div className="flex-1 min-h-0 relative scrollbar-hide">
<div className="w-full h-full bg-background rounded-t-xl border border-[rgb(var(--border))] border-b-0 overflow-hidden">
<CanvasContextMenu>
<div
ref={containerRef}
className="w-full h-full"
style={{ width: '100vw', height: 'calc(100vh - 56px)' }}
>
<ReactFlow
nodes={internalNodes}
edges={internalEdges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeDragStop={onNodeDragStop}
onNodeClick={onNodeClick}
onWheel={handleCanvasWheel}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.2, minZoom: 0.5, maxZoom: 1 }}
defaultViewport={{ x: 0, y: 0, zoom: 0.9 }}
attributionPosition="bottom-left"
className="w-full h-full"
style={{ width: '100%', height: '100%' }}
>
<Background
color={resolvedTheme === 'dark' ? '#545260' : '#878593'}
gap={16}
/>
<Controls
className="bg-background border border-[rgb(var(--border))]"
/>
<MiniMap
nodeColor={(node) => {
switch (node.data?.type) {
case 'github': return '#52297A';
case 'database': return '#181622';
case 'docker': return '#211F2D';
case 'function': return '#545260';
case 'bucket': return '#878593';
default: return '#33323E';
}
}}
className="bg-card border border-[rgb(var(--border))]"
/>
</ReactFlow>
</div>
</CanvasContextMenu>
</div>
</div>
);
}
export default function Canvas() {
return (
<ReactFlowProvider>
<CanvasContent />
</ReactFlowProvider>
);
}
+129
View File
@@ -0,0 +1,129 @@
import React from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@radix-ui/react-context-menu';
import {
Github,
Database,
Container,
Code,
HardDrive
} from 'lucide-react';
import { useCanvasStore } from '../store/canvasStore';
interface CanvasContextMenuProps {
children: React.ReactNode;
}
interface ServiceOption {
id: string;
name: string;
type: 'github' | 'database' | 'docker' | 'function' | 'bucket';
icon: React.ComponentType<{ className?: string }>;
}
const serviceOptions: ServiceOption[] = [
{
id: 'github',
name: 'GitHub Repository',
type: 'github',
icon: Github,
},
{
id: 'postgres',
name: 'PostgreSQL',
type: 'database',
icon: Database,
},
{
id: 'redis',
name: 'Redis',
type: 'database',
icon: Database,
},
{
id: 'docker',
name: 'Docker Image',
type: 'docker',
icon: Container,
},
{
id: 'function',
name: 'Serverless Function',
type: 'function',
icon: Code,
},
{
id: 'bucket',
name: 'Storage Bucket',
type: 'bucket',
icon: HardDrive,
},
];
export default function CanvasContextMenu({ children }: CanvasContextMenuProps) {
const { addNode } = useCanvasStore();
const handleSelect = (option: ServiceOption, event: React.MouseEvent) => {
// Get click position relative to the canvas
const reactFlowElement = (event.target as HTMLElement).closest('.react-flow');
if (!reactFlowElement) return;
const rect = reactFlowElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Generate a unique ID for the new node
const nodeId = `${option.type}-${Date.now()}`;
// Create the new node at click position
const newNode = {
id: nodeId,
type: option.type,
position: { x, y },
data: {
label: option.name,
type: option.type,
status: 'stopped' as const,
...(option.type === 'github' && { repo: 'user/repo' }),
},
};
// Add the node to the store
addNode(newNode);
};
return (
<ContextMenu>
<ContextMenuTrigger className="w-full h-full">
{children}
</ContextMenuTrigger>
<ContextMenuContent
className="z-50 min-w-[200px] bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
data-ui-element="true"
>
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">Add Service</div>
</div>
<div className="py-1">
{serviceOptions.map((option) => (
<ContextMenuItem
key={option.id}
className="flex items-center px-3 py-2 text-sm cursor-pointer transition-all duration-150 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-gray-700 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none focus:bg-blue-50 dark:focus:bg-gray-700 group"
onSelect={(event) => handleSelect(option, event as any)}
>
<div className="w-5 h-5 rounded bg-gray-100 dark:bg-gray-600 flex items-center justify-center mr-2 group-hover:bg-blue-100 dark:group-hover:bg-blue-900 transition-colors flex-shrink-0">
<option.icon className="w-2.5 h-2.5 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
</div>
<div className="flex-1 font-medium text-sm truncate">{option.name}</div>
</ContextMenuItem>
))}
</div>
</ContextMenuContent>
</ContextMenu>
);
}
+208
View File
@@ -0,0 +1,208 @@
import React, { useEffect, useState } from 'react';
import { Command } from 'cmdk';
import {
Github,
Database,
Container,
Code,
HardDrive,
Plus,
Search
} from 'lucide-react';
import { cn } from '../lib/utils';
import { useCanvasStore } from '../store/canvasStore';
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
}
interface ServiceOption {
id: string;
name: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
type: 'github' | 'database' | 'docker' | 'function' | 'bucket';
}
const serviceOptions: ServiceOption[] = [
{
id: 'github',
name: 'GitHub Repository',
description: 'Deploy from a GitHub repository',
icon: Github,
type: 'github',
},
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Add a PostgreSQL database',
icon: Database,
type: 'database',
},
{
id: 'redis',
name: 'Redis',
description: 'Add a Redis cache',
icon: Database,
type: 'database',
},
{
id: 'docker',
name: 'Docker Image',
description: 'Deploy a Docker image',
icon: Container,
type: 'docker',
},
{
id: 'function',
name: 'Serverless Function',
description: 'Add a serverless function',
icon: Code,
type: 'function',
},
{
id: 'bucket',
name: 'Storage Bucket',
description: 'Add object storage',
icon: HardDrive,
type: 'bucket',
},
];
export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
const [search, setSearch] = useState('');
const { addNode } = useCanvasStore();
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onClose();
}
if (e.key === 'Escape') {
onClose();
}
};
if (open) {
document.addEventListener('keydown', down);
}
return () => {
document.removeEventListener('keydown', down);
};
}, [open, onClose]);
const handleSelect = (option: ServiceOption) => {
// Generate a unique ID for the new node
const nodeId = `${option.type}-${Date.now()}`;
// Calculate a random position for the new node
const position = {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
};
// Create the new node
const newNode = {
id: nodeId,
type: option.type,
position,
data: {
label: option.name,
type: option.type,
status: 'stopped' as const,
...(option.type === 'github' && { repo: 'user/repo' }),
},
};
// Add the node to the store
addNode(newNode);
console.log('Added service:', option);
onClose();
};
if (!open) return null;
return (
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4"
data-ui-element="true"
>
<div className="w-full max-w-md">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<Command className="rounded-2xl">
<div className="flex items-center border-b border-gray-200 dark:border-gray-700 px-4 py-3">
<Search className="w-5 h-5 text-gray-400 mr-3" />
<Command.Input
placeholder="What would you like to create?"
value={search}
onValueChange={setSearch}
className="flex-1 py-2 bg-transparent outline-none text-gray-900 dark:text-gray-100 placeholder-gray-500 text-sm"
/>
<kbd className="ml-3 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">
ESC
</kbd>
</div>
<Command.List className="max-h-[350px] overflow-y-auto p-2 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<Command.Empty className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No services found.
</Command.Empty>
{serviceOptions.map((option) => (
<Command.Item
key={option.id}
onSelect={() => handleSelect(option)}
className={cn(
'flex items-center gap-3 px-3 py-3 rounded-xl text-sm cursor-pointer transition-all duration-150',
'hover:bg-blue-50 dark:hover:bg-gray-700',
'focus:bg-blue-50 dark:focus:bg-gray-700',
'text-gray-700 dark:text-gray-300',
'hover:text-blue-600 dark:hover:text-blue-400'
)}
>
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-600 flex items-center justify-center group-hover:bg-blue-100 dark:group-hover:bg-blue-900 transition-colors flex-shrink-0">
<option.icon className="w-4 h-4 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium">{option.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{option.description}
</div>
</div>
<Plus className="w-4 h-4 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
</Command.Item>
))}
</Command.List>
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-900">
<div className="flex items-center justify-center text-xs text-gray-500 dark:text-gray-400 gap-6">
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs">
</kbd>
<span>Navigate</span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs">
</kbd>
<span>Select</span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs">
ESC
</kbd>
<span>Close</span>
</div>
</div>
</div>
</Command>
</div>
</div>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { Outlet, Link, useLocation } from 'react-router-dom';
import { Plus, Activity, Settings, GitBranch, Database, Menu, X, Server, Folder, Github, Cpu, BarChart3 } from 'lucide-react';
import { Button } from './ui/button';
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { ThemeToggle } from './ui/theme-toggle';
import CommandPalette from './CommandPalette';
import { useCanvasStore } from '../store/canvasStore';
const navigation = [
{ name: 'Dashboard', href: '/', icon: Activity },
{ name: 'Projects', href: '/projects', icon: Folder },
{ name: 'Analytics', href: '/analytics', icon: BarChart3 },
{ name: 'Git Integration', href: '/git', icon: Github },
{ name: 'Infrastructure', href: '/infrastructure', icon: Server },
{ name: 'Node Agents', href: '/agents', icon: Cpu },
{ name: 'Deployments', href: '/deployments', icon: GitBranch },
{ name: 'Databases', href: '/databases', icon: Database },
{ name: 'Settings', href: '/settings', icon: Settings },
];
export default function Layout() {
const { isCommandPaletteOpen, setCommandPaletteOpen, sidebarOpen, setSidebarOpen } = useCanvasStore();
const location = useLocation();
const getPageTitle = () => {
const currentNav = navigation.find(item => item.href === location.pathname);
return currentNav ? currentNav.name : 'Dashboard';
};
return (
<div className="h-screen w-full flex bg-background overflow-hidden">
{/* Desktop Sidebar */}
<div
className={`
${sidebarOpen ? 'w-64' : 'w-0'}
transition-all duration-300 ease-in-out
bg-card border-r border-[rgb(var(--border))]
flex flex-col overflow-hidden flex-shrink-0
hidden lg:flex
`}
>
{/* Header */}
<div className="p-6 border-b border-[rgb(var(--border))] flex-shrink-0">
<h1 className="text-2xl font-bold text-foreground">
Containr
</h1>
<p className="text-sm text-muted-foreground">
Self-hosted PaaS
</p>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2 overflow-y-auto scrollbar-hide">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Button
key={item.name}
variant={isActive ? "default" : "ghost"}
className="w-full justify-start gap-3 h-10"
asChild
>
<Link to={item.href}>
<item.icon className="w-4 h-4" />
{item.name}
</Link>
</Button>
);
})}
</nav>
{/* Add Service Button */}
<div className="p-4 border-t border-[rgb(var(--border))] flex-shrink-0">
<Button
onClick={() => setCommandPaletteOpen(true)}
className="w-full gap-2"
size="default"
>
<Plus className="w-4 h-4" />
Add Service
</Button>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative min-w-0">
{/* Top Bar */}
<div className="h-16 bg-card border-b border-[rgb(var(--border))] flex items-center px-4 gap-4 flex-shrink-0">
{/* Mobile Menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-0">
<div className="p-6 border-b border-[rgb(var(--border))]">
<h1 className="text-2xl font-bold text-foreground">
Containr
</h1>
<p className="text-sm text-muted-foreground">
Self-hosted PaaS
</p>
</div>
<nav className="flex-1 p-4 space-y-2">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Button
key={item.name}
variant={isActive ? "default" : "ghost"}
className="w-full justify-start gap-3 h-10"
asChild
>
<Link to={item.href}>
<item.icon className="w-4 h-4" />
{item.name}
</Link>
</Button>
);
})}
</nav>
<div className="p-4 border-t border-[var(--border)]">
<Button
onClick={() => setCommandPaletteOpen(true)}
className="w-full gap-2"
size="default"
>
<Plus className="w-4 h-4" />
Add Service
</Button>
</div>
</SheetContent>
</Sheet>
{/* Desktop Sidebar Toggle */}
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="hidden lg:flex"
>
{sidebarOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
<span className="sr-only">Toggle sidebar</span>
</Button>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-foreground truncate">
{getPageTitle()}
</h2>
</div>
{/* Theme Toggle */}
<ThemeToggle />
{/* User Avatar */}
<Avatar className="h-8 w-8">
<AvatarImage src="/avatars/01.png" alt="User" />
<AvatarFallback>U</AvatarFallback>
</Avatar>
</div>
{/* Page Content */}
<div className="flex-1 relative min-h-0 overflow-auto">
<Outlet />
</div>
{/* Mobile Floating Action Button */}
<Button
onClick={() => setCommandPaletteOpen(true)}
size="icon"
className="lg:hidden fixed right-4 bottom-4 w-14 h-14 rounded-full shadow-lg z-50"
>
<Plus className="w-6 h-6" />
<span className="sr-only">Add Service</span>
</Button>
</div>
{/* Command Palette */}
<CommandPalette
open={isCommandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,148 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useQuery } from '@tanstack/react-query';
import { analyticsApi } from '@/lib/api';
import {
TrendingUp,
Users,
Eye,
MousePointer,
Clock,
Activity,
ArrowUp,
ArrowDown
} from 'lucide-react';
interface AnalyticsOverviewProps {
timeRange: string;
}
export function AnalyticsOverview({ timeRange }: AnalyticsOverviewProps) {
const { data: overviewData, isLoading, error } = useQuery({
queryKey: ['analytics-overview', timeRange],
queryFn: () => analyticsApi.getOverview(timeRange),
refetchInterval: 30000, // Refresh every 30 seconds
});
if (isLoading) {
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i} className="animate-pulse">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="h-4 bg-gray-200 rounded w-20"></div>
<div className="h-4 w-4 bg-gray-200 rounded"></div>
</CardHeader>
<CardContent>
<div className="h-8 bg-gray-200 rounded w-16 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-24"></div>
</CardContent>
</Card>
))}
</div>
);
}
if (error) {
return (
<Card>
<CardContent className="p-6">
<div className="text-center text-red-600">
Failed to load analytics data. Please try again later.
</div>
</CardContent>
</Card>
);
}
const metrics = [
{
title: 'Unique Visitors',
value: overviewData?.visitors.current.toLocaleString() || '0',
change: overviewData?.visitors.change || 0,
trend: overviewData?.visitors.trend || 'up',
icon: Users,
format: 'number'
},
{
title: 'Page Views',
value: overviewData?.pageviews.current.toLocaleString() || '0',
change: overviewData?.pageviews.change || 0,
trend: overviewData?.pageviews.trend || 'up',
icon: Eye,
format: 'number'
},
{
title: 'Sessions',
value: overviewData?.sessions.current.toLocaleString() || '0',
change: overviewData?.sessions.change || 0,
trend: overviewData?.sessions.trend || 'up',
icon: MousePointer,
format: 'number'
},
{
title: 'Bounce Rate',
value: `${overviewData?.bounceRate.current || 0}%`,
change: overviewData?.bounceRate.change || 0,
trend: overviewData?.bounceRate.trend || 'up',
icon: Activity,
format: 'percentage'
},
{
title: 'Session Duration',
value: overviewData ?
`${Math.floor(overviewData.sessionDuration.current / 60)}m ${overviewData.sessionDuration.current % 60}s` :
'0m 0s',
change: overviewData?.sessionDuration.change || 0,
trend: overviewData?.sessionDuration.trend || 'up',
icon: Clock,
format: 'duration'
},
{
title: 'Conversion Rate',
value: `${overviewData?.conversionRate.current || 0}%`,
change: overviewData?.conversionRate.change || 0,
trend: overviewData?.conversionRate.trend || 'up',
icon: TrendingUp,
format: 'percentage'
}
];
const getTrendIcon = (trend: 'up' | 'down') => {
return trend === 'up' ? (
<ArrowUp className="w-4 h-4 text-green-500" />
) : (
<ArrowDown className="w-4 h-4 text-red-500" />
);
};
const getTrendColor = (trend: 'up' | 'down') => {
return trend === 'up' ? 'text-green-600' : 'text-red-600';
};
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{metrics.map((metric) => (
<Card key={metric.title} className="relative overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{metric.title}
</CardTitle>
<metric.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metric.value}</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metric.trend)}
<span className={getTrendColor(metric.trend)}>
{Math.abs(metric.change)}%
</span>
<span className="text-muted-foreground">
from last period
</span>
</div>
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,324 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
FileText,
Eye,
MousePointer,
Clock,
TrendingUp,
ArrowUp,
ArrowDown,
BookOpen,
Link
} from 'lucide-react';
interface ContentAnalyticsProps {
timeRange: string;
}
export function ContentAnalytics({ timeRange }: ContentAnalyticsProps) {
// Mock data - in real implementation, this would come from Umami API
const contentData = {
topPages: [
{
url: '/dashboard',
title: 'Dashboard',
pageviews: 12456,
uniquePageviews: 8234,
avgTimeOnPage: 245,
bounceRate: 32.1,
exitRate: 28.4,
trend: 'up' as const,
change: 12.5
},
{
url: '/projects',
title: 'Projects',
pageviews: 9876,
uniquePageviews: 6789,
avgTimeOnPage: 189,
bounceRate: 28.7,
exitRate: 31.2,
trend: 'up' as const,
change: 8.3
},
{
url: '/analytics',
title: 'Analytics',
pageviews: 7654,
uniquePageviews: 5432,
avgTimeOnPage: 312,
bounceRate: 24.1,
exitRate: 26.8,
trend: 'down' as const,
change: -3.2
},
{
url: '/docs',
title: 'Documentation',
pageviews: 5432,
uniquePageviews: 4321,
avgTimeOnPage: 428,
bounceRate: 18.9,
exitRate: 22.3,
trend: 'up' as const,
change: 15.7
},
{
url: '/settings',
title: 'Settings',
pageviews: 3210,
uniquePageviews: 2876,
avgTimeOnPage: 156,
bounceRate: 41.2,
exitRate: 38.7,
trend: 'up' as const,
change: 6.8
}
],
landingPages: [
{
url: '/',
title: 'Home',
entrances: 8765,
bounceRate: 34.2,
conversions: 234,
conversionRate: 2.7
},
{
url: '/blog/getting-started',
title: 'Getting Started',
entrances: 5432,
bounceRate: 28.9,
conversions: 189,
conversionRate: 3.5
},
{
url: '/features',
title: 'Features',
entrances: 3210,
bounceRate: 31.5,
conversions: 98,
conversionRate: 3.1
}
],
exitPages: [
{
url: '/thank-you',
title: 'Thank You',
exits: 2345,
exitRate: 78.9,
totalPageviews: 2976
},
{
url: '/pricing',
title: 'Pricing',
exits: 1876,
exitRate: 45.2,
totalPageviews: 4156
},
{
url: '/contact',
title: 'Contact',
exits: 1543,
exitRate: 38.7,
totalPageviews: 3987
}
],
events: [
{
name: 'button_click',
count: 12456,
uniqueUsers: 8234,
category: 'engagement'
},
{
name: 'form_submit',
count: 3456,
uniqueUsers: 2876,
category: 'conversion'
},
{
name: 'video_play',
count: 2345,
uniqueUsers: 1987,
category: 'engagement'
},
{
name: 'download',
count: 1234,
uniqueUsers: 1098,
category: 'conversion'
}
]
};
const getTrendIcon = (trend: 'up' | 'down') => {
return trend === 'up' ? (
<ArrowUp className="w-3 h-3 text-green-500" />
) : (
<ArrowDown className="w-3 h-3 text-red-500" />
);
};
const formatDuration = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
return (
<div className="space-y-6">
{/* Top Pages */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Top Pages
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{contentData.topPages.map((page) => (
<div key={page.url} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Link className="w-4 h-4 text-muted-foreground" />
<div>
<h4 className="font-medium text-sm">{page.title}</h4>
<p className="text-xs text-muted-foreground">{page.url}</p>
</div>
</div>
<div className="flex items-center gap-1">
{getTrendIcon(page.trend)}
<span className={`text-xs ${
page.trend === 'up' ? 'text-green-600' : 'text-red-600'
}`}>
{Math.abs(page.change)}%
</span>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
<div>
<div className="text-muted-foreground">Pageviews</div>
<div className="font-semibold">{page.pageviews.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">Unique</div>
<div className="font-semibold">{page.uniquePageviews.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">Avg. Time</div>
<div className="font-semibold">{formatDuration(page.avgTimeOnPage)}</div>
</div>
<div>
<div className="text-muted-foreground">Bounce Rate</div>
<div className="font-semibold">{page.bounceRate}%</div>
</div>
</div>
</div>
))}
</CardContent>
</Card>
{/* Landing Pages */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="w-5 h-5" />
Landing Pages
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{contentData.landingPages.map((page) => (
<div key={page.url} className="space-y-2">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-sm">{page.title}</h4>
<p className="text-xs text-muted-foreground">{page.url}</p>
</div>
<div className="text-right">
<Badge variant={page.conversionRate > 3 ? "default" : "secondary"}>
{page.conversionRate}% conversion
</Badge>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-xs">
<div>
<div className="text-muted-foreground">Entrances</div>
<div className="font-semibold">{page.entrances.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">Bounce Rate</div>
<div className="font-semibold">{page.bounceRate}%</div>
</div>
<div>
<div className="text-muted-foreground">Conversions</div>
<div className="font-semibold">{page.conversions}</div>
</div>
</div>
<Progress value={page.bounceRate} className="h-2" />
</div>
))}
</CardContent>
</Card>
{/* Exit Pages */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="w-5 h-5" />
Exit Pages
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{contentData.exitPages.map((page) => (
<div key={page.url} className="flex items-center justify-between">
<div>
<h4 className="font-medium text-sm">{page.title}</h4>
<p className="text-xs text-muted-foreground">{page.url}</p>
</div>
<div className="text-right">
<div className="font-semibold">{page.exitRate}%</div>
<div className="text-xs text-muted-foreground">
{page.exits.toLocaleString()} exits
</div>
</div>
</div>
))}
</CardContent>
</Card>
{/* Custom Events */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MousePointer className="w-5 h-5" />
Custom Events
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{contentData.events.map((event) => (
<div key={event.name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-purple-500" />
<div>
<span className="text-sm font-medium">{event.name}</span>
<Badge variant="outline" className="ml-2 text-xs">
{event.category}
</Badge>
</div>
</div>
<div className="text-right">
<div className="font-semibold">{event.count.toLocaleString()}</div>
<div className="text-xs text-muted-foreground">
{event.uniqueUsers.toLocaleString()} users
</div>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,514 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Cpu,
HardDrive,
Wifi,
Clock,
TrendingUp,
TrendingDown,
AlertTriangle,
CheckCircle,
Activity,
Zap,
Server,
MemoryStick,
Network,
Timer,
Users
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { analyticsApi } from '@/lib/api';
interface CustomMetricsDashboardProps {
projectId?: string;
timeRange: string;
}
export function CustomMetricsDashboard({ projectId, timeRange }: CustomMetricsDashboardProps) {
const [selectedMetric, setSelectedMetric] = useState('performance');
const [autoRefresh, setAutoRefresh] = useState(true);
// Mock custom metrics data - in real implementation, this would come from your monitoring system
const { data: metricsData, isLoading } = useQuery({
queryKey: ['custom-metrics', projectId, timeRange],
queryFn: async () => {
// This would integrate with your monitoring system (Prometheus, Grafana, etc.)
return {
performance: {
responseTime: {
current: 245,
average: 312,
p95: 567,
p99: 892,
trend: 'down' as const,
change: -12.3
},
throughput: {
current: 1250,
average: 1180,
peak: 2340,
trend: 'up' as const,
change: 8.7
},
errorRate: {
current: 0.2,
average: 0.3,
trend: 'down' as const,
change: -33.3
},
availability: {
current: 99.95,
average: 99.91,
trend: 'up' as const,
change: 0.04
}
},
infrastructure: {
cpu: {
current: 45.2,
average: 52.8,
peak: 78.9,
trend: 'down' as const,
change: -14.5
},
memory: {
current: 62.7,
average: 68.4,
peak: 85.2,
trend: 'down' as const,
change: -8.3
},
disk: {
current: 34.8,
average: 38.1,
peak: 45.6,
trend: 'stable' as const,
change: -8.7
},
network: {
inbound: 125.6,
outbound: 89.3,
trend: 'up' as const,
change: 15.2
}
},
business: {
conversions: {
current: 156,
goal: 200,
completion: 78,
trend: 'up' as const,
change: 12.5
},
revenue: {
current: 45678,
goal: 50000,
completion: 91.4,
trend: 'up' as const,
change: 8.9
},
userSatisfaction: {
current: 4.6,
goal: 4.8,
completion: 95.8,
trend: 'stable' as const,
change: 0
},
activeUsers: {
current: 12845,
goal: 15000,
completion: 85.6,
trend: 'up' as const,
change: 6.2
}
}
};
},
refetchInterval: autoRefresh ? 30000 : false,
});
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'up':
return <TrendingUp className="w-4 h-4 text-green-500" />;
case 'down':
return <TrendingDown className="w-4 h-4 text-red-500" />;
default:
return <Activity className="w-4 h-4 text-gray-500" />;
}
};
const getStatusColor = (value: number, thresholds: { good: number; warning: number }) => {
if (value <= thresholds.good) return 'text-green-600';
if (value <= thresholds.warning) return 'text-yellow-600';
return 'text-red-600';
};
const getStatusBadge = (value: number, thresholds: { good: number; warning: number }) => {
if (value <= thresholds.good) return <Badge variant="default" className="bg-green-500">Good</Badge>;
if (value <= thresholds.warning) return <Badge variant="secondary" className="bg-yellow-500">Warning</Badge>;
return <Badge variant="destructive">Critical</Badge>;
};
if (isLoading) {
return (
<div className="space-y-6">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="animate-pulse">
<CardHeader className="pb-2">
<div className="h-4 bg-gray-200 rounded w-24"></div>
</CardHeader>
<CardContent>
<div className="h-8 bg-gray-200 rounded w-16 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-20"></div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header Controls */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h3 className="text-lg font-semibold">Custom Metrics</h3>
<p className="text-sm text-muted-foreground">
Monitor your application performance and business KPIs
</p>
</div>
<div className="flex gap-2">
<Button
variant={autoRefresh ? "default" : "outline"}
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
>
<Activity className="w-4 h-4 mr-2" />
Auto-refresh
</Button>
<Button variant="outline" size="sm">
<Timer className="w-4 h-4 mr-2" />
Set Alerts
</Button>
</div>
</div>
{/* Metric Categories */}
<Tabs value={selectedMetric} onValueChange={setSelectedMetric}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="performance">Performance</TabsTrigger>
<TabsTrigger value="infrastructure">Infrastructure</TabsTrigger>
<TabsTrigger value="business">Business KPIs</TabsTrigger>
</TabsList>
<TabsContent value="performance" className="space-y-6">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{/* Response Time */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Response Time</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metricsData?.performance.responseTime.current}ms</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.performance.responseTime.trend || 'stable')}
<span className={getStatusColor(metricsData?.performance.responseTime.current || 0, { good: 200, warning: 500 })}>
{Math.abs(metricsData?.performance.responseTime.change || 0)}%
</span>
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Avg: {metricsData?.performance.responseTime.average}ms</div>
<div>P95: {metricsData?.performance.responseTime.p95}ms</div>
<div>P99: {metricsData?.performance.responseTime.p99}ms</div>
</div>
<div className="mt-2">
{getStatusBadge(metricsData?.performance.responseTime.current || 0, { good: 200, warning: 500 })}
</div>
</CardContent>
</Card>
{/* Throughput */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Throughput</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metricsData?.performance.throughput.current.toLocaleString()}</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.performance.throughput.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.performance.throughput.change || 0)}%
</span>
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Avg: {metricsData?.performance.throughput.average.toLocaleString()}/min</div>
<div>Peak: {metricsData?.performance.throughput.peak.toLocaleString()}/min</div>
</div>
<div className="mt-2">
<Badge variant="default" className="bg-green-500">Healthy</Badge>
</div>
</CardContent>
</Card>
{/* Error Rate */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Error Rate</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metricsData?.performance.errorRate.current}%</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.performance.errorRate.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.performance.errorRate.change || 0)}%
</span>
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Avg: {metricsData?.performance.errorRate.average}%</div>
<div>Target: {'<1%'}</div>
</div>
<div className="mt-2">
{getStatusBadge(metricsData?.performance.errorRate.current || 0, { good: 1, warning: 5 })}
</div>
</CardContent>
</Card>
{/* Availability */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Availability</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metricsData?.performance.availability.current}%</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.performance.availability.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.performance.availability.change || 0)}%
</span>
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Avg: {metricsData?.performance.availability.average}%</div>
<div>Target: {'>99.9%'}</div>
</div>
<div className="mt-2">
{getStatusBadge(100 - (metricsData?.performance.availability.current || 0), { good: 0.1, warning: 0.5 })}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="infrastructure" className="space-y-6">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{/* CPU Usage */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metricsData?.infrastructure.cpu.current}%</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.infrastructure.cpu.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.infrastructure.cpu.change || 0)}%
</span>
</div>
<div className="mt-2">
<Progress value={metricsData?.infrastructure.cpu.current} className="h-2" />
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Avg: {metricsData?.infrastructure.cpu.average}%</div>
<div>Peak: {metricsData?.infrastructure.cpu.peak}%</div>
</div>
</CardContent>
</Card>
{/* Memory Usage */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Memory Usage</CardTitle>
<MemoryStick className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metricsData?.infrastructure.memory.current}%</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.infrastructure.memory.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.infrastructure.memory.change || 0)}%
</span>
</div>
<div className="mt-2">
<Progress value={metricsData?.infrastructure.memory.current} className="h-2" />
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Avg: {metricsData?.infrastructure.memory.average}%</div>
<div>Peak: {metricsData?.infrastructure.memory.peak}%</div>
</div>
</CardContent>
</Card>
{/* Disk Usage */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Disk Usage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metricsData?.infrastructure.disk.current}%</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.infrastructure.disk.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.infrastructure.disk.change || 0)}%
</span>
</div>
<div className="mt-2">
<Progress value={metricsData?.infrastructure.disk.current} className="h-2" />
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Avg: {metricsData?.infrastructure.disk.average}%</div>
<div>Peak: {metricsData?.infrastructure.disk.peak}%</div>
</div>
</CardContent>
</Card>
{/* Network Traffic */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Network Traffic</CardTitle>
<Network className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metricsData?.infrastructure.network.inbound}Mbps
</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.infrastructure.network.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.infrastructure.network.change || 0)}%
</span>
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Inbound: {metricsData?.infrastructure.network.inbound}Mbps</div>
<div>Outbound: {metricsData?.infrastructure.network.outbound}Mbps</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="business" className="space-y-6">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{/* Conversions */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Conversions</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metricsData?.business.conversions.current}</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.business.conversions.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.business.conversions.change || 0)}%
</span>
</div>
<div className="mt-2">
<Progress value={metricsData?.business.conversions.completion} className="h-2" />
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Goal: {metricsData?.business.conversions.goal}</div>
<div>Completion: {metricsData?.business.conversions.completion}%</div>
</div>
</CardContent>
</Card>
{/* Revenue */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Revenue</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${(metricsData?.business.revenue.current || 0).toLocaleString()}</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.business.revenue.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.business.revenue.change || 0)}%
</span>
</div>
<div className="mt-2">
<Progress value={metricsData?.business.revenue.completion} className="h-2" />
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Goal: ${(metricsData?.business.revenue.goal || 0).toLocaleString()}</div>
<div>Completion: {metricsData?.business.revenue.completion}%</div>
</div>
</CardContent>
</Card>
{/* User Satisfaction */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">User Satisfaction</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metricsData?.business.userSatisfaction.current}/5</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.business.userSatisfaction.trend || 'stable')}
<span className="text-gray-600">
{Math.abs(metricsData?.business.userSatisfaction.change || 0)}%
</span>
</div>
<div className="mt-2">
<Progress value={(metricsData?.business.userSatisfaction.current || 0) * 20} className="h-2" />
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Goal: {metricsData?.business.userSatisfaction.goal}/5</div>
<div>Completion: {metricsData?.business.userSatisfaction.completion}%</div>
</div>
</CardContent>
</Card>
{/* Active Users */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(metricsData?.business.activeUsers.current || 0).toLocaleString()}</div>
<div className="flex items-center space-x-1 text-xs">
{getTrendIcon(metricsData?.business.activeUsers.trend || 'stable')}
<span className="text-green-600">
{Math.abs(metricsData?.business.activeUsers.change || 0)}%
</span>
</div>
<div className="mt-2">
<Progress value={metricsData?.business.activeUsers.completion} className="h-2" />
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<div>Goal: {(metricsData?.business.activeUsers.goal || 0).toLocaleString()}</div>
<div>Completion: {metricsData?.business.activeUsers.completion}%</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,314 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Activity,
Users,
Eye,
MousePointer,
Globe,
Monitor,
Smartphone,
Clock,
TrendingUp,
MapPin
} from 'lucide-react';
export function RealTimeAnalytics() {
const [currentTime, setCurrentTime] = useState(new Date());
const [activeUsers, setActiveUsers] = useState(127);
const [currentVisitors, setCurrentVisitors] = useState(34);
// Mock real-time data - in real implementation, this would update from WebSocket/API
const [realTimeData, setRealTimeData] = useState({
onlineUsers: 127,
currentVisitors: 34,
pageviews: [
{ url: '/dashboard', title: 'Dashboard', count: 12, percentage: 35 },
{ url: '/projects', title: 'Projects', count: 8, percentage: 24 },
{ url: '/analytics', title: 'Analytics', count: 6, percentage: 18 },
{ url: '/docs', title: 'Documentation', count: 4, percentage: 12 },
{ url: '/settings', title: 'Settings', count: 4, percentage: 11 }
],
locations: [
{ country: 'United States', count: 8, percentage: 24 },
{ country: 'United Kingdom', count: 6, percentage: 18 },
{ country: 'Germany', count: 4, percentage: 12 },
{ country: 'Canada', count: 3, percentage: 9 },
{ country: 'France', count: 3, percentage: 9 },
{ country: 'Others', count: 6, percentage: 28 }
],
devices: [
{ type: 'desktop', count: 18, percentage: 53 },
{ type: 'mobile', count: 12, percentage: 35 },
{ type: 'tablet', count: 4, percentage: 12 }
],
recentActivity: [
{
type: 'page_view',
user: 'User 1234',
page: '/dashboard',
location: 'United States',
device: 'desktop',
timestamp: new Date(Date.now() - 2 * 60 * 1000)
},
{
type: 'page_view',
user: 'User 5678',
page: '/projects',
location: 'United Kingdom',
device: 'mobile',
timestamp: new Date(Date.now() - 5 * 60 * 1000)
},
{
type: 'event',
user: 'User 9012',
page: '/analytics',
location: 'Germany',
device: 'desktop',
event: 'button_click',
timestamp: new Date(Date.now() - 8 * 60 * 1000)
},
{
type: 'page_view',
user: 'User 3456',
page: '/docs',
location: 'Canada',
device: 'tablet',
timestamp: new Date(Date.now() - 12 * 60 * 1000)
},
{
type: 'conversion',
user: 'User 7890',
page: '/pricing',
location: 'France',
device: 'mobile',
event: 'form_submit',
timestamp: new Date(Date.now() - 15 * 60 * 1000)
}
]
});
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
// Simulate real-time updates
setActiveUsers(prev => prev + Math.floor(Math.random() * 5) - 2);
setCurrentVisitors(prev => prev + Math.floor(Math.random() * 3) - 1);
}, 5000);
return () => clearInterval(timer);
}, []);
const formatTimeAgo = (timestamp: Date) => {
const seconds = Math.floor((Date.now() - timestamp.getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
};
const getActivityIcon = (type: string) => {
switch (type) {
case 'page_view':
return <Eye className="w-4 h-4 text-blue-500" />;
case 'event':
return <MousePointer className="w-4 h-4 text-purple-500" />;
case 'conversion':
return <TrendingUp className="w-4 h-4 text-green-500" />;
default:
return <Activity className="w-4 h-4 text-gray-500" />;
}
};
const getDeviceIcon = (device: string) => {
switch (device) {
case 'desktop':
return <Monitor className="w-3 h-3" />;
case 'mobile':
return <Smartphone className="w-3 h-3" />;
case 'tablet':
return <Activity className="w-3 h-3" />;
default:
return <Monitor className="w-3 h-3" />;
}
};
return (
<div className="space-y-6">
{/* Real-time Overview */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Online Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeUsers}</div>
<p className="text-xs text-muted-foreground">
Active now
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Current Visitors</CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{currentVisitors}</div>
<p className="text-xs text-muted-foreground">
On site now
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pageviews/min</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">47</div>
<p className="text-xs text-muted-foreground">
Last 5 minutes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg. Duration</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">3:24</div>
<p className="text-xs text-muted-foreground">
Current session
</p>
</CardContent>
</Card>
</div>
{/* Current Activity */}
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
{/* Top Pages Now */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="w-5 h-5" />
Top Pages Now
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{realTimeData.pageviews.map((page) => (
<div key={page.url} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<div>
<span className="text-sm font-medium">{page.title}</span>
<p className="text-xs text-muted-foreground">{page.url}</p>
</div>
</div>
<div className="text-right">
<div className="font-semibold">{page.count}</div>
<div className="text-xs text-muted-foreground">{page.percentage}%</div>
</div>
</div>
))}
</CardContent>
</Card>
{/* Geographic Distribution */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
Live Locations
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{realTimeData.locations.map((location) => (
<div key={location.country} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm">{location.country}</span>
</div>
<div className="text-right">
<div className="font-semibold">{location.count}</div>
<div className="text-xs text-muted-foreground">{location.percentage}%</div>
</div>
</div>
))}
</CardContent>
</Card>
{/* Device Breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Monitor className="w-5 h-5" />
Live Devices
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{realTimeData.devices.map((device) => (
<div key={device.type} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getDeviceIcon(device.type)}
<span className="text-sm capitalize">{device.type}</span>
</div>
<div className="text-right">
<div className="font-semibold">{device.count}</div>
<div className="text-xs text-muted-foreground">{device.percentage}%</div>
</div>
</div>
<Progress value={device.percentage} className="h-2" />
</div>
))}
</CardContent>
</Card>
{/* Recent Activity Feed */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="w-5 h-5" />
Live Activity Feed
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{realTimeData.recentActivity.map((activity, index) => (
<div key={index} className="flex items-start gap-3 border-b pb-2 last:border-0">
{getActivityIcon(activity.type)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{activity.user}</span>
<Badge variant="outline" className="text-xs">
{activity.location}
</Badge>
<div className="flex items-center gap-1">
{getDeviceIcon(activity.device)}
</div>
</div>
<p className="text-xs text-muted-foreground">
{activity.type === 'page_view' && `Viewed ${activity.page}`}
{activity.type === 'event' && `Triggered ${activity.event} on ${activity.page}`}
{activity.type === 'conversion' && `Converted on ${activity.page}`}
</p>
<p className="text-xs text-muted-foreground">
{formatTimeAgo(activity.timestamp)}
</p>
</div>
</div>
))}
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,248 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Search,
Globe,
ExternalLink,
MousePointer,
TrendingUp,
ArrowUp,
ArrowDown
} from 'lucide-react';
interface TrafficAnalyticsProps {
timeRange: string;
}
export function TrafficAnalytics({ timeRange }: TrafficAnalyticsProps) {
// Mock data - in real implementation, this would come from Umami API
const trafficData = {
sources: [
{
name: 'Organic Search',
percentage: 35,
visitors: 15832,
trend: 'up' as const,
change: 12.5
},
{
name: 'Direct Traffic',
percentage: 28,
visitors: 12666,
trend: 'up' as const,
change: 8.3
},
{
name: 'Social Media',
percentage: 18,
visitors: 8142,
trend: 'down' as const,
change: -3.2
},
{
name: 'Referral',
percentage: 12,
visitors: 5428,
trend: 'up' as const,
change: 15.7
},
{
name: 'Email Marketing',
percentage: 4,
visitors: 1809,
trend: 'up' as const,
change: 22.1
},
{
name: 'Paid Search',
percentage: 3,
visitors: 1357,
trend: 'down' as const,
change: -8.9
}
],
referrers: [
{ name: 'google.com', visitors: 12456, percentage: 27.5 },
{ name: 'github.com', visitors: 8234, percentage: 18.2 },
{ name: 'stackoverflow.com', visitors: 5423, percentage: 12.0 },
{ name: 'twitter.com', visitors: 3612, percentage: 8.0 },
{ name: 'linkedin.com', visitors: 2891, percentage: 6.4 },
{ name: 'Others', visitors: 12618, percentage: 27.9 }
],
campaigns: [
{
name: 'Summer Launch 2024',
visitors: 8234,
conversionRate: 4.2,
revenue: 12456
},
{
name: 'Product Update',
visitors: 5423,
conversionRate: 3.8,
revenue: 8234
},
{
name: 'Newsletter Signup',
visitors: 3612,
conversionRate: 2.1,
revenue: 2891
},
{
name: 'Social Media Push',
visitors: 2891,
conversionRate: 1.8,
revenue: 1567
}
],
keywords: [
{ name: 'container orchestration', visitors: 3421, percentage: 12.3 },
{ name: 'paas platform', visitors: 2891, percentage: 10.4 },
{ name: 'docker deployment', visitors: 2456, percentage: 8.8 },
{ name: 'self-hosted analytics', visitors: 1987, percentage: 7.1 },
{ name: 'railway alternative', visitors: 1654, percentage: 5.9 }
]
};
const getTrendIcon = (trend: 'up' | 'down') => {
return trend === 'up' ? (
<ArrowUp className="w-3 h-3 text-green-500" />
) : (
<ArrowDown className="w-3 h-3 text-red-500" />
);
};
const getSourceIcon = (source: string) => {
if (source.includes('Search')) return <Search className="w-4 h-4" />;
if (source.includes('Direct')) return <MousePointer className="w-4 h-4" />;
if (source.includes('Social')) return <Globe className="w-4 h-4" />;
if (source.includes('Referral')) return <ExternalLink className="w-4 h-4" />;
return <TrendingUp className="w-4 h-4" />;
};
return (
<div className="space-y-6">
{/* Traffic Sources Overview */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Traffic Sources
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{trafficData.sources.map((source) => (
<div key={source.name} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getSourceIcon(source.name)}
<span className="text-sm font-medium">{source.name}</span>
<div className="flex items-center gap-1">
{getTrendIcon(source.trend)}
<span className={`text-xs ${
source.trend === 'up' ? 'text-green-600' : 'text-red-600'
}`}>
{Math.abs(source.change)}%
</span>
</div>
</div>
<div className="text-right">
<div className="font-semibold">{source.percentage}%</div>
<div className="text-xs text-muted-foreground">
{source.visitors.toLocaleString()} visitors
</div>
</div>
</div>
<Progress value={source.percentage} className="h-2" />
</div>
))}
</CardContent>
</Card>
{/* Top Referrers */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ExternalLink className="w-5 h-5" />
Top Referrers
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{trafficData.referrers.map((referrer) => (
<div key={referrer.name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-sm">{referrer.name}</span>
</div>
<div className="text-right">
<div className="font-semibold">{referrer.percentage}%</div>
<div className="text-xs text-muted-foreground">
{referrer.visitors.toLocaleString()} visitors
</div>
</div>
</div>
))}
</CardContent>
</Card>
{/* Campaign Performance */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Campaign Performance
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{trafficData.campaigns.map((campaign) => (
<div key={campaign.name} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-sm">{campaign.name}</h4>
<Badge variant="secondary">
{campaign.conversionRate}% conversion
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<div className="text-muted-foreground">Visitors</div>
<div className="font-semibold">{campaign.visitors.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">Revenue</div>
<div className="font-semibold">${campaign.revenue.toLocaleString()}</div>
</div>
</div>
</div>
))}
</CardContent>
</Card>
{/* Search Keywords */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="w-5 h-5" />
Top Search Keywords
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{trafficData.keywords.map((keyword) => (
<div key={keyword.name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm">{keyword.name}</span>
</div>
<div className="text-right">
<div className="font-semibold">{keyword.percentage}%</div>
<div className="text-xs text-muted-foreground">
{keyword.visitors.toLocaleString()} visitors
</div>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,202 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Users,
Globe,
Monitor,
Smartphone,
Tablet,
Clock,
TrendingUp,
MapPin
} from 'lucide-react';
interface VisitorAnalyticsProps {
timeRange: string;
}
export function VisitorAnalytics({ timeRange }: VisitorAnalyticsProps) {
// Mock data - in real implementation, this would come from Umami API
const visitorData = {
newVsReturning: {
new: 68,
returning: 32
},
devices: {
desktop: 45,
mobile: 42,
tablet: 13
},
browsers: [
{ name: 'Chrome', percentage: 45, users: 20356 },
{ name: 'Safari', percentage: 28, users: 12666 },
{ name: 'Firefox', percentage: 12, users: 5428 },
{ name: 'Edge', percentage: 8, users: 3619 },
{ name: 'Others', percentage: 7, users: 3166 }
],
operatingSystems: [
{ name: 'Windows', percentage: 38, users: 17189 },
{ name: 'macOS', percentage: 32, users: 14475 },
{ name: 'Android', percentage: 18, users: 8142 },
{ name: 'iOS', percentage: 10, users: 4523 },
{ name: 'Linux', percentage: 2, users: 905 }
],
countries: [
{ name: 'United States', percentage: 35, users: 15832 },
{ name: 'United Kingdom', percentage: 18, users: 8142 },
{ name: 'Germany', percentage: 12, users: 5428 },
{ name: 'Canada', percentage: 8, users: 3619 },
{ name: 'France', percentage: 7, users: 3166 },
{ name: 'Others', percentage: 20, users: 9047 }
],
languages: [
{ name: 'English', percentage: 45, users: 20356 },
{ name: 'German', percentage: 15, users: 6785 },
{ name: 'French', percentage: 12, users: 5428 },
{ name: 'Spanish', percentage: 10, users: 4523 },
{ name: 'Others', percentage: 18, users: 8142 }
]
};
const getDeviceIcon = (device: string) => {
switch (device) {
case 'desktop':
return <Monitor className="w-4 h-4" />;
case 'mobile':
return <Smartphone className="w-4 h-4" />;
case 'tablet':
return <Tablet className="w-4 h-4" />;
default:
return <Monitor className="w-4 h-4" />;
}
};
return (
<div className="space-y-6">
{/* New vs Returning Visitors */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
New vs Returning Visitors
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="secondary">New</Badge>
<span className="text-sm">First-time visitors</span>
</div>
<div className="text-right">
<div className="font-semibold">{visitorData.newVsReturning.new}%</div>
<div className="text-xs text-muted-foreground">
{(45234 * visitorData.newVsReturning.new / 100).toLocaleString()} visitors
</div>
</div>
</div>
<Progress value={visitorData.newVsReturning.new} className="h-2" />
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline">Returning</Badge>
<span className="text-sm">Repeat visitors</span>
</div>
<div className="text-right">
<div className="font-semibold">{visitorData.newVsReturning.returning}%</div>
<div className="text-xs text-muted-foreground">
{(45234 * visitorData.newVsReturning.returning / 100).toLocaleString()} visitors
</div>
</div>
</div>
<Progress value={visitorData.newVsReturning.returning} className="h-2" />
</div>
</CardContent>
</Card>
{/* Device Breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Monitor className="w-5 h-5" />
Device Breakdown
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{Object.entries(visitorData.devices).map(([device, percentage]) => (
<div key={device} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getDeviceIcon(device)}
<span className="text-sm capitalize">{device}</span>
</div>
<div className="text-right">
<div className="font-semibold">{percentage}%</div>
<div className="text-xs text-muted-foreground">
{Math.floor(45234 * percentage / 100).toLocaleString()} visitors
</div>
</div>
</div>
<Progress value={percentage} className="h-2" />
</div>
))}
</CardContent>
</Card>
{/* Browser Statistics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="w-5 h-5" />
Top Browsers
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{visitorData.browsers.map((browser) => (
<div key={browser.name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-sm">{browser.name}</span>
</div>
<div className="text-right">
<div className="font-semibold">{browser.percentage}%</div>
<div className="text-xs text-muted-foreground">
{browser.users.toLocaleString()} users
</div>
</div>
</div>
))}
</CardContent>
</Card>
{/* Geographic Distribution */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
Top Countries
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{visitorData.countries.map((country) => (
<div key={country.name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm">{country.name}</span>
</div>
<div className="text-right">
<div className="font-semibold">{country.percentage}%</div>
<div className="text-xs text-muted-foreground">
{country.users.toLocaleString()} visitors
</div>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,320 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
TrendingUp,
TrendingDown,
Users,
DollarSign,
MoreHorizontal,
Target,
Calendar,
Eye,
MousePointer,
ShoppingCart,
Activity,
Clock
} from 'lucide-react';
import { useState } from 'react';
interface Campaign {
id: string;
name: string;
status: 'active' | 'completed' | 'paused' | 'draft';
reach: number;
engagement: number;
conversions: number;
revenue: number;
trend: string;
startDate: string;
endDate?: string;
budget: number;
spent: number;
ctr: number; // Click-through rate
cpc: number; // Cost per click
platform: 'google' | 'facebook' | 'instagram' | 'email' | 'linkedin';
}
const campaignData: Campaign[] = [
{
id: 'CAMP-001',
name: 'Summer Launch 2024',
status: 'active',
reach: 45234,
engagement: 68,
conversions: 892,
revenue: 12450,
trend: '+15%',
startDate: '2024-06-01',
endDate: '2024-08-31',
budget: 15000,
spent: 8750,
ctr: 2.8,
cpc: 0.45,
platform: 'google'
},
{
id: 'CAMP-002',
name: 'Product Demo Series',
status: 'completed',
reach: 28901,
engagement: 72,
conversions: 456,
revenue: 8900,
trend: '+8%',
startDate: '2024-05-15',
endDate: '2024-06-15',
budget: 8000,
spent: 7200,
ctr: 3.2,
cpc: 0.38,
platform: 'facebook'
},
{
id: 'CAMP-003',
name: 'Newsletter Campaign',
status: 'active',
reach: 18923,
engagement: 54,
conversions: 234,
revenue: 3450,
trend: '-2%',
startDate: '2024-07-01',
budget: 5000,
spent: 2100,
ctr: 1.8,
cpc: 0.12,
platform: 'email'
}
];
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge className="bg-green-100 text-green-800 border-green-200">Active</Badge>;
case 'completed':
return <Badge variant="secondary">Completed</Badge>;
case 'paused':
return <Badge variant="outline">Paused</Badge>;
case 'draft':
return <Badge variant="outline">Draft</Badge>;
default:
return <Badge variant="outline">Unknown</Badge>;
}
};
const getPlatformIcon = (platform: string) => {
switch (platform) {
case 'google':
return <Target className="w-4 h-4 text-blue-600" />;
case 'facebook':
return <Users className="w-4 h-4 text-blue-500" />;
case 'instagram':
return <Eye className="w-4 h-4 text-pink-600" />;
case 'email':
return <Activity className="w-4 h-4 text-orange-600" />;
case 'linkedin':
return <Users className="w-4 h-4 text-blue-700" />;
default:
return <Target className="w-4 h-4" />;
}
};
const getTrendIcon = (trend: string) => {
if (trend.startsWith('+')) {
return <TrendingUp className="w-3 h-3 text-green-600" />;
} else if (trend.startsWith('-')) {
return <TrendingDown className="w-3 h-3 text-red-600" />;
}
return <Activity className="w-3 h-3 text-gray-600" />;
};
const getTrendColor = (trend: string) => {
if (trend.startsWith('+')) {
return 'text-green-600 bg-green-50';
} else if (trend.startsWith('-')) {
return 'text-red-600 bg-red-50';
}
return 'text-gray-600 bg-gray-50';
};
export function CampaignDataCard() {
const [selectedCampaign, setSelectedCampaign] = useState<string | null>(null);
const totalRevenue = campaignData.reduce((sum, item) => sum + item.revenue, 0);
const totalConversions = campaignData.reduce((sum, item) => sum + item.conversions, 0);
const totalBudget = campaignData.reduce((sum, item) => sum + item.budget, 0);
const totalSpent = campaignData.reduce((sum, item) => sum + item.spent, 0);
const avgEngagement = Math.round(campaignData.reduce((sum, item) => sum + item.engagement, 0) / campaignData.length);
const activeCampaigns = campaignData.filter(c => c.status === 'active').length;
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">Campaign Data</CardTitle>
<div className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
</div>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Key Metrics Overview */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Total Revenue</span>
</div>
<div className="text-lg font-bold">${totalRevenue.toLocaleString()}</div>
<div className="flex items-center gap-1 text-xs">
<TrendingUp className="w-3 h-3 text-green-600" />
<span className="text-green-600">+18% vs last month</span>
</div>
</div>
<div className="p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-2 mb-1">
<ShoppingCart className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Conversions</span>
</div>
<div className="text-lg font-bold">{totalConversions.toLocaleString()}</div>
<div className="flex items-center gap-1 text-xs">
<TrendingUp className="w-3 h-3 text-green-600" />
<span className="text-green-600">+12% vs last month</span>
</div>
</div>
</div>
{/* Budget Utilization */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Budget Utilization</span>
<span className="font-medium">${totalSpent.toLocaleString()} / ${totalBudget.toLocaleString()}</span>
</div>
<Progress value={(totalSpent / totalBudget) * 100} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{Math.round((totalSpent / totalBudget) * 100)}% spent</span>
<span>${(totalBudget - totalSpent).toLocaleString()} remaining</span>
</div>
</div>
{/* Performance Metrics */}
<div className="grid grid-cols-3 gap-3 text-center">
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-sm font-bold">{avgEngagement}%</div>
<div className="text-xs text-muted-foreground">Avg Engagement</div>
</div>
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-sm font-bold">{activeCampaigns}</div>
<div className="text-xs text-muted-foreground">Active</div>
</div>
<div className="p-2 rounded-lg bg-muted/30">
<div className="text-sm font-bold">2.6%</div>
<div className="text-xs text-muted-foreground">Avg CTR</div>
</div>
</div>
{/* Campaign List */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Active Campaigns</div>
<div className="space-y-2">
{campaignData.filter(c => c.status === 'active').slice(0, 3).map((campaign) => (
<div
key={campaign.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
selectedCampaign === campaign.id
? 'bg-primary/10 border-primary/30'
: 'bg-muted/30 border-border hover:bg-muted/50'
}`}
onClick={() => setSelectedCampaign(campaign.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
{getPlatformIcon(campaign.platform)}
<span className="text-sm font-medium truncate">{campaign.name}</span>
{getStatusBadge(campaign.status)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
<div>
<div className="text-muted-foreground">Reach</div>
<div className="font-medium">{campaign.reach.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">Revenue</div>
<div className="font-medium">${campaign.revenue.toLocaleString()}</div>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<Eye className="w-3 h-3 text-muted-foreground" />
<span>{campaign.ctr}% CTR</span>
</div>
<div className="flex items-center gap-1">
<MousePointer className="w-3 h-3 text-muted-foreground" />
<span>${campaign.cpc} CPC</span>
</div>
</div>
<Badge variant="outline" className={`text-xs ${getTrendColor(campaign.trend)}`}>
{getTrendIcon(campaign.trend)}
{campaign.trend}
</Badge>
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Campaign Timeline */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Recent Activity</div>
<div className="space-y-2">
{campaignData.slice(0, 2).map((campaign) => (
<div key={campaign.id} className="flex items-center gap-3 text-xs">
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center">
<Calendar className="w-3 h-3" />
</div>
<div className="flex-1">
<div className="font-medium">{campaign.name}</div>
<div className="text-muted-foreground">
{campaign.status === 'active' ? 'Started' : 'Completed'} {campaign.startDate}
</div>
</div>
<div className="text-muted-foreground">
{campaign.status === 'active' ? (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>Active</span>
</div>
) : (
<span>Ended {campaign.endDate}</span>
)}
</div>
</div>
))}
</div>
</div>
{/* Quick Actions */}
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1 text-xs">
View All Campaigns
</Button>
<Button size="sm" className="flex-1 text-xs">
Create Campaign
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,165 @@
import { MetricCard } from './MetricCard';
import { TrendingUp, TrendingDown, BarChart3 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { useState } from 'react';
export function ConversionRateCard() {
const [selectedPeriod, setSelectedPeriod] = useState('1W');
const conversionData = {
'1D': {
rate: '15.2%',
trend: '+0.8%',
direction: 'up' as 'up',
funnel: {
cart: { count: 384, trend: '+3%' },
checkout: { count: 215, trend: '+2%' },
payment: { count: 184, trend: '+1%' }
}
},
'1W': {
rate: '16.9%',
trend: '+2.1%',
direction: 'up' as 'up',
funnel: {
cart: { count: 3842, trend: '+12%' },
checkout: { count: 2156, trend: '+8%' },
payment: { count: 1842, trend: '+5%' }
}
},
'1M': {
rate: '18.3%',
trend: '+3.4%',
direction: 'up' as 'up',
funnel: {
cart: { count: 16547, trend: '+18%' },
checkout: { count: 9234, trend: '+14%' },
payment: { count: 7892, trend: '+11%' }
}
},
'3M': {
rate: '19.7%',
trend: '+4.8%',
direction: 'up' as 'up',
funnel: {
cart: { count: 52341, trend: '+28%' },
checkout: { count: 29456, trend: '+22%' },
payment: { count: 25123, trend: '+19%' }
}
},
'6M': {
rate: '21.2%',
trend: '+6.3%',
direction: 'up' as 'up',
funnel: {
cart: { count: 108934, trend: '+41%' },
checkout: { count: 61234, trend: '+35%' },
payment: { count: 52345, trend: '+31%' }
}
},
'1Y': {
rate: '23.8%',
trend: '+8.9%',
direction: 'up' as 'up',
funnel: {
cart: { count: 224567, trend: '+67%' },
checkout: { count: 126789, trend: '+58%' },
payment: { count: 108234, trend: '+52%' }
}
},
};
const currentData = conversionData[selectedPeriod as keyof typeof conversionData];
return (
<MetricCard
title="Conversion Rate"
value={currentData.rate}
trend={{
value: currentData.trend,
direction: currentData.direction
}}
actionLabel="Details"
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
>
<div className="w-full flex-col gap-3">
{/* Conversion Funnel */}
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<div className="flex-1 text-sm text-muted-foreground font-medium">Added to Cart</div>
<div className="flex items-center gap-1.5">
<div className="min-w-16 text-sm tabular-nums text-muted-foreground">{currentData.funnel.cart.count.toLocaleString()}</div>
<Badge
variant={currentData.funnel.cart.trend.startsWith('+') ? 'default' : 'destructive'}
className="h-5 gap-1 px-2 text-xs"
>
{currentData.funnel.cart.trend.startsWith('+') ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.funnel.cart.trend}
</Badge>
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1 text-sm text-muted-foreground font-medium">Checkout Started</div>
<div className="flex items-center gap-1.5">
<div className="min-w-16 text-sm tabular-nums text-muted-foreground">{currentData.funnel.checkout.count.toLocaleString()}</div>
<Badge
variant={currentData.funnel.checkout.trend.startsWith('+') ? 'default' : 'destructive'}
className="h-5 gap-1 px-2 text-xs"
>
{currentData.funnel.checkout.trend.startsWith('+') ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.funnel.checkout.trend}
</Badge>
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex-1 text-sm text-muted-foreground font-medium">Payment Completed</div>
<div className="flex items-center gap-1.5">
<div className="min-w-16 text-sm tabular-nums text-muted-foreground">{currentData.funnel.payment.count.toLocaleString()}</div>
<Badge
variant={currentData.funnel.payment.trend.startsWith('+') ? 'default' : 'destructive'}
className="h-5 gap-1 px-2 text-xs"
>
{currentData.funnel.payment.trend.startsWith('+') ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{currentData.funnel.payment.trend}
</Badge>
</div>
</div>
</div>
{/* Mini Sparkline Visualization */}
<div className="mt-4 pt-3 border-t border-border/20">
<div className="flex items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<BarChart3 className="w-4 h-4" />
<span className="text-xs">Conversion trend</span>
</div>
</div>
<div className="mt-2 h-8 w-full bg-muted/20 rounded-sm flex items-end justify-between gap-1 px-1">
{[65, 72, 68, 75, 82, 79, 85, 88, 92, 87, 91, 95].map((height, i) => (
<div
key={i}
className="flex-1 bg-primary/60 rounded-sm transition-all duration-300 hover:bg-primary/80"
style={{ height: `${height}%` }}
/>
))}
</div>
</div>
</div>
</MetricCard>
);
}

Some files were not shown because too many files have changed in this diff Show More