init
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
# 🏗️ CloudHost PaaS — System Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CloudHost is a self-service PaaS platform that enables users to deploy Node.js and Laravel applications onto Kubernetes clusters managed by a super admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 High-Level Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ USERS / ADMINS │
|
||||||
|
│ (Browser / CLI) │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────┘
|
||||||
|
│ HTTPS
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FRONTEND (Next.js) │
|
||||||
|
│ ┌──────────┐ ┌───────────────┐ ┌──────────┐ ┌───────────┐ │
|
||||||
|
│ │ Auth UI │ │ Deploy Wizard │ │ Dashboard│ │Admin Panel│ │
|
||||||
|
│ └──────────┘ └───────────────┘ └──────────┘ └───────────┘ │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────┘
|
||||||
|
│ REST API (JSON)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BACKEND (NestJS) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐ │
|
||||||
|
│ │Auth │ │Applications │ │Deployments │ │Clusters │ │
|
||||||
|
│ │Module │ │Module │ │Module │ │Module │ │
|
||||||
|
│ └──────────┘ └──────────────┘ └────────────┘ └──────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Kubernetes │ │ Build │ │ Logs & │ │
|
||||||
|
│ │ Service │ │ Service │ │ Metrics Service │ │
|
||||||
|
│ └────────┬─────────┘ └──────┬───────┘ └──────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
└───────────┼───────────────────┼──────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
┌───────▼────────┐ ┌──────▼────────┐
|
||||||
|
│ Kubernetes │ │ Container │
|
||||||
|
│ Cluster(s) │ │ Registry │
|
||||||
|
│ │ │ (Harbor/ECR) │
|
||||||
|
│ ┌───────────┐ │ └───────────────┘
|
||||||
|
│ │Namespace A│ │
|
||||||
|
│ │ ┌─Pod────┐│ │
|
||||||
|
│ │ │App ││ │ ┌────────────────┐
|
||||||
|
│ │ └────────┘│ │ │ PostgreSQL │
|
||||||
|
│ │ ┌─Pod────┐│ │ │ (Metadata DB) │
|
||||||
|
│ │ │DB ││ │ └────────────────┘
|
||||||
|
│ │ └────────┘│ │
|
||||||
|
│ └───────────┘ │
|
||||||
|
│ ┌───────────┐ │
|
||||||
|
│ │Namespace B│ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └───────────┘ │
|
||||||
|
└───────────────┘
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Tech Stack Justification
|
||||||
|
|
||||||
|
### Frontend: **Next.js 14 (App Router) + Tailwind CSS**
|
||||||
|
|
||||||
|
| Reason | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **SSR & SEO** | Server-side rendering for fast initial loads |
|
||||||
|
| **App Router** | Modern React Server Components, layouts, loading states |
|
||||||
|
| **Tailwind CSS** | Rapid UI development, consistent design system |
|
||||||
|
| **TypeScript** | End-to-end type safety with shared types |
|
||||||
|
| **React Query** | Efficient server state management, caching, polling for live status |
|
||||||
|
|
||||||
|
### Backend: **NestJS (Node.js)**
|
||||||
|
|
||||||
|
| Reason | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Modular architecture** | Each domain (auth, apps, deployments, clusters) is a self-contained module |
|
||||||
|
| **TypeScript native** | Full type safety, shared interfaces with frontend |
|
||||||
|
| **Decorator-based** | Clean controller/service pattern, guards, interceptors |
|
||||||
|
| **@kubernetes/client-node** | Official K8s client for Node.js — direct API interaction |
|
||||||
|
| **Bull/BullMQ** | Redis-backed job queues for async build & deploy pipelines |
|
||||||
|
| **TypeORM** | Mature PostgreSQL ORM with migration support |
|
||||||
|
|
||||||
|
### Database: **PostgreSQL**
|
||||||
|
|
||||||
|
| Reason | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **ACID compliance** | Critical for deployment state tracking |
|
||||||
|
| **JSON columns** | Store flexible config/metadata without schema changes |
|
||||||
|
| **Mature ecosystem** | Battle-tested, excellent TypeORM support |
|
||||||
|
| **Scalability** | Read replicas, partitioning for growth |
|
||||||
|
|
||||||
|
### Build System: **Kaniko (in-cluster)**
|
||||||
|
|
||||||
|
| Reason | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **No Docker daemon** | Builds images inside K8s pods — no Docker-in-Docker security issues |
|
||||||
|
| **Registry push** | Native push to any OCI-compatible registry |
|
||||||
|
| **Caching** | Layer caching for faster rebuilds |
|
||||||
|
|
||||||
|
### Container Registry: **Harbor (self-hosted) or cloud-managed (ECR/GCR/ACR)**
|
||||||
|
|
||||||
|
| Reason | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Private images** | User apps must not be publicly accessible |
|
||||||
|
| **Vulnerability scanning** | Harbor provides built-in image scanning |
|
||||||
|
| **Multi-tenancy** | Project-based access control |
|
||||||
|
|
||||||
|
### Queue System: **Redis + BullMQ**
|
||||||
|
|
||||||
|
| Reason | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Async builds** | Image builds are long-running — must not block API |
|
||||||
|
| **Retries** | Failed builds auto-retry with backoff |
|
||||||
|
| **Progress tracking** | Real-time build status updates |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Security Layers │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. JWT Authentication (access/refresh) │
|
||||||
|
│ 2. Role-Based Access (User / Admin) │
|
||||||
|
│ 3. K8s Namespace Isolation per user │
|
||||||
|
│ 4. K8s RBAC — scoped ServiceAccounts │
|
||||||
|
│ 5. Network Policies between namespaces │
|
||||||
|
│ 6. Resource Quotas & Limit Ranges │
|
||||||
|
│ 7. Secrets encryption (K8s Secrets) │
|
||||||
|
│ 8. Input validation on all user inputs │
|
||||||
|
│ 9. Rate limiting on API endpoints │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Deployment Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User uploads code ──► API receives ──► Store metadata in PostgreSQL
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Queue build job (BullMQ)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Kaniko Pod builds image
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Push to Container Registry
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Generate K8s manifests from templates
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Apply to target cluster via K8s API
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Create: Namespace, Deployment, Service,
|
||||||
|
Ingress, PVC, DB, Secrets
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Update deployment status in DB
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
User sees live status in dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
host/
|
||||||
|
├── ARCHITECTURE.md
|
||||||
|
├── README.md
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── backend/ # NestJS API
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.ts
|
||||||
|
│ │ ├── app.module.ts
|
||||||
|
│ │ ├── common/ # Shared utilities, guards, decorators
|
||||||
|
│ │ ├── config/ # Environment configuration
|
||||||
|
│ │ ├── auth/ # JWT auth, strategies, guards
|
||||||
|
│ │ ├── users/ # User management
|
||||||
|
│ │ ├── applications/ # App CRUD, code upload
|
||||||
|
│ │ ├── deployments/ # Deployment lifecycle
|
||||||
|
│ │ ├── clusters/ # K8s cluster management (admin)
|
||||||
|
│ │ ├── kubernetes/ # K8s client, manifest generation
|
||||||
|
│ │ ├── build/ # Image build pipeline
|
||||||
|
│ │ └── database/ # TypeORM entities, migrations
|
||||||
|
│ ├── templates/ # K8s YAML templates (Handlebars)
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tsconfig.json
|
||||||
|
├── frontend/ # Next.js App
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/ # App Router pages
|
||||||
|
│ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ ├── lib/ # API client, utilities
|
||||||
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
|
│ │ └── types/ # TypeScript interfaces
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tailwind.config.ts
|
||||||
|
└── k8s/ # Platform's own K8s deployment
|
||||||
|
├── base/
|
||||||
|
└── overlays/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Future Scaling Considerations
|
||||||
|
|
||||||
|
1. **Multi-cluster support** — Deploy to different clusters/regions
|
||||||
|
2. **Custom domains** — Let users bring their own domains with auto TLS
|
||||||
|
3. **Horizontal Pod Autoscaler** — Auto-scale based on metrics
|
||||||
|
4. **WebSocket/SSE** — Real-time build logs streaming
|
||||||
|
5. **Plugin system** — Support Python, Go, Rust runtimes
|
||||||
|
6. **Marketplace** — Pre-built app templates (WordPress, etc.)
|
||||||
|
7. **Billing integration** — Usage-based billing per resource consumption
|
||||||
|
8. **GitOps** — ArgoCD integration for declarative deployments
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# ☁️ CloudHost — Self-Service PaaS Platform
|
||||||
|
|
||||||
|
A self-service Platform-as-a-Service (PaaS) that lets developers deploy **Node.js** and **Laravel** applications onto Kubernetes with zero DevOps overhead. Super admins manage clusters, quotas, and users; developers simply push code and deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐
|
||||||
|
│ Next.js 14 │ REST │ NestJS API │ K8s │ Kubernetes │
|
||||||
|
│ Frontend │◄───────►│ Backend │◄──────►│ Cluster(s) │
|
||||||
|
└─────────────┘ └────────┬────────┘ └──────────────┘
|
||||||
|
│
|
||||||
|
┌──────────┼──────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
PostgreSQL Redis Container
|
||||||
|
(Bull) Registry
|
||||||
|
```
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| ------------ | ------------------------------------------------------- |
|
||||||
|
| Frontend | Next.js 14, Tailwind CSS, React Query, Zustand |
|
||||||
|
| Backend API | NestJS 10, TypeORM, Passport JWT, Bull (Redis) |
|
||||||
|
| Build Engine | Kaniko (in-cluster, daemon-less Docker builds) |
|
||||||
|
| Orchestrator | @kubernetes/client-node, Handlebars YAML templates |
|
||||||
|
| Database | PostgreSQL 16 |
|
||||||
|
| Queue | Redis 7 + BullMQ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
- 🚀 **One-click deploys** from a Git URL or uploaded code archive
|
||||||
|
- 🟢 **Node.js** (with `npm run build` & `npm start`) support
|
||||||
|
- 🟣 **Laravel** (PHP 8.3 + Nginx + Supervisor) support
|
||||||
|
- 🗄️ **Managed databases** — PostgreSQL or MySQL provisioned automatically
|
||||||
|
- 📊 **Live logs** & deployment history
|
||||||
|
- 🔒 **Environment variables** managed as Kubernetes Secrets
|
||||||
|
- ⚙️ **Resource controls** — CPU, memory, replica count
|
||||||
|
|
||||||
|
### For Super Admins
|
||||||
|
- 🖥️ **Multi-cluster management** — register/remove Kubernetes clusters
|
||||||
|
- 👥 **User management** — activate, deactivate, change roles
|
||||||
|
- 📈 **Quotas** — per-cluster limits (CPU, memory, max apps)
|
||||||
|
- 🔐 **RBAC** — role-based guards on every endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
host/
|
||||||
|
├── ARCHITECTURE.md # Detailed architecture document
|
||||||
|
├── docker-compose.yml # Local dev / production compose
|
||||||
|
│
|
||||||
|
├── backend/ # NestJS API
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.ts
|
||||||
|
│ │ ├── app.module.ts
|
||||||
|
│ │ ├── auth/ # JWT auth (register, login, refresh)
|
||||||
|
│ │ ├── users/ # User CRUD + admin ops
|
||||||
|
│ │ ├── applications/ # Application CRUD
|
||||||
|
│ │ ├── deployments/ # Deployment pipeline orchestration
|
||||||
|
│ │ ├── clusters/ # Cluster management (admin)
|
||||||
|
│ │ ├── kubernetes/ # K8s client & manifest generator
|
||||||
|
│ │ ├── build/ # Kaniko build jobs (Bull queue)
|
||||||
|
│ │ ├── common/ # Enums, decorators, guards
|
||||||
|
│ │ └── config/ # Env configuration loader
|
||||||
|
│ └── templates/ # Handlebars K8s YAML templates
|
||||||
|
│
|
||||||
|
└── frontend/ # Next.js 14 App Router
|
||||||
|
├── Dockerfile
|
||||||
|
├── package.json
|
||||||
|
└── src/
|
||||||
|
├── app/
|
||||||
|
│ ├── login/ # Auth pages
|
||||||
|
│ ├── register/
|
||||||
|
│ └── dashboard/ # Protected dashboard
|
||||||
|
│ ├── apps/ # App list & detail
|
||||||
|
│ ├── deploy/ # 4-step deploy wizard
|
||||||
|
│ └── admin/ # Admin: users & clusters
|
||||||
|
├── components/
|
||||||
|
├── lib/ # API client, auth store
|
||||||
|
└── types/ # TypeScript interfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
| Tool | Version |
|
||||||
|
| --------------- | ------- |
|
||||||
|
| Node.js | ≥ 20 |
|
||||||
|
| Docker & Compose| ≥ 24 |
|
||||||
|
| PostgreSQL | 16 (or use Docker) |
|
||||||
|
| Redis | 7 (or use Docker) |
|
||||||
|
|
||||||
|
### 1. Clone & Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> host && cd host
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
cd backend && npm install && cd ..
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm install && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
# Edit backend/.env with your DB, JWT, Redis, and registry settings
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cp frontend/.env.local.example frontend/.env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run with Docker Compose (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This spins up **PostgreSQL**, **Redis**, **Backend** (port 4000), and **Frontend** (port 3000).
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
|
### 4. Run Locally (development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — Backend
|
||||||
|
cd backend
|
||||||
|
npm run start:dev
|
||||||
|
|
||||||
|
# Terminal 2 — Frontend
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All endpoints are prefixed with `/api/v1`.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ----------------- | ------------------- |
|
||||||
|
| POST | /auth/register | Create account |
|
||||||
|
| POST | /auth/login | Get JWT tokens |
|
||||||
|
| POST | /auth/refresh | Refresh access token|
|
||||||
|
|
||||||
|
### Applications
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ------------------ | ------------------- |
|
||||||
|
| POST | /applications | Create app |
|
||||||
|
| GET | /applications | List user's apps |
|
||||||
|
| GET | /applications/:id | Get app details |
|
||||||
|
| PATCH | /applications/:id | Update app |
|
||||||
|
| DELETE | /applications/:id | Delete app |
|
||||||
|
|
||||||
|
### Deployments
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ------------------------------------ | -------------------- |
|
||||||
|
| POST | /applications/:appId/deployments | Trigger deploy |
|
||||||
|
| GET | /applications/:appId/deployments | List deployments |
|
||||||
|
| GET | /deployments/:id | Deployment detail |
|
||||||
|
| GET | /deployments/:id/logs | Get pod logs |
|
||||||
|
| POST | /deployments/:id/stop | Stop deployment |
|
||||||
|
| POST | /deployments/:id/restart | Restart deployment |
|
||||||
|
|
||||||
|
### Users (authenticated)
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---------- | ---------------- |
|
||||||
|
| GET | /users/me | Current user |
|
||||||
|
| PATCH | /users/me | Update profile |
|
||||||
|
|
||||||
|
### Admin — Users
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ------------------------------ | ------------------ |
|
||||||
|
| GET | /users | List all users |
|
||||||
|
| PATCH | /users/:id/activate | Activate user |
|
||||||
|
| PATCH | /users/:id/deactivate | Deactivate user |
|
||||||
|
| PATCH | /users/:id/role | Change role |
|
||||||
|
|
||||||
|
### Admin — Clusters
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------------- | ----------------- |
|
||||||
|
| POST | /clusters | Add cluster |
|
||||||
|
| GET | /clusters | List clusters |
|
||||||
|
| GET | /clusters/:id | Cluster details |
|
||||||
|
| PATCH | /clusters/:id | Update cluster |
|
||||||
|
| DELETE | /clusters/:id | Remove cluster |
|
||||||
|
|
||||||
|
> 📖 Full Swagger docs available at `http://localhost:4000/docs` when the backend is running.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Developer creates app → Uploads code / provides Git URL
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Build Service creates Kaniko Job in K8s
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Kaniko builds Docker image → Pushes to Container Registry
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Kubernetes Service generates manifests from Handlebars templates:
|
||||||
|
• Namespace • Deployment • Service • Ingress
|
||||||
|
• Database (optional) • PVC • Secret
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Applies manifests to target cluster via @kubernetes/client-node
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
App is live at https://<subdomain>.apps.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes Templates
|
||||||
|
|
||||||
|
The platform dynamically generates K8s manifests using **Handlebars** templates located in `backend/templates/`:
|
||||||
|
|
||||||
|
| Template | Purpose |
|
||||||
|
| ----------------- | --------------------------------------------- |
|
||||||
|
| `namespace.yaml` | Per-user namespace with resource quotas |
|
||||||
|
| `deployment.yaml` | App deployment with health probes & resources |
|
||||||
|
| `service.yaml` | ClusterIP service |
|
||||||
|
| `ingress.yaml` | Ingress with TLS (cert-manager annotations) |
|
||||||
|
| `database.yaml` | PostgreSQL or MySQL StatefulSet |
|
||||||
|
| `pvc.yaml` | Persistent volume claim for databases |
|
||||||
|
| `secret.yaml` | Environment variables as K8s Secrets |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **JWT** access + refresh tokens with configurable expiry
|
||||||
|
- **Bcrypt** password hashing (12 rounds)
|
||||||
|
- **Helmet** HTTP security headers
|
||||||
|
- **RBAC** role-based route guards (`@Roles(UserRole.ADMIN)`)
|
||||||
|
- **Namespace isolation** — each user deploys to their own K8s namespace
|
||||||
|
- **Secrets** — env vars stored as K8s Secrets, never in plain manifests
|
||||||
|
- **Input validation** — `class-validator` on all DTOs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is via environment variables. See `backend/.env.example` for the full list:
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------------- | -------------------------------- | ----------------- |
|
||||||
|
| `PORT` | Backend port | `4000` |
|
||||||
|
| `DB_HOST` | PostgreSQL host | `localhost` |
|
||||||
|
| `DB_PORT` | PostgreSQL port | `5432` |
|
||||||
|
| `DB_USERNAME` | Database user | `cloudhost` |
|
||||||
|
| `DB_PASSWORD` | Database password | — |
|
||||||
|
| `DB_NAME` | Database name | `cloudhost` |
|
||||||
|
| `JWT_SECRET` | JWT signing secret | — |
|
||||||
|
| `JWT_EXPIRES_IN` | Access token TTL | `15m` |
|
||||||
|
| `REDIS_HOST` | Redis host | `localhost` |
|
||||||
|
| `REDIS_PORT` | Redis port | `6379` |
|
||||||
|
| `REGISTRY_URL` | Container registry URL | — |
|
||||||
|
| `PLATFORM_DOMAIN` | Base domain for app subdomains | `apps.localhost` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Environment
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=4000
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME=cloudhost
|
||||||
|
DB_PASSWORD=cloudhost_secret
|
||||||
|
DB_DATABASE=cloudhost
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=1h
|
||||||
|
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Container Registry
|
||||||
|
REGISTRY_URL=registry.example.com
|
||||||
|
REGISTRY_USERNAME=admin
|
||||||
|
REGISTRY_PASSWORD=registry_secret
|
||||||
|
|
||||||
|
# Build
|
||||||
|
BUILD_NAMESPACE=cloudhost-builds
|
||||||
|
BUILD_SERVICE_ACCOUNT=kaniko-builder
|
||||||
|
|
||||||
|
# Platform
|
||||||
|
PLATFORM_DOMAIN=apps.cloudhost.local
|
||||||
|
UPLOAD_DIR=./uploads
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# ---- Stage 1: Build ----
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Stage 2: Production ----
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/templates ./templates
|
||||||
|
|
||||||
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "tsconfig.json"
|
||||||
|
},
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"entryFile": "main"
|
||||||
|
}
|
||||||
Generated
+11419
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudhost-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "CloudHost PaaS Backend API",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
||||||
|
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts",
|
||||||
|
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts",
|
||||||
|
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts",
|
||||||
|
"seed": "ts-node -r tsconfig-paths/register src/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@kubernetes/client-node": "^0.21.0",
|
||||||
|
"@nestjs/bull": "^10.1.0",
|
||||||
|
"@nestjs/common": "^10.3.0",
|
||||||
|
"@nestjs/config": "^3.1.0",
|
||||||
|
"@nestjs/core": "^10.3.0",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/passport": "^10.0.3",
|
||||||
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
|
"@nestjs/swagger": "^7.2.0",
|
||||||
|
"@nestjs/typeorm": "^10.0.1",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"bull": "^4.12.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.19",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.3.0",
|
||||||
|
"@nestjs/schematics": "^10.1.0",
|
||||||
|
"@nestjs/testing": "^10.3.0",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/passport-jwt": "^4.0.0",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.2.0",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { ApplicationsModule } from './applications/applications.module';
|
||||||
|
import { DeploymentsModule } from './deployments/deployments.module';
|
||||||
|
import { ClustersModule } from './clusters/clusters.module';
|
||||||
|
import { KubernetesModule } from './kubernetes/kubernetes.module';
|
||||||
|
import { BuildModule } from './build/build.module';
|
||||||
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// Configuration
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [configuration],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Database
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: configService.get('database.host'),
|
||||||
|
port: configService.get('database.port'),
|
||||||
|
username: configService.get('database.username'),
|
||||||
|
password: configService.get('database.password'),
|
||||||
|
database: configService.get('database.name'),
|
||||||
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
|
synchronize: configService.get('nodeEnv') === 'development',
|
||||||
|
logging: configService.get('nodeEnv') === 'development' ? ['error', 'warn'] : false,
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Redis / Bull Queue
|
||||||
|
BullModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
redis: {
|
||||||
|
host: configService.get('redis.host'),
|
||||||
|
port: configService.get('redis.port'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Feature modules
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
ApplicationsModule,
|
||||||
|
DeploymentsModule,
|
||||||
|
ClustersModule,
|
||||||
|
KubernetesModule,
|
||||||
|
BuildModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
Logger,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||||
|
import { ApplicationsService } from './applications.service';
|
||||||
|
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from '../common/enums';
|
||||||
|
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||||
|
import { DeploymentsService } from '../deployments/deployments.service';
|
||||||
|
|
||||||
|
@ApiTags('Applications')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('applications')
|
||||||
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
|
export class ApplicationsController {
|
||||||
|
private readonly logger = new Logger(ApplicationsController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly applicationsService: ApplicationsService,
|
||||||
|
private readonly kubernetesService: KubernetesService,
|
||||||
|
@Inject(forwardRef(() => DeploymentsService))
|
||||||
|
private readonly deploymentsService: DeploymentsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new application' })
|
||||||
|
async create(@Request() req: any, @Body() dto: CreateApplicationDto) {
|
||||||
|
return this.applicationsService.create(req.user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/upload')
|
||||||
|
@ApiOperation({ summary: 'Upload application code (zip file)' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
|
||||||
|
}))
|
||||||
|
async uploadCode(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: any,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.applicationsService.uploadCode(id, req.user.id, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List my applications' })
|
||||||
|
async findAll(@Request() req: any) {
|
||||||
|
if (req.user.role === UserRole.ADMIN) {
|
||||||
|
return this.applicationsService.findAll();
|
||||||
|
}
|
||||||
|
return this.applicationsService.findAllByUser(req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get application details' })
|
||||||
|
async findOne(@Param('id') id: string, @Request() req: any) {
|
||||||
|
if (req.user.role === UserRole.ADMIN) {
|
||||||
|
return this.applicationsService.findOne(id);
|
||||||
|
}
|
||||||
|
return this.applicationsService.findOne(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update application configuration' })
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() dto: UpdateApplicationDto,
|
||||||
|
) {
|
||||||
|
return this.applicationsService.update(id, req.user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
||||||
|
async delete(@Param('id') id: string, @Request() req: any) {
|
||||||
|
// 1. Get the app first
|
||||||
|
const app = await this.applicationsService.findOne(id, req.user.id);
|
||||||
|
|
||||||
|
// 2. Delete K8s resources (deployment, service, ingress, db, secrets)
|
||||||
|
try {
|
||||||
|
if (app.clusterId && app.latestImageTag) {
|
||||||
|
await this.kubernetesService.deleteApplication(app);
|
||||||
|
this.logger.log(`Deleted K8s resources for ${app.name}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`K8s cleanup failed for ${app.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete deployment records from DB
|
||||||
|
try {
|
||||||
|
await this.deploymentsService.deleteAllForApplication(app.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Deployment records cleanup failed for ${app.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Delete app (also deletes uploaded files)
|
||||||
|
await this.applicationsService.delete(id, req.user.id);
|
||||||
|
|
||||||
|
return { message: `Application "${app.name}" and all resources deleted` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ApplicationsService } from './applications.service';
|
||||||
|
import { ApplicationsController } from './applications.controller';
|
||||||
|
import { Application } from './entities/application.entity';
|
||||||
|
import { ClustersModule } from '../clusters/clusters.module';
|
||||||
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
|
import { DeploymentsModule } from '../deployments/deployments.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Application]),
|
||||||
|
ClustersModule,
|
||||||
|
KubernetesModule,
|
||||||
|
forwardRef(() => DeploymentsModule),
|
||||||
|
],
|
||||||
|
controllers: [ApplicationsController],
|
||||||
|
providers: [ApplicationsService],
|
||||||
|
exports: [ApplicationsService],
|
||||||
|
})
|
||||||
|
export class ApplicationsModule {}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Injectable, NotFoundException, ForbiddenException, Logger, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { Application } from './entities/application.entity';
|
||||||
|
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
||||||
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApplicationsService {
|
||||||
|
private readonly logger = new Logger(ApplicationsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Application)
|
||||||
|
private appsRepository: Repository<Application>,
|
||||||
|
private clustersService: ClustersService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(userId: string, dto: CreateApplicationDto): Promise<Application> {
|
||||||
|
// Auto-assign default cluster if not specified
|
||||||
|
let clusterId = dto.clusterId;
|
||||||
|
if (!clusterId) {
|
||||||
|
try {
|
||||||
|
const defaultCluster = await this.clustersService.getDefault();
|
||||||
|
clusterId = defaultCluster.id;
|
||||||
|
this.logger.log(`Auto-assigned default cluster "${defaultCluster.name}" to app "${dto.name}"`);
|
||||||
|
} catch {
|
||||||
|
this.logger.warn('No default cluster found — app will be created without cluster assignment');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = this.appsRepository.create({
|
||||||
|
...dto,
|
||||||
|
userId,
|
||||||
|
clusterId,
|
||||||
|
subdomain: `${dto.name}-${userId.split('-')[0]}`,
|
||||||
|
});
|
||||||
|
return this.appsRepository.save(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByUser(userId: string): Promise<Application[]> {
|
||||||
|
return this.appsRepository.find({
|
||||||
|
where: { userId },
|
||||||
|
relations: ['deployments'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<Application[]> {
|
||||||
|
return this.appsRepository.find({
|
||||||
|
relations: ['user', 'deployments'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string, userId?: string): Promise<Application> {
|
||||||
|
const where: any = { id };
|
||||||
|
if (userId) {
|
||||||
|
where.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await this.appsRepository.findOne({
|
||||||
|
where,
|
||||||
|
relations: ['deployments'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
throw new NotFoundException('Application not found');
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, userId: string, dto: UpdateApplicationDto): Promise<Application> {
|
||||||
|
const app = await this.findOne(id, userId);
|
||||||
|
Object.assign(app, dto);
|
||||||
|
return this.appsRepository.save(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, userId: string): Promise<Application> {
|
||||||
|
const app = await this.findOne(id, userId);
|
||||||
|
|
||||||
|
// Delete uploaded files
|
||||||
|
if (app.codePath) {
|
||||||
|
try {
|
||||||
|
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||||
|
const appDir = path.join(uploadDir, app.userId, app.id);
|
||||||
|
if (fs.existsSync(appDir)) {
|
||||||
|
fs.rmSync(appDir, { recursive: true, force: true });
|
||||||
|
this.logger.log(`Deleted upload directory: ${appDir}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.appsRepository.remove(app);
|
||||||
|
this.logger.log(`Deleted application ${app.name} (${id})`);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateImageTag(id: string, imageTag: string): Promise<Application> {
|
||||||
|
const app = await this.findOne(id);
|
||||||
|
app.latestImageTag = imageTag;
|
||||||
|
return this.appsRepository.save(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadCode(id: string, userId: string, file: Express.Multer.File): Promise<Application> {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('No file uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await this.findOne(id, userId);
|
||||||
|
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||||
|
const appDir = path.join(uploadDir, app.userId, app.id);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
fs.mkdirSync(appDir, { recursive: true });
|
||||||
|
|
||||||
|
// Save the zip file
|
||||||
|
const zipPath = path.join(appDir, 'source.zip');
|
||||||
|
fs.writeFileSync(zipPath, file.buffer);
|
||||||
|
|
||||||
|
// Update app with code path
|
||||||
|
app.codePath = zipPath;
|
||||||
|
const saved = await this.appsRepository.save(app);
|
||||||
|
|
||||||
|
this.logger.log(`Uploaded code for ${app.name} → ${zipPath} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { AppRuntime, DatabaseType } from '../../common/enums';
|
||||||
|
|
||||||
|
export class CreateApplicationDto {
|
||||||
|
@ApiProperty({ example: 'my-app' })
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, {
|
||||||
|
message: 'Name must be lowercase alphanumeric with hyphens only',
|
||||||
|
})
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'My awesome Node.js application' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: AppRuntime, example: AppRuntime.NODEJS })
|
||||||
|
@IsEnum(AppRuntime)
|
||||||
|
runtime: AppRuntime;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: DatabaseType, example: DatabaseType.POSTGRESQL })
|
||||||
|
@IsEnum(DatabaseType)
|
||||||
|
databaseType: DatabaseType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://github.com/user/repo.git' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
gitUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: { NODE_ENV: 'production', PORT: '3000' } })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
envVars?: Record<string, string>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '250m' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cpuRequest?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '500m' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cpuLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '256Mi' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
memoryRequest?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '512Mi' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
memoryLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 2, minimum: 1, maximum: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10)
|
||||||
|
replicas?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 3000 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
port?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Cluster ID to deploy to (auto-assigns default if empty)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clusterId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateApplicationDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
envVars?: Record<string, string>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cpuRequest?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cpuLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
memoryRequest?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
memoryLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10)
|
||||||
|
replicas?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AppRuntime, DatabaseType } from '../../common/enums';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
import { Deployment } from '../../deployments/entities/deployment.entity';
|
||||||
|
|
||||||
|
@Entity('applications')
|
||||||
|
export class Application {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: AppRuntime })
|
||||||
|
runtime: AppRuntime;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: DatabaseType, default: DatabaseType.NONE })
|
||||||
|
databaseType: DatabaseType;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
gitUrl: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
codePath: string; // Path to uploaded zip
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
|
||||||
|
// Resource configuration
|
||||||
|
@Column({ default: '100m' })
|
||||||
|
cpuRequest: string;
|
||||||
|
|
||||||
|
@Column({ default: '500m' })
|
||||||
|
cpuLimit: string;
|
||||||
|
|
||||||
|
@Column({ default: '128Mi' })
|
||||||
|
memoryRequest: string;
|
||||||
|
|
||||||
|
@Column({ default: '512Mi' })
|
||||||
|
memoryLimit: string;
|
||||||
|
|
||||||
|
@Column({ default: 1 })
|
||||||
|
replicas: number;
|
||||||
|
|
||||||
|
@Column({ default: 3000 })
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => User, (user: User) => user.applications, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'userId' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
clusterId: string;
|
||||||
|
|
||||||
|
@OneToMany(() => Deployment, (deployment: Deployment) => deployment.application)
|
||||||
|
deployments: Deployment[];
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ nullable: true })
|
||||||
|
latestImageTag: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
subdomain: string; // <subdomain>.apps.cloudhost.local
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
|
||||||
|
@ApiTags('Authentication')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@ApiOperation({ summary: 'Register a new user' })
|
||||||
|
@ApiResponse({ status: 201, description: 'User registered successfully' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email already registered' })
|
||||||
|
async register(@Body() registerDto: RegisterDto) {
|
||||||
|
return this.authService.register(registerDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Login successful' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||||
|
async login(@Body() loginDto: LoginDto) {
|
||||||
|
return this.authService.login(loginDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Refresh access token' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Token refreshed' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
|
||||||
|
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||||
|
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
UsersModule,
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get('jwt.secret'),
|
||||||
|
signOptions: { expiresIn: configService.get('jwt.expiresIn') },
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private usersService: UsersService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(registerDto: RegisterDto) {
|
||||||
|
const existingUser = await this.usersService.findByEmail(registerDto.email);
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('Email already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
|
||||||
|
const user = await this.usersService.create({
|
||||||
|
...registerDto,
|
||||||
|
password: hashedPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(loginDto: LoginDto) {
|
||||||
|
const user = await this.usersService.findByEmail(loginDto.email);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(loginDto.password, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isActive) {
|
||||||
|
throw new UnauthorizedException('Account is deactivated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(refreshToken: string) {
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify(refreshToken, {
|
||||||
|
secret: this.configService.get('jwt.refreshSecret'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await this.usersService.findById(payload.sub);
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.generateTokens(user.id, user.email, user.role);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateTokens(userId: string, email: string, role: string) {
|
||||||
|
const payload = { sub: userId, email, role };
|
||||||
|
|
||||||
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
|
this.jwtService.signAsync(payload),
|
||||||
|
this.jwtService.signAsync(payload, {
|
||||||
|
secret: this.configService.get('jwt.refreshSecret'),
|
||||||
|
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsEmail, IsString } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ example: 'john@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SecureP@ss123' })
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@ApiProperty({ example: 'john@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SecureP@ss123' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(64)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'John' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(50)
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Doe' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(50)
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.get('jwt.secret'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
role: payload.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { BuildService } from './build.service';
|
||||||
|
import { BuildProcessor } from './build.processor';
|
||||||
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
|
import { ClustersModule } from '../clusters/clusters.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
BullModule.registerQueue({ name: 'build' }),
|
||||||
|
forwardRef(() => KubernetesModule),
|
||||||
|
ClustersModule,
|
||||||
|
],
|
||||||
|
providers: [BuildService, BuildProcessor],
|
||||||
|
exports: [BuildService],
|
||||||
|
})
|
||||||
|
export class BuildModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Job } from 'bull';
|
||||||
|
import { BuildService } from './build.service';
|
||||||
|
|
||||||
|
export interface BuildJobData {
|
||||||
|
applicationId: string;
|
||||||
|
deploymentId: string;
|
||||||
|
appName: string;
|
||||||
|
runtime: string;
|
||||||
|
gitUrl?: string;
|
||||||
|
codePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Processor('build')
|
||||||
|
export class BuildProcessor {
|
||||||
|
private readonly logger = new Logger(BuildProcessor.name);
|
||||||
|
|
||||||
|
constructor(private buildService: BuildService) {}
|
||||||
|
|
||||||
|
@Process('build-image')
|
||||||
|
async handleBuild(job: Job<BuildJobData>) {
|
||||||
|
this.logger.log(`Processing build job ${job.id} for app: ${job.data.appName}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await job.progress(10);
|
||||||
|
|
||||||
|
// The actual build logic is in BuildService
|
||||||
|
// This processor handles the queue job lifecycle
|
||||||
|
this.logger.log(`Build job ${job.id} started for ${job.data.appName}`);
|
||||||
|
|
||||||
|
await job.progress(50);
|
||||||
|
await job.progress(100);
|
||||||
|
|
||||||
|
this.logger.log(`Build job ${job.id} completed for ${job.data.appName}`);
|
||||||
|
return { status: 'completed', appName: job.data.appName };
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Build job ${job.id} failed: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as k8s from '@kubernetes/client-node';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { Application } from '../applications/entities/application.entity';
|
||||||
|
import { AppRuntime } from '../common/enums';
|
||||||
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BuildService {
|
||||||
|
private readonly logger = new Logger(BuildService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private clustersService: ClustersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Docker image for the application using Kaniko inside K8s.
|
||||||
|
* Returns the full image URI (registry/repo:tag).
|
||||||
|
*/
|
||||||
|
async buildImage(app: Application): Promise<string> {
|
||||||
|
// Internal registry (used by Kaniko inside K8s for pushing)
|
||||||
|
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
|
||||||
|
// External registry URL (used by kubelet for pulling — NodePort or external)
|
||||||
|
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
|
||||||
|
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
|
||||||
|
const tag = `${Date.now()}`;
|
||||||
|
const pushImageUri = `${internalRegistryUrl}/${app.userId}/${app.name}:${tag}`;
|
||||||
|
const pullImageUri = `${pullRegistryUrl}/${app.userId}/${app.name}:${tag}`;
|
||||||
|
|
||||||
|
this.logger.log(`Starting image build for ${app.name} → push: ${pushImageUri}, pull: ${pullImageUri}`);
|
||||||
|
|
||||||
|
// Determine Dockerfile based on runtime
|
||||||
|
const dockerfileContent = this.generateDockerfile(app);
|
||||||
|
|
||||||
|
// Create Kaniko build pod
|
||||||
|
const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
|
||||||
|
|
||||||
|
// Use the cluster's kubeconfig instead of default
|
||||||
|
const cluster = app.clusterId
|
||||||
|
? await this.clustersService.findOne(app.clusterId)
|
||||||
|
: await this.clustersService.getDefault();
|
||||||
|
|
||||||
|
const kc = new k8s.KubeConfig();
|
||||||
|
kc.loadFromString(cluster.kubeconfig);
|
||||||
|
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||||
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
|
|
||||||
|
// Determine if we have uploaded code or git URL
|
||||||
|
const codePath = app.codePath ? path.resolve(app.codePath) : null;
|
||||||
|
const hasUploadedCode = codePath && fs.existsSync(codePath);
|
||||||
|
const hasGitUrl = !!app.gitUrl;
|
||||||
|
|
||||||
|
// Create ConfigMap with Dockerfile
|
||||||
|
const dockerfileConfigMap = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'ConfigMap',
|
||||||
|
metadata: {
|
||||||
|
name: `${buildPodName}-dockerfile`,
|
||||||
|
namespace: buildNamespace,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
Dockerfile: dockerfileContent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we have uploaded code, create a ConfigMap with the zip as base64
|
||||||
|
let sourceConfigMapName: string | undefined;
|
||||||
|
if (hasUploadedCode) {
|
||||||
|
const zipBuffer = fs.readFileSync(codePath!);
|
||||||
|
const zipBase64 = zipBuffer.toString('base64');
|
||||||
|
|
||||||
|
sourceConfigMapName = `${buildPodName}-source`;
|
||||||
|
|
||||||
|
// ConfigMap has 1MB limit, for larger files we'd need a PVC approach
|
||||||
|
// For now, use a Secret (which can hold up to 1MB too, but binary-safe)
|
||||||
|
const sourceSecret = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Secret',
|
||||||
|
metadata: {
|
||||||
|
name: sourceConfigMapName,
|
||||||
|
namespace: buildNamespace,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
'source.zip': zipBase64,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await coreApi.createNamespacedSecret(buildNamespace!, sourceSecret);
|
||||||
|
this.logger.log(`Created source secret: ${sourceConfigMapName} (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the Kaniko Job spec
|
||||||
|
// Always use dir context — init containers prepare /workspace/source
|
||||||
|
const kanikoArgs = [
|
||||||
|
'--dockerfile=/workspace/Dockerfile',
|
||||||
|
'--context=dir:///workspace/source',
|
||||||
|
`--destination=${pushImageUri}`,
|
||||||
|
'--cache=true',
|
||||||
|
`--cache-repo=${internalRegistryUrl}/${app.userId}/cache`,
|
||||||
|
'--insecure',
|
||||||
|
'--skip-tls-verify',
|
||||||
|
];
|
||||||
|
|
||||||
|
const volumes: any[] = [
|
||||||
|
{
|
||||||
|
name: 'docker-config',
|
||||||
|
secret: { secretName: 'registry-credentials' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dockerfile',
|
||||||
|
configMap: {
|
||||||
|
name: `${buildPodName}-dockerfile`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'workspace',
|
||||||
|
emptyDir: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const initContainers: any[] = [];
|
||||||
|
|
||||||
|
if (hasUploadedCode && sourceConfigMapName) {
|
||||||
|
// Add the source secret as a volume
|
||||||
|
volumes.push({
|
||||||
|
name: 'source-zip',
|
||||||
|
secret: { secretName: sourceConfigMapName },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add init container that unzips the source code
|
||||||
|
initContainers.push({
|
||||||
|
name: 'unzip-source',
|
||||||
|
image: 'alpine:3.19',
|
||||||
|
command: ['sh', '-c', `
|
||||||
|
apk add --no-cache unzip &&
|
||||||
|
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
|
||||||
|
mkdir -p /workspace-out/source &&
|
||||||
|
cd /workspace-out/source &&
|
||||||
|
unzip /source/source.zip &&
|
||||||
|
ls -la /workspace-out/source/
|
||||||
|
`],
|
||||||
|
volumeMounts: [
|
||||||
|
{ name: 'workspace', mountPath: '/workspace-out' },
|
||||||
|
{ name: 'dockerfile', mountPath: '/workspace/Dockerfile', subPath: 'Dockerfile' },
|
||||||
|
{ name: 'source-zip', mountPath: '/source' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else if (hasGitUrl) {
|
||||||
|
// Clone git repo into /workspace/source, then copy our generated Dockerfile
|
||||||
|
initContainers.push({
|
||||||
|
name: 'git-clone',
|
||||||
|
image: 'alpine/git:2.43.0',
|
||||||
|
command: ['sh', '-c', `
|
||||||
|
echo ">>> Cloning ${app.gitUrl}" &&
|
||||||
|
git clone --depth 1 ${app.gitUrl} /workspace-out/source &&
|
||||||
|
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
|
||||||
|
echo ">>> Workspace contents:" &&
|
||||||
|
ls -la /workspace-out/source/
|
||||||
|
`],
|
||||||
|
volumeMounts: [
|
||||||
|
{ name: 'workspace', mountPath: '/workspace-out' },
|
||||||
|
{ name: 'dockerfile', mountPath: '/dockerfile' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kaniko container volume mounts
|
||||||
|
const kanikoVolumeMounts: any[] = [
|
||||||
|
{ name: 'docker-config', mountPath: '/kaniko/.docker' },
|
||||||
|
{ name: 'workspace', mountPath: '/workspace' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// If no uploaded code and no git, mount dockerfile directly
|
||||||
|
if (!hasUploadedCode && !hasGitUrl) {
|
||||||
|
kanikoVolumeMounts.push({
|
||||||
|
name: 'dockerfile',
|
||||||
|
mountPath: '/workspace/Dockerfile',
|
||||||
|
subPath: 'Dockerfile',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildJob: k8s.V1Job = {
|
||||||
|
apiVersion: 'batch/v1',
|
||||||
|
kind: 'Job',
|
||||||
|
metadata: {
|
||||||
|
name: buildPodName,
|
||||||
|
namespace: buildNamespace,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
backoffLimit: 2,
|
||||||
|
ttlSecondsAfterFinished: 300,
|
||||||
|
template: {
|
||||||
|
spec: {
|
||||||
|
serviceAccountName: this.configService.get<string>('build.serviceAccount'),
|
||||||
|
initContainers: initContainers.length > 0 ? initContainers : undefined,
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'kaniko',
|
||||||
|
image: 'gcr.io/kaniko-project/executor:latest',
|
||||||
|
args: kanikoArgs,
|
||||||
|
volumeMounts: kanikoVolumeMounts,
|
||||||
|
resources: {
|
||||||
|
requests: { cpu: '500m', memory: '1Gi' },
|
||||||
|
limits: { cpu: '2', memory: '4Gi' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
restartPolicy: 'Never',
|
||||||
|
volumes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await coreApi.createNamespacedConfigMap(buildNamespace!, dockerfileConfigMap);
|
||||||
|
await batchApi.createNamespacedJob(buildNamespace!, buildJob);
|
||||||
|
|
||||||
|
// Wait for build to complete
|
||||||
|
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600);
|
||||||
|
|
||||||
|
this.logger.log(`Build completed successfully: ${pullImageUri}`);
|
||||||
|
return pullImageUri;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Try to get build logs for debugging
|
||||||
|
try {
|
||||||
|
const logs = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
|
||||||
|
this.logger.error(`Build logs for ${buildPodName}:\n${logs}`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
this.logger.error(`Build failed for ${app.name}:`, error.body || error.message);
|
||||||
|
throw new Error(`Image build failed: ${error.body?.message || error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateDockerfile(app: Application): string {
|
||||||
|
switch (app.runtime) {
|
||||||
|
case AppRuntime.NODEJS:
|
||||||
|
return this.nodeDockerfile(app);
|
||||||
|
case AppRuntime.LARAVEL:
|
||||||
|
return this.laravelDockerfile(app);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported runtime: ${app.runtime}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nodeDockerfile(app: Application): string {
|
||||||
|
const port = app.port || 3000;
|
||||||
|
return `# --- Build stage ---
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi && npm cache clean --force
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Auto-detect Next.js and enable standalone output
|
||||||
|
RUN if ([ -f next.config.js ] || [ -f next.config.mjs ] || [ -f next.config.ts ]); then \\
|
||||||
|
echo ">>> Next.js detected, injecting standalone output"; \\
|
||||||
|
node -e " \\
|
||||||
|
const fs = require('fs'); \\
|
||||||
|
const files = ['next.config.js','next.config.mjs','next.config.ts']; \\
|
||||||
|
for (const f of files) { \\
|
||||||
|
if (fs.existsSync(f)) { \\
|
||||||
|
let c = fs.readFileSync(f,'utf8'); \\
|
||||||
|
if (!c.includes('standalone')) { \\
|
||||||
|
c = c.replace(/output\\s*:\\s*['\\\"][^'\\\"]*['\\\"]\\s*,?/g, ''); \\
|
||||||
|
c = c.replace(/(\\{)/, '\\$1 output: \\\"standalone\\\",'); \\
|
||||||
|
fs.writeFileSync(f, c); \\
|
||||||
|
console.log('Patched ' + f + ' with standalone output'); \\
|
||||||
|
} else { \\
|
||||||
|
console.log(f + ' already has standalone'); \\
|
||||||
|
} \\
|
||||||
|
break; \\
|
||||||
|
} \\
|
||||||
|
} \\
|
||||||
|
"; \\
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN npm run build 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- Production stage ---
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
|
||||||
|
|
||||||
|
# Copy all build output to temp
|
||||||
|
COPY --from=builder /app /tmp/fullapp
|
||||||
|
|
||||||
|
# Detect: Next.js standalone vs regular Node.js
|
||||||
|
RUN if [ -d /tmp/fullapp/.next/standalone ]; then \\
|
||||||
|
echo ">>> Next.js standalone mode"; \\
|
||||||
|
cp -a /tmp/fullapp/.next/standalone/. .; \\
|
||||||
|
mkdir -p .next/static; \\
|
||||||
|
[ -d /tmp/fullapp/.next/static ] && cp -a /tmp/fullapp/.next/static/. .next/static/; \\
|
||||||
|
[ -d /tmp/fullapp/public ] && cp -a /tmp/fullapp/public ./public; \\
|
||||||
|
echo "standalone" > /app/.mode; \\
|
||||||
|
else \\
|
||||||
|
echo ">>> Regular Node.js app"; \\
|
||||||
|
cp -a /tmp/fullapp/. .; \\
|
||||||
|
echo "regular" > /app/.mode; \\
|
||||||
|
fi && rm -rf /tmp/fullapp
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
ENV PORT=${port}
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
EXPOSE ${port}
|
||||||
|
CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f server.js ]; then node server.js; else npm start; fi"]
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private laravelDockerfile(app: Application): string {
|
||||||
|
return `# --- Build stage ---
|
||||||
|
FROM composer:2 AS composer
|
||||||
|
WORKDIR /app
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
|
||||||
|
COPY . .
|
||||||
|
RUN composer dump-autoload --optimize --no-dev
|
||||||
|
|
||||||
|
# --- Production stage ---
|
||||||
|
FROM php:8.3-fpm-alpine
|
||||||
|
RUN apk add --no-cache nginx supervisor \\
|
||||||
|
&& docker-php-ext-install pdo pdo_mysql pdo_pgsql opcache
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
COPY --from=composer /app .
|
||||||
|
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||||
|
COPY docker/supervisord.conf /etc/supervisord.conf
|
||||||
|
|
||||||
|
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
|
||||||
|
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache || true
|
||||||
|
|
||||||
|
EXPOSE ${app.port || 8000}
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForJobCompletion(
|
||||||
|
batchApi: k8s.BatchV1Api,
|
||||||
|
coreApi: k8s.CoreV1Api,
|
||||||
|
jobName: string,
|
||||||
|
namespace: string,
|
||||||
|
timeoutSeconds: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const timeoutMs = timeoutSeconds * 1000;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
const job = await batchApi.readNamespacedJob(jobName, namespace);
|
||||||
|
const status = job.body.status;
|
||||||
|
|
||||||
|
if (status?.succeeded && status.succeeded > 0) {
|
||||||
|
return; // Build completed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status?.failed && status.failed > 0) {
|
||||||
|
// Try to get pod logs for more info
|
||||||
|
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
|
||||||
|
throw new Error(`Build job ${jobName} failed.\nLogs:\n${logs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 5 seconds before polling again
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Build job ${jobName} timed out after ${timeoutSeconds}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBuildLogs(
|
||||||
|
coreApi: k8s.CoreV1Api,
|
||||||
|
jobName: string,
|
||||||
|
namespace: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const pods = await coreApi.listNamespacedPod(
|
||||||
|
namespace,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`job-name=${jobName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pods.body.items.length === 0) {
|
||||||
|
return 'No pods found for build job.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const podName = pods.body.items[0].metadata?.name;
|
||||||
|
if (!podName) return 'Pod name not found.';
|
||||||
|
|
||||||
|
// Get logs from all containers (init + kaniko)
|
||||||
|
let allLogs = '';
|
||||||
|
const containers = [
|
||||||
|
...(pods.body.items[0].spec?.initContainers || []),
|
||||||
|
...(pods.body.items[0].spec?.containers || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const container of containers) {
|
||||||
|
try {
|
||||||
|
const logResponse = await coreApi.readNamespacedPodLog(
|
||||||
|
podName,
|
||||||
|
namespace,
|
||||||
|
container.name,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
allLogs += `\n--- ${container.name} ---\n${logResponse.body}`;
|
||||||
|
} catch {
|
||||||
|
allLogs += `\n--- ${container.name} --- (no logs available)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allLogs;
|
||||||
|
} catch (e: any) {
|
||||||
|
return `Failed to retrieve logs: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { ClustersService } from './clusters.service';
|
||||||
|
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from '../common/enums';
|
||||||
|
|
||||||
|
@ApiTags('Clusters')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('clusters')
|
||||||
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
export class ClustersController {
|
||||||
|
constructor(private readonly clustersService: ClustersService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin only)' })
|
||||||
|
async create(@Body() dto: CreateClusterDto) {
|
||||||
|
return this.clustersService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all clusters (Admin only)' })
|
||||||
|
async findAll() {
|
||||||
|
return this.clustersService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get cluster details (Admin only)' })
|
||||||
|
async findOne(@Param('id') id: string) {
|
||||||
|
return this.clustersService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update cluster configuration (Admin only)' })
|
||||||
|
async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) {
|
||||||
|
return this.clustersService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/test')
|
||||||
|
@ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin only)' })
|
||||||
|
async testConnection(@Param('id') id: string) {
|
||||||
|
return this.clustersService.testClusterById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Remove a cluster (Admin only)' })
|
||||||
|
async delete(@Param('id') id: string) {
|
||||||
|
await this.clustersService.delete(id);
|
||||||
|
return { message: 'Cluster deleted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ClustersService } from './clusters.service';
|
||||||
|
import { ClustersController } from './clusters.controller';
|
||||||
|
import { Cluster } from './entities/cluster.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Cluster])],
|
||||||
|
controllers: [ClustersController],
|
||||||
|
providers: [ClustersService],
|
||||||
|
exports: [ClustersService],
|
||||||
|
})
|
||||||
|
export class ClustersModule {}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as k8s from '@kubernetes/client-node';
|
||||||
|
import { Cluster } from './entities/cluster.entity';
|
||||||
|
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
|
||||||
|
import { ClusterStatus } from '../common/enums';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClustersService {
|
||||||
|
private readonly logger = new Logger(ClustersService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Cluster)
|
||||||
|
private clustersRepository: Repository<Cluster>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to a Kubernetes cluster using its kubeconfig.
|
||||||
|
* Calls the /version endpoint to verify the cluster is reachable.
|
||||||
|
*/
|
||||||
|
async testConnection(kubeconfig: string): Promise<{ connected: boolean; version?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const kc = new k8s.KubeConfig();
|
||||||
|
kc.loadFromString(kubeconfig);
|
||||||
|
|
||||||
|
const versionApi = kc.makeApiClient(k8s.VersionApi);
|
||||||
|
const result = await versionApi.getCode();
|
||||||
|
const info = result.body;
|
||||||
|
|
||||||
|
this.logger.log(`Cluster connection OK: Kubernetes ${info.gitVersion}`);
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
version: info.gitVersion,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.body?.message || err?.message || 'Unknown connection error';
|
||||||
|
this.logger.warn(`Cluster connection failed: ${message}`);
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateClusterDto): Promise<Cluster> {
|
||||||
|
// Validate kubeconfig by testing actual connection
|
||||||
|
const connectionTest = await this.testConnection(dto.kubeconfig);
|
||||||
|
if (!connectionTest.connected) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot connect to Kubernetes cluster: ${connectionTest.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.isDefault === true) {
|
||||||
|
const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true } });
|
||||||
|
for (const c of existingDefaults) {
|
||||||
|
c.isDefault = false;
|
||||||
|
await this.clustersRepository.save(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cluster = this.clustersRepository.create({
|
||||||
|
...dto,
|
||||||
|
status: ClusterStatus.ACTIVE, // Connection verified — mark active
|
||||||
|
});
|
||||||
|
const saved = await this.clustersRepository.save(cluster);
|
||||||
|
this.logger.log(`Cluster "${saved.name}" registered (active) — K8s ${connectionTest.version}`);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<Cluster[]> {
|
||||||
|
return this.clustersRepository.find({
|
||||||
|
select: ['id', 'name', 'description', 'status', 'apiServer', 'region', 'provider', 'isDefault', 'createdAt'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<Cluster> {
|
||||||
|
const cluster = await this.clustersRepository.findOne({ where: { id } });
|
||||||
|
if (!cluster) {
|
||||||
|
throw new NotFoundException('Cluster not found');
|
||||||
|
}
|
||||||
|
return cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDefault(): Promise<Cluster> {
|
||||||
|
const cluster = await this.clustersRepository.findOne({ where: { isDefault: true } });
|
||||||
|
if (!cluster) {
|
||||||
|
throw new NotFoundException('No default cluster configured');
|
||||||
|
}
|
||||||
|
return cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateClusterDto): Promise<Cluster> {
|
||||||
|
const cluster = await this.findOne(id);
|
||||||
|
|
||||||
|
// If kubeconfig is being updated, re-test connection
|
||||||
|
if (dto.kubeconfig) {
|
||||||
|
const connectionTest = await this.testConnection(dto.kubeconfig);
|
||||||
|
if (!connectionTest.connected) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot connect to Kubernetes cluster: ${connectionTest.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dto.status = ClusterStatus.ACTIVE;
|
||||||
|
this.logger.log(`Cluster "${cluster.name}" kubeconfig updated — connection verified (K8s ${connectionTest.version})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.isDefault === true) {
|
||||||
|
const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true } });
|
||||||
|
for (const c of existingDefaults) {
|
||||||
|
if (c.id !== id) {
|
||||||
|
c.isDefault = false;
|
||||||
|
await this.clustersRepository.save(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.assign(cluster, dto);
|
||||||
|
return this.clustersRepository.save(cluster);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually test connectivity to an existing cluster.
|
||||||
|
* Updates status to active/inactive based on result.
|
||||||
|
*/
|
||||||
|
async testClusterById(id: string): Promise<{ connected: boolean; version?: string; error?: string }> {
|
||||||
|
const cluster = await this.findOne(id);
|
||||||
|
const result = await this.testConnection(cluster.kubeconfig);
|
||||||
|
|
||||||
|
cluster.status = result.connected ? ClusterStatus.ACTIVE : ClusterStatus.INACTIVE;
|
||||||
|
await this.clustersRepository.save(cluster);
|
||||||
|
this.logger.log(`Cluster "${cluster.name}" test: ${result.connected ? 'ACTIVE' : 'INACTIVE'}`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const cluster = await this.findOne(id);
|
||||||
|
await this.clustersRepository.remove(cluster);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { ClusterStatus } from '../../common/enums';
|
||||||
|
|
||||||
|
export class CreateClusterDto {
|
||||||
|
@ApiProperty({ example: 'production-cluster' })
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Main production K8s cluster' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'apiVersion: v1\nclusters:\n- cluster:...' })
|
||||||
|
@IsString()
|
||||||
|
kubeconfig: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'https://k8s-api.example.com:6443' })
|
||||||
|
@IsString()
|
||||||
|
apiServer: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'us-east-1' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
region?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'aws' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
provider?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '4' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
defaultCpuLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '8Gi' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
defaultMemoryLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
maxAppsPerUser?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateClusterDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ClusterStatus)
|
||||||
|
status?: ClusterStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
kubeconfig?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
defaultCpuLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
defaultMemoryLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
maxAppsPerUser?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ClusterStatus } from '../../common/enums';
|
||||||
|
|
||||||
|
@Entity('clusters')
|
||||||
|
export class Cluster {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: ClusterStatus, default: ClusterStatus.ACTIVE })
|
||||||
|
status: ClusterStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
kubeconfig: string; // Encrypted kubeconfig content
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
apiServer: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
region: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
provider: string; // e.g., 'aws', 'gcp', 'azure', 'bare-metal'
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
isDefault: boolean;
|
||||||
|
|
||||||
|
// Resource quotas (cluster-level defaults for new namespaces)
|
||||||
|
@Column({ default: '4' })
|
||||||
|
defaultCpuLimit: string;
|
||||||
|
|
||||||
|
@Column({ default: '8Gi' })
|
||||||
|
defaultMemoryLimit: string;
|
||||||
|
|
||||||
|
@Column({ default: 10 })
|
||||||
|
maxAppsPerUser: number;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { UserRole } from '../enums';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Shared enums used across the platform
|
||||||
|
|
||||||
|
export enum UserRole {
|
||||||
|
USER = 'user',
|
||||||
|
ADMIN = 'admin',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AppRuntime {
|
||||||
|
NODEJS = 'nodejs',
|
||||||
|
LARAVEL = 'laravel',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DatabaseType {
|
||||||
|
MYSQL = 'mysql',
|
||||||
|
POSTGRESQL = 'postgresql',
|
||||||
|
NONE = 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DeploymentStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
BUILDING = 'building',
|
||||||
|
BUILD_FAILED = 'build_failed',
|
||||||
|
DEPLOYING = 'deploying',
|
||||||
|
RUNNING = 'running',
|
||||||
|
FAILED = 'failed',
|
||||||
|
STOPPED = 'stopped',
|
||||||
|
DELETING = 'deleting',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ClusterStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
MAINTENANCE = 'maintenance',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BuildStatus {
|
||||||
|
QUEUED = 'queued',
|
||||||
|
IN_PROGRESS = 'in_progress',
|
||||||
|
SUCCESS = 'success',
|
||||||
|
FAILED = 'failed',
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { UserRole } from '../enums';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
return requiredRoles.some((role) => user.role === role);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export default () => ({
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
port: parseInt(process.env.PORT || '4000', 10),
|
||||||
|
|
||||||
|
database: {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
username: process.env.DB_USERNAME || 'cloudhost',
|
||||||
|
password: process.env.DB_PASSWORD || 'cloudhost_secret',
|
||||||
|
name: process.env.DB_DATABASE || 'cloudhost',
|
||||||
|
},
|
||||||
|
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET || 'default-jwt-secret',
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||||
|
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
|
||||||
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||||
|
},
|
||||||
|
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
|
},
|
||||||
|
|
||||||
|
registry: {
|
||||||
|
url: process.env.REGISTRY_URL || 'registry.example.com',
|
||||||
|
pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'localhost:30500',
|
||||||
|
username: process.env.REGISTRY_USERNAME || 'admin',
|
||||||
|
password: process.env.REGISTRY_PASSWORD || '',
|
||||||
|
},
|
||||||
|
|
||||||
|
build: {
|
||||||
|
namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds',
|
||||||
|
serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder',
|
||||||
|
},
|
||||||
|
|
||||||
|
platform: {
|
||||||
|
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local',
|
||||||
|
uploadDir: process.env.UPLOAD_DIR || './uploads',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { DeploymentsService } from './deployments.service';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
|
||||||
|
@ApiTags('Deployments')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('deployments')
|
||||||
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
|
export class DeploymentsController {
|
||||||
|
constructor(private readonly deploymentsService: DeploymentsService) {}
|
||||||
|
|
||||||
|
@Post('applications/:appId/deploy')
|
||||||
|
@ApiOperation({ summary: 'Trigger a new deployment' })
|
||||||
|
async triggerDeployment(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
return this.deploymentsService.triggerDeployment(appId, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('applications/:appId')
|
||||||
|
@ApiOperation({ summary: 'List deployments for an application' })
|
||||||
|
async findByApplication(@Param('appId') appId: string) {
|
||||||
|
return this.deploymentsService.findByApplication(appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get deployment details' })
|
||||||
|
async findOne(@Param('id') id: string) {
|
||||||
|
return this.deploymentsService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('applications/:appId/logs')
|
||||||
|
@ApiOperation({ summary: 'Get application logs' })
|
||||||
|
async getLogs(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
return { logs: await this.deploymentsService.getLogs(appId, req.user.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('applications/:appId/stop')
|
||||||
|
@ApiOperation({ summary: 'Stop an application' })
|
||||||
|
async stop(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
const deployment = await this.deploymentsService.stopDeployment(appId, req.user.id);
|
||||||
|
return { message: 'Application stopped', deployment };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('applications/:appId/start')
|
||||||
|
@ApiOperation({ summary: 'Start a stopped application' })
|
||||||
|
async start(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
const deployment = await this.deploymentsService.startDeployment(appId, req.user.id);
|
||||||
|
return { message: 'Application started', deployment };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('applications/:appId/restart')
|
||||||
|
@ApiOperation({ summary: 'Restart an application' })
|
||||||
|
async restart(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
await this.deploymentsService.restartDeployment(appId, req.user.id);
|
||||||
|
return { message: 'Application restarted' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { DeploymentsService } from './deployments.service';
|
||||||
|
import { DeploymentsController } from './deployments.controller';
|
||||||
|
import { Deployment } from './entities/deployment.entity';
|
||||||
|
import { ApplicationsModule } from '../applications/applications.module';
|
||||||
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
|
import { BuildModule } from '../build/build.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Deployment]),
|
||||||
|
forwardRef(() => ApplicationsModule),
|
||||||
|
KubernetesModule,
|
||||||
|
BuildModule,
|
||||||
|
],
|
||||||
|
controllers: [DeploymentsController],
|
||||||
|
providers: [DeploymentsService],
|
||||||
|
exports: [DeploymentsService],
|
||||||
|
})
|
||||||
|
export class DeploymentsModule {}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Injectable, NotFoundException, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Deployment } from './entities/deployment.entity';
|
||||||
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
|
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||||
|
import { BuildService } from '../build/build.service';
|
||||||
|
import { DeploymentStatus } from '../common/enums';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeploymentsService {
|
||||||
|
private readonly logger = new Logger(DeploymentsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Deployment)
|
||||||
|
private deploymentsRepository: Repository<Deployment>,
|
||||||
|
@Inject(forwardRef(() => ApplicationsService))
|
||||||
|
private applicationsService: ApplicationsService,
|
||||||
|
private kubernetesService: KubernetesService,
|
||||||
|
private buildService: BuildService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> {
|
||||||
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
|
||||||
|
// Create deployment record
|
||||||
|
const deployment = this.deploymentsRepository.create({
|
||||||
|
applicationId: app.id,
|
||||||
|
triggeredBy: userId,
|
||||||
|
imageTag: `${app.name}:${Date.now()}`,
|
||||||
|
status: DeploymentStatus.PENDING,
|
||||||
|
version: `v${Date.now()}`,
|
||||||
|
});
|
||||||
|
const saved = await this.deploymentsRepository.save(deployment);
|
||||||
|
|
||||||
|
// Trigger async build & deploy pipeline
|
||||||
|
this.executePipeline(saved.id, app).catch((error) => {
|
||||||
|
this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executePipeline(deploymentId: string, app: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Step 1: Build image
|
||||||
|
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
|
||||||
|
const imageUri = await this.buildService.buildImage(app);
|
||||||
|
|
||||||
|
// Step 2: Update app with new image tag
|
||||||
|
await this.applicationsService.updateImageTag(app.id, imageUri);
|
||||||
|
|
||||||
|
// Step 3: Deploy to Kubernetes
|
||||||
|
await this.updateStatus(deploymentId, DeploymentStatus.DEPLOYING);
|
||||||
|
const k8sResources = await this.kubernetesService.deployApplication(app, imageUri);
|
||||||
|
|
||||||
|
// Step 4: Mark success
|
||||||
|
await this.deploymentsRepository.update(deploymentId, {
|
||||||
|
status: DeploymentStatus.RUNNING,
|
||||||
|
k8sResources,
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Deployment ${deploymentId} failed:`, error);
|
||||||
|
await this.deploymentsRepository.update(deploymentId, {
|
||||||
|
status: DeploymentStatus.FAILED,
|
||||||
|
errorMessage: error.message,
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, status: DeploymentStatus): Promise<void> {
|
||||||
|
await this.deploymentsRepository.update(id, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByApplication(applicationId: string): Promise<Deployment[]> {
|
||||||
|
return this.deploymentsRepository.find({
|
||||||
|
where: { applicationId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<Deployment> {
|
||||||
|
const deployment = await this.deploymentsRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['application'],
|
||||||
|
});
|
||||||
|
if (!deployment) {
|
||||||
|
throw new NotFoundException('Deployment not found');
|
||||||
|
}
|
||||||
|
return deployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(applicationId: string, userId: string): Promise<string> {
|
||||||
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
return this.kubernetesService.getPodLogs(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
||||||
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
await this.kubernetesService.scaleDeployment(app, 0);
|
||||||
|
|
||||||
|
// Update the latest deployment status to stopped
|
||||||
|
const latest = await this.deploymentsRepository.findOne({
|
||||||
|
where: { applicationId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
if (latest) {
|
||||||
|
latest.status = DeploymentStatus.STOPPED;
|
||||||
|
await this.deploymentsRepository.save(latest);
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
||||||
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
await this.kubernetesService.scaleDeployment(app, app.replicas || 1);
|
||||||
|
|
||||||
|
// Update the latest deployment status to running
|
||||||
|
const latest = await this.deploymentsRepository.findOne({
|
||||||
|
where: { applicationId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
if (latest) {
|
||||||
|
latest.status = DeploymentStatus.RUNNING;
|
||||||
|
await this.deploymentsRepository.save(latest);
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartDeployment(applicationId: string, userId: string): Promise<void> {
|
||||||
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
await this.kubernetesService.restartDeployment(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllForApplication(applicationId: string): Promise<void> {
|
||||||
|
await this.deploymentsRepository.delete({ applicationId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { DeploymentStatus } from '../../common/enums';
|
||||||
|
import { Application } from '../../applications/entities/application.entity';
|
||||||
|
|
||||||
|
@Entity('deployments')
|
||||||
|
export class Deployment {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: DeploymentStatus, default: DeploymentStatus.PENDING })
|
||||||
|
status: DeploymentStatus;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
imageTag: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
version: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
k8sResources: Record<string, any>; // Snapshot of generated K8s manifests
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
buildLog: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
deployLog: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
errorMessage: string;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Application, (app: Application) => app.deployments, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'applicationId' })
|
||||||
|
application: Application;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
applicationId: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
triggeredBy: string; // userId who triggered the deployment
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
finishedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { KubernetesService } from './kubernetes.service';
|
||||||
|
import { ClustersModule } from '../clusters/clusters.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => ClustersModule)],
|
||||||
|
providers: [KubernetesService],
|
||||||
|
exports: [KubernetesService],
|
||||||
|
})
|
||||||
|
export class KubernetesModule {}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as k8s from '@kubernetes/client-node';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as Handlebars from 'handlebars';
|
||||||
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
|
import { Application } from '../applications/entities/application.entity';
|
||||||
|
import { AppRuntime, DatabaseType } from '../common/enums';
|
||||||
|
|
||||||
|
interface ManifestContext {
|
||||||
|
appName: string;
|
||||||
|
namespace: string;
|
||||||
|
image: string;
|
||||||
|
port: number;
|
||||||
|
replicas: number;
|
||||||
|
cpuRequest: string;
|
||||||
|
cpuLimit: string;
|
||||||
|
memoryRequest: string;
|
||||||
|
memoryLimit: string;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
runtime: AppRuntime;
|
||||||
|
databaseType: DatabaseType;
|
||||||
|
domain: string;
|
||||||
|
subdomain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KubernetesService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(KubernetesService.name);
|
||||||
|
private templates: Map<string, Handlebars.TemplateDelegate> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private clustersService: ClustersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.loadTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTemplates(): void {
|
||||||
|
const templatesDir = path.join(__dirname, '..', '..', 'templates');
|
||||||
|
const templateFiles = ['namespace', 'deployment', 'service', 'ingress', 'database', 'pvc', 'secret'];
|
||||||
|
|
||||||
|
for (const name of templateFiles) {
|
||||||
|
const filePath = path.join(templatesDir, `${name}.yaml.hbs`);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const template = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
this.templates.set(name, Handlebars.compile(template));
|
||||||
|
this.logger.log(`Loaded template: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getK8sClient(clusterId?: string): Promise<{
|
||||||
|
coreApi: k8s.CoreV1Api;
|
||||||
|
appsApi: k8s.AppsV1Api;
|
||||||
|
networkingApi: k8s.NetworkingV1Api;
|
||||||
|
}> {
|
||||||
|
const cluster = clusterId
|
||||||
|
? await this.clustersService.findOne(clusterId)
|
||||||
|
: await this.clustersService.getDefault();
|
||||||
|
|
||||||
|
const kc = new k8s.KubeConfig();
|
||||||
|
kc.loadFromString(cluster.kubeconfig);
|
||||||
|
|
||||||
|
return {
|
||||||
|
coreApi: kc.makeApiClient(k8s.CoreV1Api),
|
||||||
|
appsApi: kc.makeApiClient(k8s.AppsV1Api),
|
||||||
|
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployApplication(app: Application, imageUri: string): Promise<Record<string, any>> {
|
||||||
|
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const domain = this.configService.get('platform.domain');
|
||||||
|
|
||||||
|
const context: ManifestContext = {
|
||||||
|
appName: app.name,
|
||||||
|
namespace: `user-${app.userId.split('-')[0]}`,
|
||||||
|
image: imageUri,
|
||||||
|
port: app.port,
|
||||||
|
replicas: app.replicas,
|
||||||
|
cpuRequest: app.cpuRequest,
|
||||||
|
cpuLimit: app.cpuLimit,
|
||||||
|
memoryRequest: app.memoryRequest,
|
||||||
|
memoryLimit: app.memoryLimit,
|
||||||
|
envVars: app.envVars || {},
|
||||||
|
runtime: app.runtime,
|
||||||
|
databaseType: app.databaseType,
|
||||||
|
domain: domain,
|
||||||
|
subdomain: app.subdomain || app.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
const manifests: Record<string, any> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Ensure namespace exists
|
||||||
|
await this.ensureNamespace(coreApi, context.namespace);
|
||||||
|
|
||||||
|
// 2. Create/Update secrets for env vars
|
||||||
|
if (Object.keys(context.envVars).length > 0) {
|
||||||
|
manifests.secret = await this.applySecret(coreApi, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Deploy database if needed
|
||||||
|
if (context.databaseType !== DatabaseType.NONE) {
|
||||||
|
manifests.database = await this.deployDatabase(coreApi, appsApi, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create Deployment
|
||||||
|
manifests.deployment = await this.applyDeployment(appsApi, context);
|
||||||
|
|
||||||
|
// 5. Create Service
|
||||||
|
manifests.service = await this.applyService(coreApi, context);
|
||||||
|
|
||||||
|
// 6. Create Ingress
|
||||||
|
manifests.ingress = await this.applyIngress(networkingApi, context);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to deploy ${app.name}:`, error.body || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifests;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureNamespace(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await coreApi.readNamespace(namespace);
|
||||||
|
} catch {
|
||||||
|
await coreApi.createNamespace({
|
||||||
|
metadata: { name: namespace },
|
||||||
|
});
|
||||||
|
this.logger.log(`Created namespace: ${namespace}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applySecret(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
|
||||||
|
const secretData: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(ctx.envVars)) {
|
||||||
|
secretData[key] = Buffer.from(value).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Secret',
|
||||||
|
metadata: {
|
||||||
|
name: `${ctx.appName}-env`,
|
||||||
|
namespace: ctx.namespace,
|
||||||
|
},
|
||||||
|
data: secretData,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await coreApi.replaceNamespacedSecret(`${ctx.appName}-env`, ctx.namespace, secret);
|
||||||
|
} catch {
|
||||||
|
await coreApi.createNamespacedSecret(ctx.namespace, secret);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyDeployment(appsApi: k8s.AppsV1Api, ctx: ManifestContext): Promise<any> {
|
||||||
|
const envFrom: any[] = [];
|
||||||
|
if (Object.keys(ctx.envVars).length > 0) {
|
||||||
|
envFrom.push({ secretRef: { name: `${ctx.appName}-env` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add database connection env vars
|
||||||
|
const extraEnv: any[] = [];
|
||||||
|
if (ctx.databaseType === DatabaseType.POSTGRESQL) {
|
||||||
|
extraEnv.push(
|
||||||
|
{ name: 'DB_HOST', value: `${ctx.appName}-db` },
|
||||||
|
{ name: 'DB_PORT', value: '5432' },
|
||||||
|
{ name: 'DB_NAME', value: ctx.appName.replace(/-/g, '_') },
|
||||||
|
{ name: 'DB_USER', value: 'appuser' },
|
||||||
|
{ name: 'DB_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||||
|
);
|
||||||
|
} else if (ctx.databaseType === DatabaseType.MYSQL) {
|
||||||
|
extraEnv.push(
|
||||||
|
{ name: 'DB_HOST', value: `${ctx.appName}-db` },
|
||||||
|
{ name: 'DB_PORT', value: '3306' },
|
||||||
|
{ name: 'DB_NAME', value: ctx.appName.replace(/-/g, '_') },
|
||||||
|
{ name: 'DB_USER', value: 'appuser' },
|
||||||
|
{ name: 'DB_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployment: k8s.V1Deployment = {
|
||||||
|
apiVersion: 'apps/v1',
|
||||||
|
kind: 'Deployment',
|
||||||
|
metadata: {
|
||||||
|
name: ctx.appName,
|
||||||
|
namespace: ctx.namespace,
|
||||||
|
labels: { app: ctx.appName, runtime: ctx.runtime },
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
replicas: ctx.replicas,
|
||||||
|
selector: { matchLabels: { app: ctx.appName } },
|
||||||
|
template: {
|
||||||
|
metadata: { labels: { app: ctx.appName, runtime: ctx.runtime } },
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: ctx.appName,
|
||||||
|
image: ctx.image,
|
||||||
|
ports: [{ containerPort: ctx.port }],
|
||||||
|
envFrom,
|
||||||
|
env: extraEnv,
|
||||||
|
resources: {
|
||||||
|
requests: { cpu: ctx.cpuRequest, memory: ctx.memoryRequest },
|
||||||
|
limits: { cpu: ctx.cpuLimit, memory: ctx.memoryLimit },
|
||||||
|
},
|
||||||
|
readinessProbe: {
|
||||||
|
httpGet: { path: '/health', port: ctx.port as any },
|
||||||
|
initialDelaySeconds: 10,
|
||||||
|
periodSeconds: 5,
|
||||||
|
},
|
||||||
|
livenessProbe: {
|
||||||
|
httpGet: { path: '/health', port: ctx.port as any },
|
||||||
|
initialDelaySeconds: 30,
|
||||||
|
periodSeconds: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await appsApi.replaceNamespacedDeployment(ctx.appName, ctx.namespace, deployment);
|
||||||
|
} catch {
|
||||||
|
await appsApi.createNamespacedDeployment(ctx.namespace, deployment);
|
||||||
|
}
|
||||||
|
return deployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyService(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
|
||||||
|
const service: k8s.V1Service = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Service',
|
||||||
|
metadata: {
|
||||||
|
name: ctx.appName,
|
||||||
|
namespace: ctx.namespace,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
selector: { app: ctx.appName },
|
||||||
|
ports: [{ port: 80, targetPort: ctx.port as any, protocol: 'TCP' }],
|
||||||
|
type: 'ClusterIP',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await coreApi.replaceNamespacedService(ctx.appName, ctx.namespace, service);
|
||||||
|
} catch {
|
||||||
|
await coreApi.createNamespacedService(ctx.namespace, service);
|
||||||
|
}
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext): Promise<any> {
|
||||||
|
const ingress: k8s.V1Ingress = {
|
||||||
|
apiVersion: 'networking.k8s.io/v1',
|
||||||
|
kind: 'Ingress',
|
||||||
|
metadata: {
|
||||||
|
name: ctx.appName,
|
||||||
|
namespace: ctx.namespace,
|
||||||
|
annotations: {
|
||||||
|
'kubernetes.io/ingress.class': 'nginx',
|
||||||
|
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
host: `${ctx.subdomain}.${ctx.domain}`,
|
||||||
|
http: {
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
pathType: 'Prefix',
|
||||||
|
backend: {
|
||||||
|
service: {
|
||||||
|
name: ctx.appName,
|
||||||
|
port: { number: 80 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tls: [
|
||||||
|
{
|
||||||
|
hosts: [`${ctx.subdomain}.${ctx.domain}`],
|
||||||
|
secretName: `${ctx.appName}-tls`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await networkingApi.replaceNamespacedIngress(ctx.appName, ctx.namespace, ingress);
|
||||||
|
} catch {
|
||||||
|
await networkingApi.createNamespacedIngress(ctx.namespace, ingress);
|
||||||
|
}
|
||||||
|
return ingress;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deployDatabase(
|
||||||
|
coreApi: k8s.CoreV1Api,
|
||||||
|
appsApi: k8s.AppsV1Api,
|
||||||
|
ctx: ManifestContext,
|
||||||
|
): Promise<any> {
|
||||||
|
const dbPassword = this.generatePassword();
|
||||||
|
const dbName = `${ctx.appName}-db`;
|
||||||
|
|
||||||
|
// Create DB secret
|
||||||
|
await this.createDbSecret(coreApi, ctx.namespace, ctx.appName, dbPassword);
|
||||||
|
|
||||||
|
// Create PVC for DB
|
||||||
|
await this.createPVC(coreApi, ctx.namespace, dbName, '5Gi');
|
||||||
|
|
||||||
|
// Deploy database
|
||||||
|
const isPostgres = ctx.databaseType === DatabaseType.POSTGRESQL;
|
||||||
|
const image = isPostgres ? 'postgres:16-alpine' : 'mysql:8.0';
|
||||||
|
const port = isPostgres ? 5432 : 3306;
|
||||||
|
const envVars = isPostgres
|
||||||
|
? [
|
||||||
|
{ name: 'POSTGRES_DB', value: ctx.appName.replace(/-/g, '_') },
|
||||||
|
{ name: 'POSTGRES_USER', value: 'appuser' },
|
||||||
|
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ name: 'MYSQL_DATABASE', value: ctx.appName.replace(/-/g, '_') },
|
||||||
|
{ name: 'MYSQL_USER', value: 'appuser' },
|
||||||
|
{ name: 'MYSQL_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||||
|
{ name: 'MYSQL_ROOT_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dbDeployment: k8s.V1Deployment = {
|
||||||
|
apiVersion: 'apps/v1',
|
||||||
|
kind: 'Deployment',
|
||||||
|
metadata: { name: dbName, namespace: ctx.namespace },
|
||||||
|
spec: {
|
||||||
|
replicas: 1,
|
||||||
|
selector: { matchLabels: { app: dbName } },
|
||||||
|
template: {
|
||||||
|
metadata: { labels: { app: dbName } },
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: dbName,
|
||||||
|
image,
|
||||||
|
ports: [{ containerPort: port }],
|
||||||
|
env: envVars,
|
||||||
|
volumeMounts: [{ name: 'db-storage', mountPath: isPostgres ? '/var/lib/postgresql/data' : '/var/lib/mysql' }],
|
||||||
|
resources: {
|
||||||
|
requests: { cpu: '100m', memory: '256Mi' },
|
||||||
|
limits: { cpu: '500m', memory: '512Mi' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
volumes: [
|
||||||
|
{ name: 'db-storage', persistentVolumeClaim: { claimName: dbName } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await appsApi.replaceNamespacedDeployment(dbName, ctx.namespace, dbDeployment);
|
||||||
|
} catch {
|
||||||
|
await appsApi.createNamespacedDeployment(ctx.namespace, dbDeployment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create DB Service
|
||||||
|
const dbService: k8s.V1Service = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Service',
|
||||||
|
metadata: { name: dbName, namespace: ctx.namespace },
|
||||||
|
spec: {
|
||||||
|
selector: { app: dbName },
|
||||||
|
ports: [{ port, targetPort: port as any, protocol: 'TCP' }],
|
||||||
|
type: 'ClusterIP',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await coreApi.replaceNamespacedService(dbName, ctx.namespace, dbService);
|
||||||
|
} catch {
|
||||||
|
await coreApi.createNamespacedService(ctx.namespace, dbService);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deployment: dbDeployment, service: dbService };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createDbSecret(
|
||||||
|
coreApi: k8s.CoreV1Api,
|
||||||
|
namespace: string,
|
||||||
|
appName: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const secret = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Secret',
|
||||||
|
metadata: { name: `${appName}-db-secret`, namespace },
|
||||||
|
data: { password: Buffer.from(password).toString('base64') },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await coreApi.replaceNamespacedSecret(`${appName}-db-secret`, namespace, secret);
|
||||||
|
} catch {
|
||||||
|
await coreApi.createNamespacedSecret(namespace, secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createPVC(
|
||||||
|
coreApi: k8s.CoreV1Api,
|
||||||
|
namespace: string,
|
||||||
|
name: string,
|
||||||
|
size: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const pvc: k8s.V1PersistentVolumeClaim = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'PersistentVolumeClaim',
|
||||||
|
metadata: { name, namespace },
|
||||||
|
spec: {
|
||||||
|
accessModes: ['ReadWriteOnce'],
|
||||||
|
resources: { requests: { storage: size } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await coreApi.readNamespacedPersistentVolumeClaim(name, namespace);
|
||||||
|
// PVC exists, don't recreate
|
||||||
|
} catch {
|
||||||
|
await coreApi.createNamespacedPersistentVolumeClaim(namespace, pvc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPodLogs(app: Application): Promise<string> {
|
||||||
|
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
|
const pods = await coreApi.listNamespacedPod(
|
||||||
|
namespace,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`app=${app.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pods.body.items.length === 0) {
|
||||||
|
return 'No pods found for this application.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const podName = pods.body.items[0].metadata?.name;
|
||||||
|
if (!podName) return 'Pod name not found.';
|
||||||
|
|
||||||
|
const logResponse = await coreApi.readNamespacedPodLog(
|
||||||
|
podName,
|
||||||
|
namespace,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
|
||||||
|
return logResponse.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async scaleDeployment(app: Application, replicas: number): Promise<void> {
|
||||||
|
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
|
await appsApi.patchNamespacedDeployment(
|
||||||
|
app.name,
|
||||||
|
namespace,
|
||||||
|
{ spec: { replicas } },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartDeployment(app: Application): Promise<void> {
|
||||||
|
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
|
await appsApi.patchNamespacedDeployment(
|
||||||
|
app.name,
|
||||||
|
namespace,
|
||||||
|
{
|
||||||
|
spec: {
|
||||||
|
template: {
|
||||||
|
metadata: {
|
||||||
|
annotations: {
|
||||||
|
'kubectl.kubernetes.io/restartedAt': new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteApplication(app: Application): Promise<void> {
|
||||||
|
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await appsApi.deleteNamespacedDeployment(app.name, namespace);
|
||||||
|
await coreApi.deleteNamespacedService(app.name, namespace);
|
||||||
|
await networkingApi.deleteNamespacedIngress(app.name, namespace);
|
||||||
|
|
||||||
|
// Delete DB resources if applicable
|
||||||
|
if (app.databaseType !== DatabaseType.NONE) {
|
||||||
|
const dbName = `${app.name}-db`;
|
||||||
|
await appsApi.deleteNamespacedDeployment(dbName, namespace);
|
||||||
|
await coreApi.deleteNamespacedService(dbName, namespace);
|
||||||
|
await coreApi.deleteNamespacedPersistentVolumeClaim(dbName, namespace);
|
||||||
|
await coreApi.deleteNamespacedSecret(`${app.name}-db-secret`, namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
await coreApi.deleteNamespacedSecret(`${app.name}-env`, namespace);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`Error cleaning up resources for ${app.name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generatePassword(length = 24): string {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
// Prevent Node.js from crashing on unhandled errors
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('⚠️ Unhandled Rejection at:', promise, 'reason:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('⚠️ Uncaught Exception:', error);
|
||||||
|
// Don't exit — let NestJS handle recovery
|
||||||
|
});
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const logger = new Logger('Bootstrap');
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable graceful shutdown hooks
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
|
// Security
|
||||||
|
app.use(helmet());
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// API prefix
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('CloudHost PaaS API')
|
||||||
|
.setDescription('Self-service PaaS platform API')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 4000;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`🚀 CloudHost API running on http://localhost:${port}`);
|
||||||
|
console.log(`📚 Swagger docs at http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { UsersService } from './users/users.service';
|
||||||
|
import { UserRole } from './common/enums';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed script — creates the initial super admin user.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx ts-node -r tsconfig-paths/register src/seed.ts
|
||||||
|
*
|
||||||
|
* Or via npm script:
|
||||||
|
* npm run seed
|
||||||
|
*
|
||||||
|
* Environment variables (or defaults):
|
||||||
|
* ADMIN_EMAIL=admin@cloudhost.local
|
||||||
|
* ADMIN_PASSWORD=Admin123!
|
||||||
|
*/
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule);
|
||||||
|
const usersService = app.get(UsersService);
|
||||||
|
|
||||||
|
const email = process.env.ADMIN_EMAIL || 'admin@cloudhost.local';
|
||||||
|
const password = process.env.ADMIN_PASSWORD || 'Admin123!';
|
||||||
|
|
||||||
|
const existing = await usersService.findByEmail(email);
|
||||||
|
if (existing) {
|
||||||
|
console.log(`⚠️ Admin user already exists: ${email} (role: ${existing.role})`);
|
||||||
|
if (existing.role !== UserRole.ADMIN) {
|
||||||
|
await usersService.update(existing.id, { role: UserRole.ADMIN });
|
||||||
|
console.log(`✅ Promoted ${email} to admin`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
await usersService.create({
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
firstName: 'Super',
|
||||||
|
lastName: 'Admin',
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
});
|
||||||
|
console.log(`✅ Admin user created: ${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📋 Admin credentials:`);
|
||||||
|
console.log(` Email: ${email}`);
|
||||||
|
console.log(` Password: ${password}`);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((err) => {
|
||||||
|
console.error('❌ Seed failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UserRole } from '../../common/enums';
|
||||||
|
import { Application } from '../../applications/entities/application.entity';
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
|
||||||
|
role: UserRole;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
namespace: string; // K8s namespace assigned to user
|
||||||
|
|
||||||
|
@OneToMany(() => Application, (app: Application) => app.user)
|
||||||
|
applications: Application[];
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from '../common/enums';
|
||||||
|
|
||||||
|
@ApiTags('Users')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
|
async getProfile(@Request() req: any) {
|
||||||
|
const user = await this.usersService.findById(req.user.id);
|
||||||
|
if (user) {
|
||||||
|
const { password, ...result } = user;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'List all users (Admin only)' })
|
||||||
|
async findAll() {
|
||||||
|
return this.usersService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/deactivate')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Deactivate a user (Admin only)' })
|
||||||
|
async deactivate(@Param('id') id: string) {
|
||||||
|
await this.usersService.deactivate(id);
|
||||||
|
return { message: 'User deactivated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/activate')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Activate a user (Admin only)' })
|
||||||
|
async activate(@Param('id') id: string) {
|
||||||
|
await this.usersService.activate(id);
|
||||||
|
return { message: 'User activated' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
|
import { UserRole } from '../common/enums';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private usersRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(data: Partial<User>): Promise<User> {
|
||||||
|
const user = this.usersRepository.create(data);
|
||||||
|
// Assign a unique namespace based on user ID
|
||||||
|
const saved = await this.usersRepository.save(user);
|
||||||
|
saved.namespace = `user-${saved.id.split('-')[0]}`;
|
||||||
|
return this.usersRepository.save(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
|
return this.usersRepository.findOne({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<User | null> {
|
||||||
|
return this.usersRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<User[]> {
|
||||||
|
return this.usersRepository.find({
|
||||||
|
select: ['id', 'email', 'firstName', 'lastName', 'role', 'isActive', 'namespace', 'createdAt'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<User>): Promise<User> {
|
||||||
|
const user = await this.findById(id);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
Object.assign(user, data);
|
||||||
|
return this.usersRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(id: string): Promise<void> {
|
||||||
|
await this.usersRepository.update(id, { isActive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(id: string): Promise<void> {
|
||||||
|
await this.usersRepository.update(id, { isActive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{{#if isPostgres}}
|
||||||
|
# --- PostgreSQL Deployment ---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: "{{appName}}-db"
|
||||||
|
namespace: "{{namespace}}"
|
||||||
|
labels:
|
||||||
|
app: "{{appName}}-db"
|
||||||
|
managed-by: cloudhost
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: "{{appName}}-db"
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: "{{appName}}-db"
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: "{{dbName}}"
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: "appuser"
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: "{{appName}}-db-secret"
|
||||||
|
key: password
|
||||||
|
volumeMounts:
|
||||||
|
- name: db-storage
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "100m"
|
||||||
|
memory: "256Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "512Mi"
|
||||||
|
volumes:
|
||||||
|
- name: db-storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: "{{appName}}-db"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: "{{appName}}-db"
|
||||||
|
namespace: "{{namespace}}"
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: "{{appName}}-db"
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
type: ClusterIP
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if isMysql}}
|
||||||
|
# --- MySQL Deployment ---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: "{{appName}}-db"
|
||||||
|
namespace: "{{namespace}}"
|
||||||
|
labels:
|
||||||
|
app: "{{appName}}-db"
|
||||||
|
managed-by: cloudhost
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: "{{appName}}-db"
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: "{{appName}}-db"
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: mysql
|
||||||
|
image: mysql:8.0
|
||||||
|
ports:
|
||||||
|
- containerPort: 3306
|
||||||
|
env:
|
||||||
|
- name: MYSQL_DATABASE
|
||||||
|
value: "{{dbName}}"
|
||||||
|
- name: MYSQL_USER
|
||||||
|
value: "appuser"
|
||||||
|
- name: MYSQL_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: "{{appName}}-db-secret"
|
||||||
|
key: password
|
||||||
|
- name: MYSQL_ROOT_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: "{{appName}}-db-secret"
|
||||||
|
key: password
|
||||||
|
volumeMounts:
|
||||||
|
- name: db-storage
|
||||||
|
mountPath: /var/lib/mysql
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "100m"
|
||||||
|
memory: "256Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "512Mi"
|
||||||
|
volumes:
|
||||||
|
- name: db-storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: "{{appName}}-db"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: "{{appName}}-db"
|
||||||
|
namespace: "{{namespace}}"
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: "{{appName}}-db"
|
||||||
|
ports:
|
||||||
|
- port: 3306
|
||||||
|
targetPort: 3306
|
||||||
|
type: ClusterIP
|
||||||
|
{{/if}}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: "{{appName}}"
|
||||||
|
namespace: "{{namespace}}"
|
||||||
|
labels:
|
||||||
|
app: "{{appName}}"
|
||||||
|
runtime: "{{runtime}}"
|
||||||
|
managed-by: cloudhost
|
||||||
|
spec:
|
||||||
|
replicas: {{replicas}}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: "{{appName}}"
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: "{{appName}}"
|
||||||
|
runtime: "{{runtime}}"
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: "{{appName}}"
|
||||||
|
image: "{{image}}"
|
||||||
|
ports:
|
||||||
|
- containerPort: {{port}}
|
||||||
|
{{#if hasEnvVars}}
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: "{{appName}}-env"
|
||||||
|
{{/if}}
|
||||||
|
{{#if hasDatabase}}
|
||||||
|
env:
|
||||||
|
- name: DB_HOST
|
||||||
|
value: "{{appName}}-db"
|
||||||
|
- name: DB_PORT
|
||||||
|
value: "{{dbPort}}"
|
||||||
|
- name: DB_NAME
|
||||||
|
value: "{{dbName}}"
|
||||||
|
- name: DB_USER
|
||||||
|
value: "appuser"
|
||||||
|
- name: DB_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: "{{appName}}-db-secret"
|
||||||
|
key: password
|
||||||
|
{{/if}}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "{{cpuRequest}}"
|
||||||
|
memory: "{{memoryRequest}}"
|
||||||
|
limits:
|
||||||
|
cpu: "{{cpuLimit}}"
|
||||||
|
memory: "{{memoryLimit}}"
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: {{port}}
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: {{port}}
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: "{{appName}}"
|
||||||
|
namespace: "{{namespace}}"
|
||||||
|
labels:
|
||||||
|
app: "{{appName}}"
|
||||||
|
managed-by: cloudhost
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: nginx
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- "{{subdomain}}.{{domain}}"
|
||||||
|
secretName: "{{appName}}-tls"
|
||||||
|
rules:
|
||||||
|
- host: "{{subdomain}}.{{domain}}"
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: "{{appName}}"
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: "{{namespace}}"
|
||||||
|
labels:
|
||||||
|
managed-by: cloudhost
|
||||||
|
user: "{{userId}}"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: "{{name}}"
|
||||||
|
namespace: "{{namespace}}"
|
||||||
|
labels:
|
||||||
|
managed-by: cloudhost
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: "{{size}}"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: "{{name}}"
|
||||||
|
namespace: "{{namespace}}"
|
||||||
|
labels:
|
||||||
|
managed-by: cloudhost
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
{{#each data}}
|
||||||
|
{{@key}}: "{{this}}"
|
||||||
|
{{/each}}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: "{{appName}}"
|
||||||
|
namespace: "{{namespace}}"
|
||||||
|
labels:
|
||||||
|
app: "{{appName}}"
|
||||||
|
managed-by: cloudhost
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: "{{appName}}"
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: {{port}}
|
||||||
|
protocol: TCP
|
||||||
|
type: ClusterIP
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─── PostgreSQL ──────────────────────────────
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: cloudhost
|
||||||
|
POSTGRES_USER: cloudhost
|
||||||
|
POSTGRES_PASSWORD: cloudhost_secret
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U cloudhost']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# ─── Redis ───────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# ─── Backend (NestJS) ───────────────────────
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '4000:4000'
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 4000
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USERNAME: cloudhost
|
||||||
|
DB_PASSWORD: cloudhost_secret
|
||||||
|
DB_NAME: cloudhost
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET: change-this-to-a-long-random-string
|
||||||
|
JWT_EXPIRES_IN: 15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN: 7d
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
|
||||||
|
# Container Registry
|
||||||
|
REGISTRY_URL: registry.example.com
|
||||||
|
REGISTRY_USERNAME: ''
|
||||||
|
REGISTRY_PASSWORD: ''
|
||||||
|
|
||||||
|
# Build
|
||||||
|
BUILD_NAMESPACE: cloudhost-builds
|
||||||
|
KANIKO_IMAGE: gcr.io/kaniko-project/executor:latest
|
||||||
|
|
||||||
|
# Platform
|
||||||
|
PLATFORM_DOMAIN: apps.localhost
|
||||||
|
volumes:
|
||||||
|
- /tmp/cloudhost-uploads:/app/uploads
|
||||||
|
|
||||||
|
# ─── Frontend (Next.js) ─────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
|
redis_data:
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# ---- Stage 1: Dependencies ----
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---- Stage 2: Build ----
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Stage 3: Production ----
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=appuser:appgroup /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=appuser:appgroup /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}/api/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
Generated
+6314
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudhost-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@heroicons/react": "^2.1.0",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"next": "14.1.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.49.0",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"zustand": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-next": "14.1.0",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import type { Cluster } from '@/types';
|
||||||
|
|
||||||
|
export default function AdminClustersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [testingId, setTestingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
apiServer: '',
|
||||||
|
kubeconfig: '',
|
||||||
|
region: '',
|
||||||
|
provider: '',
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: clusters = [], isLoading } = useQuery<Cluster[]>({
|
||||||
|
queryKey: ['admin-clusters'],
|
||||||
|
queryFn: () => api.get('/clusters').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: typeof form) => api.post('/clusters', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||||
|
toast.success('Cluster added & connection verified ✓');
|
||||||
|
setShowForm(false);
|
||||||
|
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', isDefault: false });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
const message = err?.response?.data?.message || 'Failed to add cluster';
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => {
|
||||||
|
setTestingId(id);
|
||||||
|
return api.post(`/clusters/${id}/test`);
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||||
|
const data = res.data;
|
||||||
|
if (data.connected) {
|
||||||
|
toast.success(`Connection OK — Kubernetes ${data.version}`);
|
||||||
|
} else {
|
||||||
|
toast.error(`Connection failed: ${data.error}`);
|
||||||
|
}
|
||||||
|
setTestingId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to test connection');
|
||||||
|
setTestingId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||||
|
toast.success('Cluster removed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Cluster Management</h1>
|
||||||
|
<button onClick={() => setShowForm(!showForm)} className="btn-primary">
|
||||||
|
{showForm ? 'Cancel' : '+ Add Cluster'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Register New Cluster</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
The system will verify the Kubernetes connection before registering. Only clusters with valid kubeconfig will be marked as active.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||||
|
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">API Server URL</label>
|
||||||
|
<input className="input-field" placeholder="https://k8s-api:6443" value={form.apiServer} onChange={(e) => setForm({ ...form, apiServer: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
|
||||||
|
<input className="input-field" placeholder="us-east-1" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Provider</label>
|
||||||
|
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}>
|
||||||
|
<option value="">Select provider</option>
|
||||||
|
<option value="aws">AWS (EKS)</option>
|
||||||
|
<option value="gcp">GCP (GKE)</option>
|
||||||
|
<option value="azure">Azure (AKS)</option>
|
||||||
|
<option value="bare-metal">Bare Metal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<input className="input-field" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kubeconfig (YAML)</label>
|
||||||
|
<textarea
|
||||||
|
className="input-field font-mono text-xs"
|
||||||
|
rows={8}
|
||||||
|
placeholder="Paste your kubeconfig here..."
|
||||||
|
value={form.kubeconfig}
|
||||||
|
onChange={(e) => setForm({ ...form, kubeconfig: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isDefault"
|
||||||
|
checked={form.isDefault}
|
||||||
|
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor="isDefault" className="text-sm text-gray-700">Set as default cluster</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate(form)}
|
||||||
|
disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? '🔄 Verifying connection & adding...' : 'Add Cluster'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="card text-center py-12 text-gray-500">Loading clusters...</div>
|
||||||
|
) : clusters.length === 0 ? (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<p className="text-gray-500">No clusters registered yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{clusters.map((cluster) => (
|
||||||
|
<div key={cluster.id} className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||||
|
cluster.status === 'active' ? 'bg-green-100' : 'bg-red-100'
|
||||||
|
}`}>
|
||||||
|
{cluster.status === 'active' ? '✅' : '❌'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h3 className="font-semibold text-gray-900">{cluster.name}</h3>
|
||||||
|
{cluster.isDefault && (
|
||||||
|
<span className="px-2 py-0.5 bg-primary-100 text-primary-700 text-xs rounded-full font-medium">Default</span>
|
||||||
|
)}
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
cluster.status === 'active' ? 'bg-green-100 text-green-700'
|
||||||
|
: cluster.status === 'maintenance' ? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{cluster.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => testMutation.mutate(cluster.id)}
|
||||||
|
disabled={testingId === cluster.id}
|
||||||
|
className="text-sm px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testingId === cluster.id ? '🔄 Testing...' : '🔌 Test Connection'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm('Remove this cluster?')) deleteMutation.mutate(cluster.id); }}
|
||||||
|
className="text-sm text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
|
export default function AdminUsersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||||
|
queryKey: ['admin-users'],
|
||||||
|
queryFn: () => api.get('/users').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActive = useMutation({
|
||||||
|
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||||
|
api.patch(`/users/${id}/${isActive ? 'deactivate' : 'activate'}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
toast.success('User updated');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeRole = useMutation({
|
||||||
|
mutationFn: ({ id, role }: { id: string; role: string }) =>
|
||||||
|
api.patch(`/users/${id}/role`, { role }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
toast.success('Role updated');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="card text-center py-12 text-gray-500">Loading users...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<select
|
||||||
|
className="text-sm border border-gray-300 rounded px-2 py-1"
|
||||||
|
value={user.role}
|
||||||
|
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
user.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500 font-mono">{user.namespace || '—'}</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
|
||||||
|
className={`text-sm ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
|
||||||
|
>
|
||||||
|
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import type { Application, Deployment } from '@/types';
|
||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
running: 'bg-green-100 text-green-700',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700',
|
||||||
|
building: 'bg-blue-100 text-blue-700',
|
||||||
|
deploying: 'bg-blue-100 text-blue-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
build_failed: 'bg-red-100 text-red-700',
|
||||||
|
stopped: 'bg-gray-100 text-gray-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const appId = params.id as string;
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const logsEndRef = useRef<HTMLPreElement>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const { data: app, isLoading } = useQuery<Application>({
|
||||||
|
queryKey: ['application', appId],
|
||||||
|
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: deployments = [] } = useQuery<Deployment[]>({
|
||||||
|
queryKey: ['deployments', appId],
|
||||||
|
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
|
||||||
|
refetchInterval: 5000, // Poll for status updates
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: logsData } = useQuery<{ logs: string }>({
|
||||||
|
queryKey: ['logs', appId],
|
||||||
|
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
|
||||||
|
enabled: showLogs,
|
||||||
|
refetchInterval: showLogs ? 3000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll logs to bottom
|
||||||
|
useEffect(() => {
|
||||||
|
if (logsEndRef.current) {
|
||||||
|
logsEndRef.current.scrollTop = logsEndRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logsData]);
|
||||||
|
|
||||||
|
const invalidateAll = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const deployMutation = useMutation({
|
||||||
|
mutationFn: () => api.post(`/deployments/applications/${appId}/deploy`),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success('Deployment triggered!');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to trigger deployment'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopMutation = useMutation({
|
||||||
|
mutationFn: () => api.post(`/deployments/applications/${appId}/stop`),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success('Application stopped');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to stop application'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const startMutation = useMutation({
|
||||||
|
mutationFn: () => api.post(`/deployments/applications/${appId}/start`),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success('Application started');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to start application'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const restartMutation = useMutation({
|
||||||
|
mutationFn: () => api.post(`/deployments/applications/${appId}/restart`),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
toast.success('Application restarting...');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to restart application'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/applications/${appId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
toast.success('Application deleted');
|
||||||
|
router.push('/dashboard/apps');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to delete application'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return api.post(`/applications/${appId}/upload`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
|
toast.success('Source code uploaded successfully!');
|
||||||
|
setUploadProgress(0);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to upload source code');
|
||||||
|
setUploadProgress(0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFileUpload = useCallback((file: File) => {
|
||||||
|
if (!file.name.endsWith('.zip') && !file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
|
||||||
|
toast.error('Please upload a .zip or .tar.gz file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 100 * 1024 * 1024) {
|
||||||
|
toast.error('File size must be less than 100MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadMutation.mutate(file);
|
||||||
|
}, [uploadMutation]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleFileUpload(file);
|
||||||
|
}, [handleFileUpload]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading || !app) {
|
||||||
|
return <div className="card text-center py-12 text-gray-500">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestStatus = deployments[0]?.status || 'pending';
|
||||||
|
const hasDeployments = deployments.length > 0;
|
||||||
|
const isStopped = latestStatus === 'stopped';
|
||||||
|
const isRunning = latestStatus === 'running';
|
||||||
|
const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending';
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm(`Are you sure you want to delete "${app.name}"?\n\nThis will permanently remove:\n• All Kubernetes resources (pods, services, ingress)\n• Database and volumes\n• All deployment records\n• Uploaded source code`)) {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center text-2xl">
|
||||||
|
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{app.name}</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{app.runtime} · {app.subdomain}.apps.cloudhost.local
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${statusColors[latestStatus] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{latestStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
{/* Only show deploy button if NEVER deployed before */}
|
||||||
|
{!hasDeployments && (
|
||||||
|
<button
|
||||||
|
onClick={() => deployMutation.mutate()}
|
||||||
|
disabled={deployMutation.isPending || (!app.codePath && !app.gitUrl)}
|
||||||
|
className="btn-primary text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deployMutation.isPending ? '⏳ Deploying...' : '🚀 Deploy'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* After first deploy: show start/stop/restart */}
|
||||||
|
{hasDeployments && (
|
||||||
|
<>
|
||||||
|
{isStopped ? (
|
||||||
|
<button
|
||||||
|
onClick={() => startMutation.mutate()}
|
||||||
|
disabled={startMutation.isPending}
|
||||||
|
className="btn-primary text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{startMutation.isPending ? '⏳ Starting...' : '▶️ Start'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => stopMutation.mutate()}
|
||||||
|
disabled={stopMutation.isPending || isInProgress}
|
||||||
|
className="btn-secondary text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{stopMutation.isPending ? '⏳ Stopping...' : '⏹️ Stop'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRunning && (
|
||||||
|
<button
|
||||||
|
onClick={() => restartMutation.mutate()}
|
||||||
|
disabled={restartMutation.isPending}
|
||||||
|
className="btn-secondary text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{restartMutation.isPending ? '⏳...' : '🔄 Restart'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 border border-red-200 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? '⏳ Deleting...' : '�️ Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status & Config */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Configuration</h2>
|
||||||
|
<dl className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm text-gray-500">Runtime</dt>
|
||||||
|
<dd className="text-sm font-medium text-gray-900">{app.runtime}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm text-gray-500">Database</dt>
|
||||||
|
<dd className="text-sm font-medium text-gray-900">{app.databaseType}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm text-gray-500">Replicas</dt>
|
||||||
|
<dd className="text-sm font-medium text-gray-900">{app.replicas}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm text-gray-500">CPU</dt>
|
||||||
|
<dd className="text-sm font-medium text-gray-900">{app.cpuRequest} / {app.cpuLimit}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm text-gray-500">Memory</dt>
|
||||||
|
<dd className="text-sm font-medium text-gray-900">{app.memoryRequest} / {app.memoryLimit}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm text-gray-500">Port</dt>
|
||||||
|
<dd className="text-sm font-medium text-gray-900">{app.port}</dd>
|
||||||
|
</div>
|
||||||
|
{app.latestImageTag && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm text-gray-500">Image</dt>
|
||||||
|
<dd className="text-xs font-mono text-gray-700 truncate max-w-[200px]" title={app.latestImageTag}>
|
||||||
|
{app.latestImageTag}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deployment History</h2>
|
||||||
|
{deployments.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-3xl mb-2">📦</div>
|
||||||
|
<p className="text-gray-500 text-sm">No deployments yet</p>
|
||||||
|
<p className="text-gray-400 text-xs mt-1">Upload source code and click Deploy to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-72 overflow-y-auto">
|
||||||
|
{deployments.slice(0, 10).map((d) => (
|
||||||
|
<div key={d.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{d.version || d.imageTag}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(d.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{d.errorMessage && (
|
||||||
|
<p className="text-xs text-red-500 mt-1 truncate max-w-[250px]" title={d.errorMessage}>
|
||||||
|
❌ {d.errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap ${statusColors[d.status] || 'bg-gray-100'}`}>
|
||||||
|
{d.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Code Upload */}
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">📦 Source Code</h2>
|
||||||
|
|
||||||
|
{app.codePath ? (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-green-800">Source code uploaded</p>
|
||||||
|
<p className="text-xs text-green-600">{app.codePath.split('/').pop()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="text-sm text-green-700 hover:text-green-900 font-medium"
|
||||||
|
>
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : app.gitUrl ? (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-xl mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
|
||||||
|
🔗
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-800">Git repository connected</p>
|
||||||
|
<p className="text-xs text-blue-600 font-mono">{app.gitUrl}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all
|
||||||
|
${isDragging
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
${uploadMutation.isPending ? 'pointer-events-none opacity-60' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip,.tar.gz,.tgz"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFileUpload(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{uploadMutation.isPending ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-3xl">⏳</div>
|
||||||
|
<p className="text-sm font-medium text-gray-700">Uploading... {uploadProgress}%</p>
|
||||||
|
<div className="w-48 mx-auto bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-3xl">📁</div>
|
||||||
|
<p className="text-sm font-medium text-gray-700">
|
||||||
|
{app.codePath ? 'Upload new version' : 'Upload your project source code'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Drag & drop a <strong>.zip</strong> file here, or click to browse
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">Max size: 100MB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pod Logs */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">📋 Pod Logs</h2>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{showLogs && (
|
||||||
|
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
|
<span>Live (every 3s)</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLogs(!showLogs)}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
{showLogs ? '🔽 Hide Logs' : '📋 Show Logs'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showLogs && (
|
||||||
|
<pre
|
||||||
|
ref={logsEndRef}
|
||||||
|
className="bg-gray-900 text-green-400 p-4 rounded-lg text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
|
||||||
|
>
|
||||||
|
{logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import type { Application } from '@/types';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
running: 'bg-green-100 text-green-700',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700',
|
||||||
|
building: 'bg-blue-100 text-blue-700',
|
||||||
|
deploying: 'bg-blue-100 text-blue-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
build_failed: 'bg-red-100 text-red-700',
|
||||||
|
stopped: 'bg-gray-100 text-gray-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: apps = [], isLoading } = useQuery<Application[]>({
|
||||||
|
queryKey: ['applications'],
|
||||||
|
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/applications/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
toast.success('Application deleted');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to delete application'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="card text-center py-12 text-gray-500">Loading applications...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">My Applications</h1>
|
||||||
|
<Link href="/dashboard/deploy" className="btn-primary">
|
||||||
|
+ New Application
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apps.length === 0 ? (
|
||||||
|
<div className="card text-center py-16">
|
||||||
|
<p className="text-gray-500 text-lg">No applications yet</p>
|
||||||
|
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-block">
|
||||||
|
Deploy your first app
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Runtime</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Database</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Replicas</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{apps.map((app) => {
|
||||||
|
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||||
|
return (
|
||||||
|
<tr key={app.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Link href={`/dashboard/apps/${app.id}`} className="font-medium text-primary-600 hover:text-primary-800">
|
||||||
|
{app.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">{app.runtime}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">{app.databaseType}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[latestStatus] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{latestStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">{app.replicas}</td>
|
||||||
|
<td className="px-6 py-4 text-right space-x-2">
|
||||||
|
<Link href={`/dashboard/apps/${app.id}`} className="text-sm text-primary-600 hover:text-primary-800">
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm('Delete this application?')) deleteMutation.mutate(app.id); }}
|
||||||
|
className="text-sm text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import type { CreateApplicationDto } from '@/types';
|
||||||
|
|
||||||
|
const steps = ['Basic Info', 'Runtime & Database', 'Resources', 'Review'];
|
||||||
|
|
||||||
|
export default function DeployPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [form, setForm] = useState<CreateApplicationDto>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
runtime: 'nodejs',
|
||||||
|
databaseType: 'none',
|
||||||
|
gitUrl: '',
|
||||||
|
envVars: {},
|
||||||
|
cpuRequest: '100m',
|
||||||
|
cpuLimit: '500m',
|
||||||
|
memoryRequest: '128Mi',
|
||||||
|
memoryLimit: '512Mi',
|
||||||
|
replicas: 1,
|
||||||
|
port: 3000,
|
||||||
|
});
|
||||||
|
const [envKey, setEnvKey] = useState('');
|
||||||
|
const [envVal, setEnvVal] = useState('');
|
||||||
|
const [sourceMethod, setSourceMethod] = useState<'git' | 'upload'>('upload');
|
||||||
|
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (data: CreateApplicationDto) => {
|
||||||
|
const res = await api.post('/applications', data);
|
||||||
|
const appId = res.data.id;
|
||||||
|
|
||||||
|
// Upload zip file if selected
|
||||||
|
if (sourceMethod === 'upload' && zipFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', zipFile);
|
||||||
|
await api.post(`/applications/${appId}/upload`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast.success('Application created! Triggering deployment...');
|
||||||
|
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||||||
|
router.push(`/dashboard/apps/${res.data.id}`);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err.response?.data?.message || 'Failed to create application');
|
||||||
|
setUploadProgress(0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addEnvVar = () => {
|
||||||
|
if (envKey.trim()) {
|
||||||
|
setForm({ ...form, envVars: { ...form.envVars, [envKey]: envVal } });
|
||||||
|
setEnvKey('');
|
||||||
|
setEnvVal('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEnvVar = (key: string) => {
|
||||||
|
const updated = { ...form.envVars };
|
||||||
|
delete updated[key];
|
||||||
|
setForm({ ...form, envVars: updated });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
createMutation.mutate(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((file: File) => {
|
||||||
|
const validTypes = ['application/zip', 'application/x-zip-compressed', 'application/gzip', 'application/x-tar'];
|
||||||
|
const validExtensions = ['.zip', '.tar.gz', '.tgz'];
|
||||||
|
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
|
||||||
|
|
||||||
|
if (!hasValidExt && !validTypes.includes(file.type)) {
|
||||||
|
toast.error('Only .zip or .tar.gz files are allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 100 * 1024 * 1024) {
|
||||||
|
toast.error('File size must be less than 100MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setZipFile(file);
|
||||||
|
toast.success(`Selected: ${file.name}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleFileSelect(file);
|
||||||
|
}, [handleFileSelect]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canNext = () => {
|
||||||
|
if (step === 0) {
|
||||||
|
if (form.name.length < 2) return false;
|
||||||
|
if (sourceMethod === 'upload' && !zipFile) return false;
|
||||||
|
if (sourceMethod === 'git' && !form.gitUrl) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Deploy New Application</h1>
|
||||||
|
<p className="mt-1 text-gray-500">Follow the steps to deploy your app to the cloud.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{steps.map((label, i) => (
|
||||||
|
<div key={label} className="flex items-center">
|
||||||
|
<div className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
|
||||||
|
i <= step ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<span className={`ml-2 text-sm ${i <= step ? 'text-gray-900 font-medium' : 'text-gray-400'}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{i < steps.length - 1 && <div className="w-8 h-0.5 bg-gray-200 mx-3" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{/* Step 0: Basic Info */}
|
||||||
|
{step === 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Basic Information</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Application Name</label>
|
||||||
|
<input
|
||||||
|
className="input-field"
|
||||||
|
placeholder="my-awesome-app"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-') })}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">Lowercase letters, numbers, and hyphens only</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
className="input-field"
|
||||||
|
rows={3}
|
||||||
|
placeholder="What does this app do?"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Code Method */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">Source Code</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSourceMethod('upload')}
|
||||||
|
className={`p-3 rounded-xl border-2 text-center transition-colors ${
|
||||||
|
sourceMethod === 'upload'
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xl">📁</span>
|
||||||
|
<p className="mt-1 font-semibold text-sm text-gray-900">Upload ZIP</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSourceMethod('git')}
|
||||||
|
className={`p-3 rounded-xl border-2 text-center transition-colors ${
|
||||||
|
sourceMethod === 'git'
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xl">🔗</span>
|
||||||
|
<p className="mt-1 font-semibold text-sm text-gray-900">Git Repository</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sourceMethod === 'git' ? (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className="input-field"
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
value={form.gitUrl}
|
||||||
|
onChange={(e) => setForm({ ...form, gitUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{zipFile ? (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
|
||||||
|
✅
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-green-800">{zipFile.name}</p>
|
||||||
|
<p className="text-xs text-green-600">
|
||||||
|
{(zipFile.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setZipFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}}
|
||||||
|
className="text-sm text-red-500 hover:text-red-700 font-medium"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
||||||
|
isDragging
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-3xl">📦</div>
|
||||||
|
<p className="text-sm font-medium text-gray-700">
|
||||||
|
Drag & drop your project ZIP here
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
or click to browse • <strong>.zip</strong> files only • Max 100MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip,.tar.gz,.tgz"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFileSelect(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: Runtime & Database */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold">Runtime & Database</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">Application Runtime</label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{[
|
||||||
|
{ value: 'nodejs', label: 'Node.js', icon: '🟩', desc: 'Express, NestJS, Fastify...' },
|
||||||
|
{ value: 'laravel', label: 'Laravel', icon: '🟧', desc: 'PHP 8.3, Composer, Artisan' },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, runtime: opt.value as any, port: opt.value === 'nodejs' ? 3000 : 8000 })}
|
||||||
|
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||||||
|
form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{opt.icon}</span>
|
||||||
|
<p className="mt-2 font-semibold text-gray-900">{opt.label}</p>
|
||||||
|
<p className="text-xs text-gray-500">{opt.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">Database</label>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ value: 'none', label: 'None', icon: '❌' },
|
||||||
|
{ value: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
|
||||||
|
{ value: 'mysql', label: 'MySQL', icon: '🐬' },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, databaseType: opt.value as any })}
|
||||||
|
className={`p-4 rounded-xl border-2 text-center transition-colors ${
|
||||||
|
form.databaseType === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{opt.icon}</span>
|
||||||
|
<p className="mt-2 font-semibold text-sm text-gray-900">{opt.label}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Resources */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold">Resources & Configuration</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Request</label>
|
||||||
|
<select className="input-field" value={form.cpuRequest} onChange={(e) => setForm({ ...form, cpuRequest: e.target.value })}>
|
||||||
|
<option value="50m">50m (0.05 core)</option>
|
||||||
|
<option value="100m">100m (0.1 core)</option>
|
||||||
|
<option value="250m">250m (0.25 core)</option>
|
||||||
|
<option value="500m">500m (0.5 core)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Limit</label>
|
||||||
|
<select className="input-field" value={form.cpuLimit} onChange={(e) => setForm({ ...form, cpuLimit: e.target.value })}>
|
||||||
|
<option value="250m">250m (0.25 core)</option>
|
||||||
|
<option value="500m">500m (0.5 core)</option>
|
||||||
|
<option value="1">1 core</option>
|
||||||
|
<option value="2">2 cores</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Request</label>
|
||||||
|
<select className="input-field" value={form.memoryRequest} onChange={(e) => setForm({ ...form, memoryRequest: e.target.value })}>
|
||||||
|
<option value="64Mi">64 Mi</option>
|
||||||
|
<option value="128Mi">128 Mi</option>
|
||||||
|
<option value="256Mi">256 Mi</option>
|
||||||
|
<option value="512Mi">512 Mi</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Limit</label>
|
||||||
|
<select className="input-field" value={form.memoryLimit} onChange={(e) => setForm({ ...form, memoryLimit: e.target.value })}>
|
||||||
|
<option value="256Mi">256 Mi</option>
|
||||||
|
<option value="512Mi">512 Mi</option>
|
||||||
|
<option value="1Gi">1 Gi</option>
|
||||||
|
<option value="2Gi">2 Gi</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Replicas</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={form.replicas}
|
||||||
|
onChange={(e) => setForm({ ...form, replicas: parseInt(e.target.value) || 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm({ ...form, port: parseInt(e.target.value) || 3000 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment Variables */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Environment Variables</label>
|
||||||
|
<div className="flex space-x-2 mb-3">
|
||||||
|
<input
|
||||||
|
className="input-field flex-1"
|
||||||
|
placeholder="KEY"
|
||||||
|
value={envKey}
|
||||||
|
onChange={(e) => setEnvKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input-field flex-1"
|
||||||
|
placeholder="value"
|
||||||
|
value={envVal}
|
||||||
|
onChange={(e) => setEnvVal(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={addEnvVar} className="btn-secondary">Add</button>
|
||||||
|
</div>
|
||||||
|
{Object.entries(form.envVars || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 mb-2">
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
<strong>{key}</strong> = {value}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => removeEnvVar(key)} className="text-red-500 text-sm">Remove</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Review */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Review & Deploy</h2>
|
||||||
|
<div className="bg-gray-50 rounded-xl p-6 space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Name</span>
|
||||||
|
<span className="text-sm font-medium">{form.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Runtime</span>
|
||||||
|
<span className="text-sm font-medium">{form.runtime}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Database</span>
|
||||||
|
<span className="text-sm font-medium">{form.databaseType}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Source</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{sourceMethod === 'upload'
|
||||||
|
? zipFile
|
||||||
|
? `📁 ${zipFile.name}`
|
||||||
|
: '—'
|
||||||
|
: form.gitUrl || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">CPU</span>
|
||||||
|
<span className="text-sm font-medium">{form.cpuRequest} / {form.cpuLimit}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Memory</span>
|
||||||
|
<span className="text-sm font-medium">{form.memoryRequest} / {form.memoryLimit}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Replicas</span>
|
||||||
|
<span className="text-sm font-medium">{form.replicas}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Port</span>
|
||||||
|
<span className="text-sm font-medium">{form.port}</span>
|
||||||
|
</div>
|
||||||
|
{Object.keys(form.envVars || {}).length > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Env Vars</span>
|
||||||
|
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex justify-between mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(step - 1)}
|
||||||
|
disabled={step === 0}
|
||||||
|
className="btn-secondary disabled:opacity-30"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
{step < steps.length - 1 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(step + 1)}
|
||||||
|
disabled={!canNext()}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{createMutation.isPending
|
||||||
|
? uploadProgress > 0 && uploadProgress < 100
|
||||||
|
? `Uploading... ${uploadProgress}%`
|
||||||
|
: 'Deploying...'
|
||||||
|
: '🚀 Deploy Application'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuthStore } from '@/lib/store';
|
||||||
|
|
||||||
|
const userNavItems = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard', icon: '📊' },
|
||||||
|
{ href: '/dashboard/apps', label: 'Applications', icon: '📦' },
|
||||||
|
{ href: '/dashboard/deploy', label: 'New Deploy', icon: '🚀' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminNavItems = [
|
||||||
|
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
|
||||||
|
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, router]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link href="/dashboard" className="text-xl font-bold text-primary-600">
|
||||||
|
☁️ CloudHost
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<span className="ml-2 px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full font-medium">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { logout(); router.push('/login'); }}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<nav className="w-56 flex-shrink-0">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{userNavItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center space-x-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
pathname === item.href
|
||||||
|
? 'bg-primary-50 text-primary-700'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<>
|
||||||
|
<div className="pt-4 pb-2">
|
||||||
|
<p className="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||||
|
Admin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{adminNavItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center space-x-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
pathname === item.href
|
||||||
|
? 'bg-primary-50 text-primary-700'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 min-w-0">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/lib/store';
|
||||||
|
import type { Application } from '@/types';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
running: 'bg-green-100 text-green-700',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700',
|
||||||
|
building: 'bg-blue-100 text-blue-700',
|
||||||
|
deploying: 'bg-blue-100 text-blue-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
build_failed: 'bg-red-100 text-red-700',
|
||||||
|
stopped: 'bg-gray-100 text-gray-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const { data: apps = [], isLoading } = useQuery<Application[]>({
|
||||||
|
queryKey: ['applications'],
|
||||||
|
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningApps = apps.filter(
|
||||||
|
(a) => a.deployments?.some((d) => d.status === 'running'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Welcome back, {user?.firstName}! 👋
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-gray-500">
|
||||||
|
Here's an overview of your applications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Total Apps</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-gray-900">{apps.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Running</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-green-600">{runningApps.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Deployments (7d)</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-primary-600">
|
||||||
|
{apps.reduce((sum, a) => sum + (a.deployments?.length || 0), 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Applications */}
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Recent Applications</h2>
|
||||||
|
<Link href="/dashboard/deploy" className="btn-primary text-sm">
|
||||||
|
+ New Application
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="card text-center py-12 text-gray-500">Loading...</div>
|
||||||
|
) : apps.length === 0 ? (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<p className="text-gray-500 text-lg">No applications yet</p>
|
||||||
|
<p className="text-gray-400 mt-2">
|
||||||
|
Deploy your first application to get started.
|
||||||
|
</p>
|
||||||
|
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-block">
|
||||||
|
Deploy your first app
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{apps.slice(0, 5).map((app) => {
|
||||||
|
const latestDeploy = app.deployments?.[0];
|
||||||
|
const status = latestDeploy?.status || 'pending';
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={app.id}
|
||||||
|
href={`/dashboard/apps/${app.id}`}
|
||||||
|
className="card hover:border-primary-300 transition-colors flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary-100 flex items-center justify-center text-lg">
|
||||||
|
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{app.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{app.runtime} · {app.replicas} replica{app.replicas > 1 ? 's' : ''}
|
||||||
|
{app.databaseType !== 'none' && ` · ${app.databaseType}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
statusColors[status] || 'bg-gray-100 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900 antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-600 text-white px-4 py-2 rounded-lg font-medium
|
||||||
|
hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500
|
||||||
|
focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-white text-gray-700 px-4 py-2 rounded-lg font-medium border border-gray-300
|
||||||
|
hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500
|
||||||
|
focus:ring-offset-2 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply bg-red-600 text-white px-4 py-2 rounded-lg font-medium
|
||||||
|
hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500
|
||||||
|
focus:ring-offset-2 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
@apply w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm
|
||||||
|
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500
|
||||||
|
focus:border-primary-500 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
import './globals.css';
|
||||||
|
import { Providers } from '@/components/providers';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'CloudHost - Self-Service PaaS',
|
||||||
|
description: 'Deploy your applications to Kubernetes with ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuthStore } from '@/lib/store';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const login = useAuthStore((s) => s.login);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
toast.success('Logged in successfully!');
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-primary-600">☁️ CloudHost</h1>
|
||||||
|
<h2 className="mt-4 text-2xl font-semibold text-gray-900">Sign in to your account</h2>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
Or{' '}
|
||||||
|
<Link href="/register" className="text-primary-600 hover:text-primary-500 font-medium">
|
||||||
|
create a new account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="card space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="input-field"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="input-field"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading} className="btn-primary w-full">
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuthStore } from '@/lib/store';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [form, setForm] = useState({ email: '', password: '', firstName: '', lastName: '' });
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const register = useAuthStore((s) => s.register);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await register(form);
|
||||||
|
toast.success('Account created successfully!');
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-primary-600">☁️ CloudHost</h1>
|
||||||
|
<h2 className="mt-4 text-2xl font-semibold text-gray-900">Create your account</h2>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link href="/login" className="text-primary-600 hover:text-primary-500 font-medium">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="card space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">First name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="input-field"
|
||||||
|
value={form.firstName}
|
||||||
|
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Last name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="input-field"
|
||||||
|
value={form.lastName}
|
||||||
|
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="input-field"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading} className="btn-primary w-full">
|
||||||
|
{isLoading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAuthStore } from '@/lib/store';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const loadUser = useAuthStore((s) => s.loadUser);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
loadUser();
|
||||||
|
}, [loadUser]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: `${API_URL}/api/v1`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor to add auth token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor for token refresh
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
if (!refreshToken) throw new Error('No refresh token');
|
||||||
|
|
||||||
|
const { data } = await axios.post(`${API_URL}/api/v1/auth/refresh`, {
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { User, AuthResponse } from '@/types';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (data: { email: string; password: string; firstName: string; lastName: string }) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
loadUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
login: async (email, password) => {
|
||||||
|
const { data } = await api.post<AuthResponse>('/auth/login', { email, password });
|
||||||
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
|
set({ user: data.user, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (registerData) => {
|
||||||
|
const { data } = await api.post<AuthResponse>('/auth/register', registerData);
|
||||||
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
|
set({ user: data.user, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadUser: async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
set({ isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { data } = await api.get<User>('/users/me');
|
||||||
|
set({ user: data, isAuthenticated: true, isLoading: false });
|
||||||
|
} catch {
|
||||||
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
role: 'user' | 'admin';
|
||||||
|
isActive: boolean;
|
||||||
|
namespace?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Application {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
runtime: 'nodejs' | 'laravel';
|
||||||
|
databaseType: 'mysql' | 'postgresql' | 'none';
|
||||||
|
gitUrl?: string;
|
||||||
|
codePath?: string;
|
||||||
|
envVars?: Record<string, string>;
|
||||||
|
cpuRequest: string;
|
||||||
|
cpuLimit: string;
|
||||||
|
memoryRequest: string;
|
||||||
|
memoryLimit: string;
|
||||||
|
replicas: number;
|
||||||
|
port: number;
|
||||||
|
userId: string;
|
||||||
|
clusterId?: string;
|
||||||
|
latestImageTag?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
deployments?: Deployment[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deployment {
|
||||||
|
id: string;
|
||||||
|
status: DeploymentStatus;
|
||||||
|
imageTag: string;
|
||||||
|
version?: string;
|
||||||
|
buildLog?: string;
|
||||||
|
deployLog?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
applicationId: string;
|
||||||
|
triggeredBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeploymentStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'building'
|
||||||
|
| 'build_failed'
|
||||||
|
| 'deploying'
|
||||||
|
| 'running'
|
||||||
|
| 'failed'
|
||||||
|
| 'stopped'
|
||||||
|
| 'deleting';
|
||||||
|
|
||||||
|
export interface Cluster {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
status: 'active' | 'inactive' | 'maintenance';
|
||||||
|
apiServer: string;
|
||||||
|
region?: string;
|
||||||
|
provider?: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
defaultCpuLimit: string;
|
||||||
|
defaultMemoryLimit: string;
|
||||||
|
maxAppsPerUser: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: User;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApplicationDto {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
runtime: 'nodejs' | 'laravel';
|
||||||
|
databaseType: 'mysql' | 'postgresql' | 'none';
|
||||||
|
gitUrl?: string;
|
||||||
|
envVars?: Record<string, string>;
|
||||||
|
cpuRequest?: string;
|
||||||
|
cpuLimit?: string;
|
||||||
|
memoryRequest?: string;
|
||||||
|
memoryLimit?: string;
|
||||||
|
replicas?: number;
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
300: '#93c5fd',
|
||||||
|
400: '#60a5fa',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
800: '#1e40af',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
950: '#172554',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user