mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup
Backend: - GET /services/:id/metrics with real Docker container stats - DELETE /git/providers/:id and GET repo branches endpoint Frontend: - Service detail shows real telemetry or honest empty states - Live status distinguishes idle/polling/reconnecting - Git providers section in Settings; repo+branch pickers in Add Service - Real logo, compact Projects header, canvas fitView fix - index.css deduplicated (1725 -> 1384 lines) - Fixed smoke tests, lint errors, ToastProvider/context unification Repo hygiene: - Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README - Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore - Removed legacy root src/internal/cmd and stale configs/docs/artifacts - Moved guides and design references under docs/ Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
355a97bab4
commit
9f3b196032
499 files changed
+4358
-94122
No files matched your search
@@ -1,118 +0,0 @@
|
||||
# Canonical production environment template for:
|
||||
# - self-hosted full stack via ./start-unified.sh prod
|
||||
# - frontend deployment on Vercel (copy VITE_* and FRONTEND_URL/BACKEND_URL values)
|
||||
# - backend deployment on Railway (copy backend/auth/database values)
|
||||
|
||||
ENVIRONMENT=production
|
||||
|
||||
# Public application URLs
|
||||
FRONTEND_URL=https://containr-web.vercel.app
|
||||
BACKEND_URL=https://containr-api.up.railway.app
|
||||
VITE_API_URL=https://containr-api.up.railway.app
|
||||
VITE_AUTH_URL=https://containr-api.up.railway.app/api/auth
|
||||
BETTER_AUTH_URL=https://containr-api.up.railway.app
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://containr-web.vercel.app,https://containr-api.up.railway.app
|
||||
CORS_ORIGINS=https://containr-web.vercel.app,https://containr-api.up.railway.app
|
||||
CORS_CREDENTIALS=true
|
||||
|
||||
# Self-hosted domain settings
|
||||
DOMAIN=containr.example.com
|
||||
ACME_EMAIL=[email protected]
|
||||
TRAEFIK_API_INSECURE=false
|
||||
TRAEFIK_AUTH=admin:$$apr1$$replace_me$$replace_me
|
||||
|
||||
# Backend runtime
|
||||
PORT=8080
|
||||
HOST=0.0.0.0
|
||||
AUTH_PORT=3001
|
||||
BETTER_AUTH_ENABLED=true
|
||||
BETTER_AUTH_ENTRYPOINT=auth/src/server.js
|
||||
BETTER_AUTH_NODE_BINARY=node
|
||||
BETTER_AUTH_STARTUP_TIMEOUT=20s
|
||||
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
|
||||
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
|
||||
MAX_REQUEST_BODY_BYTES=10485760
|
||||
READ_TIMEOUT=30s
|
||||
WRITE_TIMEOUT=30s
|
||||
IDLE_TIMEOUT=60s
|
||||
SHUTDOWN_TIMEOUT=30s
|
||||
AUTO_MIGRATE=true
|
||||
MIGRATION_LOCK_TIMEOUT=5m
|
||||
SEED_DATA_ON_START=false
|
||||
TRUSTED_PROXY_CIDR=172.20.0.0/16
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=json
|
||||
LOG_OUTPUT=stdout
|
||||
DEBUG=false
|
||||
|
||||
# Secrets
|
||||
JWT_SECRET=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG
|
||||
BETTER_AUTH_SECRET=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG
|
||||
BETTER_AUTH_INTERNAL_TOKEN=CHANGE_ME_INTERNAL_AUTH_TOKEN
|
||||
CONTAINR_AGENT_AUTH_TOKEN=CHANGE_ME_AGENT_AUTH_TOKEN
|
||||
# Optional rotating token list
|
||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
||||
|
||||
# Cookies
|
||||
COOKIE_SECURE=true
|
||||
COOKIE_DOMAIN=
|
||||
COOKIE_PATH=/
|
||||
COOKIE_SAME_SITE=lax
|
||||
|
||||
# Self-hosted database defaults.
|
||||
# Railway: replace DATABASE_URL and REDIS_URL with Railway-provided values.
|
||||
POSTGRES_DB=containr
|
||||
POSTGRES_USER=containr_user
|
||||
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
|
||||
DATABASE_URL=postgres://containr_user:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/containr?sslmode=disable
|
||||
MAX_CONNECTIONS=50
|
||||
MAX_IDLE_CONNECTIONS=10
|
||||
CONN_MAX_LIFETIME=10m
|
||||
CONN_MAX_IDLE_TIME=5m
|
||||
|
||||
REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD
|
||||
REDIS_URL=redis://:CHANGE_ME_REDIS_PASSWORD@redis:6379/0
|
||||
|
||||
# Explicit DB settings for the embedded Better Auth runtime.
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=containr
|
||||
DB_USER=containr_user
|
||||
DB_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
|
||||
|
||||
# Optional OAuth providers
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
|
||||
GITLAB_CLIENT_SECRET=PLACEHOLDER_GITLAB_CLIENT_SECRET
|
||||
GITLAB_OAUTH_AUTHORIZE_URL=https://gitlab.com/oauth/authorize
|
||||
GITLAB_OAUTH_TOKEN_URL=https://gitlab.com/oauth/token
|
||||
GITLAB_OAUTH_USERINFO_URL=https://gitlab.com/api/v4/user
|
||||
BITBUCKET_CLIENT_ID=PLACEHOLDER_BITBUCKET_CLIENT_ID
|
||||
BITBUCKET_CLIENT_SECRET=PLACEHOLDER_BITBUCKET_CLIENT_SECRET
|
||||
BITBUCKET_OAUTH_AUTHORIZE_URL=https://bitbucket.org/site/oauth2/authorize
|
||||
BITBUCKET_OAUTH_TOKEN_URL=https://bitbucket.org/site/oauth2/access_token
|
||||
BITBUCKET_OAUTH_USERINFO_URL=https://api.bitbucket.org/2.0/user
|
||||
BITBUCKET_OAUTH_EMAILS_URL=https://api.bitbucket.org/2.0/user/emails
|
||||
GITEA_CLIENT_ID=PLACEHOLDER_GITEA_CLIENT_ID
|
||||
GITEA_CLIENT_SECRET=PLACEHOLDER_GITEA_CLIENT_SECRET
|
||||
GITEA_OAUTH_AUTHORIZE_URL=https://gitea.example.com/login/oauth/authorize
|
||||
GITEA_OAUTH_TOKEN_URL=https://gitea.example.com/login/oauth/access_token
|
||||
GITEA_OAUTH_USERINFO_URL=https://gitea.example.com/api/v1/user
|
||||
|
||||
# Repo sync / SCM integrations
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_SLUG=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
GITHUB_APP_BASE_URL=https://api.github.com
|
||||
GITLAB_API_URL=https://gitlab.com/api/v4
|
||||
GITLAB_BASE_URL=https://gitlab.com
|
||||
BITBUCKET_API_URL=https://api.bitbucket.org/2.0
|
||||
BITBUCKET_BASE_URL=https://bitbucket.org
|
||||
GITEA_BASE_URL=https://gitea.example.com
|
||||
|
||||
# Optional analytics / observability
|
||||
UMAMI_BASE_URL=
|
||||
UMAMI_API_KEY=
|
||||
UMAMI_WEBSITE_ID=
|
||||
CLOUDFLARED_TOKEN=
|
||||
+9
-1
@@ -21,6 +21,11 @@ build/
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.prod
|
||||
.env.production
|
||||
.env.*.local
|
||||
!.env.example
|
||||
!.env.production.example
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
@@ -69,7 +74,7 @@ jspm_packages/
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
/public/
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
@@ -110,3 +115,6 @@ test-results.xml
|
||||
.claude
|
||||
.desloppify
|
||||
bin
|
||||
# Tool session dirs
|
||||
.jj/
|
||||
.playwright-cli/
|
||||
@@ -1,47 +0,0 @@
|
||||
# Containr Design System
|
||||
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
- **Primary**: DevOps engineers, system administrators, and developers managing container infrastructure
|
||||
- **Secondary**: Small team leads and solo developers looking for self-hosted container management
|
||||
- **Context**: Users access the dashboard to deploy, monitor, and manage containers. They need quick access to critical information, real-time metrics, and intuitive workflows.
|
||||
- **Job to be done**: "I want to deploy and manage containers without the complexity of Kubernetes or expensive cloud platforms."
|
||||
|
||||
### Brand Personality
|
||||
- **Voice**: Professional yet approachable, technical but not intimidating
|
||||
- **Three words**: Modern, reliable, empowering
|
||||
- **Emotional goals**: Users should feel in control, confident, and efficient. The interface should reduce anxiety around infrastructure management.
|
||||
|
||||
### Aesthetic Direction
|
||||
- **Visual tone**: Dark mode SaaS dashboard with subtle gradients and refined details
|
||||
- **References**: Railway (primary inspiration) - clean, modern, professional; Vercel - polished developer experience; Linear - refined typography and spacing
|
||||
- **Anti-references**: Avoid cluttered interfaces, overly bright colors, generic Bootstrap aesthetics
|
||||
- **Theme**: Dark mode with accent colors for status indicators (emerald for success, amber for warning, rose for errors)
|
||||
|
||||
### Design Principles
|
||||
1. **Clarity over density** - Every element serves a purpose, generous whitespace reduces cognitive load
|
||||
2. **Progressive disclosure** - Surface critical information first, details available on interaction
|
||||
3. **Consistent visual language** - Reusable patterns for cards, buttons, forms, and status indicators
|
||||
4. **Performance perception** - Loading states and transitions that feel snappy and responsive
|
||||
5. **Accessibility by default** - WCAG 2.1 AA compliance, readable contrast ratios, keyboard navigation
|
||||
|
||||
### Typography
|
||||
- **Headlines**: Geist (modern, technical, distinctive)
|
||||
- **Body**: Inter (highly readable at all sizes)
|
||||
- **Code/Mono**: JetBrains Mono (developer-friendly)
|
||||
|
||||
### Color System
|
||||
- **Background**: #0a0a0a (near-black with subtle warmth)
|
||||
- **Surface**: #141414 (elevated cards and containers)
|
||||
- **Border**: #1f1f1f (subtle separation)
|
||||
- **Primary accent**: #6366f1 (indigo - modern tech feel)
|
||||
- **Success**: #10b981 (emerald)
|
||||
- **Warning**: #f59e0b (amber)
|
||||
- **Danger**: #f43f5e (rose)
|
||||
|
||||
### Spacing Scale
|
||||
- Use 4px base unit
|
||||
- Component padding: 16px-24px
|
||||
- Section gaps: 32px-48px
|
||||
- Page padding: 24px-48px (responsive)
|
||||
@@ -0,0 +1,42 @@
|
||||
# Contributing to Containr
|
||||
|
||||
Thanks for your interest in contributing. Containr is a self-hosted deployment
|
||||
platform — contributions of all sizes are welcome.
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
app/backend/ Go control-plane API (Gin, PostgreSQL, Redis, Docker SDK)
|
||||
app/frontend/ React + Vite + TypeScript dashboard
|
||||
infra/ Self-hosted compose stack (Traefik, Cloudflare Tunnel)
|
||||
docs/ Guides, API spec, and design documents
|
||||
templates/ Service template catalog notes
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Copy `.env.example` to `.env` and fill in values (never commit `.env`).
|
||||
2. Start Postgres and Redis: `docker compose up -d postgres redis`
|
||||
3. Backend: `cd app/backend && go run ./cmd/server`
|
||||
4. Frontend: `cd app/frontend && npm install && npm run dev`
|
||||
|
||||
## Checks Before Submitting
|
||||
|
||||
- Backend: `cd app/backend && go build ./... && go test ./... && go vet ./...`
|
||||
- Frontend: `cd app/frontend && npm run build && npm test`
|
||||
- Compose: `docker compose config -q`
|
||||
|
||||
## Conventions
|
||||
|
||||
- Reuse existing utilities before adding dependencies.
|
||||
- Keep handlers thin; business logic lives in `internal/` packages.
|
||||
- Regenerate API types after schema changes:
|
||||
`npm --prefix app/frontend run generate:api`.
|
||||
- Never commit secrets, tokens, or environment files.
|
||||
- Write tests for non-trivial logic.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Keep PRs focused — one concern per PR.
|
||||
- Describe the "why", not just the "what".
|
||||
- Include a test plan for anything touching deployment, auth, or networking.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
# Install ca-certificates for HTTPS requests
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S appgroup && \
|
||||
adduser -u 1001 -S appuser -G appgroup
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder stage
|
||||
COPY --from=builder /app/main .
|
||||
|
||||
# Copy migrations
|
||||
COPY --from=builder /app/migrations ./migrations
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R appuser:appgroup /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
# Run the binary
|
||||
CMD ["./main"]
|
||||
@@ -1,53 +0,0 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci && npm install --save-dev terser
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S appgroup && \
|
||||
adduser -u 1001 -S appuser -G appgroup
|
||||
|
||||
# Change ownership of nginx directories
|
||||
RUN chown -R appuser:appgroup /usr/share/nginx/html && \
|
||||
chown -R appuser:appgroup /var/cache/nginx && \
|
||||
chown -R appuser:appgroup /var/log/nginx && \
|
||||
chown -R appuser:appgroup /etc/nginx/conf.d
|
||||
|
||||
# Create nginx PID directory
|
||||
RUN touch /var/run/nginx.pid && \
|
||||
chown -R appuser:appgroup /var/run/nginx.pid
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Tomas Dvorak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,354 +1,123 @@
|
||||
<p align="center">
|
||||
<img src="./containr.svg" alt="Containr Logo" width="200">
|
||||
<img src="./containr.svg" alt="Containr" width="120">
|
||||
</p>
|
||||
|
||||
<h1 align="center">
|
||||
Containr - Modern Container Management Platform
|
||||
</h1>
|
||||
<h1 align="center">Containr</h1>
|
||||
|
||||
<p align="center">
|
||||
🚧 <strong>Work in Progress</strong> - Not yet fully tested
|
||||
</p>
|
||||
<p align="center">
|
||||
Deploy, manage, and scale containers with built-in Cloudflare tunnel support.
|
||||
Self-hosted platform for deploying and managing containerized services.
|
||||
Railway-style project canvases on your own hardware.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick Start</a>
|
||||
<span> • </span>
|
||||
<a href="#features">Features</a>
|
||||
<span> • </span>
|
||||
<a href="#tech-stack">Tech Stack</a>
|
||||
<span> • </span>
|
||||
<a href="#documentation">Documentation</a>
|
||||
<span> • </span>
|
||||
<a href="#contributing">Contributing</a>
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#architecture">Architecture</a> •
|
||||
<a href="docs/guides/">Guides</a> •
|
||||
<a href="CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/archive/research/scorecard.png" alt="Code Quality Scorecard" width="100%">
|
||||
</p>
|
||||
## What is Containr?
|
||||
|
||||
> **🚀 Inspired by Railway**: This project draws inspiration from [Railway](https://railway.app)'s seamless deployment experience and developer-friendly approach. The goal is to bring that same level of simplicity and power to self-hosted container management.
|
||||
Containr is an open-source control plane for Docker. It gives you a
|
||||
project-first deployment workflow — create a project, drop services onto a
|
||||
visual canvas, wire them together with environment variables, and deploy —
|
||||
without Kubernetes, without a cloud bill, and without giving up control of
|
||||
your infrastructure.
|
||||
|
||||
## Introduction
|
||||
|
||||
I built Containr because I was tired of complex container orchestration tools that required weeks of setup and deep expertise. You know how it is – Kubernetes clusters to configure, CI/CD pipelines to maintain, networking nightmares to debug, and that simple app deployment that somehow took three days to get running.
|
||||
|
||||
So I decided to build my own solution. Containr is basically the platform I wish I had – one place to deploy, manage, and scale containers without the complexity. It's open-source and self-hosted because I believe your infrastructure should belong to you, not some cloud provider.
|
||||
|
||||
It's like if Docker Compose, Traefik, and a modern dashboard had a baby. Built by one person (with AI's help), for anyone who's tired of container management being a mess.
|
||||
|
||||
### My Design Inspiration
|
||||
|
||||
I've always been a huge fan of **Portainer** – their clean, intuitive approach to container management just makes sense. Everything feels intentional and puts your containers first.
|
||||
|
||||
So yeah, Containr borrows heavily from that design philosophy. But I wrote every line of code from scratch – this isn't some fork or copy-paste job. I took that beautiful simplicity and tried to expand it into more of a comprehensive container platform with modern features.
|
||||
|
||||
### The AI-Assisted Journey
|
||||
|
||||
This project was built with **Windsurf SWE 1.5**.
|
||||
|
||||
Look, I'll be honest – I'm not the best DevOps engineer out there. But I really wanted to build something that solves real problems, and I think passion matters more than being perfect. Windsurf helped me figure out the hard stuff, learn new technologies, and actually build something I'm proud of.
|
||||
|
||||
As a solo dev, having an AI pair programmer made this whole thing possible. Go backend, React frontend, Docker orchestration – stuff I probably couldn't have tackled alone. It's pretty cool what you can create when humans and AI work together, even when you're still learning.
|
||||
|
||||
With Containr, your container deployments are centralized, scalable, and easy to manage, while remaining self-hosted so you maintain full control and privacy – just as it should be.
|
||||
|
||||
## Project Status
|
||||
|
||||
Containr is production-ready for small to medium deployments with comprehensive features for container management. The platform has been enhanced with enterprise-grade security, monitoring, and reliability features.
|
||||
|
||||
**Production Readiness Score: 75/100**
|
||||
|
||||
- ✅ Core functionality complete and tested
|
||||
- ✅ Security hardening implemented
|
||||
- ✅ Structured logging and error handling
|
||||
- ✅ Rate limiting and request validation
|
||||
- ✅ Comprehensive documentation
|
||||
- ⚠️ Monitoring integration recommended
|
||||
- ⚠️ Load testing recommended for high-traffic scenarios
|
||||
|
||||
See [PRODUCTION_READINESS.md](./PRODUCTION_READINESS.md) for detailed assessment.
|
||||
The UI is inspired by Railway's project canvas: services appear as nodes,
|
||||
connections are inferred automatically, and everything — logs, metrics,
|
||||
deployments, builds — is one click away.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
- **Container Management**: Deploy, start, stop, and restart containers with ease
|
||||
- **Service Orchestration**: Manage multi-container applications with Docker Compose integration
|
||||
- **Automatic SSL**: Built-in Let's Encrypt certificate management with Traefik
|
||||
- **Cloudflare Tunnel**: Zero-config deployment with automatic public URLs
|
||||
- **Web Dashboard**: Modern React-based interface for container management
|
||||
- **Real-time Logs**: View and search container logs in real-time
|
||||
- **Project canvas** — visual service topology with groups, drag/drop layout,
|
||||
and auto-inferred connections
|
||||
- **Deployments** — Docker-based deploys with history, logs, and rollback
|
||||
- **Git integration** — deploy from GitHub, GitLab, Bitbucket, or Gitea;
|
||||
repository and branch pickers, webhooks, GitHub App support
|
||||
- **Builds** — build pipeline with live status over WebSocket
|
||||
- **Service metrics** — real CPU/memory/network telemetry from Docker stats
|
||||
- **Templates** — one-click service catalog (databases, apps, tooling)
|
||||
- **Databases** — managed database services with backup/restore actions
|
||||
- **Cron jobs** — scheduled jobs with execution history
|
||||
- **Preview environments** — per-project preview deploys
|
||||
- **Variables & environments** — per-service env vars, production/preview/dev
|
||||
- **Node monitoring** — host CPU, memory, disk, and network telemetry
|
||||
- **Security** — audit logs, vulnerability scans, compliance reports
|
||||
- **Auth** — Better Auth sessions (email + OAuth), per-user resource scoping
|
||||
- **Self-hosted networking** — Traefik reverse proxy, optional Cloudflare
|
||||
Tunnel for exposure without port forwarding
|
||||
|
||||
### Advanced Features
|
||||
- **Auto-scaling**: Automatic horizontal scaling based on resource metrics
|
||||
- **Health Monitoring**: Comprehensive health checks and monitoring for all services
|
||||
- **Resource Metrics**: CPU, memory, and network usage tracking
|
||||
- **Preview Environments**: Automatic preview deployments for branches
|
||||
- **Git Integration**: Connect repositories for automatic deployments
|
||||
- **Environment Variables**: Secure management of application secrets and configuration
|
||||
- **Database Management**: Built-in PostgreSQL and Redis with automated backups
|
||||
- **Security Scanning**: Container vulnerability scanning and compliance reporting
|
||||
## Architecture
|
||||
|
||||
### Deployment & Infrastructure
|
||||
- **Multi-environment Support**: Development, staging, and production configurations
|
||||
- **Load Balancing**: Intelligent traffic distribution with Traefik
|
||||
- **Service Discovery**: Automatic service registration and discovery
|
||||
- **Network Management**: Isolated networks and secure inter-service communication
|
||||
- **Volume Management**: Persistent storage with automated backups
|
||||
- **CI/CD Integration**: GitHub Actions and GitLab CI integration
|
||||
```
|
||||
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
|
||||
app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar
|
||||
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
|
||||
docs/ Guides, OpenAPI spec, design documents
|
||||
```
|
||||
|
||||
### Security & Compliance
|
||||
- **Authentication**: JWT-based authentication with OAuth2 support
|
||||
- **Role-based Access Control**: Granular permissions for users and teams
|
||||
- **Audit Logging**: Comprehensive audit trails for all actions
|
||||
- **Data Encryption**: Encrypted storage for sensitive data
|
||||
- **Security Scanning**: Automated vulnerability assessment
|
||||
- **Compliance Reporting**: GDPR and SOC2 compliance frameworks
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
- **React + TypeScript** – Modern, reactive UI framework with type safety
|
||||
- **Vite** – Fast build tool and development server with HMR
|
||||
- **TailwindCSS** – Utility-first CSS framework for rapid UI development
|
||||
- **@tanstack/react-query** – Powerful data fetching and state management
|
||||
- **React Router** – Client-side routing for SPAs
|
||||
- **@xyflow/react** – Interactive diagrams and visualizations
|
||||
- **Recharts** – Beautiful charts and data visualization
|
||||
- **Zustand** – Lightweight state management
|
||||
|
||||
### Backend Services
|
||||
- **Main Backend (Go)** – Core API, container management, and business logic
|
||||
- Gin web framework for HTTP routing
|
||||
- GORM for database operations
|
||||
- JWT authentication and authorization
|
||||
- Docker SDK integration
|
||||
- **Database Layer** – PostgreSQL for production data
|
||||
- **Cache Layer** – Redis for session management and caching
|
||||
- **Message Queue** – Background job processing
|
||||
|
||||
### Infrastructure & DevOps
|
||||
- **Docker & Docker Compose** – Containerized deployment
|
||||
- **Traefik** – Reverse proxy with automatic SSL
|
||||
- **Cloudflare Tunnel** – Zero-config public exposure
|
||||
- **PostgreSQL** – Primary database
|
||||
- **Redis** – Caching and session storage
|
||||
- **Nginx** – Static file serving
|
||||
| Component | Default port |
|
||||
| ------------ | ------------ |
|
||||
| Frontend | 3000 (prod) / 5173 (dev) |
|
||||
| API | 8082 |
|
||||
| Auth sidecar | 3001 |
|
||||
| PostgreSQL | 5432 |
|
||||
| Redis | 6379 |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker Engine with Compose v2 (`docker compose`)
|
||||
- Git
|
||||
|
||||
### Installation with Docker (Recommended)
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/your-username/containr.git
|
||||
cd containr
|
||||
```
|
||||
|
||||
2. **Configure environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp .env.production.example .env.prod
|
||||
# Edit .env for local development and .env.prod for production
|
||||
```
|
||||
|
||||
3. **Start the full local stack**
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
4. **Access the application**
|
||||
- **Local full stack**: http://localhost:3000 (Frontend), http://localhost:8082 (API)
|
||||
- **Self-hosted production**: `./start-unified.sh prod`
|
||||
- **Cloudflare profile**: `./start-unified.sh cloudflare`
|
||||
|
||||
### Split Production Deployment
|
||||
### Docker Compose (everything)
|
||||
|
||||
```bash
|
||||
# Frontend -> Vercel
|
||||
cd app/frontend
|
||||
npm run remote
|
||||
|
||||
# Backend only -> self-hosted Docker Compose
|
||||
cd ../backend
|
||||
docker compose up -d --build
|
||||
|
||||
# Backend -> Railway
|
||||
# Configure Railway service root directory: /app/backend
|
||||
# Configure Railway config file path: /app/backend/railway.toml
|
||||
railway up
|
||||
cp .env.example .env # fill in secrets
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Use `.env.prod` in the repository root as the canonical production variable set. Copy the relevant values into Vercel and Railway project environments so the remote frontend and backend talk to each other over `FRONTEND_URL`, `BACKEND_URL`, `VITE_API_URL`, and `VITE_AUTH_URL`.
|
||||
Frontend at `http://localhost:3000`, API at `http://localhost:8082`.
|
||||
|
||||
### Management Commands
|
||||
### Local development
|
||||
|
||||
```bash
|
||||
./start-unified.sh dev # Start local full stack
|
||||
./start-unified.sh prod # Start self-hosted production stack from infra/
|
||||
./start-unified.sh cloudflare # Start self-hosted production stack with cloudflared
|
||||
./start-unified.sh stop # Stop both compose stacks
|
||||
./start-unified.sh logs # View local stack logs
|
||||
./start-unified.sh status # Check service status
|
||||
./start-unified.sh config # Validate compose configuration
|
||||
./start-unified.sh clean # Clean up containers and volumes
|
||||
docker compose up -d postgres redis # data services only
|
||||
cd app/backend && go run ./cmd/server # API + embedded auth sidecar
|
||||
cd app/frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
## Development
|
||||
Frontend dev server at `http://localhost:5173`.
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
containr/
|
||||
├── app/
|
||||
│ ├── frontend/ # React + Vite frontend (deploy from here to Vercel)
|
||||
│ └── backend/ # Go API + embedded Better Auth runtime
|
||||
├── infra/ # Self-hosted production compose + Traefik/cloudflared
|
||||
├── docs/ # API contract and design references
|
||||
├── scripts/ # Utility scripts
|
||||
├── .env.prod # Canonical production env template
|
||||
└── README.md
|
||||
```
|
||||
### Self-hosted with Traefik + Cloudflare Tunnel
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd app/backend
|
||||
go mod download
|
||||
go run cmd/migrate/main.go up # optional manual migration run (legacy + goose)
|
||||
go run cmd/server/main.go
|
||||
|
||||
# Frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive documentation is available in the project:
|
||||
|
||||
- **[Cloudflare Setup](./CLOUDFLARE_SETUP.md)** – Cloudflare tunnel configuration
|
||||
- **[Docker Setup](./DOCKER_SETUP.md)** – Docker deployment guide
|
||||
- **[Autoscaling](./AUTOSCALING.md)** – Auto-scaling configuration
|
||||
- **[API Documentation](./docs/api/openapi.yaml)** – REST API reference
|
||||
See `infra/docker-compose.yml` and
|
||||
[docs/guides/CLOUDFLARE_SETUP.md](docs/guides/CLOUDFLARE_SETUP.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
All configuration is via environment variables — see `.env.example`.
|
||||
Required for production: `JWT_SECRET`, `BETTER_AUTH_SECRET`,
|
||||
`BETTER_AUTH_INTERNAL_TOKEN`, database and Redis credentials.
|
||||
|
||||
Key configuration options in `.env`:
|
||||
**Never commit `.env` or `.env.prod`.** Both are gitignored; if a secret was
|
||||
ever committed, rotate it.
|
||||
|
||||
```bash
|
||||
# Domain Configuration (optional if using Cloudflare)
|
||||
DOMAIN=yourdomain.com
|
||||
[email protected]
|
||||
## Documentation
|
||||
|
||||
# Database Configuration
|
||||
POSTGRES_DB=containr
|
||||
POSTGRES_USER=containr_user
|
||||
POSTGRES_PASSWORD=your_secure_postgres_password
|
||||
MAX_CONNECTIONS=25
|
||||
MAX_IDLE_CONNECTIONS=5
|
||||
CONN_MAX_LIFETIME=5m
|
||||
CONN_MAX_IDLE_TIME=5m
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_PASSWORD=your_secure_redis_password
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET=your_very_secure_jwt_secret_key_here # min 32 chars in production
|
||||
CONTAINR_AGENT_AUTH_TOKEN=your_agent_shared_secret # required for node-agent auth
|
||||
# Optional rotating token list (comma-separated)
|
||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
||||
CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||
COOKIE_SECURE=true
|
||||
|
||||
# Traefik Authentication (Basic Auth for dashboard)
|
||||
TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/
|
||||
# true for local dev dashboard on :8080, set false in production
|
||||
TRAEFIK_API_INSECURE=true
|
||||
|
||||
# Cloudflare Tunnel (alternative to domain)
|
||||
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
||||
|
||||
# Optional: Custom Docker Registry
|
||||
DOCKER_REGISTRY=your-registry.com
|
||||
DOCKER_USERNAME=your_username
|
||||
DOCKER_PASSWORD=your_password
|
||||
|
||||
# Optional: External Services
|
||||
SENTRY_DSN=https://your-sentry-dsn
|
||||
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url
|
||||
|
||||
# Development/Testing
|
||||
ENVIRONMENT=development
|
||||
DEBUG=true
|
||||
TRUSTED_PROXY_CIDR=172.20.0.0/16
|
||||
```
|
||||
- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling
|
||||
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
|
||||
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
|
||||
- [docs/design/](docs/design/) — UI design references
|
||||
- `make help` — common tasks
|
||||
|
||||
## Contributing
|
||||
|
||||
Building Containr as a solo developer has been an incredible journey, but it's always better when we build together! Whether you're fixing a typo, adding a feature, or just sharing ideas – your contribution matters.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome.
|
||||
|
||||
**Feel free to contribute and enhance it to your liking. Help me make this project successful!**
|
||||
## Security
|
||||
|
||||
Every contribution, no matter how small, helps turn this solo dream into a community success story. Whether you're a seasoned developer or just starting out, your perspective and skills can help make Containr better for everyone.
|
||||
|
||||
Here's how you can join this adventure:
|
||||
|
||||
1. **Fork the repository** and make it your own
|
||||
2. **Create a feature branch** (`git checkout -b feature/amazing-feature`)
|
||||
3. **Commit your changes** (`git commit -m 'Add some amazing feature'`)
|
||||
4. **Push to the branch** (`git push origin feature/amazing-feature`)
|
||||
5. **Open a Pull Request** – I'll be excited to review it!
|
||||
|
||||
### Development Guidelines
|
||||
- Follow Go coding standards for backend development
|
||||
- Use TypeScript for frontend development
|
||||
- Write tests for new features (helps future you!)
|
||||
- Update documentation as needed
|
||||
- Remember: this is a labor of love, so let's keep it fun and welcoming
|
||||
|
||||
Don't hesitate to reach out if you're new to contributing – we all started somewhere, and I'm happy to help you get started!
|
||||
See [SECURITY.md](SECURITY.md). Report vulnerabilities privately — do not
|
||||
open public issues.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Community & Support
|
||||
|
||||
- **Issues**: Use GitHub Issues for bug reports and feature requests
|
||||
- **Discussions**: Use GitHub Discussions for questions and community support
|
||||
- **Documentation**: Check the project files for comprehensive guides
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
### Inspiration
|
||||
- **[Portainer](https://www.portainer.io/)** – Container management UI inspiration
|
||||
- **[Docker Compose](https://docs.docker.com/compose/)** – Service orchestration concepts
|
||||
- **[Traefik](https://traefik.io/)** – Reverse proxy and load balancing
|
||||
- **[Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/)** – Zero-config deployment
|
||||
|
||||
### Technology Stack
|
||||
This project is built with amazing open-source technologies:
|
||||
- **Frontend**: React, TypeScript, Vite, TailwindCSS
|
||||
- **Backend**: Go, Gin, GORM, PostgreSQL
|
||||
- **Infrastructure**: Docker, Traefik, Cloudflare, Redis
|
||||
|
||||
## A Personal Note
|
||||
|
||||
Thank you for taking the time to look at my project. Containr represents months of learning, building, and dreaming – all in the service of creating something that makes container management a little more accessible and a lot more powerful.
|
||||
|
||||
In a world of complex orchestration tools and expensive cloud services, I believe there's beauty in owning your infrastructure. Containr is my attempt to build that bridge between simplicity and power.
|
||||
|
||||
Whether you use it, contribute to it, or just find inspiration here – know that you're part of something special. A solo developer's dream, powered by AI assistance, and shared with the open-source community.
|
||||
|
||||
---
|
||||
|
||||
**Containr** – Built with ❤️ by one human, one AI, and a passion for better container management.
|
||||
[MIT](LICENSE) © 2026 Tomas Dvorak
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do **not** open a public issue for security vulnerabilities.
|
||||
|
||||
Report privately via GitHub's private vulnerability reporting
|
||||
(Security tab → Report a vulnerability), or contact the maintainer directly.
|
||||
|
||||
Include:
|
||||
|
||||
- A description of the vulnerability and its impact
|
||||
- Steps to reproduce
|
||||
- Affected version/commit if known
|
||||
|
||||
You will receive an acknowledgement as soon as possible. Please allow
|
||||
reasonable time for a fix before any public disclosure.
|
||||
|
||||
## Scope
|
||||
|
||||
Containr is a self-hosted platform that runs Docker containers, stores
|
||||
credentials, and terminates user traffic. In scope:
|
||||
|
||||
- Authentication and session handling (Better Auth sidecar, JWT, agent tokens)
|
||||
- Secrets storage (Git provider tokens, environment variables)
|
||||
- API authorization and per-user resource scoping
|
||||
- Webhook signature validation
|
||||
- CORS and trusted-origin configuration
|
||||
- Deployment engine command/path handling
|
||||
|
||||
## Secrets
|
||||
|
||||
- Never commit `.env`, `.env.prod`, or any file containing credentials.
|
||||
- Git provider access tokens are stored server-side only and are never
|
||||
returned by the API.
|
||||
- Rotate any secret that has ever been committed to git history.
|
||||
|
||||
## Self-Hosting Notes
|
||||
|
||||
- Set strong `JWT_SECRET`, `BETTER_AUTH_SECRET`, and
|
||||
`BETTER_AUTH_INTERNAL_TOKEN` values in production.
|
||||
- Set `COOKIE_SECURE=true` when serving over HTTPS.
|
||||
- Restrict `CORS_ALLOWED_ORIGINS` to your actual frontend origin.
|
||||
- Keep Traefik's dashboard disabled or behind auth in production
|
||||
(`TRAEFIK_API_INSECURE=false`).
|
||||
+14
-183
@@ -1,6 +1,4 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { magicLink } from 'better-auth/plugins/magic-link';
|
||||
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
||||
import { Pool } from 'pg';
|
||||
import { PostgresDialect } from 'kysely';
|
||||
import { getMigrations } from 'better-auth/db/migration';
|
||||
@@ -50,41 +48,22 @@ const database = new PostgresDialect({
|
||||
|
||||
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
|
||||
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
|
||||
|
||||
const gitlabClientId = env.GITLAB_CLIENT_ID?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_ID';
|
||||
const gitlabClientSecret = env.GITLAB_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_SECRET';
|
||||
const gitlabAuthorizeUrl =
|
||||
env.GITLAB_OAUTH_AUTHORIZE_URL?.trim() || 'https://gitlab.com/oauth/authorize';
|
||||
const gitlabTokenUrl = env.GITLAB_OAUTH_TOKEN_URL?.trim() || 'https://gitlab.com/oauth/token';
|
||||
const gitlabUserUrl = env.GITLAB_OAUTH_USERINFO_URL?.trim() || 'https://gitlab.com/api/v4/user';
|
||||
|
||||
const giteaClientId = env.GITEA_CLIENT_ID?.trim() || 'PLACEHOLDER_GITEA_CLIENT_ID';
|
||||
const giteaClientSecret = env.GITEA_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITEA_CLIENT_SECRET';
|
||||
const giteaAuthorizeUrl =
|
||||
env.GITEA_OAUTH_AUTHORIZE_URL?.trim() ||
|
||||
'https://gitea.example.com/login/oauth/authorize';
|
||||
const giteaTokenUrl =
|
||||
env.GITEA_OAUTH_TOKEN_URL?.trim() ||
|
||||
'https://gitea.example.com/login/oauth/access_token';
|
||||
const giteaUserUrl = env.GITEA_OAUTH_USERINFO_URL?.trim() || 'https://gitea.example.com/api/v1/user';
|
||||
|
||||
const bitbucketClientId = env.BITBUCKET_CLIENT_ID?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_ID';
|
||||
const bitbucketClientSecret =
|
||||
env.BITBUCKET_CLIENT_SECRET?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_SECRET';
|
||||
const bitbucketAuthorizeUrl =
|
||||
env.BITBUCKET_OAUTH_AUTHORIZE_URL?.trim() || 'https://bitbucket.org/site/oauth2/authorize';
|
||||
const bitbucketTokenUrl =
|
||||
env.BITBUCKET_OAUTH_TOKEN_URL?.trim() || 'https://bitbucket.org/site/oauth2/access_token';
|
||||
const bitbucketUserUrl =
|
||||
env.BITBUCKET_OAUTH_USERINFO_URL?.trim() || 'https://api.bitbucket.org/2.0/user';
|
||||
const bitbucketEmailsUrl =
|
||||
env.BITBUCKET_OAUTH_EMAILS_URL?.trim() || 'https://api.bitbucket.org/2.0/user/emails';
|
||||
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
|
||||
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
|
||||
|
||||
const socialProviders = {};
|
||||
if (githubClientId && githubClientSecret) {
|
||||
socialProviders.github = {
|
||||
clientId: githubClientId,
|
||||
clientSecret: githubClientSecret,
|
||||
disableImplicitSignUp: true,
|
||||
};
|
||||
}
|
||||
if (googleClientId && googleClientSecret) {
|
||||
socialProviders.google = {
|
||||
clientId: googleClientId,
|
||||
clientSecret: googleClientSecret,
|
||||
disableImplicitSignUp: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,6 +76,10 @@ const trustedOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:8082',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
]
|
||||
.map((origin) => (origin || '').trim())
|
||||
.filter(Boolean);
|
||||
@@ -130,158 +113,6 @@ export const auth = betterAuth({
|
||||
maxPasswordLength: 128,
|
||||
},
|
||||
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url, token, metadata }) => {
|
||||
const payload = {
|
||||
email,
|
||||
url,
|
||||
token,
|
||||
metadata,
|
||||
};
|
||||
console.log('[better-auth] magic-link', JSON.stringify(payload));
|
||||
},
|
||||
disableSignUp: false,
|
||||
}),
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'gitlab',
|
||||
clientId: gitlabClientId,
|
||||
clientSecret: gitlabClientSecret,
|
||||
authorizationUrl: gitlabAuthorizeUrl,
|
||||
tokenUrl: gitlabTokenUrl,
|
||||
userInfoUrl: gitlabUserUrl,
|
||||
scopes: ['read_user', 'read_api'],
|
||||
getUserInfo: async (tokens) => {
|
||||
if (!tokens.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(gitlabUserUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await response.json();
|
||||
if (!profile || (!profile.id && !profile.username)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: profile.id || profile.username,
|
||||
email: profile.email || null,
|
||||
name: profile.name || profile.username || 'GitLab User',
|
||||
image: profile.avatar_url || null,
|
||||
emailVerified: Boolean(profile.email),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
providerId: 'gitea',
|
||||
clientId: giteaClientId,
|
||||
clientSecret: giteaClientSecret,
|
||||
authorizationUrl: giteaAuthorizeUrl,
|
||||
tokenUrl: giteaTokenUrl,
|
||||
userInfoUrl: giteaUserUrl,
|
||||
scopes: ['read:user', 'read:repository'],
|
||||
getUserInfo: async (tokens) => {
|
||||
if (!tokens.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(giteaUserUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `token ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await response.json();
|
||||
if (!profile || (!profile.id && !profile.login)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: profile.id || profile.login,
|
||||
email: profile.email || null,
|
||||
name: profile.full_name || profile.login || 'Gitea User',
|
||||
image: profile.avatar_url || null,
|
||||
emailVerified: Boolean(profile.email),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
providerId: 'bitbucket',
|
||||
clientId: bitbucketClientId,
|
||||
clientSecret: bitbucketClientSecret,
|
||||
authorizationUrl: bitbucketAuthorizeUrl,
|
||||
tokenUrl: bitbucketTokenUrl,
|
||||
userInfoUrl: bitbucketUserUrl,
|
||||
scopes: ['account', 'email', 'repository'],
|
||||
getUserInfo: async (tokens) => {
|
||||
if (!tokens.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userResponse = await fetch(bitbucketUserUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await userResponse.json();
|
||||
if (!user || (!user.account_id && !user.username && !user.nickname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let primaryEmail = null;
|
||||
const emailsResponse = await fetch(bitbucketEmailsUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
if (emailsResponse.ok) {
|
||||
const emailsPayload = await emailsResponse.json();
|
||||
const values = Array.isArray(emailsPayload?.values) ? emailsPayload.values : [];
|
||||
const primary = values.find((entry) => entry?.is_primary) || values[0];
|
||||
if (primary && typeof primary.email === 'string') {
|
||||
primaryEmail = primary.email;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.account_id || user.username || user.nickname,
|
||||
email: primaryEmail,
|
||||
name: user.display_name || user.nickname || 'Bitbucket User',
|
||||
image: user?.links?.avatar?.href || null,
|
||||
emailVerified: Boolean(primaryEmail),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export async function runAuthMigrations() {
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type agentConfig struct {
|
||||
APIURL string
|
||||
Token string
|
||||
Name string
|
||||
Hostname string
|
||||
IP string
|
||||
Port int
|
||||
Interval time.Duration
|
||||
}
|
||||
|
||||
type capabilities struct {
|
||||
ContainerRuntimes []string `json:"container_runtimes"`
|
||||
SupportedArchitectures []string `json:"supported_architectures"`
|
||||
MaxContainers int `json:"max_containers"`
|
||||
StorageDriver string `json:"storage_driver"`
|
||||
NetworkPlugins []string `json:"network_plugins"`
|
||||
Features []string `json:"features"`
|
||||
}
|
||||
|
||||
type nodeResources struct {
|
||||
CPU cpuResources `json:"cpu"`
|
||||
Memory memoryResources `json:"memory"`
|
||||
Storage storageResources `json:"storage"`
|
||||
Network networkResources `json:"network"`
|
||||
}
|
||||
|
||||
type cpuResources struct {
|
||||
Cores int `json:"cores"`
|
||||
Allocation float64 `json:"allocation"`
|
||||
Usage float64 `json:"usage"`
|
||||
}
|
||||
|
||||
type memoryResources struct {
|
||||
Total int `json:"total"`
|
||||
Allocated int `json:"allocated"`
|
||||
Used int `json:"used"`
|
||||
Available int `json:"available"`
|
||||
}
|
||||
|
||||
type storageResources struct {
|
||||
Total int `json:"total"`
|
||||
Allocated int `json:"allocated"`
|
||||
Used int `json:"used"`
|
||||
Available int `json:"available"`
|
||||
}
|
||||
|
||||
type networkResources struct {
|
||||
Interfaces []networkInterface `json:"interfaces"`
|
||||
Bandwidth bandwidthInfo `json:"bandwidth"`
|
||||
}
|
||||
|
||||
type networkInterface struct {
|
||||
Name string `json:"name"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
MACAddress string `json:"mac_address"`
|
||||
Speed int `json:"speed"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type bandwidthInfo struct {
|
||||
Inbound int `json:"inbound"`
|
||||
Outbound int `json:"outbound"`
|
||||
}
|
||||
|
||||
type systemLoad struct {
|
||||
Load1M float64 `json:"load_1m"`
|
||||
Load5M float64 `json:"load_5m"`
|
||||
Load15M float64 `json:"load_15m"`
|
||||
}
|
||||
|
||||
type command struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type commandList struct {
|
||||
Commands []command `json:"commands"`
|
||||
}
|
||||
|
||||
type registerResponse struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
agentID, err := register(context.Background(), client, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("register agent: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("containr agent connected: id=%s api=%s", agentID, cfg.APIURL)
|
||||
ticker := time.NewTicker(cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if err := sendHeartbeat(context.Background(), client, cfg, agentID); err != nil {
|
||||
log.Printf("heartbeat failed: %v", err)
|
||||
}
|
||||
if err := processCommands(context.Background(), client, cfg, agentID); err != nil {
|
||||
log.Printf("command poll failed: %v", err)
|
||||
}
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (agentConfig, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
apiURL := strings.TrimRight(firstEnv("CONTAINR_API_URL", "http://localhost:8082"), "/")
|
||||
apiURL = strings.TrimSuffix(strings.TrimSuffix(apiURL, "/api/v1"), "/api")
|
||||
token := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKEN"))
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(os.Getenv("CONTAINR_AGENT_TOKEN"))
|
||||
}
|
||||
if token == "" {
|
||||
return agentConfig{}, errors.New("set CONTAINR_AGENT_AUTH_TOKEN")
|
||||
}
|
||||
|
||||
intervalSeconds, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_INTERVAL_SECONDS", "15"))
|
||||
if intervalSeconds < 5 {
|
||||
intervalSeconds = 5
|
||||
}
|
||||
port, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_PORT", "9090"))
|
||||
|
||||
return agentConfig{
|
||||
APIURL: apiURL,
|
||||
Token: token,
|
||||
Name: firstEnv("CONTAINR_AGENT_NAME", hostname),
|
||||
Hostname: firstEnv("CONTAINR_AGENT_HOSTNAME", hostname),
|
||||
IP: firstEnv("CONTAINR_AGENT_IP", detectOutboundIP()),
|
||||
Port: port,
|
||||
Interval: time.Duration(intervalSeconds) * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func register(ctx context.Context, client *http.Client, cfg agentConfig) (string, error) {
|
||||
payload := map[string]interface{}{
|
||||
"name": cfg.Name,
|
||||
"hostname": cfg.Hostname,
|
||||
"ip_address": cfg.IP,
|
||||
"port": cfg.Port,
|
||||
"capabilities": detectCapabilities(),
|
||||
}
|
||||
var response registerResponse
|
||||
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if response.AgentID == "" {
|
||||
return "", errors.New("register response missing agent_id")
|
||||
}
|
||||
return response.AgentID, nil
|
||||
}
|
||||
|
||||
func sendHeartbeat(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
|
||||
resources := collectResources(cfg.IP)
|
||||
payload := map[string]interface{}{
|
||||
"node_agent_id": agentID,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"status": agentStatus(),
|
||||
"resources": resources,
|
||||
"container_count": dockerContainerCount(),
|
||||
"system_load": readSystemLoad(),
|
||||
"uptime": readSystemUptime(),
|
||||
"version": firstEnv("CONTAINR_AGENT_VERSION", "dev"),
|
||||
}
|
||||
return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil)
|
||||
}
|
||||
|
||||
func processCommands(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/agents/%s/commands", cfg.APIURL, agentID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("command poll status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var list commandList
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range list.Commands {
|
||||
result, runErr := executeCommand(item)
|
||||
status := "completed"
|
||||
errText := ""
|
||||
if runErr != nil {
|
||||
status = "failed"
|
||||
errText = runErr.Error()
|
||||
}
|
||||
payload := map[string]string{
|
||||
"status": status,
|
||||
"result": result,
|
||||
"error": errText,
|
||||
}
|
||||
path := fmt.Sprintf("/api/agents/%s/commands/%s/result", agentID, item.ID)
|
||||
if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil {
|
||||
log.Printf("report command result failed: command=%s err=%v", item.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeCommand(item command) (string, error) {
|
||||
switch item.Type {
|
||||
case "create_container":
|
||||
return createContainer(item.Payload)
|
||||
case "start_container":
|
||||
return runDocker("start", commandTarget(item.Payload))
|
||||
case "stop_container":
|
||||
return runDocker("stop", commandTarget(item.Payload))
|
||||
case "restart_container":
|
||||
return runDocker("restart", commandTarget(item.Payload))
|
||||
case "remove_container":
|
||||
return runDocker("rm", "-f", commandTarget(item.Payload))
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported command type %q", item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func createContainer(payload map[string]interface{}) (string, error) {
|
||||
container, ok := payload["container"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", errors.New("missing container payload")
|
||||
}
|
||||
|
||||
name := stringValue(container["name"])
|
||||
image := stringValue(container["image"])
|
||||
if name == "" || image == "" {
|
||||
return "", errors.New("container name and image are required")
|
||||
}
|
||||
|
||||
args := []string{"run", "-d", "--name", name, "--restart", "unless-stopped"}
|
||||
if env, ok := container["environment"].(map[string]interface{}); ok {
|
||||
for key, value := range env {
|
||||
args = append(args, "-e", fmt.Sprintf("%s=%v", key, value))
|
||||
}
|
||||
}
|
||||
if ports, ok := container["ports"].([]interface{}); ok {
|
||||
for _, rawPort := range ports {
|
||||
port, ok := rawPort.(map[string]interface{})
|
||||
if !ok || !boolValue(port["published"]) {
|
||||
continue
|
||||
}
|
||||
hostPort := intValue(port["host_port"])
|
||||
containerPort := intValue(port["container_port"])
|
||||
protocol := firstString(stringValue(port["protocol"]), "tcp")
|
||||
if hostPort > 0 && containerPort > 0 {
|
||||
args = append(args, "-p", fmt.Sprintf("%d:%d/%s", hostPort, containerPort, protocol))
|
||||
}
|
||||
}
|
||||
}
|
||||
if volumes, ok := container["volumes"].([]interface{}); ok {
|
||||
for _, rawVolume := range volumes {
|
||||
volume, ok := rawVolume.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source := stringValue(volume["source"])
|
||||
target := stringValue(volume["target"])
|
||||
if source != "" && target != "" {
|
||||
mount := source + ":" + target
|
||||
if boolValue(volume["read_only"]) {
|
||||
mount += ":ro"
|
||||
}
|
||||
args = append(args, "-v", mount)
|
||||
}
|
||||
}
|
||||
}
|
||||
args = append(args, image)
|
||||
return runDocker(args...)
|
||||
}
|
||||
|
||||
func commandTarget(payload map[string]interface{}) string {
|
||||
for _, key := range []string{"container_name", "docker_name", "name", "container_id"} {
|
||||
if value := stringValue(payload[key]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func postJSON(ctx context.Context, client *http.Client, cfg agentConfig, path string, payload interface{}, out interface{}) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.APIURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("request %s failed with status %d", path, resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func detectCapabilities() capabilities {
|
||||
runtimes := []string{}
|
||||
if dockerAvailable() {
|
||||
runtimes = append(runtimes, "docker")
|
||||
}
|
||||
return capabilities{
|
||||
ContainerRuntimes: runtimes,
|
||||
SupportedArchitectures: []string{runtime.GOARCH},
|
||||
MaxContainers: 100,
|
||||
StorageDriver: dockerStorageDriver(),
|
||||
NetworkPlugins: []string{"bridge"},
|
||||
Features: []string{"host-metrics", "heartbeats", "command-poll", "docker-lifecycle"},
|
||||
}
|
||||
}
|
||||
|
||||
func collectResources(ip string) nodeResources {
|
||||
mem := readMemory()
|
||||
storage := readStorage("/")
|
||||
return nodeResources{
|
||||
CPU: cpuResources{
|
||||
Cores: runtime.NumCPU(),
|
||||
Allocation: 0,
|
||||
Usage: readCPULoadPercent(),
|
||||
},
|
||||
Memory: mem,
|
||||
Storage: storage,
|
||||
Network: networkResources{
|
||||
Interfaces: []networkInterface{{
|
||||
Name: "primary",
|
||||
IPAddress: ip,
|
||||
Speed: 0,
|
||||
Status: "up",
|
||||
}},
|
||||
Bandwidth: bandwidthInfo{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readMemory() memoryResources {
|
||||
values := map[string]int{}
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return memoryResources{}
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(fields[1])
|
||||
if err == nil {
|
||||
values[strings.TrimSuffix(fields[0], ":")] = value * 1024
|
||||
}
|
||||
}
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
used := total - available
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return memoryResources{Total: total, Used: used, Available: available}
|
||||
}
|
||||
|
||||
func readStorage(path string) storageResources {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return storageResources{}
|
||||
}
|
||||
total := int(stat.Blocks * uint64(stat.Bsize))
|
||||
available := int(stat.Bavail * uint64(stat.Bsize))
|
||||
used := total - available
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return storageResources{Total: total, Used: used, Available: available}
|
||||
}
|
||||
|
||||
func readSystemLoad() systemLoad {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return systemLoad{}
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return systemLoad{}
|
||||
}
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
return systemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
||||
}
|
||||
|
||||
func readSystemUptime() int64 {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
uptime, _ := strconv.ParseFloat(fields[0], 64)
|
||||
return int64(uptime)
|
||||
}
|
||||
|
||||
func readCPULoadPercent() float64 {
|
||||
load := readSystemLoad()
|
||||
cores := runtime.NumCPU()
|
||||
if cores == 0 {
|
||||
return 0
|
||||
}
|
||||
percent := load.Load1M / float64(cores) * 100
|
||||
if percent < 0 {
|
||||
return 0
|
||||
}
|
||||
if percent > 100 {
|
||||
return 100
|
||||
}
|
||||
return percent
|
||||
}
|
||||
|
||||
func runDocker(args ...string) (string, error) {
|
||||
if len(args) == 0 || args[len(args)-1] == "" {
|
||||
return "", errors.New("missing docker target")
|
||||
}
|
||||
cmd := exec.Command("docker", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(output)), err
|
||||
}
|
||||
|
||||
func dockerAvailable() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cmd := exec.Command("docker", "info", "--format", "{{.ServerVersion}}")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func dockerStorageDriver() string {
|
||||
out, err := exec.Command("docker", "info", "--format", "{{.Driver}}").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func dockerContainerCount() int {
|
||||
out, err := exec.Command("docker", "ps", "-q").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func agentStatus() string {
|
||||
if dockerAvailable() {
|
||||
return "online"
|
||||
}
|
||||
return "degraded"
|
||||
}
|
||||
|
||||
func detectOutboundIP() string {
|
||||
conn, err := net.DialTimeout("udp", "8.8.8.8:80", 2*time.Second)
|
||||
if err != nil {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
defer conn.Close()
|
||||
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||
return addr.IP.String()
|
||||
}
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
func firstEnv(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func firstString(value, fallback string) string {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func stringValue(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(value interface{}) int {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
case json.Number:
|
||||
out, _ := typed.Int64()
|
||||
return int(out)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func boolValue(value interface{}) bool {
|
||||
typed, _ := value.(bool)
|
||||
return typed
|
||||
}
|
||||
@@ -48,8 +48,8 @@ func main() {
|
||||
log.Println("AUTO_MIGRATE disabled; skipping startup migrations")
|
||||
}
|
||||
|
||||
// Seed demo data in development (or when explicitly requested).
|
||||
if cfg.IsDevelopment() || cfg.SeedDataOnStart {
|
||||
// Seed demo data only when explicitly requested.
|
||||
if cfg.SeedDataOnStart {
|
||||
if err := db.SeedData(); err != nil {
|
||||
log.Printf("Warning: Failed to seed data: %v", err)
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ type AgentCommand struct {
|
||||
// AgentHeartbeat represents a heartbeat message from an agent
|
||||
type AgentHeartbeat struct {
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
Resources NodeResources `json:"resources"`
|
||||
@@ -193,6 +194,10 @@ type AgentHeartbeatRecord struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (AgentHeartbeatRecord) TableName() string {
|
||||
return "agent_heartbeats"
|
||||
}
|
||||
|
||||
type SystemLoad struct {
|
||||
Load1M float64 `json:"load_1m"`
|
||||
Load5M float64 `json:"load_5m"`
|
||||
@@ -216,7 +221,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
Port int `json:"port" binding:"required"`
|
||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||
AuthToken string `json:"auth_token" binding:"required"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -224,7 +229,8 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidAgentAuthToken(req.AuthToken) {
|
||||
authToken := firstNonEmpty(req.AuthToken, agentAuthTokenFromRequest(c))
|
||||
if !isValidAgentAuthToken(authToken) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
@@ -246,7 +252,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"agent_id": existingAgent.ID,
|
||||
"auth_token": req.AuthToken,
|
||||
"auth_token": authToken,
|
||||
"status": "updated",
|
||||
})
|
||||
return
|
||||
@@ -308,7 +314,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"agent_id": agent.ID,
|
||||
"auth_token": req.AuthToken,
|
||||
"auth_token": authToken,
|
||||
"status": "registered",
|
||||
})
|
||||
}
|
||||
@@ -388,6 +394,11 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authToken := firstNonEmpty(heartbeat.AuthToken, agentAuthTokenFromRequest(c))
|
||||
if !isValidAgentAuthToken(authToken) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
if heartbeat.Timestamp.IsZero() {
|
||||
heartbeat.Timestamp = time.Now()
|
||||
}
|
||||
@@ -436,6 +447,118 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetPendingCommandsForAgent exposes queued commands to token-authenticated node agents.
|
||||
func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
|
||||
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
|
||||
agentID := c.Param("id")
|
||||
var commands []AgentCommand
|
||||
if err := h.db.
|
||||
Where("node_agent_id = ? AND status = ?", agentID, "pending").
|
||||
Order("created_at ASC").
|
||||
Limit(25).
|
||||
Find(&commands).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
||||
}
|
||||
|
||||
// CompleteCommand lets a node agent report command completion or failure.
|
||||
func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
|
||||
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
|
||||
agentID := c.Param("id")
|
||||
commandID := c.Param("commandId")
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Status != "completed" && req.Status != "failed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var command AgentCommand
|
||||
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
command.Status = req.Status
|
||||
command.UpdatedAt = now
|
||||
command.CompletedAt = &now
|
||||
if req.Result != "" {
|
||||
command.Result = &req.Result
|
||||
}
|
||||
if req.Error != "" {
|
||||
command.Error = &req.Error
|
||||
}
|
||||
|
||||
if err := h.db.Save(&command).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"})
|
||||
return
|
||||
}
|
||||
|
||||
if command.ContainerID != nil {
|
||||
h.updateContainerStatusAfterCommand(command)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"command": command})
|
||||
}
|
||||
|
||||
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) {
|
||||
var container ContainerInstance
|
||||
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if command.Status == "failed" {
|
||||
errorMessage := ""
|
||||
if command.Error != nil {
|
||||
errorMessage = *command.Error
|
||||
}
|
||||
container.Status.State = "error"
|
||||
container.Status.Error = &errorMessage
|
||||
_ = h.db.Save(&container).Error
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch command.Type {
|
||||
case "create_container", "start_container", "restart_container":
|
||||
container.Status.State = "running"
|
||||
container.Status.Health = "unknown"
|
||||
container.Status.StartedAt = &now
|
||||
container.Status.Error = nil
|
||||
case "stop_container":
|
||||
container.Status.State = "stopped"
|
||||
container.Status.FinishedAt = &now
|
||||
case "remove_container":
|
||||
container.Status.State = "removed"
|
||||
container.Status.FinishedAt = &now
|
||||
}
|
||||
container.UpdatedAt = now
|
||||
_ = h.db.Save(&container).Error
|
||||
}
|
||||
|
||||
// GetAgentContainers returns containers running on a specific agent
|
||||
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
|
||||
agentID := c.Param("id")
|
||||
@@ -635,6 +758,8 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
|
||||
ContainerID: &container.ID,
|
||||
Payload: map[string]interface{}{
|
||||
"container_id": containerID,
|
||||
"container_name": container.Name,
|
||||
"docker_name": container.Name,
|
||||
},
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
@@ -757,6 +882,19 @@ func isValidAgentAuthToken(token string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func agentAuthTokenFromRequest(c *gin.Context) string {
|
||||
if token := strings.TrimSpace(c.GetHeader("X-Containr-Agent-Token")); token != "" {
|
||||
return token
|
||||
}
|
||||
if authHeader := strings.TrimSpace(c.GetHeader("Authorization")); authHeader != "" {
|
||||
const bearerPrefix = "Bearer "
|
||||
if strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.Query("auth_token"))
|
||||
}
|
||||
|
||||
func configuredAgentAuthTokens() []string {
|
||||
candidateCSV := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKENS"))
|
||||
if candidateCSV != "" {
|
||||
@@ -806,16 +944,25 @@ func maxInt(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// SetupRoutes registers the agent routes
|
||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
||||
// SetupPublicRoutes registers token-authenticated agent ingestion routes.
|
||||
func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
|
||||
agents := router.Group("/agents")
|
||||
{
|
||||
agents.POST("/register", h.RegisterAgent)
|
||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
||||
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRoutes registers authenticated dashboard/control agent routes.
|
||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
||||
agents := router.Group("/agents")
|
||||
{
|
||||
agents.GET("", h.GetAgents)
|
||||
agents.GET("/:id", h.GetAgent)
|
||||
agents.PUT("/:id", h.UpdateAgent)
|
||||
agents.DELETE("/:id", h.DeleteAgent)
|
||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||
|
||||
agents.GET("/:id/containers", h.GetAgentContainers)
|
||||
agents.POST("/:id/containers", h.CreateContainer)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"containr/internal/database"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -18,10 +26,16 @@ type LoginRequest struct {
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
}
|
||||
|
||||
type ManualUserCreateRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User interface{} `json:"user"`
|
||||
@@ -55,18 +69,29 @@ func handleLogin(c *gin.Context) {
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err = createLocalUserFromBetterAuth(db, authUser)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create local user"})
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
} else {
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
||||
if _, ok := verifyBetterAuthCredentials(c, req.Email, req.Password); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := generateJWT(user.ID, user.Email, jwtSecret)
|
||||
@@ -81,6 +106,154 @@ func handleLogin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleAuthBootstrap(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to inspect auth bootstrap state"})
|
||||
return
|
||||
}
|
||||
|
||||
mode := "login"
|
||||
if count == 0 {
|
||||
mode = "register"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"has_users": count > 0,
|
||||
"user_count": count,
|
||||
"mode": mode,
|
||||
})
|
||||
}
|
||||
|
||||
type betterAuthLoginUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type betterAuthLoginResponse struct {
|
||||
User betterAuthLoginUser `json:"user"`
|
||||
}
|
||||
|
||||
func verifyBetterAuthCredentials(c *gin.Context, email string, password string) (betterAuthLoginUser, bool) {
|
||||
endpoint, err := betterAuthSignInURL()
|
||||
if err != nil {
|
||||
log.Printf("Better Auth fallback disabled: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
body, err := json.Marshal(gin.H{
|
||||
"email": strings.TrimSpace(email),
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
log.Printf("Better Auth fallback failed: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
var payload betterAuthLoginResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
log.Printf("Better Auth fallback response decode failed: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
payload.User.Email = strings.ToLower(strings.TrimSpace(firstNonEmpty(payload.User.Email, email)))
|
||||
payload.User.Name = strings.TrimSpace(payload.User.Name)
|
||||
if payload.User.Name == "" {
|
||||
payload.User.Name = "Containr User"
|
||||
}
|
||||
|
||||
if payload.User.Email == "" {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
return payload.User, true
|
||||
}
|
||||
|
||||
func betterAuthSignInURL() (string, error) {
|
||||
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||
if rawTarget == "" {
|
||||
rawTarget = "http://127.0.0.1:3001"
|
||||
}
|
||||
|
||||
target, err := parseAuthProxyTarget(rawTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-in/email"}).String(), nil
|
||||
}
|
||||
|
||||
func betterAuthSignUpURL() (string, error) {
|
||||
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||
if rawTarget == "" {
|
||||
rawTarget = "http://127.0.0.1:3001"
|
||||
}
|
||||
|
||||
target, err := parseAuthProxyTarget(rawTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-up/email"}).String(), nil
|
||||
}
|
||||
|
||||
func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser) (User, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(authUser.Email))
|
||||
if email == "" {
|
||||
return User{}, errors.New("email is required")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(authUser.Name)
|
||||
if name == "" {
|
||||
name = "Containr User"
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||
updated_at = NOW()
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)).
|
||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func handleRegister(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -91,9 +264,19 @@ func handleRegister(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||
|
||||
// Check if user already exists
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&count)
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
return
|
||||
}
|
||||
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
@@ -137,6 +320,121 @@ func handleRegister(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleCreateUser(c *gin.Context) {
|
||||
var req ManualUserCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Email == "" || req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Email and name are required"})
|
||||
return
|
||||
}
|
||||
|
||||
var existing int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
if existing > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := createBetterAuthUser(c, req.Name, req.Email, req.Password); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, errBetterAuthUserExists) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"user": user})
|
||||
}
|
||||
|
||||
var errBetterAuthUserExists = errors.New("User already exists")
|
||||
|
||||
func createBetterAuthUser(c *gin.Context, name string, email string, password string) error {
|
||||
endpoint, err := betterAuthSignUpURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(gin.H{
|
||||
"name": name,
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
|
||||
request.Header.Set("X-Containr-Auth-Internal", token)
|
||||
}
|
||||
|
||||
response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
_ = json.NewDecoder(response.Body).Decode(&payload)
|
||||
message := "Failed to create auth user"
|
||||
if rawMessage, ok := payload["message"].(string); ok && strings.TrimSpace(rawMessage) != "" {
|
||||
message = rawMessage
|
||||
} else if rawError, ok := payload["error"].(string); ok && strings.TrimSpace(rawError) != "" {
|
||||
message = rawError
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusConflict || strings.Contains(strings.ToLower(message), "already") {
|
||||
return errBetterAuthUserExists
|
||||
}
|
||||
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
func countLocalUsers(db *database.DB) (int, error) {
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func handleGetProfile(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config, db *database.DB) {
|
||||
targetURL, err := parseAuthProxyTarget(cfg.BetterAuthProxyURL)
|
||||
if err != nil {
|
||||
log.Printf("Warning: auth proxy disabled: %v", err)
|
||||
@@ -28,6 +29,12 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
}
|
||||
|
||||
handler := func(c *gin.Context) {
|
||||
if shouldBlockPublicBetterAuthSignup(c, cfg, db) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
}
|
||||
@@ -36,6 +43,33 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
router.Any("/api/auth/*proxyPath", handler)
|
||||
}
|
||||
|
||||
func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *database.DB) bool {
|
||||
if db == nil || db.DB == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.EqualFold(c.Request.Method, http.MethodPost) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(c.Request.URL.Path, "/sign-up/email") {
|
||||
return false
|
||||
}
|
||||
|
||||
internalToken := strings.TrimSpace(cfg.BetterAuthInternalToken)
|
||||
if internalToken != "" && c.GetHeader("X-Containr-Auth-Internal") == internalToken {
|
||||
return false
|
||||
}
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
log.Printf("Failed to inspect signup bootstrap state: %v", err)
|
||||
return true
|
||||
}
|
||||
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||
trimmed := strings.TrimSpace(rawTarget)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -1306,3 +1306,208 @@ func generateWebhookSecret() (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// handleDeleteGitProvider removes a provider connection. Repositories and
|
||||
// webhooks are removed by ON DELETE CASCADE.
|
||||
func handleDeleteGitProvider(c *gin.Context) {
|
||||
ctx, ok := requireGitRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||
if _, err := uuid.Parse(providerID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := ctx.db.Exec(
|
||||
"DELETE FROM git_providers WHERE id = $1 AND user_id = $2",
|
||||
providerID, ctx.userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
|
||||
return
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Provider disconnected"})
|
||||
}
|
||||
|
||||
// handleGetGitRepositoryBranches lists branches for a repository through the
|
||||
// provider's stored credentials.
|
||||
func handleGetGitRepositoryBranches(c *gin.Context) {
|
||||
ctx, ok := requireGitRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||
if _, err := uuid.Parse(providerID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
owner := strings.TrimSpace(c.Param("owner"))
|
||||
repo := strings.TrimSpace(c.Param("repo"))
|
||||
if owner == "" || repo == "" || strings.ContainsAny(owner+repo, " \t\r\n") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository reference"})
|
||||
return
|
||||
}
|
||||
|
||||
var providerName, providerAPIURL, providerAccessToken string
|
||||
err := ctx.db.QueryRow(`
|
||||
SELECT name, api_url, access_token
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo)
|
||||
if err != nil {
|
||||
log.Printf("branch fetch failed for %s/%s via %s: %v", owner, repo, providerName, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch branches from provider"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"branches": branches})
|
||||
}
|
||||
|
||||
type remoteBranch struct {
|
||||
Name string `json:"name"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
|
||||
func fetchRepositoryBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(providerName)) {
|
||||
case "github", "github_app":
|
||||
return fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo)
|
||||
case "gitea":
|
||||
return fetchGiteaBranches(providerAPIURL, accessToken, owner, repo)
|
||||
default:
|
||||
return nil, fmt.Errorf("branch listing is not supported for provider %s", providerName)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.github.com"
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(accessToken)
|
||||
if strings.EqualFold(providerName, "github_app") {
|
||||
installationID, err := strconv.ParseInt(token, 10, 64)
|
||||
if err != nil || installationID <= 0 {
|
||||
return nil, fmt.Errorf("github_app installation id is invalid")
|
||||
}
|
||||
token, err = getGitHubAppInstallationToken(baseURL, installationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("github access token is missing")
|
||||
}
|
||||
|
||||
branches := make([]remoteBranch, 0)
|
||||
for page := 1; page <= 5; page++ {
|
||||
request, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", baseURL, owner, repo, page), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/vnd.github+json")
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
response.Body.Close()
|
||||
return nil, fmt.Errorf("github branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
decodeErr := json.NewDecoder(response.Body).Decode(&rows)
|
||||
response.Body.Close()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
name := asGitString(row["name"])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||
}
|
||||
|
||||
if len(rows) < 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
func fetchGiteaBranches(providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "")), "/")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("gitea api_url is required")
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?limit=100", baseURL, owner, repo), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if token := strings.TrimSpace(accessToken); token != "" {
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
return nil, fmt.Errorf("gitea branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branches := make([]remoteBranch, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if name := asGitString(row["name"]); name != "" {
|
||||
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||
}
|
||||
}
|
||||
return branches, nil
|
||||
}
|
||||
@@ -64,11 +64,15 @@ func handleGetLogs(c *gin.Context) {
|
||||
|
||||
dockerClient, exists := c.Get("docker_client")
|
||||
if !exists || dockerClient == nil {
|
||||
respondDependencyUnavailable(c, "docker", "Container log streaming is unavailable because Docker is not configured.")
|
||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||
return
|
||||
}
|
||||
|
||||
client := dockerClient.(*docker.Client)
|
||||
client, ok := dockerClient.(*docker.Client)
|
||||
if !ok || client == nil {
|
||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||
return
|
||||
}
|
||||
containerName := fmt.Sprintf("containr-%s", serviceID)
|
||||
|
||||
logOpts := docker.LogOptions{
|
||||
@@ -82,6 +86,10 @@ func handleGetLogs(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
||||
if err != nil {
|
||||
if isDockerNotFoundError(err) {
|
||||
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||
return
|
||||
}
|
||||
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
||||
return
|
||||
}
|
||||
@@ -156,6 +164,13 @@ func handleGetLogs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
|
||||
}
|
||||
|
||||
func respondEmptyServiceLogs(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": []LogEntry{},
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetDeploymentLogs(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ServiceMetricsResponse is the aggregated live telemetry for a service,
|
||||
// sourced from Docker container stats for containers named
|
||||
// containr-<serviceID>-<deploymentID>-<index>.
|
||||
type ServiceMetricsResponse struct {
|
||||
ServiceID string `json:"service_id"`
|
||||
Status string `json:"status"` // ok, no_containers, docker_unavailable
|
||||
Instances []ServiceInstanceMetrics `json:"instances"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// ServiceInstanceMetrics holds live stats for one running container.
|
||||
type ServiceInstanceMetrics struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// handleGetServiceMetrics returns real resource usage for the service's
|
||||
// Docker containers. Empty result when the service has never deployed.
|
||||
func handleGetServiceMetrics(c *gin.Context) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
db := dbValue.(*database.DB)
|
||||
|
||||
serviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
var status string
|
||||
err = db.QueryRow(
|
||||
`SELECT p.owner_id, COALESCE(s.status, '')
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerID, &status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
if ownerID != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
response := ServiceMetricsResponse{
|
||||
ServiceID: serviceID.String(),
|
||||
Status: "ok",
|
||||
Instances: []ServiceInstanceMetrics{},
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
|
||||
dockerValue, exists := c.Get("docker_client")
|
||||
if !exists || dockerValue == nil {
|
||||
response.Status = "docker_unavailable"
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
return
|
||||
}
|
||||
dockerClient := dockerValue.(*docker.Client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
containers, err := dockerClient.ListContainers(ctx, false)
|
||||
if err != nil {
|
||||
response.Status = "docker_unavailable"
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
return
|
||||
}
|
||||
|
||||
namePrefix := fmt.Sprintf("containr-%s-", serviceID.String())
|
||||
matched := 0
|
||||
for _, ctr := range containers {
|
||||
name := ""
|
||||
for _, n := range ctr.Names {
|
||||
name = strings.TrimPrefix(n, "/")
|
||||
if strings.HasPrefix(name, namePrefix) {
|
||||
break
|
||||
}
|
||||
name = ""
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
|
||||
instance := ServiceInstanceMetrics{
|
||||
ContainerID: ctr.ID,
|
||||
Name: name,
|
||||
State: ctr.State,
|
||||
}
|
||||
|
||||
if info, err := dockerClient.GetContainer(ctx, ctr.ID); err == nil {
|
||||
if info.State != nil && info.State.StartedAt != "" {
|
||||
if parsed, perr := time.Parse(time.RFC3339Nano, info.State.StartedAt); perr == nil {
|
||||
instance.StartedAt = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ctr.State == "running" {
|
||||
if stats, err := readContainerStats(ctx, dockerClient, ctr.ID); err == nil {
|
||||
instance.CPUPercent = stats.cpuPercent
|
||||
instance.MemoryUsageBytes = stats.memoryUsage
|
||||
instance.MemoryLimitBytes = stats.memoryLimit
|
||||
instance.NetworkRxBytes = stats.networkRx
|
||||
instance.NetworkTxBytes = stats.networkTx
|
||||
}
|
||||
}
|
||||
|
||||
response.Instances = append(response.Instances, instance)
|
||||
response.CPUPercent += instance.CPUPercent
|
||||
response.MemoryUsageBytes += instance.MemoryUsageBytes
|
||||
response.MemoryLimitBytes += instance.MemoryLimitBytes
|
||||
response.NetworkRxBytes += instance.NetworkRxBytes
|
||||
response.NetworkTxBytes += instance.NetworkTxBytes
|
||||
}
|
||||
|
||||
if matched == 0 {
|
||||
response.Status = "no_containers"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
}
|
||||
|
||||
type containerStatsSnapshot struct {
|
||||
cpuPercent float64
|
||||
memoryUsage uint64
|
||||
memoryLimit uint64
|
||||
networkRx uint64
|
||||
networkTx uint64
|
||||
}
|
||||
|
||||
func readContainerStats(ctx context.Context, client *docker.Client, containerID string) (*containerStatsSnapshot, error) {
|
||||
reader, err := client.GetContainerStats(ctx, containerID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Body.Close()
|
||||
|
||||
var stats containertypes.StatsResponse
|
||||
if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot := &containerStatsSnapshot{
|
||||
memoryUsage: stats.MemoryStats.Usage,
|
||||
memoryLimit: stats.MemoryStats.Limit,
|
||||
}
|
||||
|
||||
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage)
|
||||
systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage)
|
||||
onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
|
||||
if onlineCPUs == 0 {
|
||||
onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
|
||||
}
|
||||
if cpuDelta > 0 && systemDelta > 0 && onlineCPUs > 0 {
|
||||
snapshot.cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100
|
||||
}
|
||||
|
||||
for _, net := range stats.Networks {
|
||||
snapshot.networkRx += net.RxBytes
|
||||
snapshot.networkTx += net.TxBytes
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
// Expose Better Auth through backend so frontend can use a single backend origin.
|
||||
setupAuthProxyRoutes(router, cfg)
|
||||
setupAuthProxyRoutes(router, cfg, db)
|
||||
|
||||
// Initialize Docker client (non-fatal if it fails)
|
||||
var dockerClient *docker.Client
|
||||
@@ -158,11 +158,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
router.HEAD("/ready", healthHandler)
|
||||
|
||||
// API v1 routes
|
||||
publicAgents := router.Group("/api")
|
||||
agentHandler.SetupPublicRoutes(publicAgents)
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
// Public routes (no authentication required)
|
||||
public := v1.Group("/")
|
||||
{
|
||||
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
||||
public.POST("/auth/login", handleLogin)
|
||||
public.POST("/auth/register", handleRegister)
|
||||
}
|
||||
@@ -174,6 +178,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// User routes
|
||||
protected.GET("/user/profile", handleGetProfile)
|
||||
protected.PUT("/user/profile", handleUpdateProfile)
|
||||
protected.POST("/users", handleCreateUser)
|
||||
|
||||
// Project routes
|
||||
protected.GET("/projects", handleGetProjects)
|
||||
@@ -192,6 +197,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.GET("/services/:id", handleGetService)
|
||||
protected.PUT("/services/:id", handleUpdateService)
|
||||
protected.DELETE("/services/:id", handleDeleteService)
|
||||
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||
|
||||
// Deployment routes
|
||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||
@@ -212,7 +218,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||
protected.GET("/git/providers", handleGetGitProviders)
|
||||
protected.POST("/git/providers", handleCreateGitProvider)
|
||||
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
|
||||
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
|
||||
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
|
||||
protected.POST("/git/repositories/connect", handleConnectGitRepository)
|
||||
protected.GET("/git/repositories", handleGetConnectedRepositories)
|
||||
protected.POST("/git/webhooks", handleCreateWebhook)
|
||||
@@ -226,6 +234,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||
protected.GET("/builds/detect", buildHandler.DetectBuildType)
|
||||
|
||||
// System routes
|
||||
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
|
||||
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
|
||||
protected.GET("/system/host", handleGetHostMonitoring)
|
||||
|
||||
// Scaling routes
|
||||
scalingHandler.RegisterRoutes(protected)
|
||||
haAPIManager.RegisterRoutes(protected)
|
||||
@@ -241,9 +254,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||
|
||||
// Node Agent routes
|
||||
api := router.Group("/api")
|
||||
api.Use(middleware.Auth(cfg.JWTSecret))
|
||||
agentHandler.SetupRoutes(api)
|
||||
agentHandler.SetupRoutes(protected)
|
||||
|
||||
// Preview Environments routes
|
||||
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
|
||||
@@ -99,8 +99,18 @@ func handleGetServices(c *gin.Context) {
|
||||
|
||||
// Get services for the project
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, project_id, name, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at
|
||||
`SELECT id, project_id, name,
|
||||
COALESCE(type, service_type, ''),
|
||||
COALESCE(status, ''),
|
||||
COALESCE(image, image_name, ''),
|
||||
COALESCE(command, start_command, ''),
|
||||
COALESCE(environment, ''),
|
||||
COALESCE(git_repo, source_url, ''),
|
||||
COALESCE(git_branch, ''),
|
||||
COALESCE(build_path, ''),
|
||||
COALESCE(cpu, ''),
|
||||
COALESCE(memory, ''),
|
||||
created_at, updated_at
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
@@ -228,15 +238,25 @@ func handleCreateService(c *gin.Context) {
|
||||
service.Memory = "512Mi"
|
||||
}
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||
return
|
||||
}
|
||||
|
||||
sourceType := inferServiceSourceType(service)
|
||||
|
||||
// Insert service into database
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO services
|
||||
(id, project_id, name, type, status, image, command, environment,
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
service.ID, service.ProjectID, service.Name, service.Type, service.Status,
|
||||
service.Image, service.Command, service.Environment, service.GitRepo,
|
||||
service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -248,6 +268,26 @@ func handleCreateService(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||
}
|
||||
|
||||
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
|
||||
var environmentID uuid.UUID
|
||||
err := db.QueryRow(
|
||||
"SELECT id FROM environments WHERE project_id = $1 AND name = $2",
|
||||
projectID,
|
||||
environment,
|
||||
).Scan(&environmentID)
|
||||
return environmentID, err
|
||||
}
|
||||
|
||||
func inferServiceSourceType(service Service) string {
|
||||
if service.GitRepo != "" {
|
||||
return "github"
|
||||
}
|
||||
if service.Image != "" {
|
||||
return "image"
|
||||
}
|
||||
return "dockerfile"
|
||||
}
|
||||
|
||||
// handleGetService retrieves a specific service
|
||||
func handleGetService(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
@@ -273,8 +313,17 @@ func handleGetService(c *gin.Context) {
|
||||
// Get service with project ownership check
|
||||
var service Service
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''),
|
||||
COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''),
|
||||
COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''),
|
||||
COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''),
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -326,8 +375,17 @@ func handleUpdateService(c *gin.Context) {
|
||||
// Check if service exists and user has access
|
||||
var existingService Service
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''),
|
||||
COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''),
|
||||
COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''),
|
||||
COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''),
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type upgradeStatusResponse struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
Registry string `json:"registry"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
Installed bool `json:"installed"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
AuthConfigured bool `json:"auth_configured"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type hostMonitoringResponse struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
CPU hostCPU `json:"cpu"`
|
||||
Memory hostMemory `json:"memory"`
|
||||
Storage hostStorage `json:"storage"`
|
||||
Load SystemLoad `json:"load"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
Docker map[string]any `json:"docker,omitempty"`
|
||||
CollectedAt string `json:"collected_at"`
|
||||
}
|
||||
|
||||
type hostCPU struct {
|
||||
Cores int `json:"cores"`
|
||||
}
|
||||
|
||||
type hostMemory struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
UsagePct float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
type hostStorage struct {
|
||||
Path string `json:"path"`
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
UsagePct float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
func handleGetUpgradeStatus(c *gin.Context) {
|
||||
imageRef := configuredUpgradeImageRef()
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
client, _ := dockerClient.(*docker.Client)
|
||||
|
||||
response := upgradeStatusResponse{
|
||||
ImageRef: imageRef,
|
||||
Registry: registryHost(imageRef),
|
||||
DockerAvailable: client != nil,
|
||||
AuthConfigured: upgradeRegistryAuthConfigured(),
|
||||
Message: "Upgrade image auto-detected",
|
||||
}
|
||||
|
||||
if imageRef == "" {
|
||||
response.Message = "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF to enable image pulls"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
response.Message = "Docker client unavailable"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
info, err := client.GetImageInfo(ctx, imageRef)
|
||||
if err != nil {
|
||||
response.Message = "Image not installed locally"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
response.Installed = true
|
||||
response.Digest = info.Digest
|
||||
response.Size = info.Size
|
||||
response.Message = "Image installed locally"
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handlePullUpgradeImage(c *gin.Context) {
|
||||
imageRef := configuredUpgradeImageRef()
|
||||
if imageRef == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF before pulling"})
|
||||
return
|
||||
}
|
||||
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
client, ok := dockerClient.(*docker.Client)
|
||||
if !ok || client == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker client unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
reader, err := client.PullImage(ctx, imageRef, upgradeRegistryAuth())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
|
||||
info, infoErr := client.GetImageInfo(ctx, imageRef)
|
||||
response := gin.H{
|
||||
"image_ref": imageRef,
|
||||
"registry": registryHost(imageRef),
|
||||
"message": "Image pull completed",
|
||||
}
|
||||
if infoErr == nil {
|
||||
response["digest"] = info.Digest
|
||||
response["size"] = info.Size
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handleGetHostMonitoring(c *gin.Context) {
|
||||
hostname, _ := os.Hostname()
|
||||
memory := readHostMemory()
|
||||
storage := readHostStorage("/")
|
||||
load := readSystemLoad()
|
||||
uptime := readSystemUptime()
|
||||
|
||||
response := hostMonitoringResponse{
|
||||
Hostname: hostname,
|
||||
OS: runtime.GOOS,
|
||||
Architecture: runtime.GOARCH,
|
||||
CPU: hostCPU{Cores: runtime.NumCPU()},
|
||||
Memory: memory,
|
||||
Storage: storage,
|
||||
Load: load,
|
||||
UptimeSeconds: uptime,
|
||||
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if dockerClient, ok := c.Get("docker_client"); ok {
|
||||
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if info, err := client.GetSystemInfo(ctx); err == nil {
|
||||
response.DockerAvailable = true
|
||||
response.Docker = map[string]any{
|
||||
"containers": info.Containers,
|
||||
"images": info.Images,
|
||||
"driver": info.Driver,
|
||||
"server": info.ServerVersion,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func configuredUpgradeImageRef() string {
|
||||
for _, key := range []string{"CONTAINR_UPGRADE_IMAGE_REF", "CONTAINR_IMAGE_REF", "DOCKER_IMAGE_REF"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func registryHost(imageRef string) string {
|
||||
parts := strings.Split(imageRef, "/")
|
||||
if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
return parts[0]
|
||||
}
|
||||
if strings.HasPrefix(imageRef, "ghcr.io/") {
|
||||
return "ghcr.io"
|
||||
}
|
||||
return "docker.io"
|
||||
}
|
||||
|
||||
func upgradeRegistryAuthConfigured() bool {
|
||||
auth := upgradeRegistryAuth()
|
||||
return strings.TrimSpace(auth.Username) != "" || strings.TrimSpace(auth.Password) != "" || strings.TrimSpace(auth.Auth) != ""
|
||||
}
|
||||
|
||||
func upgradeRegistryAuth() registry.AuthConfig {
|
||||
username := firstConfigured("GITHUB_CONTAINER_REGISTRY_USER", "GHCR_USERNAME", "DOCKER_REGISTRY_USERNAME")
|
||||
password := firstConfigured("GITHUB_CONTAINER_REGISTRY_TOKEN", "GHCR_TOKEN", "DOCKER_REGISTRY_PASSWORD")
|
||||
return registry.AuthConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func firstConfigured(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readHostMemory() hostMemory {
|
||||
values := map[string]uint64{}
|
||||
file, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return hostMemory{}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
value, parseErr := strconv.ParseUint(fields[1], 10, 64)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
values[key] = value * 1024
|
||||
}
|
||||
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
used := uint64(0)
|
||||
if total > available {
|
||||
used = total - available
|
||||
}
|
||||
|
||||
usage := 0.0
|
||||
if total > 0 {
|
||||
usage = float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
return hostMemory{
|
||||
Total: total,
|
||||
Used: used,
|
||||
Available: available,
|
||||
UsagePct: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func readHostStorage(path string) hostStorage {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return hostStorage{Path: path}
|
||||
}
|
||||
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
available := stat.Bavail * uint64(stat.Bsize)
|
||||
used := total - available
|
||||
usage := 0.0
|
||||
if total > 0 {
|
||||
usage = float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
return hostStorage{
|
||||
Path: path,
|
||||
Total: total,
|
||||
Used: used,
|
||||
Available: available,
|
||||
UsagePct: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func readSystemLoad() SystemLoad {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return SystemLoad{}
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return SystemLoad{}
|
||||
}
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
return SystemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
||||
}
|
||||
|
||||
func readSystemUptime() int64 {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
uptime, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(uptime)
|
||||
}
|
||||
@@ -279,9 +279,16 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
serviceImage := resolveTemplateRuntimeImage(config.Runtime)
|
||||
serviceCommand := strings.TrimSpace(config.StartCommand)
|
||||
buildCommand := strings.TrimSpace(config.BuildCommand)
|
||||
cpu, memory := defaultTemplateResources(serviceType)
|
||||
serviceEnvironment := "production"
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db, projectID, serviceEnvironment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
@@ -297,10 +304,16 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
ID: serviceID,
|
||||
ProjectID: projectID,
|
||||
Name: req.Name,
|
||||
EnvironmentID: environmentID,
|
||||
ServiceType: serviceType,
|
||||
SourceType: "template",
|
||||
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
|
||||
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||
Type: serviceType,
|
||||
Status: "stopped",
|
||||
Image: sql.NullString{String: serviceImage, Valid: true},
|
||||
Command: sql.NullString{String: serviceCommand, Valid: true},
|
||||
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||
Cpu: sql.NullString{String: cpu, Valid: true},
|
||||
Memory: sql.NullString{String: memory, Valid: true},
|
||||
|
||||
@@ -146,6 +146,9 @@ func Load() *Config {
|
||||
cfg.CORSMethods = getenvSlice("CORS_METHODS", []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"})
|
||||
cfg.CORSHeaders = getenvSlice("CORS_HEADERS", []string{"Origin", "Content-Type", "Accept", "Authorization"})
|
||||
cfg.CORSCredentials = getenvBool("CORS_CREDENTIALS", true)
|
||||
if cfg.IsDevelopment() {
|
||||
cfg.CORSOrigins = expandDevelopmentCORSOrigins(cfg.CORSOrigins)
|
||||
}
|
||||
|
||||
cfg.APIKeyHeader = getenv("API_KEY_HEADER", "X-API-Key")
|
||||
cfg.ServiceTokenHeader = getenv("SERVICE_TOKEN_HEADER", "X-Service-Token")
|
||||
@@ -331,6 +334,57 @@ func getenvSliceWithAliases(keys []string, defaultValue []string) []string {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func expandDevelopmentCORSOrigins(origins []string) []string {
|
||||
hasLocalOrigin := false
|
||||
seen := make(map[string]struct{}, len(origins))
|
||||
expanded := make([]string, 0, len(origins)+12)
|
||||
|
||||
for _, origin := range origins {
|
||||
trimmed := strings.TrimSpace(origin)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
expanded = append(expanded, trimmed)
|
||||
|
||||
if strings.HasPrefix(trimmed, "http://localhost") ||
|
||||
strings.HasPrefix(trimmed, "http://127.0.0.1") ||
|
||||
strings.HasPrefix(trimmed, "http://0.0.0.0") {
|
||||
hasLocalOrigin = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasLocalOrigin {
|
||||
return expanded
|
||||
}
|
||||
|
||||
for _, origin := range []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
"http://localhost:5175",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:5174",
|
||||
"http://127.0.0.1:5175",
|
||||
"http://0.0.0.0:3000",
|
||||
"http://0.0.0.0:5173",
|
||||
"http://0.0.0.0:5174",
|
||||
"http://0.0.0.0:5175",
|
||||
} {
|
||||
if _, ok := seen[origin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[origin] = struct{}{}
|
||||
expanded = append(expanded, origin)
|
||||
}
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
// encodeDatabasePassword URL-encodes the password in a database connection string
|
||||
func encodeDatabasePassword(databaseURL string) string {
|
||||
// Find the password part between :@ and encode it
|
||||
|
||||
@@ -12,9 +12,14 @@ func TestLoadUsesCORSAllowedOriginsAlias(t *testing.T) {
|
||||
t.Setenv("CORS_ALLOWED_ORIGINS", "http://localhost:3000,https://app.example.com")
|
||||
|
||||
cfg := Load()
|
||||
want := []string{"http://localhost:3000", "https://app.example.com"}
|
||||
if !reflect.DeepEqual(cfg.CORSOrigins, want) {
|
||||
t.Fatalf("unexpected CORS origins: got %v want %v", cfg.CORSOrigins, want)
|
||||
if !containsAll(cfg.CORSOrigins, []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
"https://app.example.com",
|
||||
}) {
|
||||
t.Fatalf("unexpected CORS origins: got %v", cfg.CORSOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,3 +185,27 @@ func TestLoadUsesNewStartupDefaults(t *testing.T) {
|
||||
t.Fatalf("expected positive MAX_REQUEST_BODY_BYTES, got %d", cfg.MaxRequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDoesNotExpandNonLocalDevelopmentCORSOrigins(t *testing.T) {
|
||||
t.Setenv("ENVIRONMENT", "development")
|
||||
t.Setenv("CORS_ORIGINS", "https://preferred.example.com")
|
||||
|
||||
cfg := Load()
|
||||
want := []string{"https://preferred.example.com"}
|
||||
if !reflect.DeepEqual(cfg.CORSOrigins, want) {
|
||||
t.Fatalf("unexpected CORS origins: got %v want %v", cfg.CORSOrigins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(haystack []string, needles []string) bool {
|
||||
seen := make(map[string]struct{}, len(haystack))
|
||||
for _, item := range haystack {
|
||||
seen[item] = struct{}{}
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if _, ok := seen[needle]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -31,14 +31,61 @@ func (q *Queries) CountServicesByProjectAndName(ctx context.Context, arg CountSe
|
||||
}
|
||||
|
||||
const createServiceFromTemplate = `-- name: CreateServiceFromTemplate :exec
|
||||
INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
INSERT INTO services (
|
||||
id,
|
||||
project_id,
|
||||
name,
|
||||
environment_id,
|
||||
service_type,
|
||||
source_type,
|
||||
source_url,
|
||||
image_name,
|
||||
build_command,
|
||||
start_command,
|
||||
type,
|
||||
status,
|
||||
image,
|
||||
command,
|
||||
environment,
|
||||
cpu,
|
||||
memory,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12,
|
||||
$13,
|
||||
$14,
|
||||
$15,
|
||||
$16,
|
||||
$17,
|
||||
$18,
|
||||
$19
|
||||
)
|
||||
`
|
||||
|
||||
type CreateServiceFromTemplateParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||
ServiceType string `json:"service_type"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceUrl sql.NullString `json:"source_url"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
BuildCommand sql.NullString `json:"build_command"`
|
||||
StartCommand sql.NullString `json:"start_command"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Image sql.NullString `json:"image"`
|
||||
@@ -55,6 +102,13 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.EnvironmentID,
|
||||
arg.ServiceType,
|
||||
arg.SourceType,
|
||||
arg.SourceUrl,
|
||||
arg.ImageName,
|
||||
arg.BuildCommand,
|
||||
arg.StartCommand,
|
||||
arg.Type,
|
||||
arg.Status,
|
||||
arg.Image,
|
||||
|
||||
@@ -25,8 +25,48 @@ FROM services
|
||||
WHERE project_id = $1 AND name = $2;
|
||||
|
||||
-- name: CreateServiceFromTemplate :exec
|
||||
INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
|
||||
INSERT INTO services (
|
||||
id,
|
||||
project_id,
|
||||
name,
|
||||
environment_id,
|
||||
service_type,
|
||||
source_type,
|
||||
source_url,
|
||||
image_name,
|
||||
build_command,
|
||||
start_command,
|
||||
type,
|
||||
status,
|
||||
image,
|
||||
command,
|
||||
environment,
|
||||
cpu,
|
||||
memory,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12,
|
||||
$13,
|
||||
$14,
|
||||
$15,
|
||||
$16,
|
||||
$17,
|
||||
$18,
|
||||
$19
|
||||
);
|
||||
|
||||
-- name: UpsertEnvironmentVariable :exec
|
||||
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
|
||||
Generated
+15
-67
@@ -526,7 +526,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -543,7 +542,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -560,7 +558,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -577,7 +574,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -594,7 +590,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -611,7 +606,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -628,7 +622,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -645,7 +638,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -662,7 +654,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -679,7 +670,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -696,7 +686,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -713,7 +702,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -730,7 +718,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -747,7 +734,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -764,7 +750,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -781,7 +766,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -798,7 +782,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -815,7 +798,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -832,7 +814,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -849,7 +830,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -866,7 +846,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -883,7 +862,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -900,7 +878,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -917,7 +894,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -934,7 +910,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -951,7 +926,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1241,7 +1215,7 @@
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -3108,7 +3082,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3122,7 +3095,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3136,7 +3108,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3150,7 +3121,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3164,7 +3134,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3178,7 +3147,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3192,7 +3160,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3206,7 +3173,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3220,7 +3186,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3234,7 +3199,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3248,7 +3212,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3262,7 +3225,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3276,7 +3238,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3290,7 +3251,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3304,7 +3264,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3318,7 +3277,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3332,7 +3290,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3346,7 +3303,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3360,7 +3316,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3374,7 +3329,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3388,7 +3342,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3402,7 +3355,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3416,7 +3368,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3430,7 +3381,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3444,7 +3394,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4221,7 +4170,7 @@
|
||||
"version": "24.10.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.15.tgz",
|
||||
"integrity": "sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
@@ -4257,14 +4206,14 @@
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -4792,7 +4741,7 @@
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -5179,7 +5128,7 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
@@ -5343,7 +5292,7 @@
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
@@ -5726,7 +5675,7 @@
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -6214,7 +6163,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -6338,7 +6286,7 @@
|
||||
"version": "20.7.0",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz",
|
||||
"integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
@@ -7858,7 +7806,7 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -7877,7 +7825,7 @@
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
@@ -7979,7 +7927,7 @@
|
||||
"version": "5.46.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
|
||||
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
@@ -8130,7 +8078,7 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -8429,7 +8377,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -8482,7 +8430,7 @@
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #877cd6;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #6e65d8;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: url(#linear-gradient2);
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #6e63d7;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #b1abf0;
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: url(#linear-gradient1);
|
||||
}
|
||||
|
||||
.st6 {
|
||||
fill: #a39ae9;
|
||||
}
|
||||
|
||||
.st7 {
|
||||
fill: #8476d5;
|
||||
}
|
||||
|
||||
.st8 {
|
||||
fill: #aaa2eb;
|
||||
}
|
||||
|
||||
.st9 {
|
||||
fill: #675bd2;
|
||||
}
|
||||
|
||||
.st10 {
|
||||
fill: #b9b4f1;
|
||||
}
|
||||
|
||||
.st11 {
|
||||
fill: #5449b9;
|
||||
}
|
||||
|
||||
.st12 {
|
||||
fill: #655acd;
|
||||
}
|
||||
|
||||
.st13 {
|
||||
fill: #b6b1f0;
|
||||
}
|
||||
|
||||
.st14 {
|
||||
fill: #9e93eb;
|
||||
}
|
||||
|
||||
.st15 {
|
||||
fill: #a298e8;
|
||||
}
|
||||
|
||||
.st16 {
|
||||
fill: #4239a5;
|
||||
}
|
||||
|
||||
.st17 {
|
||||
fill: #b0a9ed;
|
||||
}
|
||||
|
||||
.st18 {
|
||||
fill: #3d35a1;
|
||||
}
|
||||
|
||||
.st19 {
|
||||
fill: #8f80e4;
|
||||
}
|
||||
|
||||
.st20 {
|
||||
fill: #9186f3;
|
||||
}
|
||||
|
||||
.st21 {
|
||||
fill: #5a4ec2;
|
||||
}
|
||||
|
||||
.st22 {
|
||||
fill: #423aa4;
|
||||
}
|
||||
|
||||
.st23 {
|
||||
fill: #8e81dc;
|
||||
}
|
||||
|
||||
.st24 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
|
||||
.st25 {
|
||||
fill: #594ec0;
|
||||
}
|
||||
|
||||
.st26 {
|
||||
fill: #9487e7;
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="945.85" y1="1577.02" x2="761.14" y2="1748.14" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#7367dc"/>
|
||||
<stop offset="1" stop-color="#4e44b1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient1" x1="722.68" y1="1595.53" x2="596.26" y2="1731.01" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#5b53c3"/>
|
||||
<stop offset="1" stop-color="#392e91"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient2" x1="948.55" y1="1362.8" x2="635.29" y2="1790.01" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#887ded"/>
|
||||
<stop offset="1" stop-color="#342a89"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon class="st20" points="681.31 447.54 681.31 467.16 481.77 437.1 483.48 417.01 681.31 447.54"/>
|
||||
<path class="st18" d="M555.67,521.86c4.67-.95,15.46,7.01,23.68,7.48,10.63.6,19.08-6.88,26.17-5.47,5.58,1.11,5.41,7.21,2.27,10.95-11.39,13.58-43.92,11.42-55.13-1.69-3.59-4.2-2.96-10.06,3-11.27Z"/>
|
||||
<circle class="st16" cx="528.3" cy="506.02" r="13.52"/>
|
||||
<circle class="st22" cx="632.44" cy="508.64" r="13.43"/>
|
||||
<g>
|
||||
<path class="st24" d="M712.92,596.88v110.09l-234.36,18.53c-1.16-49.03-1.16-98.08,0-147.15.23-.82-.31-2.2.05-3.19,21.9,1.33,43.96,3.31,65.89,5.38,7.24.68,14.5,1.49,21.8,2.18,48.86,4.63,97.74,9.75,146.62,14.16Z"/>
|
||||
<g>
|
||||
<path class="st3" d="M712.92,706.97v-110.09,110.09Z"/>
|
||||
<path class="st4" d="M684.58,612.14c1.03,24.3,1.03,48.64,0,73.03-.26,1.25.34,2.95-.05,4.3-7.86,1.68-15.92,1.09-23.93,1.16v-81.75c4.86,1.32,21.04.41,23.98,3.27Z"/>
|
||||
<path class="st17" d="M531.97,596.88v68.67c-.15.54-.33,1.07-.6,1.55-1.78,3.15-25.19-1.98-24.47-9.18v-62.13c2.91-2.93,19.92,2.34,25.07,1.09Z"/>
|
||||
<path class="st13" d="M583.21,602.33c1.01,21.29,1.14,42.65.39,64.08l-1.56.27c-6.01-4.73-13.02-8.71-20.97-7.04l-1.85-1.71v-57.77c.85-.85,22.56,1.51,23.98,2.18Z"/>
|
||||
<path class="st10" d="M634.44,606.69v52.32l-2.45.71c-5.32-4.68-12.12-9.52-19.58-7.78l-1.95-1.65v-45.78l23.98,2.18Z"/>
|
||||
<path class="st8" d="M634.44,659.01v32.7l-23.98,1.09v-42.51c8.78-3.35,17.83,2.97,23.98,8.72Z"/>
|
||||
<path class="st6" d="M572.31,694.98l-13.63.35.55-37.41c7.76-2.78,18.95,1.52,23.97,7.63v-63.22c4.08,1.92-.83,79.47,1.1,89.93-.76,4.34-8.2,2.64-11.99,2.72Z"/>
|
||||
<path class="st15" d="M531.97,665.55c.59,10.18.59,20.36,0,30.52-.66,2.02-12.84.95-15.85,1.04-2.38.07-8.37,2.53-9.22.05v-39.24c5.92,5.49,17.26,8.87,25.07,7.63Z"/>
|
||||
<path class="st12" d="M559.23,657.92c.05,12.34-.04,24.73,0,37.07,4.35-.07,8.74.09,13.09,0l-14.18,1.1,1.1-95.93c.06,19.24-.08,38.53,0,57.77Z"/>
|
||||
<path class="st12" d="M506.9,657.92c.05,13.06-.04,26.18,0,39.24v-101.37c.06,20.69-.08,41.44,0,62.13Z"/>
|
||||
<path class="st12" d="M531.97,696.07c1.19-3.63-.03-24.65,0-30.52.14-22.87-.1-45.8,0-68.67v99.19Z"/>
|
||||
<path class="st3" d="M610.46,650.29c.05,14.15-.04,28.36,0,42.51v-88.29c.04,15.24-.05,30.54,0,45.78Z"/>
|
||||
<path class="st3" d="M634.44,691.71c.02-10.88-.03-21.82,0-32.7.05-17.42-.03-34.9,0-52.32v85.02Z"/>
|
||||
<path class="st3" d="M684.58,685.17v-73.03c.86.84,1.32,1.19,1.15,2.61-2.14,21.8,3.15,49.6-1.15,70.42Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<polygon class="st5" points="453.49 725.5 306.33 705.88 306.28 601.87 453.49 573.99 453.49 725.5"/>
|
||||
<g>
|
||||
<path class="st23" d="M408.8,608.87c2.53-2.96,13.34-2.52,17.43-4.36.24,7.3.9,14.48,1.1,21.8l-.66,68.12-8.06-.54c-2.16.07-4.37.04-6.54,0l-3.11-.93c-.93-27.55-1.62-56.31-.16-84.09Z"/>
|
||||
<path class="st0" d="M387,611.05v80.66c-3.62-2.19-15.34.22-16.96-2.11l-.33-74.59c.96-2.88,13.95-2.4,17.29-3.96Z"/>
|
||||
<polygon class="st7" points="348.85 617.59 348.85 688.44 332.5 687.35 332.5 619.77 348.85 617.59"/>
|
||||
<path class="st11" d="M408.8,608.87l-.09,82.37,3.36,2.65c-1.45-.03-2.92.04-4.37,0,1.53-10.81-2.44-80.9,1.1-85.03Z"/>
|
||||
<path class="st11" d="M427.33,626.31c.63,22.84-.47,45.83,0,68.68l-8.73-1.09c2.53-.08,5.1.06,7.64,0l1.08-67.59Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st2" d="M336.86,517.31c.08-12.89-1.57-31.04-.06-43.12.15-1.2-.08-2.43,1.14-3.22l129.72-55.03,1.08,123.49,39.25,13.84c-16.1,1.65-32.37,1.74-48.49,3.29-54.98,5.3-109.8,17.26-163.87,27.97-2.52-.29-5.64,5.91-5.64,7.44v106.28c-44.32-.62-86.36-25.78-106.3-65.38-37.11-73.67,8.88-159.45,89.48-171.08,5.05-70.54,77.25-114.81,143.6-93.75,29.07-38.59,75.03-64,123.39-68.73,87.07-8.53,171.01,51.08,190.76,136.23,1.53,6.58,5.31,22.98,4.99,28.91-.31,5.95-8.43,6.69-11,11.42,76.86-14.03,142.15,54.55,125.94,130.85-11.63,54.71-59.12,92.08-115.04,91.52l-1.09-119.9-68.67-12c.38-2.1,2.28-1.8,3.76-2.22,3.68-1.06,27.53-4.67,27.91-6.07l-.04-116.53c-.1-3.45-2.21-6.47-5.46-7.62l-217.45-34.36c-50.26,17.71-99.5,38.5-149.08,58.09-3.38,1.55-4.24,4.11-5.18,7.35-1.1,30.52-1.1,61.04,0,91.56,1,1.09.36,1.02,2.17.66,1.57-.31,12.26-3.46,13.09-3.93,1.12-11.83,1.48-23.82,1.09-35.97Z"/>
|
||||
<g>
|
||||
<path class="st1" d="M735.81,698.25l-1.09-119.9,1.09,119.9Z"/>
|
||||
<path class="st25" d="M320.51,556.55c-.76-.83-1.38-1.13-1.15-2.62,2.09-27.37-2.57-58.33-.02-85.26.21-2.19.83-2.53,1.17-3.69v91.56Z"/>
|
||||
<path class="st14" d="M441.5,456.26v85.02c-2.84,1.69-12.57,1.65-16.35,2.18v-81.21c3.7-2.31,9.24-5.16,13.35-6.37,1.09-.32,2.59-1.31,3,.37Z"/>
|
||||
<path class="st26" d="M404.44,470.43v76.3c-5.3,1.35-10.21.87-15.26,3.27v-75.21c5.46-.27,9.78-4.76,15.26-4.36Z"/>
|
||||
<path class="st19" d="M370.65,552.19c-3.11,1.45-9.5.64-13.08,2.18v-66.49c0-.61,10.85-4.28,11.46-4.36,2.82-.39,1.19,1.72,1.62,3.27,1.04,21.85,1.04,43.65,0,65.4Z"/>
|
||||
<path class="st9" d="M441.5,541.29v-85.02,85.02Z"/>
|
||||
<path class="st21" d="M370.65,552.19v-65.4c4.6,16.83-1.22,44.53,1.15,62.76-.16,1.29.36,1.94-1.15,2.64Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
@@ -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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '@/shared/components';
|
||||
import App from './App';
|
||||
|
||||
function renderApp(initialPath: string) {
|
||||
@@ -16,9 +17,11 @@ function renderApp(initialPath: string) {
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -29,14 +32,14 @@ describe('App smoke routes', () => {
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /^projects$/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/demo mode active/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/visual topology mapping and real-time observability/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/deploy, manage, and monitor containerized services/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders builds page in demo mode', async () => {
|
||||
renderApp('/builds?demo=1');
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /build pipeline/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/offline/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/polling/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders templates page in demo mode', async () => {
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
FolderKanban,
|
||||
Hammer,
|
||||
LayoutTemplate,
|
||||
ChartBar,
|
||||
Users,
|
||||
Settings,
|
||||
BookOpen,
|
||||
Container,
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChartBar,
|
||||
CheckCircle2,
|
||||
Container,
|
||||
FolderKanban,
|
||||
LayoutTemplate,
|
||||
LogOut,
|
||||
Moon,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Zap,
|
||||
Activity,
|
||||
Settings,
|
||||
Sun,
|
||||
UploadCloud,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getUpgradeStatus,
|
||||
listAuditLogs,
|
||||
listBuilds,
|
||||
pullUpgradeImage,
|
||||
} from '@/lib/api-client';
|
||||
import { signOutAuthSession } from '@/lib/auth-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useToast } from '@/shared/components';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban },
|
||||
{ label: 'Builds', href: '/builds', icon: Hammer },
|
||||
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
|
||||
{ label: 'Usage', href: '/usage', icon: ChartBar },
|
||||
{ label: 'People', href: '/people', icon: Users },
|
||||
@@ -24,240 +37,299 @@ const navItems = [
|
||||
{ label: 'Docs', href: '/docs', icon: BookOpen },
|
||||
];
|
||||
|
||||
// Developer/Demo pages
|
||||
const demoNavItems = [
|
||||
{ label: 'Metrics Demo', href: '/metrics-demo', icon: Activity },
|
||||
{ label: 'Showcase', href: '/showcase', icon: Zap },
|
||||
];
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
function getInitialTheme(): ThemeMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark';
|
||||
}
|
||||
return localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
export function PlatformShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1';
|
||||
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
|
||||
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const upgradeQuery = useQuery({
|
||||
queryKey: ['upgrade-status'],
|
||||
queryFn: getUpgradeStatus,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const buildsQuery = useQuery({
|
||||
queryKey: ['shell-build-notifications'],
|
||||
queryFn: () => listBuilds({ page: 1, limit: 5 }),
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const auditQuery = useQuery({
|
||||
queryKey: ['shell-audit-notifications'],
|
||||
queryFn: () => listAuditLogs({ page: 1, limit: 5 }),
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem('containr.theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const pullMutation = useMutation({
|
||||
mutationFn: pullUpgradeImage,
|
||||
onSuccess: (status) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upgrade-status'] });
|
||||
showToast('success', 'Upgrade image pulled', status.digest || status.imageRef || status.message);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast('error', 'Upgrade failed', error instanceof Error ? error.message : 'Unable to pull image');
|
||||
},
|
||||
});
|
||||
|
||||
const notifications = useMemo(() => {
|
||||
const builds = (buildsQuery.data?.builds ?? []).map((build) => ({
|
||||
id: `build-${build.id}`,
|
||||
title: `${build.status} build`,
|
||||
body: build.imageName || build.serviceId || build.id,
|
||||
}));
|
||||
const audits = (auditQuery.data ?? []).map((log) => ({
|
||||
id: `audit-${log.id}`,
|
||||
title: log.action,
|
||||
body: `${log.resource}${log.resourceId ? ` / ${log.resourceId}` : ''}`,
|
||||
}));
|
||||
return [...builds, ...audits].slice(0, 6);
|
||||
}, [auditQuery.data, buildsQuery.data?.builds]);
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
await signOutAuthSession();
|
||||
} finally {
|
||||
queryClient.clear();
|
||||
navigate('/auth/sign-in', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const isActiveRoute = (itemHref: string) =>
|
||||
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
||||
|
||||
const userName = sessionQuery.data?.user.name ?? 'Account';
|
||||
const userEmail = sessionQuery.data?.user.email ?? 'Local session';
|
||||
const initials = userName
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'C';
|
||||
|
||||
return (
|
||||
<div className="app-shell min-h-screen">
|
||||
{/* Ambient glow background */}
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<div className="flex min-h-screen relative">
|
||||
{/* Desktop Sidebar - self.html exact match: 64px width */}
|
||||
<div className="relative flex min-h-screen">
|
||||
<aside
|
||||
className="hidden md:flex shrink-0 flex-col items-center border-r"
|
||||
className="hidden h-screen shrink-0 flex-col border-r md:flex"
|
||||
style={{
|
||||
width: '64px',
|
||||
background: '#111217',
|
||||
borderRight: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '16px 0',
|
||||
gap: '5px'
|
||||
width: '232px',
|
||||
background: 'var(--bg-base)',
|
||||
borderRightColor: 'var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
{/* Logo - exact self.html: 38px circular pink */}
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
width: '38px',
|
||||
height: '38px',
|
||||
background: '#e8316a',
|
||||
marginBottom: '14px'
|
||||
}}
|
||||
<NavLink
|
||||
to={href('/projects')}
|
||||
className="flex h-[58px] shrink-0 items-center gap-3 border-b px-4"
|
||||
style={{ borderBottomColor: 'var(--border-subtle)' }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
|
||||
</svg>
|
||||
<img src="/containr.svg" alt="Containr" className="h-9 w-9 rounded-[var(--radius-md)]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)]">Containr</p>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)]">Deploy platform</p>
|
||||
</div>
|
||||
</NavLink>
|
||||
|
||||
{/* Nav Items - 40x40 icon-only */}
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-1 px-3 py-4">
|
||||
{navItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator */}
|
||||
<div style={{ width: '32px', height: '1px', background: 'rgba(255,255,255,0.07)', margin: '8px 0' }} />
|
||||
|
||||
{/* Demo/Developer Items */}
|
||||
{demoNavItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Bottom icons - Settings */}
|
||||
<NavLink to="/settings" className="nav-item" title="Settings">
|
||||
<Settings size={18} />
|
||||
</NavLink>
|
||||
<NavLink to="/docs" className="nav-item" title="Help">
|
||||
<BookOpen size={18} />
|
||||
</NavLink>
|
||||
|
||||
{/* User Avatar - exact self.html: 34px */}
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center cursor-pointer"
|
||||
style={{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
background: '#22233a',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: '#9295a4',
|
||||
marginTop: '4px',
|
||||
letterSpacing: '-0.3px'
|
||||
}}
|
||||
title="User"
|
||||
>
|
||||
w.
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="min-w-0 flex-1 relative z-10 flex flex-col min-h-screen overflow-hidden">
|
||||
{/* Topbar - self.html exact match: 52px height */}
|
||||
<header
|
||||
className="shrink-0 flex items-center"
|
||||
style={{
|
||||
height: '52px',
|
||||
background: '#111217',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '0 22px',
|
||||
gap: '14px'
|
||||
}}
|
||||
>
|
||||
{/* Search Box */}
|
||||
<div className="search-box">
|
||||
<Search size={14} />
|
||||
<input type="text" placeholder="Search logs..." />
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex items-center" style={{ gap: '8px' }}>
|
||||
<button
|
||||
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
Support
|
||||
</button>
|
||||
<button
|
||||
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<polyline points="17 11 12 6 7 11"/>
|
||||
<polyline points="17 18 12 13 7 18"/>
|
||||
</svg>
|
||||
Upgrade
|
||||
</button>
|
||||
<button
|
||||
className="rounded-[9px] border flex items-center justify-center cursor-pointer bg-transparent"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/70 backdrop-blur-2xl">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
background: '#e8316a'
|
||||
}}
|
||||
>
|
||||
<Container size={16} className="text-white" />
|
||||
</div>
|
||||
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all">
|
||||
<Search size={16} />
|
||||
</button>
|
||||
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all relative">
|
||||
<Bell size={16} />
|
||||
<div className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[var(--accent-primary)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex gap-1 px-3 pb-3 overflow-x-auto scrollbar-hide">
|
||||
{navItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
|
||||
className={`flex h-10 items-center gap-3 rounded-[var(--radius-md)] px-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<Icon size={17} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{/* Demo items */}
|
||||
{demoNavItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 border-t p-3" style={{ borderTopColor: 'var(--border-subtle)' }}>
|
||||
<NavLink
|
||||
to={href('/settings')}
|
||||
className="mb-2 flex items-center gap-3 rounded-[var(--radius-md)] p-2 hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--surface-card-hover)] text-xs font-semibold text-[var(--text-primary)]">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{userName}</p>
|
||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
|
||||
</div>
|
||||
</NavLink>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NavLink
|
||||
to={href('/settings')}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-[var(--radius-md)] border text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
>
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-[var(--radius-md)] border text-xs font-medium text-[var(--error)] hover:bg-[var(--error-soft)]"
|
||||
style={{ borderColor: 'var(--error-soft)' }}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header
|
||||
className="hidden h-[58px] shrink-0 items-center border-b px-5 md:flex"
|
||||
style={{
|
||||
background: 'var(--bg-base)',
|
||||
borderBottomColor: 'var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
<NavLink to={href('/projects')} className="mr-4 flex items-center gap-2 text-[var(--text-primary)]">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
|
||||
<Container size={15} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold">Containr</span>
|
||||
</NavLink>
|
||||
|
||||
<div className="search-box max-w-[420px]">
|
||||
<Search size={14} />
|
||||
<input type="text" placeholder="Search projects, builds, logs..." />
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pullMutation.mutate()}
|
||||
disabled={isDemoMode || pullMutation.isPending || !upgradeQuery.data?.imageRef}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ background: '#e8316a' }}
|
||||
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
||||
>
|
||||
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
|
||||
Upgrade
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotificationsOpen((open) => !open)}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={15} />
|
||||
{notifications.length > 0 ? (
|
||||
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-[var(--accent-primary)]" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{notificationsOpen ? (
|
||||
<div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Build and audit activity</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shell-build-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['shell-audit-notifications'] });
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
aria-label="Refresh notifications"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map((item) => (
|
||||
<div key={item.id} className="rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{item.title}</p>
|
||||
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
|
||||
<CheckCircle2 size={15} className="text-[var(--success)]" />
|
||||
No recent events
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<NavLink to={href('/projects')} className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-white">
|
||||
<Container size={16} />
|
||||
</div>
|
||||
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
className="h-9 w-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)]"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={15} className="mx-auto" /> : <Moon size={15} className="mx-auto" />}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex gap-1 overflow-x-auto px-3 pb-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
|
||||
className={`flex items-center gap-1.5 whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
@@ -268,7 +340,6 @@ export function PlatformShell() {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Page Content - scrollable */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, ArrowRight, Github, Gitlab, KeyRound, Loader2, Mail, Shield, User2, Workflow } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, ArrowRight, Github, Loader2, Mail, User2 } from 'lucide-react';
|
||||
import {
|
||||
AuthError,
|
||||
startBitbucketSignIn,
|
||||
startGitLabSignIn,
|
||||
requestMagicLinkInvite,
|
||||
getAuthBootstrap,
|
||||
startGoogleSignIn,
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
startGiteaSignIn,
|
||||
startGitHubSignIn,
|
||||
} from '@/lib/auth-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
@@ -48,7 +46,7 @@ function AuthCanvas() {
|
||||
Better Auth
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-relaxed text-[var(--text-secondary)]">
|
||||
Email/password, invite magic links, and OAuth providers (GitHub, GitLab, Bitbucket, Gitea) are handled by a dedicated Better Auth service with cookie sessions.
|
||||
Email/password plus GitHub and Google sign-in run through secure cookie sessions. First account bootstraps platform ownership; every later user is created from inside Containr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -132,18 +130,24 @@ export function SignInPage() {
|
||||
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
|
||||
|
||||
const sessionQuery = useAuthSession();
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: ['auth-bootstrap'],
|
||||
queryFn: getAuthBootstrap,
|
||||
});
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [magicEmail, setMagicEmail] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isMagicLoading, setIsMagicLoading] = useState(false);
|
||||
|
||||
if (sessionQuery.data) {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
if (bootstrapQuery.data?.mode === 'register') {
|
||||
return <Navigate to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||
}
|
||||
|
||||
const submitEmailPassword = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -162,48 +166,32 @@ export function SignInPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitMagicLink = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const signInWithGitHubProvider = async () => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setIsMagicLoading(true);
|
||||
|
||||
try {
|
||||
await requestMagicLinkInvite(magicEmail.trim(), buildOAuthCallbackURL(redirectPath));
|
||||
setInfo('Magic invite link sent. Check your email inbox.');
|
||||
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
} catch (exception) {
|
||||
const message = exception instanceof AuthError ? exception.message : 'Failed to send magic link';
|
||||
const message = exception instanceof AuthError ? exception.message : 'GitHub sign-in failed';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsMagicLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signInWithGitHubProvider = async () => {
|
||||
const signInWithGoogleProvider = async () => {
|
||||
setError(null);
|
||||
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
};
|
||||
|
||||
const signInWithGiteaProvider = async () => {
|
||||
setError(null);
|
||||
await startGiteaSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
};
|
||||
|
||||
const signInWithGitLabProvider = async () => {
|
||||
setError(null);
|
||||
await startGitLabSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
};
|
||||
|
||||
const signInWithBitbucketProvider = async () => {
|
||||
setError(null);
|
||||
await startBitbucketSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
try {
|
||||
await startGoogleSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
} catch (exception) {
|
||||
const message = exception instanceof AuthError ? exception.message : 'Google sign-in failed';
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Sign In" subtitle="Use your Containr account, invite magic link, or provider OAuth.">
|
||||
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google.">
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{info ? <AuthInfoNotice message={info} /> : null}
|
||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
|
||||
|
||||
<form className="space-y-3" onSubmit={submitEmailPassword}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
@@ -256,58 +244,17 @@ export function SignInPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGitLabProvider()}
|
||||
onClick={() => void signInWithGoogleProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<Gitlab size={15} />
|
||||
GitLab
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithBitbucketProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<Workflow size={15} />
|
||||
Bitbucket
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGiteaProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<Shield size={15} />
|
||||
Gitea
|
||||
<span className="text-sm font-semibold">G</span>
|
||||
Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="mt-4 space-y-2" onSubmit={submitMagicLink}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Magic Link Invite
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={magicEmail}
|
||||
onChange={(event) => setMagicEmail(event.target.value)}
|
||||
required
|
||||
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isMagicLoading}
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)] disabled:opacity-60"
|
||||
>
|
||||
{isMagicLoading ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
|
||||
Send Invite Link
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span>No account yet?</span>
|
||||
<Link to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
|
||||
Create one <ArrowRight size={12} />
|
||||
</Link>
|
||||
<span>Need access?</span>
|
||||
<span>Ask platform owner to create account.</span>
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthLayout>
|
||||
@@ -321,6 +268,10 @@ export function SignUpPage() {
|
||||
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
|
||||
|
||||
const sessionQuery = useAuthSession();
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: ['auth-bootstrap'],
|
||||
queryFn: getAuthBootstrap,
|
||||
});
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -331,6 +282,10 @@ export function SignUpPage() {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
if (bootstrapQuery.data?.mode === 'login') {
|
||||
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||
}
|
||||
|
||||
const submitSignUp = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -350,8 +305,9 @@ export function SignUpPage() {
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Create Account" subtitle="Provision your operator account with email/password auth.">
|
||||
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
||||
|
||||
<form className="space-y-3" onSubmit={submitSignUp}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
|
||||
@@ -126,8 +126,11 @@ export function BuildsPage() {
|
||||
[isDemoMode, buildsQuery.data?.builds],
|
||||
);
|
||||
|
||||
const subscribedBuildIds = useMemo(() => builds.map((build) => build.id), [builds]);
|
||||
const liveConnected = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
||||
const subscribedBuildIds = useMemo(
|
||||
() => (isDemoMode ? [] : builds.map((build) => build.id)),
|
||||
[isDemoMode, builds],
|
||||
);
|
||||
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
|
||||
if (selectedBuild && channel === `build:${selectedBuild.id}`) {
|
||||
@@ -149,9 +152,6 @@ export function BuildsPage() {
|
||||
queryFn: () => getBuildLogs(selectedBuild!.id),
|
||||
});
|
||||
|
||||
const isCancellingBuild = (buildId: string): boolean =>
|
||||
cancelMutation.isPending && cancelMutation.variables === buildId;
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
const running = builds.filter(b => b.status === 'running' || b.status === 'pending').length;
|
||||
@@ -173,9 +173,9 @@ export function BuildsPage() {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
|
||||
{liveConnected ? 'Live' : 'Offline'}
|
||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
|
||||
{liveStatus === 'live' ? 'Live' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -226,21 +226,24 @@ export function ProjectsPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Hero Section - Premium ambient design */}
|
||||
<div className="relative overflow-hidden border-b border-[var(--border-subtle)]">
|
||||
{/* Solid ambient background */}
|
||||
<div className="absolute inset-0 bg-[#e8316a]/5" />
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-[var(--accent-primary)]/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-[var(--accent-secondary)]/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-[1400px] px-6 py-12 md:py-16 lg:py-20">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center justify-center rounded-xl shadow-lg ring-1 ring-white/10"
|
||||
style={{ width: '44px', height: '44px', background: '#e8316a' }}
|
||||
className="flex items-center justify-center rounded-[var(--radius-md)]"
|
||||
style={{ width: '36px', height: '36px', background: '#e8316a' }}
|
||||
>
|
||||
<FolderOpen size={20} className="text-white" />
|
||||
<FolderOpen size={17} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-headline text-xl font-semibold tracking-tight text-[var(--text-primary)]">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Deploy, manage, and monitor containerized services
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
|
||||
@@ -249,18 +252,10 @@ export function ProjectsPage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="font-headline text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-[var(--text-primary)]">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-[var(--text-secondary)] max-w-xl leading-relaxed">
|
||||
Deploy, manage, and monitor your containerized services with visual topology mapping and real-time observability.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-8">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<div className="relative flex-1">
|
||||
|
||||
@@ -3,9 +3,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
getApiBaseUrl,
|
||||
getAgentPublicBaseUrl,
|
||||
getHostMonitoring,
|
||||
createUser,
|
||||
createGitProvider,
|
||||
deleteGitProvider,
|
||||
getCurrentUserProfile,
|
||||
listAuditLogs,
|
||||
listAgents,
|
||||
listBuilds,
|
||||
listGitProviders,
|
||||
listProjects,
|
||||
listTemplates,
|
||||
updateCurrentUserProfile,
|
||||
@@ -21,6 +28,7 @@ import {
|
||||
FileText,
|
||||
Settings,
|
||||
User,
|
||||
UserPlus,
|
||||
Key,
|
||||
Database,
|
||||
RefreshCw,
|
||||
@@ -34,6 +42,11 @@ import {
|
||||
Box,
|
||||
Terminal,
|
||||
Radio,
|
||||
Server,
|
||||
HardDrive,
|
||||
GitBranch,
|
||||
Link2,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
@@ -120,15 +133,51 @@ function endpointStateBadge(isLoading: boolean, isError: boolean): {
|
||||
return { label: 'Available', toneClass: 'text-[var(--ok)]' };
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = value;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
return `${hours}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
export function UsagePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const buildsQuery = useQuery({
|
||||
queryKey: ['usage-builds'],
|
||||
queryFn: () => listBuilds({ page: 1, limit: 100 }),
|
||||
});
|
||||
const hostQuery = useQuery({
|
||||
queryKey: ['usage-host-monitoring'],
|
||||
queryFn: getHostMonitoring,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ['usage-agents'],
|
||||
queryFn: listAgents,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const builds = buildsQuery.data?.builds ?? [];
|
||||
const liveConnected = useBuildUpdates(
|
||||
const liveStatus = useBuildUpdates(
|
||||
builds.map((build) => build.id),
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
|
||||
@@ -156,6 +205,15 @@ export function UsagePage() {
|
||||
const avgBuildMinutes = averageBuildDurationMs / 60_000;
|
||||
const activeBuildPressure = runningBuilds + pendingBuilds;
|
||||
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
|
||||
const host = hostQuery.data;
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
|
||||
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||
const agentEndpoint = getAgentPublicBaseUrl();
|
||||
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
|
||||
CONTAINR_AGENT_AUTH_TOKEN=<token> \\
|
||||
containr-agent`;
|
||||
|
||||
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
|
||||
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
||||
@@ -197,9 +255,9 @@ export function UsagePage() {
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={`text-sm ${liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
|
||||
{liveConnected ? 'Live sync active' : 'Offline'}
|
||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
|
||||
{liveStatus === 'live' ? 'Live sync active' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling for updates'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -216,6 +274,7 @@ export function UsagePage() {
|
||||
<p className="text-sm text-[var(--error)]">Failed to load usage data</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Runtime Hours"
|
||||
@@ -239,6 +298,142 @@ export function UsagePage() {
|
||||
color={activeBuildPressure > 0 ? 'warning' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
|
||||
<Server size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Autoscaling base signal from this Containr host.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
void hostQuery.refetch();
|
||||
void agentsQuery.refetch();
|
||||
}}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 text-sm"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hostQuery.isError ? (
|
||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||
Host monitoring unavailable.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title="Host"
|
||||
value={host?.hostname || '—'}
|
||||
description={host ? `${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}` : 'Loading host identity.'}
|
||||
icon={Server}
|
||||
color={host?.dockerAvailable ? 'success' : 'warning'}
|
||||
/>
|
||||
<StatCard
|
||||
title="CPU Load"
|
||||
value={host ? host.load.load1m.toFixed(2) : '—'}
|
||||
description={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU load.'}
|
||||
icon={Gauge}
|
||||
color={host && host.load.load1m > host.cpu.cores ? 'warning' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Memory"
|
||||
value={host ? `${host.memory.usagePercent.toFixed(0)}%` : '—'}
|
||||
description={host ? `${formatBytes(host.memory.used)} used · ${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'}
|
||||
icon={Activity}
|
||||
color={host && host.memory.usagePercent > 80 ? 'warning' : 'success'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Disk"
|
||||
value={host ? `${host.storage.usagePercent.toFixed(0)}%` : '—'}
|
||||
description={host ? `${formatBytes(host.storage.used)} used · ${formatBytes(host.storage.available)} free on ${host.storage.path}` : 'Loading disk telemetry.'}
|
||||
icon={HardDrive}
|
||||
color={host && host.storage.usagePercent > 80 ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]">
|
||||
<Radio size={18} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
title="Registered"
|
||||
value={String(agents.length)}
|
||||
description={agentsQuery.isError ? 'Agent API unavailable.' : 'Nodes connected through token heartbeats.'}
|
||||
icon={Radio}
|
||||
color={agents.length > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Online"
|
||||
value={String(onlineAgents)}
|
||||
description="Online and connecting agents can receive queued commands."
|
||||
icon={Activity}
|
||||
color={onlineAgents > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Remote Memory"
|
||||
value={formatBytes(totalAgentMemory)}
|
||||
description={`${formatBytes(availableAgentMemory)} currently free across agents.`}
|
||||
icon={Database}
|
||||
color={totalAgentMemory > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
|
||||
{agents.length > 0 ? (
|
||||
agents.slice(0, 5).map((agent) => (
|
||||
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
|
||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
|
||||
No remote agents registered yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Terminal size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connect Node</span>
|
||||
</div>
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
|
||||
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
||||
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
||||
<p>Set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKEN</span> to a value accepted by the backend.</p>
|
||||
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,10 +441,30 @@ export function UsagePage() {
|
||||
}
|
||||
|
||||
export function PeoplePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
});
|
||||
const [newUserName, setNewUserName] = useState('');
|
||||
const [newUserEmail, setNewUserEmail] = useState('');
|
||||
const [newUserPassword, setNewUserPassword] = useState('');
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createUser({
|
||||
name: newUserName.trim(),
|
||||
email: newUserEmail.trim(),
|
||||
password: newUserPassword,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setNewUserName('');
|
||||
setNewUserEmail('');
|
||||
setNewUserPassword('');
|
||||
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['people-audit-logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['people-projects'],
|
||||
@@ -312,10 +527,11 @@ export function PeoplePage() {
|
||||
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading team data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Members"
|
||||
value={profileQuery.data ? '1' : '—'}
|
||||
value={profileQuery.data ? '1+' : '—'}
|
||||
description={membersBody}
|
||||
icon={Users}
|
||||
color={profileQuery.data ? 'success' : 'default'}
|
||||
@@ -335,12 +551,268 @@ export function PeoplePage() {
|
||||
color={auditLogsQuery.data?.length ? 'success' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
|
||||
<UserPlus size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createUserMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={newUserName}
|
||||
onChange={(event) => setNewUserName(event.target.value)}
|
||||
required
|
||||
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
value={newUserEmail}
|
||||
onChange={(event) => setNewUserEmail(event.target.value)}
|
||||
type="email"
|
||||
required
|
||||
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
<input
|
||||
value={newUserPassword}
|
||||
onChange={(event) => setNewUserPassword(event.target.value)}
|
||||
type="password"
|
||||
minLength={8}
|
||||
required
|
||||
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
|
||||
placeholder="Temporary password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createUserMutation.isPending}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 text-sm font-semibold text-white disabled:opacity-60"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{createUserMutation.isError ? (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||
{createUserMutation.error instanceof Error ? createUserMutation.error.message : 'Failed to create user'}
|
||||
</div>
|
||||
) : null}
|
||||
{createUserMutation.isSuccess ? (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--success-soft)] px-4 py-3 text-sm text-[var(--success)]">
|
||||
User created. Share credentials through secure channel.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gitProviderTypes = [
|
||||
{ value: 'github', label: 'GitHub', tokenHint: 'Personal access token with repo scope' },
|
||||
{ value: 'gitlab', label: 'GitLab', tokenHint: 'Personal access token with read_api scope' },
|
||||
{ value: 'gitea', label: 'Gitea', tokenHint: 'Access token from your Gitea instance' },
|
||||
{ value: 'bitbucket', label: 'Bitbucket', tokenHint: 'App password with repository read' },
|
||||
] as const;
|
||||
|
||||
function GitProvidersSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const providersQuery = useQuery({
|
||||
queryKey: ['git-providers'],
|
||||
queryFn: listGitProviders,
|
||||
});
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: 'github' as (typeof gitProviderTypes)[number]['value'],
|
||||
displayName: '',
|
||||
accessToken: '',
|
||||
apiUrl: '',
|
||||
});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createGitProvider({
|
||||
name: form.name,
|
||||
display_name: form.displayName.trim() || gitProviderTypes.find((t) => t.value === form.name)!.label,
|
||||
access_token: form.accessToken.trim(),
|
||||
api_url: form.apiUrl.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
|
||||
setForm({ name: 'github', displayName: '', accessToken: '', apiUrl: '' });
|
||||
setFormOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (providerId: string) => deleteGitProvider(providerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const providers = providersQuery.data ?? [];
|
||||
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<GitBranch size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFormOpen((open) => !open)}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg transition-all"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{formOpen ? 'Close' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{providersQuery.isLoading ? (
|
||||
<div className="py-6 text-center">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : providersQuery.isError ? (
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
|
||||
Failed to load providers
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-[var(--text-secondary)]">No Git providers connected</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Connect GitHub, GitLab, Gitea, or Bitbucket to deploy services from source
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center">
|
||||
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(provider.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center gap-2 h-8 px-3 rounded-[var(--radius-sm)] border border-[var(--error-soft)] text-[var(--error)] text-xs font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Unlink size={12} />
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border-subtle)] space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value as typeof form.name }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
{gitProviderTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((p) => ({ ...p, displayName: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder={selectedType.label}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.accessToken}
|
||||
onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="••••••••••••••••"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
|
||||
</div>
|
||||
{form.name === 'gitea' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Instance URL
|
||||
</label>
|
||||
<input
|
||||
value={form.apiUrl}
|
||||
onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="https://gitea.example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{createMutation.isError && (
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to connect provider'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!form.accessToken.trim() || createMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Connect Provider
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -502,6 +974,9 @@ export function SettingsPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Git Providers Section */}
|
||||
<GitProvidersSection />
|
||||
|
||||
{/* Runtime & Local State Section */}
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -701,7 +1176,7 @@ export function DocsPage() {
|
||||
<FileText size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Generated Types</span>
|
||||
</div>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">frontend/src/generated/api-types.ts</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">app/frontend/src/generated/api-types.ts</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -715,7 +1190,7 @@ export function DocsPage() {
|
||||
<FileText size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">UI References</span>
|
||||
</div>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">docs/references/self.html</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">docs/references/dashboard.png • projects.png</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -735,13 +1210,16 @@ export function DocsPage() {
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">
|
||||
{`# Regenerate frontend API types
|
||||
npm --prefix frontend run generate:api
|
||||
npm --prefix app/frontend run generate:api
|
||||
|
||||
# Frontend type-check + build
|
||||
npm --prefix frontend run build:check
|
||||
npm --prefix app/frontend run build:check
|
||||
|
||||
# Backend API tests
|
||||
cd backend && go test ./internal/api/...`}
|
||||
cd app/backend && go test ./internal/api/...
|
||||
|
||||
# Build remote node agent
|
||||
cd app/backend && go build -o bin/containr-agent ./cmd/agent`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
@@ -7,21 +7,18 @@ import {
|
||||
getProjectById,
|
||||
getDeploymentLogs,
|
||||
getServiceById,
|
||||
getServiceMetrics,
|
||||
listDeployments,
|
||||
listServiceLogs,
|
||||
rollbackDeployment,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
|
||||
import { formatDate, formatRelative, seededMetric } from '@/lib/time';
|
||||
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Activity,
|
||||
FileText,
|
||||
Settings,
|
||||
Sliders,
|
||||
Play,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
@@ -171,23 +168,50 @@ export function ServiceDetailPage() {
|
||||
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['service-metrics', serviceId],
|
||||
queryFn: () => getServiceMetrics(serviceId),
|
||||
enabled: !isDemoMode && Boolean(serviceId),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
// Accumulate live samples into a small history for the timeline charts.
|
||||
const [metricHistory, setMetricHistory] = useState<{ cpu: number[]; memory: number[] }>({ cpu: [], memory: [] });
|
||||
useEffect(() => {
|
||||
const sample = metricsQuery.data;
|
||||
if (!sample || sample.status !== 'ok') {
|
||||
return;
|
||||
}
|
||||
const memPercent = sample.memory_limit_bytes > 0
|
||||
? (sample.memory_usage_bytes / sample.memory_limit_bytes) * 100
|
||||
: 0;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
|
||||
setMetricHistory((prev) => ({
|
||||
cpu: [...prev.cpu, Math.min(100, sample.cpu_percent)].slice(-24),
|
||||
memory: [...prev.memory, Math.min(100, memPercent)].slice(-24),
|
||||
}));
|
||||
}, [metricsQuery.data]);
|
||||
|
||||
const liveMetrics = isDemoMode ? null : metricsQuery.data ?? null;
|
||||
const hasLiveTelemetry = Boolean(liveMetrics && liveMetrics.status === 'ok' && liveMetrics.instances.length > 0);
|
||||
const runningInstances = liveMetrics?.instances.filter((instance) => instance.state === 'running').length ?? 0;
|
||||
const memoryPercent = liveMetrics && liveMetrics.memory_limit_bytes > 0
|
||||
? (liveMetrics.memory_usage_bytes / liveMetrics.memory_limit_bytes) * 100
|
||||
: 0;
|
||||
|
||||
// Demo mode renders seeded values so the layout can be previewed without a backend.
|
||||
const metricSet = useMemo(() => {
|
||||
if (!service) {
|
||||
if (!service || !isDemoMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cpu = seededMetric(`${service.id}:cpu`, 12, 78);
|
||||
const memory = seededMetric(`${service.id}:mem`, 24, 91);
|
||||
const req = seededMetric(`${service.id}:req`, 120, 5100);
|
||||
const latency = seededMetric(`${service.id}:lat`, 17, 210);
|
||||
|
||||
return {
|
||||
cpu,
|
||||
memory,
|
||||
req,
|
||||
latency,
|
||||
cpu: seededMetric(`${service.id}:cpu`, 12, 78),
|
||||
memory: seededMetric(`${service.id}:mem`, 24, 91),
|
||||
req: seededMetric(`${service.id}:req`, 120, 5100),
|
||||
latency: seededMetric(`${service.id}:lat`, 17, 210),
|
||||
};
|
||||
}, [service]);
|
||||
}, [service, isDemoMode]);
|
||||
|
||||
const deploymentSummary = useMemo(() => {
|
||||
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
|
||||
@@ -372,30 +396,29 @@ export function ServiceDetailPage() {
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
label="CPU"
|
||||
value={`${metricSet?.cpu ?? 0}%`}
|
||||
hint="Current utilization"
|
||||
value={isDemoMode ? `${metricSet?.cpu ?? 0}%` : hasLiveTelemetry ? `${liveMetrics!.cpu_percent.toFixed(1)}%` : '—'}
|
||||
hint={isDemoMode ? 'Current utilization' : hasLiveTelemetry ? `${runningInstances} running container${runningInstances === 1 ? '' : 's'}` : 'No running containers'}
|
||||
icon={Cpu}
|
||||
trend={metricSet && metricSet.cpu > 60 ? 'up' : metricSet && metricSet.cpu < 30 ? 'down' : 'stable'}
|
||||
trend={hasLiveTelemetry && liveMetrics!.cpu_percent > 60 ? 'up' : 'stable'}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Memory"
|
||||
value={`${metricSet?.memory ?? 0}%`}
|
||||
hint="Container footprint"
|
||||
value={isDemoMode ? `${metricSet?.memory ?? 0}%` : hasLiveTelemetry ? formatBytes(liveMetrics!.memory_usage_bytes) : '—'}
|
||||
hint={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${memoryPercent.toFixed(0)}% of ${formatBytes(liveMetrics!.memory_limit_bytes)}` : 'No running containers'}
|
||||
icon={MemoryStick}
|
||||
trend={metricSet && metricSet.memory > 70 ? 'up' : 'stable'}
|
||||
trend={hasLiveTelemetry && memoryPercent > 70 ? 'up' : 'stable'}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Requests"
|
||||
value={`${metricSet?.req ?? 0}`}
|
||||
hint="Last 60 minutes"
|
||||
label="Network"
|
||||
value={isDemoMode ? `${metricSet?.req ?? 0}` : hasLiveTelemetry ? `${formatBytes(liveMetrics!.network_rx_bytes)}` : '—'}
|
||||
hint={isDemoMode ? 'Requests, last 60 minutes' : hasLiveTelemetry ? `RX total · TX ${formatBytes(liveMetrics!.network_tx_bytes)}` : 'No traffic recorded'}
|
||||
icon={Zap}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Latency"
|
||||
value={`${metricSet?.latency ?? 0}ms`}
|
||||
hint="P95 estimate"
|
||||
label="Instances"
|
||||
value={isDemoMode ? `${metricSet?.latency ?? 0}ms` : hasLiveTelemetry ? `${runningInstances}` : '0'}
|
||||
hint={isDemoMode ? 'P95 latency estimate' : hasLiveTelemetry ? `${liveMetrics!.instances.length} container${liveMetrics!.instances.length === 1 ? '' : 's'} discovered` : 'Deploy to create containers'}
|
||||
icon={Timer}
|
||||
trend={metricSet && metricSet.latency > 150 ? 'up' : 'stable'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -458,6 +481,20 @@ export function ServiceDetailPage() {
|
||||
{activeSection === 'metrics' && (
|
||||
<div className="space-y-6">
|
||||
{/* Metrics Grid with enhanced cards */}
|
||||
{!isDemoMode && !hasLiveTelemetry && (
|
||||
<div className="panel p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
|
||||
<Activity size={22} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<p className="text-base font-medium text-[var(--text-primary)]">No telemetry yet</p>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{liveMetrics?.status === 'docker_unavailable'
|
||||
? 'Docker is not reachable on this host, so container metrics cannot be collected.'
|
||||
: 'Metrics appear once this service has running containers. Deploy it to start collecting.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(isDemoMode || hasLiveTelemetry) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="panel p-5 group hover:border-[var(--accent-primary)]/30 transition-all duration-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -467,28 +504,36 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">CPU Timeline</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-xs text-[var(--text-tertiary)]">Click to expand</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-32 items-end gap-1">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
{isDemoMode
|
||||
? Array.from({ length: 24 }).map((_, i) => {
|
||||
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer group/bar"
|
||||
style={{
|
||||
height: `${value}%`,
|
||||
background: '#ff7043',
|
||||
}}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
|
||||
style={{ height: `${value}%`, background: '#ff7043' }}
|
||||
title={`${value}%`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
})
|
||||
: metricHistory.cpu.map((value, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
|
||||
style={{ height: `${Math.max(3, value)}%`, background: '#ff7043' }}
|
||||
title={`${value.toFixed(1)}%`}
|
||||
/>
|
||||
))}
|
||||
{!isDemoMode && metricHistory.cpu.length === 0 && (
|
||||
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -496,32 +541,77 @@ export function ServiceDetailPage() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="card-icon">
|
||||
<Zap size={18} />
|
||||
<MemoryStick size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Request Timeline</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Memory Timeline</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-32 items-end gap-1">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
{isDemoMode
|
||||
? Array.from({ length: 24 }).map((_, i) => {
|
||||
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
height: `${value}%`,
|
||||
background: '#3dd68c',
|
||||
}}
|
||||
style={{ height: `${value}%`, background: '#3dd68c' }}
|
||||
title={`${value}%`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
})
|
||||
: metricHistory.memory.map((value, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
|
||||
style={{ height: `${Math.max(3, value)}%`, background: '#3dd68c' }}
|
||||
title={`${value.toFixed(1)}%`}
|
||||
/>
|
||||
))}
|
||||
{!isDemoMode && metricHistory.memory.length === 0 && (
|
||||
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instance list — real container states */}
|
||||
{!isDemoMode && hasLiveTelemetry && (
|
||||
<div className="panel p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="card-icon">
|
||||
<Layers size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Instances</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Containers backing this service</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{liveMetrics!.instances.map((instance) => (
|
||||
<div
|
||||
key={instance.container_id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${instance.state === 'running' ? 'bg-[var(--success)]' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className="mono text-xs text-[var(--text-primary)] truncate">{instance.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--text-tertiary)] flex-shrink-0">
|
||||
<span className="mono">{instance.cpu_percent.toFixed(1)}% CPU</span>
|
||||
<span className="mono">{formatBytes(instance.memory_usage_bytes)}</span>
|
||||
<span>{instance.started_at ? `up ${formatRelative(instance.started_at).replace(' ago', '')}` : instance.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain & Networking Panel */}
|
||||
{service.status === 'running' && (
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
Zap,
|
||||
Users,
|
||||
Globe,
|
||||
ArrowLeft,
|
||||
Search,
|
||||
Bell,
|
||||
ChevronUp,
|
||||
Activity,
|
||||
FileText,
|
||||
Sliders,
|
||||
Settings as SettingsIcon
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
LineAreaChart,
|
||||
DonutChart,
|
||||
MultiLineChart,
|
||||
EnhancedMetricCard,
|
||||
CacheMetricCard,
|
||||
PerformanceMetricCard
|
||||
@@ -49,7 +45,6 @@ const timePeriods = ['Day', 'Month', 'Year'];
|
||||
|
||||
export function ServiceMetricsDashboard() {
|
||||
const { serviceId = 'demo-service' } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('metrics');
|
||||
@@ -86,10 +81,10 @@ export function ServiceMetricsDashboard() {
|
||||
{ data: perfData1, color: '#6c8ef0', fillOpacity: 0.15 },
|
||||
{ data: perfData2, color: '#9c7ef0', fillOpacity: 0.15 }
|
||||
],
|
||||
upSpeed: (Math.random() * 5 + 8).toFixed(1),
|
||||
downSpeed: (Math.random() * 3 + 4).toFixed(1),
|
||||
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
|
||||
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
|
||||
};
|
||||
}, [serviceId, timePeriod]);
|
||||
}, [serviceId]);
|
||||
|
||||
// Auto-update metrics every 2 seconds
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,11 +14,12 @@ import '@xyflow/react/dist/style.css';
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
|
||||
import {
|
||||
DEFAULT_VIEWPORT,
|
||||
type CanvasGroup,
|
||||
type CanvasNodeLayout,
|
||||
type ProjectCanvasMetadata,
|
||||
} from '../model';
|
||||
import { loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
||||
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
|
||||
|
||||
@@ -152,6 +153,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const persistTimeout = useRef<number | null>(null);
|
||||
const hydratedRef = useRef(false);
|
||||
const viewportRestoredRef = useRef(false);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
|
||||
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
|
||||
const [viewportTick, setViewportTick] = useState(0);
|
||||
@@ -169,20 +171,34 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
|
||||
const metadata = loadCanvasMetadata(projectId, services);
|
||||
const nextNodes = toFlowNodes(metadata, services, onOpenService);
|
||||
|
||||
viewportRestoredRef.current = false;
|
||||
setNodes(nextNodes);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
setViewport(metadata.viewport, { duration: 120 });
|
||||
window.requestAnimationFrame(() => {
|
||||
const isDefaultViewport =
|
||||
metadata.viewport.x === DEFAULT_VIEWPORT.x &&
|
||||
metadata.viewport.y === DEFAULT_VIEWPORT.y &&
|
||||
metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom;
|
||||
|
||||
if (!hasStoredViewport || isDefaultViewport) {
|
||||
void fitView({ padding: 0.15, duration: 120 });
|
||||
} else {
|
||||
void setViewport(metadata.viewport, { duration: 120 });
|
||||
}
|
||||
viewportRestoredRef.current = true;
|
||||
});
|
||||
});
|
||||
|
||||
hydratedRef.current = true;
|
||||
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, services]);
|
||||
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydratedRef.current) {
|
||||
if (!hydratedRef.current || !viewportRestoredRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -414,7 +430,6 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
}}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
||||
fitView
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
minZoom={0.25}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
createService,
|
||||
getProjectById,
|
||||
listGitBranches,
|
||||
listGitProviders,
|
||||
listGitRepositories,
|
||||
listServiceLogs,
|
||||
listServiceVariables,
|
||||
listServicesByProject,
|
||||
@@ -31,6 +34,8 @@ import {
|
||||
Sparkles,
|
||||
Box,
|
||||
Search,
|
||||
GitBranch,
|
||||
Container,
|
||||
} from 'lucide-react';
|
||||
|
||||
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
|
||||
@@ -59,6 +64,44 @@ function ServiceCreateDialog(props: {
|
||||
image: '',
|
||||
command: '',
|
||||
});
|
||||
const [source, setSource] = useState<'image' | 'git'>('image');
|
||||
const [providerId, setProviderId] = useState('');
|
||||
const [repoFullName, setRepoFullName] = useState('');
|
||||
const [repoSearch, setRepoSearch] = useState('');
|
||||
|
||||
const providersQuery = useQuery({
|
||||
queryKey: ['git-providers'],
|
||||
queryFn: listGitProviders,
|
||||
enabled: props.open && source === 'git',
|
||||
});
|
||||
|
||||
const repositoriesQuery = useQuery({
|
||||
queryKey: ['git-repositories', providerId, repoSearch],
|
||||
queryFn: () => listGitRepositories(providerId, repoSearch),
|
||||
enabled: props.open && source === 'git' && Boolean(providerId),
|
||||
});
|
||||
|
||||
const selectedRepo = useMemo(
|
||||
() => (repositoriesQuery.data ?? []).find((repo) => repo.full_name === repoFullName) ?? null,
|
||||
[repositoriesQuery.data, repoFullName],
|
||||
);
|
||||
|
||||
const branchesQuery = useQuery({
|
||||
queryKey: ['git-branches', providerId, repoFullName],
|
||||
queryFn: () => {
|
||||
const [owner, repo] = repoFullName.split('/');
|
||||
return listGitBranches(providerId, owner, repo);
|
||||
},
|
||||
enabled: props.open && source === 'git' && Boolean(providerId) && Boolean(repoFullName),
|
||||
});
|
||||
|
||||
const providers = providersQuery.data ?? [];
|
||||
const repositories = repositoriesQuery.data ?? [];
|
||||
const branches = branchesQuery.data ?? [];
|
||||
|
||||
const canSubmit =
|
||||
Boolean(form.name.trim()) &&
|
||||
(source === 'image' || Boolean(repoFullName));
|
||||
|
||||
if (!props.open) return null;
|
||||
|
||||
@@ -120,6 +163,39 @@ function ServiceCreateDialog(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Source
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSource('image')}
|
||||
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
|
||||
source === 'image'
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
>
|
||||
<Container size={14} />
|
||||
Image
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSource('git')}
|
||||
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
|
||||
source === 'git'
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
Git Repository
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source === 'image' ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
|
||||
@@ -131,6 +207,126 @@ function ServiceCreateDialog(props: {
|
||||
placeholder="ghcr.io/org/app:latest"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
value={providerId}
|
||||
onChange={(e) => {
|
||||
setProviderId(e.target.value);
|
||||
setRepoFullName('');
|
||||
setForm((p) => ({ ...p, git_repo: '', git_branch: '' }));
|
||||
}}
|
||||
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||
>
|
||||
<option value="">Select provider</option>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>{provider.display_name}</option>
|
||||
))}
|
||||
</select>
|
||||
{!providersQuery.isLoading && providers.length === 0 && (
|
||||
<p className="text-xs text-[var(--warn)] mt-1.5">
|
||||
No providers connected — add one in Settings → Git Providers
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{providerId && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Repository
|
||||
</label>
|
||||
<input
|
||||
value={repoSearch}
|
||||
onChange={(e) => setRepoSearch(e.target.value)}
|
||||
className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono mb-2"
|
||||
placeholder="Search repositories..."
|
||||
/>
|
||||
{repositoriesQuery.isLoading ? (
|
||||
<div className="py-3 text-center">
|
||||
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : repositoriesQuery.isError ? (
|
||||
<p className="text-xs text-[var(--error)] py-2">Failed to load repositories — check the provider token</p>
|
||||
) : repositories.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-muted)] py-2">No repositories found</p>
|
||||
) : (
|
||||
<div className="max-h-40 overflow-y-auto space-y-0.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
|
||||
{repositories.slice(0, 50).map((repo) => (
|
||||
<button
|
||||
key={repo.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRepoFullName(repo.full_name);
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
git_repo: repo.clone_url ?? repo.full_name,
|
||||
git_branch: p.git_branch || repo.default_branch || '',
|
||||
}));
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${
|
||||
repoFullName === repo.full_name
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="mono text-xs truncate">{repo.full_name}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repoFullName && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Branch
|
||||
</label>
|
||||
{branchesQuery.isLoading ? (
|
||||
<div className="py-2 text-center">
|
||||
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : branches.length > 0 ? (
|
||||
<select
|
||||
value={form.git_branch ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
|
||||
>
|
||||
<option value="">Select branch</option>
|
||||
{branches.map((branch) => (
|
||||
<option key={branch.name} value={branch.name}>{branch.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.git_branch ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
|
||||
placeholder={selectedRepo?.default_branch ?? 'main'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Build Path <span className="normal-case text-[var(--text-muted)]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={form.build_path ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
|
||||
placeholder="/ (repo root)"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
@@ -159,8 +355,18 @@ function ServiceCreateDialog(props: {
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={!form.name.trim() || props.loading}
|
||||
onClick={() => props.onSubmit({ ...form, name: form.name.trim(), image: form.image?.trim(), command: form.command?.trim() })}
|
||||
disabled={!canSubmit || props.loading}
|
||||
onClick={() =>
|
||||
props.onSubmit({
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
image: source === 'image' ? form.image?.trim() : '',
|
||||
command: form.command?.trim(),
|
||||
git_repo: source === 'git' ? form.git_repo?.trim() : '',
|
||||
git_branch: source === 'git' ? form.git_branch?.trim() : '',
|
||||
build_path: source === 'git' ? form.build_path?.trim() : '',
|
||||
})
|
||||
}
|
||||
className="px-5 py-2 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
|
||||
+77
-351
@@ -84,6 +84,76 @@
|
||||
--bg-0: var(--bg-void);
|
||||
--bg-1: var(--bg-base);
|
||||
--bg-2: var(--bg-surface);
|
||||
--bg-primary: var(--bg-base);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--bg-void: #f4f6fb;
|
||||
--bg-base: #ffffff;
|
||||
--bg-surface: #eef1f7;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-overlay: #ffffff;
|
||||
|
||||
--surface-card: #ffffff;
|
||||
--surface-card-hover: #eef1f7;
|
||||
--surface-muted: rgba(17, 18, 23, 0.05);
|
||||
--surface-glass: rgba(255, 255, 255, 0.72);
|
||||
--surface-base: #ffffff;
|
||||
|
||||
--text-primary: #151821;
|
||||
--text-secondary: #4f5668;
|
||||
--text-tertiary: #7b8498;
|
||||
--text-muted: #7b8498;
|
||||
|
||||
--accent-primary: #d92d64;
|
||||
--accent-primary-soft: rgba(217, 45, 100, 0.12);
|
||||
--accent-primary-glow: rgba(217, 45, 100, 0.22);
|
||||
--accent-secondary: #7357d8;
|
||||
--accent-tertiary: #2f67d8;
|
||||
|
||||
--success: #168a55;
|
||||
--success-soft: rgba(22, 138, 85, 0.12);
|
||||
--success-glow: rgba(22, 138, 85, 0.2);
|
||||
|
||||
--warning: #c7511f;
|
||||
--warning-soft: rgba(199, 81, 31, 0.12);
|
||||
--warning-glow: rgba(199, 81, 31, 0.2);
|
||||
|
||||
--error: #c9362b;
|
||||
--error-soft: rgba(201, 54, 43, 0.12);
|
||||
--error-glow: rgba(201, 54, 43, 0.2);
|
||||
|
||||
--info: #2f67d8;
|
||||
--info-soft: rgba(47, 103, 216, 0.12);
|
||||
|
||||
--border-subtle: rgba(21, 24, 33, 0.1);
|
||||
--border-default: rgba(21, 24, 33, 0.16);
|
||||
--border-strong: rgba(217, 45, 100, 0.35);
|
||||
--border-glow: rgba(217, 45, 100, 0.45);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(17, 18, 23, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(17, 18, 23, 0.1);
|
||||
--shadow-lg: 0 8px 30px rgba(17, 18, 23, 0.12);
|
||||
--shadow-xl: 0 16px 48px rgba(17, 18, 23, 0.16);
|
||||
--shadow-glow: 0 0 20px var(--accent-primary-glow);
|
||||
--shadow-glow-lg: 0 0 40px var(--accent-primary-glow);
|
||||
|
||||
--text: var(--text-primary);
|
||||
--text-dim: var(--text-secondary);
|
||||
--line: var(--border-default);
|
||||
--line-strong: var(--border-strong);
|
||||
--accent: var(--accent-primary);
|
||||
--accent-soft: var(--accent-primary-soft);
|
||||
--ok: var(--success);
|
||||
--warn: var(--warning);
|
||||
--bad: var(--error);
|
||||
--surface: var(--bg-elevated);
|
||||
--surface-soft: var(--surface-card);
|
||||
--surface-muted: var(--bg-surface);
|
||||
--bg-0: var(--bg-void);
|
||||
--bg-1: var(--bg-base);
|
||||
--bg-2: var(--bg-surface);
|
||||
--bg-primary: var(--bg-base);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -100,7 +170,7 @@ body,
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
color: var(--text-primary);
|
||||
background: #16171c;
|
||||
background: var(--bg-void);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -115,7 +185,7 @@ body {
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
background: #16171c;
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
/* Ambient glow effects for premium depth */
|
||||
@@ -162,56 +232,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations from self.html */
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Live pulse animation for status indicators */
|
||||
@keyframes livePulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
.live-pulse {
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Subtle glow pulse for active elements */
|
||||
@keyframes glowPulse {
|
||||
0%, 100% {
|
||||
@@ -226,22 +246,6 @@ body {
|
||||
animation: glowPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Slide in from right - for toasts and modals */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Fade in scale - for modals and overlays */
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
@@ -270,28 +274,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
@@ -365,177 +347,6 @@ textarea {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Card icon - matching self.html */
|
||||
.card-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: #9295a4;
|
||||
fill: none;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Arrow button - matching self.html */
|
||||
.arrow-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
background: #e8316a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.arrow-btn:hover {
|
||||
transform: scale(1.07);
|
||||
}
|
||||
|
||||
.arrow-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke: white;
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Pill group - matching self.html */
|
||||
.pill-group {
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 5px 14px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: #6b6e7d;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pill.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e8e9f0;
|
||||
}
|
||||
|
||||
.pill:hover:not(.active) {
|
||||
color: #9295a4;
|
||||
}
|
||||
|
||||
/* Flag - matching self.html */
|
||||
.flag {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Stat dot - matching self.html */
|
||||
.stat-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Cache segment - matching self.html */
|
||||
.cache-seg {
|
||||
height: 32px;
|
||||
transition: opacity 0.2s, filter 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cache-seg:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* Speed row - matching self.html */
|
||||
.speed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.speed-row svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* Live indicator - matching self.html */
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3dd68c;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
|
||||
.live-dot.stopped {
|
||||
background: #6b6e7d;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Badge - matching self.html */
|
||||
.badge-active {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(61, 214, 140, 0.15);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.7px;
|
||||
color: #3dd68c;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge-stopped {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.7px;
|
||||
color: #6b6e7d;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Subtle grid background - Railway-style dots */
|
||||
.subtle-grid {
|
||||
background-image:
|
||||
@@ -589,18 +400,7 @@ textarea {
|
||||
100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0% { box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.7); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(61, 214, 140, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(61, 214, 140, 0); }
|
||||
}
|
||||
|
||||
/* Card animations - matching self.html */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fadeUp 0.4s ease both;
|
||||
}
|
||||
@@ -718,17 +518,6 @@ textarea {
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.03) 25%,
|
||||
rgba(255, 255, 255, 0.08) 50%,
|
||||
rgba(255, 255, 255, 0.03) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Hover lift effect for cards */
|
||||
.hover-lift {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
@@ -778,18 +567,6 @@ textarea {
|
||||
animation: gradient-rotate 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Ripple effect for buttons */
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ripple-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -1073,8 +850,8 @@ textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
height: 34px;
|
||||
@@ -1084,7 +861,7 @@ textarea {
|
||||
.search-box svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: #6b6e7d;
|
||||
stroke: var(--text-tertiary);
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
@@ -1095,14 +872,14 @@ textarea {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #9295a4;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #6b6e7d;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Tab - self.html exact match */
|
||||
@@ -1172,47 +949,6 @@ textarea {
|
||||
color: #9295a4;
|
||||
}
|
||||
|
||||
/* Arrow Button - self.html style (pink circular) */
|
||||
.arrow-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
background: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.arrow-btn:hover {
|
||||
transform: scale(1.07);
|
||||
}
|
||||
|
||||
.arrow-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke: white;
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Live Dot Pulse - self.html style */
|
||||
@keyframes pulse-dot {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(61, 214, 140, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(61, 214, 140, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -1489,16 +1225,6 @@ textarea {
|
||||
animation: ripple 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Shimmer loading effect */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
|
||||
@@ -148,6 +148,105 @@ export type UpdateUserProfileInput = {
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type CreateUserInput = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type UpgradeStatus = {
|
||||
imageRef: string;
|
||||
registry: string;
|
||||
dockerAvailable: boolean;
|
||||
installed: boolean;
|
||||
digest?: string;
|
||||
size?: number;
|
||||
authConfigured: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SystemLoad = {
|
||||
load1m: number;
|
||||
load5m: number;
|
||||
load15m: number;
|
||||
};
|
||||
|
||||
export type HostMonitoring = {
|
||||
hostname: string;
|
||||
os: string;
|
||||
architecture: string;
|
||||
cpu: {
|
||||
cores: number;
|
||||
};
|
||||
memory: {
|
||||
total: number;
|
||||
used: number;
|
||||
available: number;
|
||||
usagePercent: number;
|
||||
};
|
||||
storage: {
|
||||
path: string;
|
||||
total: number;
|
||||
used: number;
|
||||
available: number;
|
||||
usagePercent: number;
|
||||
};
|
||||
load: SystemLoad;
|
||||
uptimeSeconds: number;
|
||||
dockerAvailable: boolean;
|
||||
docker?: {
|
||||
containers?: number;
|
||||
images?: number;
|
||||
driver?: string;
|
||||
server?: string;
|
||||
};
|
||||
collectedAt: string;
|
||||
};
|
||||
|
||||
export type AgentCapabilities = {
|
||||
containerRuntimes: string[];
|
||||
supportedArchitectures: string[];
|
||||
maxContainers: number;
|
||||
storageDriver: string;
|
||||
networkPlugins: string[];
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export type NodeResources = {
|
||||
cpu: {
|
||||
cores: number;
|
||||
allocation: number;
|
||||
usage: number;
|
||||
};
|
||||
memory: {
|
||||
total: number;
|
||||
allocated: number;
|
||||
used: number;
|
||||
available: number;
|
||||
};
|
||||
storage: {
|
||||
total: number;
|
||||
allocated: number;
|
||||
used: number;
|
||||
available: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type NodeAgentEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
ipAddress: string;
|
||||
port: number;
|
||||
status: string;
|
||||
version: string;
|
||||
capabilities: AgentCapabilities;
|
||||
resources: NodeResources;
|
||||
lastHeartbeat?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type AuditLogEntity = {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -256,6 +355,91 @@ type RawServiceLog = components['schemas']['ServiceLogEntry'];
|
||||
type RawServiceLogsResponse = components['schemas']['ServiceLogsResponse'];
|
||||
type RawDeploymentLogsResponse = components['schemas']['DeploymentLogsResponse'];
|
||||
type RawRollbackDeploymentResponse = components['schemas']['RollbackDeploymentResponse'];
|
||||
type RawUpgradeStatus = {
|
||||
image_ref?: string;
|
||||
registry?: string;
|
||||
docker_available?: boolean;
|
||||
installed?: boolean;
|
||||
digest?: string;
|
||||
size?: number;
|
||||
auth_configured?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
type RawSystemLoad = {
|
||||
load_1m?: number;
|
||||
load_5m?: number;
|
||||
load_15m?: number;
|
||||
};
|
||||
type RawHostMonitoring = {
|
||||
hostname?: string;
|
||||
os?: string;
|
||||
architecture?: string;
|
||||
cpu?: {
|
||||
cores?: number;
|
||||
};
|
||||
memory?: {
|
||||
total?: number;
|
||||
used?: number;
|
||||
available?: number;
|
||||
usage_percent?: number;
|
||||
};
|
||||
storage?: {
|
||||
path?: string;
|
||||
total?: number;
|
||||
used?: number;
|
||||
available?: number;
|
||||
usage_percent?: number;
|
||||
};
|
||||
load?: RawSystemLoad;
|
||||
uptime_seconds?: number;
|
||||
docker_available?: boolean;
|
||||
docker?: {
|
||||
containers?: number;
|
||||
images?: number;
|
||||
driver?: string;
|
||||
server?: string;
|
||||
};
|
||||
collected_at?: string;
|
||||
};
|
||||
type RawNodeAgent = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
ip_address?: string;
|
||||
port?: number;
|
||||
status?: string;
|
||||
version?: string;
|
||||
capabilities?: {
|
||||
container_runtimes?: string[];
|
||||
supported_architectures?: string[];
|
||||
max_containers?: number;
|
||||
storage_driver?: string;
|
||||
network_plugins?: string[];
|
||||
features?: string[];
|
||||
};
|
||||
resources?: {
|
||||
cpu?: {
|
||||
cores?: number;
|
||||
allocation?: number;
|
||||
usage?: number;
|
||||
};
|
||||
memory?: {
|
||||
total?: number;
|
||||
allocated?: number;
|
||||
used?: number;
|
||||
available?: number;
|
||||
};
|
||||
storage?: {
|
||||
total?: number;
|
||||
allocated?: number;
|
||||
used?: number;
|
||||
available?: number;
|
||||
};
|
||||
};
|
||||
last_heartbeat?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
|
||||
export type CreateServiceInput = components['schemas']['CreateServiceRequest'] & {
|
||||
@@ -286,12 +470,17 @@ type JsonLike = Record<string, unknown>;
|
||||
|
||||
const rawBase = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082';
|
||||
const normalizedBase = rawBase.replace(/\/$/, '');
|
||||
const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
|
||||
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return API_BASE;
|
||||
}
|
||||
|
||||
export function getAgentPublicBaseUrl(): string {
|
||||
return `${API_ROOT}/api/agents`;
|
||||
}
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
return { 'Content-Type': 'application/json' };
|
||||
}
|
||||
@@ -669,6 +858,151 @@ export async function updateCurrentUserProfile(input: UpdateUserProfileInput): P
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function createUser(input: CreateUserInput): Promise<UserProfile> {
|
||||
const payload = await requestJson<RawUserProfile | { user?: RawUserProfile }>(`/users`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const rawProfile = (payload as { user?: RawUserProfile }).user ?? (payload as RawUserProfile);
|
||||
const profile = normalizeUserProfile(rawProfile);
|
||||
|
||||
if (!profile) {
|
||||
throw new ApiError('Create user response is invalid', 500);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
function normalizeUpgradeStatus(payload: RawUpgradeStatus): UpgradeStatus {
|
||||
return {
|
||||
imageRef: payload.image_ref ?? '',
|
||||
registry: payload.registry ?? '',
|
||||
dockerAvailable: Boolean(payload.docker_available),
|
||||
installed: Boolean(payload.installed),
|
||||
digest: payload.digest,
|
||||
size: payload.size,
|
||||
authConfigured: Boolean(payload.auth_configured),
|
||||
message: payload.message ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSystemLoad(payload?: RawSystemLoad): SystemLoad {
|
||||
return {
|
||||
load1m: payload?.load_1m ?? 0,
|
||||
load5m: payload?.load_5m ?? 0,
|
||||
load15m: payload?.load_15m ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHostMonitoring(payload: RawHostMonitoring): HostMonitoring {
|
||||
return {
|
||||
hostname: payload.hostname ?? '',
|
||||
os: payload.os ?? '',
|
||||
architecture: payload.architecture ?? '',
|
||||
cpu: {
|
||||
cores: payload.cpu?.cores ?? 0,
|
||||
},
|
||||
memory: {
|
||||
total: payload.memory?.total ?? 0,
|
||||
used: payload.memory?.used ?? 0,
|
||||
available: payload.memory?.available ?? 0,
|
||||
usagePercent: payload.memory?.usage_percent ?? 0,
|
||||
},
|
||||
storage: {
|
||||
path: payload.storage?.path ?? '/',
|
||||
total: payload.storage?.total ?? 0,
|
||||
used: payload.storage?.used ?? 0,
|
||||
available: payload.storage?.available ?? 0,
|
||||
usagePercent: payload.storage?.usage_percent ?? 0,
|
||||
},
|
||||
load: normalizeSystemLoad(payload.load),
|
||||
uptimeSeconds: payload.uptime_seconds ?? 0,
|
||||
dockerAvailable: Boolean(payload.docker_available),
|
||||
docker: payload.docker,
|
||||
collectedAt: payload.collected_at ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
|
||||
if (!agent.id || !agent.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
hostname: agent.hostname ?? '',
|
||||
ipAddress: agent.ip_address ?? '',
|
||||
port: agent.port ?? 0,
|
||||
status: agent.status ?? 'unknown',
|
||||
version: agent.version ?? '',
|
||||
capabilities: {
|
||||
containerRuntimes: agent.capabilities?.container_runtimes ?? [],
|
||||
supportedArchitectures: agent.capabilities?.supported_architectures ?? [],
|
||||
maxContainers: agent.capabilities?.max_containers ?? 0,
|
||||
storageDriver: agent.capabilities?.storage_driver ?? '',
|
||||
networkPlugins: agent.capabilities?.network_plugins ?? [],
|
||||
features: agent.capabilities?.features ?? [],
|
||||
},
|
||||
resources: {
|
||||
cpu: {
|
||||
cores: agent.resources?.cpu?.cores ?? 0,
|
||||
allocation: agent.resources?.cpu?.allocation ?? 0,
|
||||
usage: agent.resources?.cpu?.usage ?? 0,
|
||||
},
|
||||
memory: {
|
||||
total: agent.resources?.memory?.total ?? 0,
|
||||
allocated: agent.resources?.memory?.allocated ?? 0,
|
||||
used: agent.resources?.memory?.used ?? 0,
|
||||
available: agent.resources?.memory?.available ?? 0,
|
||||
},
|
||||
storage: {
|
||||
total: agent.resources?.storage?.total ?? 0,
|
||||
allocated: agent.resources?.storage?.allocated ?? 0,
|
||||
used: agent.resources?.storage?.used ?? 0,
|
||||
available: agent.resources?.storage?.available ?? 0,
|
||||
},
|
||||
},
|
||||
lastHeartbeat: agent.last_heartbeat,
|
||||
createdAt: agent.created_at,
|
||||
updatedAt: agent.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgentArray(raw: unknown): NodeAgentEntity[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw
|
||||
.map((entry) => normalizeAgent(entry as RawNodeAgent))
|
||||
.filter((entry): entry is NodeAgentEntity => entry !== null);
|
||||
}
|
||||
|
||||
export async function getUpgradeStatus(): Promise<UpgradeStatus> {
|
||||
const payload = await requestJson<RawUpgradeStatus>(`/system/upgrade/status`);
|
||||
return normalizeUpgradeStatus(payload);
|
||||
}
|
||||
|
||||
export async function pullUpgradeImage(): Promise<UpgradeStatus> {
|
||||
const payload = await requestJson<RawUpgradeStatus>(`/system/upgrade/pull`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return normalizeUpgradeStatus(payload);
|
||||
}
|
||||
|
||||
export async function getHostMonitoring(): Promise<HostMonitoring> {
|
||||
const payload = await requestJson<RawHostMonitoring>(`/system/host`);
|
||||
return normalizeHostMonitoring(payload);
|
||||
}
|
||||
|
||||
export async function listAgents(): Promise<NodeAgentEntity[]> {
|
||||
const payload = await requestJson<{ agents?: RawNodeAgent[] }>(`/agents`);
|
||||
return normalizeAgentArray(payload.agents);
|
||||
}
|
||||
|
||||
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
|
||||
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
|
||||
method: 'POST',
|
||||
@@ -958,3 +1292,109 @@ export function serviceStatusClass(status: ServiceStatus): string {
|
||||
return 'status-stopped';
|
||||
}
|
||||
}
|
||||
|
||||
export type ServiceInstanceMetrics = {
|
||||
container_id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
cpu_percent: number;
|
||||
memory_usage_bytes: number;
|
||||
memory_limit_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
started_at?: string;
|
||||
};
|
||||
|
||||
export type ServiceMetrics = {
|
||||
service_id: string;
|
||||
status: 'ok' | 'no_containers' | 'docker_unavailable';
|
||||
instances: ServiceInstanceMetrics[];
|
||||
cpu_percent: number;
|
||||
memory_usage_bytes: number;
|
||||
memory_limit_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
collected_at: string;
|
||||
};
|
||||
|
||||
export async function getServiceMetrics(serviceId: string): Promise<ServiceMetrics> {
|
||||
const payload = await requestJson<{ metrics?: ServiceMetrics }>(`/services/${serviceId}/metrics`);
|
||||
if (!payload.metrics) {
|
||||
throw new ApiError('Service metrics payload is invalid', 500);
|
||||
}
|
||||
return payload.metrics;
|
||||
}
|
||||
|
||||
export type GitProviderEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
api_url: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type GitRepositoryEntity = {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string;
|
||||
clone_url: string;
|
||||
default_branch: string;
|
||||
is_private: boolean;
|
||||
};
|
||||
|
||||
export type GitBranchEntity = {
|
||||
name: string;
|
||||
protected: boolean;
|
||||
};
|
||||
|
||||
export async function listGitProviders(): Promise<GitProviderEntity[]> {
|
||||
const payload = await requestJson<{ providers?: GitProviderEntity[] }>('/git/providers');
|
||||
return payload.providers ?? [];
|
||||
}
|
||||
|
||||
export async function createGitProvider(input: {
|
||||
name: 'github' | 'gitlab' | 'bitbucket' | 'gitea';
|
||||
display_name: string;
|
||||
access_token: string;
|
||||
api_url?: string;
|
||||
}): Promise<GitProviderEntity> {
|
||||
const payload = await requestJson<GitProviderEntity>('/git/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!payload.id) {
|
||||
throw new ApiError('Provider response is invalid', 500);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function deleteGitProvider(providerId: string): Promise<void> {
|
||||
await requestJson(`/git/providers/${providerId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listGitRepositories(
|
||||
providerId: string,
|
||||
search?: string,
|
||||
): Promise<GitRepositoryEntity[]> {
|
||||
const params = new URLSearchParams({ limit: '100' });
|
||||
if (search?.trim()) {
|
||||
params.set('search', search.trim());
|
||||
}
|
||||
const payload = await requestJson<{ repositories?: GitRepositoryEntity[] }>(
|
||||
`/git/providers/${providerId}/repositories?${params.toString()}`,
|
||||
);
|
||||
return payload.repositories ?? [];
|
||||
}
|
||||
|
||||
export async function listGitBranches(
|
||||
providerId: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<GitBranchEntity[]> {
|
||||
const payload = await requestJson<{ branches?: GitBranchEntity[] }>(
|
||||
`/git/providers/${providerId}/repositories/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`,
|
||||
);
|
||||
return payload.branches ?? [];
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export type AuthSessionPayload = {
|
||||
session: AuthSession;
|
||||
};
|
||||
|
||||
export type AuthBootstrap = {
|
||||
hasUsers: boolean;
|
||||
userCount: number;
|
||||
mode: 'register' | 'login';
|
||||
};
|
||||
|
||||
export class AuthError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
@@ -33,6 +39,30 @@ export function getAuthBaseUrl(): string {
|
||||
return AUTH_BASE;
|
||||
}
|
||||
|
||||
export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
||||
const apiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082').replace(/\/$/, '');
|
||||
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
|
||||
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload?.message as string | undefined) ??
|
||||
(payload?.error as string | undefined) ??
|
||||
`Auth bootstrap request failed with status ${response.status}`;
|
||||
throw new AuthError(message, response.status);
|
||||
}
|
||||
|
||||
return {
|
||||
hasUsers: Boolean(payload?.has_users),
|
||||
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
|
||||
mode: payload?.mode === 'register' ? 'register' : 'login',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionPayload(payload: unknown): AuthSessionPayload | null {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
@@ -160,19 +190,6 @@ export async function signUpWithEmail(name: string, email: string, password: str
|
||||
return normalizeSessionPayload(payload);
|
||||
}
|
||||
|
||||
export async function requestMagicLinkInvite(email: string, callbackURL: string): Promise<void> {
|
||||
await authRequest('/sign-in/magic-link', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
callbackURL,
|
||||
metadata: {
|
||||
invite: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startGitHubSignIn(callbackURL: string): Promise<void> {
|
||||
const payload = await authRequest('/sign-in/social', {
|
||||
method: 'POST',
|
||||
@@ -189,11 +206,11 @@ export async function startGitHubSignIn(callbackURL: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string): Promise<void> {
|
||||
const payload = await authRequest('/sign-in/oauth2', {
|
||||
export async function startGoogleSignIn(callbackURL: string): Promise<void> {
|
||||
const payload = await authRequest('/sign-in/social', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
providerId,
|
||||
provider: 'google',
|
||||
callbackURL,
|
||||
disableRedirect: true,
|
||||
}),
|
||||
@@ -205,18 +222,6 @@ async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function startGitLabSignIn(callbackURL: string): Promise<void> {
|
||||
await startOAuth2ProviderSignIn('gitlab', callbackURL);
|
||||
}
|
||||
|
||||
export async function startBitbucketSignIn(callbackURL: string): Promise<void> {
|
||||
await startOAuth2ProviderSignIn('bitbucket', callbackURL);
|
||||
}
|
||||
|
||||
export async function startGiteaSignIn(callbackURL: string): Promise<void> {
|
||||
await startOAuth2ProviderSignIn('gitea', callbackURL);
|
||||
}
|
||||
|
||||
export async function signOutAuthSession(): Promise<void> {
|
||||
await authRequest('/sign-out', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -36,6 +36,16 @@ export function formatDate(iso?: string): string {
|
||||
return dt.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
const value = bytes / Math.pow(1024, index);
|
||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function seededMetric(seed: string, min: number, max: number): number {
|
||||
let hash = 0;
|
||||
for (let idx = 0; idx < seed.length; idx += 1) {
|
||||
|
||||
@@ -17,10 +17,12 @@ function toWebSocketUrl(apiBase: string): string {
|
||||
return `${wsBase}/ws`;
|
||||
}
|
||||
|
||||
export type LiveSyncStatus = 'idle' | 'offline' | 'live';
|
||||
|
||||
export function useBuildUpdates(
|
||||
buildIds: string[],
|
||||
onBuildUpdate: (payload: BuildUpdatePayload) => void,
|
||||
): boolean {
|
||||
): LiveSyncStatus {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const callbackRef = useRef(onBuildUpdate);
|
||||
useEffect(() => {
|
||||
@@ -127,5 +129,8 @@ export function useBuildUpdates(
|
||||
};
|
||||
}, [buildIdsKey, subscriptionIds]);
|
||||
|
||||
return subscriptionIds.length > 0 && connected;
|
||||
if (subscriptionIds.length === 0) {
|
||||
return 'idle';
|
||||
}
|
||||
return connected ? 'live' : 'offline';
|
||||
}
|
||||
@@ -184,7 +184,6 @@ export function CacheMetricCard({
|
||||
}: CacheMetricCardProps) {
|
||||
const cachePercent = Math.round((cacheMB / totalMB) * 100);
|
||||
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
|
||||
const freePercent = 100 - cachePercent - nonCachePercent;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -316,7 +315,6 @@ export function PerformanceMetricCard({
|
||||
percentage,
|
||||
upSpeed,
|
||||
downSpeed,
|
||||
datasets,
|
||||
onClick,
|
||||
animationDelay = 0,
|
||||
}: PerformanceMetricCardProps) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as ToastPrimitive from '@radix-ui/react-toast';
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { IconX, IconCheck, IconAlertCircle, IconInfoCircle } from '@tabler/icons-react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
import { ToastContext, type ToastType } from '../hooks/use-toast';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
@@ -11,20 +11,6 @@ interface Toast {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (type: ToastType, title: string, description?: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -32,7 +18,11 @@ interface ToastProviderProps {
|
||||
export function ToastProvider({ children }: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = (type: ToastType, title: string, description?: string) => {
|
||||
const showToast = (first: ToastType | string, second: ToastType | string, description?: string) => {
|
||||
const toastTypes: ToastType[] = ['success', 'error', 'info', 'warning'];
|
||||
const firstIsType = toastTypes.includes(first as ToastType);
|
||||
const type = (firstIsType ? first : second) as ToastType;
|
||||
const title = (firstIsType ? second : first) as string;
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
setToasts((prev) => [...prev, { id, type, title, description }]);
|
||||
|
||||
|
||||
@@ -6,3 +6,5 @@ export * from './DonutChart';
|
||||
export * from './EnhancedMetricCard';
|
||||
export * from './CommandPalette';
|
||||
export * from './StatusBadge';
|
||||
export { useToast } from '../hooks/use-toast';
|
||||
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
||||
@@ -3,7 +3,7 @@ import { createContext, useContext } from 'react';
|
||||
export type ToastType = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
export interface ToastContextValue {
|
||||
showToast: (message: string, type?: ToastType) => void;
|
||||
showToast: (first: ToastType | string, second: ToastType | string, description?: string) => void;
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
@@ -11,7 +11,7 @@ export const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
@@ -1,29 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"apwhy/internal/api"
|
||||
"apwhy/internal/config"
|
||||
"apwhy/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
store, err := storage.Open(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
server := api.NewServer(store, cfg)
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
log.Printf("APwhy server listening on http://localhost%s", addr)
|
||||
if err := http.ListenAndServe(addr, server.Handler()); err != nil {
|
||||
log.Fatalf("server exited: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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.IsDevelopment() {
|
||||
if err := db.SeedData(); err != nil {
|
||||
log.Printf("Warning: Failed to seed data: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Redis
|
||||
redis := database.NewRedis("redis://localhost:6379") // Default Redis URL
|
||||
|
||||
// Setup Gin router
|
||||
if cfg.IsProduction() {
|
||||
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.CORSOrigins,
|
||||
AllowedMethods: cfg.CORSMethods,
|
||||
AllowedHeaders: cfg.CORSHeaders,
|
||||
ExposedHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: cfg.CORSCredentials,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
|
||||
// Wrap Gin router with CORS
|
||||
handler := c.Handler(router)
|
||||
|
||||
// Initialize API routes
|
||||
api.SetupRoutes(router, db, redis, cfg)
|
||||
|
||||
// Create HTTP server
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
log.Printf("Server starting on %s", addr)
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
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 %s", addr)
|
||||
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")
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"containr/internal/build"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("🧪 Testing Build Manager Detection...")
|
||||
|
||||
// Test build type detection on the current project
|
||||
fmt.Println("\n📁 Testing on current project (has package.json)...")
|
||||
|
||||
// Note: We can't fully test BuildManager without a docker client,
|
||||
// but we can test the detection logic
|
||||
|
||||
// Create mock scenarios
|
||||
testCases := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"Node.js project (package.json)", "railpack"},
|
||||
{"Python project (requirements.txt)", "railpack"},
|
||||
{"Go project (go.mod)", "railpack"},
|
||||
{"Dockerfile project", "dockerfile"},
|
||||
{"Unknown project", "nixpacks"},
|
||||
}
|
||||
|
||||
fmt.Println("\n🎯 Expected detection priorities:")
|
||||
for _, tc := range testCases {
|
||||
fmt.Printf(" • %s → %s\n", tc.name, tc.expected)
|
||||
}
|
||||
|
||||
// Test Railpack builder directly
|
||||
builder := build.NewRailpackBuilder("/tmp/test", nil)
|
||||
|
||||
fmt.Println("\n🔍 Testing Railpack detection on current project:")
|
||||
err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Detection failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✅ Railpack can build this project!")
|
||||
}
|
||||
|
||||
fmt.Println("\n📋 Build Priority Order:")
|
||||
fmt.Println(" 1. Dockerfile (if present)")
|
||||
fmt.Println(" 2. Railpack (primary choice)")
|
||||
fmt.Println(" 3. Nixpacks (fallback)")
|
||||
fmt.Println(" 4. Prebuilt (manual)")
|
||||
|
||||
fmt.Println("\n🚀 Build Manager Integration Test Complete!")
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"containr/internal/build"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a RailpackBuilder
|
||||
builder := build.NewRailpackBuilder("/tmp/containr-build-test", nil)
|
||||
|
||||
// Test detection on the current project (has package.json)
|
||||
fmt.Println("Testing Railpack detection on current project...")
|
||||
|
||||
err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Railpack detection failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✅ Railpack can build this project!")
|
||||
}
|
||||
|
||||
// Show supported frameworks
|
||||
fmt.Println("\nSupported frameworks:")
|
||||
frameworks := builder.GetSupportedFrameworks()
|
||||
for _, fw := range frameworks {
|
||||
fmt.Printf(" - %s\n", fw)
|
||||
}
|
||||
|
||||
fmt.Println("\n✅ Railpack integration test completed successfully!")
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$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
@@ -1,385 +0,0 @@
|
||||
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.
|
||||
Whitespace-only changes.
+5
-16
@@ -64,22 +64,11 @@ services:
|
||||
CONTAINR_AGENT_AUTH_TOKENS: ${CONTAINR_AGENT_AUTH_TOKENS:-}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
GITLAB_CLIENT_ID: ${GITLAB_CLIENT_ID:-PLACEHOLDER_GITLAB_CLIENT_ID}
|
||||
GITLAB_CLIENT_SECRET: ${GITLAB_CLIENT_SECRET:-PLACEHOLDER_GITLAB_CLIENT_SECRET}
|
||||
GITLAB_OAUTH_AUTHORIZE_URL: ${GITLAB_OAUTH_AUTHORIZE_URL:-https://gitlab.com/oauth/authorize}
|
||||
GITLAB_OAUTH_TOKEN_URL: ${GITLAB_OAUTH_TOKEN_URL:-https://gitlab.com/oauth/token}
|
||||
GITLAB_OAUTH_USERINFO_URL: ${GITLAB_OAUTH_USERINFO_URL:-https://gitlab.com/api/v4/user}
|
||||
BITBUCKET_CLIENT_ID: ${BITBUCKET_CLIENT_ID:-PLACEHOLDER_BITBUCKET_CLIENT_ID}
|
||||
BITBUCKET_CLIENT_SECRET: ${BITBUCKET_CLIENT_SECRET:-PLACEHOLDER_BITBUCKET_CLIENT_SECRET}
|
||||
BITBUCKET_OAUTH_AUTHORIZE_URL: ${BITBUCKET_OAUTH_AUTHORIZE_URL:-https://bitbucket.org/site/oauth2/authorize}
|
||||
BITBUCKET_OAUTH_TOKEN_URL: ${BITBUCKET_OAUTH_TOKEN_URL:-https://bitbucket.org/site/oauth2/access_token}
|
||||
BITBUCKET_OAUTH_USERINFO_URL: ${BITBUCKET_OAUTH_USERINFO_URL:-https://api.bitbucket.org/2.0/user}
|
||||
BITBUCKET_OAUTH_EMAILS_URL: ${BITBUCKET_OAUTH_EMAILS_URL:-https://api.bitbucket.org/2.0/user/emails}
|
||||
GITEA_CLIENT_ID: ${GITEA_CLIENT_ID:-PLACEHOLDER_GITEA_CLIENT_ID}
|
||||
GITEA_CLIENT_SECRET: ${GITEA_CLIENT_SECRET:-PLACEHOLDER_GITEA_CLIENT_SECRET}
|
||||
GITEA_OAUTH_AUTHORIZE_URL: ${GITEA_OAUTH_AUTHORIZE_URL:-https://gitea.example.com/login/oauth/authorize}
|
||||
GITEA_OAUTH_TOKEN_URL: ${GITEA_OAUTH_TOKEN_URL:-https://gitea.example.com/login/oauth/access_token}
|
||||
GITEA_OAUTH_USERINFO_URL: ${GITEA_OAUTH_USERINFO_URL:-https://gitea.example.com/api/v1/user}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
CONTAINR_UPGRADE_IMAGE_REF: ${CONTAINR_UPGRADE_IMAGE_REF:-}
|
||||
GITHUB_CONTAINER_REGISTRY_USER: ${GITHUB_CONTAINER_REGISTRY_USER:-}
|
||||
GITHUB_CONTAINER_REGISTRY_TOKEN: ${GITHUB_CONTAINER_REGISTRY_TOKEN:-}
|
||||
GITHUB_APP_ID: ${GITHUB_APP_ID:-}
|
||||
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
|
||||
GITHUB_APP_PRIVATE_KEY: ${GITHUB_APP_PRIVATE_KEY:-}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,69 @@
|
||||
# Containr Auth And Shell Design
|
||||
|
||||
Date: 2026-04-16
|
||||
|
||||
## Scope
|
||||
|
||||
Change only `app/frontend` and `app/backend`. Leave legacy root `src/` frontend untouched.
|
||||
|
||||
## Auth
|
||||
|
||||
Containr uses embedded Better Auth for cookie sessions. Public auth supports only:
|
||||
|
||||
- Email/password
|
||||
- GitHub OAuth
|
||||
- Google OAuth
|
||||
|
||||
Magic links, GitLab, Bitbucket, and Gitea auth are removed from active auth UI and runtime config.
|
||||
|
||||
Bootstrap behavior:
|
||||
|
||||
- If no local platform users exist, auth UI sends users to first-account registration.
|
||||
- If at least one local platform user exists, public registration is disabled.
|
||||
- Existing users can sign in only.
|
||||
- New users after bootstrap must be created manually inside authenticated platform UI/API.
|
||||
|
||||
Backend enforcement:
|
||||
|
||||
- Better Auth blocks user creation after bootstrap unless request carries trusted internal token.
|
||||
- Go API exposes bootstrap status.
|
||||
- Go API exposes authenticated manual user creation. It creates both Better Auth credentials and local platform user.
|
||||
|
||||
## Shell
|
||||
|
||||
Desktop sidebar becomes expanded, non-scrollable, and label-first. Primary items:
|
||||
|
||||
- Projects
|
||||
- Templates
|
||||
- Usage
|
||||
- People
|
||||
- Settings
|
||||
- Docs
|
||||
|
||||
Bottom area keeps account access and settings/sign-out. Top logo placeholder links to `/projects`. Support is removed.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Upgrade control becomes functional. Backend exposes current image/ref status and a pull operation for configured GitHub Container Registry or Docker image refs. Frontend can auto-detect configured image and trigger pull.
|
||||
|
||||
## Notifications
|
||||
|
||||
Notifications menu shows live operational data from existing build/audit APIs with refresh. It no longer acts as static decoration.
|
||||
|
||||
## Theme
|
||||
|
||||
Add light mode support by toggling root theme attribute and CSS variables. Keep current dark palette as default.
|
||||
|
||||
## Task 7: Host Monitoring And Agents
|
||||
|
||||
Add backend/frontend foundation for host telemetry and distributed nodes:
|
||||
|
||||
- Monitor server running Containr.
|
||||
- Store/surface CPU, memory, disk, load, uptime, Docker availability, and node health.
|
||||
- Reuse existing node agent routes where possible.
|
||||
- Allow connecting additional VPS, LXC, VM, or bare-metal nodes through agent registration.
|
||||
- Expose data needed later by autoscaling and distributed placement.
|
||||
|
||||
## Validation
|
||||
|
||||
Run frontend typecheck/build for `app/frontend`. Run focused Go tests or `go test ./internal/api/... ./internal/middleware/...` from `app/backend` where feasible.
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,104 +0,0 @@
|
||||
module containr
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/docker/go-connections v0.6.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/rs/cors v1.10.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
modernc.org/sqlite v1.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
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-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // 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.30.1 // 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/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.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // 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/ncruces/go-strftime v0.1.9 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/pflag v1.0.10 // 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/exp v0.0.0-20250620022241-b7579e27df2b // 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
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -1,291 +0,0 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
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-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
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-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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.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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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.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/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
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=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<!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>
|
||||
@@ -116,22 +116,11 @@ services:
|
||||
- AUTH_PORT=${AUTH_PORT:-3001}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
|
||||
- GITLAB_CLIENT_ID=${GITLAB_CLIENT_ID:-PLACEHOLDER_GITLAB_CLIENT_ID}
|
||||
- GITLAB_CLIENT_SECRET=${GITLAB_CLIENT_SECRET:-PLACEHOLDER_GITLAB_CLIENT_SECRET}
|
||||
- GITLAB_OAUTH_AUTHORIZE_URL=${GITLAB_OAUTH_AUTHORIZE_URL:-https://gitlab.com/oauth/authorize}
|
||||
- GITLAB_OAUTH_TOKEN_URL=${GITLAB_OAUTH_TOKEN_URL:-https://gitlab.com/oauth/token}
|
||||
- GITLAB_OAUTH_USERINFO_URL=${GITLAB_OAUTH_USERINFO_URL:-https://gitlab.com/api/v4/user}
|
||||
- BITBUCKET_CLIENT_ID=${BITBUCKET_CLIENT_ID:-PLACEHOLDER_BITBUCKET_CLIENT_ID}
|
||||
- BITBUCKET_CLIENT_SECRET=${BITBUCKET_CLIENT_SECRET:-PLACEHOLDER_BITBUCKET_CLIENT_SECRET}
|
||||
- BITBUCKET_OAUTH_AUTHORIZE_URL=${BITBUCKET_OAUTH_AUTHORIZE_URL:-https://bitbucket.org/site/oauth2/authorize}
|
||||
- BITBUCKET_OAUTH_TOKEN_URL=${BITBUCKET_OAUTH_TOKEN_URL:-https://bitbucket.org/site/oauth2/access_token}
|
||||
- BITBUCKET_OAUTH_USERINFO_URL=${BITBUCKET_OAUTH_USERINFO_URL:-https://api.bitbucket.org/2.0/user}
|
||||
- BITBUCKET_OAUTH_EMAILS_URL=${BITBUCKET_OAUTH_EMAILS_URL:-https://api.bitbucket.org/2.0/user/emails}
|
||||
- GITEA_CLIENT_ID=${GITEA_CLIENT_ID:-PLACEHOLDER_GITEA_CLIENT_ID}
|
||||
- GITEA_CLIENT_SECRET=${GITEA_CLIENT_SECRET:-PLACEHOLDER_GITEA_CLIENT_SECRET}
|
||||
- GITEA_OAUTH_AUTHORIZE_URL=${GITEA_OAUTH_AUTHORIZE_URL:-https://gitea.example.com/login/oauth/authorize}
|
||||
- GITEA_OAUTH_TOKEN_URL=${GITEA_OAUTH_TOKEN_URL:-https://gitea.example.com/login/oauth/access_token}
|
||||
- GITEA_OAUTH_USERINFO_URL=${GITEA_OAUTH_USERINFO_URL:-https://gitea.example.com/api/v1/user}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
- CONTAINR_UPGRADE_IMAGE_REF=${CONTAINR_UPGRADE_IMAGE_REF:-}
|
||||
- GITHUB_CONTAINER_REGISTRY_USER=${GITHUB_CONTAINER_REGISTRY_USER:-}
|
||||
- GITHUB_CONTAINER_REGISTRY_TOKEN=${GITHUB_CONTAINER_REGISTRY_TOKEN:-}
|
||||
- GITHUB_APP_ID=${GITHUB_APP_ID:-}
|
||||
- GITHUB_APP_SLUG=${GITHUB_APP_SLUG:-}
|
||||
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY:-}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
WebsiteID string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey, websiteID string) *Client {
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
WebsiteID: websiteID,
|
||||
HTTP: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Enabled() bool {
|
||||
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
|
||||
}
|
||||
|
||||
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
|
||||
if !c.Enabled() {
|
||||
return map[string]any{
|
||||
"enabled": false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
|
||||
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
res, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["enabled"] = true
|
||||
return payload, nil
|
||||
}
|
||||
@@ -1,679 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,541 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Constants for validation and limits
|
||||
const (
|
||||
MaxServiceNameLength = 100
|
||||
MaxRoutePrefixLength = 200
|
||||
MaxUpstreamURLLength = 500
|
||||
MaxAPIKeyNameLength = 100
|
||||
MinAPIKeyLength = 20
|
||||
MaxAPIKeyLength = 100
|
||||
DefaultRPMLimit = 60
|
||||
DefaultMonthlyQuota = 1000
|
||||
MaxRPMLimit = 10000
|
||||
MaxMonthlyQuota = 10000000
|
||||
)
|
||||
|
||||
// Validator instance for request validation
|
||||
var validate = validator.New()
|
||||
|
||||
// APIError represents a structured API error response
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// APIResponse represents a standardized API response
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Meta contains pagination and metadata
|
||||
type Meta struct {
|
||||
Page int `json:"page,omitempty"`
|
||||
PerPage int `json:"per_page,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
TotalPages int `json:"total_pages,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceRequest represents the request payload for creating/updating services
|
||||
type ServiceRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100" validate:"required,min=1,max=100"`
|
||||
UpstreamURL string `json:"upstreamUrl" binding:"required,max=500" validate:"required,url,max=500"`
|
||||
RoutePrefix string `json:"routePrefix" binding:"required,max=200" validate:"required,min=1,max=200"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
RPMLimit *int `json:"rpmLimit,omitempty" validate:"omitempty,min=1,max=10000"`
|
||||
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
|
||||
}
|
||||
|
||||
// APIKeyRequest represents the request payload for creating/updating API keys
|
||||
type APIKeyRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100" validate:"required,min=1,max=100"`
|
||||
Plan string `json:"plan" validate:"omitempty,oneof=free pro business enterprise"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
RPMLimit *int `json:"rpmLimit,omitempty" validate:"omitempty,min=1,max=10000"`
|
||||
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
|
||||
}
|
||||
|
||||
// generateServiceID generates a cryptographically secure service ID
|
||||
func generateServiceID() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return "svc_" + hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateAPIKey generates a cryptographically secure API key
|
||||
func generateAPIKey() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return "ap_" + hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// hashAPIKey creates a bcrypt hash for the API key
|
||||
func hashAPIKey(key string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash API key: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// validateAPIKeyPlan validates and returns default values for API key plans
|
||||
func validateAPIKeyPlan(plan string) (string, int, int, error) {
|
||||
if plan == "" {
|
||||
plan = "free"
|
||||
}
|
||||
|
||||
switch plan {
|
||||
case "free":
|
||||
return plan, 60, 1000, nil
|
||||
case "pro":
|
||||
return plan, 600, 50000, nil
|
||||
case "business":
|
||||
return plan, 3000, 300000, nil
|
||||
case "enterprise":
|
||||
return plan, 10000, 10000000, nil
|
||||
default:
|
||||
return "", 0, 0, fmt.Errorf("invalid plan: %s", plan)
|
||||
}
|
||||
}
|
||||
|
||||
// sendJSONResponse sends a standardized JSON response
|
||||
func sendJSONResponse(c *gin.Context, statusCode int, response APIResponse) {
|
||||
c.JSON(statusCode, response)
|
||||
}
|
||||
|
||||
// sendErrorResponse sends a standardized error response
|
||||
func sendErrorResponse(c *gin.Context, statusCode int, code, message string, details interface{}) {
|
||||
response := APIResponse{
|
||||
Success: false,
|
||||
Error: &APIError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
},
|
||||
}
|
||||
sendJSONResponse(c, statusCode, response)
|
||||
}
|
||||
|
||||
// sendSuccessResponse sends a standardized success response
|
||||
func sendSuccessResponse(c *gin.Context, statusCode int, data interface{}) {
|
||||
response := APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
sendJSONResponse(c, statusCode, response)
|
||||
}
|
||||
|
||||
// validateRequest validates the request payload using the validator
|
||||
func validateRequest(c *gin.Context, req interface{}) error {
|
||||
if err := c.ShouldBindJSON(req); err != nil {
|
||||
return fmt.Errorf("invalid request body: %w", err)
|
||||
}
|
||||
|
||||
if err := validate.Struct(req); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPwhyServicesList returns a list of API services
|
||||
func handleAPwhyServicesList(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
// Query services from database
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
|
||||
FROM api_services
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
||||
"Failed to query services", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
|
||||
var enabled bool
|
||||
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
continue // Skip malformed rows
|
||||
}
|
||||
|
||||
services = append(services, map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"upstreamUrl": upstreamURL,
|
||||
"routePrefix": routePrefix,
|
||||
"enabled": enabled,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
|
||||
"services": services,
|
||||
"count": len(services),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAPwhyServicesCreate creates a new API service
|
||||
func handleAPwhyServicesCreate(c *gin.Context) {
|
||||
var req ServiceRequest
|
||||
|
||||
if err := validateRequest(c, &req); err != nil {
|
||||
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||
"Invalid request parameters", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
// Generate slug and ID
|
||||
id, err := generateServiceID()
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
||||
"Failed to generate service ID", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
|
||||
|
||||
// Insert service into database
|
||||
query := `
|
||||
INSERT INTO api_services (
|
||||
id, name, slug, upstream_url, route_prefix, health_path,
|
||||
enabled, rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW())
|
||||
`
|
||||
|
||||
var rpmLimit, monthlyQuota int
|
||||
if req.RPMLimit != nil {
|
||||
rpmLimit = *req.RPMLimit
|
||||
} else {
|
||||
rpmLimit = DefaultRPMLimit
|
||||
}
|
||||
|
||||
if req.MonthlyQuota != nil {
|
||||
monthlyQuota = *req.MonthlyQuota
|
||||
} else {
|
||||
monthlyQuota = DefaultMonthlyQuota
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL,
|
||||
req.RoutePrefix, rpmLimit, monthlyQuota)
|
||||
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
||||
"Failed to create service", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
serviceData := map[string]interface{}{
|
||||
"id": id,
|
||||
"name": req.Name,
|
||||
"slug": slug,
|
||||
"upstreamUrl": req.UpstreamURL,
|
||||
"routePrefix": req.RoutePrefix,
|
||||
"enabled": true,
|
||||
"rpmLimit": rpmLimit,
|
||||
"monthlyQuota": monthlyQuota,
|
||||
"createdAt": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
sendSuccessResponse(c, http.StatusCreated, map[string]interface{}{
|
||||
"service": serviceData,
|
||||
"message": "Service created successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// handleAPwhyServicesPatch updates an existing API service
|
||||
func handleAPwhyServicesPatch(c *gin.Context) {
|
||||
serviceID := c.Param("id")
|
||||
|
||||
var input struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"ok": false,
|
||||
"error": "Invalid input: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
*input.Enabled, serviceID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to update service: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{"id": serviceID, "updated": true},
|
||||
})
|
||||
}
|
||||
|
||||
// handleAPwhyKeysList returns a list of API keys
|
||||
func handleAPwhyKeysList(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
rows, err := db.QueryContext(context.Background(), `
|
||||
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
|
||||
FROM api_keys
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"data": []interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, name, keyPrefix, plan, createdAt, updatedAt string
|
||||
var enabled bool
|
||||
var rpmLimit, monthlyQuota sql.NullInt64
|
||||
|
||||
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
key := map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"keyPrefix": keyPrefix,
|
||||
"plan": plan,
|
||||
"enabled": enabled,
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
}
|
||||
|
||||
if rpmLimit.Valid {
|
||||
key["rpmLimit"] = rpmLimit.Int64
|
||||
}
|
||||
if monthlyQuota.Valid {
|
||||
key["monthlyQuota"] = monthlyQuota.Int64
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"data": keys,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAPwhyKeysCreate creates a new API key
|
||||
func handleAPwhyKeysCreate(c *gin.Context) {
|
||||
var input struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"ok": false,
|
||||
"error": "Invalid input: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if input.Plan == "" {
|
||||
input.Plan = "free"
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
// Generate API key and hash
|
||||
apiKey, err := generateAPIKey()
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
||||
"Failed to generate API key", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
keyHash, err := hashAPIKey(apiKey)
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "HASH_ERROR",
|
||||
"Failed to hash API key", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ID and prefix
|
||||
id, err := generateServiceID()
|
||||
if err != nil {
|
||||
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
||||
"Failed to generate key ID", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
keyPrefix := apiKey[:8]
|
||||
|
||||
// Set default limits based on plan
|
||||
var rpmLimit, monthlyQuota int
|
||||
switch input.Plan {
|
||||
case "free":
|
||||
rpmLimit, monthlyQuota = 60, 1000
|
||||
case "pro":
|
||||
rpmLimit, monthlyQuota = 600, 50000
|
||||
case "business":
|
||||
rpmLimit, monthlyQuota = 3000, 300000
|
||||
default:
|
||||
rpmLimit, monthlyQuota = 60, 1000
|
||||
}
|
||||
|
||||
// Insert key into database
|
||||
_, err = db.ExecContext(context.Background(), `
|
||||
INSERT INTO api_keys (
|
||||
id, name, key_hash, key_prefix, plan, allowed_service_ids,
|
||||
enabled, rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW())
|
||||
`, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to create key: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{
|
||||
"id": id,
|
||||
"name": input.Name,
|
||||
"plan": input.Plan,
|
||||
"key": apiKey, // Only return the actual key once
|
||||
"keyPrefix": keyPrefix,
|
||||
"enabled": true,
|
||||
"rpmLimit": rpmLimit,
|
||||
"monthlyQuota": monthlyQuota,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleAPwhyKeysPatch updates an existing API key
|
||||
func handleAPwhyKeysPatch(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
|
||||
var input struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"ok": false,
|
||||
"error": "Invalid input: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
if input.Enabled != nil {
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||
*input.Enabled, keyID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"ok": false,
|
||||
"error": "Failed to update key: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{"id": keyID, "updated": true},
|
||||
})
|
||||
}
|
||||
|
||||
// handleAPwhyAnalyticsOps returns operational analytics
|
||||
func handleAPwhyAnalyticsOps(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
// Get counts from database
|
||||
var totalServices, totalKeys, totalUsers int
|
||||
|
||||
db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices)
|
||||
db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys)
|
||||
db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers)
|
||||
|
||||
// For requests, we'll return 0 for now (would need to implement usage tracking)
|
||||
requestsToday := 0
|
||||
requestsThisMonth := 0
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{
|
||||
"total_requests": 0,
|
||||
"total_services": totalServices,
|
||||
"total_keys": totalKeys,
|
||||
"total_users": totalUsers,
|
||||
"requests_today": requestsToday,
|
||||
"requests_this_month": requestsThisMonth,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleAPwhyAnalyticsTraffic returns traffic analytics
|
||||
func handleAPwhyAnalyticsTraffic(c *gin.Context) {
|
||||
// TODO: Implement traffic analytics from database
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{
|
||||
"top_services": []interface{}{},
|
||||
"requests_by_day": []interface{}{},
|
||||
"status_codes": []interface{}{},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuditLog struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
UserEmail string `json:"user_email" db:"user_email"`
|
||||
Resource string `json:"resource" db:"resource"`
|
||||
ResourceID string `json:"resource_id" db:"resource_id"`
|
||||
Action string `json:"action" db:"action"`
|
||||
Details string `json:"details" db:"details"`
|
||||
IPAddress string `json:"ip_address" db:"ip_address"`
|
||||
UserAgent string `json:"user_agent" db:"user_agent"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
type AuditLogDetail struct {
|
||||
OldValue interface{} `json:"old_value,omitempty"`
|
||||
NewValue interface{} `json:"new_value,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
func LogAudit(userID, resource, resourceID, action string, details map[string]interface{}) {
|
||||
db := GetAuditDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
detailsJSON, _ := json.Marshal(details)
|
||||
|
||||
auditID := uuid.New().String()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
auditID, userID, resource, resourceID, action, string(detailsJSON), time.Now(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
}
|
||||
|
||||
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userEmail, _ := c.Get("user_email")
|
||||
|
||||
details["ip_address"] = c.ClientIP()
|
||||
details["user_agent"] = c.GetHeader("User-Agent")
|
||||
|
||||
detailsJSON, _ := json.Marshal(details)
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
auditID := uuid.New().String()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO audit_logs (id, user_id, user_email, resource, resource_id, action, details, ip_address, user_agent, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
auditID, userID, userEmail, resource, resourceID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), time.Now(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
}
|
||||
|
||||
var auditDB *database.DB
|
||||
|
||||
func GetAuditDB() *database.DB {
|
||||
return auditDB
|
||||
}
|
||||
|
||||
func SetAuditDB(db *database.DB) {
|
||||
auditDB = db
|
||||
}
|
||||
|
||||
func handleGetAuditLogs(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
|
||||
resource := c.Query("resource")
|
||||
action := c.Query("action")
|
||||
page := c.DefaultQuery("page", "1")
|
||||
limit := c.DefaultQuery("limit", "50")
|
||||
|
||||
query := `SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
|
||||
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
|
||||
FROM audit_logs WHERE user_id = $1`
|
||||
args := []interface{}{userID}
|
||||
argNum := 2
|
||||
|
||||
if resource != "" {
|
||||
query += " AND resource = $" + string(rune('0'+argNum))
|
||||
args = append(args, resource)
|
||||
argNum++
|
||||
}
|
||||
|
||||
if action != "" {
|
||||
query += " AND action = $" + string(rune('0'+argNum))
|
||||
args = append(args, action)
|
||||
argNum++
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT $" + string(rune('0'+argNum)) + " OFFSET $" + string(rune('0'+argNum+1))
|
||||
args = append(args, limit, (atoi(page)-1)*atoi(limit))
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AuditLog
|
||||
for rows.Next() {
|
||||
var log AuditLog
|
||||
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
|
||||
}
|
||||
|
||||
func handleGetResourceAuditLogs(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
resource := c.Param("resource")
|
||||
resourceID := c.Param("id")
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
|
||||
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
|
||||
FROM audit_logs
|
||||
WHERE user_id = $1 AND resource = $2 AND resource_id = $3
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
userID, resource, resourceID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AuditLog
|
||||
for rows.Next() {
|
||||
var log AuditLog
|
||||
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
var result int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
result = result*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
func (h *BuildHandler) buildUnavailable(c *gin.Context) bool {
|
||||
if h.buildManager != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Build service is unavailable: Docker client not initialized",
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if h.buildUnavailable(c) {
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
if h.buildUnavailable(c) {
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
if h.buildUnavailable(c) {
|
||||
return
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CronJob struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ProjectID string `json:"project_id" db:"project_id"`
|
||||
ServiceID string `json:"service_id" db:"service_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Schedule string `json:"schedule" db:"schedule"`
|
||||
Command string `json:"command" db:"command"`
|
||||
Timezone string `json:"timezone" db:"timezone"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
LastRunAt *time.Time `json:"last_run_at" db:"last_run_at"`
|
||||
NextRunAt *time.Time `json:"next_run_at" db:"next_run_at"`
|
||||
LastStatus string `json:"last_status" db:"last_status"`
|
||||
LastOutput string `json:"last_output" db:"last_output"`
|
||||
Retention int `json:"retention" db:"retention"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CronExecution struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
CronJobID string `json:"cron_job_id" db:"cron_job_id"`
|
||||
StartedAt time.Time `json:"started_at" db:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at" db:"finished_at"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Output string `json:"output" db:"output"`
|
||||
Error string `json:"error" db:"error"`
|
||||
}
|
||||
|
||||
type CreateCronJobRequest struct {
|
||||
ProjectID string `json:"project_id" binding:"required"`
|
||||
ServiceID string `json:"service_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Schedule string `json:"schedule" binding:"required"`
|
||||
Command string `json:"command" binding:"required"`
|
||||
Timezone string `json:"timezone"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Retention int `json:"retention"`
|
||||
}
|
||||
|
||||
type UpdateCronJobRequest struct {
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Command string `json:"command"`
|
||||
Timezone string `json:"timezone"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Retention int `json:"retention"`
|
||||
}
|
||||
|
||||
func handleGetCronJobs(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
projectID := c.Query("project_id")
|
||||
|
||||
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
||||
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
||||
cj.retention, cj.created_at, cj.updated_at
|
||||
FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE p.owner_id = $1`
|
||||
args := []interface{}{userID}
|
||||
|
||||
if projectID != "" {
|
||||
query += " AND cj.project_id = $2"
|
||||
args = append(args, projectID)
|
||||
}
|
||||
|
||||
query += " ORDER BY cj.created_at DESC"
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch cron jobs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []CronJob
|
||||
for rows.Next() {
|
||||
var job CronJob
|
||||
err := rows.Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
|
||||
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
|
||||
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"cron_jobs": jobs})
|
||||
}
|
||||
|
||||
func handleCreateCronJob(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
var req CreateCronJobRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM projects p
|
||||
JOIN services s ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
req.ServiceID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Timezone == "" {
|
||||
req.Timezone = "UTC"
|
||||
}
|
||||
if req.Retention == 0 {
|
||||
req.Retention = 30
|
||||
}
|
||||
|
||||
nextRun := calculateNextRun(req.Schedule, req.Timezone)
|
||||
|
||||
job := CronJob{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: req.ProjectID,
|
||||
ServiceID: req.ServiceID,
|
||||
Name: req.Name,
|
||||
Schedule: req.Schedule,
|
||||
Command: req.Command,
|
||||
Timezone: req.Timezone,
|
||||
Enabled: req.Enabled,
|
||||
NextRunAt: nextRun,
|
||||
Retention: req.Retention,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
job.ID, job.ProjectID, job.ServiceID, job.Name, job.Schedule, job.Command, job.Timezone, job.Enabled, job.NextRunAt, job.Retention, job.CreatedAt, job.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cron job"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "cron_job", job.ID, "create", map[string]interface{}{
|
||||
"name": job.Name,
|
||||
"schedule": job.Schedule,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"cron_job": job})
|
||||
}
|
||||
|
||||
func handleGetCronJob(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var job CronJob
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
||||
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
||||
cj.retention, cj.created_at, cj.updated_at, p.owner_id
|
||||
FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
|
||||
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
|
||||
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"cron_job": job})
|
||||
}
|
||||
|
||||
func handleUpdateCronJob(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var req UpdateCronJobRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Name != "" {
|
||||
updates["name"] = req.Name
|
||||
}
|
||||
if req.Schedule != "" {
|
||||
updates["schedule"] = req.Schedule
|
||||
updates["next_run_at"] = calculateNextRun(req.Schedule, "UTC")
|
||||
}
|
||||
if req.Command != "" {
|
||||
updates["command"] = req.Command
|
||||
}
|
||||
if req.Timezone != "" {
|
||||
updates["timezone"] = req.Timezone
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.Retention > 0 {
|
||||
updates["retention"] = req.Retention
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
updateQuery := "UPDATE cron_jobs SET "
|
||||
args := []interface{}{}
|
||||
argNum := 1
|
||||
for key, value := range updates {
|
||||
if argNum > 1 {
|
||||
updateQuery += ", "
|
||||
}
|
||||
updateQuery += key + " = $" + string(rune('0'+argNum))
|
||||
args = append(args, value)
|
||||
argNum++
|
||||
}
|
||||
updateQuery += " WHERE id = $" + string(rune('0'+argNum))
|
||||
args = append(args, jobID)
|
||||
|
||||
_, err = db.Exec(updateQuery, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cron job"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "cron_job", jobID, "update", updates)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Cron job updated successfully"})
|
||||
}
|
||||
|
||||
func handleDeleteCronJob(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Exec("DELETE FROM cron_jobs WHERE id = $1", jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete cron job"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "cron_job", jobID, "delete", nil)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Cron job deleted successfully"})
|
||||
}
|
||||
|
||||
func handleGetCronExecutions(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
userID := c.MustGet("user_id").(string)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT id, cron_job_id, started_at, finished_at, status, output, error
|
||||
FROM cron_executions
|
||||
WHERE cron_job_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 100`,
|
||||
jobID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var executions []CronExecution
|
||||
for rows.Next() {
|
||||
var exec CronExecution
|
||||
err := rows.Scan(&exec.ID, &exec.CronJobID, &exec.StartedAt, &exec.FinishedAt, &exec.Status, &exec.Output, &exec.Error)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
executions = append(executions, exec)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"executions": executions})
|
||||
}
|
||||
|
||||
func handleTriggerCronJob(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jobID := c.Param("id")
|
||||
|
||||
var job CronJob
|
||||
var ownerCheck string
|
||||
err := db.QueryRow(
|
||||
`SELECT cj.command, p.owner_id FROM cron_jobs cj
|
||||
JOIN projects p ON cj.project_id = p.id
|
||||
WHERE cj.id = $1`,
|
||||
jobID,
|
||||
).Scan(&job.Command, &ownerCheck)
|
||||
|
||||
if err != nil || ownerCheck != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
execID := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO cron_executions (id, cron_job_id, started_at, status)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
execID, jobID, now, "running",
|
||||
)
|
||||
|
||||
go executeCronJob(jobID, execID, job.Command)
|
||||
|
||||
LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{
|
||||
"execution_id": execID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Cron job triggered",
|
||||
"execution_id": execID,
|
||||
})
|
||||
}
|
||||
|
||||
func calculateNextRun(schedule, timezone string) *time.Time {
|
||||
now := time.Now()
|
||||
next := now.Add(1 * time.Hour)
|
||||
return &next
|
||||
}
|
||||
|
||||
func executeCronJob(jobID, execID, command string) {
|
||||
db := auditDB
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
now := time.Now()
|
||||
db.Exec(
|
||||
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`,
|
||||
now, "success", "Job completed successfully", execID,
|
||||
)
|
||||
|
||||
db.Exec(
|
||||
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`,
|
||||
now, "success", time.Now().Add(1*time.Hour), jobID,
|
||||
)
|
||||
}
|
||||
|
||||
func init() {
|
||||
cronJobsData, _ := json.Marshal([]CronJob{})
|
||||
_ = cronJobsData
|
||||
}
|
||||
@@ -1,543 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DeploymentModel struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
|
||||
CommitHash *string `json:"commit_hash" db:"commit_hash"`
|
||||
Status string `json:"status" db:"status"`
|
||||
ImageName string `json:"image_name" db:"image_name"`
|
||||
ImageTag string `json:"image_tag" db:"image_tag"`
|
||||
BuildLog string `json:"build_log" db:"build_log"`
|
||||
RuntimeLog string `json:"runtime_log" db:"runtime_log"`
|
||||
Error *string `json:"error" db:"error"`
|
||||
StartedAt *time.Time `json:"started_at" db:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateDeploymentRequest struct {
|
||||
CommitHash string `json:"commit_hash"`
|
||||
Branch string `json:"branch"`
|
||||
Trigger string `json:"trigger"`
|
||||
EnvVars map[string]string `json:"env_vars"`
|
||||
}
|
||||
|
||||
type DeploymentResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ServiceID uuid.UUID `json:"service_id"`
|
||||
CommitHash *string `json:"commit_hash"`
|
||||
Status string `json:"status"`
|
||||
ImageName string `json:"image_name"`
|
||||
ImageTag string `json:"image_tag"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func handleGetDeployments(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
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck 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(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||
FROM deployments
|
||||
WHERE service_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var deployments []DeploymentModel
|
||||
for rows.Next() {
|
||||
var d DeploymentModel
|
||||
err := rows.Scan(
|
||||
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
|
||||
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
|
||||
&d.CreatedAt, &d.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
|
||||
return
|
||||
}
|
||||
deployments = append(deployments, d)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||
}
|
||||
|
||||
func handleCreateDeployment(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 CreateDeploymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Trigger == "" {
|
||||
req.Trigger = "manual"
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var service Service
|
||||
var projectOwner string
|
||||
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, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).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, &projectOwner,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if projectOwner != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Branch == "" {
|
||||
req.Branch = service.GitBranch
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var commitHash *string
|
||||
if trimmed := strings.TrimSpace(req.CommitHash); trimmed != "" {
|
||||
commitHash = &trimmed
|
||||
}
|
||||
|
||||
d := DeploymentModel{
|
||||
ID: uuid.New(),
|
||||
ServiceID: serviceID,
|
||||
CommitHash: commitHash,
|
||||
Status: "pending",
|
||||
ImageName: "",
|
||||
ImageTag: "",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"})
|
||||
return
|
||||
}
|
||||
|
||||
engine, exists := c.Get("deployment_engine")
|
||||
if !exists || engine == nil {
|
||||
unavailableErr := "Deployment engine unavailable. Docker may not be configured on this server."
|
||||
completedAt := time.Now()
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE deployments
|
||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
unavailableErr, completedAt, d.ID,
|
||||
)
|
||||
d.Status = "failed"
|
||||
d.Error = &unavailableErr
|
||||
d.CompletedAt = &completedAt
|
||||
} else {
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service status"})
|
||||
return
|
||||
}
|
||||
|
||||
engineInstance := engine.(*deployment.DeploymentEngine)
|
||||
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, DeploymentResponse{
|
||||
ID: d.ID,
|
||||
ServiceID: d.ServiceID,
|
||||
CommitHash: d.CommitHash,
|
||||
Status: d.Status,
|
||||
Error: d.Error,
|
||||
CompletedAt: d.CompletedAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func runDeploymentAndSync(
|
||||
parentCtx context.Context,
|
||||
db *database.DB,
|
||||
engine *deployment.DeploymentEngine,
|
||||
dbDeployment *DeploymentModel,
|
||||
service Service,
|
||||
req CreateDeploymentRequest,
|
||||
userID string,
|
||||
) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
sourcePath := strings.TrimSpace(service.BuildPath)
|
||||
if sourcePath == "" {
|
||||
sourcePath = "."
|
||||
}
|
||||
|
||||
deployReq := &deployment.DeploymentRequest{
|
||||
ProjectID: service.ProjectID.String(),
|
||||
ServiceID: service.ID.String(),
|
||||
Environment: service.Environment,
|
||||
Config: deployment.ServiceConfig{
|
||||
Name: service.Name,
|
||||
Image: service.Image,
|
||||
Environment: req.EnvVars,
|
||||
Replicas: 1,
|
||||
},
|
||||
BuildConfig: &deployment.BuildConfig{
|
||||
BuildType: "nixpacks",
|
||||
SourcePath: sourcePath,
|
||||
Branch: req.Branch,
|
||||
Commit: req.CommitHash,
|
||||
},
|
||||
Trigger: deployment.TriggerConfig{
|
||||
Type: req.Trigger,
|
||||
Source: "api",
|
||||
User: userID,
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
||||
if err != nil {
|
||||
failedAt := time.Now()
|
||||
failure := "Failed to start deployment engine: " + err.Error()
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments
|
||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
failure, failedAt, dbDeployment.ID,
|
||||
)
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||
failedAt, service.ID,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
syncTicker := time.NewTicker(1 * time.Second)
|
||||
defer syncTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
failedAt := time.Now()
|
||||
timeoutErr := "Deployment timed out before completion"
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments
|
||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
timeoutErr, failedAt, dbDeployment.ID,
|
||||
)
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||
failedAt, service.ID,
|
||||
)
|
||||
return
|
||||
case <-syncTicker.C:
|
||||
current, getErr := engine.GetDeployment(engineDeployment.ID)
|
||||
if getErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dbStatus := mapEngineStatusToDBStatus(current.Status)
|
||||
imageName, imageTag := splitImageReference(current.ImageName, dbDeployment.ImageTag)
|
||||
|
||||
var dbError interface{}
|
||||
if current.Error != "" {
|
||||
dbError = current.Error
|
||||
}
|
||||
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments
|
||||
SET status = $1,
|
||||
image_name = $2,
|
||||
image_tag = $3,
|
||||
build_log = $4,
|
||||
runtime_log = $5,
|
||||
error = $6,
|
||||
started_at = $7,
|
||||
completed_at = $8,
|
||||
updated_at = $9
|
||||
WHERE id = $10`,
|
||||
dbStatus,
|
||||
imageName,
|
||||
imageTag,
|
||||
current.BuildLog,
|
||||
current.DeployLog,
|
||||
dbError,
|
||||
current.StartedAt,
|
||||
current.CompletedAt,
|
||||
time.Now(),
|
||||
dbDeployment.ID,
|
||||
)
|
||||
|
||||
switch dbStatus {
|
||||
case "deployed":
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
return
|
||||
case "failed":
|
||||
_, _ = db.Exec(
|
||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapEngineStatusToDBStatus(status string) string {
|
||||
switch status {
|
||||
case "running":
|
||||
return "deployed"
|
||||
case "cancelled":
|
||||
return "failed"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func splitImageReference(image, fallbackTag string) (string, string) {
|
||||
if image == "" {
|
||||
return "", fallbackTag
|
||||
}
|
||||
|
||||
lastSlash := strings.LastIndex(image, "/")
|
||||
lastColon := strings.LastIndex(image, ":")
|
||||
if lastColon > lastSlash && !strings.Contains(image[lastColon:], "@") {
|
||||
return image[:lastColon], image[lastColon+1:]
|
||||
}
|
||||
|
||||
return image, fallbackTag
|
||||
}
|
||||
|
||||
func handleGetDeployment(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
deploymentIDStr := c.Param("id")
|
||||
deploymentID, err := uuid.Parse(deploymentIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var d DeploymentModel
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(
|
||||
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
|
||||
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
|
||||
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deployment": d})
|
||||
}
|
||||
|
||||
func handleRollbackDeployment(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
deploymentIDStr := c.Param("id")
|
||||
deploymentID, err := uuid.Parse(deploymentIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var targetDeployment DeploymentModel
|
||||
var serviceID uuid.UUID
|
||||
var ownerCheck string
|
||||
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
|
||||
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
|
||||
d.created_at, d.updated_at, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(
|
||||
&targetDeployment.ID, &serviceID, &targetDeployment.CommitHash, &targetDeployment.Status,
|
||||
&targetDeployment.ImageName, &targetDeployment.ImageTag, &targetDeployment.BuildLog,
|
||||
&targetDeployment.RuntimeLog, &targetDeployment.Error, &targetDeployment.StartedAt,
|
||||
&targetDeployment.CompletedAt, &targetDeployment.CreatedAt, &targetDeployment.UpdatedAt,
|
||||
&ownerCheck,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if targetDeployment.Status != "deployed" && targetDeployment.Status != "failed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Can only rollback completed or failed deployments"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rollbackID := uuid.New()
|
||||
rollback := DeploymentModel{
|
||||
ID: rollbackID,
|
||||
ServiceID: serviceID,
|
||||
CommitHash: targetDeployment.CommitHash,
|
||||
Status: "rolling_back",
|
||||
ImageName: targetDeployment.ImageName,
|
||||
ImageTag: targetDeployment.ImageTag,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO deployments
|
||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status,
|
||||
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), rollbackID,
|
||||
)
|
||||
db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"deployment": DeploymentResponse{
|
||||
ID: rollback.ID,
|
||||
ServiceID: rollback.ServiceID,
|
||||
CommitHash: rollback.CommitHash,
|
||||
Status: rollback.Status,
|
||||
ImageName: rollback.ImageName,
|
||||
ImageTag: rollback.ImageTag,
|
||||
CreatedAt: rollback.CreatedAt,
|
||||
},
|
||||
"message": "Rollback initiated",
|
||||
})
|
||||
}
|
||||
@@ -1,784 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"containr/internal/database"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
RemoteWebhookID string `json:"remote_webhook_id" db:"remote_webhook_id"`
|
||||
Active bool `json:"active" db:"active"`
|
||||
BranchFilter string `json:"branch_filter,omitempty" db:"branch_filter"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type GitBranch struct {
|
||||
Name string `json:"name"`
|
||||
CommitHash string `json:"commit_hash"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
type githubRepo struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
Private bool `json:"private"`
|
||||
}
|
||||
|
||||
type githubBranch struct {
|
||||
Name string `json:"name"`
|
||||
Commit struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
|
||||
type githubUser struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
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.api_url, 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.APIUrl, &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", publicBaseURL(c), req.RepoID)
|
||||
remoteWebhookID, err := createGitWebhook(provider.Name, repo.FullName, provider.AccessToken,
|
||||
provider.APIUrl, webhookURL, req.Events, webhookSecret)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook on Git provider: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create webhook record
|
||||
webhook := GitWebhook{
|
||||
ID: uuid.New().String(),
|
||||
RepoID: req.RepoID,
|
||||
ProviderID: provider.ID,
|
||||
Events: string(eventsJSON),
|
||||
Secret: webhookSecret,
|
||||
RemoteWebhookID: remoteWebhookID,
|
||||
Active: true,
|
||||
BranchFilter: req.Branch,
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, remote_webhook_id, active, branch_filter, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
`, webhook.ID, webhook.RepoID, webhook.ProviderID, webhook.Events, webhook.Secret, webhook.RemoteWebhookID, webhook.Active, nullableString(webhook.BranchFilter))
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetRepositoryBranches(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
repoID := c.Param("repoId")
|
||||
|
||||
if _, err := uuid.Parse(repoID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var repo GitRepository
|
||||
var provider GitProvider
|
||||
err := db.QueryRow(`
|
||||
SELECT r.id, r.full_name, r.default_branch,
|
||||
p.id, p.name, p.access_token, p.api_url
|
||||
FROM git_repositories r
|
||||
JOIN git_providers p ON r.provider_id = p.id
|
||||
WHERE r.id = $1 AND r.user_id = $2
|
||||
`, repoID, userID).Scan(&repo.ID, &repo.FullName, &repo.DefaultBranch,
|
||||
&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
|
||||
return
|
||||
}
|
||||
|
||||
branches, err := fetchGitBranches(provider.Name, provider.AccessToken, provider.APIUrl, repo.FullName, repo.DefaultBranch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch branches: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"branches": branches})
|
||||
}
|
||||
|
||||
// Helper functions (these would need to be implemented with actual Git provider API calls)
|
||||
|
||||
func validateGitToken(provider, token string) bool {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if provider != "github" {
|
||||
return true
|
||||
}
|
||||
|
||||
body, status, err := gitProviderRequest("GET", "https://api.github.com", token, "/user", nil)
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
}
|
||||
|
||||
var user githubUser
|
||||
return json.Unmarshal(body, &user) == nil && user.Login != ""
|
||||
}
|
||||
|
||||
func fetchGitRepositories(provider, token, apiUrl string) ([]map[string]interface{}, error) {
|
||||
if provider != "github" {
|
||||
return nil, fmt.Errorf("%s repository listing is not implemented yet", provider)
|
||||
}
|
||||
|
||||
body, status, err := gitProviderRequest(
|
||||
"GET",
|
||||
apiUrl,
|
||||
token,
|
||||
"/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, providerStatusError(status, body)
|
||||
}
|
||||
|
||||
var repos []githubRepo
|
||||
if err := json.Unmarshal(body, &repos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
result = append(result, map[string]interface{}{
|
||||
"name": repo.Name,
|
||||
"full_name": repo.FullName,
|
||||
"description": repo.Description,
|
||||
"clone_url": repo.CloneURL,
|
||||
"default_branch": repo.DefaultBranch,
|
||||
"private": repo.Private,
|
||||
"html_url": repo.HTMLURL,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchGitRepositoryDetails(provider, repoFullName, token, apiUrl string) (map[string]interface{}, error) {
|
||||
if provider != "github" {
|
||||
return nil, fmt.Errorf("%s repository details are not implemented yet", provider)
|
||||
}
|
||||
|
||||
path, err := githubRepoPath(repoFullName, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, status, err := gitProviderRequest("GET", apiUrl, token, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, providerStatusError(status, body)
|
||||
}
|
||||
|
||||
var repo githubRepo
|
||||
if err := json.Unmarshal(body, &repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"name": repo.Name,
|
||||
"description": repo.Description,
|
||||
"clone_url": repo.CloneURL,
|
||||
"default_branch": repo.DefaultBranch,
|
||||
"private": repo.Private,
|
||||
"html_url": repo.HTMLURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fetchGitBranches(provider, token, apiUrl, repoFullName, defaultBranch string) ([]GitBranch, error) {
|
||||
if provider != "github" {
|
||||
return nil, fmt.Errorf("%s branch listing is not implemented yet", provider)
|
||||
}
|
||||
|
||||
path, err := githubRepoPath(repoFullName, "/branches?per_page=100")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, status, err := gitProviderRequest("GET", apiUrl, token, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, providerStatusError(status, body)
|
||||
}
|
||||
|
||||
var rawBranches []githubBranch
|
||||
if err := json.Unmarshal(body, &rawBranches); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branches := make([]GitBranch, 0, len(rawBranches))
|
||||
for _, branch := range rawBranches {
|
||||
branches = append(branches, GitBranch{
|
||||
Name: branch.Name,
|
||||
CommitHash: branch.Commit.SHA,
|
||||
IsDefault: branch.Name == defaultBranch,
|
||||
})
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
func createGitWebhook(provider, repoFullName, token, apiUrl, targetURL string, events []string, secret string) (string, error) {
|
||||
if provider != "github" {
|
||||
return "", fmt.Errorf("%s webhooks are not implemented yet", provider)
|
||||
}
|
||||
|
||||
path, err := githubRepoPath(repoFullName, "/hooks")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "web",
|
||||
"active": true,
|
||||
"events": events,
|
||||
"config": map[string]string{
|
||||
"url": targetURL,
|
||||
"content_type": "json",
|
||||
"secret": secret,
|
||||
"insecure_ssl": "0",
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
|
||||
body, status, err := gitProviderRequest("POST", apiUrl, token, path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return "", providerStatusError(status, body)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if response.ID == 0 {
|
||||
return "", fmt.Errorf("GitHub returned an empty webhook ID")
|
||||
}
|
||||
|
||||
return strconv.FormatInt(response.ID, 10), nil
|
||||
}
|
||||
|
||||
func generateWebhookSecret() string {
|
||||
return "webhook-secret-" + uuid.New().String()
|
||||
}
|
||||
|
||||
func gitProviderRequest(method, apiUrl, token, path string, body io.Reader) ([]byte, int, error) {
|
||||
base := strings.TrimRight(apiUrl, "/")
|
||||
if base == "" {
|
||||
base = "https://api.github.com"
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, base+path, body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
if strings.TrimSpace(token) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func githubRepoPath(repoFullName, suffix string) (string, error) {
|
||||
parts := strings.Split(repoFullName, "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", fmt.Errorf("repository must use owner/name format")
|
||||
}
|
||||
|
||||
return "/repos/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]) + suffix, nil
|
||||
}
|
||||
|
||||
func providerStatusError(status int, body []byte) error {
|
||||
var response struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &response); err == nil && response.Message != "" {
|
||||
return fmt.Errorf("provider returned %d: %s", status, response.Message)
|
||||
}
|
||||
|
||||
return fmt.Errorf("provider returned %d", status)
|
||||
}
|
||||
|
||||
func publicBaseURL(c *gin.Context) string {
|
||||
for _, key := range []string{"CONTAINR_PUBLIC_URL", "PUBLIC_URL", "APP_URL"} {
|
||||
if value := strings.TrimRight(os.Getenv(key), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
host := c.Request.Host
|
||||
if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func fetchGitHubFile(provider, token, apiUrl, repoFullName, branch, filePath string) ([]byte, error) {
|
||||
if provider != "github" {
|
||||
return nil, fmt.Errorf("%s file fetch is not implemented yet", provider)
|
||||
}
|
||||
|
||||
suffix := "/contents/" + strings.TrimLeft(filePath, "/")
|
||||
if branch != "" {
|
||||
suffix += "?ref=" + url.QueryEscape(branch)
|
||||
}
|
||||
|
||||
path, err := githubRepoPath(repoFullName, suffix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, status, err := gitProviderRequest("GET", apiUrl, token, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, providerStatusError(status, body)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Content string `json:"content"`
|
||||
Encoding string `json:"encoding"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Encoding != "base64" {
|
||||
return nil, fmt.Errorf("unsupported GitHub content encoding: %s", response.Encoding)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(response.Content, "\n", ""))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
@@ -1,607 +0,0 @@
|
||||
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",
|
||||
})
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Message string `json:"message"`
|
||||
Stream string `json:"stream"`
|
||||
}
|
||||
|
||||
func handleGetLogs(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
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerCheck 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(&ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
follow := c.DefaultQuery("follow", "false") == "true"
|
||||
tail := c.DefaultQuery("tail", "100")
|
||||
|
||||
dockerClient, exists := c.Get("docker_client")
|
||||
if !exists || dockerClient == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"logs": []LogEntry{}, "message": "Docker not available - showing mock logs"})
|
||||
return
|
||||
}
|
||||
|
||||
client := dockerClient.(*docker.Client)
|
||||
containerName := fmt.Sprintf("containr-%s", serviceID)
|
||||
|
||||
logOpts := docker.LogOptions{
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
Follow: follow,
|
||||
Tail: tail,
|
||||
Timestamps: true,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": []LogEntry{
|
||||
{Timestamp: time.Now(), Message: "Service not running or container not found", Stream: "system"},
|
||||
{Timestamp: time.Now(), Message: "Start the service to see logs", Stream: "system"},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
defer logsReader.Close()
|
||||
|
||||
if follow {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
|
||||
streamWriter := c.Writer
|
||||
flusher, ok := streamWriter.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming not supported"})
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(logsReader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cleanLine := stripDockerLogHeader(line)
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Message: cleanLine,
|
||||
Stream: "stdout",
|
||||
}
|
||||
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
|
||||
entry.Stream = "stderr"
|
||||
}
|
||||
|
||||
fmt.Fprintf(streamWriter, "data: {\"timestamp\":\"%s\",\"message\":\"%s\",\"stream\":\"%s\"}\n\n",
|
||||
entry.Timestamp.Format(time.RFC3339),
|
||||
strings.ReplaceAll(entry.Message, `"`, `\"`),
|
||||
entry.Stream,
|
||||
)
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logBytes, err := io.ReadAll(logsReader)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read logs"})
|
||||
return
|
||||
}
|
||||
|
||||
logContent := string(logBytes)
|
||||
var logEntries []LogEntry
|
||||
scanner := bufio.NewScanner(strings.NewReader(logContent))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cleanLine := stripDockerLogHeader(line)
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Message: cleanLine,
|
||||
Stream: "stdout",
|
||||
}
|
||||
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
|
||||
entry.Stream = "stderr"
|
||||
}
|
||||
logEntries = append(logEntries, entry)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
|
||||
}
|
||||
|
||||
func handleGetDeploymentLogs(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
|
||||
deploymentIDStr := c.Param("id")
|
||||
deploymentID, err := uuid.Parse(deploymentIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var buildLog, runtimeLog string
|
||||
var ownerCheck string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT d.build_log, d.runtime_log, p.owner_id
|
||||
FROM deployments d
|
||||
JOIN services s ON d.service_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE d.id = $1`,
|
||||
deploymentID,
|
||||
).Scan(&buildLog, &runtimeLog, &ownerCheck)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ownerCheck != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
logType := c.DefaultQuery("type", "all")
|
||||
var logs []LogEntry
|
||||
|
||||
parseLogs := func(logContent string, stream string) []LogEntry {
|
||||
var entries []LogEntry
|
||||
scanner := bufio.NewScanner(strings.NewReader(logContent))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Message: line,
|
||||
Stream: stream,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
if logType == "all" || logType == "build" {
|
||||
logs = append(logs, parseLogs(buildLog, "build")...)
|
||||
}
|
||||
if logType == "all" || logType == "runtime" {
|
||||
logs = append(logs, parseLogs(runtimeLog, "runtime")...)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": logs,
|
||||
"build_log": buildLog,
|
||||
"runtime_log": runtimeLog,
|
||||
})
|
||||
}
|
||||
|
||||
func stripDockerLogHeader(line string) string {
|
||||
if len(line) > 8 && (line[0] == 1 || line[0] == 2) {
|
||||
return line[8:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package api
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// firstPathParam returns the first non-empty route param from the provided names.
|
||||
func firstPathParam(c *gin.Context, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value := c.Param(name); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,617 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"database/sql"
|
||||
"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"`
|
||||
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 := firstPathParam(c, "id", "project_id", "projectId")
|
||||
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 serviceID sql.NullString
|
||||
var serviceName sql.NullString
|
||||
var serviceType sql.NullString
|
||||
|
||||
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,
|
||||
&serviceID, &serviceName, &serviceType,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan preview environment"})
|
||||
return
|
||||
}
|
||||
|
||||
if serviceID.Valid {
|
||||
parsedServiceID, parseErr := uuid.Parse(serviceID.String)
|
||||
if parseErr == nil {
|
||||
env.Service = &Service{
|
||||
ID: parsedServiceID,
|
||||
Name: serviceName.String,
|
||||
Type: serviceType.String,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
||||
projectID, err := uuid.Parse(projectIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreatePreviewEnvironmentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.ProjectID == uuid.Nil {
|
||||
req.ProjectID = projectID
|
||||
} else if req.ProjectID != projectID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Project ID in URL and request body must match"})
|
||||
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 serviceID sql.NullString
|
||||
var serviceName sql.NullString
|
||||
var serviceType sql.NullString
|
||||
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,
|
||||
&serviceID, &serviceName, &serviceType,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Populate service info if available
|
||||
if serviceID.Valid {
|
||||
parsedServiceID, parseErr := uuid.Parse(serviceID.String)
|
||||
if parseErr == nil {
|
||||
env.Service = &Service{
|
||||
ID: parsedServiceID,
|
||||
Name: serviceName.String,
|
||||
Type: serviceType.String,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
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"})
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
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",
|
||||
})
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"containr/internal/build"
|
||||
"containr/internal/config"
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/metrics"
|
||||
"containr/internal/middleware"
|
||||
"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 (non-fatal if it fails)
|
||||
var dockerClient *docker.Client
|
||||
var buildManager *build.BuildManager
|
||||
var deploymentEngine *deployment.DeploymentEngine
|
||||
|
||||
if client, err := docker.NewClient(); err != nil {
|
||||
log.Printf("Warning: Failed to initialize Docker client: %v", err)
|
||||
log.Printf("Docker-related features will be disabled")
|
||||
} else {
|
||||
dockerClient = client
|
||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||
deploymentEngine = deployment.NewDeploymentEngine(buildManager, 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)
|
||||
|
||||
// Note: Proxmox integration can be added later if needed
|
||||
// For now, focusing on core Containr and APwhy functionality
|
||||
|
||||
// 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)
|
||||
if deploymentEngine != nil {
|
||||
c.Set("deployment_engine", deploymentEngine)
|
||||
}
|
||||
c.Set("scheduler", scheduler)
|
||||
c.Set("metrics_collector", metricsCollector)
|
||||
c.Set("auto_scaler", autoScaler)
|
||||
c.Set("scaling_handler", scalingHandler)
|
||||
c.Set("gorm_db", gormDB)
|
||||
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)
|
||||
|
||||
// Service routes (nested under projects)
|
||||
protected.GET("/projects/:id/services", handleGetServices)
|
||||
protected.POST("/projects/:id/services", handleCreateService)
|
||||
|
||||
// Generic project routes
|
||||
protected.GET("/projects/:id", handleGetProject)
|
||||
protected.PUT("/projects/:id", handleUpdateProject)
|
||||
protected.DELETE("/projects/:id", handleDeleteProject)
|
||||
|
||||
// Service routes
|
||||
protected.GET("/services/:id", handleGetService)
|
||||
protected.PUT("/services/:id", handleUpdateService)
|
||||
protected.DELETE("/services/:id", handleDeleteService)
|
||||
|
||||
// Deployment routes
|
||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||
protected.POST("/services/:id/deployments", handleCreateDeployment)
|
||||
protected.GET("/deployments/:id", handleGetDeployment)
|
||||
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||
|
||||
// Environment variables routes
|
||||
protected.GET("/services/:id/variables", handleGetVariables)
|
||||
protected.PUT("/services/:id/variables", handleUpdateVariables)
|
||||
|
||||
// Logs routes
|
||||
protected.GET("/services/: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.GET("/git/repositories/:repoId/branches", handleGetRepositoryBranches)
|
||||
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/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
protected.POST("/projects/: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/:id", securityHandler.GetSecurityScan)
|
||||
protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
|
||||
protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
|
||||
protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
|
||||
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
|
||||
protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
|
||||
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
|
||||
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
|
||||
protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
|
||||
protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
|
||||
|
||||
// WebSocket endpoint
|
||||
protected.GET("/ws", handleWebSocket)
|
||||
|
||||
// Templates routes
|
||||
protected.GET("/templates", handleGetTemplates)
|
||||
protected.GET("/templates/:id", handleGetTemplate)
|
||||
protected.POST("/templates/import/github", handleImportGitHubTemplate)
|
||||
protected.POST("/templates/import/compose", handleImportComposeTemplate)
|
||||
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
||||
|
||||
// Cron Jobs routes
|
||||
protected.GET("/cron-jobs", handleGetCronJobs)
|
||||
protected.POST("/cron-jobs", handleCreateCronJob)
|
||||
protected.GET("/cron-jobs/:id", handleGetCronJob)
|
||||
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
||||
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
|
||||
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
|
||||
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
|
||||
|
||||
// Audit Logs routes
|
||||
protected.GET("/audit-logs", handleGetAuditLogs)
|
||||
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||
}
|
||||
|
||||
// APwhy Gateway routes
|
||||
apwhy := router.Group("/api/v1")
|
||||
{
|
||||
// Health check (no auth required)
|
||||
apwhy.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"ok": true,
|
||||
"data": gin.H{
|
||||
"status": "ok",
|
||||
"name": "Containr + APwhy",
|
||||
"database": "postgresql",
|
||||
"generatedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Protected APwhy routes (authentication required)
|
||||
protectedAPwhy := router.Group("/api/v1")
|
||||
protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret))
|
||||
{
|
||||
// Service management
|
||||
protectedAPwhy.GET("/services", handleAPwhyServicesList)
|
||||
protectedAPwhy.POST("/services", handleAPwhyServicesCreate)
|
||||
protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch)
|
||||
|
||||
// API Keys
|
||||
protectedAPwhy.GET("/keys", handleAPwhyKeysList)
|
||||
protectedAPwhy.POST("/keys", handleAPwhyKeysCreate)
|
||||
protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch)
|
||||
|
||||
// Analytics
|
||||
protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps)
|
||||
protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/security"
|
||||
"database/sql"
|
||||
"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, ok := sh.requireProjectAccess(c, req.ProjectID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ServiceID != "" {
|
||||
if _, err := uuid.Parse(req.ServiceID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var serviceExists bool
|
||||
err := sh.db.QueryRow(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM services WHERE id = $1 AND project_id = $2
|
||||
)`,
|
||||
req.ServiceID,
|
||||
req.ProjectID,
|
||||
).Scan(&serviceExists)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate service"})
|
||||
return
|
||||
}
|
||||
|
||||
if !serviceExists {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Service not found in project"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := firstPathParam(c, "scanId", "id")
|
||||
if !sh.requireSecurityScanAccess(c, scanID) {
|
||||
return
|
||||
}
|
||||
|
||||
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 := firstPathParam(c, "projectId", "id", "project_id")
|
||||
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 1000 {
|
||||
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 := firstPathParam(c, "projectId", "id", "project_id")
|
||||
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 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 := firstPathParam(c, "vulnId", "id")
|
||||
userID, ok := sh.requireVulnerabilityAccess(c, vulnID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
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, ok := sh.requireProjectAccess(c, req.ProjectID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := uuid.Parse(req.FrameworkID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid framework ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var frameworkExists bool
|
||||
err := sh.db.QueryRow(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM compliance_frameworks WHERE id = $1
|
||||
)`,
|
||||
req.FrameworkID,
|
||||
).Scan(&frameworkExists)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate framework"})
|
||||
return
|
||||
}
|
||||
if !frameworkExists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance framework not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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 := firstPathParam(c, "reportId", "id")
|
||||
if !sh.requireComplianceReportAccess(c, reportID) {
|
||||
return
|
||||
}
|
||||
|
||||
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 := firstPathParam(c, "projectId", "id", "project_id")
|
||||
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
err := 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)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerability metrics"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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 == sql.ErrNoRows {
|
||||
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"}
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get latest scan"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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 == sql.ErrNoRows {
|
||||
complianceStatus = struct {
|
||||
OverallStatus string `json:"overall_status"`
|
||||
Score int `json:"score"`
|
||||
LastAssessed *time.Time `json:"last_assessed"`
|
||||
}{OverallStatus: "not_assessed", Score: 0, LastAssessed: nil}
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance status"})
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
projectID := firstPathParam(c, "projectId", "id", "project_id")
|
||||
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
|
||||
userIDValue, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return "", false
|
||||
}
|
||||
userID, ok := userIDValue.(string)
|
||||
if !ok || userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, err := uuid.Parse(projectID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||
return "", false
|
||||
}
|
||||
|
||||
var hasAccess bool
|
||||
err := sh.db.QueryRow(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
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(&hasAccess)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !hasAccess {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
return "", false
|
||||
}
|
||||
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func (sh *SecurityHandler) requireSecurityScanAccess(c *gin.Context, scanID string) bool {
|
||||
if _, err := uuid.Parse(scanID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scan ID"})
|
||||
return false
|
||||
}
|
||||
|
||||
var projectID string
|
||||
err := sh.db.QueryRow("SELECT project_id FROM security_scans WHERE id = $1", scanID).Scan(&projectID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"})
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify scan access"})
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := sh.requireProjectAccess(c, projectID)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (sh *SecurityHandler) requireComplianceReportAccess(c *gin.Context, reportID string) bool {
|
||||
if _, err := uuid.Parse(reportID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid report ID"})
|
||||
return false
|
||||
}
|
||||
|
||||
var projectID string
|
||||
err := sh.db.QueryRow("SELECT project_id FROM compliance_reports WHERE id = $1", reportID).Scan(&projectID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"})
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify report access"})
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := sh.requireProjectAccess(c, projectID)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (sh *SecurityHandler) requireVulnerabilityAccess(c *gin.Context, vulnID string) (string, bool) {
|
||||
if _, err := uuid.Parse(vulnID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vulnerability ID"})
|
||||
return "", false
|
||||
}
|
||||
|
||||
var projectID string
|
||||
err := sh.db.QueryRow("SELECT project_id FROM vulnerabilities WHERE id = $1", vulnID).Scan(&projectID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Vulnerability not found"})
|
||||
return "", false
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify vulnerability access"})
|
||||
return "", false
|
||||
}
|
||||
|
||||
return sh.requireProjectAccess(c, projectID)
|
||||
}
|
||||
|
||||
// max helper function
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,462 +0,0 @@
|
||||
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"`
|
||||
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"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=building running failed stopped"`
|
||||
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 := firstPathParam(c, "id", "project_id", "projectId")
|
||||
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
|
||||
}
|
||||
|
||||
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
||||
projectID, err := uuid.Parse(projectIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateServiceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.ProjectID == uuid.Nil {
|
||||
req.ProjectID = projectID
|
||||
} else if req.ProjectID != projectID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Project ID in URL and request body must match"})
|
||||
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.Status != "" {
|
||||
existingService.Status = req.Status
|
||||
}
|
||||
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, status = $3, image = $4, command = $5, environment = $6,
|
||||
git_repo = $7, git_branch = $8, build_path = $9, cpu = $10, memory = $11, updated_at = $12
|
||||
WHERE id = $13`,
|
||||
existingService.Name, existingService.Type, existingService.Status, 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"})
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 499 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user