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:
2025-11-11 18:49:02 +00:00
parent d93d701d43
commit f5ac1005e2
43 changed files with 4491 additions and 0 deletions

8
.env.example Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# This file ensures the directory is tracked by git

21
control-plane/Dockerfile Normal file
View 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"]

View 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
};

View 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;

View 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;

View 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
};

View 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;

View 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"
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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);
});
});

View 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();

View 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
};

View 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
};

View 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
};

View 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
};

View 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();

View 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
};

View 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
};

View 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

File diff suppressed because one or more lines are too long

318
dashboard/css/dashboard.css Normal file
View 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
View 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
View 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
View 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()">&times;</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()">&times;</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
View 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
View 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
View 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
View 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()">&times;</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()">&times;</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
View 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
View 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
View 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
View 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">&#x2713;</div>
<span>Automatic builds from GitHub</span>
</div>
<div class="feature">
<div class="feature-icon">&#x2713;</div>
<span>Real-time logs and analytics</span>
</div>
<div class="feature">
<div class="feature-icon">&#x2713;</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
View 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
View 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
View 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