mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-07-28 21:43:50 +00:00
feat: initial implementation of container management platform
This commit is contained in:
@@ -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*
|
||||
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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;"]
|
||||
@@ -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
|
||||
@@ -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
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
👉 They’re 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
|
||||
|
||||
Here’s what’s happening internally:
|
||||
|
||||
A) Root Layout
|
||||
stack-container
|
||||
nested-canvas
|
||||
canvas-container
|
||||
|
||||
|
||||
This is:
|
||||
|
||||
Grid layout
|
||||
|
||||
Responsive behavior
|
||||
|
||||
Sidebar + center canvas layout
|
||||
|
||||
Mobile floating button
|
||||
|
||||
They’re 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
|
||||
|
||||
|
||||
That’s 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">
|
||||
|
||||
|
||||
That’s 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 Flow–powered 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):
|
||||
|
||||
You’d 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
|
||||
|
||||
It’s a visual abstraction over cloud container orchestration.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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! 🚀
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 623 KiB |
@@ -0,0 +1,63 @@
|
||||
# 🚆 What the Railway Dashboard Is
|
||||
|
||||
The Railway dashboard is the main web interface for managing your Railway cloud projects. It’s essentially your control panel where you can see, organize, configure, and monitor everything you’re hosting on Railway — services, deployments, databases, environments, logs, metrics, and more.
|
||||
|
||||
You typically land on the dashboard after signing into your Railway account. It’s 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)
|
||||
|
||||
Railway’s 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.
|
||||
@@ -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.
|
||||
|
||||
Key–value 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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
|
||||
$$;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+5035
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+263
@@ -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.
|
||||
Executable
+56
@@ -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"
|
||||
+42
@@ -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
@@ -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;
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user