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:
Giuseppe Raffa
2026-04-13 23:23:18 +02:00
commit 87d698bc5c
48 changed files with 5558 additions and 0 deletions

114
dashboard/js/api.js Normal file
View 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
View 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
View 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),
};

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

View 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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

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

View 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

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