This repository has been archived on 2026-05-11. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
autodeployer-old-version/dashboard/js/pages/service-detail.js
Giuseppe Raffa 87d698bc5c 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.
2026-04-13 23:23:18 +02:00

721 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;');
}