feat: add Docker and Gitea services, monitoring, queue, and Telegram notification functionalities
- Implemented Docker operations including image building, container management, and resource stats. - Added Gitea API client for repository management and webhook handling. - Introduced monitoring service to collect and store container metrics in InfluxDB. - Created a queue system using BullMQ for managing deployment jobs with real-time log streaming. - Developed Telegram notification service for deployment status updates. - Added Traefik label generation for dynamic reverse proxy configuration. - Implemented WebSocket endpoints for log streaming and terminal access to containers. - Created an updater sidecar for self-updating the AutoDeployer container.
This commit is contained in:
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl -sL \"https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js\" -o chart.min.js)",
|
||||
"Bash(curl -sL \"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css\" -o xterm.min.css)",
|
||||
"Bash(curl -sL \"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js\" -o xterm.min.js)",
|
||||
"Bash(curl -sL \"https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js\" -o xterm-addon-fit.min.js)"
|
||||
]
|
||||
}
|
||||
}
|
||||
43
.env.example
Normal file
43
.env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
# ===========================================
|
||||
# AutoDeployer Configuration
|
||||
# ===========================================
|
||||
|
||||
# JWT secret for authentication (generate with: openssl rand -hex 64)
|
||||
JWT_SECRET=change-me-to-a-random-64-char-hex-string
|
||||
|
||||
# Admin password (will be set on first login via setup wizard)
|
||||
# Leave empty on first run — the app will prompt you to create it
|
||||
ADMIN_PASSWORD_HASH=
|
||||
|
||||
# Webhook secret for Gitea webhook validation
|
||||
WEBHOOK_SECRET=change-me-to-a-random-string
|
||||
|
||||
# ===========================================
|
||||
# Gitea Configuration
|
||||
# ===========================================
|
||||
GITEA_URL=http://gitea:3000
|
||||
GITEA_TOKEN=your-gitea-api-token
|
||||
|
||||
# ===========================================
|
||||
# Redis (existing instance on meb-private)
|
||||
# ===========================================
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
|
||||
# ===========================================
|
||||
# Telegram Notifications
|
||||
# ===========================================
|
||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||
TELEGRAM_CHAT_ID=your-chat-id
|
||||
|
||||
# ===========================================
|
||||
# InfluxDB (existing instance for monitoring)
|
||||
# ===========================================
|
||||
INFLUXDB_URL=http://influxdb:8086
|
||||
INFLUXDB_TOKEN=your-influxdb-token
|
||||
INFLUXDB_ORG=autodeployer
|
||||
INFLUXDB_BUCKET=metrics
|
||||
|
||||
# ===========================================
|
||||
# Domain
|
||||
# ===========================================
|
||||
DEPLOY_DOMAIN=deploy.example.com
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
server/data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# ============================================
|
||||
# AutoDeployer — No build step, static dashboard
|
||||
# ============================================
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install build dependencies for native modules (better-sqlite3, node-pty)
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
git \
|
||||
docker-cli
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install server dependencies
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci --production && npm cache clean --force
|
||||
|
||||
# Copy server source
|
||||
COPY server/src ./src
|
||||
|
||||
# Copy static dashboard (no build step needed)
|
||||
COPY dashboard/index.html ./public/index.html
|
||||
COPY dashboard/css ./public/css
|
||||
COPY dashboard/js ./public/js
|
||||
COPY dashboard/lib ./public/lib
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data /tmp/builds
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# AutoDeployer
|
||||
|
||||
Piattaforma di deployment self-hosted per gestire i servizi Docker con integrazione Gitea, Traefik, e monitoring in tempo reale.
|
||||
|
||||
## Feature
|
||||
|
||||
- 🔗 **Integrazione Gitea** — ogni repo = un servizio, deploy automatico via webhook
|
||||
- 🚀 **Auto-Deploy** — webhook push → build → deploy automatico
|
||||
- 🐳 **Dockerfile Build** — supporto per path e context personalizzati
|
||||
- 🏷️ **Traefik Labels** — gestione visuale con preview dei label
|
||||
- 📋 **Log Viewer** — streaming real-time via WebSocket
|
||||
- 🔐 **Variabili d'Ambiente** — build-time e runtime, con supporto secret
|
||||
- ⚡ **Zero-Downtime Deploy** — health check + switch traffico senza interruzioni
|
||||
- 🔔 **Notifiche Telegram** — notifiche deploy su Telegram
|
||||
- 📊 **Monitoring** — CPU, RAM, Network in tempo reale con storico su InfluxDB
|
||||
- 💻 **Web Terminal** — shell interattiva nei container dal browser
|
||||
- 🛡️ **Docker Networks** — gestione reti Docker dall'interfaccia
|
||||
|
||||
## Setup Rapido
|
||||
|
||||
```bash
|
||||
# 1. Copia .env
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Configura le variabili in .env
|
||||
# - JWT_SECRET (genera con: openssl rand -hex 64)
|
||||
# - WEBHOOK_SECRET
|
||||
# - GITEA_TOKEN
|
||||
# - TELEGRAM_BOT_TOKEN e TELEGRAM_CHAT_ID
|
||||
# - INFLUXDB_TOKEN
|
||||
# - DEPLOY_DOMAIN
|
||||
|
||||
# 3. Build e avvia
|
||||
docker compose up -d --build
|
||||
|
||||
# 4. Apri https://deploy.tuodominio.com
|
||||
# Al primo accesso, crea l'account admin
|
||||
```
|
||||
|
||||
## Architettura
|
||||
|
||||
- **Backend**: Node.js + Express + SQLite + BullMQ
|
||||
- **Frontend**: React + Vite (servito dallo stesso container)
|
||||
- **Build Queue**: BullMQ su Redis esistente (DB 2)
|
||||
- **Monitoring**: InfluxDB per metriche storiche
|
||||
- **Container**: Singolo container con accesso al Docker socket
|
||||
|
||||
## Reti Docker
|
||||
|
||||
- `meb-public` — Traefik, Gitea, AutoDeployer
|
||||
- `meb-private` — Redis, PostgreSQL, InfluxDB, servizi interni
|
||||
|
||||
## Webhook
|
||||
|
||||
Ogni servizio genera un URL webhook univoco. Configura il webhook nel repository Gitea:
|
||||
|
||||
1. Gitea → Settings → Webhooks → Add Webhook
|
||||
2. Target URL: `http://autodeployer:3000/api/webhooks/{webhook-id}`
|
||||
3. Secret: il `WEBHOOK_SECRET` del `.env`
|
||||
4. Trigger: Push Events
|
||||
|
||||
> **Nota**: Usa l'URL interno Docker (`http://autodeployer:3000`) per evitare
|
||||
> il passaggio da Cloudflare.
|
||||
392
dashboard/css/style.css
Normal file
392
dashboard/css/style.css
Normal file
@@ -0,0 +1,392 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
AutoDeployer — Design System
|
||||
Premium dark theme with glassmorphism
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── CSS Variables ──────────────────────────────────────── */
|
||||
:root {
|
||||
--bg-primary: #0a0e1a;
|
||||
--bg-secondary: #111827;
|
||||
--bg-card: rgba(17, 24, 39, 0.7);
|
||||
--bg-card-hover: rgba(30, 41, 59, 0.8);
|
||||
--bg-glass: rgba(255, 255, 255, 0.03);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-input: rgba(255, 255, 255, 0.05);
|
||||
--bg-input-focus: rgba(255, 255, 255, 0.08);
|
||||
--border-primary: rgba(255, 255, 255, 0.06);
|
||||
--border-hover: rgba(255, 255, 255, 0.12);
|
||||
--border-focus: rgba(99, 102, 241, 0.5);
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-tertiary: #64748b;
|
||||
--text-muted: #475569;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--accent-muted: rgba(99, 102, 241, 0.15);
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--status-running: #10b981;
|
||||
--status-running-bg: rgba(16, 185, 129, 0.12);
|
||||
--status-building: #f59e0b;
|
||||
--status-building-bg: rgba(245, 158, 11, 0.12);
|
||||
--status-error: #ef4444;
|
||||
--status-error-bg: rgba(239, 68, 68, 0.12);
|
||||
--status-stopped: #64748b;
|
||||
--status-stopped-bg: rgba(100, 116, 139, 0.12);
|
||||
--status-queued: #8b5cf6;
|
||||
--status-queued-bg: rgba(139, 92, 246, 0.12);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 20px var(--accent-glow);
|
||||
--sidebar-width: 260px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { font-size: 14px; -webkit-font-smoothing: antialiased; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, rgba(99, 102, 241, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(139, 92, 246, 0.03) 0%, transparent 50%);
|
||||
pointer-events: none; z-index: 0;
|
||||
}
|
||||
#app { position: relative; z-index: 1; }
|
||||
a { color: var(--accent); text-decoration: none; transition: color var(--transition-fast); }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
|
||||
|
||||
/* ─── Layout ─────────────────────────────────────────────── */
|
||||
.app-layout { display: flex; min-height: 100vh; }
|
||||
.main-content {
|
||||
flex: 1; margin-left: var(--sidebar-width);
|
||||
padding: 32px; max-width: calc(100vw - var(--sidebar-width)); min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ─── Sidebar ────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; width: var(--sidebar-width); height: 100vh;
|
||||
background: rgba(17, 24, 39, 0.85); backdrop-filter: blur(20px);
|
||||
border-right: 1px solid var(--border-primary);
|
||||
display: flex; flex-direction: column; z-index: 100; overflow-y: auto;
|
||||
}
|
||||
.sidebar-brand {
|
||||
padding: 24px 20px; border-bottom: 1px solid var(--border-primary);
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.sidebar-brand-icon {
|
||||
width: 36px; height: 36px;
|
||||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; box-shadow: var(--shadow-glow); color: white;
|
||||
}
|
||||
.sidebar-brand h1 {
|
||||
font-size: 1.15rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--text-primary), var(--accent-hover));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.sidebar-nav { flex: 1; padding: 16px 12px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.sidebar-section {
|
||||
margin-top: 20px; margin-bottom: 8px; padding: 0 8px;
|
||||
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; color: var(--text-tertiary);
|
||||
}
|
||||
.sidebar-link {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary); font-size: 0.9rem; font-weight: 500;
|
||||
transition: all var(--transition-fast); cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.sidebar-link:hover { background: var(--bg-glass-hover); color: var(--text-primary); }
|
||||
.sidebar-link.active { background: var(--accent-muted); color: var(--accent-hover); border-color: rgba(99, 102, 241, 0.2); }
|
||||
.sidebar-link svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
.sidebar-footer { padding: 16px; border-top: 1px solid var(--border-primary); }
|
||||
|
||||
/* ─── Page Header ────────────────────────────────────────── */
|
||||
.page-header {
|
||||
margin-bottom: 28px; display: flex; align-items: center;
|
||||
justify-content: space-between; flex-wrap: wrap; gap: 16px;
|
||||
}
|
||||
.page-header h2 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.page-header p { color: var(--text-secondary); font-size: 0.9rem; margin-top: 4px; }
|
||||
|
||||
/* ─── Cards ──────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bg-card); backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border-primary); border-radius: var(--radius-lg);
|
||||
padding: 24px; transition: all var(--transition-normal);
|
||||
}
|
||||
.card:hover { border-color: var(--border-hover); box-shadow: var(--shadow-md); }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.card-title { font-size: 1rem; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
|
||||
|
||||
/* ─── Status Badges ──────────────────────────────────────── */
|
||||
.status-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 100px;
|
||||
font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
}
|
||||
.status-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-badge.running { background: var(--status-running-bg); color: var(--status-running); }
|
||||
.status-badge.running::before { background: var(--status-running); box-shadow: 0 0 6px var(--status-running); }
|
||||
.status-badge.building { background: var(--status-building-bg); color: var(--status-building); animation: pulse-badge 2s ease-in-out infinite; }
|
||||
.status-badge.building::before { background: var(--status-building); }
|
||||
.status-badge.error, .status-badge.failed { background: var(--status-error-bg); color: var(--status-error); }
|
||||
.status-badge.error::before, .status-badge.failed::before { background: var(--status-error); }
|
||||
.status-badge.stopped { background: var(--status-stopped-bg); color: var(--status-stopped); }
|
||||
.status-badge.stopped::before { background: var(--status-stopped); }
|
||||
.status-badge.queued { background: var(--status-queued-bg); color: var(--status-queued); }
|
||||
.status-badge.queued::before { background: var(--status-queued); animation: pulse-dot 1.5s infinite; }
|
||||
.status-badge.success { background: var(--status-running-bg); color: var(--status-running); }
|
||||
.status-badge.success::before { background: var(--status-running); }
|
||||
@keyframes pulse-badge { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
|
||||
@keyframes pulse-dot { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: 0.5; } }
|
||||
|
||||
/* ─── Buttons ────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 9px 18px; border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem; font-weight: 600; font-family: var(--font-sans);
|
||||
cursor: pointer; border: 1px solid transparent;
|
||||
transition: all var(--transition-fast); white-space: nowrap;
|
||||
}
|
||||
.btn svg { width: 16px; height: 16px; }
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), #7c3aed); color: white;
|
||||
border-color: rgba(99, 102, 241, 0.3); box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-primary:hover { background: linear-gradient(135deg, var(--accent-hover), #8b5cf6); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35); transform: translateY(-1px); }
|
||||
.btn-secondary { background: var(--bg-glass); color: var(--text-secondary); border-color: var(--border-primary); }
|
||||
.btn-secondary:hover { background: var(--bg-glass-hover); color: var(--text-primary); border-color: var(--border-hover); }
|
||||
.btn-danger { background: rgba(239, 68, 68, 0.1); color: var(--status-error); border-color: rgba(239, 68, 68, 0.2); }
|
||||
.btn-danger:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.4); }
|
||||
.btn-sm { padding: 5px 12px; font-size: 0.78rem; }
|
||||
.btn-icon { padding: 8px; border-radius: var(--radius-sm); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; }
|
||||
|
||||
/* ─── Forms ──────────────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-label {
|
||||
display: block; font-size: 0.8rem; font-weight: 600; color: var(--text-secondary);
|
||||
margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.form-input, .form-select, textarea.form-input {
|
||||
width: 100%; padding: 10px 14px; background: var(--bg-input);
|
||||
border: 1px solid var(--border-primary); border-radius: var(--radius-sm);
|
||||
color: var(--text-primary); font-family: var(--font-sans); font-size: 0.9rem;
|
||||
transition: all var(--transition-fast); outline: none;
|
||||
}
|
||||
.form-input:focus, .form-select:focus, textarea.form-input:focus {
|
||||
background: var(--bg-input-focus); border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-muted);
|
||||
}
|
||||
.form-input::placeholder { color: var(--text-muted); }
|
||||
.form-input.mono { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||
textarea.form-input { min-height: 100px; resize: vertical; font-family: var(--font-mono); font-size: 0.85rem; }
|
||||
.form-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2394a3b8' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 12px center; padding-right: 36px;
|
||||
}
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-hint { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 4px; }
|
||||
.form-checkbox { display: flex; align-items: center; gap: 10px; cursor: pointer; }
|
||||
.form-checkbox input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
|
||||
|
||||
/* ─── Toggle ──────────────────────────────────────────── */
|
||||
.toggle { position: relative; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; }
|
||||
.toggle input { display: none; }
|
||||
.toggle-track {
|
||||
width: 40px; height: 22px; background: var(--bg-input);
|
||||
border: 1px solid var(--border-primary); border-radius: 11px;
|
||||
position: relative; transition: all var(--transition-fast);
|
||||
}
|
||||
.toggle-track::after {
|
||||
content: ''; position: absolute; width: 16px; height: 16px;
|
||||
background: var(--text-tertiary); border-radius: 50%; top: 2px; left: 2px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.toggle input:checked + .toggle-track { background: var(--accent-muted); border-color: var(--accent); }
|
||||
.toggle input:checked + .toggle-track::after { background: var(--accent); left: 20px; }
|
||||
|
||||
/* ─── Tables ─────────────────────────────────────────────── */
|
||||
.table-container { overflow-x: auto; border: 1px solid var(--border-primary); border-radius: var(--radius-md); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px 16px; text-align: left; }
|
||||
th { background: var(--bg-glass); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-tertiary); border-bottom: 1px solid var(--border-primary); }
|
||||
td { border-bottom: 1px solid var(--border-primary); font-size: 0.85rem; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--bg-glass); }
|
||||
|
||||
/* ─── Tabs ───────────────────────────────────────────────── */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border-primary); margin-bottom: 24px; overflow-x: auto; }
|
||||
.tab {
|
||||
padding: 12px 20px; font-size: 0.85rem; font-weight: 500;
|
||||
color: var(--text-tertiary); cursor: pointer;
|
||||
border-bottom: 2px solid transparent; transition: all var(--transition-fast);
|
||||
white-space: nowrap; background: none; border-top: none; border-left: none;
|
||||
border-right: none; font-family: var(--font-sans);
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active { color: var(--accent-hover); border-bottom-color: var(--accent); }
|
||||
|
||||
/* ─── Log Viewer ─────────────────────────────────────────── */
|
||||
.log-viewer {
|
||||
background: #0d1117; border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md); font-family: var(--font-mono);
|
||||
font-size: 0.8rem; line-height: 1.7; overflow-y: auto;
|
||||
max-height: 500px; padding: 16px;
|
||||
}
|
||||
.log-line { white-space: pre-wrap; word-break: break-all; color: var(--text-secondary); }
|
||||
.log-line.error { color: var(--status-error); }
|
||||
.log-line.info { color: var(--accent); }
|
||||
|
||||
/* ─── Modal ──────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px); z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn var(--transition-fast) ease-out;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-secondary); border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl); padding: 32px;
|
||||
max-width: 600px; width: 90%; max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg); animation: slideUp var(--transition-normal) ease-out;
|
||||
}
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
|
||||
.modal-title { font-size: 1.2rem; font-weight: 700; }
|
||||
.hidden { display: none !important; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
|
||||
/* ─── Empty State ────────────────────────────────────────── */
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-tertiary); }
|
||||
.empty-state h3 { font-size: 1.1rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; }
|
||||
.empty-state p { font-size: 0.9rem; max-width: 400px; margin: 0 auto 20px; }
|
||||
|
||||
/* ─── Login ─────────────────────────────────────────────── */
|
||||
.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-primary); }
|
||||
.login-card {
|
||||
background: var(--bg-card); backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-primary); border-radius: var(--radius-xl);
|
||||
padding: 40px; width: 90%; max-width: 420px; box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.login-brand { text-align: center; margin-bottom: 32px; }
|
||||
.login-brand-icon {
|
||||
width: 56px; height: 56px;
|
||||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||
border-radius: var(--radius-lg); display: flex; align-items: center;
|
||||
justify-content: center; font-size: 28px; margin: 0 auto 16px;
|
||||
box-shadow: var(--shadow-glow); color: white;
|
||||
}
|
||||
.login-brand h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 4px; }
|
||||
.login-brand p { color: var(--text-tertiary); font-size: 0.85rem; }
|
||||
.login-error {
|
||||
background: var(--status-error-bg); color: var(--status-error);
|
||||
padding: 10px 14px; border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem; margin-bottom: 16px; border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* ─── Service Card ───────────────────────────────────────── */
|
||||
.service-card { position: relative; overflow: hidden; cursor: pointer; }
|
||||
.service-card::before {
|
||||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
opacity: 0; transition: opacity var(--transition-normal);
|
||||
}
|
||||
.service-card:hover::before { opacity: 1; }
|
||||
.service-card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; }
|
||||
.service-card-name { font-size: 1.05rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
||||
.service-card-repo { font-family: var(--font-mono); font-size: 0.78rem; color: var(--text-tertiary); margin-top: 4px; display: flex; align-items: center; gap: 6px; }
|
||||
.service-card-meta { display: flex; gap: 16px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-primary); }
|
||||
.service-card-meta-item { font-size: 0.78rem; color: var(--text-tertiary); display: flex; align-items: center; gap: 4px; }
|
||||
.service-card-actions { display: flex; gap: 6px; margin-top: 14px; }
|
||||
|
||||
/* ─── Monitoring ─────────────────────────────────────────── */
|
||||
.monitoring-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
|
||||
.metric-value {
|
||||
font-size: 2rem; font-weight: 800; letter-spacing: -0.03em;
|
||||
background: linear-gradient(135deg, var(--text-primary), var(--accent-hover));
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.metric-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-tertiary); margin-top: 2px; }
|
||||
|
||||
/* ─── Terminal ───────────────────────────────────────────── */
|
||||
.terminal-container { background: #0d1117; border: 1px solid var(--border-primary); border-radius: var(--radius-md); overflow: hidden; }
|
||||
.terminal-header { background: rgba(255, 255, 255, 0.03); border-bottom: 1px solid var(--border-primary); padding: 10px 16px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.terminal-dots { display: flex; gap: 6px; }
|
||||
.terminal-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.terminal-dot.red { background: #ff5f56; }
|
||||
.terminal-dot.yellow { background: #ffbd2e; }
|
||||
.terminal-dot.green { background: #27c93f; }
|
||||
.terminal-body { padding: 4px; min-height: 300px; }
|
||||
|
||||
/* ─── Chart container ─────────────────────────────────────── */
|
||||
.chart-container { width: 100%; height: 300px; position: relative; }
|
||||
.chart-container canvas { width: 100% !important; height: 100% !important; }
|
||||
|
||||
/* ─── Settings grid ───────────────────────────────────────── */
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
/* ─── Utility ────────────────────────────────────────────── */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.text-sm { font-size: 0.85rem; }
|
||||
.text-xs { font-size: 0.75rem; }
|
||||
.text-muted { color: var(--text-tertiary); }
|
||||
.text-mono { font-family: var(--font-mono); }
|
||||
.font-bold { font-weight: 700; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.animate-spin { animation: spin 1s linear infinite; }
|
||||
@keyframes slideInRight { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
|
||||
.animate-slide-in { animation: slideInRight var(--transition-normal) ease-out; }
|
||||
|
||||
/* ─── Logs page layout ─────────────────────────────────────── */
|
||||
.logs-layout { display: grid; grid-template-columns: 350px 1fr; gap: 16px; min-height: 500px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 100%; height: auto; position: relative; }
|
||||
.main-content { margin-left: 0; padding: 16px; max-width: 100%; }
|
||||
.card-grid { grid-template-columns: 1fr; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
.page-header { flex-direction: column; align-items: flex-start; }
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
.logs-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
21
dashboard/index.html
Normal file
21
dashboard/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="AutoDeployer — Self-hosted deployment platform" />
|
||||
<title>AutoDeployer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/lib/xterm.min.css">
|
||||
<script src="/lib/chart.min.js"></script>
|
||||
<script src="/lib/xterm.min.js"></script>
|
||||
<script src="/lib/xterm-addon-fit.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
114
dashboard/js/api.js
Normal file
114
dashboard/js/api.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// AutoDeployer API Client — vanilla JS (ES module)
|
||||
const API_BASE = '/api';
|
||||
let accessToken = localStorage.getItem('accessToken') || '';
|
||||
|
||||
function setToken(token) {
|
||||
accessToken = token;
|
||||
token ? localStorage.setItem('accessToken', token) : localStorage.removeItem('accessToken');
|
||||
}
|
||||
function getToken() { return accessToken; }
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, body) { super(body.error || 'API Error'); this.status = status; this.body = body; }
|
||||
}
|
||||
|
||||
async function request(path, opts = {}) {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const headers = { 'Content-Type': 'application/json', ...opts.headers };
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
|
||||
let res = await fetch(url, { ...opts, headers, credentials: 'include' });
|
||||
|
||||
if (res.status === 401 && path !== '/auth/login' && path !== '/auth/refresh') {
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
res = await fetch(url, { ...opts, headers, credentials: 'include' });
|
||||
} else {
|
||||
throw new ApiError(401, { error: 'Session expired' });
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new ApiError(res.status, body);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function refreshToken() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, { method: 'POST', credentials: 'include' });
|
||||
if (res.ok) { const d = await res.json(); setToken(d.accessToken); return true; }
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const auth = {
|
||||
status: () => request('/auth/status'),
|
||||
login: (u, p) => request('/auth/login', { method: 'POST', body: JSON.stringify({ username: u, password: p }) }).then(d => { setToken(d.accessToken); return d; }),
|
||||
setup: (u, p) => request('/auth/setup', { method: 'POST', body: JSON.stringify({ username: u, password: p }) }).then(d => { setToken(d.accessToken); return d; }),
|
||||
logout: () => request('/auth/logout', { method: 'POST' }).then(() => setToken('')).catch(() => setToken('')),
|
||||
me: () => request('/auth/me'),
|
||||
changePassword: (cur, nw) => request('/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword: cur, newPassword: nw }) }),
|
||||
};
|
||||
|
||||
export const services = {
|
||||
list: () => request('/services'),
|
||||
get: (id) => request(`/services/${id}`),
|
||||
create: (d) => request('/services', { method: 'POST', body: JSON.stringify(d) }),
|
||||
update: (id, d) => request(`/services/${id}`, { method: 'PUT', body: JSON.stringify(d) }),
|
||||
delete: (id) => request(`/services/${id}`, { method: 'DELETE' }),
|
||||
deploy: (id) => request(`/services/${id}/deploy`, { method: 'POST' }),
|
||||
stop: (id) => request(`/services/${id}/stop`, { method: 'POST' }),
|
||||
restart: (id) => request(`/services/${id}/restart`, { method: 'POST' }),
|
||||
inspect: (id) => request(`/services/${id}/inspect`),
|
||||
cleanup: () => request('/services/cleanup', { method: 'POST' }),
|
||||
pruneImages: (keep = 2) => request('/services/prune-images', { method: 'POST', body: JSON.stringify({ keep }) }),
|
||||
traefikPreview: (id) => request(`/services/${id}/traefik-preview`),
|
||||
traefikPreviewBody: (d) => request('/services/traefik-preview', { method: 'POST', body: JSON.stringify(d) }),
|
||||
getEnv: (id) => request(`/services/${id}/env`),
|
||||
setEnv: (id, vars) => request(`/services/${id}/env`, { method: 'PUT', body: JSON.stringify({ vars }) }),
|
||||
};
|
||||
|
||||
export const deploys = {
|
||||
list: () => request('/deploys'),
|
||||
get: (id) => request(`/deploys/${id}`),
|
||||
};
|
||||
|
||||
export const networks = {
|
||||
list: () => request('/networks'),
|
||||
create: (name, driver, internal) => request('/networks', { method: 'POST', body: JSON.stringify({ name, driver, internal }) }),
|
||||
remove: (name) => request(`/networks/${name}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export const monitoring = {
|
||||
realtime: () => request('/monitoring/realtime'),
|
||||
stats: (name) => request(`/monitoring/${name}/stats`),
|
||||
history: (name, range = '-1h', field = 'cpu_percent') => request(`/monitoring/${name}/history?range=${range}&field=${field}`),
|
||||
};
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => request('/settings'),
|
||||
update: (s) => request('/settings', { method: 'PUT', body: JSON.stringify({ settings: s }) }),
|
||||
testGitea: () => request('/settings/test/gitea'),
|
||||
testTelegram: () => request('/settings/test/telegram'),
|
||||
queueStatus: () => request('/settings/queue'),
|
||||
};
|
||||
|
||||
export const system = {
|
||||
version: () => request('/system/version'),
|
||||
selfUpdate: () => request('/system/self-update', { method: 'POST' }),
|
||||
updateStatus: () => request('/system/update-status'),
|
||||
};
|
||||
|
||||
export function createLogSocket(target) {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return new WebSocket(`${proto}//${location.host}/ws/logs/${target}?token=${accessToken}`);
|
||||
}
|
||||
|
||||
export function createTerminalSocket(containerId) {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return new WebSocket(`${proto}//${location.host}/ws/terminal/${containerId}?token=${accessToken}`);
|
||||
}
|
||||
|
||||
export { getToken, setToken };
|
||||
85
dashboard/js/app.js
Normal file
85
dashboard/js/app.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { auth } from './api.js';
|
||||
import { icons } from './icons.js';
|
||||
import { route, navigate, startRouter } from './router.js';
|
||||
import { renderDashboard } from './pages/dashboard.js';
|
||||
import { renderLogin } from './pages/login.js';
|
||||
import { renderServiceDetail } from './pages/service-detail.js';
|
||||
import { renderLogs } from './pages/logs.js';
|
||||
import { renderMonitoring } from './pages/monitoring.js';
|
||||
import { renderSettings } from './pages/settings.js';
|
||||
|
||||
let currentUser = null;
|
||||
|
||||
function renderSidebar(user) {
|
||||
return `
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-brand-icon">${icons.rocket(20)}</div>
|
||||
<h1>AutoDeployer</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-section">Navigation</div>
|
||||
<div class="sidebar-link" data-path="/">${icons.layoutDashboard()} <span>Dashboard</span></div>
|
||||
<div class="sidebar-link" data-path="/logs">${icons.scrollText()} <span>Logs</span></div>
|
||||
<div class="sidebar-link" data-path="/monitoring">${icons.activity()} <span>Monitoring</span></div>
|
||||
<div class="sidebar-section">System</div>
|
||||
<div class="sidebar-link" data-path="/settings">${icons.settings()} <span>Settings</span></div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-link" id="logout-btn">${icons.logOut()} <span>Logout</span></div>
|
||||
<div class="text-xs text-muted mt-2" style="padding:0 12px">
|
||||
Logged in as <strong>${user.username}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
function setupSidebarEvents() {
|
||||
document.querySelectorAll('.sidebar-link[data-path]').forEach(el => {
|
||||
el.onclick = () => navigate(el.dataset.path);
|
||||
});
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
if (logoutBtn) logoutBtn.onclick = async () => { await auth.logout(); location.reload(); };
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const app = document.getElementById('app');
|
||||
|
||||
// Check auth
|
||||
try {
|
||||
const status = await auth.status();
|
||||
if (status.setupRequired) {
|
||||
renderLogin(app, true, onLogin);
|
||||
return;
|
||||
}
|
||||
const data = await auth.me();
|
||||
currentUser = data.user;
|
||||
} catch {
|
||||
renderLogin(app, false, onLogin);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render app layout
|
||||
app.innerHTML = `
|
||||
${renderSidebar(currentUser)}
|
||||
<main class="main-content" id="page-content"></main>
|
||||
`;
|
||||
setupSidebarEvents();
|
||||
|
||||
// Setup routes
|
||||
const content = document.getElementById('page-content');
|
||||
route('/', (c) => renderDashboard(c));
|
||||
route('/services/:id', (c, p) => renderServiceDetail(c, p.id));
|
||||
route('/logs', (c) => renderLogs(c));
|
||||
route('/monitoring', (c) => renderMonitoring(c));
|
||||
route('/settings', (c) => renderSettings(c));
|
||||
|
||||
startRouter(content);
|
||||
}
|
||||
|
||||
function onLogin(user) {
|
||||
currentUser = user;
|
||||
init();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
47
dashboard/js/icons.js
Normal file
47
dashboard/js/icons.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// SVG icons from Lucide (MIT License) - inline SVG strings
|
||||
const s = (d, size = 18) => `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
|
||||
|
||||
export const icons = {
|
||||
rocket: (sz) => s('<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>', sz),
|
||||
layoutDashboard: (sz) => s('<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/>', sz),
|
||||
scrollText: (sz) => s('<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2"/>', sz),
|
||||
activity: (sz) => s('<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/>', sz),
|
||||
settings: (sz) => s('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', sz),
|
||||
logOut: (sz) => s('<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>', sz),
|
||||
play: (sz) => s('<polygon points="6 3 20 12 6 21 6 3"/>', sz),
|
||||
square: (sz) => s('<rect width="18" height="18" x="3" y="3" rx="2"/>', sz),
|
||||
plus: (sz) => s('<path d="M5 12h14"/><path d="M12 5v14"/>', sz),
|
||||
trash2: (sz) => s('<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>', sz),
|
||||
refreshCw: (sz) => s('<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>', sz),
|
||||
arrowLeft: (sz) => s('<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>', sz),
|
||||
gitBranch: (sz) => s('<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>', sz),
|
||||
globe: (sz) => s('<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>', sz),
|
||||
terminal: (sz) => s('<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>', sz),
|
||||
shield: (sz) => s('<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>', sz),
|
||||
network: (sz) => s('<rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/>', sz),
|
||||
fileCode: (sz) => s('<path d="M10 12.5 8 15l2 2.5"/><path d="m14 12.5 2 2.5-2 2.5"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/>', sz),
|
||||
clock: (sz) => s('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>', sz),
|
||||
gitCommit: (sz) => s('<circle cx="12" cy="12" r="3"/><line x1="3" x2="9" y1="12" y2="12"/><line x1="15" x2="21" y1="12" y2="12"/>', sz),
|
||||
user: (sz) => s('<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>', sz),
|
||||
copy: (sz) => s('<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>', sz),
|
||||
download: (sz) => s('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>', sz),
|
||||
pause: (sz) => s('<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>', sz),
|
||||
eye: (sz) => s('<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>', sz),
|
||||
eyeOff: (sz) => s('<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>', sz),
|
||||
save: (sz) => s('<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>', sz),
|
||||
chevronDown: (sz) => s('<path d="m6 9 6 6 6-6"/>', sz),
|
||||
chevronUp: (sz) => s('<path d="m18 15-6-6-6 6"/>', sz),
|
||||
hardDrive: (sz) => s('<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>', sz),
|
||||
cpu: (sz) => s('<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>', sz),
|
||||
wifi: (sz) => s('<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/>', sz),
|
||||
bell: (sz) => s('<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>', sz),
|
||||
key: (sz) => s('<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>', sz),
|
||||
database: (sz) => s('<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>', sz),
|
||||
check: (sz) => s('<path d="M20 6 9 17l-5-5"/>', sz),
|
||||
x: (sz) => s('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', sz),
|
||||
externalLink: (sz) => s('<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>', sz),
|
||||
lock: (sz) => s('<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', sz),
|
||||
box: (sz) => s('<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>', sz),
|
||||
checkCircle: (sz) => s('<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>', sz),
|
||||
xCircle: (sz) => s('<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>', sz),
|
||||
};
|
||||
144
dashboard/js/pages/dashboard.js
Normal file
144
dashboard/js/pages/dashboard.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { services as api } from '../api.js';
|
||||
import { icons } from '../icons.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return '–';
|
||||
const d = new Date(dateStr), diff = Math.floor((Date.now() - d) / 60000);
|
||||
if (diff < 1) return 'ora';
|
||||
if (diff < 60) return `${diff}m fa`;
|
||||
const h = Math.floor(diff / 60);
|
||||
if (h < 24) return `${h}h fa`;
|
||||
return `${Math.floor(h / 24)}g fa`;
|
||||
}
|
||||
|
||||
function serviceCardHTML(s) {
|
||||
const status = s.container?.status || s.status || 'stopped';
|
||||
const repo = s.gitea_repo_url?.replace(/https?:\/\/[^/]+\//, '').replace('.git', '') || '';
|
||||
const ld = s.last_deploy;
|
||||
return `
|
||||
<div class="card service-card" data-id="${s.id}">
|
||||
<div class="service-card-header">
|
||||
<div>
|
||||
<div class="service-card-name">${s.name} <span class="status-badge ${status}">${status}</span></div>
|
||||
${s.description ? `<p class="text-xs text-muted mt-2" style="max-width:260px">${s.description}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-card-repo">${icons.gitBranch(12)} <span class="truncate">${repo}</span> <span style="color:var(--accent)">:${s.gitea_branch}</span></div>
|
||||
${s.traefik_domain ? `<div class="service-card-repo mt-2">${icons.globe(12)} <span>${s.traefik_domain}</span></div>` : ''}
|
||||
<div class="service-card-meta">
|
||||
<div class="service-card-meta-item">${icons.clock(12)} ${ld ? `${ld.status === 'success' ? '✅' : ld.status === 'failed' ? '❌' : '⏳'} ${formatTime(ld.created_at)}` : 'Mai deployato'}</div>
|
||||
${ld?.commit_sha ? `<div class="service-card-meta-item text-mono">${ld.commit_sha}</div>` : ''}
|
||||
</div>
|
||||
<div class="service-card-actions">
|
||||
<button class="btn btn-primary btn-sm btn-deploy" data-id="${s.id}" ${status === 'building' ? 'disabled' : ''}>${icons.play(14)} Deploy</button>
|
||||
${status === 'running' || status === 'error' ? `<button class="btn btn-danger btn-sm btn-stop" data-id="${s.id}">${icons.square(14)} Stop</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderDashboard(container) {
|
||||
let interval;
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div><h2>Dashboard</h2><p>Gestisci i tuoi servizi</p></div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary" id="refresh-btn">${icons.refreshCw(16)} Refresh</button>
|
||||
<button class="btn btn-primary" id="create-btn">${icons.plus(16)} Nuovo Servizio</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="services-grid" class="card-grid"></div>
|
||||
<div id="create-modal"></div>`;
|
||||
|
||||
async function load() {
|
||||
const grid = document.getElementById('services-grid');
|
||||
try {
|
||||
const list = await api.list();
|
||||
if (list.length === 0) {
|
||||
grid.innerHTML = `<div class="empty-state"><h3>Nessun servizio configurato</h3><p>Crea il tuo primo servizio per iniziare</p></div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = list.map(serviceCardHTML).join('');
|
||||
// Events
|
||||
grid.querySelectorAll('.service-card').forEach(el => {
|
||||
el.onclick = (e) => { if (!e.target.closest('button')) navigate(`/services/${el.dataset.id}`); };
|
||||
});
|
||||
grid.querySelectorAll('.btn-deploy').forEach(b => {
|
||||
b.onclick = async (e) => { e.stopPropagation(); await api.deploy(b.dataset.id); load(); };
|
||||
});
|
||||
grid.querySelectorAll('.btn-stop').forEach(b => {
|
||||
b.onclick = async (e) => { e.stopPropagation(); await api.stop(b.dataset.id); load(); };
|
||||
});
|
||||
} catch (err) { grid.innerHTML = `<div class="empty-state"><p>Errore: ${err.message}</p></div>`; }
|
||||
}
|
||||
|
||||
document.getElementById('refresh-btn').onclick = load;
|
||||
document.getElementById('create-btn').onclick = () => showCreateModal(load);
|
||||
load();
|
||||
interval = setInterval(load, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
|
||||
function showCreateModal(onCreated) {
|
||||
const modal = document.getElementById('create-modal');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal" style="max-width:700px" onclick="event.stopPropagation()">
|
||||
<div class="modal-header"><h3 class="modal-title">Nuovo Servizio</h3><button class="btn btn-secondary btn-sm" id="modal-close">✕</button></div>
|
||||
<div id="modal-error" class="login-error hidden"></div>
|
||||
<form id="create-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Nome Servizio</label><input class="form-input" id="cf-name" placeholder="api-service" required></div>
|
||||
<div class="form-group"><label class="form-label">Container Name</label><input class="form-input mono" id="cf-container" placeholder="api-service" required></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">Descrizione</label><input class="form-input" id="cf-desc" placeholder="Opzionale"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Gitea Repository URL</label><input class="form-input mono" id="cf-repo" value="http://gitea:3000/" required></div>
|
||||
<div class="form-group"><label class="form-label">Branch</label><input class="form-input mono" id="cf-branch" value="main"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Container Port</label><input type="number" class="form-input" id="cf-port" value="3000"></div>
|
||||
<div class="form-group"><label class="form-label">Dominio Traefik</label><input class="form-input mono" id="cf-domain" placeholder="api.mebboat.it"></div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4" style="justify-content:flex-end">
|
||||
<button type="button" class="btn btn-secondary" id="modal-cancel">Annulla</button>
|
||||
<button type="submit" class="btn btn-primary" id="modal-submit">Crea Servizio</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => { modal.innerHTML = ''; };
|
||||
document.getElementById('modal-overlay').onclick = close;
|
||||
document.getElementById('modal-close').onclick = close;
|
||||
document.getElementById('modal-cancel').onclick = close;
|
||||
document.getElementById('cf-name').oninput = (e) => {
|
||||
document.getElementById('cf-container').value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
};
|
||||
|
||||
document.getElementById('create-form').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('modal-error');
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const s = await api.create({
|
||||
name: document.getElementById('cf-name').value,
|
||||
container_name: document.getElementById('cf-container').value,
|
||||
description: document.getElementById('cf-desc').value,
|
||||
gitea_repo_url: document.getElementById('cf-repo').value,
|
||||
gitea_branch: document.getElementById('cf-branch').value,
|
||||
container_port: parseInt(document.getElementById('cf-port').value),
|
||||
traefik_domain: document.getElementById('cf-domain').value,
|
||||
traefik_enabled: true, traefik_tls_resolver: 'cloudflare',
|
||||
traefik_network: 'meb-public', networks: ['meb-public'],
|
||||
});
|
||||
close();
|
||||
onCreated();
|
||||
navigate(`/services/${s.id}`);
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
}
|
||||
67
dashboard/js/pages/login.js
Normal file
67
dashboard/js/pages/login.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { auth } from '../api.js';
|
||||
import { icons } from '../icons.js';
|
||||
|
||||
export function renderLogin(container, isSetup, onLogin) {
|
||||
container.innerHTML = `
|
||||
<div class="login-page">
|
||||
<div class="login-card animate-slide-in">
|
||||
<div class="login-brand">
|
||||
<div class="login-brand-icon">${icons.rocket(28)}</div>
|
||||
<h1>AutoDeployer</h1>
|
||||
<p>${isSetup ? 'Configura il tuo account admin' : 'Accedi alla dashboard'}</p>
|
||||
</div>
|
||||
<div id="login-error" class="login-error hidden"></div>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Username</label>
|
||||
<input id="login-username" type="text" class="form-input" placeholder="admin" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
<input id="login-password" type="password" class="form-input" placeholder="${isSetup ? 'Minimo 12 caratteri' : '••••••••••••'}" required autocomplete="${isSetup ? 'new-password' : 'current-password'}">
|
||||
${isSetup ? '<div class="form-hint">Utilizza una password forte con almeno 12 caratteri</div>' : ''}
|
||||
</div>
|
||||
${isSetup ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Conferma Password</label>
|
||||
<input id="login-confirm" type="password" class="form-input" placeholder="Ripeti la password" required autocomplete="new-password">
|
||||
</div>` : ''}
|
||||
<button type="submit" class="btn btn-primary w-full" style="justify-content:center;margin-top:8px" id="login-submit">
|
||||
${isSetup ? 'Crea Account' : 'Accedi'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const form = document.getElementById('login-form');
|
||||
const errorEl = document.getElementById('login-error');
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.classList.add('hidden');
|
||||
const btn = document.getElementById('login-submit');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
|
||||
const username = document.getElementById('login-username').value;
|
||||
const password = document.getElementById('login-password').value;
|
||||
|
||||
try {
|
||||
if (isSetup) {
|
||||
const confirm = document.getElementById('login-confirm').value;
|
||||
if (password !== confirm) throw new Error('Le password non coincidono');
|
||||
if (password.length < 12) throw new Error('La password deve avere almeno 12 caratteri');
|
||||
const data = await auth.setup(username, password);
|
||||
onLogin(data.user);
|
||||
} else {
|
||||
const data = await auth.login(username, password);
|
||||
onLogin(data.user);
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message || 'Errore di autenticazione';
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = isSetup ? 'Crea Account' : 'Accedi';
|
||||
}
|
||||
};
|
||||
}
|
||||
137
dashboard/js/pages/logs.js
Normal file
137
dashboard/js/pages/logs.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { deploys as deploysApi, createLogSocket } from '../api.js';
|
||||
import { icons } from '../icons.js';
|
||||
|
||||
export function renderLogs(container) {
|
||||
let deploysList = [];
|
||||
let selectedDeploy = null;
|
||||
let logWs = null;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="page-header"><div><h2>Log Deploy</h2><p>Storico di tutti i deploy con i relativi log</p></div></div>
|
||||
<div style="display:grid;grid-template-columns:350px 1fr;gap:16px;min-height:500px">
|
||||
<div class="card" style="overflow:auto;max-height:70vh" id="deploy-list">
|
||||
<h3 class="card-title mb-4">${icons.scrollText(16)} Deploy Recenti</h3>
|
||||
<p class="text-muted">Caricamento...</p>
|
||||
</div>
|
||||
<div class="card" id="log-panel">
|
||||
<div class="empty-state">${icons.scrollText(32)}<h3 class="mt-4">Seleziona un deploy</h3><p>Clicca su un deploy nella lista per visualizzarne i log</p></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
deploysList = await deploysApi.list();
|
||||
renderList();
|
||||
} catch (err) {
|
||||
document.getElementById('deploy-list').innerHTML = `<p class="text-muted text-sm">Errore: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('deploy-list');
|
||||
el.innerHTML = `<h3 class="card-title mb-4">${icons.scrollText(16)} Deploy Recenti</h3>` +
|
||||
(deploysList.length === 0 ? '<p class="text-muted text-sm">Nessun deploy trovato</p>' :
|
||||
`<div class="flex flex-col gap-2">${deploysList.map(d => `
|
||||
<div class="card deploy-item" data-id="${d.id}" style="cursor:pointer;padding:12px 14px;background:${selectedDeploy?.id === d.id ? 'var(--accent-muted)' : 'var(--bg-glass)'};border-color:${selectedDeploy?.id === d.id ? 'rgba(99,102,241,0.3)' : 'var(--border-primary)'}">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-bold text-sm">${d.service_name}</span>
|
||||
<span class="status-badge ${d.status}">${d.status}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
${d.commit_sha ? `<span class="text-mono">${icons.gitCommit(10)} ${d.commit_sha}</span>` : ''}
|
||||
<span>${icons.clock(10)} ${new Date(d.created_at).toLocaleString('it-IT')}</span>
|
||||
</div>
|
||||
${d.commit_message ? `<p class="text-xs text-muted mt-1 truncate">${esc(d.commit_message)}</p>` : ''}
|
||||
</div>`).join('')}</div>`);
|
||||
|
||||
el.querySelectorAll('.deploy-item').forEach(item => {
|
||||
item.onclick = () => {
|
||||
const did = parseInt(item.dataset.id);
|
||||
selectedDeploy = deploysList.find(d => d.id === did);
|
||||
renderList();
|
||||
renderLogPanel();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderLogPanel() {
|
||||
const panel = document.getElementById('log-panel');
|
||||
if (!selectedDeploy) {
|
||||
panel.innerHTML = `<div class="empty-state">${icons.scrollText(32)}<h3 class="mt-4">Seleziona un deploy</h3><p>Clicca su un deploy nella lista per visualizzarne i log</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (logWs) { logWs.close(); logWs = null; }
|
||||
|
||||
const d = selectedDeploy;
|
||||
panel.innerHTML = `
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Log: ${d.service_name}</h3>
|
||||
<span class="status-badge ${d.status}">${d.status}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 text-xs text-muted mb-4">
|
||||
<span>${icons.user(10)} ${d.commit_author || d.trigger}</span>
|
||||
<span>${icons.gitCommit(10)} ${d.commit_sha || '–'}</span>
|
||||
${d.duration_ms > 0 ? `<span>${icons.clock(10)} ${(d.duration_ms / 1000).toFixed(1)}s</span>` : ''}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-muted" id="lv-count">0 righe</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" id="lv-pause">${icons.pause(12)} Pause</button>
|
||||
<button class="btn btn-secondary btn-sm" id="lv-download">${icons.download(12)} Download</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-viewer" id="lv-container"><div class="log-line info">In attesa di log...</div></div>`;
|
||||
|
||||
let lines = [];
|
||||
let paused = false;
|
||||
const lv = panel.querySelector('#lv-container');
|
||||
|
||||
logWs = createLogSocket(`deploy:${d.id}`);
|
||||
logWs.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'log' || msg.type === 'info' || msg.type === 'error') {
|
||||
lines.push(msg);
|
||||
if (lines.length > 2000) lines = lines.slice(-1500);
|
||||
if (lv.firstChild?.classList?.contains('info') && lines.length === 1) lv.innerHTML = '';
|
||||
const div = document.createElement('div');
|
||||
div.className = `log-line ${msg.type}`;
|
||||
div.textContent = msg.data;
|
||||
lv.appendChild(div);
|
||||
panel.querySelector('#lv-count').textContent = `${lines.length} righe`;
|
||||
if (!paused) lv.scrollTop = lv.scrollHeight;
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
logWs.onerror = () => appendLine('error', 'WebSocket connection error');
|
||||
logWs.onclose = () => appendLine('info', '— Stream ended —');
|
||||
|
||||
function appendLine(type, text) {
|
||||
lines.push({ type, data: text });
|
||||
const div = document.createElement('div');
|
||||
div.className = `log-line ${type}`;
|
||||
div.textContent = text;
|
||||
lv.appendChild(div);
|
||||
if (!paused) lv.scrollTop = lv.scrollHeight;
|
||||
}
|
||||
|
||||
panel.querySelector('#lv-pause').onclick = () => {
|
||||
paused = !paused;
|
||||
panel.querySelector('#lv-pause').innerHTML = paused ? `${icons.play(12)} Resume` : `${icons.pause(12)} Pause`;
|
||||
};
|
||||
panel.querySelector('#lv-download').onclick = () => {
|
||||
const blob = new Blob([lines.map(l => l.data).join('\n')], { type: 'text/plain' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `logs-deploy-${d.id}-${Date.now()}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
}
|
||||
|
||||
load();
|
||||
return () => { if (logWs) logWs.close(); };
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
184
dashboard/js/pages/monitoring.js
Normal file
184
dashboard/js/pages/monitoring.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { monitoring as monitoringApi, services as servicesApi } from '../api.js';
|
||||
import { icons } from '../icons.js';
|
||||
|
||||
const RANGES = [
|
||||
{ value: '-1h', label: '1 ora' },
|
||||
{ value: '-6h', label: '6 ore' },
|
||||
{ value: '-24h', label: '24 ore' },
|
||||
{ value: '-7d', label: '7 giorni' },
|
||||
];
|
||||
|
||||
const METRICS = [
|
||||
{ value: 'cpu_percent', label: 'CPU %', color: '#6366f1' },
|
||||
{ value: 'memory_percent', label: 'RAM %', color: '#8b5cf6' },
|
||||
{ value: 'network_rx', label: 'Network RX', color: '#10b981' },
|
||||
{ value: 'network_tx', label: 'Network TX', color: '#f59e0b' },
|
||||
];
|
||||
|
||||
export function renderMonitoring(container) {
|
||||
let stats = {};
|
||||
let selectedService = null;
|
||||
let chartRange = '-1h';
|
||||
let chartMetric = 'cpu_percent';
|
||||
let chart = null;
|
||||
let interval;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="page-header"><div><h2>Monitoring</h2><p>Risorse in tempo reale di tutti i servizi attivi</p></div></div>
|
||||
<div class="monitoring-grid mb-4" id="stats-grid"></div>
|
||||
<div id="empty-msg" class="hidden"></div>
|
||||
<div id="chart-section" class="hidden"></div>`;
|
||||
|
||||
async function loadInitial() {
|
||||
try {
|
||||
const st = await monitoringApi.realtime();
|
||||
stats = st;
|
||||
renderStats();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function pollStats() {
|
||||
try { stats = await monitoringApi.realtime(); renderStats(); } catch {}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const grid = document.getElementById('stats-grid');
|
||||
const entries = Object.entries(stats);
|
||||
|
||||
if (entries.length === 0) {
|
||||
grid.innerHTML = '';
|
||||
document.getElementById('empty-msg').innerHTML = `<div class="empty-state">${icons.activity(48)}<h3 class="mt-4">Nessun servizio attivo</h3><p>I dati di monitoring saranno visibili quando almeno un servizio sarà in esecuzione</p></div>`;
|
||||
document.getElementById('empty-msg').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
document.getElementById('empty-msg').classList.add('hidden');
|
||||
|
||||
grid.innerHTML = entries.map(([name, s]) => `
|
||||
<div class="card metric-card" data-svc="${name}" style="cursor:pointer;${selectedService === name ? 'border-color:var(--accent)' : ''}">
|
||||
<div class="card-header" style="margin-bottom:8px">
|
||||
<span class="card-title">${name}</span>
|
||||
<span class="status-badge running">running</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-muted text-xs mb-1">${icons.cpu(12)} CPU</div>
|
||||
<div class="metric-value" style="font-size:1.4rem">${s?.cpu_percent?.toFixed(1) || '0'}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-muted text-xs mb-1">${icons.hardDrive(12)} RAM</div>
|
||||
<div class="metric-value" style="font-size:1.4rem">${formatBytes(s?.memory_usage)}</div>
|
||||
<div class="text-xs text-muted">/ ${formatBytes(s?.memory_limit)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-muted text-xs mb-1">${icons.wifi(12)} Net RX</div>
|
||||
<div class="text-sm font-bold">${formatBytes(s?.network_rx)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-muted text-xs mb-1">${icons.wifi(12)} Net TX</div>
|
||||
<div class="text-sm font-bold">${formatBytes(s?.network_tx)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
grid.querySelectorAll('.metric-card').forEach(card => {
|
||||
card.onclick = () => {
|
||||
selectedService = card.dataset.svc;
|
||||
renderStats();
|
||||
renderChart();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function renderChart() {
|
||||
if (!selectedService) { document.getElementById('chart-section').classList.add('hidden'); return; }
|
||||
const section = document.getElementById('chart-section');
|
||||
section.classList.remove('hidden');
|
||||
|
||||
section.innerHTML = `
|
||||
<div class="card animate-slide-in">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">📊 ${selectedService} — Storico</h3>
|
||||
<div class="flex gap-2" id="range-btns">${RANGES.map(r => `<button class="btn btn-sm ${chartRange === r.value ? 'btn-primary' : 'btn-secondary'}" data-range="${r.value}">${r.label}</button>`).join('')}</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4" id="metric-btns">${METRICS.map(m => `<button class="btn btn-sm ${chartMetric === m.value ? 'btn-primary' : 'btn-secondary'}" data-metric="${m.value}" ${chartMetric === m.value ? `style="background:${m.color};border-color:${m.color}"` : ''}>${m.label}</button>`).join('')}</div>
|
||||
<div id="chart-area" style="width:100%;height:300px">
|
||||
<canvas id="monitoring-canvas"></canvas>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
section.querySelectorAll('[data-range]').forEach(b => {
|
||||
b.onclick = () => { chartRange = b.dataset.range; renderChart(); };
|
||||
});
|
||||
section.querySelectorAll('[data-metric]').forEach(b => {
|
||||
b.onclick = () => { chartMetric = b.dataset.metric; renderChart(); };
|
||||
});
|
||||
|
||||
await loadChartData();
|
||||
}
|
||||
|
||||
async function loadChartData() {
|
||||
try {
|
||||
const result = await monitoringApi.history(selectedService, chartRange, chartMetric);
|
||||
const labels = result.map(d => new Date(d.time).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }));
|
||||
const values = result.map(d => Math.round(d.value * 100) / 100);
|
||||
const currentMetric = METRICS.find(m => m.value === chartMetric);
|
||||
|
||||
if (chart) chart.destroy();
|
||||
const canvas = document.getElementById('monitoring-canvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Use Chart.js if available
|
||||
if (typeof Chart !== 'undefined') {
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
borderColor: currentMetric.color,
|
||||
backgroundColor: currentMetric.color + '30',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10,
|
||||
borderWidth: 2,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#6b7280', font: { size: 11 } }, grid: { color: 'rgba(255,255,255,0.05)' } },
|
||||
y: {
|
||||
min: chartMetric.includes('percent') ? 0 : undefined,
|
||||
max: chartMetric.includes('percent') ? 100 : undefined,
|
||||
ticks: { color: '#6b7280', font: { size: 11 } },
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
document.getElementById('chart-area').innerHTML = `<div class="empty-state" style="padding:40px"><p class="text-muted">Chart.js non caricato. Aggiungi chart.min.js nella cartella lib/</p></div>`;
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('chart-area').innerHTML = `<div class="empty-state" style="padding:40px"><p class="text-muted">Nessun dato disponibile</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
loadInitial();
|
||||
interval = setInterval(pollStats, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (chart) chart.destroy();
|
||||
};
|
||||
}
|
||||
720
dashboard/js/pages/service-detail.js
Normal file
720
dashboard/js/pages/service-detail.js
Normal file
@@ -0,0 +1,720 @@
|
||||
import { services as api, deploys as deploysApi, networks as networksApi, createLogSocket, createTerminalSocket } from '../api.js';
|
||||
import { icons } from '../icons.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'general', label: 'Generale', icon: 'settings' },
|
||||
{ id: 'traefik', label: 'Traefik', icon: 'globe' },
|
||||
{ id: 'env', label: 'Variabili', icon: 'fileCode' },
|
||||
{ id: 'deploys', label: 'Deploy', icon: 'gitBranch' },
|
||||
{ id: 'logs', label: 'Logs', icon: 'terminal' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: 'terminal' },
|
||||
{ id: 'networks', label: 'Networks', icon: 'network' },
|
||||
{ id: 'webhook', label: 'Webhook', icon: 'shield' },
|
||||
];
|
||||
|
||||
const MIDDLEWARE_TYPES = [
|
||||
{ value: 'ratelimit', label: 'Rate Limit' },
|
||||
{ value: 'headers', label: 'Security Headers' },
|
||||
{ value: 'redirectscheme', label: 'Redirect HTTPS' },
|
||||
{ value: 'compress', label: 'Compress' },
|
||||
{ value: 'stripprefix', label: 'Strip Prefix' },
|
||||
{ value: 'basicauth', label: 'Basic Auth' },
|
||||
{ value: 'ipallowlist', label: 'IP Allow List' },
|
||||
];
|
||||
|
||||
const REFERENCE_PRESETS = [
|
||||
{ name: 'security', provider: 'file', label: 'security@file' },
|
||||
{ name: 'ratelimit', provider: 'file', label: 'ratelimit@file' },
|
||||
{ name: 'ratelimitws', provider: 'file', label: 'ratelimitws@file' },
|
||||
{ name: 'internalonly', provider: 'file', label: 'internalonly@file' },
|
||||
{ name: 'higherauth', provider: 'file', label: 'higherauth@file' },
|
||||
];
|
||||
|
||||
export function renderServiceDetail(container, id) {
|
||||
let service = null;
|
||||
let form = {};
|
||||
let activeTab = 'general';
|
||||
let saving = false;
|
||||
let logCleanup = null;
|
||||
let termCleanup = null;
|
||||
|
||||
container.innerHTML = `<div class="empty-state"><div class="animate-spin" style="font-size:2rem">⏳</div></div>`;
|
||||
|
||||
async function loadService() {
|
||||
try {
|
||||
const data = await api.get(id);
|
||||
service = data;
|
||||
form = {
|
||||
name: data.name, description: data.description,
|
||||
gitea_repo_url: data.gitea_repo_url, gitea_branch: data.gitea_branch,
|
||||
dockerfile_path: data.dockerfile_path, build_context: data.build_context,
|
||||
container_name: data.container_name, container_port: data.container_port,
|
||||
traefik_enabled: !!data.traefik_enabled, traefik_domain: data.traefik_domain,
|
||||
traefik_entrypoints: data.traefik_entrypoints, traefik_tls_resolver: data.traefik_tls_resolver,
|
||||
traefik_path_prefix: data.traefik_path_prefix, traefik_middlewares: data.traefik_middlewares || [],
|
||||
traefik_network: data.traefik_network, networks: data.networks || ['meb-public'],
|
||||
health_check_enabled: !!data.health_check_enabled, health_check_path: data.health_check_path,
|
||||
health_check_interval: data.health_check_interval, health_check_timeout: data.health_check_timeout,
|
||||
health_check_retries: data.health_check_retries, zero_downtime: !!data.zero_downtime,
|
||||
};
|
||||
render();
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="empty-state"><h3>Servizio non trovato</h3><button class="btn btn-primary mt-4" id="back-btn">Torna alla Dashboard</button></div>`;
|
||||
container.querySelector('#back-btn').onclick = () => navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true; render();
|
||||
try { await api.update(id, form); await loadService(); }
|
||||
catch (err) { alert('Errore: ' + err.message); }
|
||||
finally { saving = false; render(); }
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
try { await api.deploy(id); loadService(); } catch (err) { alert('Errore deploy: ' + err.message); }
|
||||
}
|
||||
async function handleStop() {
|
||||
try { await api.stop(id); loadService(); } catch (err) { alert('Errore stop: ' + err.message); }
|
||||
}
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Sei sicuro di voler eliminare "${service.name}"? Questa azione è irreversibile.`)) return;
|
||||
try { await api.delete(id); navigate('/'); } catch (err) { alert('Errore: ' + err.message); }
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!service) return;
|
||||
cleanupTab();
|
||||
const status = service.container?.status || service.status || 'stopped';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="animate-slide-in">
|
||||
<div class="page-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="btn btn-secondary btn-icon" id="back-btn">${icons.arrowLeft(18)}</button>
|
||||
<div>
|
||||
<div class="flex items-center gap-3"><h2>${service.name}</h2><span class="status-badge ${status}">${status}</span></div>
|
||||
${service.description ? `<p>${service.description}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" id="deploy-btn" ${status === 'building' ? 'disabled' : ''}>${icons.play(16)} Deploy</button>
|
||||
${status === 'running' ? `<button class="btn btn-danger" id="stop-btn">${icons.square(16)} Stop</button>` : ''}
|
||||
<button class="btn btn-danger btn-sm" id="delete-btn">${icons.trash2(14)}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs" id="tabs-bar">${TABS.map(t => `<button class="tab ${activeTab === t.id ? 'active' : ''}" data-tab="${t.id}">${t.label}</button>`).join('')}</div>
|
||||
<div id="tab-content"></div>
|
||||
</div>`;
|
||||
|
||||
container.querySelector('#back-btn').onclick = () => navigate('/');
|
||||
container.querySelector('#deploy-btn').onclick = handleDeploy;
|
||||
container.querySelector('#stop-btn')?.addEventListener('click', handleStop);
|
||||
container.querySelector('#delete-btn').onclick = handleDelete;
|
||||
container.querySelectorAll('.tab').forEach(b => {
|
||||
b.onclick = () => { activeTab = b.dataset.tab; render(); };
|
||||
});
|
||||
|
||||
renderTab();
|
||||
}
|
||||
|
||||
function cleanupTab() {
|
||||
if (logCleanup) { logCleanup(); logCleanup = null; }
|
||||
if (termCleanup) { termCleanup(); termCleanup = null; }
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const el = document.getElementById('tab-content');
|
||||
if (!el) return;
|
||||
switch (activeTab) {
|
||||
case 'general': renderGeneralTab(el); break;
|
||||
case 'traefik': renderTraefikTab(el); break;
|
||||
case 'env': renderEnvTab(el); break;
|
||||
case 'deploys': renderDeploysTab(el); break;
|
||||
case 'logs': renderLogsTab(el); break;
|
||||
case 'terminal': renderTerminalTab(el); break;
|
||||
case 'networks': renderNetworksTab(el); break;
|
||||
case 'webhook': renderWebhookTab(el); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── General Tab ──
|
||||
function renderGeneralTab(el) {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h3 class="card-title mb-4">Configurazione Generale</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Nome</label><input class="form-input" id="f-name" value="${esc(form.name)}"></div>
|
||||
<div class="form-group"><label class="form-label">Container Name</label><input class="form-input mono" id="f-container" value="${esc(form.container_name)}"></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">Descrizione</label><input class="form-input" id="f-desc" value="${esc(form.description || '')}"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Repository URL</label><input class="form-input mono" id="f-repo" value="${esc(form.gitea_repo_url)}"></div>
|
||||
<div class="form-group"><label class="form-label">Branch</label><input class="form-input mono" id="f-branch" value="${esc(form.gitea_branch)}"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Dockerfile Path</label><input class="form-input mono" id="f-dockerfile" value="${esc(form.dockerfile_path || '')}"></div>
|
||||
<div class="form-group"><label class="form-label">Build Context</label><input class="form-input mono" id="f-context" value="${esc(form.build_context || '')}"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Container Port</label><input type="number" class="form-input" id="f-port" value="${form.container_port}"></div>
|
||||
</div>
|
||||
<h3 class="card-title mt-4 mb-4">Deploy Strategy</h3>
|
||||
<div class="form-row">
|
||||
<label class="toggle"><input type="checkbox" id="f-zd" ${form.zero_downtime ? 'checked' : ''}><div class="toggle-track"></div><span class="text-sm">Zero-Downtime Deploy</span></label>
|
||||
<label class="toggle"><input type="checkbox" id="f-hc" ${form.health_check_enabled ? 'checked' : ''}><div class="toggle-track"></div><span class="text-sm">Health Check</span></label>
|
||||
</div>
|
||||
<div id="hc-fields" class="${form.health_check_enabled ? '' : 'hidden'}">
|
||||
<div class="form-row mt-4">
|
||||
<div class="form-group"><label class="form-label">Health Check Path</label><input class="form-input mono" id="f-hcpath" value="${esc(form.health_check_path || '')}"></div>
|
||||
<div class="form-group"><label class="form-label">Interval (secondi)</label><input type="number" class="form-input" id="f-hcint" value="${form.health_check_interval || 30}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4"><button class="btn btn-primary" id="save-general">${saving ? '⏳ Salvando...' : '💾 Salva Configurazione'}</button></div>
|
||||
</div>`;
|
||||
|
||||
const bind = (sel, field, parser) => {
|
||||
const inp = el.querySelector(sel);
|
||||
inp.oninput = () => { form[field] = parser ? parser(inp.value) : inp.value; };
|
||||
};
|
||||
bind('#f-name', 'name'); bind('#f-container', 'container_name');
|
||||
bind('#f-desc', 'description'); bind('#f-repo', 'gitea_repo_url');
|
||||
bind('#f-branch', 'gitea_branch'); bind('#f-dockerfile', 'dockerfile_path');
|
||||
bind('#f-context', 'build_context'); bind('#f-port', 'container_port', v => parseInt(v));
|
||||
bind('#f-hcpath', 'health_check_path'); bind('#f-hcint', 'health_check_interval', v => parseInt(v));
|
||||
|
||||
el.querySelector('#f-zd').onchange = (e) => { form.zero_downtime = e.target.checked; };
|
||||
el.querySelector('#f-hc').onchange = (e) => {
|
||||
form.health_check_enabled = e.target.checked;
|
||||
el.querySelector('#hc-fields').classList.toggle('hidden', !e.target.checked);
|
||||
};
|
||||
el.querySelector('#save-general').onclick = handleSave;
|
||||
}
|
||||
|
||||
// ── Traefik Tab ──
|
||||
function renderTraefikTab(el) {
|
||||
const mws = form.traefik_middlewares || [];
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">${icons.globe(16)} Configurazione Traefik</h3>
|
||||
<button class="btn btn-secondary btn-sm" id="preview-btn">${icons.eye(14)} Preview Labels</button>
|
||||
</div>
|
||||
<label class="toggle mb-4"><input type="checkbox" id="t-enabled" ${form.traefik_enabled ? 'checked' : ''}><div class="toggle-track"></div><span class="text-sm">Traefik Enabled</span></label>
|
||||
<div id="traefik-fields" class="${form.traefik_enabled ? '' : 'hidden'}">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Dominio</label><input class="form-input mono" id="t-domain" value="${esc(form.traefik_domain || '')}" placeholder="api.mebboat.it"></div>
|
||||
<div class="form-group"><label class="form-label">Path Prefix</label><input class="form-input mono" id="t-prefix" value="${esc(form.traefik_path_prefix || '')}" placeholder="/api (opzionale)"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Entrypoints</label><input class="form-input mono" id="t-entry" value="${esc(form.traefik_entrypoints || '')}" placeholder="websecure"></div>
|
||||
<div class="form-group"><label class="form-label">TLS Cert Resolver</label><input class="form-input mono" id="t-tls" value="${esc(form.traefik_tls_resolver || '')}" placeholder="cloudflare"></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">Docker Network (Traefik)</label><input class="form-input mono" id="t-net" value="${esc(form.traefik_network || '')}" placeholder="meb-public"></div>
|
||||
<h4 class="card-title mt-4 mb-2">${icons.shield(14)} Middlewares</h4>
|
||||
<div id="mw-list"></div>
|
||||
<div class="mb-3"><p class="text-xs text-muted mb-1">Middleware esistenti (file provider):</p><div class="flex gap-2 flex-wrap" id="mw-presets"></div></div>
|
||||
<div><p class="text-xs text-muted mb-1">Aggiungi middleware inline:</p><div class="flex gap-2 flex-wrap" id="mw-add"></div></div>
|
||||
</div>
|
||||
<div id="preview-area" class="hidden mt-4"><h4 class="card-title mb-2">Preview Labels</h4><pre class="log-viewer" id="preview-content" style="max-height:300px;white-space:pre;font-size:0.78rem"></pre><button class="btn btn-secondary btn-sm mt-2" id="close-preview">Chiudi Preview</button></div>
|
||||
<div class="mt-4"><button class="btn btn-primary" id="save-traefik">${saving ? 'Salvando...' : 'Salva'}</button></div>
|
||||
</div>`;
|
||||
|
||||
const bindT = (sel, field) => { el.querySelector(sel).oninput = (e) => { form[field] = e.target.value; }; };
|
||||
bindT('#t-domain', 'traefik_domain'); bindT('#t-prefix', 'traefik_path_prefix');
|
||||
bindT('#t-entry', 'traefik_entrypoints'); bindT('#t-tls', 'traefik_tls_resolver');
|
||||
bindT('#t-net', 'traefik_network');
|
||||
|
||||
el.querySelector('#t-enabled').onchange = (e) => {
|
||||
form.traefik_enabled = e.target.checked;
|
||||
el.querySelector('#traefik-fields').classList.toggle('hidden', !e.target.checked);
|
||||
};
|
||||
|
||||
el.querySelector('#preview-btn').onclick = async () => {
|
||||
try {
|
||||
const data = await api.traefikPreview(id);
|
||||
el.querySelector('#preview-content').textContent = data.labels || 'Nessun label generato';
|
||||
el.querySelector('#preview-area').classList.remove('hidden');
|
||||
} catch (err) {
|
||||
el.querySelector('#preview-content').textContent = 'Errore: ' + err.message;
|
||||
el.querySelector('#preview-area').classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
el.querySelector('#close-preview').onclick = () => el.querySelector('#preview-area').classList.add('hidden');
|
||||
el.querySelector('#save-traefik').onclick = handleSave;
|
||||
|
||||
renderMiddlewares();
|
||||
|
||||
function renderMiddlewares() {
|
||||
const list = el.querySelector('#mw-list');
|
||||
list.innerHTML = mws.map((mw, i) => `
|
||||
<div class="card mb-2" style="padding:12px 16px;background:var(--bg-glass)">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-bold" style="text-transform:capitalize">${mw.type === 'reference' ? `${mw.name || '?'}@${mw.provider || 'file'}` : mw.type}</span>
|
||||
<button class="btn btn-danger btn-sm btn-icon mw-rm" data-i="${i}">${icons.trash2(12)}</button>
|
||||
</div>
|
||||
${renderMwFields(mw, i)}
|
||||
</div>`).join('');
|
||||
|
||||
list.querySelectorAll('.mw-rm').forEach(b => {
|
||||
b.onclick = () => { form.traefik_middlewares.splice(parseInt(b.dataset.i), 1); renderMiddlewares(); };
|
||||
});
|
||||
list.querySelectorAll('[data-mw-field]').forEach(inp => {
|
||||
inp.oninput = () => {
|
||||
const idx = parseInt(inp.dataset.mwIdx);
|
||||
const field = inp.dataset.mwField;
|
||||
let val = inp.value;
|
||||
if (inp.type === 'number') val = parseInt(val);
|
||||
if (inp.type === 'checkbox') val = inp.checked;
|
||||
if (field === 'prefixes') val = inp.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (field === 'sourceRange') val = inp.value.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
form.traefik_middlewares[idx][field] = val;
|
||||
};
|
||||
if (inp.type === 'checkbox') {
|
||||
inp.onchange = inp.oninput;
|
||||
}
|
||||
});
|
||||
|
||||
// Presets
|
||||
const presets = el.querySelector('#mw-presets');
|
||||
presets.innerHTML = REFERENCE_PRESETS.filter(p => !mws.some(m => m.type === 'reference' && m.name === p.name)).map(p =>
|
||||
`<button class="btn btn-secondary btn-sm" data-preset="${p.name}">${icons.plus(12)} ${p.label}</button>`
|
||||
).join('');
|
||||
presets.querySelectorAll('[data-preset]').forEach(b => {
|
||||
b.onclick = () => {
|
||||
const p = REFERENCE_PRESETS.find(r => r.name === b.dataset.preset);
|
||||
form.traefik_middlewares.push({ type: 'reference', name: p.name, provider: p.provider });
|
||||
renderMiddlewares();
|
||||
};
|
||||
});
|
||||
|
||||
// Add custom
|
||||
const addArea = el.querySelector('#mw-add');
|
||||
addArea.innerHTML = MIDDLEWARE_TYPES.map(mt =>
|
||||
`<button class="btn btn-secondary btn-sm" data-mwtype="${mt.value}">${icons.plus(12)} ${mt.label}</button>`
|
||||
).join('');
|
||||
addArea.querySelectorAll('[data-mwtype]').forEach(b => {
|
||||
b.onclick = () => {
|
||||
const type = b.dataset.mwtype;
|
||||
const newMw = { type };
|
||||
if (type === 'ratelimit') { newMw.average = 100; newMw.burst = 50; newMw.period = '1m'; }
|
||||
if (type === 'headers') { newMw.stsSeconds = 31536000; newMw.contentTypeNosniff = true; newMw.frameDeny = true; newMw.browserXssFilter = true; }
|
||||
if (type === 'redirectscheme') { newMw.scheme = 'https'; newMw.permanent = true; }
|
||||
if (type === 'stripprefix') { newMw.prefixes = ['/api']; }
|
||||
if (type === 'ipallowlist') { newMw.sourceRange = []; }
|
||||
form.traefik_middlewares.push(newMw);
|
||||
renderMiddlewares();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderMwFields(mw, i) {
|
||||
const f = (field, val) => `data-mw-idx="${i}" data-mw-field="${field}" value="${esc(String(val ?? ''))}"`;
|
||||
switch (mw.type) {
|
||||
case 'reference': return `<div class="form-row"><div class="form-group"><label class="form-label">Nome</label><input class="form-input mono" ${f('name', mw.name)} placeholder="security"></div><div class="form-group"><label class="form-label">Provider</label><select class="form-input" ${f('provider', mw.provider)}><option value="file" ${mw.provider === 'file' ? 'selected' : ''}>file</option><option value="docker" ${mw.provider === 'docker' ? 'selected' : ''}>docker</option></select></div></div>`;
|
||||
case 'ratelimit': return `<div class="form-row"><div class="form-group"><label class="form-label">Average (req/s)</label><input class="form-input" type="number" ${f('average', mw.average)}></div><div class="form-group"><label class="form-label">Burst</label><input class="form-input" type="number" ${f('burst', mw.burst)}></div><div class="form-group"><label class="form-label">Period</label><input class="form-input mono" ${f('period', mw.period)} placeholder="1m"></div></div>`;
|
||||
case 'headers': return `<div class="form-row"><div class="form-group"><label class="form-label">STS Seconds</label><input class="form-input" type="number" ${f('stsSeconds', mw.stsSeconds)}></div></div><div class="flex gap-4 flex-wrap mt-2">${[['stsIncludeSubdomains','STS Subdomains'],['forceSTSHeader','Force STS'],['contentTypeNosniff','No Sniff'],['frameDeny','Frame Deny'],['browserXssFilter','XSS Filter']].map(([k,l]) => `<label class="flex items-center gap-1 text-sm"><input type="checkbox" data-mw-idx="${i}" data-mw-field="${k}" ${mw[k] ? 'checked' : ''}> ${l}</label>`).join('')}</div>`;
|
||||
case 'redirectscheme': return `<div class="form-row"><div class="form-group"><label class="form-label">Scheme</label><input class="form-input mono" ${f('scheme', mw.scheme)}></div><div class="form-group"><label class="flex items-center gap-1 text-sm mt-4"><input type="checkbox" data-mw-idx="${i}" data-mw-field="permanent" ${mw.permanent !== false ? 'checked' : ''}> Permanente (301)</label></div></div>`;
|
||||
case 'stripprefix': return `<div class="form-group"><label class="form-label">Prefissi (separati da virgola)</label><input class="form-input mono" ${f('prefixes', (mw.prefixes||[]).join(', '))} placeholder="/api, /v1"></div>`;
|
||||
case 'basicauth': return `<div class="form-group"><label class="form-label">Users (formato htpasswd)</label><input class="form-input mono" ${f('users', mw.users)} placeholder="admin:$apr1$..."></div>`;
|
||||
case 'ipallowlist': return `<div class="form-group"><label class="form-label">IP Range (uno per riga)</label><textarea class="form-input mono" rows="3" data-mw-idx="${i}" data-mw-field="sourceRange" placeholder="192.168.1.0/24\n10.0.0.1/32">${(mw.sourceRange||[]).join('\n')}</textarea></div>`;
|
||||
case 'compress': return `<p class="text-sm text-muted">Compressione gzip attivata. Nessuna configurazione aggiuntiva.</p>`;
|
||||
default: return `<pre class="text-xs text-mono text-muted">${JSON.stringify(mw, null, 2)}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Env Tab ──
|
||||
function renderEnvTab(el) {
|
||||
let vars = [];
|
||||
let envSaving = false;
|
||||
const revealedKeys = new Set();
|
||||
|
||||
el.innerHTML = `<div class="card"><p class="text-muted">Caricamento variabili...</p></div>`;
|
||||
|
||||
async function loadVars() {
|
||||
try {
|
||||
const data = await api.getEnv(id);
|
||||
vars = data.map(v => ({ key: v.key, value: v.is_secret ? '' : v.value, is_build_arg: !!v.is_build_arg, is_secret: !!v.is_secret, original_secret: v.is_secret }));
|
||||
renderEnv();
|
||||
} catch (err) { el.innerHTML = `<div class="card"><p class="text-muted">Errore: ${err.message}</p></div>`; }
|
||||
}
|
||||
|
||||
function renderEnv() {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Variabili d'Ambiente</h3>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" id="env-add">${icons.plus(14)} Aggiungi</button>
|
||||
<button class="btn btn-primary btn-sm" id="env-save" ${envSaving ? 'disabled' : ''}>${icons.save(14)} ${envSaving ? 'Salvando...' : 'Salva'}</button>
|
||||
</div>
|
||||
</div>
|
||||
${vars.length === 0 ? `<div class="empty-state" style="padding:30px 20px"><p>Nessuna variabile configurata</p></div>` : `
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="form-row" style="grid-template-columns:1fr 1fr 80px 80px 40px;gap:8px">
|
||||
<span class="form-label" style="margin-bottom:0">Key</span><span class="form-label" style="margin-bottom:0">Value</span><span class="form-label" style="margin-bottom:0">Build</span><span class="form-label" style="margin-bottom:0">Secret</span><span></span>
|
||||
</div>
|
||||
${vars.map((v, i) => `
|
||||
<div class="form-row" style="grid-template-columns:1fr 1fr 80px 80px 40px;gap:8px;align-items:center">
|
||||
<input class="form-input mono" value="${esc(v.key)}" data-env="${i}" data-field="key" placeholder="KEY_NAME" style="padding:8px 10px;font-size:0.8rem">
|
||||
<div style="position:relative">
|
||||
<input class="form-input mono" type="${v.is_secret && !revealedKeys.has(v.key) ? 'password' : 'text'}" value="${esc(v.value)}" data-env="${i}" data-field="value" placeholder="${v.original_secret ? '(invariato)' : 'value'}" style="padding:8px 10px;font-size:0.8rem;padding-right:32px">
|
||||
${v.is_secret ? `<button class="env-reveal" data-key="${esc(v.key)}" style="position:absolute;right:6px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text-tertiary);cursor:pointer">${revealedKeys.has(v.key) ? icons.eyeOff(14) : icons.eye(14)}</button>` : ''}
|
||||
</div>
|
||||
<label class="form-checkbox" style="justify-content:center"><input type="checkbox" data-env="${i}" data-field="is_build_arg" ${v.is_build_arg ? 'checked' : ''}></label>
|
||||
<label class="form-checkbox" style="justify-content:center"><input type="checkbox" data-env="${i}" data-field="is_secret" ${v.is_secret ? 'checked' : ''}></label>
|
||||
<button class="btn btn-danger btn-sm btn-icon" data-env-rm="${i}">${icons.trash2(12)}</button>
|
||||
</div>`).join('')}
|
||||
</div>`}
|
||||
<div class="text-xs text-muted mt-4"><strong>Build</strong>: variabile passata come build arg al Dockerfile.<br><strong>Secret</strong>: il valore viene mascherato nella UI. Redeploy necessario per applicare le modifiche.</div>
|
||||
</div>`;
|
||||
|
||||
el.querySelector('#env-add').onclick = () => { vars.push({ key: '', value: '', is_build_arg: false, is_secret: false }); renderEnv(); };
|
||||
el.querySelector('#env-save').onclick = async () => {
|
||||
envSaving = true; renderEnv();
|
||||
try {
|
||||
const valid = vars.filter(v => v.key.trim());
|
||||
const toSave = valid.map(v => ({ key: v.key.trim(), value: (v.original_secret && !v.value) ? '___KEEP___' : v.value, is_build_arg: v.is_build_arg, is_secret: v.is_secret })).filter(v => v.value !== '___KEEP___');
|
||||
await api.setEnv(id, toSave);
|
||||
await loadVars();
|
||||
} catch (err) { alert('Errore: ' + err.message); }
|
||||
finally { envSaving = false; renderEnv(); }
|
||||
};
|
||||
el.querySelectorAll('[data-env][data-field]').forEach(inp => {
|
||||
const i = parseInt(inp.dataset.env), field = inp.dataset.field;
|
||||
if (inp.type === 'checkbox') { inp.onchange = () => { vars[i][field] = inp.checked; renderEnv(); }; }
|
||||
else { inp.oninput = () => { vars[i][field] = inp.value; }; }
|
||||
});
|
||||
el.querySelectorAll('[data-env-rm]').forEach(b => { b.onclick = () => { vars.splice(parseInt(b.dataset.envRm), 1); renderEnv(); }; });
|
||||
el.querySelectorAll('.env-reveal').forEach(b => {
|
||||
b.onclick = () => { const k = b.dataset.key; revealedKeys.has(k) ? revealedKeys.delete(k) : revealedKeys.add(k); renderEnv(); };
|
||||
});
|
||||
}
|
||||
loadVars();
|
||||
}
|
||||
|
||||
// ── Deploy History Tab ──
|
||||
function renderDeploysTab(el) {
|
||||
const deploys = service.deploys || [];
|
||||
let expandedId = null;
|
||||
|
||||
function fmt(ms) { if (!ms) return '–'; return ms < 1000 ? `${ms}ms` : `${(ms/1000).toFixed(1)}s`; }
|
||||
|
||||
function renderDeploys() {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Storico Deploy</h3><button class="btn btn-secondary btn-sm" id="deploy-refresh">Refresh</button></div>
|
||||
${deploys.length === 0 ? '<div class="empty-state"><p>Nessun deploy ancora. Esegui il primo deploy!</p></div>' : `
|
||||
<div class="flex flex-col gap-2">${deploys.map(d => `
|
||||
<div class="card deploy-row" data-did="${d.id}" style="padding:14px 16px;background:var(--bg-glass);cursor:pointer">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="status-badge ${d.status}">${d.status}</span>
|
||||
<div>
|
||||
<div class="text-sm font-bold">${d.trigger === 'webhook' ? '🔗 Webhook' : '👤 Manuale'}</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted mt-1">
|
||||
${d.commit_sha ? `<span class="text-mono">${icons.gitCommit(10)} ${d.commit_sha}</span>` : ''}
|
||||
${d.commit_author ? `<span>${icons.user(10)} ${d.commit_author}</span>` : ''}
|
||||
<span>${icons.clock(10)} ${fmt(d.duration_ms)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-muted">${new Date(d.created_at).toLocaleString('it-IT')}</span>
|
||||
${expandedId === d.id ? icons.chevronUp(16) : icons.chevronDown(16)}
|
||||
</div>
|
||||
</div>
|
||||
${d.commit_message ? `<p class="text-xs text-muted mt-2 truncate">${esc(d.commit_message)}</p>` : ''}
|
||||
${expandedId === d.id ? `<div class="mt-4" id="deploy-log-${d.id}"></div>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>`}
|
||||
</div>`;
|
||||
|
||||
el.querySelector('#deploy-refresh')?.addEventListener('click', () => loadService());
|
||||
el.querySelectorAll('.deploy-row').forEach(row => {
|
||||
row.onclick = (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const did = parseInt(row.dataset.did);
|
||||
expandedId = expandedId === did ? null : did;
|
||||
if (logCleanup) { logCleanup(); logCleanup = null; }
|
||||
renderDeploys();
|
||||
if (expandedId) {
|
||||
const logEl = document.getElementById(`deploy-log-${expandedId}`);
|
||||
if (logEl) logCleanup = initLogViewer(logEl, `deploy:${expandedId}`);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
renderDeploys();
|
||||
}
|
||||
|
||||
// ── Logs Tab ──
|
||||
function renderLogsTab(el) {
|
||||
el.innerHTML = `<div class="card"><h3 class="card-title mb-4">Container Logs</h3><div id="log-area"></div></div>`;
|
||||
logCleanup = initLogViewer(el.querySelector('#log-area'), `service:${id}`);
|
||||
}
|
||||
|
||||
// ── Terminal Tab ──
|
||||
function renderTerminalTab(el) {
|
||||
if (!service.container?.id) {
|
||||
el.innerHTML = `<div class="card"><h3 class="card-title mb-4">Web Terminal</h3><div class="empty-state"><p>Il container deve essere in esecuzione per usare il terminal</p></div></div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h3 class="card-title mb-4">Web Terminal</h3>
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-dots"><div class="terminal-dot red" id="term-dot"></div><div class="terminal-dot yellow"></div><div class="terminal-dot red"></div></div>
|
||||
<span class="text-xs text-muted text-mono" id="term-status">Connecting...</span>
|
||||
</div>
|
||||
<div id="term-error" class="hidden" style="padding:8px 16px;background:var(--status-error-bg);color:var(--status-error);font-size:0.8rem"></div>
|
||||
<div class="terminal-body" id="term-body" style="min-height:400px"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
termCleanup = initTerminal(el, service.container.id);
|
||||
}
|
||||
|
||||
// ── Networks Tab ──
|
||||
function renderNetworksTab(el) {
|
||||
let allNetworks = [];
|
||||
let newName = '';
|
||||
let creating = false;
|
||||
|
||||
el.innerHTML = `<div class="card"><p class="text-muted">Caricamento reti...</p></div>`;
|
||||
|
||||
async function loadNets() {
|
||||
try { allNetworks = await networksApi.list(); renderNets(); }
|
||||
catch (err) { el.innerHTML = `<div class="card"><p class="text-muted">Errore: ${err.message}</p></div>`; }
|
||||
}
|
||||
|
||||
function renderNets() {
|
||||
const sn = form.networks || [];
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h3 class="card-title mb-4">${icons.network(16)} Gestione Networks</h3>
|
||||
<p class="text-sm text-muted mb-4">Seleziona le reti Docker a cui connettere il servizio al deploy.</p>
|
||||
<div class="table-container mb-4">
|
||||
<table><thead><tr><th>Assegnata</th><th>Nome</th><th>Driver</th><th>Containers</th><th>Interna</th><th></th></tr></thead>
|
||||
<tbody>${allNetworks.map(n => `
|
||||
<tr>
|
||||
<td><label class="form-checkbox" style="justify-content:center"><input type="checkbox" data-net="${esc(n.name)}" ${sn.includes(n.name) ? 'checked' : ''}></label></td>
|
||||
<td><span class="text-mono text-sm font-bold">${n.name}</span></td>
|
||||
<td class="text-xs text-muted">${n.driver}</td>
|
||||
<td class="text-xs">${n.containers}</td>
|
||||
<td>${n.internal ? icons.check(14) : icons.x(14)}</td>
|
||||
<td>${!['meb-public','meb-private','bridge','host','none'].includes(n.name) ? `<button class="btn btn-danger btn-sm btn-icon" data-del-net="${esc(n.name)}">${icons.trash2(12)}</button>` : ''}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input class="form-input mono" id="new-net" value="${esc(newName)}" placeholder="nome-nuova-rete" style="max-width:300px">
|
||||
<button class="btn btn-secondary btn-sm" id="create-net" ${creating ? 'disabled' : ''}>${icons.plus(14)} Crea Rete</button>
|
||||
</div>
|
||||
<div class="mt-4"><button class="btn btn-primary" id="save-nets">💾 Salva Networks</button></div>
|
||||
</div>`;
|
||||
|
||||
el.querySelectorAll('[data-net]').forEach(cb => {
|
||||
cb.onchange = () => {
|
||||
const name = cb.dataset.net;
|
||||
if (cb.checked) { if (!form.networks.includes(name)) form.networks.push(name); }
|
||||
else { form.networks = form.networks.filter(n => n !== name); }
|
||||
};
|
||||
});
|
||||
el.querySelectorAll('[data-del-net]').forEach(b => {
|
||||
b.onclick = async () => {
|
||||
if (!confirm(`Eliminare la rete "${b.dataset.delNet}"?`)) return;
|
||||
try { await networksApi.remove(b.dataset.delNet); await loadNets(); }
|
||||
catch (err) { alert('Errore: ' + err.message); }
|
||||
};
|
||||
});
|
||||
el.querySelector('#new-net').oninput = (e) => { newName = e.target.value; };
|
||||
el.querySelector('#create-net').onclick = async () => {
|
||||
if (!newName.trim()) return;
|
||||
creating = true; renderNets();
|
||||
try { await networksApi.create(newName.trim()); newName = ''; await loadNets(); }
|
||||
catch (err) { alert('Errore: ' + err.message); }
|
||||
finally { creating = false; renderNets(); }
|
||||
};
|
||||
el.querySelector('#save-nets').onclick = handleSave;
|
||||
}
|
||||
loadNets();
|
||||
}
|
||||
|
||||
// ── Webhook Tab ──
|
||||
function renderWebhookTab(el) {
|
||||
const webhookUrl = `${window.location.origin}/api/webhooks/${service.webhook_id}`;
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h3 class="card-title mb-4">${icons.shield(16)} Configurazione Webhook</h3>
|
||||
<p class="text-sm text-muted mb-4">Configura questo webhook nel tuo repository Gitea per abilitare il deploy automatico ad ogni push.</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Webhook URL</label>
|
||||
<div class="flex gap-2"><input class="form-input mono" value="${webhookUrl}" readonly style="flex:1" id="wh-url"><button class="btn btn-secondary btn-sm" id="wh-copy">${icons.copy(14)}</button></div>
|
||||
<div class="form-hint">Usa questo URL come Target URL nel webhook di Gitea</div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">Webhook ID</label><input class="form-input mono text-xs" value="${service.webhook_id}" readonly></div>
|
||||
<div class="card mt-4" style="background:var(--bg-glass);padding:20px">
|
||||
<h4 class="card-title mb-2">📋 Istruzioni Setup</h4>
|
||||
<ol class="text-sm" style="padding-left:20px;line-height:2">
|
||||
<li>Vai al repository su Gitea → <strong>Settings</strong> → <strong>Webhooks</strong></li>
|
||||
<li>Clicca <strong>Add Webhook</strong> → seleziona <strong>Gitea</strong></li>
|
||||
<li>Incolla il <strong>Target URL</strong> sopra</li>
|
||||
<li>Imposta il <strong>Secret</strong> uguale al <code>WEBHOOK_SECRET</code> configurato nel <code>.env</code></li>
|
||||
<li>In <strong>Trigger On</strong>, seleziona <strong>Push Events</strong></li>
|
||||
<li>Assicurati che il <strong>branch</strong> (<code>${service.gitea_branch}</code>) corrisponda al branch monitorato</li>
|
||||
<li>Clicca <strong>Add Webhook</strong></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="card mt-4" style="background:var(--accent-muted);padding:16px;border-color:rgba(99,102,241,0.2)">
|
||||
<p class="text-sm"><strong>💡 Nota:</strong> Se Gitea e AutoDeployer sono sulla stessa rete Docker (<code>meb-public</code>), il webhook bypassa Cloudflare automaticamente. L'URL interno sarà: <code class="text-mono">http://autodeployer:3000/api/webhooks/${service.webhook_id}</code></p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
el.querySelector('#wh-copy').onclick = () => navigator.clipboard.writeText(webhookUrl);
|
||||
}
|
||||
|
||||
loadService();
|
||||
return () => cleanupTab();
|
||||
}
|
||||
|
||||
// ── Log Viewer (reusable) ──
|
||||
function initLogViewer(el, target) {
|
||||
let lines = [];
|
||||
let paused = false;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-muted" id="lv-count">0 righe</span>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" id="lv-pause">${icons.pause(12)} Pause</button>
|
||||
<button class="btn btn-secondary btn-sm" id="lv-download">${icons.download(12)} Download</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-viewer" id="lv-container"><div class="log-line info">In attesa di log...</div></div>`;
|
||||
|
||||
const ws = createLogSocket(target);
|
||||
const container = el.querySelector('#lv-container');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'log' || msg.type === 'info' || msg.type === 'error') {
|
||||
lines.push(msg);
|
||||
if (lines.length > 2000) lines = lines.slice(-1500);
|
||||
const div = document.createElement('div');
|
||||
div.className = `log-line ${msg.type}`;
|
||||
div.textContent = msg.data;
|
||||
if (container.firstChild?.classList?.contains('info') && lines.length === 1) container.innerHTML = '';
|
||||
container.appendChild(div);
|
||||
el.querySelector('#lv-count').textContent = `${lines.length} righe`;
|
||||
if (!paused) container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
ws.onerror = () => { appendLine('error', 'WebSocket connection error'); };
|
||||
ws.onclose = () => { appendLine('info', '— Stream ended —'); };
|
||||
|
||||
function appendLine(type, text) {
|
||||
lines.push({ type, data: text });
|
||||
const div = document.createElement('div');
|
||||
div.className = `log-line ${type}`;
|
||||
div.textContent = text;
|
||||
container.appendChild(div);
|
||||
if (!paused) container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
el.querySelector('#lv-pause').onclick = () => {
|
||||
paused = !paused;
|
||||
el.querySelector('#lv-pause').innerHTML = paused ? `${icons.play(12)} Resume` : `${icons.pause(12)} Pause`;
|
||||
};
|
||||
el.querySelector('#lv-download').onclick = () => {
|
||||
const blob = new Blob([lines.map(l => l.data).join('\n')], { type: 'text/plain' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `logs-${target.replace(':', '-')}-${Date.now()}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
|
||||
return () => { ws.close(); };
|
||||
}
|
||||
|
||||
// ── Terminal (xterm.js) ──
|
||||
function initTerminal(el, containerId) {
|
||||
let ws, term, fitAddon, resizeObs;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const Terminal = globalThis.Terminal;
|
||||
const FitAddon = globalThis.FitAddon;
|
||||
|
||||
term = new Terminal({
|
||||
theme: {
|
||||
background: '#0d1117', foreground: '#c9d1d9', cursor: '#f0f6fc',
|
||||
cursorAccent: '#0d1117', selectionBackground: 'rgba(56,139,253,0.3)',
|
||||
black: '#0d1117', red: '#ff7b72', green: '#7ee787', yellow: '#d29922',
|
||||
blue: '#79c0ff', magenta: '#d2a8ff', cyan: '#a5d6ff', white: '#c9d1d9',
|
||||
},
|
||||
fontFamily: "'JetBrains Mono','Fira Code',monospace",
|
||||
fontSize: 13, lineHeight: 1.4, cursorBlink: true, cursorStyle: 'bar', scrollback: 5000,
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(el.querySelector('#term-body'));
|
||||
fitAddon.fit();
|
||||
|
||||
ws = createTerminalSocket(containerId);
|
||||
ws.onopen = () => {
|
||||
el.querySelector('#term-dot').className = 'terminal-dot green';
|
||||
el.querySelector('#term-status').textContent = `Connected — ${containerId.slice(0, 12)}`;
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'output') term.write(msg.data);
|
||||
else if (msg.type === 'info') term.writeln(`\r\n\x1b[36m${msg.data}\x1b[0m\r\n`);
|
||||
else if (msg.type === 'error') { term.writeln(`\r\n\x1b[31m${msg.data}\x1b[0m\r\n`); showTermError(msg.data); }
|
||||
} catch { term.write(event.data); }
|
||||
};
|
||||
ws.onclose = () => {
|
||||
el.querySelector('#term-dot').className = 'terminal-dot red';
|
||||
el.querySelector('#term-status').textContent = 'Disconnected';
|
||||
term.writeln('\r\n\x1b[33m— Session closed —\x1b[0m');
|
||||
};
|
||||
ws.onerror = () => showTermError('WebSocket connection failed');
|
||||
|
||||
term.onData((data) => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data })); });
|
||||
|
||||
resizeObs = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
});
|
||||
resizeObs.observe(el.querySelector('#term-body'));
|
||||
} catch (err) {
|
||||
showTermError('Failed to initialize terminal: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showTermError(msg) {
|
||||
const errEl = el.querySelector('#term-error');
|
||||
errEl.textContent = msg;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
init();
|
||||
return () => {
|
||||
if (resizeObs) resizeObs.disconnect();
|
||||
if (ws) ws.close();
|
||||
if (term) term.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
168
dashboard/js/pages/settings.js
Normal file
168
dashboard/js/pages/settings.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { settingsApi, auth, system as systemApi, services as servicesApi } from '../api.js';
|
||||
import { icons } from '../icons.js';
|
||||
|
||||
export function renderSettings(container) {
|
||||
let queueStatus = null;
|
||||
let version = null;
|
||||
let interval;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="page-header"><div><h2>Settings</h2><p>Configurazione globale e test connessioni</p></div></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px" id="settings-grid">
|
||||
<!-- Gitea -->
|
||||
<div class="card" id="s-gitea">
|
||||
<h3 class="card-title mb-4">${icons.gitBranch(16)} Connessione Gitea</h3>
|
||||
<p class="text-sm text-muted mb-4">Testa la connessione al server Gitea configurato</p>
|
||||
<button class="btn btn-secondary" id="test-gitea">Test Connessione</button>
|
||||
<div id="gitea-result" class="mt-4 text-sm hidden"></div>
|
||||
</div>
|
||||
<!-- Telegram -->
|
||||
<div class="card" id="s-telegram">
|
||||
<h3 class="card-title mb-4">${icons.bell(16)} Notifiche Telegram</h3>
|
||||
<p class="text-sm text-muted mb-4">Invia un messaggio di test al bot Telegram configurato</p>
|
||||
<button class="btn btn-secondary" id="test-telegram">Test Telegram</button>
|
||||
<div id="telegram-result" class="mt-4 text-sm hidden"></div>
|
||||
</div>
|
||||
<!-- Queue -->
|
||||
<div class="card" id="s-queue">
|
||||
<h3 class="card-title mb-4">${icons.database(16)} Build Queue</h3>
|
||||
<div id="queue-content"><p class="text-muted text-sm">Caricamento...</p></div>
|
||||
</div>
|
||||
<!-- Password -->
|
||||
<div class="card">
|
||||
<h3 class="card-title mb-4">${icons.key(16)} Cambia Password</h3>
|
||||
<form id="pw-form">
|
||||
<div class="form-group"><label class="form-label">Password Attuale</label><input type="password" class="form-input" id="pw-current" required></div>
|
||||
<div class="form-group"><label class="form-label">Nuova Password</label><input type="password" class="form-input" id="pw-new" required minlength="12"><div class="form-hint">Minimo 12 caratteri</div></div>
|
||||
<div class="form-group"><label class="form-label">Conferma</label><input type="password" class="form-input" id="pw-confirm" required></div>
|
||||
<div id="pw-msg" class="text-sm mb-2 hidden"></div>
|
||||
<button type="submit" class="btn btn-primary">Aggiorna Password</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Self-Update -->
|
||||
<div class="card">
|
||||
<h3 class="card-title mb-4">${icons.refreshCw(16)} Self-Update</h3>
|
||||
<div id="version-info" class="text-sm text-muted mb-4"></div>
|
||||
<p class="text-sm text-muted mb-4">Aggiorna AutoDeployer all'ultima versione. Il servizio si riavvierà automaticamente.</p>
|
||||
<button class="btn btn-primary" id="self-update">Aggiorna AutoDeployer</button>
|
||||
<div id="update-result" class="mt-4 text-sm hidden"></div>
|
||||
</div>
|
||||
<!-- Cleanup -->
|
||||
<div class="card">
|
||||
<h3 class="card-title mb-4">${icons.trash2(16)} Pulizia Container Orfani</h3>
|
||||
<p class="text-sm text-muted mb-4">Rimuovi container temporanei rimasti da deploy falliti.</p>
|
||||
<button class="btn btn-secondary" id="cleanup-btn">Scansiona e Pulisci</button>
|
||||
<div id="cleanup-result" class="mt-4 text-sm hidden"></div>
|
||||
</div>
|
||||
<!-- Prune -->
|
||||
<div class="card">
|
||||
<h3 class="card-title mb-4">${icons.hardDrive(16)} Pulizia Immagini Vecchie</h3>
|
||||
<p class="text-sm text-muted mb-4">Rimuovi immagini Docker obsolete, mantenendo le 2 più recenti per servizio.</p>
|
||||
<button class="btn btn-secondary" id="prune-btn">Pulisci Immagini</button>
|
||||
<div id="prune-result" class="mt-4 text-sm hidden"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Gitea test
|
||||
document.getElementById('test-gitea').onclick = async () => {
|
||||
const btn = document.getElementById('test-gitea');
|
||||
btn.textContent = '⏳ Testing...'; btn.disabled = true;
|
||||
const res = document.getElementById('gitea-result');
|
||||
try {
|
||||
const r = await settingsApi.testGitea();
|
||||
res.innerHTML = r.ok ? `${icons.checkCircle(16)} Connesso come <strong>${r.user}</strong>` : `${icons.xCircle(16)} ${r.error}`;
|
||||
} catch (err) { res.innerHTML = `${icons.xCircle(16)} ${err.message}`; }
|
||||
res.classList.remove('hidden'); btn.textContent = 'Test Connessione'; btn.disabled = false;
|
||||
};
|
||||
|
||||
// Telegram test
|
||||
document.getElementById('test-telegram').onclick = async () => {
|
||||
const btn = document.getElementById('test-telegram');
|
||||
btn.textContent = '⏳ Testing...'; btn.disabled = true;
|
||||
const res = document.getElementById('telegram-result');
|
||||
try {
|
||||
const r = await settingsApi.testTelegram();
|
||||
res.innerHTML = r.ok ? `${icons.checkCircle(16)} Messaggio inviato` : `${icons.xCircle(16)} ${r.error}`;
|
||||
} catch (err) { res.innerHTML = `${icons.xCircle(16)} ${err.message}`; }
|
||||
res.classList.remove('hidden'); btn.textContent = 'Test Telegram'; btn.disabled = false;
|
||||
};
|
||||
|
||||
// Queue status
|
||||
async function loadQueue() {
|
||||
try {
|
||||
queueStatus = await settingsApi.queueStatus();
|
||||
document.getElementById('queue-content').innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><div class="metric-label">In Attesa</div><div class="metric-value" style="font-size:1.5rem">${queueStatus.waiting}</div></div>
|
||||
<div><div class="metric-label">Attive</div><div class="metric-value" style="font-size:1.5rem">${queueStatus.active}</div></div>
|
||||
<div><div class="metric-label">Completate</div><div class="text-sm font-bold" style="color:var(--status-running)">${queueStatus.completed}</div></div>
|
||||
<div><div class="metric-label">Fallite</div><div class="text-sm font-bold" style="color:var(--status-error)">${queueStatus.failed}</div></div>
|
||||
</div>`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Version
|
||||
async function loadVersion() {
|
||||
try {
|
||||
version = await systemApi.version();
|
||||
document.getElementById('version-info').innerHTML = `Versione: <strong class="mono">${version.commit}</strong> (${version.branch})<br><span class="text-xs">${version.date}</span>`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Password
|
||||
document.getElementById('pw-form').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById('pw-msg');
|
||||
msg.classList.add('hidden');
|
||||
const newPw = document.getElementById('pw-new').value;
|
||||
const confirm = document.getElementById('pw-confirm').value;
|
||||
if (newPw !== confirm) { msg.textContent = 'Le password non coincidono'; msg.classList.remove('hidden'); return; }
|
||||
try {
|
||||
await auth.changePassword(document.getElementById('pw-current').value, newPw);
|
||||
msg.textContent = '✅ Password aggiornata'; msg.classList.remove('hidden');
|
||||
document.getElementById('pw-form').reset();
|
||||
} catch (err) { msg.textContent = '❌ ' + err.message; msg.classList.remove('hidden'); }
|
||||
};
|
||||
|
||||
// Self-update
|
||||
document.getElementById('self-update').onclick = async () => {
|
||||
const btn = document.getElementById('self-update');
|
||||
btn.textContent = 'Aggiornamento...'; btn.disabled = true;
|
||||
const res = document.getElementById('update-result');
|
||||
try {
|
||||
await systemApi.selfUpdate();
|
||||
res.innerHTML = `${icons.checkCircle(14)} Update avviato. AutoDeployer si riavvierà a breve.`;
|
||||
} catch (err) { res.innerHTML = `${icons.xCircle(14)} ${err.message}`; }
|
||||
res.classList.remove('hidden'); btn.textContent = 'Aggiorna AutoDeployer'; btn.disabled = false;
|
||||
};
|
||||
|
||||
// Cleanup
|
||||
document.getElementById('cleanup-btn').onclick = async () => {
|
||||
const btn = document.getElementById('cleanup-btn');
|
||||
btn.textContent = 'Scansione...'; btn.disabled = true;
|
||||
const res = document.getElementById('cleanup-result');
|
||||
try {
|
||||
const r = await servicesApi.cleanup();
|
||||
res.textContent = `${r.orphans_found} orfani trovati, ${r.results?.filter(x => x.status === 'removed').length || 0} rimossi`;
|
||||
} catch (err) { res.innerHTML = `<span style="color:var(--status-error)">${err.message}</span>`; }
|
||||
res.classList.remove('hidden'); btn.textContent = 'Scansiona e Pulisci'; btn.disabled = false;
|
||||
};
|
||||
|
||||
// Prune
|
||||
document.getElementById('prune-btn').onclick = async () => {
|
||||
const btn = document.getElementById('prune-btn');
|
||||
btn.textContent = 'Pulizia...'; btn.disabled = true;
|
||||
const res = document.getElementById('prune-result');
|
||||
try {
|
||||
const r = await servicesApi.pruneImages();
|
||||
res.textContent = `${r.results?.filter(x => x.status === 'removed').length || 0} immagini rimosse`;
|
||||
} catch (err) { res.innerHTML = `<span style="color:var(--status-error)">${err.message}</span>`; }
|
||||
res.classList.remove('hidden'); btn.textContent = 'Pulisci Immagini'; btn.disabled = false;
|
||||
};
|
||||
|
||||
loadQueue();
|
||||
loadVersion();
|
||||
interval = setInterval(loadQueue, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
74
dashboard/js/router.js
Normal file
74
dashboard/js/router.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// Simple hash-based SPA router
|
||||
const routes = {};
|
||||
let currentCleanup = null;
|
||||
|
||||
export function route(path, handler) {
|
||||
routes[path] = handler;
|
||||
}
|
||||
|
||||
export function navigate(path) {
|
||||
window.location.hash = path;
|
||||
}
|
||||
|
||||
export function currentPath() {
|
||||
return window.location.hash.slice(1) || '/';
|
||||
}
|
||||
|
||||
export function getParam(name) {
|
||||
const path = currentPath();
|
||||
// Match patterns like /services/:id
|
||||
for (const pattern of Object.keys(routes)) {
|
||||
const paramNames = [];
|
||||
const regex = pattern.replace(/:(\w+)/g, (_, name) => {
|
||||
paramNames.push(name);
|
||||
return '([^/]+)';
|
||||
});
|
||||
const match = path.match(new RegExp(`^${regex}$`));
|
||||
if (match) {
|
||||
const idx = paramNames.indexOf(name);
|
||||
if (idx >= 0) return match[idx + 1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function startRouter(container) {
|
||||
function handleRoute() {
|
||||
const path = currentPath();
|
||||
|
||||
// Cleanup previous page
|
||||
if (currentCleanup) { currentCleanup(); currentCleanup = null; }
|
||||
|
||||
// Find matching route
|
||||
for (const [pattern, handler] of Object.entries(routes)) {
|
||||
const paramNames = [];
|
||||
const regex = pattern.replace(/:(\w+)/g, (_, n) => { paramNames.push(n); return '([^/]+)'; });
|
||||
const match = path.match(new RegExp(`^${regex}$`));
|
||||
if (match) {
|
||||
const params = {};
|
||||
paramNames.forEach((n, i) => params[n] = match[i + 1]);
|
||||
const cleanup = handler(container, params);
|
||||
if (typeof cleanup === 'function') currentCleanup = cleanup;
|
||||
updateActiveLink(path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: redirect to /
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', handleRoute);
|
||||
handleRoute();
|
||||
}
|
||||
|
||||
function updateActiveLink(path) {
|
||||
document.querySelectorAll('.sidebar-link[data-path]').forEach(el => {
|
||||
const linkPath = el.dataset.path;
|
||||
if (linkPath === '/') {
|
||||
el.classList.toggle('active', path === '/');
|
||||
} else {
|
||||
el.classList.toggle('active', path.startsWith(linkPath));
|
||||
}
|
||||
});
|
||||
}
|
||||
20
dashboard/lib/chart.min.js
vendored
Normal file
20
dashboard/lib/chart.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
dashboard/lib/xterm-addon-fit.min.js
vendored
Normal file
8
dashboard/lib/xterm-addon-fit.min.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
8
dashboard/lib/xterm.min.css
vendored
Normal file
8
dashboard/lib/xterm.min.css
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Minified by jsDelivr using clean-css v5.3.3.
|
||||
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
|
||||
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */
|
||||
8
dashboard/lib/xterm.min.js
vendored
Normal file
8
dashboard/lib/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
autodeployer:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: autodeployer
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
|
||||
- REDIS_URL=redis://redis:6379/2
|
||||
- GITEA_URL=http://gitea:3000
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
||||
- INFLUXDB_URL=${INFLUXDB_URL:-http://influxdb:8086}
|
||||
- INFLUXDB_TOKEN=${INFLUXDB_TOKEN}
|
||||
- INFLUXDB_ORG=${INFLUXDB_ORG:-autodeployer}
|
||||
- INFLUXDB_BUCKET=${INFLUXDB_BUCKET:-metrics}
|
||||
- DEPLOY_DOMAIN=${DEPLOY_DOMAIN:-deploy.mebboat.it}
|
||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||
- UPDATE_SECRET=${UPDATE_SECRET:-}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- autodeployer-data:/app/data
|
||||
- autodeployer-builds:/tmp/builds
|
||||
- updater-trigger:/app/trigger
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.autodeployer.rule=Host(`${DEPLOY_DOMAIN:-deploy.mebboat.it}`)"
|
||||
- "traefik.http.routers.autodeployer.entrypoints=websecure"
|
||||
- "traefik.http.routers.autodeployer.tls.certresolver=cloudflare"
|
||||
- "traefik.http.services.autodeployer.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=meb-public"
|
||||
networks:
|
||||
- meb-public
|
||||
- meb-private
|
||||
depends_on: []
|
||||
|
||||
updater:
|
||||
build:
|
||||
context: ./updater
|
||||
dockerfile: Dockerfile
|
||||
container_name: autodeployer-updater
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- .:/project:ro
|
||||
- updater-trigger:/trigger
|
||||
working_dir: /project
|
||||
networks: []
|
||||
|
||||
volumes:
|
||||
autodeployer-data:
|
||||
autodeployer-builds:
|
||||
updater-trigger:
|
||||
|
||||
networks:
|
||||
meb-public:
|
||||
external: true
|
||||
meb-private:
|
||||
external: true
|
||||
27
server/package.json
Normal file
27
server/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "autodeployer-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Self-hosted deployment platform — API server",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dockerode": "^4.0.4",
|
||||
"express": "^4.21.2",
|
||||
"ioredis": "^5.4.2",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"ws": "^8.18.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"@influxdata/influxdb-client": "^1.35.0"
|
||||
}
|
||||
}
|
||||
39
server/src/config.js
Normal file
39
server/src/config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const config = {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
// JWT
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-me',
|
||||
jwtAccessExpiry: '15m',
|
||||
jwtRefreshExpiry: '7d',
|
||||
|
||||
// Redis
|
||||
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379/2',
|
||||
|
||||
// Gitea
|
||||
giteaUrl: process.env.GITEA_URL || 'http://gitea:3000',
|
||||
giteaToken: process.env.GITEA_TOKEN || '',
|
||||
|
||||
// Telegram
|
||||
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
telegramChatId: process.env.TELEGRAM_CHAT_ID || '',
|
||||
|
||||
// InfluxDB
|
||||
influxUrl: process.env.INFLUXDB_URL || 'http://influxdb:8086',
|
||||
influxToken: process.env.INFLUXDB_TOKEN || '',
|
||||
influxOrg: process.env.INFLUXDB_ORG || 'autodeployer',
|
||||
influxBucket: process.env.INFLUXDB_BUCKET || 'metrics',
|
||||
|
||||
// Domain
|
||||
deployDomain: process.env.DEPLOY_DOMAIN || 'deploy.example.com',
|
||||
|
||||
// Webhook
|
||||
webhookSecret: process.env.WEBHOOK_SECRET || 'default-webhook-secret',
|
||||
|
||||
// Paths
|
||||
dataDir: process.env.DATA_DIR || '/app/data',
|
||||
buildsDir: process.env.BUILDS_DIR || '/tmp/builds',
|
||||
dbPath: process.env.DB_PATH || '/app/data/autodeployer.db',
|
||||
};
|
||||
|
||||
export default config;
|
||||
235
server/src/db/index.js
Normal file
235
server/src/db/index.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import config from '../config.js';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
// Ensure data directory exists
|
||||
mkdirSync(dirname(config.dbPath), { recursive: true });
|
||||
|
||||
const db = new Database(config.dbPath);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────
|
||||
function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
|
||||
-- Gitea config
|
||||
gitea_repo_url TEXT NOT NULL,
|
||||
gitea_branch TEXT NOT NULL DEFAULT 'main',
|
||||
|
||||
-- Build config
|
||||
dockerfile_path TEXT NOT NULL DEFAULT 'Dockerfile',
|
||||
build_context TEXT NOT NULL DEFAULT '.',
|
||||
build_args TEXT DEFAULT '{}',
|
||||
|
||||
-- Container config
|
||||
container_name TEXT NOT NULL UNIQUE,
|
||||
container_port INTEGER NOT NULL DEFAULT 3000,
|
||||
|
||||
-- Traefik config
|
||||
traefik_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
traefik_domain TEXT DEFAULT '',
|
||||
traefik_entrypoints TEXT NOT NULL DEFAULT 'websecure',
|
||||
traefik_tls_resolver TEXT DEFAULT 'cloudflare',
|
||||
traefik_path_prefix TEXT DEFAULT '',
|
||||
traefik_middlewares TEXT DEFAULT '[]',
|
||||
traefik_network TEXT NOT NULL DEFAULT 'meb-public',
|
||||
|
||||
-- Networks
|
||||
networks TEXT NOT NULL DEFAULT '["meb-public"]',
|
||||
|
||||
-- Health check
|
||||
health_check_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
health_check_path TEXT DEFAULT '/health',
|
||||
health_check_interval INTEGER DEFAULT 10,
|
||||
health_check_timeout INTEGER DEFAULT 5,
|
||||
health_check_retries INTEGER DEFAULT 3,
|
||||
|
||||
-- Zero-downtime
|
||||
zero_downtime INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Status
|
||||
status TEXT NOT NULL DEFAULT 'stopped',
|
||||
current_image TEXT DEFAULT '',
|
||||
current_container_id TEXT DEFAULT '',
|
||||
|
||||
-- Webhook
|
||||
webhook_id TEXT NOT NULL UNIQUE,
|
||||
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS env_vars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
is_build_arg INTEGER NOT NULL DEFAULT 0,
|
||||
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
|
||||
UNIQUE(service_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deploys (
|
||||
id TEXT PRIMARY KEY,
|
||||
service_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
trigger TEXT NOT NULL DEFAULT 'manual',
|
||||
commit_sha TEXT DEFAULT '',
|
||||
commit_message TEXT DEFAULT '',
|
||||
commit_author TEXT DEFAULT '',
|
||||
image_tag TEXT DEFAULT '',
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deploy_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
deploy_id TEXT NOT NULL,
|
||||
stream TEXT NOT NULL DEFAULT 'stdout',
|
||||
message TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (deploy_id) REFERENCES deploys(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deploys_service ON deploys(service_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deploys_created ON deploys(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_deploy_logs_deploy ON deploy_logs(deploy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_env_vars_service ON env_vars(service_id);
|
||||
`);
|
||||
}
|
||||
|
||||
migrate();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
// Admin
|
||||
export const getAdmin = () => db.prepare('SELECT * FROM admin WHERE id = 1').get();
|
||||
export const createAdmin = (username, passwordHash) =>
|
||||
db.prepare('INSERT OR REPLACE INTO admin (id, username, password_hash) VALUES (1, ?, ?)').run(username, passwordHash);
|
||||
|
||||
// Services
|
||||
export const getAllServices = () => db.prepare('SELECT * FROM services ORDER BY name').all();
|
||||
export const getServiceById = (id) => db.prepare('SELECT * FROM services WHERE id = ?').get(id);
|
||||
export const getServiceByWebhookId = (webhookId) => db.prepare('SELECT * FROM services WHERE webhook_id = ?').get(webhookId);
|
||||
export const getServiceByName = (name) => db.prepare('SELECT * FROM services WHERE name = ?').get(name);
|
||||
|
||||
export const createService = (service) => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO services (id, name, description, gitea_repo_url, gitea_branch,
|
||||
dockerfile_path, build_context, build_args, container_name, container_port,
|
||||
traefik_enabled, traefik_domain, traefik_entrypoints, traefik_tls_resolver,
|
||||
traefik_path_prefix, traefik_middlewares, traefik_network, networks,
|
||||
health_check_enabled, health_check_path, health_check_interval,
|
||||
health_check_timeout, health_check_retries, zero_downtime, webhook_id)
|
||||
VALUES (@id, @name, @description, @gitea_repo_url, @gitea_branch,
|
||||
@dockerfile_path, @build_context, @build_args, @container_name, @container_port,
|
||||
@traefik_enabled, @traefik_domain, @traefik_entrypoints, @traefik_tls_resolver,
|
||||
@traefik_path_prefix, @traefik_middlewares, @traefik_network, @networks,
|
||||
@health_check_enabled, @health_check_path, @health_check_interval,
|
||||
@health_check_timeout, @health_check_retries, @zero_downtime, @webhook_id)
|
||||
`);
|
||||
return stmt.run(service);
|
||||
};
|
||||
|
||||
export const updateService = (id, updates) => {
|
||||
const fields = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
|
||||
const stmt = db.prepare(`UPDATE services SET ${fields}, updated_at = datetime('now') WHERE id = @id`);
|
||||
return stmt.run({ id, ...updates });
|
||||
};
|
||||
|
||||
export const deleteService = (id) => db.prepare('DELETE FROM services WHERE id = ?').run(id);
|
||||
|
||||
// Environment Variables
|
||||
export const getEnvVars = (serviceId) =>
|
||||
db.prepare('SELECT * FROM env_vars WHERE service_id = ? ORDER BY key').all(serviceId);
|
||||
|
||||
export const setEnvVar = (serviceId, key, value, isBuildArg = false, isSecret = false) =>
|
||||
db.prepare(`
|
||||
INSERT INTO env_vars (service_id, key, value, is_build_arg, is_secret)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(service_id, key) DO UPDATE SET value = ?, is_build_arg = ?, is_secret = ?
|
||||
`).run(serviceId, key, value, isBuildArg ? 1 : 0, isSecret ? 1 : 0, value, isBuildArg ? 1 : 0, isSecret ? 1 : 0);
|
||||
|
||||
export const deleteEnvVar = (serviceId, key) =>
|
||||
db.prepare('DELETE FROM env_vars WHERE service_id = ? AND key = ?').run(serviceId, key);
|
||||
|
||||
export const bulkSetEnvVars = db.transaction((serviceId, vars) => {
|
||||
db.prepare('DELETE FROM env_vars WHERE service_id = ?').run(serviceId);
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO env_vars (service_id, key, value, is_build_arg, is_secret)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const v of vars) {
|
||||
stmt.run(serviceId, v.key, v.value, v.is_build_arg ? 1 : 0, v.is_secret ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Deploys
|
||||
export const getDeploysByService = (serviceId, limit = 20) =>
|
||||
db.prepare('SELECT * FROM deploys WHERE service_id = ? ORDER BY created_at DESC LIMIT ?').all(serviceId, limit);
|
||||
|
||||
export const getDeployById = (id) => db.prepare('SELECT * FROM deploys WHERE id = ?').get(id);
|
||||
|
||||
export const createDeploy = (deploy) => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO deploys (id, service_id, status, trigger, commit_sha, commit_message, commit_author, image_tag)
|
||||
VALUES (@id, @service_id, @status, @trigger, @commit_sha, @commit_message, @commit_author, @image_tag)
|
||||
`);
|
||||
return stmt.run(deploy);
|
||||
};
|
||||
|
||||
export const updateDeploy = (id, updates) => {
|
||||
const fields = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
|
||||
return db.prepare(`UPDATE deploys SET ${fields} WHERE id = @id`).run({ id, ...updates });
|
||||
};
|
||||
|
||||
// Deploy Logs
|
||||
export const getDeployLogs = (deployId) =>
|
||||
db.prepare('SELECT * FROM deploy_logs WHERE deploy_id = ? ORDER BY id').all(deployId);
|
||||
|
||||
export const addDeployLog = (deployId, message, stream = 'stdout') =>
|
||||
db.prepare('INSERT INTO deploy_logs (deploy_id, stream, message) VALUES (?, ?, ?)').run(deployId, stream, message);
|
||||
|
||||
// Settings
|
||||
export const getSetting = (key) => {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row ? row.value : null;
|
||||
};
|
||||
|
||||
export const setSetting = (key, value) =>
|
||||
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')`)
|
||||
.run(key, value, value);
|
||||
|
||||
export const getAllSettings = () => {
|
||||
const rows = db.prepare('SELECT * FROM settings').all();
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
};
|
||||
|
||||
export default db;
|
||||
147
server/src/index.js
Normal file
147
server/src/index.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import helmet from 'helmet';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { URL } from 'url';
|
||||
|
||||
import config from './config.js';
|
||||
import { requireAuth, requireSetup, authenticateWs } from './middleware/auth.js';
|
||||
|
||||
// Routes
|
||||
import authRoutes from './routes/auth.js';
|
||||
import servicesRoutes from './routes/services.js';
|
||||
import webhooksRoutes from './routes/webhooks.js';
|
||||
import deploysRoutes from './routes/deploys.js';
|
||||
import logsRoutes from './routes/logs.js';
|
||||
import networksRoutes from './routes/networks.js';
|
||||
import monitoringRoutes from './routes/monitoring.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import systemRoutes from './routes/system.js';
|
||||
|
||||
// WebSocket
|
||||
import { handleLogStream } from './ws/logs.js';
|
||||
import { handleTerminal } from './ws/terminal.js';
|
||||
|
||||
// Services init
|
||||
import { initQueue } from './services/queue.js';
|
||||
import { startMonitoringCollector } from './services/monitoring.js';
|
||||
import { startPeriodicCleanup } from './services/cleanup.js';
|
||||
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
// ─── Security ─────────────────────────────────────────────
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false, // Disable CSP for SPA
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: config.nodeEnv === 'development' ? 'http://localhost:5173' : true,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// ─── Health Check ─────────────────────────────────────────
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// ─── Auth Routes (no auth required) ──────────────────────
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// ─── Webhook Routes (authenticated via secret, not JWT) ──
|
||||
app.use('/api/webhooks', webhooksRoutes);
|
||||
|
||||
// ─── Protected API Routes ────────────────────────────────
|
||||
app.use('/api/services', requireAuth, servicesRoutes);
|
||||
app.use('/api/deploys', requireAuth, deploysRoutes);
|
||||
app.use('/api/logs', requireAuth, logsRoutes);
|
||||
app.use('/api/networks', requireAuth, networksRoutes);
|
||||
app.use('/api/monitoring', requireAuth, monitoringRoutes);
|
||||
app.use('/api/settings', requireAuth, settingsRoutes);
|
||||
app.use('/api/system', requireAuth, systemRoutes);
|
||||
|
||||
// ─── Serve Static Dashboard ──────────────────────────────
|
||||
const publicDir = join(__dirname, '..', 'public');
|
||||
if (existsSync(publicDir)) {
|
||||
app.use(express.static(publicDir));
|
||||
// SPA fallback: serve index.html for all non-API routes
|
||||
app.get('*', (req, res) => {
|
||||
if (!req.path.startsWith('/api')) {
|
||||
res.sendFile(join(publicDir, 'index.html'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── WebSocket Server ─────────────────────────────────────
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
// Authenticate WebSocket connections
|
||||
const payload = authenticateWs(token);
|
||||
if (!payload) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname.startsWith('/ws/logs/')) {
|
||||
const target = pathname.replace('/ws/logs/', '');
|
||||
handleLogStream(ws, target);
|
||||
} else if (pathname.startsWith('/ws/terminal/')) {
|
||||
const containerId = pathname.replace('/ws/terminal/', '');
|
||||
handleTerminal(ws, containerId);
|
||||
} else {
|
||||
ws.close(4004, 'Unknown WebSocket path');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Handler ────────────────────────────────────────
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[ERROR]', err.message);
|
||||
res.status(err.status || 500).json({
|
||||
error: config.nodeEnv === 'production' ? 'Internal server error' : err.message,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────
|
||||
async function start() {
|
||||
try {
|
||||
// Initialize build queue
|
||||
await initQueue();
|
||||
console.log('[QUEUE] Build queue initialized');
|
||||
|
||||
// Start monitoring collector
|
||||
startMonitoringCollector();
|
||||
console.log('[MONITORING] Metrics collector started');
|
||||
|
||||
// Start periodic cleanup (orphan containers + old images)
|
||||
startPeriodicCleanup();
|
||||
console.log('[CLEANUP] Periodic cleanup scheduled');
|
||||
|
||||
server.listen(config.port, '0.0.0.0', () => {
|
||||
console.log(`[SERVER] AutoDeployer running on port ${config.port}`);
|
||||
console.log(`[SERVER] Environment: ${config.nodeEnv}`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[FATAL]', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
86
server/src/middleware/auth.js
Normal file
86
server/src/middleware/auth.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import config from '../config.js';
|
||||
import { getAdmin } from '../db/index.js';
|
||||
|
||||
// ─── Password Hashing ────────────────────────────────────
|
||||
const SALT_ROUNDS = 12;
|
||||
export const hashPassword = (password) => bcrypt.hashSync(password, SALT_ROUNDS);
|
||||
export const verifyPassword = (password, hash) => bcrypt.compareSync(password, hash);
|
||||
|
||||
// ─── JWT ──────────────────────────────────────────────────
|
||||
export const generateAccessToken = (userId) =>
|
||||
jwt.sign({ sub: userId, type: 'access' }, config.jwtSecret, { expiresIn: config.jwtAccessExpiry });
|
||||
|
||||
export const generateRefreshToken = (userId) =>
|
||||
jwt.sign({ sub: userId, type: 'refresh' }, config.jwtSecret, { expiresIn: config.jwtRefreshExpiry });
|
||||
|
||||
export const verifyToken = (token) => {
|
||||
try {
|
||||
return jwt.verify(token, config.jwtSecret);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Rate Limiter ─────────────────────────────────────────
|
||||
export const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // 5 attempts
|
||||
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// ─── Auth Middleware ──────────────────────────────────────
|
||||
export const requireAuth = (req, res, next) => {
|
||||
// Check Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
let token = null;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.slice(7);
|
||||
}
|
||||
|
||||
// Fallback to cookie
|
||||
if (!token && req.cookies?.accessToken) {
|
||||
token = req.cookies.accessToken;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const payload = verifyToken(token);
|
||||
if (!payload || payload.type !== 'access') {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
req.userId = payload.sub;
|
||||
next();
|
||||
};
|
||||
|
||||
// ─── Setup Check Middleware ───────────────────────────────
|
||||
export const requireSetup = (req, res, next) => {
|
||||
const admin = getAdmin();
|
||||
if (!admin) {
|
||||
// Allow setup endpoints when no admin exists
|
||||
if (req.path === '/api/auth/setup' && req.method === 'POST') {
|
||||
return next();
|
||||
}
|
||||
return res.status(503).json({
|
||||
error: 'Setup required',
|
||||
setupRequired: true,
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// ─── WebSocket Auth ───────────────────────────────────────
|
||||
export const authenticateWs = (token) => {
|
||||
if (!token) return null;
|
||||
const payload = verifyToken(token);
|
||||
if (!payload || payload.type !== 'access') return null;
|
||||
return payload;
|
||||
};
|
||||
185
server/src/routes/auth.js
Normal file
185
server/src/routes/auth.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Router } from 'express';
|
||||
import { getAdmin, createAdmin } from '../db/index.js';
|
||||
import {
|
||||
hashPassword, verifyPassword,
|
||||
generateAccessToken, generateRefreshToken, verifyToken,
|
||||
loginRateLimiter,
|
||||
} from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api/auth/setup — Initial admin setup (only works if no admin exists)
|
||||
*/
|
||||
router.post('/setup', async (req, res) => {
|
||||
const existing = getAdmin();
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Admin already configured' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
if (password.length < 12) {
|
||||
return res.status(400).json({ error: 'Password must be at least 12 characters' });
|
||||
}
|
||||
|
||||
const hash = hashPassword(password);
|
||||
createAdmin(username, hash);
|
||||
|
||||
const accessToken = generateAccessToken(1);
|
||||
const refreshToken = generateRefreshToken(1);
|
||||
|
||||
// Set secure cookies
|
||||
setAuthCookies(res, accessToken, refreshToken);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
user: { id: 1, username },
|
||||
accessToken,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login — Login with username/password
|
||||
*/
|
||||
router.post('/login', loginRateLimiter, async (req, res) => {
|
||||
const admin = getAdmin();
|
||||
if (!admin) {
|
||||
return res.status(503).json({ error: 'Setup required', setupRequired: true });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
if (username !== admin.username || !verifyPassword(password, admin.password_hash)) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken(1);
|
||||
const refreshToken = generateRefreshToken(1);
|
||||
|
||||
setAuthCookies(res, accessToken, refreshToken);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
user: { id: 1, username: admin.username },
|
||||
accessToken,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh — Refresh access token
|
||||
*/
|
||||
router.post('/refresh', async (req, res) => {
|
||||
const refreshToken = req.cookies?.refreshToken || req.body.refreshToken;
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ error: 'Refresh token required' });
|
||||
}
|
||||
|
||||
const payload = verifyToken(refreshToken);
|
||||
if (!payload || payload.type !== 'refresh') {
|
||||
return res.status(401).json({ error: 'Invalid refresh token' });
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken(payload.sub);
|
||||
setCookie(res, 'accessToken', accessToken, 15 * 60 * 1000);
|
||||
|
||||
res.json({ ok: true, accessToken });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
router.post('/logout', (_req, res) => {
|
||||
res.clearCookie('accessToken');
|
||||
res.clearCookie('refreshToken');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me — Get current user info
|
||||
*/
|
||||
router.get('/me', (req, res) => {
|
||||
// Check auth manually here since this route doesn't use requireAuth middleware
|
||||
const authHeader = req.headers.authorization;
|
||||
let token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.accessToken;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const payload = verifyToken(token);
|
||||
if (!payload || payload.type !== 'access') {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
if (!admin) {
|
||||
return res.status(503).json({ error: 'Setup required', setupRequired: true });
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: { id: 1, username: admin.username },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/status — Check if setup is needed
|
||||
*/
|
||||
router.get('/status', (_req, res) => {
|
||||
const admin = getAdmin();
|
||||
res.json({
|
||||
setupRequired: !admin,
|
||||
configured: !!admin,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/auth/password — Change password
|
||||
*/
|
||||
router.put('/password', async (req, res) => {
|
||||
// Auth check
|
||||
const authHeader = req.headers.authorization;
|
||||
let token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.accessToken;
|
||||
const payload = verifyToken(token);
|
||||
if (!payload || payload.type !== 'access') {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const admin = getAdmin();
|
||||
|
||||
if (!verifyPassword(currentPassword, admin.password_hash)) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
if (newPassword.length < 12) {
|
||||
return res.status(400).json({ error: 'Password must be at least 12 characters' });
|
||||
}
|
||||
|
||||
const hash = hashPassword(newPassword);
|
||||
createAdmin(admin.username, hash);
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Cookie helpers ───────────────────────────────────────
|
||||
function setCookie(res, name, value, maxAge) {
|
||||
res.cookie(name, value, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
function setAuthCookies(res, accessToken, refreshToken) {
|
||||
setCookie(res, 'accessToken', accessToken, 15 * 60 * 1000); // 15 min
|
||||
setCookie(res, 'refreshToken', refreshToken, 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
}
|
||||
|
||||
export default router;
|
||||
51
server/src/routes/deploys.js
Normal file
51
server/src/routes/deploys.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Router } from 'express';
|
||||
import * as db from '../db/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/deploys — List recent deploys across all services
|
||||
*/
|
||||
router.get('/', (_req, res) => {
|
||||
try {
|
||||
const services = db.getAllServices();
|
||||
const allDeploys = [];
|
||||
|
||||
for (const service of services) {
|
||||
const deploys = db.getDeploysByService(service.id, 10);
|
||||
for (const d of deploys) {
|
||||
allDeploys.push({ ...d, service_name: service.name });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by created_at desc
|
||||
allDeploys.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
res.json(allDeploys.slice(0, 50));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/deploys/:id — Get a single deploy with logs
|
||||
*/
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const deploy = db.getDeployById(req.params.id);
|
||||
if (!deploy) return res.status(404).json({ error: 'Deploy not found' });
|
||||
|
||||
const logs = db.getDeployLogs(deploy.id);
|
||||
const service = db.getServiceById(deploy.service_id);
|
||||
|
||||
res.json({
|
||||
...deploy,
|
||||
service_name: service?.name || 'unknown',
|
||||
logs,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
34
server/src/routes/logs.js
Normal file
34
server/src/routes/logs.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Router } from 'express';
|
||||
import * as db from '../db/index.js';
|
||||
import { streamLogs } from '../services/docker.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/logs/:serviceId — Get recent container logs (non-streaming)
|
||||
*/
|
||||
router.get('/:serviceId', async (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.serviceId);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
const tail = parseInt(req.query.tail || '200');
|
||||
const logs = [];
|
||||
|
||||
const cleanup = await streamLogs(
|
||||
service.container_name,
|
||||
(line) => logs.push(line),
|
||||
{ follow: false, tail }
|
||||
);
|
||||
|
||||
// Wait a short moment for non-follow logs to complete
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
cleanup();
|
||||
|
||||
res.json({ service: service.name, logs });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
49
server/src/routes/monitoring.js
Normal file
49
server/src/routes/monitoring.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express';
|
||||
import { queryMetrics, getRealtimeStats } from '../services/monitoring.js';
|
||||
import { getContainerStats } from '../services/docker.js';
|
||||
import * as db from '../db/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/realtime — Get real-time stats for all running services
|
||||
*/
|
||||
router.get('/realtime', async (_req, res) => {
|
||||
try {
|
||||
const stats = await getRealtimeStats();
|
||||
res.json(stats);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/:serviceName — Get real-time stats for a specific service
|
||||
*/
|
||||
router.get('/:serviceName/stats', async (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceByName(req.params.serviceName) || db.getServiceById(req.params.serviceName);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
const stats = await getContainerStats(service.container_name);
|
||||
res.json(stats || { error: 'Container not running' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/:serviceName/history — Get historical metrics
|
||||
*/
|
||||
router.get('/:serviceName/history', async (req, res) => {
|
||||
try {
|
||||
const range = req.query.range || '-1h';
|
||||
const field = req.query.field || 'cpu_percent';
|
||||
const data = await queryMetrics(req.params.serviceName, range, field);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
45
server/src/routes/networks.js
Normal file
45
server/src/routes/networks.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Router } from 'express';
|
||||
import { listNetworks, createNetwork, removeNetwork } from '../services/docker.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/networks — List Docker networks
|
||||
*/
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const networks = await listNetworks();
|
||||
res.json(networks);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/networks — Create a new Docker network
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, driver, internal } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Network name is required' });
|
||||
|
||||
await createNetwork(name, driver || 'bridge', internal || false);
|
||||
res.status(201).json({ ok: true, name });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/networks/:name — Remove a Docker network
|
||||
*/
|
||||
router.delete('/:name', async (req, res) => {
|
||||
try {
|
||||
await removeNetwork(req.params.name);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
404
server/src/routes/services.js
Normal file
404
server/src/routes/services.js
Normal file
@@ -0,0 +1,404 @@
|
||||
import { Router } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as db from '../db/index.js';
|
||||
import { getContainerInfo, stopAndRemoveContainer } from '../services/docker.js';
|
||||
import { previewTraefikLabels } from '../services/traefik.js';
|
||||
import { queueDeploy } from '../services/queue.js';
|
||||
import { scanOrphanContainers, cleanupOrphanContainers, pruneOldImages } from '../services/cleanup.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/services — List all services
|
||||
*/
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const services = db.getAllServices();
|
||||
|
||||
// Enrich with live container info
|
||||
const enriched = await Promise.all(
|
||||
services.map(async (s) => {
|
||||
let containerInfo = null;
|
||||
try {
|
||||
containerInfo = await getContainerInfo(s.container_name);
|
||||
} catch {}
|
||||
|
||||
const lastDeploy = db.getDeploysByService(s.id, 1)[0] || null;
|
||||
|
||||
return {
|
||||
...s,
|
||||
networks: safeJsonParse(s.networks, []),
|
||||
traefik_middlewares: safeJsonParse(s.traefik_middlewares, []),
|
||||
build_args: safeJsonParse(s.build_args, {}),
|
||||
container: containerInfo,
|
||||
last_deploy: lastDeploy,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(enriched);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/services/:id — Get a single service
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
let containerInfo = null;
|
||||
try {
|
||||
containerInfo = await getContainerInfo(service.container_name);
|
||||
} catch {}
|
||||
|
||||
const deploys = db.getDeploysByService(service.id, 10);
|
||||
const envVars = db.getEnvVars(service.id);
|
||||
|
||||
res.json({
|
||||
...service,
|
||||
networks: safeJsonParse(service.networks, []),
|
||||
traefik_middlewares: safeJsonParse(service.traefik_middlewares, []),
|
||||
build_args: safeJsonParse(service.build_args, {}),
|
||||
container: containerInfo,
|
||||
deploys,
|
||||
env_vars: envVars.map(e => ({
|
||||
...e,
|
||||
value: e.is_secret ? '••••••••' : e.value,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/services — Create a new service
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name, description, gitea_repo_url, gitea_branch,
|
||||
dockerfile_path, build_context, container_name, container_port,
|
||||
traefik_enabled, traefik_domain, traefik_entrypoints,
|
||||
traefik_tls_resolver, traefik_path_prefix, traefik_middlewares,
|
||||
traefik_network, networks,
|
||||
health_check_enabled, health_check_path, health_check_interval,
|
||||
health_check_timeout, health_check_retries, zero_downtime,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !gitea_repo_url || !container_name) {
|
||||
return res.status(400).json({ error: 'name, gitea_repo_url, and container_name are required' });
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
if (db.getServiceByName(name)) {
|
||||
return res.status(409).json({ error: `Service "${name}" already exists` });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const webhookId = randomUUID();
|
||||
|
||||
const service = {
|
||||
id,
|
||||
name,
|
||||
description: description || '',
|
||||
gitea_repo_url,
|
||||
gitea_branch: gitea_branch || 'main',
|
||||
dockerfile_path: dockerfile_path || 'Dockerfile',
|
||||
build_context: build_context || '.',
|
||||
build_args: JSON.stringify(req.body.build_args || {}),
|
||||
container_name,
|
||||
container_port: container_port || 3000,
|
||||
traefik_enabled: traefik_enabled !== false ? 1 : 0,
|
||||
traefik_domain: traefik_domain || '',
|
||||
traefik_entrypoints: traefik_entrypoints || 'websecure',
|
||||
traefik_tls_resolver: traefik_tls_resolver || 'cloudflare',
|
||||
traefik_path_prefix: traefik_path_prefix || '',
|
||||
traefik_middlewares: JSON.stringify(traefik_middlewares || []),
|
||||
traefik_network: traefik_network || 'meb-public',
|
||||
networks: JSON.stringify(networks || ['meb-public']),
|
||||
health_check_enabled: health_check_enabled ? 1 : 0,
|
||||
health_check_path: health_check_path || '/health',
|
||||
health_check_interval: health_check_interval || 10,
|
||||
health_check_timeout: health_check_timeout || 5,
|
||||
health_check_retries: health_check_retries || 3,
|
||||
zero_downtime: zero_downtime ? 1 : 0,
|
||||
webhook_id: webhookId,
|
||||
};
|
||||
|
||||
db.createService(service);
|
||||
|
||||
res.status(201).json({
|
||||
...service,
|
||||
networks: safeJsonParse(service.networks, []),
|
||||
traefik_middlewares: safeJsonParse(service.traefik_middlewares, []),
|
||||
build_args: safeJsonParse(service.build_args, {}),
|
||||
webhook_url: `/api/webhooks/${webhookId}`,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/services/:id — Update a service
|
||||
*/
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
const updates = {};
|
||||
const allowedFields = [
|
||||
'name', 'description', 'gitea_repo_url', 'gitea_branch',
|
||||
'dockerfile_path', 'build_context', 'container_name', 'container_port',
|
||||
'traefik_enabled', 'traefik_domain', 'traefik_entrypoints',
|
||||
'traefik_tls_resolver', 'traefik_path_prefix', 'traefik_network',
|
||||
'health_check_enabled', 'health_check_path', 'health_check_interval',
|
||||
'health_check_timeout', 'health_check_retries', 'zero_downtime',
|
||||
];
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
if (typeof req.body[field] === 'boolean') {
|
||||
updates[field] = req.body[field] ? 1 : 0;
|
||||
} else {
|
||||
updates[field] = req.body[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON fields
|
||||
if (req.body.networks !== undefined) updates.networks = JSON.stringify(req.body.networks);
|
||||
if (req.body.traefik_middlewares !== undefined) updates.traefik_middlewares = JSON.stringify(req.body.traefik_middlewares);
|
||||
if (req.body.build_args !== undefined) updates.build_args = JSON.stringify(req.body.build_args);
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
db.updateService(req.params.id, updates);
|
||||
}
|
||||
|
||||
const updated = db.getServiceById(req.params.id);
|
||||
res.json({
|
||||
...updated,
|
||||
networks: safeJsonParse(updated.networks, []),
|
||||
traefik_middlewares: safeJsonParse(updated.traefik_middlewares, []),
|
||||
build_args: safeJsonParse(updated.build_args, {}),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/services/:id — Delete a service
|
||||
*/
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
db.deleteService(req.params.id);
|
||||
res.json({ ok: true, deleted: service.name });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/services/:id/deploy — Trigger a manual deploy
|
||||
*/
|
||||
router.post('/:id/deploy', async (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
const deployId = randomUUID();
|
||||
db.createDeploy({
|
||||
id: deployId,
|
||||
service_id: service.id,
|
||||
status: 'queued',
|
||||
trigger: 'manual',
|
||||
commit_sha: '',
|
||||
commit_message: 'Manual deploy',
|
||||
commit_author: 'admin',
|
||||
image_tag: '',
|
||||
});
|
||||
|
||||
await queueDeploy(deployId, service.id);
|
||||
|
||||
res.json({ ok: true, deploy_id: deployId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/services/:id/stop — Stop a service container
|
||||
*/
|
||||
router.post('/:id/stop', async (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
await stopAndRemoveContainer(service.container_name);
|
||||
db.updateService(service.id, { status: 'stopped' });
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/services/:id/restart — Restart a service container
|
||||
*/
|
||||
router.post('/:id/restart', async (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
if (!service.current_image) {
|
||||
return res.status(400).json({ error: 'No image available. Deploy the service first.' });
|
||||
}
|
||||
|
||||
// Queue a redeploy with current image
|
||||
const deployId = randomUUID();
|
||||
db.createDeploy({
|
||||
id: deployId,
|
||||
service_id: service.id,
|
||||
status: 'queued',
|
||||
trigger: 'restart',
|
||||
commit_sha: '',
|
||||
commit_message: 'Container restart',
|
||||
commit_author: 'admin',
|
||||
image_tag: service.current_image,
|
||||
});
|
||||
|
||||
await queueDeploy(deployId, service.id);
|
||||
res.json({ ok: true, deploy_id: deployId });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/services/:id/inspect — Full Docker inspect output
|
||||
*/
|
||||
router.get('/:id/inspect', async (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
const { docker } = await import('../services/docker.js');
|
||||
const container = docker.getContainer(service.container_name);
|
||||
const info = await container.inspect();
|
||||
res.json(info);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: 'Container not found' });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/services/:id/traefik-preview — Preview Traefik labels
|
||||
*/
|
||||
router.get('/:id/traefik-preview', (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
const preview = previewTraefikLabels(service);
|
||||
res.json({ labels: preview });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/services/traefik-preview — Preview Traefik labels from body (for unsaved config)
|
||||
*/
|
||||
router.post('/traefik-preview', (req, res) => {
|
||||
try {
|
||||
const preview = previewTraefikLabels(req.body);
|
||||
res.json({ labels: preview });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/services/cleanup — Scan and remove orphan containers
|
||||
*/
|
||||
router.post('/cleanup', async (_req, res) => {
|
||||
try {
|
||||
const orphans = await scanOrphanContainers();
|
||||
const results = await cleanupOrphanContainers();
|
||||
res.json({ orphans_found: orphans.length, results });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/services/prune-images — Remove old Docker images
|
||||
*/
|
||||
router.post('/prune-images', async (req, res) => {
|
||||
try {
|
||||
const keep = parseInt(req.body?.keep) || 2;
|
||||
const results = await pruneOldImages(keep);
|
||||
res.json({ results });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Env Vars sub-routes ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/services/:id/env — Get env vars
|
||||
*/
|
||||
router.get('/:id/env', (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
const vars = db.getEnvVars(service.id);
|
||||
res.json(vars.map(e => ({
|
||||
...e,
|
||||
value: e.is_secret ? '••••••••' : e.value,
|
||||
})));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/services/:id/env — Bulk set env vars
|
||||
*/
|
||||
router.put('/:id/env', (req, res) => {
|
||||
try {
|
||||
const service = db.getServiceById(req.params.id);
|
||||
if (!service) return res.status(404).json({ error: 'Service not found' });
|
||||
|
||||
const { vars } = req.body;
|
||||
if (!Array.isArray(vars)) {
|
||||
return res.status(400).json({ error: 'vars must be an array' });
|
||||
}
|
||||
|
||||
db.bulkSetEnvVars(service.id, vars);
|
||||
res.json({ ok: true, count: vars.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
function safeJsonParse(str, fallback) {
|
||||
try { return JSON.parse(str); } catch { return fallback; }
|
||||
}
|
||||
|
||||
export default router;
|
||||
77
server/src/routes/settings.js
Normal file
77
server/src/routes/settings.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Router } from 'express';
|
||||
import * as db from '../db/index.js';
|
||||
import { testConnection as testGitea } from '../services/gitea.js';
|
||||
import { testTelegram } from '../services/telegram.js';
|
||||
import { getQueueStatus } from '../services/queue.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/settings — Get all settings
|
||||
*/
|
||||
router.get('/', (_req, res) => {
|
||||
try {
|
||||
const settings = db.getAllSettings();
|
||||
res.json(settings);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/settings — Bulk update settings
|
||||
*/
|
||||
router.put('/', (req, res) => {
|
||||
try {
|
||||
const { settings } = req.body;
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return res.status(400).json({ error: 'settings object required' });
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
db.setSetting(key, String(value));
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/test/gitea — Test Gitea connection
|
||||
*/
|
||||
router.get('/test/gitea', async (_req, res) => {
|
||||
try {
|
||||
const result = await testGitea();
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/test/telegram — Test Telegram bot
|
||||
*/
|
||||
router.get('/test/telegram', async (_req, res) => {
|
||||
try {
|
||||
const result = await testTelegram();
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/queue — Get build queue status
|
||||
*/
|
||||
router.get('/queue', async (_req, res) => {
|
||||
try {
|
||||
const status = await getQueueStatus();
|
||||
res.json(status);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
50
server/src/routes/system.js
Normal file
50
server/src/routes/system.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Router } from 'express';
|
||||
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const TRIGGER_FILE = '/app/trigger/update-trigger';
|
||||
const STATUS_FILE = '/app/trigger/update-status';
|
||||
const LOG_FILE = '/app/trigger/update.log';
|
||||
|
||||
/**
|
||||
* POST /api/system/self-update — Trigger self-update via sidecar
|
||||
*/
|
||||
router.post('/self-update', (_req, res) => {
|
||||
try {
|
||||
writeFileSync(TRIGGER_FILE, new Date().toISOString());
|
||||
res.json({ status: 'update_triggered', message: 'Il sidecar aggiornerà AutoDeployer a breve.' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: `Failed to trigger update: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/system/update-status — Check self-update status
|
||||
*/
|
||||
router.get('/update-status', (_req, res) => {
|
||||
try {
|
||||
const status = existsSync(STATUS_FILE) ? readFileSync(STATUS_FILE, 'utf8').trim() : 'idle';
|
||||
const log = existsSync(LOG_FILE) ? readFileSync(LOG_FILE, 'utf8').split('\n').slice(-50).join('\n') : '';
|
||||
res.json({ status, log });
|
||||
} catch {
|
||||
res.json({ status: 'unknown', log: '' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/system/version — Current version info
|
||||
*/
|
||||
router.get('/version', (_req, res) => {
|
||||
try {
|
||||
const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { stdio: 'pipe' }).toString().trim();
|
||||
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { stdio: 'pipe' }).toString().trim();
|
||||
const date = execFileSync('git', ['log', '-1', '--format=%ci'], { stdio: 'pipe' }).toString().trim();
|
||||
res.json({ commit, branch, date });
|
||||
} catch {
|
||||
res.json({ commit: 'unknown', branch: 'unknown', date: 'unknown' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
100
server/src/routes/webhooks.js
Normal file
100
server/src/routes/webhooks.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Router } from 'express';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as db from '../db/index.js';
|
||||
import { queueDeploy } from '../services/queue.js';
|
||||
import config from '../config.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/:webhookId — Receive webhook from Gitea
|
||||
* No JWT auth — authenticated via webhook secret
|
||||
*/
|
||||
router.post('/:webhookId', async (req, res) => {
|
||||
try {
|
||||
const { webhookId } = req.params;
|
||||
|
||||
// Find the service by webhook ID
|
||||
const service = db.getServiceByWebhookId(webhookId);
|
||||
if (!service) {
|
||||
return res.status(404).json({ error: 'Unknown webhook' });
|
||||
}
|
||||
|
||||
// Verify Gitea webhook signature (X-Gitea-Signature header)
|
||||
const signature = req.headers['x-gitea-signature'];
|
||||
if (config.webhookSecret) {
|
||||
if (!signature) {
|
||||
return res.status(401).json({ error: 'Missing webhook signature' });
|
||||
}
|
||||
const expectedSig = createHmac('sha256', config.webhookSecret)
|
||||
.update(JSON.stringify(req.body))
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
const sigBuffer = Buffer.from(signature, 'hex');
|
||||
const expectedBuffer = Buffer.from(expectedSig, 'hex');
|
||||
if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) {
|
||||
return res.status(401).json({ error: 'Invalid webhook signature' });
|
||||
}
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid webhook signature format' });
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Gitea push event
|
||||
const event = req.headers['x-gitea-event'];
|
||||
if (event !== 'push') {
|
||||
return res.json({ ok: true, skipped: true, reason: `Event ${event} not handled` });
|
||||
}
|
||||
|
||||
const payload = req.body;
|
||||
const ref = payload.ref || '';
|
||||
const branch = ref.replace('refs/heads/', '');
|
||||
|
||||
// Verify branch matches
|
||||
if (branch !== service.gitea_branch) {
|
||||
return res.json({
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: `Branch ${branch} does not match configured branch ${service.gitea_branch}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract commit info
|
||||
const headCommit = payload.head_commit || payload.commits?.[0] || {};
|
||||
const commitSha = headCommit.id || payload.after || '';
|
||||
const commitMessage = headCommit.message || '';
|
||||
const commitAuthor = headCommit.author?.name || headCommit.author?.username || '';
|
||||
|
||||
// Create deploy record
|
||||
const deployId = randomUUID();
|
||||
db.createDeploy({
|
||||
id: deployId,
|
||||
service_id: service.id,
|
||||
status: 'queued',
|
||||
trigger: 'webhook',
|
||||
commit_sha: commitSha.slice(0, 12),
|
||||
commit_message: commitMessage.split('\n')[0].slice(0, 200),
|
||||
commit_author: commitAuthor,
|
||||
image_tag: '',
|
||||
});
|
||||
|
||||
// Queue the build
|
||||
await queueDeploy(deployId, service.id);
|
||||
|
||||
console.log(`[WEBHOOK] Deploy queued for ${service.name} (commit: ${commitSha.slice(0, 8)})`);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
deploy_id: deployId,
|
||||
service: service.name,
|
||||
commit: commitSha.slice(0, 8),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[WEBHOOK] Error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
237
server/src/services/builder.js
Normal file
237
server/src/services/builder.js
Normal file
@@ -0,0 +1,237 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdirSync, rmSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import config from '../config.js';
|
||||
import * as db from '../db/index.js';
|
||||
import * as dockerService from './docker.js';
|
||||
import { generateTraefikLabels } from './traefik.js';
|
||||
import { getCloneUrl } from './gitea.js';
|
||||
import { sendDeployNotification } from './telegram.js';
|
||||
|
||||
/**
|
||||
* Build and deploy a service
|
||||
* @param {string} deployId - Deploy record ID
|
||||
* @param {function} onLog - Callback for log messages
|
||||
*/
|
||||
export async function buildAndDeploy(deployId, onLog = () => {}) {
|
||||
const deploy = db.getDeployById(deployId);
|
||||
if (!deploy) throw new Error(`Deploy ${deployId} not found`);
|
||||
|
||||
const service = db.getServiceById(deploy.service_id);
|
||||
if (!service) throw new Error(`Service ${deploy.service_id} not found`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const imageTag = `${service.container_name}:${Date.now()}`;
|
||||
const buildDir = join(config.buildsDir, `${service.id}-${Date.now()}`);
|
||||
|
||||
const log = (msg, stream = 'stdout') => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const line = `[${timestamp}] ${msg}`;
|
||||
db.addDeployLog(deployId, line, stream);
|
||||
onLog(line, stream);
|
||||
};
|
||||
|
||||
try {
|
||||
// ─── Update status ──────────────────────────────────────
|
||||
db.updateDeploy(deployId, { status: 'building', started_at: new Date().toISOString() });
|
||||
db.updateService(service.id, { status: 'building' });
|
||||
log(`🚀 Starting deploy for ${service.name}`);
|
||||
await sendDeployNotification(service.name, 'building', `Building ${service.name}...`);
|
||||
|
||||
// ─── Clone repository ───────────────────────────────────
|
||||
log(`📦 Cloning ${service.gitea_repo_url} (branch: ${service.gitea_branch})`);
|
||||
mkdirSync(buildDir, { recursive: true });
|
||||
const cloneUrl = getCloneUrl(service.gitea_repo_url);
|
||||
|
||||
execFileSync('git', [
|
||||
'clone', '--depth', '1', '--branch', service.gitea_branch,
|
||||
'--', cloneUrl, buildDir
|
||||
], { stdio: 'pipe', timeout: 120000 });
|
||||
log(`✅ Repository cloned successfully`);
|
||||
|
||||
// ─── Build Docker image ─────────────────────────────────
|
||||
log(`🔨 Building Docker image: ${imageTag}`);
|
||||
log(` Dockerfile: ${service.dockerfile_path}`);
|
||||
log(` Context: ${service.build_context}`);
|
||||
|
||||
// Get build args from env vars
|
||||
const envVars = db.getEnvVars(service.id);
|
||||
const buildArgs = {};
|
||||
for (const ev of envVars.filter(e => e.is_build_arg)) {
|
||||
buildArgs[ev.key] = ev.value;
|
||||
}
|
||||
|
||||
// Merge with service-level build args
|
||||
try {
|
||||
const serviceBuildArgs = JSON.parse(service.build_args || '{}');
|
||||
Object.assign(buildArgs, serviceBuildArgs);
|
||||
} catch {}
|
||||
|
||||
const contextPath = join(buildDir, service.build_context);
|
||||
|
||||
await dockerService.buildImage(
|
||||
contextPath,
|
||||
imageTag,
|
||||
service.dockerfile_path,
|
||||
buildArgs,
|
||||
(line) => log(` ${line}`)
|
||||
);
|
||||
log(`✅ Image built: ${imageTag}`);
|
||||
|
||||
// ─── Prepare environment ────────────────────────────────
|
||||
const runtimeEnv = envVars
|
||||
.filter(e => !e.is_build_arg)
|
||||
.map(e => `${e.key}=${e.value}`);
|
||||
|
||||
// ─── Generate Traefik labels ────────────────────────────
|
||||
const labels = generateTraefikLabels(service);
|
||||
log(`🏷️ Traefik labels generated (${Object.keys(labels).length} labels)`);
|
||||
|
||||
// ─── Parse networks ─────────────────────────────────────
|
||||
let networks = ['meb-public'];
|
||||
try {
|
||||
networks = JSON.parse(service.networks || '["meb-public"]');
|
||||
} catch {}
|
||||
|
||||
// ─── Deploy container ───────────────────────────────────
|
||||
if (service.zero_downtime && service.health_check_enabled) {
|
||||
await zeroDowntimeDeploy(service, imageTag, runtimeEnv, labels, networks, log);
|
||||
} else {
|
||||
await standardDeploy(service, imageTag, runtimeEnv, labels, networks, log);
|
||||
}
|
||||
|
||||
// ─── Update records ─────────────────────────────────────
|
||||
const duration = Date.now() - startTime;
|
||||
db.updateDeploy(deployId, {
|
||||
status: 'success',
|
||||
image_tag: imageTag,
|
||||
finished_at: new Date().toISOString(),
|
||||
duration_ms: duration,
|
||||
});
|
||||
db.updateService(service.id, {
|
||||
status: 'running',
|
||||
current_image: imageTag,
|
||||
});
|
||||
|
||||
log(`✅ Deploy completed in ${(duration / 1000).toFixed(1)}s`);
|
||||
await sendDeployNotification(service.name, 'success', `Deployed in ${(duration / 1000).toFixed(1)}s`);
|
||||
|
||||
} catch (err) {
|
||||
const duration = Date.now() - startTime;
|
||||
log(`❌ Deploy failed: ${err.message}`, 'stderr');
|
||||
db.updateDeploy(deployId, {
|
||||
status: 'failed',
|
||||
finished_at: new Date().toISOString(),
|
||||
duration_ms: duration,
|
||||
});
|
||||
db.updateService(service.id, { status: 'error' });
|
||||
await sendDeployNotification(service.name, 'failed', err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
// ─── Cleanup build directory ────────────────────────────
|
||||
try {
|
||||
if (existsSync(buildDir)) {
|
||||
rmSync(buildDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard deploy: stop old → start new
|
||||
*/
|
||||
async function standardDeploy(service, imageTag, env, labels, networks, log) {
|
||||
log(`📥 Deploying container: ${service.container_name}`);
|
||||
|
||||
const healthCheck = service.health_check_enabled ? {
|
||||
path: service.health_check_path,
|
||||
port: service.container_port,
|
||||
interval: service.health_check_interval,
|
||||
timeout: service.health_check_timeout,
|
||||
retries: service.health_check_retries,
|
||||
} : null;
|
||||
|
||||
const containerInfo = await dockerService.runContainer({
|
||||
name: service.container_name,
|
||||
image: imageTag,
|
||||
env,
|
||||
labels,
|
||||
networks,
|
||||
healthCheck,
|
||||
});
|
||||
|
||||
log(`✅ Container started: ${containerInfo.name} (${containerInfo.id.slice(0, 12)})`);
|
||||
|
||||
// Update with actual container ID
|
||||
db.updateService(service.id, { current_container_id: containerInfo.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-downtime deploy: start new → wait healthy → stop old → rename new
|
||||
* Traffic gap is only the rename operation (milliseconds).
|
||||
*/
|
||||
async function zeroDowntimeDeploy(service, imageTag, env, labels, networks, log) {
|
||||
const tempName = `${service.container_name}-new`;
|
||||
const oldName = service.container_name;
|
||||
log(`⚡ Zero-downtime deploy: starting temporary container ${tempName}`);
|
||||
|
||||
const healthCheck = {
|
||||
path: service.health_check_path,
|
||||
port: service.container_port,
|
||||
interval: service.health_check_interval,
|
||||
timeout: service.health_check_timeout,
|
||||
retries: service.health_check_retries,
|
||||
};
|
||||
|
||||
// Start new container with REAL Traefik labels but temporary name
|
||||
// Traefik won't route to it yet because the container name doesn't match
|
||||
// what Traefik expects (the labels reference the correct router name)
|
||||
await dockerService.runContainer({
|
||||
name: tempName,
|
||||
image: imageTag,
|
||||
env,
|
||||
labels,
|
||||
networks,
|
||||
healthCheck,
|
||||
});
|
||||
|
||||
log(`⏳ Waiting for health check...`);
|
||||
|
||||
try {
|
||||
await dockerService.waitForHealthy(tempName, 120000);
|
||||
log(`✅ New container is healthy`);
|
||||
} catch (err) {
|
||||
log(`❌ Health check failed, rolling back: ${err.message}`, 'stderr');
|
||||
await dockerService.stopAndRemoveContainer(tempName).catch(() => {});
|
||||
throw new Error(`Zero-downtime deploy failed: health check did not pass`);
|
||||
}
|
||||
|
||||
// Switch traffic: stop old → rename new (millisecond gap)
|
||||
log(`🔄 Switching traffic...`);
|
||||
await dockerService.stopAndRemoveContainer(oldName).catch(() => {});
|
||||
|
||||
try {
|
||||
await dockerService.renameContainer(tempName, oldName);
|
||||
} catch (renameErr) {
|
||||
// If rename fails, the new container is still running as tempName
|
||||
// Try to recover by stopping and recreating with correct name
|
||||
log(`⚠️ Rename failed, recreating container: ${renameErr.message}`, 'stderr');
|
||||
await dockerService.stopAndRemoveContainer(tempName).catch(() => {});
|
||||
const containerInfo = await dockerService.runContainer({
|
||||
name: oldName,
|
||||
image: imageTag,
|
||||
env,
|
||||
labels,
|
||||
networks,
|
||||
healthCheck,
|
||||
});
|
||||
log(`✅ Container recreated: ${containerInfo.name}`);
|
||||
db.updateService(service.id, { current_container_id: containerInfo.id });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the renamed container's info
|
||||
const info = await dockerService.getContainerInfo(oldName);
|
||||
log(`✅ Traffic switched to new container: ${oldName} (${info.id.slice(0, 12)})`);
|
||||
db.updateService(service.id, { current_container_id: info.id });
|
||||
}
|
||||
107
server/src/services/cleanup.js
Normal file
107
server/src/services/cleanup.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as dockerService from './docker.js';
|
||||
import { docker } from './docker.js';
|
||||
import * as db from '../db/index.js';
|
||||
|
||||
/**
|
||||
* Find orphan containers (leftover -new containers from failed zero-downtime deploys)
|
||||
*/
|
||||
export async function scanOrphanContainers() {
|
||||
const containers = await dockerService.listContainers(true);
|
||||
const orphans = [];
|
||||
|
||||
for (const container of containers) {
|
||||
if (container.name.endsWith('-new')) {
|
||||
const baseName = container.name.replace(/-new$/, '');
|
||||
// Check if there's an active deploy for this service
|
||||
const service = db.getAllServices().find(s => s.container_name === baseName);
|
||||
if (!service || service.status !== 'building') {
|
||||
orphans.push(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return orphans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove orphan containers
|
||||
*/
|
||||
export async function cleanupOrphanContainers() {
|
||||
const orphans = await scanOrphanContainers();
|
||||
const results = [];
|
||||
|
||||
for (const orphan of orphans) {
|
||||
try {
|
||||
await dockerService.stopAndRemoveContainer(orphan.name);
|
||||
results.push({ name: orphan.name, status: 'removed' });
|
||||
} catch (err) {
|
||||
results.push({ name: orphan.name, status: 'error', error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old images for managed services, keeping the N most recent per service
|
||||
*/
|
||||
export async function pruneOldImages(keepLatest = 2) {
|
||||
const services = db.getAllServices();
|
||||
const results = [];
|
||||
|
||||
for (const service of services) {
|
||||
try {
|
||||
const images = await docker.listImages({
|
||||
filters: { reference: [`${service.container_name}:*`] },
|
||||
});
|
||||
|
||||
if (images.length <= keepLatest) continue;
|
||||
|
||||
// Sort by creation date descending
|
||||
const sorted = images.sort((a, b) => b.Created - a.Created);
|
||||
const toRemove = sorted.slice(keepLatest);
|
||||
|
||||
for (const img of toRemove) {
|
||||
const tag = img.RepoTags?.[0] || img.Id;
|
||||
try {
|
||||
await dockerService.removeImage(tag);
|
||||
results.push({ image: tag, status: 'removed' });
|
||||
} catch (err) {
|
||||
results.push({ image: tag, status: 'error', error: err.message });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({ service: service.name, status: 'error', error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup (every 30 minutes)
|
||||
*/
|
||||
export function startPeriodicCleanup() {
|
||||
const INTERVAL = 30 * 60 * 1000;
|
||||
|
||||
// Initial delay of 60s to let services stabilize
|
||||
setTimeout(() => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const orphans = await cleanupOrphanContainers();
|
||||
if (orphans.length > 0) {
|
||||
console.log(`[CLEANUP] Removed ${orphans.filter(o => o.status === 'removed').length} orphan containers`);
|
||||
}
|
||||
const pruned = await pruneOldImages();
|
||||
if (pruned.length > 0) {
|
||||
console.log(`[CLEANUP] Pruned ${pruned.filter(p => p.status === 'removed').length} old images`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CLEANUP] Error:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
setInterval(run, INTERVAL);
|
||||
}, 60000);
|
||||
}
|
||||
350
server/src/services/docker.js
Normal file
350
server/src/services/docker.js
Normal file
@@ -0,0 +1,350 @@
|
||||
import Dockerode from 'dockerode';
|
||||
|
||||
const docker = new Dockerode({ socketPath: '/var/run/docker.sock' });
|
||||
|
||||
// ─── Image Operations ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a Docker image from a context directory
|
||||
* @param {string} contextPath - Path to the build context
|
||||
* @param {string} tag - Image tag (e.g. "myservice:20260404-123456")
|
||||
* @param {string} dockerfilePath - Relative path to Dockerfile within context
|
||||
* @param {object} buildArgs - Build arguments
|
||||
* @param {function} onLog - Callback for build output lines
|
||||
*/
|
||||
export async function buildImage(contextPath, tag, dockerfilePath = 'Dockerfile', buildArgs = {}, onLog = () => {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
docker.buildImage(
|
||||
{ context: contextPath, src: ['.'] },
|
||||
{
|
||||
t: tag,
|
||||
dockerfile: dockerfilePath,
|
||||
buildargs: buildArgs,
|
||||
rm: true,
|
||||
forcerm: true,
|
||||
},
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
docker.modem.followProgress(
|
||||
stream,
|
||||
(err, output) => {
|
||||
if (err) return reject(err);
|
||||
resolve(output);
|
||||
},
|
||||
(event) => {
|
||||
if (event.stream) onLog(event.stream.trim());
|
||||
if (event.error) onLog(`[ERROR] ${event.error}`);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Docker image
|
||||
*/
|
||||
export async function removeImage(imageTag) {
|
||||
try {
|
||||
const image = docker.getImage(imageTag);
|
||||
await image.remove({ force: true });
|
||||
} catch (err) {
|
||||
if (err.statusCode !== 404) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Container Operations ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create and start a container
|
||||
* @param {object} opts
|
||||
* @returns {object} container info
|
||||
*/
|
||||
export async function runContainer({
|
||||
name,
|
||||
image,
|
||||
env = [],
|
||||
labels = {},
|
||||
networks = [],
|
||||
ports = {},
|
||||
healthCheck = null,
|
||||
restart = 'unless-stopped',
|
||||
}) {
|
||||
// Remove existing container with same name (if any)
|
||||
await stopAndRemoveContainer(name).catch(() => {});
|
||||
|
||||
const containerConfig = {
|
||||
name,
|
||||
Image: image,
|
||||
Env: env,
|
||||
Labels: labels,
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: restart },
|
||||
},
|
||||
};
|
||||
|
||||
// Port bindings (optional, usually Traefik handles routing)
|
||||
if (ports && Object.keys(ports).length > 0) {
|
||||
containerConfig.ExposedPorts = {};
|
||||
containerConfig.HostConfig.PortBindings = {};
|
||||
for (const [containerPort, hostPort] of Object.entries(ports)) {
|
||||
containerConfig.ExposedPorts[`${containerPort}/tcp`] = {};
|
||||
containerConfig.HostConfig.PortBindings[`${containerPort}/tcp`] = [{ HostPort: String(hostPort) }];
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (healthCheck) {
|
||||
containerConfig.Healthcheck = {
|
||||
Test: healthCheck.test || ['CMD-SHELL', `wget -qO- http://localhost:${healthCheck.port || 3000}${healthCheck.path || '/health'} || exit 1`],
|
||||
Interval: (healthCheck.interval || 10) * 1e9, // nanoseconds
|
||||
Timeout: (healthCheck.timeout || 5) * 1e9,
|
||||
Retries: healthCheck.retries || 3,
|
||||
StartPeriod: (healthCheck.startPeriod || 15) * 1e9,
|
||||
};
|
||||
}
|
||||
|
||||
const container = await docker.createContainer(containerConfig);
|
||||
|
||||
// Connect to networks
|
||||
for (const networkName of networks) {
|
||||
try {
|
||||
const network = docker.getNetwork(networkName);
|
||||
await network.connect({ Container: container.id });
|
||||
} catch (err) {
|
||||
console.warn(`[DOCKER] Failed to connect to network ${networkName}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await container.start();
|
||||
const info = await container.inspect();
|
||||
|
||||
return {
|
||||
id: info.Id,
|
||||
name: info.Name.replace('/', ''),
|
||||
status: info.State.Status,
|
||||
image: info.Config.Image,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and remove a container by name
|
||||
*/
|
||||
export async function stopAndRemoveContainer(name) {
|
||||
try {
|
||||
const container = docker.getContainer(name);
|
||||
const info = await container.inspect();
|
||||
if (info.State.Running) {
|
||||
await container.stop({ t: 10 });
|
||||
}
|
||||
await container.remove({ force: true });
|
||||
} catch (err) {
|
||||
if (err.statusCode !== 404) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container info by name or ID
|
||||
*/
|
||||
export async function getContainerInfo(nameOrId) {
|
||||
try {
|
||||
const container = docker.getContainer(nameOrId);
|
||||
const info = await container.inspect();
|
||||
return {
|
||||
id: info.Id,
|
||||
name: info.Name.replace('/', ''),
|
||||
status: info.State.Status,
|
||||
health: info.State.Health?.Status || 'none',
|
||||
image: info.Config.Image,
|
||||
created: info.Created,
|
||||
started: info.State.StartedAt,
|
||||
ports: info.NetworkSettings.Ports,
|
||||
networks: Object.keys(info.NetworkSettings.Networks || {}),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all containers (running and stopped)
|
||||
*/
|
||||
export async function listContainers(all = true) {
|
||||
const containers = await docker.listContainers({ all });
|
||||
return containers.map(c => ({
|
||||
id: c.Id,
|
||||
name: c.Names[0]?.replace('/', '') || '',
|
||||
image: c.Image,
|
||||
status: c.Status,
|
||||
state: c.State,
|
||||
labels: c.Labels,
|
||||
networks: Object.keys(c.NetworkSettings?.Networks || {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream container logs
|
||||
* @param {string} nameOrId - container name or ID
|
||||
* @param {function} onData - callback for log data
|
||||
* @param {object} opts - options (tail, since, follow)
|
||||
* @returns {function} cleanup function
|
||||
*/
|
||||
export async function streamLogs(nameOrId, onData, opts = {}) {
|
||||
const container = docker.getContainer(nameOrId);
|
||||
const stream = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
follow: opts.follow !== false,
|
||||
tail: opts.tail || 200,
|
||||
since: opts.since || 0,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
// Use Dockerode's demuxer to properly parse multiplexed stream
|
||||
const { PassThrough } = await import('stream');
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
|
||||
const handleOutput = (s) => {
|
||||
s.on('data', (chunk) => {
|
||||
const lines = chunk.toString('utf8').split('\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
onData(line);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleOutput(stdout);
|
||||
handleOutput(stderr);
|
||||
|
||||
docker.modem.demuxStream(stream, stdout, stderr);
|
||||
|
||||
stream.on('error', (err) => {
|
||||
onData(`[ERROR] ${err.message}`);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
});
|
||||
|
||||
return () => {
|
||||
try { stream.destroy(); } catch {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container resource stats
|
||||
*/
|
||||
export async function getContainerStats(nameOrId) {
|
||||
try {
|
||||
const container = docker.getContainer(nameOrId);
|
||||
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 || 1) * 100 : 0;
|
||||
|
||||
const memUsage = stats.memory_stats.usage || 0;
|
||||
const memLimit = stats.memory_stats.limit || 1;
|
||||
const memPercent = (memUsage / memLimit) * 100;
|
||||
|
||||
const netRx = Object.values(stats.networks || {}).reduce((sum, n) => sum + (n.rx_bytes || 0), 0);
|
||||
const netTx = Object.values(stats.networks || {}).reduce((sum, n) => sum + (n.tx_bytes || 0), 0);
|
||||
|
||||
return {
|
||||
cpu_percent: Math.round(cpuPercent * 100) / 100,
|
||||
memory_usage: memUsage,
|
||||
memory_limit: memLimit,
|
||||
memory_percent: Math.round(memPercent * 100) / 100,
|
||||
network_rx: netRx,
|
||||
network_tx: netTx,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command in a container and return a stream
|
||||
*/
|
||||
export async function execInContainer(containerId, cmd = ['/bin/sh']) {
|
||||
const container = docker.getContainer(containerId);
|
||||
const exec = await container.exec({
|
||||
Cmd: cmd,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
});
|
||||
const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
|
||||
return { exec, stream };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize exec TTY
|
||||
*/
|
||||
export async function resizeExec(execId, w, h) {
|
||||
try {
|
||||
const exec = docker.getExec(execId);
|
||||
await exec.resize({ w, h });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a container to become healthy
|
||||
*/
|
||||
export async function waitForHealthy(nameOrId, timeoutMs = 120000, pollMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const info = await getContainerInfo(nameOrId);
|
||||
if (!info) throw new Error(`Container ${nameOrId} not found`);
|
||||
if (info.health === 'healthy') return true;
|
||||
if (info.health === 'unhealthy') throw new Error(`Container ${nameOrId} is unhealthy`);
|
||||
if (info.health === 'none' && info.status === 'running') return true; // No health check configured
|
||||
await new Promise(r => setTimeout(r, pollMs));
|
||||
}
|
||||
throw new Error(`Container ${nameOrId} health check timeout after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a container
|
||||
*/
|
||||
export async function renameContainer(currentName, newName) {
|
||||
const container = docker.getContainer(currentName);
|
||||
await container.rename({ name: newName });
|
||||
}
|
||||
|
||||
// ─── Network Operations ───────────────────────────────────
|
||||
|
||||
export async function listNetworks() {
|
||||
const networks = await docker.listNetworks();
|
||||
return networks
|
||||
.filter(n => !['none', 'host', 'bridge'].includes(n.Name))
|
||||
.map(n => ({
|
||||
id: n.Id,
|
||||
name: n.Name,
|
||||
driver: n.Driver,
|
||||
scope: n.Scope,
|
||||
containers: Object.keys(n.Containers || {}).length,
|
||||
internal: n.Internal || false,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createNetwork(name, driver = 'bridge', internal = false) {
|
||||
const network = await docker.createNetwork({
|
||||
Name: name,
|
||||
Driver: driver,
|
||||
Internal: internal,
|
||||
});
|
||||
return network;
|
||||
}
|
||||
|
||||
export async function removeNetwork(nameOrId) {
|
||||
const network = docker.getNetwork(nameOrId);
|
||||
await network.remove();
|
||||
}
|
||||
|
||||
export { docker };
|
||||
134
server/src/services/gitea.js
Normal file
134
server/src/services/gitea.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import config from '../config.js';
|
||||
|
||||
/**
|
||||
* Gitea API client — uses API token auth
|
||||
*/
|
||||
|
||||
const headers = () => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `token ${config.giteaToken}`,
|
||||
});
|
||||
|
||||
const apiUrl = (path) => `${config.giteaUrl}/api/v1${path}`;
|
||||
|
||||
async function request(path, opts = {}) {
|
||||
const url = apiUrl(path);
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
headers: { ...headers(), ...opts.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Gitea API error: ${res.status} ${res.statusText} — ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all accessible repositories
|
||||
*/
|
||||
export async function listRepos(page = 1, limit = 50) {
|
||||
return request(`/repos/search?page=${page}&limit=${limit}&sort=updated&order=desc`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a repository by owner/name
|
||||
*/
|
||||
export async function getRepo(owner, name) {
|
||||
return request(`/repos/${owner}/${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List branches for a repository
|
||||
*/
|
||||
export async function listBranches(owner, name) {
|
||||
return request(`/repos/${owner}/${name}/branches`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest commit on a branch
|
||||
*/
|
||||
export async function getLatestCommit(owner, name, branch = 'main') {
|
||||
const commits = await request(`/repos/${owner}/${name}/commits?sha=${branch}&limit=1`);
|
||||
if (commits.length === 0) return null;
|
||||
return {
|
||||
sha: commits[0].sha,
|
||||
message: commits[0].commit?.message || '',
|
||||
author: commits[0].commit?.author?.name || '',
|
||||
date: commits[0].commit?.author?.date || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a webhook on a Gitea repository
|
||||
*/
|
||||
export async function createWebhook(owner, name, targetUrl, secret) {
|
||||
return request(`/repos/${owner}/${name}/hooks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'gitea',
|
||||
config: {
|
||||
url: targetUrl,
|
||||
content_type: 'json',
|
||||
secret: secret,
|
||||
},
|
||||
events: ['push'],
|
||||
active: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook from a Gitea repository
|
||||
*/
|
||||
export async function deleteWebhook(owner, name, hookId) {
|
||||
const url = apiUrl(`/repos/${owner}/${name}/hooks/${hookId}`);
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: headers(),
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error(`Gitea API error: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List webhooks for a repository
|
||||
*/
|
||||
export async function listWebhooks(owner, name) {
|
||||
return request(`/repos/${owner}/${name}/hooks`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the clone URL (with token embedded for private repos)
|
||||
*/
|
||||
export function getCloneUrl(repoUrl) {
|
||||
// repoUrl is like "http://gitea:3000/owner/repo"
|
||||
// We need to inject the token for authenticated clone
|
||||
const url = new URL(repoUrl);
|
||||
url.username = 'autodeployer';
|
||||
url.password = config.giteaToken;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Gitea repo URL into owner/name
|
||||
*/
|
||||
export function parseRepoUrl(repoUrl) {
|
||||
const url = new URL(repoUrl);
|
||||
const parts = url.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/');
|
||||
if (parts.length < 2) throw new Error(`Invalid repo URL: ${repoUrl}`);
|
||||
return { owner: parts[0], name: parts[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the Gitea connection
|
||||
*/
|
||||
export async function testConnection() {
|
||||
try {
|
||||
const user = await request('/user');
|
||||
return { ok: true, user: user.login };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
146
server/src/services/monitoring.js
Normal file
146
server/src/services/monitoring.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import config from '../config.js';
|
||||
import * as db from '../db/index.js';
|
||||
import { getContainerStats, listContainers } from './docker.js';
|
||||
|
||||
let influxWriteApi = null;
|
||||
let influxQueryApi = null;
|
||||
let collectorInterval = null;
|
||||
|
||||
/**
|
||||
* Initialize InfluxDB client
|
||||
*/
|
||||
async function initInflux() {
|
||||
if (!config.influxUrl || !config.influxToken) {
|
||||
console.warn('[MONITORING] InfluxDB not configured, monitoring disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { InfluxDB } = await import('@influxdata/influxdb-client');
|
||||
const client = new InfluxDB({
|
||||
url: config.influxUrl,
|
||||
token: config.influxToken,
|
||||
});
|
||||
|
||||
influxWriteApi = client.getWriteApi(config.influxOrg, config.influxBucket, 's');
|
||||
influxQueryApi = client.getQueryApi(config.influxOrg);
|
||||
console.log('[MONITORING] InfluxDB client initialized');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[MONITORING] InfluxDB init failed:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect metrics for all managed containers and write to InfluxDB
|
||||
*/
|
||||
async function collectMetrics() {
|
||||
if (!influxWriteApi) return;
|
||||
|
||||
try {
|
||||
const { Point } = await import('@influxdata/influxdb-client');
|
||||
const services = db.getAllServices();
|
||||
|
||||
for (const service of services) {
|
||||
if (service.status !== 'running') continue;
|
||||
|
||||
const stats = await getContainerStats(service.container_name);
|
||||
if (!stats) continue;
|
||||
|
||||
const point = new Point('container_metrics')
|
||||
.tag('service', service.name)
|
||||
.tag('container', service.container_name)
|
||||
.floatField('cpu_percent', stats.cpu_percent)
|
||||
.intField('memory_usage', stats.memory_usage)
|
||||
.intField('memory_limit', stats.memory_limit)
|
||||
.floatField('memory_percent', stats.memory_percent)
|
||||
.intField('network_rx', stats.network_rx)
|
||||
.intField('network_tx', stats.network_tx);
|
||||
|
||||
influxWriteApi.writePoint(point);
|
||||
}
|
||||
|
||||
await influxWriteApi.flush();
|
||||
} catch (err) {
|
||||
console.error('[MONITORING] Metrics collection error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the metrics collection loop (every 30 seconds)
|
||||
*/
|
||||
export function startMonitoringCollector() {
|
||||
initInflux().then((ok) => {
|
||||
if (!ok) return;
|
||||
// Collect immediately, then every 30 seconds
|
||||
collectMetrics();
|
||||
collectorInterval = setInterval(collectMetrics, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the metrics collector
|
||||
*/
|
||||
export function stopMonitoringCollector() {
|
||||
if (collectorInterval) {
|
||||
clearInterval(collectorInterval);
|
||||
collectorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query historical metrics for a service
|
||||
* @param {string} serviceName - Service name
|
||||
* @param {string} range - Time range (e.g. '-1h', '-6h', '-24h', '-7d')
|
||||
* @param {string} field - Metric field (cpu_percent, memory_percent, etc.)
|
||||
*/
|
||||
export async function queryMetrics(serviceName, range = '-1h', field = 'cpu_percent') {
|
||||
if (!influxQueryApi) return [];
|
||||
|
||||
const query = `
|
||||
from(bucket: "${config.influxBucket}")
|
||||
|> range(start: ${range})
|
||||
|> filter(fn: (r) => r._measurement == "container_metrics")
|
||||
|> filter(fn: (r) => r.service == "${serviceName}")
|
||||
|> filter(fn: (r) => r._field == "${field}")
|
||||
|> aggregateWindow(every: 1m, fn: mean, createEmpty: false)
|
||||
|> yield(name: "mean")
|
||||
`;
|
||||
|
||||
const results = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
influxQueryApi.queryRows(query, {
|
||||
next(row, tableMeta) {
|
||||
const obj = tableMeta.toObject(row);
|
||||
results.push({
|
||||
time: obj._time,
|
||||
value: obj._value,
|
||||
});
|
||||
},
|
||||
error(err) {
|
||||
console.error('[MONITORING] Query error:', err.message);
|
||||
resolve([]); // Return empty on error instead of rejecting
|
||||
},
|
||||
complete() {
|
||||
resolve(results);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time stats for all running services
|
||||
*/
|
||||
export async function getRealtimeStats() {
|
||||
const services = db.getAllServices();
|
||||
const stats = {};
|
||||
|
||||
for (const service of services) {
|
||||
if (service.status !== 'running') continue;
|
||||
const s = await getContainerStats(service.container_name);
|
||||
if (s) stats[service.name] = s;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
125
server/src/services/queue.js
Normal file
125
server/src/services/queue.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import config from '../config.js';
|
||||
import { buildAndDeploy } from './builder.js';
|
||||
|
||||
// Use ioredis for BullMQ compatibility (BullMQ ships with it)
|
||||
let connection;
|
||||
let buildQueue;
|
||||
let buildWorker;
|
||||
|
||||
// Event listeners registry for real-time log streaming
|
||||
const deployListeners = new Map();
|
||||
|
||||
/**
|
||||
* Register a listener for deploy events (used by WebSocket)
|
||||
*/
|
||||
export function onDeployLog(deployId, callback) {
|
||||
if (!deployListeners.has(deployId)) {
|
||||
deployListeners.set(deployId, new Set());
|
||||
}
|
||||
deployListeners.get(deployId).add(callback);
|
||||
|
||||
return () => {
|
||||
const listeners = deployListeners.get(deployId);
|
||||
if (listeners) {
|
||||
listeners.delete(callback);
|
||||
if (listeners.size === 0) deployListeners.delete(deployId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function notifyDeployListeners(deployId, message, stream) {
|
||||
const listeners = deployListeners.get(deployId);
|
||||
if (listeners) {
|
||||
for (const cb of listeners) {
|
||||
try { cb(message, stream); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the build queue and worker
|
||||
*/
|
||||
export async function initQueue() {
|
||||
// Parse Redis URL
|
||||
const redisUrl = new URL(config.redisUrl);
|
||||
connection = new IORedis({
|
||||
host: redisUrl.hostname,
|
||||
port: parseInt(redisUrl.port || '6379'),
|
||||
db: parseInt(redisUrl.pathname?.replace('/', '') || '2'),
|
||||
password: redisUrl.password || undefined,
|
||||
maxRetriesPerRequest: null, // Required by BullMQ
|
||||
enableReadyCheck: false,
|
||||
});
|
||||
|
||||
buildQueue = new Queue('autodeployer:builds', { connection });
|
||||
|
||||
buildWorker = new Worker(
|
||||
'autodeployer:builds',
|
||||
async (job) => {
|
||||
const { deployId } = job.data;
|
||||
console.log(`[WORKER] Processing deploy ${deployId}`);
|
||||
|
||||
await buildAndDeploy(deployId, (message, stream) => {
|
||||
notifyDeployListeners(deployId, message, stream);
|
||||
});
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 1, // Process one build at a time
|
||||
removeOnComplete: { count: 50 },
|
||||
removeOnFail: { count: 20 },
|
||||
}
|
||||
);
|
||||
|
||||
buildWorker.on('completed', (job) => {
|
||||
console.log(`[WORKER] Deploy ${job.data.deployId} completed`);
|
||||
});
|
||||
|
||||
buildWorker.on('failed', (job, err) => {
|
||||
console.error(`[WORKER] Deploy ${job?.data?.deployId} failed:`, err.message);
|
||||
});
|
||||
|
||||
buildWorker.on('error', (err) => {
|
||||
console.error('[WORKER] Queue error:', err.message);
|
||||
});
|
||||
|
||||
console.log('[QUEUE] Connected to Redis, worker ready');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a deploy job to the queue
|
||||
*/
|
||||
export async function queueDeploy(deployId, serviceId) {
|
||||
if (!buildQueue) throw new Error('Build queue not initialized');
|
||||
|
||||
const job = await buildQueue.add(
|
||||
'deploy',
|
||||
{ deployId, serviceId },
|
||||
{
|
||||
attempts: 1, // No retry on failure (deploy should be manually triggered again)
|
||||
removeOnComplete: { count: 50 },
|
||||
removeOnFail: { count: 20 },
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[QUEUE] Deploy ${deployId} queued as job ${job.id}`);
|
||||
return job.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue status
|
||||
*/
|
||||
export async function getQueueStatus() {
|
||||
if (!buildQueue) return { waiting: 0, active: 0, completed: 0, failed: 0 };
|
||||
|
||||
const [waiting, active, completed, failed] = await Promise.all([
|
||||
buildQueue.getWaitingCount(),
|
||||
buildQueue.getActiveCount(),
|
||||
buildQueue.getCompletedCount(),
|
||||
buildQueue.getFailedCount(),
|
||||
]);
|
||||
|
||||
return { waiting, active, completed, failed };
|
||||
}
|
||||
92
server/src/services/telegram.js
Normal file
92
server/src/services/telegram.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import config from '../config.js';
|
||||
|
||||
/**
|
||||
* Send a Telegram notification
|
||||
*/
|
||||
async function sendMessage(text, parseMode = 'HTML') {
|
||||
if (!config.telegramBotToken || !config.telegramChatId) {
|
||||
return; // Telegram not configured, skip silently
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`;
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: config.telegramChatId,
|
||||
text,
|
||||
parse_mode: parseMode,
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TELEGRAM] Failed to send notification:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_EMOJI = {
|
||||
building: '🔨',
|
||||
success: '✅',
|
||||
failed: '❌',
|
||||
stopped: '⏹️',
|
||||
started: '▶️',
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a deploy status notification
|
||||
*/
|
||||
export async function sendDeployNotification(serviceName, status, details = '') {
|
||||
const emoji = STATUS_EMOJI[status] || '📋';
|
||||
const statusText = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
let text = `${emoji} <b>AutoDeployer</b>\n\n`;
|
||||
text += `<b>Service:</b> ${escapeHtml(serviceName)}\n`;
|
||||
text += `<b>Status:</b> ${statusText}\n`;
|
||||
if (details) {
|
||||
text += `<b>Details:</b> ${escapeHtml(details)}\n`;
|
||||
}
|
||||
text += `\n<i>${new Date().toLocaleString('it-IT', { timeZone: 'Europe/Rome' })}</i>`;
|
||||
|
||||
await sendMessage(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a generic notification
|
||||
*/
|
||||
export async function sendNotification(title, message) {
|
||||
const text = `📢 <b>${escapeHtml(title)}</b>\n\n${escapeHtml(message)}`;
|
||||
await sendMessage(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Telegram configuration
|
||||
*/
|
||||
export async function testTelegram() {
|
||||
if (!config.telegramBotToken || !config.telegramChatId) {
|
||||
return { ok: false, error: 'Telegram bot token and chat ID are required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: config.telegramChatId,
|
||||
text: '✅ AutoDeployer — Telegram notifications configured successfully!',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
return { ok: data.ok, error: data.ok ? null : data.description };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
195
server/src/services/traefik.js
Normal file
195
server/src/services/traefik.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Traefik label generator
|
||||
* Generates Docker labels for Traefik reverse proxy configuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate all Traefik labels for a service
|
||||
* @param {object} service - Service configuration from DB
|
||||
* @returns {object} Docker labels object
|
||||
*/
|
||||
export function generateTraefikLabels(service) {
|
||||
if (!service.traefik_enabled) {
|
||||
return { 'traefik.enable': 'false' };
|
||||
}
|
||||
|
||||
const name = sanitizeRouterName(service.container_name);
|
||||
const labels = {
|
||||
'traefik.enable': 'true',
|
||||
};
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────
|
||||
const routerPrefix = `traefik.http.routers.${name}`;
|
||||
|
||||
// Build routing rule
|
||||
let rule = '';
|
||||
if (service.traefik_domain) {
|
||||
rule = `Host(\`${service.traefik_domain}\`)`;
|
||||
}
|
||||
if (service.traefik_path_prefix) {
|
||||
const pathRule = `PathPrefix(\`${service.traefik_path_prefix}\`)`;
|
||||
rule = rule ? `${rule} && ${pathRule}` : pathRule;
|
||||
}
|
||||
if (!rule) {
|
||||
rule = `Host(\`${service.container_name}.localhost\`)`;
|
||||
}
|
||||
|
||||
labels[`${routerPrefix}.rule`] = rule;
|
||||
|
||||
// Entrypoints
|
||||
labels[`${routerPrefix}.entrypoints`] = service.traefik_entrypoints || 'websecure';
|
||||
|
||||
// TLS
|
||||
if (service.traefik_tls_resolver) {
|
||||
labels[`${routerPrefix}.tls`] = 'true';
|
||||
labels[`${routerPrefix}.tls.certresolver`] = service.traefik_tls_resolver;
|
||||
}
|
||||
|
||||
// ─── Service ─────────────────────────────────────────────
|
||||
const svcPrefix = `traefik.http.services.${name}`;
|
||||
labels[`${svcPrefix}.loadbalancer.server.port`] = String(service.container_port || 3000);
|
||||
|
||||
// ─── Network ─────────────────────────────────────────────
|
||||
if (service.traefik_network) {
|
||||
labels['traefik.docker.network'] = service.traefik_network;
|
||||
}
|
||||
|
||||
// ─── Middlewares ─────────────────────────────────────────
|
||||
const middlewares = parseMiddlewares(service.traefik_middlewares);
|
||||
const middlewareNames = [];
|
||||
|
||||
for (const mw of middlewares) {
|
||||
// Reference middleware: use existing middleware from file/docker provider
|
||||
if (mw.type === 'reference') {
|
||||
const provider = mw.provider || 'file';
|
||||
middlewareNames.push(`${mw.name}@${provider}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const mwName = `${name}-${mw.type}`;
|
||||
middlewareNames.push(mwName);
|
||||
const mwPrefix = `traefik.http.middlewares.${mwName}`;
|
||||
|
||||
switch (mw.type) {
|
||||
case 'ratelimit':
|
||||
labels[`${mwPrefix}.ratelimit.average`] = String(mw.average || 100);
|
||||
labels[`${mwPrefix}.ratelimit.burst`] = String(mw.burst || 50);
|
||||
labels[`${mwPrefix}.ratelimit.period`] = mw.period || '1m';
|
||||
break;
|
||||
|
||||
case 'headers':
|
||||
if (mw.stsSeconds) labels[`${mwPrefix}.headers.stsSeconds`] = String(mw.stsSeconds);
|
||||
if (mw.stsIncludeSubdomains) labels[`${mwPrefix}.headers.stsIncludeSubdomains`] = 'true';
|
||||
if (mw.forceSTSHeader) labels[`${mwPrefix}.headers.forceSTSHeader`] = 'true';
|
||||
if (mw.contentTypeNosniff) labels[`${mwPrefix}.headers.contentTypeNosniff`] = 'true';
|
||||
if (mw.frameDeny) labels[`${mwPrefix}.headers.frameDeny`] = 'true';
|
||||
if (mw.browserXssFilter) labels[`${mwPrefix}.headers.browserXssFilter`] = 'true';
|
||||
if (mw.customRequestHeaders) {
|
||||
for (const [k, v] of Object.entries(mw.customRequestHeaders)) {
|
||||
labels[`${mwPrefix}.headers.customrequestheaders.${k}`] = v;
|
||||
}
|
||||
}
|
||||
if (mw.customResponseHeaders) {
|
||||
for (const [k, v] of Object.entries(mw.customResponseHeaders)) {
|
||||
labels[`${mwPrefix}.headers.customresponseheaders.${k}`] = v;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'redirectscheme':
|
||||
labels[`${mwPrefix}.redirectscheme.scheme`] = mw.scheme || 'https';
|
||||
labels[`${mwPrefix}.redirectscheme.permanent`] = String(mw.permanent !== false);
|
||||
break;
|
||||
|
||||
case 'basicauth':
|
||||
if (mw.users) labels[`${mwPrefix}.basicauth.users`] = mw.users;
|
||||
break;
|
||||
|
||||
case 'stripprefix':
|
||||
if (mw.prefixes) labels[`${mwPrefix}.stripprefix.prefixes`] = mw.prefixes.join(',');
|
||||
break;
|
||||
|
||||
case 'compress':
|
||||
labels[`${mwPrefix}.compress`] = 'true';
|
||||
break;
|
||||
|
||||
case 'retry':
|
||||
labels[`${mwPrefix}.retry.attempts`] = String(mw.attempts || 3);
|
||||
if (mw.initialInterval) labels[`${mwPrefix}.retry.initialInterval`] = mw.initialInterval;
|
||||
break;
|
||||
|
||||
case 'ipallowlist':
|
||||
if (mw.sourceRange) labels[`${mwPrefix}.ipallowlist.sourcerange`] = mw.sourceRange.join(',');
|
||||
break;
|
||||
|
||||
default:
|
||||
// Custom labels pass-through
|
||||
if (mw.labels) {
|
||||
for (const [k, v] of Object.entries(mw.labels)) {
|
||||
labels[`${mwPrefix}.${k}`] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (middlewareNames.length > 0) {
|
||||
labels[`${routerPrefix}.middlewares`] = middlewareNames.join(',');
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a preview of Traefik labels (formatted for display)
|
||||
*/
|
||||
export function previewTraefikLabels(service) {
|
||||
const labels = generateTraefikLabels(service);
|
||||
return Object.entries(labels)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Common middleware presets
|
||||
*/
|
||||
export const MIDDLEWARE_PRESETS = {
|
||||
'security-headers': {
|
||||
type: 'headers',
|
||||
stsSeconds: 31536000,
|
||||
stsIncludeSubdomains: true,
|
||||
forceSTSHeader: true,
|
||||
contentTypeNosniff: true,
|
||||
frameDeny: true,
|
||||
browserXssFilter: true,
|
||||
},
|
||||
'redirect-https': {
|
||||
type: 'redirectscheme',
|
||||
scheme: 'https',
|
||||
permanent: true,
|
||||
},
|
||||
'rate-limit-default': {
|
||||
type: 'ratelimit',
|
||||
average: 100,
|
||||
burst: 50,
|
||||
period: '1m',
|
||||
},
|
||||
'compress': {
|
||||
type: 'compress',
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function sanitizeRouterName(name) {
|
||||
return name.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
|
||||
}
|
||||
|
||||
function parseMiddlewares(json) {
|
||||
try {
|
||||
const parsed = JSON.parse(json || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
82
server/src/ws/logs.js
Normal file
82
server/src/ws/logs.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { streamLogs } from '../services/docker.js';
|
||||
import { onDeployLog } from '../services/queue.js';
|
||||
import * as db from '../db/index.js';
|
||||
|
||||
/**
|
||||
* Handle WebSocket log streaming
|
||||
* Path: /ws/logs/{target}
|
||||
*
|
||||
* Target can be:
|
||||
* - service:{serviceId} — Stream runtime container logs
|
||||
* - deploy:{deployId} — Stream build logs for a deploy
|
||||
*/
|
||||
export function handleLogStream(ws, target) {
|
||||
let cleanup = null;
|
||||
|
||||
const sendLog = (line) => {
|
||||
if (ws.readyState === 1) { // WebSocket.OPEN
|
||||
ws.send(JSON.stringify({ type: 'log', data: line, timestamp: new Date().toISOString() }));
|
||||
}
|
||||
};
|
||||
|
||||
if (target.startsWith('service:')) {
|
||||
// Stream runtime container logs
|
||||
const serviceId = target.replace('service:', '');
|
||||
const service = db.getServiceById(serviceId);
|
||||
|
||||
if (!service) {
|
||||
ws.send(JSON.stringify({ type: 'error', data: 'Service not found' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: 'info', data: `Streaming logs for ${service.name}...` }));
|
||||
|
||||
streamLogs(service.container_name, sendLog, { follow: true, tail: 100 })
|
||||
.then((cleanupFn) => {
|
||||
cleanup = cleanupFn;
|
||||
})
|
||||
.catch((err) => {
|
||||
ws.send(JSON.stringify({ type: 'error', data: `Failed to stream logs: ${err.message}` }));
|
||||
});
|
||||
|
||||
} else if (target.startsWith('deploy:')) {
|
||||
// Stream build logs for a deploy
|
||||
const deployId = target.replace('deploy:', '');
|
||||
const deploy = db.getDeployById(deployId);
|
||||
|
||||
if (!deploy) {
|
||||
ws.send(JSON.stringify({ type: 'error', data: 'Deploy not found' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send existing logs first
|
||||
const existingLogs = db.getDeployLogs(deployId);
|
||||
for (const log of existingLogs) {
|
||||
sendLog(log.message);
|
||||
}
|
||||
|
||||
// If deploy is still in progress, stream new logs
|
||||
if (['queued', 'building'].includes(deploy.status)) {
|
||||
ws.send(JSON.stringify({ type: 'info', data: 'Streaming build output...' }));
|
||||
cleanup = onDeployLog(deployId, (message) => {
|
||||
sendLog(message);
|
||||
});
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'info', data: `Deploy ${deploy.status}. Showing historical logs.` }));
|
||||
}
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'error', data: 'Invalid target. Use service:{id} or deploy:{id}' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on('close', () => {
|
||||
if (cleanup) cleanup();
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
if (cleanup) cleanup();
|
||||
});
|
||||
}
|
||||
82
server/src/ws/terminal.js
Normal file
82
server/src/ws/terminal.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { execInContainer, resizeExec } from '../services/docker.js';
|
||||
|
||||
/**
|
||||
* Handle WebSocket terminal session
|
||||
* Path: /ws/terminal/{containerId}
|
||||
*
|
||||
* Creates an interactive shell (docker exec) in the container
|
||||
* and bridges it to the WebSocket connection.
|
||||
*
|
||||
* Client messages:
|
||||
* - { type: 'input', data: '...' } — stdin data
|
||||
* - { type: 'resize', cols: N, rows: N } — terminal resize
|
||||
*/
|
||||
export async function handleTerminal(ws, containerId) {
|
||||
let execInstance = null;
|
||||
let stream = null;
|
||||
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'info', data: `Connecting to container ${containerId.slice(0, 12)}...` }));
|
||||
|
||||
const result = await execInContainer(containerId, ['/bin/sh', '-c', 'if command -v bash > /dev/null; then exec bash; else exec sh; fi']);
|
||||
execInstance = result.exec;
|
||||
stream = result.stream;
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected', data: 'Terminal connected' }));
|
||||
|
||||
// Docker exec stream → WebSocket
|
||||
stream.on('data', (chunk) => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: chunk.toString('utf8') }));
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'info', data: 'Terminal session ended' }));
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'error', data: `Stream error: ${err.message}` }));
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket → Docker exec stream
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
|
||||
if (msg.type === 'input' && stream && stream.writable) {
|
||||
stream.write(msg.data);
|
||||
} else if (msg.type === 'resize' && execInstance) {
|
||||
resizeExec(execInstance.id, msg.cols || 80, msg.rows || 24);
|
||||
}
|
||||
} catch {
|
||||
// If raw text, treat as stdin input
|
||||
if (stream && stream.writable) {
|
||||
stream.write(message.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: 'error', data: `Failed to connect: ${err.message}` }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on('close', () => {
|
||||
try {
|
||||
if (stream) stream.end();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
try {
|
||||
if (stream) stream.end();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
10
updater/Dockerfile
Normal file
10
updater/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM docker:27-cli
|
||||
|
||||
RUN apk add --no-cache bash curl git docker-compose
|
||||
|
||||
COPY update.sh /update.sh
|
||||
RUN chmod +x /update.sh
|
||||
|
||||
EXPOSE 9111
|
||||
|
||||
CMD ["bash", "/update.sh"]
|
||||
45
updater/update.sh
Normal file
45
updater/update.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# AutoDeployer self-update sidecar
|
||||
# Watches for a trigger file and rebuilds the autodeployer container.
|
||||
|
||||
PROJECT_DIR="/project"
|
||||
TRIGGER_FILE="/trigger/update-trigger"
|
||||
LOG_FILE="/trigger/update.log"
|
||||
|
||||
log() {
|
||||
echo "[$(date -Iseconds)] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
do_update() {
|
||||
log "=== Self-update triggered ==="
|
||||
cd "$PROJECT_DIR" || { log "ERROR: project dir not found"; return 1; }
|
||||
|
||||
log "Pulling latest changes..."
|
||||
if ! git pull --ff-only 2>&1 | tee -a "$LOG_FILE"; then
|
||||
log "ERROR: git pull failed"
|
||||
echo "failed" > /trigger/update-status
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Rebuilding autodeployer..."
|
||||
if ! docker compose up -d --build --no-deps autodeployer 2>&1 | tee -a "$LOG_FILE"; then
|
||||
log "ERROR: docker compose up failed"
|
||||
echo "failed" > /trigger/update-status
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "=== Self-update completed ==="
|
||||
echo "success" > /trigger/update-status
|
||||
return 0
|
||||
}
|
||||
|
||||
log "Updater sidecar started, watching $TRIGGER_FILE"
|
||||
|
||||
while true; do
|
||||
if [ -f "$TRIGGER_FILE" ]; then
|
||||
rm -f "$TRIGGER_FILE"
|
||||
echo "updating" > /trigger/update-status
|
||||
do_update
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
Reference in New Issue
Block a user