Initial implementation of miniPaaS platform
Complete implementation including: - Docker Compose setup with PostgreSQL and Traefik - Express.js backend with full API endpoints - GitHub OAuth integration - Docker build engine with automatic Dockerfile generation - Deployment service with Traefik routing - Real-time log streaming via WebSocket - Analytics collection and visualization - Environment variable management with encryption - Professional dark-themed web dashboard - Project, deployment, logs, analytics, and settings UI - Comprehensive README with setup instructions Tech stack: Node.js, Express, PostgreSQL, Docker, Traefik, WebSocket, Chart.js
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
GITHUB_CLIENT_ID=your_github_client_id
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||
SESSION_SECRET=your_random_session_secret_here
|
||||
DATABASE_URL=postgresql://paasuser:paaspass@postgres:5432/paasdb
|
||||
POSTGRES_USER=paasuser
|
||||
POSTGRES_PASSWORD=paaspass
|
||||
POSTGRES_DB=paasdb
|
||||
ENCRYPTION_KEY=your_32_character_encryption_key_here
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
build_cache/
|
||||
traefik/logs/*.log
|
||||
control-plane/tmp/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
postgres_data/
|
||||
293
README.md
Normal file
293
README.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# miniPaaS
|
||||
|
||||
A self-hosted Platform as a Service (PaaS) that runs entirely on your local machine. Deploy applications from GitHub repositories with automatic builds, real-time logs, analytics, and environment variable management.
|
||||
|
||||
## Features
|
||||
|
||||
- **GitHub Integration**: Connect repositories via OAuth and deploy with one click
|
||||
- **Automatic Builds**: Detects project type and builds Docker images automatically
|
||||
- **Subdomain Routing**: Access deployments at `appname.localhost`
|
||||
- **Real-time Logs**: Stream build and runtime logs via WebSocket
|
||||
- **Analytics Dashboard**: Track HTTP requests, visitors, and resource usage
|
||||
- **Environment Variables**: Auto-detect and manage environment variables
|
||||
- **Clean UI**: Professional, modern dashboard with dark theme
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Node.js + Express
|
||||
- **Frontend**: Vanilla HTML/CSS/JavaScript with Chart.js
|
||||
- **Database**: PostgreSQL 15
|
||||
- **Containerization**: Docker + Docker Compose
|
||||
- **Reverse Proxy**: Traefik v3
|
||||
- **Real-time**: WebSocket for log streaming
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Desktop (Windows/Mac) or Docker Engine + Docker Compose (Linux)
|
||||
- Node.js 20+ (for local development)
|
||||
- GitHub account
|
||||
- Git
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd miniPaaS
|
||||
```
|
||||
|
||||
### 2. Create GitHub OAuth App
|
||||
|
||||
1. Go to https://github.com/settings/developers
|
||||
2. Click "New OAuth App"
|
||||
3. Fill in the details:
|
||||
- **Application name**: miniPaaS Local
|
||||
- **Homepage URL**: `http://localhost:3000`
|
||||
- **Authorization callback URL**: `http://localhost:3000/auth/github/callback`
|
||||
4. Click "Register application"
|
||||
5. Note down your **Client ID** and **Client Secret**
|
||||
|
||||
### 3. Configure Environment Variables
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and add your GitHub credentials:
|
||||
|
||||
```env
|
||||
GITHUB_CLIENT_ID=your_client_id_here
|
||||
GITHUB_CLIENT_SECRET=your_client_secret_here
|
||||
SESSION_SECRET=random_string_for_sessions
|
||||
ENCRYPTION_KEY=your_32_character_encryption_key
|
||||
```
|
||||
|
||||
### 4. Start the Platform
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
- PostgreSQL database
|
||||
- Traefik reverse proxy
|
||||
- miniPaaS control plane
|
||||
|
||||
### 5. Access the Dashboard
|
||||
|
||||
Open your browser and navigate to: **http://localhost:3000**
|
||||
|
||||
Click "Connect with GitHub" to authenticate and start deploying applications.
|
||||
|
||||
## Deploying Your First Application
|
||||
|
||||
1. **Create a New Project**
|
||||
- Click "New Project" in the dashboard
|
||||
- Select a GitHub repository
|
||||
- Choose a subdomain (e.g., `myapp` will be accessible at `myapp.localhost`)
|
||||
- Configure the port your app runs on (default: 3000)
|
||||
|
||||
2. **Deploy**
|
||||
- Click "Deploy Now" on your project
|
||||
- Watch the build logs in real-time
|
||||
- Once deployed, visit `http://yourapp.localhost`
|
||||
|
||||
3. **Manage Environment Variables**
|
||||
- Navigate to the "Environment" tab
|
||||
- Add required environment variables
|
||||
- Redeploy for changes to take effect
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
miniPaaS/
|
||||
├── control-plane/ # Node.js backend
|
||||
│ ├── config/ # Database, Docker, GitHub config
|
||||
│ ├── routes/ # API routes
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── websockets/ # WebSocket server
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ └── utils/ # Helper utilities
|
||||
├── dashboard/ # Frontend
|
||||
│ ├── css/ # Stylesheets
|
||||
│ ├── js/ # JavaScript
|
||||
│ └── assets/ # Static assets
|
||||
├── database/ # SQL schemas
|
||||
├── traefik/ # Traefik configuration
|
||||
└── docker-compose.yml # Infrastructure orchestration
|
||||
```
|
||||
|
||||
## Supported Project Types
|
||||
|
||||
miniPaaS automatically detects and builds:
|
||||
|
||||
- **Node.js**: Projects with `package.json`
|
||||
- **Python**: Projects with `requirements.txt`
|
||||
- **Go**: Projects with `go.mod`
|
||||
- **Static Sites**: Projects with `index.html`
|
||||
|
||||
If your project has a `Dockerfile`, it will be used directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Browser │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Traefik (Port 80) │
|
||||
│ Routes *.localhost │
|
||||
└────┬────────────────┬───┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────────┐
|
||||
│ Control │ │ User │
|
||||
│ Plane │ │ Containers │
|
||||
│ (API + │ │ (Your Apps) │
|
||||
│ WebSocket)│ │ │
|
||||
└────┬─────┘ └──────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│PostgreSQL│
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `GET /auth/github` - Initiate GitHub OAuth
|
||||
- `GET /auth/github/callback` - OAuth callback
|
||||
- `GET /auth/user` - Get current user
|
||||
- `GET /auth/logout` - Logout
|
||||
|
||||
### Projects
|
||||
- `GET /api/projects` - List all projects
|
||||
- `POST /api/projects` - Create new project
|
||||
- `GET /api/projects/:id` - Get project details
|
||||
- `PUT /api/projects/:id` - Update project
|
||||
- `DELETE /api/projects/:id` - Delete project
|
||||
- `GET /api/projects/repositories` - List GitHub repositories
|
||||
|
||||
### Deployments
|
||||
- `POST /api/projects/:id/deploy` - Deploy project
|
||||
- `GET /api/deployments/:id` - Get deployment details
|
||||
- `POST /api/deployments/:id/stop` - Stop deployment
|
||||
- `DELETE /api/deployments/:id` - Delete deployment
|
||||
|
||||
### Logs
|
||||
- `GET /api/deployments/:id/build-logs` - Get build logs
|
||||
- `GET /api/deployments/:id/runtime-logs` - Get runtime logs
|
||||
- `WS /ws/logs` - WebSocket for real-time logs
|
||||
|
||||
### Analytics
|
||||
- `GET /api/projects/:id/analytics` - Get analytics data
|
||||
|
||||
### Environment Variables
|
||||
- `GET /api/projects/:id/env` - List environment variables
|
||||
- `POST /api/projects/:id/env` - Add environment variable
|
||||
- `PUT /api/projects/:id/env/:key` - Update environment variable
|
||||
- `DELETE /api/projects/:id/env/:key` - Delete environment variable
|
||||
|
||||
## Development
|
||||
|
||||
### Running Locally (without Docker)
|
||||
|
||||
1. Start PostgreSQL:
|
||||
```bash
|
||||
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=paaspass -e POSTGRES_USER=paasuser -e POSTGRES_DB=paasdb postgres:15-alpine
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
cd control-plane
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
The database schema is automatically initialized on first run. For manual initialization:
|
||||
|
||||
```bash
|
||||
docker-compose exec postgres psql -U paasuser -d paasdb -f /docker-entrypoint-initdb.d/init.sql
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot access *.localhost domains
|
||||
|
||||
**Windows/Mac**: Should work out of the box.
|
||||
|
||||
**Linux**: Add to `/etc/hosts`:
|
||||
```
|
||||
127.0.0.1 localhost *.localhost
|
||||
```
|
||||
|
||||
Or use dnsmasq for wildcard DNS.
|
||||
|
||||
### Docker build fails
|
||||
|
||||
Check build logs in the dashboard. Common issues:
|
||||
- Missing Dockerfile (miniPaaS will generate one)
|
||||
- Incorrect port configuration
|
||||
- Missing dependencies in package.json
|
||||
|
||||
### Container won't start
|
||||
|
||||
Check deployment logs. Common issues:
|
||||
- Port mismatch between Dockerfile EXPOSE and project settings
|
||||
- Missing environment variables
|
||||
- Application crashes on startup
|
||||
|
||||
### WebSocket connection fails
|
||||
|
||||
Ensure:
|
||||
- Control plane is running
|
||||
- No firewall blocking WebSocket connections
|
||||
- Using correct protocol (ws:// not wss://)
|
||||
|
||||
## Security Notes
|
||||
|
||||
For local development, this setup uses:
|
||||
- Insecure Traefik dashboard (acceptable for localhost)
|
||||
- HTTP only (no SSL)
|
||||
- Session secrets should be changed from defaults
|
||||
|
||||
**For production deployment:**
|
||||
- Enable HTTPS with Let's Encrypt
|
||||
- Secure Traefik dashboard with authentication
|
||||
- Use strong session secrets
|
||||
- Implement rate limiting
|
||||
- Add CSRF protection
|
||||
- Enable audit logging
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Automatic deployments via GitHub webhooks
|
||||
- [ ] Rollback to previous deployments
|
||||
- [ ] Multi-environment support (staging, production)
|
||||
- [ ] Custom domains with SSL
|
||||
- [ ] Database service provisioning
|
||||
- [ ] Horizontal scaling support
|
||||
- [ ] CI/CD pipeline integration
|
||||
- [ ] CLI tool for deployments
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with inspiration from Railway, Vercel, and Heroku.
|
||||
1
control-plane/.gitkeep
Normal file
1
control-plane/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# This file ensures the directory is tracked by git
|
||||
21
control-plane/Dockerfile
Normal file
21
control-plane/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install git for cloning repositories
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
17
control-plane/config/database.js
Normal file
17
control-plane/config/database.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected database error:', err);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
query: (text, params) => pool.query(text, params),
|
||||
pool
|
||||
};
|
||||
7
control-plane/config/docker.js
Normal file
7
control-plane/config/docker.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const Docker = require('dockerode');
|
||||
|
||||
const docker = new Docker({
|
||||
socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock'
|
||||
});
|
||||
|
||||
module.exports = docker;
|
||||
51
control-plane/config/github.js
Normal file
51
control-plane/config/github.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const passport = require('passport');
|
||||
const GitHubStrategy = require('passport-github2').Strategy;
|
||||
const db = require('./database');
|
||||
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user.id);
|
||||
});
|
||||
|
||||
passport.deserializeUser(async (id, done) => {
|
||||
try {
|
||||
const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
|
||||
done(null, result.rows[0]);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
|
||||
passport.use(new GitHubStrategy({
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: 'http://localhost:3000/auth/github/callback'
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM users WHERE github_id = $1',
|
||||
[profile.id]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const user = result.rows[0];
|
||||
await db.query(
|
||||
'UPDATE users SET github_access_token = $1, github_username = $2, updated_at = NOW() WHERE id = $3',
|
||||
[accessToken, profile.username, user.id]
|
||||
);
|
||||
done(null, user);
|
||||
} else {
|
||||
const insertResult = await db.query(
|
||||
'INSERT INTO users (github_id, github_username, github_access_token) VALUES ($1, $2, $3) RETURNING *',
|
||||
[profile.id, profile.username, accessToken]
|
||||
);
|
||||
done(null, insertResult.rows[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = passport;
|
||||
15
control-plane/middleware/auth.js
Normal file
15
control-plane/middleware/auth.js
Normal file
@@ -0,0 +1,15 @@
|
||||
function ensureAuthenticated(req, res, next) {
|
||||
if (req.isAuthenticated()) {
|
||||
return next();
|
||||
}
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
function optionalAuth(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureAuthenticated,
|
||||
optionalAuth
|
||||
};
|
||||
13
control-plane/middleware/errorHandler.js
Normal file
13
control-plane/middleware/errorHandler.js
Normal file
@@ -0,0 +1,13 @@
|
||||
function errorHandler(err, req, res, next) {
|
||||
console.error('Error:', err);
|
||||
|
||||
const statusCode = err.statusCode || 500;
|
||||
const message = err.message || 'Internal server error';
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: message,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = errorHandler;
|
||||
33
control-plane/package.json
Normal file
33
control-plane/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "minipaas-control-plane",
|
||||
"version": "1.0.0",
|
||||
"description": "miniPaaS Control Plane - Self-hosted PaaS platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"keywords": ["paas", "docker", "devops", "deployment"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"pg": "^8.11.3",
|
||||
"dockerode": "^4.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-github2": "^0.1.12",
|
||||
"@octokit/rest": "^20.0.2",
|
||||
"simple-git": "^3.22.0",
|
||||
"tar-stream": "^3.1.7",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"compression": "^1.7.4",
|
||||
"dotenv": "^16.4.1",
|
||||
"crypto": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.3"
|
||||
}
|
||||
}
|
||||
47
control-plane/routes/analytics.js
Normal file
47
control-plane/routes/analytics.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../config/database');
|
||||
const { ensureAuthenticated } = require('../middleware/auth');
|
||||
const analyticsCollector = require('../services/analyticsCollector');
|
||||
|
||||
router.get('/projects/:id/analytics', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const project = await db.query(
|
||||
'SELECT * FROM projects WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (project.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const timeRange = req.query.range || '24h';
|
||||
const analytics = await analyticsCollector.getAnalytics(req.params.id, timeRange);
|
||||
|
||||
res.json(analytics);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/event', async (req, res, next) => {
|
||||
try {
|
||||
const { projectId, type, data } = req.body;
|
||||
|
||||
if (!projectId || !type || !data) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
if (type === 'http_request') {
|
||||
await analyticsCollector.recordHttpRequest(projectId, data);
|
||||
} else if (type === 'container_stat') {
|
||||
await analyticsCollector.recordContainerStats(projectId, data);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
38
control-plane/routes/auth.js
Normal file
38
control-plane/routes/auth.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const passport = require('../config/github');
|
||||
|
||||
router.get('/github', passport.authenticate('github', { scope: ['repo'] }));
|
||||
|
||||
router.get('/github/callback',
|
||||
passport.authenticate('github', { failureRedirect: '/login.html' }),
|
||||
(req, res) => {
|
||||
res.redirect('/');
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/user', (req, res) => {
|
||||
if (req.isAuthenticated()) {
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: req.user.id,
|
||||
username: req.user.github_username,
|
||||
githubId: req.user.github_id
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
req.logout((err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
178
control-plane/routes/deployments.js
Normal file
178
control-plane/routes/deployments.js
Normal file
@@ -0,0 +1,178 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../config/database');
|
||||
const { ensureAuthenticated } = require('../middleware/auth');
|
||||
const { decrypt } = require('../utils/encryption');
|
||||
const githubService = require('../services/githubService');
|
||||
const buildEngine = require('../services/buildEngine');
|
||||
const deploymentService = require('../services/deploymentService');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
|
||||
router.post('/projects/:projectId/deploy', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const projectResult = await db.query(
|
||||
'SELECT * FROM projects WHERE id = $1 AND user_id = $2',
|
||||
[req.params.projectId, req.user.id]
|
||||
);
|
||||
|
||||
if (projectResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const project = projectResult.rows[0];
|
||||
const accessToken = decrypt(project.github_access_token);
|
||||
|
||||
const deploymentResult = await db.query(
|
||||
'INSERT INTO deployments (project_id, status) VALUES ($1, $2) RETURNING *',
|
||||
[project.id, 'pending']
|
||||
);
|
||||
|
||||
const deployment = deploymentResult.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deploymentId: deployment.id,
|
||||
message: 'Deployment started'
|
||||
});
|
||||
|
||||
const buildPath = path.join('/tmp/builds', `project-${project.id}-${deployment.id}`);
|
||||
|
||||
try {
|
||||
const cloneResult = await githubService.cloneRepository(
|
||||
project.github_repo_url,
|
||||
project.github_branch,
|
||||
buildPath,
|
||||
accessToken
|
||||
);
|
||||
|
||||
await db.query(
|
||||
'UPDATE deployments SET commit_sha = $1 WHERE id = $2',
|
||||
[cloneResult.commitSha, deployment.id]
|
||||
);
|
||||
|
||||
const imageName = `minipaas-${project.subdomain}:${deployment.id}`;
|
||||
|
||||
const buildResult = await buildEngine.buildImage(deployment.id, buildPath, imageName);
|
||||
|
||||
const envVars = await db.query(
|
||||
'SELECT key, value FROM env_vars WHERE project_id = $1',
|
||||
[project.id]
|
||||
);
|
||||
|
||||
const envObject = {};
|
||||
envVars.rows.forEach(row => {
|
||||
envObject[row.key] = decrypt(row.value) || row.value;
|
||||
});
|
||||
|
||||
const stopPrevious = await db.query(
|
||||
'SELECT id FROM deployments WHERE project_id = $1 AND status = $2 AND id != $3',
|
||||
[project.id, 'running', deployment.id]
|
||||
);
|
||||
|
||||
for (const prevDep of stopPrevious.rows) {
|
||||
try {
|
||||
await deploymentService.stopContainer(prevDep.id);
|
||||
} catch (error) {
|
||||
console.error('Error stopping previous deployment:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await deploymentService.startContainer(
|
||||
deployment.id,
|
||||
project.id,
|
||||
imageName,
|
||||
project.subdomain,
|
||||
envObject,
|
||||
project.port
|
||||
);
|
||||
|
||||
await fs.remove(buildPath);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Deployment error:', error);
|
||||
await buildEngine.logBuild(deployment.id, `Deployment failed: ${error.message}`);
|
||||
await db.query(
|
||||
'UPDATE deployments SET status = $1 WHERE id = $2',
|
||||
['failed', deployment.id]
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.remove(buildPath);
|
||||
} catch (cleanupError) {
|
||||
console.error('Error cleaning up build directory:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT d.*, p.name as project_name, p.subdomain
|
||||
FROM deployments d
|
||||
JOIN projects p ON d.project_id = p.id
|
||||
WHERE d.id = $1 AND p.user_id = $2`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Deployment not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/stop', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const deployment = await db.query(
|
||||
`SELECT d.* FROM deployments d
|
||||
JOIN projects p ON d.project_id = p.id
|
||||
WHERE d.id = $1 AND p.user_id = $2`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (deployment.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Deployment not found' });
|
||||
}
|
||||
|
||||
await deploymentService.stopContainer(req.params.id);
|
||||
|
||||
res.json({ success: true, message: 'Deployment stopped' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const deployment = await db.query(
|
||||
`SELECT d.* FROM deployments d
|
||||
JOIN projects p ON d.project_id = p.id
|
||||
WHERE d.id = $1 AND p.user_id = $2`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (deployment.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Deployment not found' });
|
||||
}
|
||||
|
||||
if (deployment.rows[0].status === 'running') {
|
||||
await deploymentService.stopContainer(req.params.id);
|
||||
}
|
||||
|
||||
await db.query('DELETE FROM deployments WHERE id = $1', [req.params.id]);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
179
control-plane/routes/envVars.js
Normal file
179
control-plane/routes/envVars.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../config/database');
|
||||
const { ensureAuthenticated } = require('../middleware/auth');
|
||||
const { encrypt, decrypt } = require('../utils/encryption');
|
||||
const { detectEnvironmentVariables } = require('../services/envDetector');
|
||||
const githubService = require('../services/githubService');
|
||||
const path = require('path');
|
||||
|
||||
router.get('/projects/:id/env', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const project = await db.query(
|
||||
'SELECT * FROM projects WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (project.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
'SELECT id, key, value, is_suggested, created_at FROM env_vars WHERE project_id = $1 ORDER BY key ASC',
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const envVars = result.rows.map(row => ({
|
||||
id: row.id,
|
||||
key: row.key,
|
||||
value: decrypt(row.value) || row.value,
|
||||
is_suggested: row.is_suggested,
|
||||
created_at: row.created_at
|
||||
}));
|
||||
|
||||
res.json(envVars);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/projects/:id/env/suggest', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const project = await db.query(
|
||||
'SELECT * FROM projects WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (project.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const buildPath = path.join('/tmp', `suggest-${project.rows[0].id}-${Date.now()}`);
|
||||
|
||||
try {
|
||||
const accessToken = decrypt(project.rows[0].github_access_token);
|
||||
|
||||
await githubService.cloneRepository(
|
||||
project.rows[0].github_repo_url,
|
||||
project.rows[0].github_branch,
|
||||
buildPath,
|
||||
accessToken
|
||||
);
|
||||
|
||||
const suggestions = detectEnvironmentVariables(buildPath);
|
||||
|
||||
const fs = require('fs-extra');
|
||||
await fs.remove(buildPath);
|
||||
|
||||
res.json(suggestions);
|
||||
} catch (error) {
|
||||
const fs = require('fs-extra');
|
||||
await fs.remove(buildPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/projects/:id/env', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const { key, value, is_suggested } = req.body;
|
||||
|
||||
if (!key || value === undefined) {
|
||||
return res.status(400).json({ error: 'Key and value are required' });
|
||||
}
|
||||
|
||||
const project = await db.query(
|
||||
'SELECT * FROM projects WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (project.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const encryptedValue = encrypt(value);
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO env_vars (project_id, key, value, is_suggested)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (project_id, key)
|
||||
DO UPDATE SET value = $3, is_suggested = $4
|
||||
RETURNING *`,
|
||||
[req.params.id, key, encryptedValue, is_suggested || false]
|
||||
);
|
||||
|
||||
res.json({
|
||||
id: result.rows[0].id,
|
||||
key: result.rows[0].key,
|
||||
value: value,
|
||||
is_suggested: result.rows[0].is_suggested
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/projects/:id/env/:key', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const { value } = req.body;
|
||||
|
||||
if (value === undefined) {
|
||||
return res.status(400).json({ error: 'Value is required' });
|
||||
}
|
||||
|
||||
const project = await db.query(
|
||||
'SELECT * FROM projects WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (project.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const encryptedValue = encrypt(value);
|
||||
|
||||
const result = await db.query(
|
||||
'UPDATE env_vars SET value = $1 WHERE project_id = $2 AND key = $3 RETURNING *',
|
||||
[encryptedValue, req.params.id, req.params.key]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Environment variable not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: result.rows[0].id,
|
||||
key: result.rows[0].key,
|
||||
value: value,
|
||||
is_suggested: result.rows[0].is_suggested
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/projects/:id/env/:key', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const project = await db.query(
|
||||
'SELECT * FROM projects WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (project.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
await db.query(
|
||||
'DELETE FROM env_vars WHERE project_id = $1 AND key = $2',
|
||||
[req.params.id, req.params.key]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
49
control-plane/routes/logs.js
Normal file
49
control-plane/routes/logs.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../config/database');
|
||||
const { ensureAuthenticated } = require('../middleware/auth');
|
||||
const logAggregator = require('../services/logAggregator');
|
||||
|
||||
router.get('/deployments/:id/build-logs', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const deployment = await db.query(
|
||||
`SELECT d.* FROM deployments d
|
||||
JOIN projects p ON d.project_id = p.id
|
||||
WHERE d.id = $1 AND p.user_id = $2`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (deployment.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Deployment not found' });
|
||||
}
|
||||
|
||||
const logs = await logAggregator.getBuildLogs(req.params.id);
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/deployments/:id/runtime-logs', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 500;
|
||||
|
||||
const deployment = await db.query(
|
||||
`SELECT d.* FROM deployments d
|
||||
JOIN projects p ON d.project_id = p.id
|
||||
WHERE d.id = $1 AND p.user_id = $2`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (deployment.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Deployment not found' });
|
||||
}
|
||||
|
||||
const logs = await logAggregator.getRuntimeLogs(req.params.id, limit);
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
145
control-plane/routes/projects.js
Normal file
145
control-plane/routes/projects.js
Normal file
@@ -0,0 +1,145 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../config/database');
|
||||
const { ensureAuthenticated } = require('../middleware/auth');
|
||||
const { encrypt, decrypt } = require('../utils/encryption');
|
||||
const githubService = require('../services/githubService');
|
||||
const { detectEnvironmentVariables } = require('../services/envDetector');
|
||||
|
||||
router.get('/', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT p.*,
|
||||
(SELECT status FROM deployments WHERE project_id = p.id ORDER BY created_at DESC LIMIT 1) as latest_status,
|
||||
(SELECT COUNT(*) FROM deployments WHERE project_id = p.id) as deployment_count
|
||||
FROM projects p
|
||||
WHERE p.user_id = $1
|
||||
ORDER BY p.updated_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/repositories', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const repos = await githubService.listRepositories(req.user.github_access_token);
|
||||
res.json(repos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT p.*,
|
||||
(SELECT json_agg(d ORDER BY d.created_at DESC)
|
||||
FROM deployments d
|
||||
WHERE d.project_id = p.id) as deployments
|
||||
FROM projects p
|
||||
WHERE p.id = $1 AND p.user_id = $2`,
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const { name, subdomain, github_repo_url, github_repo_name, github_branch, port } = req.body;
|
||||
|
||||
if (!name || !subdomain || !github_repo_url) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const existing = await db.query(
|
||||
'SELECT id FROM projects WHERE subdomain = $1',
|
||||
[subdomain]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return res.status(400).json({ error: 'Subdomain already in use' });
|
||||
}
|
||||
|
||||
const encryptedToken = encrypt(req.user.github_access_token);
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO projects
|
||||
(user_id, name, subdomain, github_repo_url, github_repo_name, github_branch, github_access_token, port)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[req.user.id, name, subdomain, github_repo_url, github_repo_name, github_branch || 'main', encryptedToken, port || 3000]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const { name, github_branch, port } = req.body;
|
||||
|
||||
const result = await db.query(
|
||||
`UPDATE projects
|
||||
SET name = COALESCE($1, name),
|
||||
github_branch = COALESCE($2, github_branch),
|
||||
port = COALESCE($3, port),
|
||||
updated_at = NOW()
|
||||
WHERE id = $4 AND user_id = $5
|
||||
RETURNING *`,
|
||||
[name, github_branch, port, req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', ensureAuthenticated, async (req, res, next) => {
|
||||
try {
|
||||
const deployments = await db.query(
|
||||
'SELECT docker_container_id FROM deployments WHERE project_id = $1 AND docker_container_id IS NOT NULL',
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const docker = require('../config/docker');
|
||||
for (const deployment of deployments.rows) {
|
||||
try {
|
||||
const container = docker.getContainer(deployment.docker_container_id);
|
||||
await container.stop();
|
||||
await container.remove();
|
||||
} catch (error) {
|
||||
console.error('Error removing container:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await db.query(
|
||||
'DELETE FROM projects WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
88
control-plane/server.js
Normal file
88
control-plane/server.js
Normal file
@@ -0,0 +1,88 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const passport = require('./config/github');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const projectsRoutes = require('./routes/projects');
|
||||
const deploymentsRoutes = require('./routes/deployments');
|
||||
const logsRoutes = require('./routes/logs');
|
||||
const analyticsRoutes = require('./routes/analytics');
|
||||
const envVarsRoutes = require('./routes/envVars');
|
||||
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
const setupWebSocketServer = require('./websockets/logStreamer');
|
||||
const analyticsCollector = require('./services/analyticsCollector');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false
|
||||
}));
|
||||
app.use(compression());
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'your-secret-key',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 24 * 60 * 60 * 1000
|
||||
}
|
||||
}));
|
||||
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/api/projects', projectsRoutes);
|
||||
app.use('/api', deploymentsRoutes);
|
||||
app.use('/api', logsRoutes);
|
||||
app.use('/api', analyticsRoutes);
|
||||
app.use('/api', envVarsRoutes);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
setupWebSocketServer(server);
|
||||
|
||||
analyticsCollector.startStatsCollection();
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`miniPaaS Control Plane running on port ${PORT}`);
|
||||
console.log(`Dashboard: http://localhost:${PORT}`);
|
||||
console.log(`WebSocket: ws://localhost:${PORT}/ws/logs`);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Shutting down gracefully...');
|
||||
analyticsCollector.stopStatsCollection();
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
149
control-plane/services/analyticsCollector.js
Normal file
149
control-plane/services/analyticsCollector.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const db = require('../config/database');
|
||||
const docker = require('../config/docker');
|
||||
const fs = require('fs');
|
||||
const { Tail } = require('tail');
|
||||
|
||||
class AnalyticsCollector {
|
||||
constructor() {
|
||||
this.statsInterval = null;
|
||||
}
|
||||
|
||||
async recordHttpRequest(projectId, requestData) {
|
||||
try {
|
||||
await db.query(
|
||||
'INSERT INTO analytics_events (project_id, event_type, data) VALUES ($1, $2, $3)',
|
||||
[projectId, 'http_request', JSON.stringify(requestData)]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error recording HTTP request:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async recordContainerStats(projectId, statsData) {
|
||||
try {
|
||||
await db.query(
|
||||
'INSERT INTO analytics_events (project_id, event_type, data) VALUES ($1, $2, $3)',
|
||||
[projectId, 'container_stat', JSON.stringify(statsData)]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error recording container stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
startStatsCollection() {
|
||||
if (this.statsInterval) return;
|
||||
|
||||
this.statsInterval = setInterval(async () => {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: false });
|
||||
|
||||
for (const containerInfo of containers) {
|
||||
const projectId = containerInfo.Labels?.['minipaas.project.id'];
|
||||
if (!projectId) continue;
|
||||
|
||||
const container = docker.getContainer(containerInfo.Id);
|
||||
const stats = await container.stats({ stream: false });
|
||||
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100 : 0;
|
||||
|
||||
const memoryMB = stats.memory_stats.usage / (1024 * 1024);
|
||||
|
||||
await this.recordContainerStats(projectId, {
|
||||
cpu_percent: parseFloat(cpuPercent.toFixed(2)),
|
||||
memory_mb: parseFloat(memoryMB.toFixed(2))
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error collecting container stats:', error);
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
stopStatsCollection() {
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
this.statsInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async getAnalytics(projectId, timeRange = '24h') {
|
||||
const timeRangeMap = {
|
||||
'1h': '1 hour',
|
||||
'24h': '24 hours',
|
||||
'7d': '7 days',
|
||||
'30d': '30 days'
|
||||
};
|
||||
|
||||
const interval = timeRangeMap[timeRange] || '24 hours';
|
||||
|
||||
const requestStats = await db.query(
|
||||
`SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COUNT(DISTINCT data->>'ip') as unique_visitors,
|
||||
AVG((data->>'duration_ms')::float) as avg_response_time
|
||||
FROM analytics_events
|
||||
WHERE project_id = $1
|
||||
AND event_type = 'http_request'
|
||||
AND timestamp > NOW() - INTERVAL '${interval}'`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const statusCodes = await db.query(
|
||||
`SELECT
|
||||
CASE
|
||||
WHEN (data->>'status')::int < 300 THEN '2xx'
|
||||
WHEN (data->>'status')::int < 400 THEN '3xx'
|
||||
WHEN (data->>'status')::int < 500 THEN '4xx'
|
||||
ELSE '5xx'
|
||||
END as status_group,
|
||||
COUNT(*) as count
|
||||
FROM analytics_events
|
||||
WHERE project_id = $1
|
||||
AND event_type = 'http_request'
|
||||
AND timestamp > NOW() - INTERVAL '${interval}'
|
||||
GROUP BY status_group`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const requestsByHour = await db.query(
|
||||
`SELECT
|
||||
DATE_TRUNC('hour', timestamp) as hour,
|
||||
COUNT(*) as count
|
||||
FROM analytics_events
|
||||
WHERE project_id = $1
|
||||
AND event_type = 'http_request'
|
||||
AND timestamp > NOW() - INTERVAL '${interval}'
|
||||
GROUP BY hour
|
||||
ORDER BY hour ASC`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const resourceUsage = await db.query(
|
||||
`SELECT
|
||||
timestamp,
|
||||
(data->>'cpu_percent')::float as cpu,
|
||||
(data->>'memory_mb')::float as memory
|
||||
FROM analytics_events
|
||||
WHERE project_id = $1
|
||||
AND event_type = 'container_stat'
|
||||
AND timestamp > NOW() - INTERVAL '${interval}'
|
||||
ORDER BY timestamp ASC`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalRequests: parseInt(requestStats.rows[0]?.total_requests || 0),
|
||||
uniqueVisitors: parseInt(requestStats.rows[0]?.unique_visitors || 0),
|
||||
avgResponseTime: parseFloat(requestStats.rows[0]?.avg_response_time || 0).toFixed(2)
|
||||
},
|
||||
statusCodes: statusCodes.rows,
|
||||
requestsByHour: requestsByHour.rows,
|
||||
resourceUsage: resourceUsage.rows
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AnalyticsCollector();
|
||||
139
control-plane/services/buildEngine.js
Normal file
139
control-plane/services/buildEngine.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const docker = require('../config/docker');
|
||||
const db = require('../config/database');
|
||||
const { ensureDockerfile } = require('../utils/dockerfileGenerator');
|
||||
const tar = require('tar-stream');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
|
||||
async function buildImage(deploymentId, repoPath, imageName) {
|
||||
try {
|
||||
await ensureDockerfile(repoPath);
|
||||
|
||||
await logBuild(deploymentId, 'Starting Docker build...');
|
||||
await updateDeploymentStatus(deploymentId, 'building');
|
||||
|
||||
const tarStream = await createTarStream(repoPath);
|
||||
|
||||
const stream = await docker.buildImage(tarStream, {
|
||||
t: imageName,
|
||||
dockerfile: 'Dockerfile'
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let imageId = null;
|
||||
|
||||
docker.modem.followProgress(stream,
|
||||
async (err, res) => {
|
||||
if (err) {
|
||||
await logBuild(deploymentId, `Build failed: ${err.message}`);
|
||||
await updateDeploymentStatus(deploymentId, 'failed');
|
||||
reject(err);
|
||||
} else {
|
||||
await logBuild(deploymentId, 'Build completed successfully');
|
||||
|
||||
if (imageId) {
|
||||
await db.query(
|
||||
'UPDATE deployments SET docker_image_id = $1 WHERE id = $2',
|
||||
[imageId, deploymentId]
|
||||
);
|
||||
}
|
||||
|
||||
resolve({ imageId, imageName });
|
||||
}
|
||||
},
|
||||
async (event) => {
|
||||
if (event.stream) {
|
||||
const logLine = event.stream.trim();
|
||||
if (logLine) {
|
||||
await logBuild(deploymentId, logLine);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.aux?.ID) {
|
||||
imageId = event.aux.ID;
|
||||
}
|
||||
|
||||
if (event.error) {
|
||||
await logBuild(deploymentId, `Error: ${event.error}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
await logBuild(deploymentId, `Build error: ${error.message}`);
|
||||
await updateDeploymentStatus(deploymentId, 'failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTarStream(sourceDir) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const pack = tar.pack();
|
||||
const files = getAllFiles(sourceDir);
|
||||
|
||||
files.forEach(file => {
|
||||
const relativePath = path.relative(sourceDir, file);
|
||||
const content = fs.readFileSync(file);
|
||||
pack.entry({ name: relativePath }, content);
|
||||
});
|
||||
|
||||
pack.finalize();
|
||||
resolve(pack);
|
||||
});
|
||||
}
|
||||
|
||||
function getAllFiles(dir, fileList = []) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(dir, file);
|
||||
|
||||
if (shouldIgnore(file)) return;
|
||||
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
getAllFiles(filePath, fileList);
|
||||
} else {
|
||||
fileList.push(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
function shouldIgnore(filename) {
|
||||
const ignorePatterns = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.env',
|
||||
'.DS_Store',
|
||||
'dist',
|
||||
'build',
|
||||
'.vscode',
|
||||
'.idea'
|
||||
];
|
||||
|
||||
return ignorePatterns.some(pattern => filename.includes(pattern));
|
||||
}
|
||||
|
||||
async function logBuild(deploymentId, message) {
|
||||
try {
|
||||
await db.query(
|
||||
'INSERT INTO build_logs (deployment_id, log_line) VALUES ($1, $2)',
|
||||
[deploymentId, message]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error logging build:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeploymentStatus(deploymentId, status) {
|
||||
await db.query(
|
||||
'UPDATE deployments SET status = $1, updated_at = NOW() WHERE id = $2',
|
||||
[status, deploymentId]
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildImage,
|
||||
logBuild
|
||||
};
|
||||
138
control-plane/services/deploymentService.js
Normal file
138
control-plane/services/deploymentService.js
Normal file
@@ -0,0 +1,138 @@
|
||||
const docker = require('../config/docker');
|
||||
const db = require('../config/database');
|
||||
|
||||
async function startContainer(deploymentId, projectId, imageName, subdomain, envVars = {}, port = 3000) {
|
||||
try {
|
||||
const project = await db.query('SELECT * FROM projects WHERE id = $1', [projectId]);
|
||||
if (project.rows.length === 0) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
const projectName = project.rows[0].name.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
const containerName = `${projectName}-${deploymentId}`;
|
||||
|
||||
const containerConfig = {
|
||||
Image: imageName,
|
||||
name: containerName,
|
||||
Env: Object.entries(envVars).map(([key, value]) => `${key}=${value}`),
|
||||
Labels: {
|
||||
'traefik.enable': 'true',
|
||||
[`traefik.http.routers.${projectName}.rule`]: `Host(\`${subdomain}.localhost\`)`,
|
||||
[`traefik.http.routers.${projectName}.entrypoints`]: 'web',
|
||||
[`traefik.http.services.${projectName}.loadbalancer.server.port`]: port.toString(),
|
||||
'minipaas.project.id': projectId.toString(),
|
||||
'minipaas.deployment.id': deploymentId.toString()
|
||||
},
|
||||
HostConfig: {
|
||||
NetworkMode: 'minipaas_paas_network',
|
||||
RestartPolicy: {
|
||||
Name: 'unless-stopped'
|
||||
}
|
||||
},
|
||||
ExposedPorts: {
|
||||
[`${port}/tcp`]: {}
|
||||
}
|
||||
};
|
||||
|
||||
const container = await docker.createContainer(containerConfig);
|
||||
await container.start();
|
||||
|
||||
const containerInfo = await container.inspect();
|
||||
const containerId = containerInfo.Id;
|
||||
|
||||
await db.query(
|
||||
'UPDATE deployments SET docker_container_id = $1, status = $2, started_at = NOW(), completed_at = NOW() WHERE id = $3',
|
||||
[containerId, 'running', deploymentId]
|
||||
);
|
||||
|
||||
return {
|
||||
containerId,
|
||||
containerName,
|
||||
status: 'running'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error starting container:', error);
|
||||
await db.query(
|
||||
'UPDATE deployments SET status = $1 WHERE id = $2',
|
||||
['failed', deploymentId]
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopContainer(deploymentId) {
|
||||
try {
|
||||
const deployment = await db.query(
|
||||
'SELECT docker_container_id FROM deployments WHERE id = $1',
|
||||
[deploymentId]
|
||||
);
|
||||
|
||||
if (deployment.rows.length === 0 || !deployment.rows[0].docker_container_id) {
|
||||
throw new Error('Container not found');
|
||||
}
|
||||
|
||||
const containerId = deployment.rows[0].docker_container_id;
|
||||
const container = docker.getContainer(containerId);
|
||||
|
||||
await container.stop();
|
||||
await container.remove();
|
||||
|
||||
await db.query(
|
||||
'UPDATE deployments SET status = $1 WHERE id = $2',
|
||||
['stopped', deploymentId]
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error stopping container:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerLogs(containerId, tail = 100) {
|
||||
try {
|
||||
const container = docker.getContainer(containerId);
|
||||
const logs = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: tail,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
return logs.toString('utf8');
|
||||
} catch (error) {
|
||||
console.error('Error getting container logs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerStats(containerId) {
|
||||
try {
|
||||
const container = docker.getContainer(containerId);
|
||||
const stats = await container.stats({ stream: false });
|
||||
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuPercent = (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100;
|
||||
|
||||
const memoryUsage = stats.memory_stats.usage / (1024 * 1024);
|
||||
const memoryLimit = stats.memory_stats.limit / (1024 * 1024);
|
||||
|
||||
return {
|
||||
cpuPercent: cpuPercent.toFixed(2),
|
||||
memoryMB: memoryUsage.toFixed(2),
|
||||
memoryLimitMB: memoryLimit.toFixed(2),
|
||||
memoryPercent: ((memoryUsage / memoryLimit) * 100).toFixed(2)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting container stats:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startContainer,
|
||||
stopContainer,
|
||||
getContainerLogs,
|
||||
getContainerStats
|
||||
};
|
||||
141
control-plane/services/envDetector.js
Normal file
141
control-plane/services/envDetector.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function detectEnvironmentVariables(repoPath) {
|
||||
const suggestions = [];
|
||||
|
||||
try {
|
||||
if (fs.existsSync(path.join(repoPath, '.env.example'))) {
|
||||
const envExample = fs.readFileSync(path.join(repoPath, '.env.example'), 'utf-8');
|
||||
const parsed = parseEnvFile(envExample);
|
||||
suggestions.push(...parsed.map(item => ({
|
||||
...item,
|
||||
reason: 'Found in .env.example'
|
||||
})));
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(repoPath, 'package.json'))) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoPath, 'package.json'), 'utf-8'));
|
||||
|
||||
if (packageJson.dependencies?.express || packageJson.dependencies?.koa) {
|
||||
suggestions.push({
|
||||
key: 'PORT',
|
||||
value: '3000',
|
||||
reason: 'Node.js web server detected'
|
||||
});
|
||||
suggestions.push({
|
||||
key: 'NODE_ENV',
|
||||
value: 'production',
|
||||
reason: 'Node.js application'
|
||||
});
|
||||
}
|
||||
|
||||
if (packageJson.dependencies?.mongoose) {
|
||||
suggestions.push({
|
||||
key: 'MONGODB_URI',
|
||||
value: 'mongodb://localhost:27017/dbname',
|
||||
reason: 'Mongoose (MongoDB) detected'
|
||||
});
|
||||
}
|
||||
|
||||
if (packageJson.dependencies?.pg) {
|
||||
suggestions.push({
|
||||
key: 'DATABASE_URL',
|
||||
value: 'postgresql://user:pass@localhost:5432/dbname',
|
||||
reason: 'PostgreSQL (pg) detected'
|
||||
});
|
||||
}
|
||||
|
||||
if (packageJson.dependencies?.mysql || packageJson.dependencies?.mysql2) {
|
||||
suggestions.push({
|
||||
key: 'DATABASE_URL',
|
||||
value: 'mysql://user:pass@localhost:3306/dbname',
|
||||
reason: 'MySQL detected'
|
||||
});
|
||||
}
|
||||
|
||||
if (packageJson.dependencies?.redis) {
|
||||
suggestions.push({
|
||||
key: 'REDIS_URL',
|
||||
value: 'redis://localhost:6379',
|
||||
reason: 'Redis detected'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(repoPath, 'requirements.txt'))) {
|
||||
const requirements = fs.readFileSync(path.join(repoPath, 'requirements.txt'), 'utf-8');
|
||||
|
||||
if (requirements.includes('django')) {
|
||||
suggestions.push({
|
||||
key: 'SECRET_KEY',
|
||||
value: '',
|
||||
reason: 'Django application'
|
||||
});
|
||||
suggestions.push({
|
||||
key: 'DEBUG',
|
||||
value: 'False',
|
||||
reason: 'Django application'
|
||||
});
|
||||
suggestions.push({
|
||||
key: 'ALLOWED_HOSTS',
|
||||
value: 'localhost',
|
||||
reason: 'Django application'
|
||||
});
|
||||
}
|
||||
|
||||
if (requirements.includes('flask')) {
|
||||
suggestions.push({
|
||||
key: 'FLASK_ENV',
|
||||
value: 'production',
|
||||
reason: 'Flask application'
|
||||
});
|
||||
suggestions.push({
|
||||
key: 'SECRET_KEY',
|
||||
value: '',
|
||||
reason: 'Flask application'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueSuggestions = suggestions.reduce((acc, current) => {
|
||||
const exists = acc.find(item => item.key === current.key);
|
||||
if (!exists) {
|
||||
acc.push(current);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return uniqueSuggestions;
|
||||
} catch (error) {
|
||||
console.error('Error detecting environment variables:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseEnvFile(content) {
|
||||
const lines = content.split('\n');
|
||||
const vars = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const [key, ...valueParts] = trimmed.split('=');
|
||||
if (key) {
|
||||
vars.push({
|
||||
key: key.trim(),
|
||||
value: valueParts.join('=').trim() || '',
|
||||
is_suggested: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectEnvironmentVariables,
|
||||
parseEnvFile
|
||||
};
|
||||
80
control-plane/services/githubService.js
Normal file
80
control-plane/services/githubService.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const { Octokit } = require('@octokit/rest');
|
||||
const simpleGit = require('simple-git');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
|
||||
async function listRepositories(accessToken) {
|
||||
const octokit = new Octokit({ auth: accessToken });
|
||||
|
||||
try {
|
||||
const { data } = await octokit.repos.listForAuthenticatedUser({
|
||||
sort: 'updated',
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
return data.map(repo => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
clone_url: repo.clone_url,
|
||||
default_branch: repo.default_branch,
|
||||
private: repo.private,
|
||||
description: repo.description
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching repositories:', error);
|
||||
throw new Error('Failed to fetch repositories from GitHub');
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneRepository(repoUrl, branch, targetPath, accessToken) {
|
||||
try {
|
||||
await fs.ensureDir(targetPath);
|
||||
|
||||
const urlWithAuth = repoUrl.replace('https://', `https://x-access-token:${accessToken}@`);
|
||||
|
||||
const git = simpleGit();
|
||||
await git.clone(urlWithAuth, targetPath, ['--branch', branch, '--single-branch', '--depth', '1']);
|
||||
|
||||
const commitInfo = await simpleGit(targetPath).log(['-1']);
|
||||
const commitSha = commitInfo.latest?.hash || 'unknown';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commitSha,
|
||||
path: targetPath
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error cloning repository:', error);
|
||||
throw new Error(`Failed to clone repository: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestCommit(repoFullName, branch, accessToken) {
|
||||
const octokit = new Octokit({ auth: accessToken });
|
||||
|
||||
try {
|
||||
const [owner, repo] = repoFullName.split('/');
|
||||
const { data } = await octokit.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: branch
|
||||
});
|
||||
|
||||
return {
|
||||
sha: data.sha,
|
||||
message: data.commit.message,
|
||||
author: data.commit.author.name,
|
||||
date: data.commit.author.date
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching latest commit:', error);
|
||||
throw new Error('Failed to fetch latest commit');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRepositories,
|
||||
cloneRepository,
|
||||
getLatestCommit
|
||||
};
|
||||
95
control-plane/services/logAggregator.js
Normal file
95
control-plane/services/logAggregator.js
Normal file
@@ -0,0 +1,95 @@
|
||||
const docker = require('../config/docker');
|
||||
const db = require('../config/database');
|
||||
|
||||
class LogAggregator {
|
||||
constructor() {
|
||||
this.activeStreams = new Map();
|
||||
}
|
||||
|
||||
async attachToContainer(deploymentId, containerId, onLog) {
|
||||
try {
|
||||
if (this.activeStreams.has(deploymentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = docker.getContainer(containerId);
|
||||
const stream = await container.logs({
|
||||
follow: true,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
this.activeStreams.set(deploymentId, stream);
|
||||
|
||||
stream.on('data', async (chunk) => {
|
||||
const logLine = chunk.toString('utf8').trim();
|
||||
if (logLine) {
|
||||
const logLevel = this.detectLogLevel(logLine);
|
||||
|
||||
await db.query(
|
||||
'INSERT INTO runtime_logs (deployment_id, log_line, log_level) VALUES ($1, $2, $3)',
|
||||
[deploymentId, logLine, logLevel]
|
||||
);
|
||||
|
||||
if (onLog) {
|
||||
onLog(logLine);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
this.activeStreams.delete(deploymentId);
|
||||
});
|
||||
|
||||
stream.on('error', (error) => {
|
||||
console.error(`Log stream error for deployment ${deploymentId}:`, error);
|
||||
this.activeStreams.delete(deploymentId);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error attaching to container logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
detachFromContainer(deploymentId) {
|
||||
const stream = this.activeStreams.get(deploymentId);
|
||||
if (stream) {
|
||||
stream.destroy();
|
||||
this.activeStreams.delete(deploymentId);
|
||||
}
|
||||
}
|
||||
|
||||
detectLogLevel(logLine) {
|
||||
const line = logLine.toLowerCase();
|
||||
|
||||
if (line.includes('error') || line.includes('fatal') || line.includes('critical')) {
|
||||
return 'error';
|
||||
}
|
||||
if (line.includes('warn') || line.includes('warning')) {
|
||||
return 'warn';
|
||||
}
|
||||
if (line.includes('debug')) {
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
return 'info';
|
||||
}
|
||||
|
||||
async getBuildLogs(deploymentId) {
|
||||
const result = await db.query(
|
||||
'SELECT log_line, timestamp FROM build_logs WHERE deployment_id = $1 ORDER BY timestamp ASC',
|
||||
[deploymentId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async getRuntimeLogs(deploymentId, limit = 500) {
|
||||
const result = await db.query(
|
||||
'SELECT log_line, log_level, timestamp FROM runtime_logs WHERE deployment_id = $1 ORDER BY timestamp DESC LIMIT $2',
|
||||
[deploymentId, limit]
|
||||
);
|
||||
return result.rows.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new LogAggregator();
|
||||
127
control-plane/utils/dockerfileGenerator.js
Normal file
127
control-plane/utils/dockerfileGenerator.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function detectProjectType(repoPath) {
|
||||
if (fs.existsSync(path.join(repoPath, 'Dockerfile'))) {
|
||||
return 'existing';
|
||||
}
|
||||
if (fs.existsSync(path.join(repoPath, 'package.json'))) {
|
||||
return 'nodejs';
|
||||
}
|
||||
if (fs.existsSync(path.join(repoPath, 'requirements.txt'))) {
|
||||
return 'python';
|
||||
}
|
||||
if (fs.existsSync(path.join(repoPath, 'go.mod'))) {
|
||||
return 'go';
|
||||
}
|
||||
if (fs.existsSync(path.join(repoPath, 'index.html'))) {
|
||||
return 'static';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function generateDockerfile(repoPath, projectType) {
|
||||
let dockerfile = '';
|
||||
|
||||
switch (projectType) {
|
||||
case 'nodejs':
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoPath, 'package.json'), 'utf8'));
|
||||
const hasYarnLock = fs.existsSync(path.join(repoPath, 'yarn.lock'));
|
||||
|
||||
dockerfile = `FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
${hasYarnLock ? 'COPY yarn.lock ./\n' : ''}
|
||||
|
||||
RUN ${hasYarnLock ? 'yarn install --frozen-lockfile' : 'npm ci --only=production'}
|
||||
|
||||
COPY . .
|
||||
|
||||
${packageJson.scripts?.build ? 'RUN ' + (hasYarnLock ? 'yarn build' : 'npm run build') + '\n' : ''}
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["${hasYarnLock ? 'yarn' : 'npm'}", "start"]
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'python':
|
||||
dockerfile = `FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'go':
|
||||
dockerfile = `FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.* ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go build -o main .
|
||||
|
||||
FROM alpine:latest
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/main .
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./main"]
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'static':
|
||||
dockerfile = `FROM nginx:alpine
|
||||
|
||||
COPY . /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
`;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error('Unknown project type - cannot generate Dockerfile');
|
||||
}
|
||||
|
||||
return dockerfile;
|
||||
}
|
||||
|
||||
function ensureDockerfile(repoPath) {
|
||||
const dockerfilePath = path.join(repoPath, 'Dockerfile');
|
||||
|
||||
if (fs.existsSync(dockerfilePath)) {
|
||||
return { exists: true, generated: false };
|
||||
}
|
||||
|
||||
const projectType = detectProjectType(repoPath);
|
||||
|
||||
if (projectType === 'unknown') {
|
||||
throw new Error('Cannot detect project type');
|
||||
}
|
||||
|
||||
const dockerfile = generateDockerfile(repoPath, projectType);
|
||||
fs.writeFileSync(dockerfilePath, dockerfile);
|
||||
|
||||
return { exists: false, generated: true, projectType };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectProjectType,
|
||||
generateDockerfile,
|
||||
ensureDockerfile
|
||||
};
|
||||
42
control-plane/utils/encryption.js
Normal file
42
control-plane/utils/encryption.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY = Buffer.from(process.env.ENCRYPTION_KEY || 'change_me_to_32_character_key', 'utf-8').slice(0, 32);
|
||||
|
||||
function encrypt(text) {
|
||||
if (!text) return null;
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
function decrypt(encryptedText) {
|
||||
if (!encryptedText) return null;
|
||||
|
||||
const parts = encryptedText.split(':');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const authTag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = parts[2];
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encrypt,
|
||||
decrypt
|
||||
};
|
||||
100
control-plane/websockets/logStreamer.js
Normal file
100
control-plane/websockets/logStreamer.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const WebSocket = require('ws');
|
||||
const logAggregator = require('../services/logAggregator');
|
||||
const db = require('../config/database');
|
||||
|
||||
function setupWebSocketServer(server) {
|
||||
const wss = new WebSocket.Server({ server, path: '/ws/logs' });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
console.log('WebSocket client connected');
|
||||
|
||||
let deploymentId = null;
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
if (data.type === 'subscribe' && data.deploymentId) {
|
||||
deploymentId = data.deploymentId;
|
||||
|
||||
const deployment = await db.query(
|
||||
'SELECT * FROM deployments WHERE id = $1',
|
||||
[deploymentId]
|
||||
);
|
||||
|
||||
if (deployment.rows.length === 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Deployment not found'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const buildLogs = await logAggregator.getBuildLogs(deploymentId);
|
||||
buildLogs.forEach(log => {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'log',
|
||||
source: 'build',
|
||||
data: log.log_line,
|
||||
timestamp: log.timestamp
|
||||
}));
|
||||
});
|
||||
|
||||
if (deployment.rows[0].docker_container_id && deployment.rows[0].status === 'running') {
|
||||
const runtimeLogs = await logAggregator.getRuntimeLogs(deploymentId, 100);
|
||||
runtimeLogs.forEach(log => {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'log',
|
||||
source: 'runtime',
|
||||
level: log.log_level,
|
||||
data: log.log_line,
|
||||
timestamp: log.timestamp
|
||||
}));
|
||||
});
|
||||
|
||||
logAggregator.attachToContainer(
|
||||
deploymentId,
|
||||
deployment.rows[0].docker_container_id,
|
||||
(logLine) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'log',
|
||||
source: 'runtime',
|
||||
data: logLine,
|
||||
timestamp: new Date()
|
||||
}));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribed',
|
||||
deploymentId: deploymentId
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebSocket message error:', error);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Invalid message format'
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket client disconnected');
|
||||
if (deploymentId) {
|
||||
logAggregator.detachFromContainer(deploymentId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
module.exports = setupWebSocketServer;
|
||||
20
dashboard/assets/chart.min.js
vendored
Normal file
20
dashboard/assets/chart.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
318
dashboard/css/dashboard.css
Normal file
318
dashboard/css/dashboard.css
Normal file
@@ -0,0 +1,318 @@
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--border-color-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.project-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.project-card-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.project-card-url {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.project-card-url:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.project-card-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.project-card-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--accent-primary);
|
||||
border-bottom-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.deployment-history {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.deployment-item {
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.deployment-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.deployment-commit {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.deployment-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-viewer {
|
||||
background-color: #000;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin-bottom: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-info {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.log-warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.log-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.log-success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.env-var-list {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.env-var-item {
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.env-var-key {
|
||||
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.env-var-value {
|
||||
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.env-var-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.overview-section h4 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-item {
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.overview-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.overview-value {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.danger-zone h4 {
|
||||
color: var(--error);
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.danger-zone p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
92
dashboard/css/logs.css
Normal file
92
dashboard/css/logs.css
Normal file
@@ -0,0 +1,92 @@
|
||||
.log-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.log-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-filter-btn {
|
||||
padding: 6px 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.log-filter-btn:hover {
|
||||
border-color: var(--border-color-light);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.log-filter-btn.active {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-search {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-search input {
|
||||
padding: 6px 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.log-status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--success);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.log-viewer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.log-viewer::-webkit-scrollbar-track {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.log-viewer::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-viewer::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-color-light);
|
||||
}
|
||||
444
dashboard/css/main.css
Normal file
444
dashboard/css/main.css
Normal file
@@ -0,0 +1,444 @@
|
||||
:root {
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #141414;
|
||||
--bg-tertiary: #1e1e1e;
|
||||
--bg-elevated: #242424;
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-tertiary: #6b6b6b;
|
||||
|
||||
--accent-primary: #ff6b35;
|
||||
--accent-secondary: #ff8c42;
|
||||
--accent-hover: #ff5722;
|
||||
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--info: #3b82f6;
|
||||
|
||||
--border-color: #2a2a2a;
|
||||
--border-color-light: #3a3a3a;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6);
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
|
||||
--transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 16px 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-brand h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
padding: 8px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover, .nav-link.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 48px 0;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color-light);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--bg-elevated);
|
||||
border-color: var(--border-color-light);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 16px 32px;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.input-group .form-control {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.input-addon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: none;
|
||||
border-top-right-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 48px;
|
||||
color: var(--accent-primary);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 48px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-features {
|
||||
margin-top: 32px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
130
dashboard/index.html
Normal file
130
dashboard/index.html
Normal file
@@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>miniPaaS - Dashboard</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/dashboard.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<div class="nav-content">
|
||||
<div class="nav-brand">
|
||||
<h1>miniPaaS</h1>
|
||||
</div>
|
||||
<div class="nav-menu">
|
||||
<a href="/" class="nav-link active">Projects</a>
|
||||
<div class="nav-user" id="userMenu">
|
||||
<span id="username"></span>
|
||||
<button class="btn-text" onclick="logout()">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">Your Projects</h2>
|
||||
<p class="page-subtitle">Manage and deploy your applications</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="showNewProjectModal()">New Project</button>
|
||||
</div>
|
||||
|
||||
<div id="projectsGrid" class="projects-grid">
|
||||
<div class="loading">Loading projects...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="newProjectModal" class="modal">
|
||||
<div class="modal-overlay" onclick="closeNewProjectModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Project</h3>
|
||||
<button class="modal-close" onclick="closeNewProjectModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="newProjectForm" onsubmit="createProject(event)">
|
||||
<div class="form-group">
|
||||
<label>GitHub Repository</label>
|
||||
<select id="repoSelect" class="form-control" required>
|
||||
<option value="">Loading repositories...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Project Name</label>
|
||||
<input type="text" id="projectName" class="form-control" required placeholder="my-awesome-app">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Subdomain</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="subdomain" class="form-control" required placeholder="myapp">
|
||||
<span class="input-addon">.localhost</span>
|
||||
</div>
|
||||
<small class="form-hint">Your app will be accessible at subdomain.localhost</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Branch</label>
|
||||
<input type="text" id="branch" class="form-control" value="main" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Port</label>
|
||||
<input type="number" id="port" class="form-control" value="3000" required>
|
||||
<small class="form-hint">Port your application runs on</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeNewProjectModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="projectDetailModal" class="modal">
|
||||
<div class="modal-overlay" onclick="closeProjectDetail()"></div>
|
||||
<div class="modal-content modal-large">
|
||||
<div class="modal-header">
|
||||
<h3 id="projectDetailTitle">Project Details</h3>
|
||||
<button class="modal-close" onclick="closeProjectDetail()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="tabs">
|
||||
<button class="tab-button active" onclick="switchTab('overview')">Overview</button>
|
||||
<button class="tab-button" onclick="switchTab('logs')">Logs</button>
|
||||
<button class="tab-button" onclick="switchTab('analytics')">Analytics</button>
|
||||
<button class="tab-button" onclick="switchTab('env')">Environment</button>
|
||||
<button class="tab-button" onclick="switchTab('settings')">Settings</button>
|
||||
</div>
|
||||
|
||||
<div id="tabContent">
|
||||
<div id="overviewTab" class="tab-content active"></div>
|
||||
<div id="logsTab" class="tab-content"></div>
|
||||
<div id="analyticsTab" class="tab-content"></div>
|
||||
<div id="envTab" class="tab-content"></div>
|
||||
<div id="settingsTab" class="tab-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/chart.min.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/projects.js"></script>
|
||||
<script src="/js/deployment.js"></script>
|
||||
<script src="/js/logs.js"></script>
|
||||
<script src="/js/analytics.js"></script>
|
||||
<script src="/js/envVars.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
208
dashboard/js/analytics.js
Normal file
208
dashboard/js/analytics.js
Normal file
@@ -0,0 +1,208 @@
|
||||
function renderAnalyticsTab() {
|
||||
const tab = document.getElementById('analyticsTab');
|
||||
|
||||
tab.innerHTML = `
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Requests</div>
|
||||
<div class="stat-value">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Unique Visitors</div>
|
||||
<div class="stat-value">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Avg Response Time</div>
|
||||
<div class="stat-value">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h4 class="chart-title">Requests Over Time</h4>
|
||||
<canvas id="requestsChart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h4 class="chart-title">Resource Usage</h4>
|
||||
<canvas id="resourceChart"></canvas>
|
||||
</div>
|
||||
`;
|
||||
|
||||
loadAnalytics();
|
||||
}
|
||||
|
||||
async function loadAnalytics() {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const analytics = await api.get(`/api/projects/${currentProject.id}/analytics?range=24h`);
|
||||
|
||||
document.querySelector('.stats-grid').innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Requests</div>
|
||||
<div class="stat-value">${analytics.summary.totalRequests}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Unique Visitors</div>
|
||||
<div class="stat-value">${analytics.summary.uniqueVisitors}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Avg Response Time</div>
|
||||
<div class="stat-value">${analytics.summary.avgResponseTime}ms</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderRequestsChart(analytics.requestsByHour);
|
||||
renderResourceChart(analytics.resourceUsage);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics:', error);
|
||||
showNotification('Failed to load analytics', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderRequestsChart(data) {
|
||||
const ctx = document.getElementById('requestsChart');
|
||||
if (!ctx) return;
|
||||
|
||||
if (data.length === 0) {
|
||||
ctx.parentElement.innerHTML += '<p style="text-align: center; color: var(--text-secondary); padding: 32px;">No data available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = data.map(d => {
|
||||
const date = new Date(d.hour);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
});
|
||||
|
||||
const chartData = data.map(d => d.count);
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Requests',
|
||||
data: chartData,
|
||||
borderColor: 'rgb(255, 107, 53)',
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a0a0a0'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a0a0a0'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderResourceChart(data) {
|
||||
const ctx = document.getElementById('resourceChart');
|
||||
if (!ctx) return;
|
||||
|
||||
if (data.length === 0) {
|
||||
ctx.parentElement.innerHTML += '<p style="text-align: center; color: var(--text-secondary); padding: 32px;">No data available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = data.map(d => {
|
||||
const date = new Date(d.timestamp);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
});
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU %',
|
||||
data: data.map(d => d.cpu),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
yAxisID: 'y',
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Memory MB',
|
||||
data: data.map(d => d.memory),
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
yAxisID: 'y1',
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#a0a0a0'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a0a0a0'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#a0a0a0'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a0a0a0'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
81
dashboard/js/api.js
Normal file
81
dashboard/js/api.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const API_BASE = '';
|
||||
|
||||
const api = {
|
||||
async get(endpoint) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async post(endpoint, data) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async put(endpoint, data) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete(endpoint) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const user = await api.get('/auth/user');
|
||||
if (!user.authenticated) {
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
return user;
|
||||
} catch (error) {
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.get('/auth/logout');
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
|
||||
if (window.location.pathname === '/' || window.location.pathname === '/index.html') {
|
||||
checkAuth().then(user => {
|
||||
document.getElementById('username').textContent = user.user.username;
|
||||
});
|
||||
}
|
||||
157
dashboard/js/deployment.js
Normal file
157
dashboard/js/deployment.js
Normal file
@@ -0,0 +1,157 @@
|
||||
let currentProject = null;
|
||||
|
||||
async function openProjectDetail(projectId) {
|
||||
try {
|
||||
currentProject = await api.get(`/api/projects/${projectId}`);
|
||||
document.getElementById('projectDetailTitle').textContent = currentProject.name;
|
||||
document.getElementById('projectDetailModal').classList.add('active');
|
||||
|
||||
switchTab('overview');
|
||||
renderOverviewTab();
|
||||
} catch (error) {
|
||||
console.error('Error loading project:', error);
|
||||
showNotification('Failed to load project details', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectDetail() {
|
||||
document.getElementById('projectDetailModal').classList.remove('active');
|
||||
currentProject = null;
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
event?.target.classList.add('active');
|
||||
document.getElementById(`${tabName}Tab`).classList.add('active');
|
||||
|
||||
if (tabName === 'overview') {
|
||||
renderOverviewTab();
|
||||
} else if (tabName === 'logs') {
|
||||
renderLogsTab();
|
||||
} else if (tabName === 'analytics') {
|
||||
renderAnalyticsTab();
|
||||
} else if (tabName === 'env') {
|
||||
renderEnvTab();
|
||||
} else if (tabName === 'settings') {
|
||||
renderSettingsTab();
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverviewTab() {
|
||||
const latestDeployment = currentProject.deployments?.[0];
|
||||
const tab = document.getElementById('overviewTab');
|
||||
|
||||
tab.innerHTML = `
|
||||
<div class="overview-section">
|
||||
<div class="overview-grid">
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">Status</div>
|
||||
<div class="overview-value">${getStatusBadge(latestDeployment?.status || 'inactive')}</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">URL</div>
|
||||
<div class="overview-value">
|
||||
<a href="http://${currentProject.subdomain}.localhost" target="_blank" style="color: var(--accent-primary)">
|
||||
${currentProject.subdomain}.localhost
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">Repository</div>
|
||||
<div class="overview-value">${escapeHtml(currentProject.github_repo_name)}</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">Branch</div>
|
||||
<div class="overview-value">${escapeHtml(currentProject.github_branch)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h4>Deployments</h4>
|
||||
<button class="btn btn-primary" onclick="deployProject()">Deploy Now</button>
|
||||
</div>
|
||||
|
||||
<div class="deployment-history">
|
||||
${renderDeployments(currentProject.deployments || [])}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDeployments(deployments) {
|
||||
if (deployments.length === 0) {
|
||||
return '<div class="empty-state"><p>No deployments yet</p></div>';
|
||||
}
|
||||
|
||||
return deployments.map(deployment => `
|
||||
<div class="deployment-item">
|
||||
<div class="deployment-info">
|
||||
<div>
|
||||
${getStatusBadge(deployment.status)}
|
||||
<span style="margin-left: 12px; color: var(--text-secondary)">
|
||||
${formatDate(deployment.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="deployment-commit">
|
||||
Commit: ${truncateCommit(deployment.commit_sha)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="deployment-actions">
|
||||
${deployment.status === 'running' ? `
|
||||
<button class="btn btn-secondary btn-icon" onclick="stopDeployment(${deployment.id})" title="Stop">
|
||||
⏹
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn btn-secondary btn-icon" onclick="viewDeploymentLogs(${deployment.id})" title="View Logs">
|
||||
📄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function deployProject() {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
showNotification('Starting deployment...', 'info');
|
||||
await api.post(`/api/projects/${currentProject.id}/deploy`, {});
|
||||
showNotification('Deployment started successfully', 'success');
|
||||
|
||||
setTimeout(async () => {
|
||||
currentProject = await api.get(`/api/projects/${currentProject.id}`);
|
||||
renderOverviewTab();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error deploying project:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDeployment(deploymentId) {
|
||||
if (!confirm('Are you sure you want to stop this deployment?')) return;
|
||||
|
||||
try {
|
||||
await api.post(`/api/deployments/${deploymentId}/stop`, {});
|
||||
showNotification('Deployment stopped', 'success');
|
||||
|
||||
currentProject = await api.get(`/api/projects/${currentProject.id}`);
|
||||
renderOverviewTab();
|
||||
} catch (error) {
|
||||
console.error('Error stopping deployment:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function viewDeploymentLogs(deploymentId) {
|
||||
switchTab('logs');
|
||||
loadLogsForDeployment(deploymentId);
|
||||
}
|
||||
277
dashboard/js/envVars.js
Normal file
277
dashboard/js/envVars.js
Normal file
@@ -0,0 +1,277 @@
|
||||
let currentEnvVars = [];
|
||||
|
||||
function renderEnvTab() {
|
||||
const tab = document.getElementById('envTab');
|
||||
|
||||
tab.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
||||
<div>
|
||||
<h4>Environment Variables</h4>
|
||||
<p style="color: var(--text-secondary); margin-top: 4px;">Manage environment variables for your application</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="showAddEnvModal()">Add Variable</button>
|
||||
</div>
|
||||
|
||||
<div id="envVarsList">
|
||||
<div class="loading">Loading environment variables...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
loadEnvVars();
|
||||
}
|
||||
|
||||
async function loadEnvVars() {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const envVars = await api.get(`/api/projects/${currentProject.id}/env`);
|
||||
currentEnvVars = envVars;
|
||||
renderEnvVars(envVars);
|
||||
} catch (error) {
|
||||
console.error('Error loading environment variables:', error);
|
||||
document.getElementById('envVarsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>Error loading environment variables</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEnvVars(envVars) {
|
||||
const container = document.getElementById('envVarsList');
|
||||
|
||||
if (envVars.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>No environment variables configured</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="env-var-list">
|
||||
${envVars.map(envVar => `
|
||||
<div class="env-var-item">
|
||||
<div>
|
||||
<div class="env-var-key">${escapeHtml(envVar.key)}</div>
|
||||
<div class="env-var-value">${maskValue(envVar.value)}</div>
|
||||
</div>
|
||||
<div class="env-var-actions">
|
||||
<button class="btn btn-secondary btn-icon" onclick='editEnvVar(${JSON.stringify(envVar)})' title="Edit">
|
||||
✏
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-icon" onclick="deleteEnvVar('${escapeHtml(envVar.key)}')" title="Delete">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function maskValue(value) {
|
||||
if (!value) return '';
|
||||
if (value.length <= 4) return '****';
|
||||
return value.substring(0, 2) + '****' + value.substring(value.length - 2);
|
||||
}
|
||||
|
||||
function showAddEnvModal() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
modal.id = 'envModal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" onclick="closeEnvModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Add Environment Variable</h3>
|
||||
<button class="modal-close" onclick="closeEnvModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="envForm" onsubmit="saveEnvVar(event)">
|
||||
<div class="form-group">
|
||||
<label>Key</label>
|
||||
<input type="text" id="envKey" class="form-control" required placeholder="DATABASE_URL">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Value</label>
|
||||
<input type="text" id="envValue" class="form-control" required placeholder="postgresql://...">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEnvModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Variable</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
function editEnvVar(envVar) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
modal.id = 'envModal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" onclick="closeEnvModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Environment Variable</h3>
|
||||
<button class="modal-close" onclick="closeEnvModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="envForm" onsubmit="updateEnvVar(event, '${escapeHtml(envVar.key)}')">
|
||||
<div class="form-group">
|
||||
<label>Key</label>
|
||||
<input type="text" id="envKey" class="form-control" value="${escapeHtml(envVar.key)}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Value</label>
|
||||
<input type="text" id="envValue" class="form-control" value="${escapeHtml(envVar.value)}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEnvModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Update Variable</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
function closeEnvModal() {
|
||||
const modal = document.getElementById('envModal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEnvVar(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const key = document.getElementById('envKey').value;
|
||||
const value = document.getElementById('envValue').value;
|
||||
|
||||
try {
|
||||
await api.post(`/api/projects/${currentProject.id}/env`, { key, value });
|
||||
showNotification('Environment variable added', 'success');
|
||||
closeEnvModal();
|
||||
loadEnvVars();
|
||||
} catch (error) {
|
||||
console.error('Error saving environment variable:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEnvVar(event, key) {
|
||||
event.preventDefault();
|
||||
|
||||
const value = document.getElementById('envValue').value;
|
||||
|
||||
try {
|
||||
await api.put(`/api/projects/${currentProject.id}/env/${key}`, { value });
|
||||
showNotification('Environment variable updated', 'success');
|
||||
closeEnvModal();
|
||||
loadEnvVars();
|
||||
} catch (error) {
|
||||
console.error('Error updating environment variable:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEnvVar(key) {
|
||||
if (!confirm(`Are you sure you want to delete ${key}?`)) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/projects/${currentProject.id}/env/${key}`);
|
||||
showNotification('Environment variable deleted', 'success');
|
||||
loadEnvVars();
|
||||
} catch (error) {
|
||||
console.error('Error deleting environment variable:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderSettingsTab() {
|
||||
const tab = document.getElementById('settingsTab');
|
||||
|
||||
tab.innerHTML = `
|
||||
<div class="overview-section">
|
||||
<h4>Project Settings</h4>
|
||||
<form id="settingsForm" onsubmit="updateProjectSettings(event)">
|
||||
<div class="form-group">
|
||||
<label>Project Name</label>
|
||||
<input type="text" id="settingsName" class="form-control" value="${escapeHtml(currentProject.name)}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Branch</label>
|
||||
<input type="text" id="settingsBranch" class="form-control" value="${escapeHtml(currentProject.github_branch)}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Port</label>
|
||||
<input type="number" id="settingsPort" class="form-control" value="${currentProject.port}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="justify-content: flex-start;">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="danger-zone">
|
||||
<h4>Danger Zone</h4>
|
||||
<p>Once you delete a project, there is no going back. Please be certain.</p>
|
||||
<button class="btn btn-danger" onclick="deleteProject()">Delete Project</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function updateProjectSettings(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('settingsName').value,
|
||||
github_branch: document.getElementById('settingsBranch').value,
|
||||
port: parseInt(document.getElementById('settingsPort').value)
|
||||
};
|
||||
|
||||
try {
|
||||
await api.put(`/api/projects/${currentProject.id}`, data);
|
||||
showNotification('Project settings updated', 'success');
|
||||
currentProject = await api.get(`/api/projects/${currentProject.id}`);
|
||||
document.getElementById('projectDetailTitle').textContent = currentProject.name;
|
||||
} catch (error) {
|
||||
console.error('Error updating project settings:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
if (!confirm(`Are you sure you want to delete ${currentProject.name}? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('This will stop all deployments and remove all data. Are you absolutely sure?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/api/projects/${currentProject.id}`);
|
||||
showNotification('Project deleted', 'success');
|
||||
closeProjectDetail();
|
||||
loadProjects();
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
130
dashboard/js/logs.js
Normal file
130
dashboard/js/logs.js
Normal file
@@ -0,0 +1,130 @@
|
||||
let logWebSocket = null;
|
||||
let currentDeploymentId = null;
|
||||
|
||||
function renderLogsTab() {
|
||||
const tab = document.getElementById('logsTab');
|
||||
|
||||
const latestDeployment = currentProject.deployments?.[0];
|
||||
|
||||
tab.innerHTML = `
|
||||
<div class="log-controls">
|
||||
<div class="log-filters">
|
||||
<select id="deploymentSelector" class="form-control" style="width: 200px;" onchange="changeLogDeployment()">
|
||||
${(currentProject.deployments || []).map(d => `
|
||||
<option value="${d.id}">
|
||||
${formatDate(d.created_at)} - ${d.status}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="log-search">
|
||||
<button class="btn btn-secondary btn-icon" onclick="clearLogs()" title="Clear">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-status">
|
||||
<div class="log-status-indicator"></div>
|
||||
<span>Live logs</span>
|
||||
</div>
|
||||
|
||||
<div class="log-viewer" id="logViewer">
|
||||
<div class="log-line log-info">Connecting to log stream...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (latestDeployment) {
|
||||
loadLogsForDeployment(latestDeployment.id);
|
||||
}
|
||||
}
|
||||
|
||||
function changeLogDeployment() {
|
||||
const deploymentId = document.getElementById('deploymentSelector').value;
|
||||
loadLogsForDeployment(parseInt(deploymentId));
|
||||
}
|
||||
|
||||
function loadLogsForDeployment(deploymentId) {
|
||||
currentDeploymentId = deploymentId;
|
||||
|
||||
if (logWebSocket) {
|
||||
logWebSocket.close();
|
||||
logWebSocket = null;
|
||||
}
|
||||
|
||||
const logViewer = document.getElementById('logViewer');
|
||||
if (logViewer) {
|
||||
logViewer.innerHTML = '<div class="log-line log-info">Connecting to log stream...</div>';
|
||||
}
|
||||
|
||||
connectLogWebSocket(deploymentId);
|
||||
}
|
||||
|
||||
function connectLogWebSocket(deploymentId) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
|
||||
|
||||
logWebSocket = new WebSocket(wsUrl);
|
||||
|
||||
logWebSocket.onopen = () => {
|
||||
logWebSocket.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
deploymentId: deploymentId
|
||||
}));
|
||||
};
|
||||
|
||||
logWebSocket.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'log') {
|
||||
appendLog(message.data, message.level || 'info', message.source);
|
||||
} else if (message.type === 'subscribed') {
|
||||
const logViewer = document.getElementById('logViewer');
|
||||
if (logViewer && logViewer.children.length === 1) {
|
||||
logViewer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logWebSocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
appendLog('Error connecting to log stream', 'error');
|
||||
};
|
||||
|
||||
logWebSocket.onclose = () => {
|
||||
console.log('WebSocket closed');
|
||||
};
|
||||
}
|
||||
|
||||
function appendLog(logLine, level = 'info', source = 'runtime') {
|
||||
const logViewer = document.getElementById('logViewer');
|
||||
if (!logViewer) return;
|
||||
|
||||
const logElement = document.createElement('div');
|
||||
logElement.className = `log-line log-${level}`;
|
||||
|
||||
const cleanedLog = logLine.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim();
|
||||
|
||||
logElement.textContent = cleanedLog;
|
||||
|
||||
logViewer.appendChild(logElement);
|
||||
|
||||
if (logViewer.children.length > 1000) {
|
||||
logViewer.removeChild(logViewer.firstChild);
|
||||
}
|
||||
|
||||
logViewer.scrollTop = logViewer.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
const logViewer = document.getElementById('logViewer');
|
||||
if (logViewer) {
|
||||
logViewer.innerHTML = '<div class="log-line log-info">Logs cleared</div>';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (logWebSocket) {
|
||||
logWebSocket.close();
|
||||
}
|
||||
});
|
||||
122
dashboard/js/projects.js
Normal file
122
dashboard/js/projects.js
Normal file
@@ -0,0 +1,122 @@
|
||||
let currentProjects = [];
|
||||
let repositories = [];
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const projects = await api.get('/api/projects');
|
||||
currentProjects = projects;
|
||||
renderProjects(projects);
|
||||
} catch (error) {
|
||||
console.error('Error loading projects:', error);
|
||||
document.getElementById('projectsGrid').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>Error loading projects</h3>
|
||||
<p>${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProjects(projects) {
|
||||
const grid = document.getElementById('projectsGrid');
|
||||
|
||||
if (projects.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create your first project to get started</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = projects.map(project => `
|
||||
<div class="project-card" onclick="openProjectDetail(${project.id})">
|
||||
<div class="project-card-header">
|
||||
<div>
|
||||
<h3 class="project-card-title">${escapeHtml(project.name)}</h3>
|
||||
<a href="http://${project.subdomain}.localhost" target="_blank" class="project-card-url" onclick="event.stopPropagation()">
|
||||
${project.subdomain}.localhost
|
||||
</a>
|
||||
</div>
|
||||
${getStatusBadge(project.latest_status || 'inactive')}
|
||||
</div>
|
||||
<div class="project-card-meta">
|
||||
<div class="project-card-meta-item">
|
||||
<span>Branch: ${escapeHtml(project.github_branch)}</span>
|
||||
</div>
|
||||
<div class="project-card-meta-item">
|
||||
<span>${project.deployment_count || 0} deployments</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function showNewProjectModal() {
|
||||
const modal = document.getElementById('newProjectModal');
|
||||
modal.classList.add('active');
|
||||
|
||||
try {
|
||||
repositories = await api.get('/api/projects/repositories');
|
||||
const select = document.getElementById('repoSelect');
|
||||
select.innerHTML = `
|
||||
<option value="">Select a repository...</option>
|
||||
${repositories.map(repo => `
|
||||
<option value="${repo.full_name}" data-url="${repo.clone_url}" data-branch="${repo.default_branch}">
|
||||
${repo.full_name}
|
||||
</option>
|
||||
`).join('')}
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error loading repositories:', error);
|
||||
showNotification('Failed to load repositories', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeNewProjectModal() {
|
||||
document.getElementById('newProjectModal').classList.remove('active');
|
||||
document.getElementById('newProjectForm').reset();
|
||||
}
|
||||
|
||||
document.getElementById('repoSelect')?.addEventListener('change', (e) => {
|
||||
const option = e.target.options[e.target.selectedIndex];
|
||||
const repoName = e.target.value.split('/')[1];
|
||||
|
||||
if (repoName) {
|
||||
const subdomain = repoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||
document.getElementById('projectName').value = repoName;
|
||||
document.getElementById('subdomain').value = subdomain;
|
||||
document.getElementById('branch').value = option.dataset.branch || 'main';
|
||||
}
|
||||
});
|
||||
|
||||
async function createProject(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const repoSelect = document.getElementById('repoSelect');
|
||||
const option = repoSelect.options[repoSelect.selectedIndex];
|
||||
|
||||
const projectData = {
|
||||
name: document.getElementById('projectName').value,
|
||||
subdomain: document.getElementById('subdomain').value,
|
||||
github_repo_url: option.dataset.url,
|
||||
github_repo_name: repoSelect.value,
|
||||
github_branch: document.getElementById('branch').value,
|
||||
port: parseInt(document.getElementById('port').value)
|
||||
};
|
||||
|
||||
try {
|
||||
await api.post('/api/projects', projectData);
|
||||
showNotification('Project created successfully', 'success');
|
||||
closeNewProjectModal();
|
||||
loadProjects();
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (window.location.pathname === '/' || window.location.pathname === '/index.html') {
|
||||
loadProjects();
|
||||
}
|
||||
55
dashboard/js/utils.js
Normal file
55
dashboard/js/utils.js
Normal file
@@ -0,0 +1,55 @@
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 7) {
|
||||
return date.toLocaleDateString();
|
||||
} else if (days > 0) {
|
||||
return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return 'Just now';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status) {
|
||||
const statusMap = {
|
||||
'running': 'badge-success',
|
||||
'building': 'badge-info',
|
||||
'failed': 'badge-error',
|
||||
'stopped': 'badge-warning',
|
||||
'pending': 'badge-info'
|
||||
};
|
||||
|
||||
const className = statusMap[status] || 'badge-info';
|
||||
return `<span class="badge ${className}">${status}</span>`;
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function truncateCommit(sha) {
|
||||
return sha ? sha.substring(0, 7) : 'unknown';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
52
dashboard/login.html
Normal file
52
dashboard/login.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - miniPaaS</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-container">
|
||||
<div class="login-content">
|
||||
<div class="login-header">
|
||||
<h1>miniPaaS</h1>
|
||||
<p>Deploy your applications with ease</p>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<h2>Get Started</h2>
|
||||
<p class="login-description">
|
||||
Connect your GitHub account to start deploying applications to your local PaaS platform.
|
||||
</p>
|
||||
|
||||
<a href="/auth/github" class="btn btn-primary btn-large">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
|
||||
</svg>
|
||||
Connect with GitHub
|
||||
</a>
|
||||
|
||||
<div class="login-features">
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<span>Automatic builds from GitHub</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<span>Real-time logs and analytics</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✓</div>
|
||||
<span>Environment variable management</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>Self-hosted Platform as a Service</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
95
database/init.sql
Normal file
95
database/init.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- miniPaaS Database Schema
|
||||
-- PostgreSQL 15+
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
github_id INTEGER UNIQUE,
|
||||
github_username VARCHAR(255),
|
||||
github_access_token TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Projects table
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
subdomain VARCHAR(255) UNIQUE NOT NULL,
|
||||
github_repo_url TEXT NOT NULL,
|
||||
github_repo_name VARCHAR(255),
|
||||
github_branch VARCHAR(255) DEFAULT 'main',
|
||||
github_access_token TEXT,
|
||||
port INTEGER DEFAULT 3000,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Deployments table
|
||||
CREATE TABLE IF NOT EXISTS deployments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||
commit_sha VARCHAR(40),
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
docker_image_id VARCHAR(255),
|
||||
docker_container_id VARCHAR(255),
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deployments_project ON deployments(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deployments_status ON deployments(status);
|
||||
|
||||
-- Environment variables table
|
||||
CREATE TABLE IF NOT EXISTS env_vars (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||
key VARCHAR(255) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
is_suggested BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(project_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_env_vars_project ON env_vars(project_id);
|
||||
|
||||
-- Build logs table
|
||||
CREATE TABLE IF NOT EXISTS build_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
deployment_id INTEGER REFERENCES deployments(id) ON DELETE CASCADE,
|
||||
log_line TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_build_logs_deployment ON build_logs(deployment_id, timestamp);
|
||||
|
||||
-- Runtime logs table
|
||||
CREATE TABLE IF NOT EXISTS runtime_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
deployment_id INTEGER REFERENCES deployments(id) ON DELETE CASCADE,
|
||||
log_line TEXT NOT NULL,
|
||||
log_level VARCHAR(20) DEFAULT 'info',
|
||||
timestamp TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_logs_deployment ON runtime_logs(deployment_id, timestamp);
|
||||
|
||||
-- Analytics events table
|
||||
CREATE TABLE IF NOT EXISTS analytics_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_project_time ON analytics_events(project_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_type ON analytics_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_data ON analytics_events USING GIN(data);
|
||||
|
||||
-- Create default admin user (for single-user setup)
|
||||
INSERT INTO users (github_id, github_username, github_access_token)
|
||||
VALUES (0, 'admin', 'placeholder')
|
||||
ON CONFLICT (github_id) DO NOTHING;
|
||||
84
docker-compose.yml
Normal file
84
docker-compose.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
command:
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=paas_network"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--accesslog=true"
|
||||
- "--accesslog.filepath=/var/log/traefik/access.log"
|
||||
- "--accesslog.format=json"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
- ./traefik/logs:/var/log/traefik
|
||||
networks:
|
||||
- paas_network
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-paasuser}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-paaspass}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-paasdb}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
networks:
|
||||
- paas_network
|
||||
ports:
|
||||
- "5432:5432"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U paasuser -d paasdb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
control-plane:
|
||||
build: ./control-plane
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://paasuser:paaspass@postgres:5432/paasdb}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-default_secret_change_me}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change_me_to_32_character_key}
|
||||
DOCKER_SOCKET: /var/run/docker.sock
|
||||
NODE_ENV: development
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./control-plane:/app
|
||||
- /app/node_modules
|
||||
- build_cache:/tmp/builds
|
||||
- ./dashboard:/app/public
|
||||
networks:
|
||||
- paas_network
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.dashboard.rule=Host(`localhost`)"
|
||||
- "traefik.http.routers.dashboard.entrypoints=web"
|
||||
- "traefik.http.services.dashboard.loadbalancer.server.port=3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
traefik:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
paas_network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
build_cache:
|
||||
21
traefik/traefik.yml
Normal file
21
traefik/traefik.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
api:
|
||||
dashboard: true
|
||||
insecure: true
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
|
||||
providers:
|
||||
docker:
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
exposedByDefault: false
|
||||
network: "paas_network"
|
||||
|
||||
accessLog:
|
||||
filePath: "/var/log/traefik/access.log"
|
||||
format: json
|
||||
fields:
|
||||
defaultMode: keep
|
||||
headers:
|
||||
defaultMode: keep
|
||||
Reference in New Issue
Block a user