- 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.
721 lines
40 KiB
JavaScript
721 lines
40 KiB
JavaScript
import { services as api, deploys as deploysApi, networks as networksApi, createLogSocket, createTerminalSocket } from '../api.js';
|
||
import { icons } from '../icons.js';
|
||
import { navigate } from '../router.js';
|
||
|
||
const TABS = [
|
||
{ id: 'general', label: 'Generale', icon: 'settings' },
|
||
{ id: 'traefik', label: 'Traefik', icon: 'globe' },
|
||
{ id: 'env', label: 'Variabili', icon: 'fileCode' },
|
||
{ id: 'deploys', label: 'Deploy', icon: 'gitBranch' },
|
||
{ id: 'logs', label: 'Logs', icon: 'terminal' },
|
||
{ id: 'terminal', label: 'Terminal', icon: 'terminal' },
|
||
{ id: 'networks', label: 'Networks', icon: 'network' },
|
||
{ id: 'webhook', label: 'Webhook', icon: 'shield' },
|
||
];
|
||
|
||
const MIDDLEWARE_TYPES = [
|
||
{ value: 'ratelimit', label: 'Rate Limit' },
|
||
{ value: 'headers', label: 'Security Headers' },
|
||
{ value: 'redirectscheme', label: 'Redirect HTTPS' },
|
||
{ value: 'compress', label: 'Compress' },
|
||
{ value: 'stripprefix', label: 'Strip Prefix' },
|
||
{ value: 'basicauth', label: 'Basic Auth' },
|
||
{ value: 'ipallowlist', label: 'IP Allow List' },
|
||
];
|
||
|
||
const REFERENCE_PRESETS = [
|
||
{ name: 'security', provider: 'file', label: 'security@file' },
|
||
{ name: 'ratelimit', provider: 'file', label: 'ratelimit@file' },
|
||
{ name: 'ratelimitws', provider: 'file', label: 'ratelimitws@file' },
|
||
{ name: 'internalonly', provider: 'file', label: 'internalonly@file' },
|
||
{ name: 'higherauth', provider: 'file', label: 'higherauth@file' },
|
||
];
|
||
|
||
export function renderServiceDetail(container, id) {
|
||
let service = null;
|
||
let form = {};
|
||
let activeTab = 'general';
|
||
let saving = false;
|
||
let logCleanup = null;
|
||
let termCleanup = null;
|
||
|
||
container.innerHTML = `<div class="empty-state"><div class="animate-spin" style="font-size:2rem">⏳</div></div>`;
|
||
|
||
async function loadService() {
|
||
try {
|
||
const data = await api.get(id);
|
||
service = data;
|
||
form = {
|
||
name: data.name, description: data.description,
|
||
gitea_repo_url: data.gitea_repo_url, gitea_branch: data.gitea_branch,
|
||
dockerfile_path: data.dockerfile_path, build_context: data.build_context,
|
||
container_name: data.container_name, container_port: data.container_port,
|
||
traefik_enabled: !!data.traefik_enabled, traefik_domain: data.traefik_domain,
|
||
traefik_entrypoints: data.traefik_entrypoints, traefik_tls_resolver: data.traefik_tls_resolver,
|
||
traefik_path_prefix: data.traefik_path_prefix, traefik_middlewares: data.traefik_middlewares || [],
|
||
traefik_network: data.traefik_network, networks: data.networks || ['meb-public'],
|
||
health_check_enabled: !!data.health_check_enabled, health_check_path: data.health_check_path,
|
||
health_check_interval: data.health_check_interval, health_check_timeout: data.health_check_timeout,
|
||
health_check_retries: data.health_check_retries, zero_downtime: !!data.zero_downtime,
|
||
};
|
||
render();
|
||
} catch (err) {
|
||
container.innerHTML = `<div class="empty-state"><h3>Servizio non trovato</h3><button class="btn btn-primary mt-4" id="back-btn">Torna alla Dashboard</button></div>`;
|
||
container.querySelector('#back-btn').onclick = () => navigate('/');
|
||
}
|
||
}
|
||
|
||
async function handleSave() {
|
||
saving = true; render();
|
||
try { await api.update(id, form); await loadService(); }
|
||
catch (err) { alert('Errore: ' + err.message); }
|
||
finally { saving = false; render(); }
|
||
}
|
||
|
||
async function handleDeploy() {
|
||
try { await api.deploy(id); loadService(); } catch (err) { alert('Errore deploy: ' + err.message); }
|
||
}
|
||
async function handleStop() {
|
||
try { await api.stop(id); loadService(); } catch (err) { alert('Errore stop: ' + err.message); }
|
||
}
|
||
async function handleDelete() {
|
||
if (!confirm(`Sei sicuro di voler eliminare "${service.name}"? Questa azione è irreversibile.`)) return;
|
||
try { await api.delete(id); navigate('/'); } catch (err) { alert('Errore: ' + err.message); }
|
||
}
|
||
|
||
function render() {
|
||
if (!service) return;
|
||
cleanupTab();
|
||
const status = service.container?.status || service.status || 'stopped';
|
||
|
||
container.innerHTML = `
|
||
<div class="animate-slide-in">
|
||
<div class="page-header">
|
||
<div class="flex items-center gap-3">
|
||
<button class="btn btn-secondary btn-icon" id="back-btn">${icons.arrowLeft(18)}</button>
|
||
<div>
|
||
<div class="flex items-center gap-3"><h2>${service.name}</h2><span class="status-badge ${status}">${status}</span></div>
|
||
${service.description ? `<p>${service.description}</p>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button class="btn btn-primary" id="deploy-btn" ${status === 'building' ? 'disabled' : ''}>${icons.play(16)} Deploy</button>
|
||
${status === 'running' ? `<button class="btn btn-danger" id="stop-btn">${icons.square(16)} Stop</button>` : ''}
|
||
<button class="btn btn-danger btn-sm" id="delete-btn">${icons.trash2(14)}</button>
|
||
</div>
|
||
</div>
|
||
<div class="tabs" id="tabs-bar">${TABS.map(t => `<button class="tab ${activeTab === t.id ? 'active' : ''}" data-tab="${t.id}">${t.label}</button>`).join('')}</div>
|
||
<div id="tab-content"></div>
|
||
</div>`;
|
||
|
||
container.querySelector('#back-btn').onclick = () => navigate('/');
|
||
container.querySelector('#deploy-btn').onclick = handleDeploy;
|
||
container.querySelector('#stop-btn')?.addEventListener('click', handleStop);
|
||
container.querySelector('#delete-btn').onclick = handleDelete;
|
||
container.querySelectorAll('.tab').forEach(b => {
|
||
b.onclick = () => { activeTab = b.dataset.tab; render(); };
|
||
});
|
||
|
||
renderTab();
|
||
}
|
||
|
||
function cleanupTab() {
|
||
if (logCleanup) { logCleanup(); logCleanup = null; }
|
||
if (termCleanup) { termCleanup(); termCleanup = null; }
|
||
}
|
||
|
||
function renderTab() {
|
||
const el = document.getElementById('tab-content');
|
||
if (!el) return;
|
||
switch (activeTab) {
|
||
case 'general': renderGeneralTab(el); break;
|
||
case 'traefik': renderTraefikTab(el); break;
|
||
case 'env': renderEnvTab(el); break;
|
||
case 'deploys': renderDeploysTab(el); break;
|
||
case 'logs': renderLogsTab(el); break;
|
||
case 'terminal': renderTerminalTab(el); break;
|
||
case 'networks': renderNetworksTab(el); break;
|
||
case 'webhook': renderWebhookTab(el); break;
|
||
}
|
||
}
|
||
|
||
// ── General Tab ──
|
||
function renderGeneralTab(el) {
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<h3 class="card-title mb-4">Configurazione Generale</h3>
|
||
<div class="form-row">
|
||
<div class="form-group"><label class="form-label">Nome</label><input class="form-input" id="f-name" value="${esc(form.name)}"></div>
|
||
<div class="form-group"><label class="form-label">Container Name</label><input class="form-input mono" id="f-container" value="${esc(form.container_name)}"></div>
|
||
</div>
|
||
<div class="form-group"><label class="form-label">Descrizione</label><input class="form-input" id="f-desc" value="${esc(form.description || '')}"></div>
|
||
<div class="form-row">
|
||
<div class="form-group"><label class="form-label">Repository URL</label><input class="form-input mono" id="f-repo" value="${esc(form.gitea_repo_url)}"></div>
|
||
<div class="form-group"><label class="form-label">Branch</label><input class="form-input mono" id="f-branch" value="${esc(form.gitea_branch)}"></div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group"><label class="form-label">Dockerfile Path</label><input class="form-input mono" id="f-dockerfile" value="${esc(form.dockerfile_path || '')}"></div>
|
||
<div class="form-group"><label class="form-label">Build Context</label><input class="form-input mono" id="f-context" value="${esc(form.build_context || '')}"></div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group"><label class="form-label">Container Port</label><input type="number" class="form-input" id="f-port" value="${form.container_port}"></div>
|
||
</div>
|
||
<h3 class="card-title mt-4 mb-4">Deploy Strategy</h3>
|
||
<div class="form-row">
|
||
<label class="toggle"><input type="checkbox" id="f-zd" ${form.zero_downtime ? 'checked' : ''}><div class="toggle-track"></div><span class="text-sm">Zero-Downtime Deploy</span></label>
|
||
<label class="toggle"><input type="checkbox" id="f-hc" ${form.health_check_enabled ? 'checked' : ''}><div class="toggle-track"></div><span class="text-sm">Health Check</span></label>
|
||
</div>
|
||
<div id="hc-fields" class="${form.health_check_enabled ? '' : 'hidden'}">
|
||
<div class="form-row mt-4">
|
||
<div class="form-group"><label class="form-label">Health Check Path</label><input class="form-input mono" id="f-hcpath" value="${esc(form.health_check_path || '')}"></div>
|
||
<div class="form-group"><label class="form-label">Interval (secondi)</label><input type="number" class="form-input" id="f-hcint" value="${form.health_check_interval || 30}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-4"><button class="btn btn-primary" id="save-general">${saving ? '⏳ Salvando...' : '💾 Salva Configurazione'}</button></div>
|
||
</div>`;
|
||
|
||
const bind = (sel, field, parser) => {
|
||
const inp = el.querySelector(sel);
|
||
inp.oninput = () => { form[field] = parser ? parser(inp.value) : inp.value; };
|
||
};
|
||
bind('#f-name', 'name'); bind('#f-container', 'container_name');
|
||
bind('#f-desc', 'description'); bind('#f-repo', 'gitea_repo_url');
|
||
bind('#f-branch', 'gitea_branch'); bind('#f-dockerfile', 'dockerfile_path');
|
||
bind('#f-context', 'build_context'); bind('#f-port', 'container_port', v => parseInt(v));
|
||
bind('#f-hcpath', 'health_check_path'); bind('#f-hcint', 'health_check_interval', v => parseInt(v));
|
||
|
||
el.querySelector('#f-zd').onchange = (e) => { form.zero_downtime = e.target.checked; };
|
||
el.querySelector('#f-hc').onchange = (e) => {
|
||
form.health_check_enabled = e.target.checked;
|
||
el.querySelector('#hc-fields').classList.toggle('hidden', !e.target.checked);
|
||
};
|
||
el.querySelector('#save-general').onclick = handleSave;
|
||
}
|
||
|
||
// ── Traefik Tab ──
|
||
function renderTraefikTab(el) {
|
||
const mws = form.traefik_middlewares || [];
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">${icons.globe(16)} Configurazione Traefik</h3>
|
||
<button class="btn btn-secondary btn-sm" id="preview-btn">${icons.eye(14)} Preview Labels</button>
|
||
</div>
|
||
<label class="toggle mb-4"><input type="checkbox" id="t-enabled" ${form.traefik_enabled ? 'checked' : ''}><div class="toggle-track"></div><span class="text-sm">Traefik Enabled</span></label>
|
||
<div id="traefik-fields" class="${form.traefik_enabled ? '' : 'hidden'}">
|
||
<div class="form-row">
|
||
<div class="form-group"><label class="form-label">Dominio</label><input class="form-input mono" id="t-domain" value="${esc(form.traefik_domain || '')}" placeholder="api.mebboat.it"></div>
|
||
<div class="form-group"><label class="form-label">Path Prefix</label><input class="form-input mono" id="t-prefix" value="${esc(form.traefik_path_prefix || '')}" placeholder="/api (opzionale)"></div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group"><label class="form-label">Entrypoints</label><input class="form-input mono" id="t-entry" value="${esc(form.traefik_entrypoints || '')}" placeholder="websecure"></div>
|
||
<div class="form-group"><label class="form-label">TLS Cert Resolver</label><input class="form-input mono" id="t-tls" value="${esc(form.traefik_tls_resolver || '')}" placeholder="cloudflare"></div>
|
||
</div>
|
||
<div class="form-group"><label class="form-label">Docker Network (Traefik)</label><input class="form-input mono" id="t-net" value="${esc(form.traefik_network || '')}" placeholder="meb-public"></div>
|
||
<h4 class="card-title mt-4 mb-2">${icons.shield(14)} Middlewares</h4>
|
||
<div id="mw-list"></div>
|
||
<div class="mb-3"><p class="text-xs text-muted mb-1">Middleware esistenti (file provider):</p><div class="flex gap-2 flex-wrap" id="mw-presets"></div></div>
|
||
<div><p class="text-xs text-muted mb-1">Aggiungi middleware inline:</p><div class="flex gap-2 flex-wrap" id="mw-add"></div></div>
|
||
</div>
|
||
<div id="preview-area" class="hidden mt-4"><h4 class="card-title mb-2">Preview Labels</h4><pre class="log-viewer" id="preview-content" style="max-height:300px;white-space:pre;font-size:0.78rem"></pre><button class="btn btn-secondary btn-sm mt-2" id="close-preview">Chiudi Preview</button></div>
|
||
<div class="mt-4"><button class="btn btn-primary" id="save-traefik">${saving ? 'Salvando...' : 'Salva'}</button></div>
|
||
</div>`;
|
||
|
||
const bindT = (sel, field) => { el.querySelector(sel).oninput = (e) => { form[field] = e.target.value; }; };
|
||
bindT('#t-domain', 'traefik_domain'); bindT('#t-prefix', 'traefik_path_prefix');
|
||
bindT('#t-entry', 'traefik_entrypoints'); bindT('#t-tls', 'traefik_tls_resolver');
|
||
bindT('#t-net', 'traefik_network');
|
||
|
||
el.querySelector('#t-enabled').onchange = (e) => {
|
||
form.traefik_enabled = e.target.checked;
|
||
el.querySelector('#traefik-fields').classList.toggle('hidden', !e.target.checked);
|
||
};
|
||
|
||
el.querySelector('#preview-btn').onclick = async () => {
|
||
try {
|
||
const data = await api.traefikPreview(id);
|
||
el.querySelector('#preview-content').textContent = data.labels || 'Nessun label generato';
|
||
el.querySelector('#preview-area').classList.remove('hidden');
|
||
} catch (err) {
|
||
el.querySelector('#preview-content').textContent = 'Errore: ' + err.message;
|
||
el.querySelector('#preview-area').classList.remove('hidden');
|
||
}
|
||
};
|
||
el.querySelector('#close-preview').onclick = () => el.querySelector('#preview-area').classList.add('hidden');
|
||
el.querySelector('#save-traefik').onclick = handleSave;
|
||
|
||
renderMiddlewares();
|
||
|
||
function renderMiddlewares() {
|
||
const list = el.querySelector('#mw-list');
|
||
list.innerHTML = mws.map((mw, i) => `
|
||
<div class="card mb-2" style="padding:12px 16px;background:var(--bg-glass)">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<span class="text-sm font-bold" style="text-transform:capitalize">${mw.type === 'reference' ? `${mw.name || '?'}@${mw.provider || 'file'}` : mw.type}</span>
|
||
<button class="btn btn-danger btn-sm btn-icon mw-rm" data-i="${i}">${icons.trash2(12)}</button>
|
||
</div>
|
||
${renderMwFields(mw, i)}
|
||
</div>`).join('');
|
||
|
||
list.querySelectorAll('.mw-rm').forEach(b => {
|
||
b.onclick = () => { form.traefik_middlewares.splice(parseInt(b.dataset.i), 1); renderMiddlewares(); };
|
||
});
|
||
list.querySelectorAll('[data-mw-field]').forEach(inp => {
|
||
inp.oninput = () => {
|
||
const idx = parseInt(inp.dataset.mwIdx);
|
||
const field = inp.dataset.mwField;
|
||
let val = inp.value;
|
||
if (inp.type === 'number') val = parseInt(val);
|
||
if (inp.type === 'checkbox') val = inp.checked;
|
||
if (field === 'prefixes') val = inp.value.split(',').map(s => s.trim()).filter(Boolean);
|
||
if (field === 'sourceRange') val = inp.value.split('\n').map(s => s.trim()).filter(Boolean);
|
||
form.traefik_middlewares[idx][field] = val;
|
||
};
|
||
if (inp.type === 'checkbox') {
|
||
inp.onchange = inp.oninput;
|
||
}
|
||
});
|
||
|
||
// Presets
|
||
const presets = el.querySelector('#mw-presets');
|
||
presets.innerHTML = REFERENCE_PRESETS.filter(p => !mws.some(m => m.type === 'reference' && m.name === p.name)).map(p =>
|
||
`<button class="btn btn-secondary btn-sm" data-preset="${p.name}">${icons.plus(12)} ${p.label}</button>`
|
||
).join('');
|
||
presets.querySelectorAll('[data-preset]').forEach(b => {
|
||
b.onclick = () => {
|
||
const p = REFERENCE_PRESETS.find(r => r.name === b.dataset.preset);
|
||
form.traefik_middlewares.push({ type: 'reference', name: p.name, provider: p.provider });
|
||
renderMiddlewares();
|
||
};
|
||
});
|
||
|
||
// Add custom
|
||
const addArea = el.querySelector('#mw-add');
|
||
addArea.innerHTML = MIDDLEWARE_TYPES.map(mt =>
|
||
`<button class="btn btn-secondary btn-sm" data-mwtype="${mt.value}">${icons.plus(12)} ${mt.label}</button>`
|
||
).join('');
|
||
addArea.querySelectorAll('[data-mwtype]').forEach(b => {
|
||
b.onclick = () => {
|
||
const type = b.dataset.mwtype;
|
||
const newMw = { type };
|
||
if (type === 'ratelimit') { newMw.average = 100; newMw.burst = 50; newMw.period = '1m'; }
|
||
if (type === 'headers') { newMw.stsSeconds = 31536000; newMw.contentTypeNosniff = true; newMw.frameDeny = true; newMw.browserXssFilter = true; }
|
||
if (type === 'redirectscheme') { newMw.scheme = 'https'; newMw.permanent = true; }
|
||
if (type === 'stripprefix') { newMw.prefixes = ['/api']; }
|
||
if (type === 'ipallowlist') { newMw.sourceRange = []; }
|
||
form.traefik_middlewares.push(newMw);
|
||
renderMiddlewares();
|
||
};
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderMwFields(mw, i) {
|
||
const f = (field, val) => `data-mw-idx="${i}" data-mw-field="${field}" value="${esc(String(val ?? ''))}"`;
|
||
switch (mw.type) {
|
||
case 'reference': return `<div class="form-row"><div class="form-group"><label class="form-label">Nome</label><input class="form-input mono" ${f('name', mw.name)} placeholder="security"></div><div class="form-group"><label class="form-label">Provider</label><select class="form-input" ${f('provider', mw.provider)}><option value="file" ${mw.provider === 'file' ? 'selected' : ''}>file</option><option value="docker" ${mw.provider === 'docker' ? 'selected' : ''}>docker</option></select></div></div>`;
|
||
case 'ratelimit': return `<div class="form-row"><div class="form-group"><label class="form-label">Average (req/s)</label><input class="form-input" type="number" ${f('average', mw.average)}></div><div class="form-group"><label class="form-label">Burst</label><input class="form-input" type="number" ${f('burst', mw.burst)}></div><div class="form-group"><label class="form-label">Period</label><input class="form-input mono" ${f('period', mw.period)} placeholder="1m"></div></div>`;
|
||
case 'headers': return `<div class="form-row"><div class="form-group"><label class="form-label">STS Seconds</label><input class="form-input" type="number" ${f('stsSeconds', mw.stsSeconds)}></div></div><div class="flex gap-4 flex-wrap mt-2">${[['stsIncludeSubdomains','STS Subdomains'],['forceSTSHeader','Force STS'],['contentTypeNosniff','No Sniff'],['frameDeny','Frame Deny'],['browserXssFilter','XSS Filter']].map(([k,l]) => `<label class="flex items-center gap-1 text-sm"><input type="checkbox" data-mw-idx="${i}" data-mw-field="${k}" ${mw[k] ? 'checked' : ''}> ${l}</label>`).join('')}</div>`;
|
||
case 'redirectscheme': return `<div class="form-row"><div class="form-group"><label class="form-label">Scheme</label><input class="form-input mono" ${f('scheme', mw.scheme)}></div><div class="form-group"><label class="flex items-center gap-1 text-sm mt-4"><input type="checkbox" data-mw-idx="${i}" data-mw-field="permanent" ${mw.permanent !== false ? 'checked' : ''}> Permanente (301)</label></div></div>`;
|
||
case 'stripprefix': return `<div class="form-group"><label class="form-label">Prefissi (separati da virgola)</label><input class="form-input mono" ${f('prefixes', (mw.prefixes||[]).join(', '))} placeholder="/api, /v1"></div>`;
|
||
case 'basicauth': return `<div class="form-group"><label class="form-label">Users (formato htpasswd)</label><input class="form-input mono" ${f('users', mw.users)} placeholder="admin:$apr1$..."></div>`;
|
||
case 'ipallowlist': return `<div class="form-group"><label class="form-label">IP Range (uno per riga)</label><textarea class="form-input mono" rows="3" data-mw-idx="${i}" data-mw-field="sourceRange" placeholder="192.168.1.0/24\n10.0.0.1/32">${(mw.sourceRange||[]).join('\n')}</textarea></div>`;
|
||
case 'compress': return `<p class="text-sm text-muted">Compressione gzip attivata. Nessuna configurazione aggiuntiva.</p>`;
|
||
default: return `<pre class="text-xs text-mono text-muted">${JSON.stringify(mw, null, 2)}</pre>`;
|
||
}
|
||
}
|
||
|
||
// ── Env Tab ──
|
||
function renderEnvTab(el) {
|
||
let vars = [];
|
||
let envSaving = false;
|
||
const revealedKeys = new Set();
|
||
|
||
el.innerHTML = `<div class="card"><p class="text-muted">Caricamento variabili...</p></div>`;
|
||
|
||
async function loadVars() {
|
||
try {
|
||
const data = await api.getEnv(id);
|
||
vars = data.map(v => ({ key: v.key, value: v.is_secret ? '' : v.value, is_build_arg: !!v.is_build_arg, is_secret: !!v.is_secret, original_secret: v.is_secret }));
|
||
renderEnv();
|
||
} catch (err) { el.innerHTML = `<div class="card"><p class="text-muted">Errore: ${err.message}</p></div>`; }
|
||
}
|
||
|
||
function renderEnv() {
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h3 class="card-title">Variabili d'Ambiente</h3>
|
||
<div class="flex gap-2">
|
||
<button class="btn btn-secondary btn-sm" id="env-add">${icons.plus(14)} Aggiungi</button>
|
||
<button class="btn btn-primary btn-sm" id="env-save" ${envSaving ? 'disabled' : ''}>${icons.save(14)} ${envSaving ? 'Salvando...' : 'Salva'}</button>
|
||
</div>
|
||
</div>
|
||
${vars.length === 0 ? `<div class="empty-state" style="padding:30px 20px"><p>Nessuna variabile configurata</p></div>` : `
|
||
<div class="flex flex-col gap-2">
|
||
<div class="form-row" style="grid-template-columns:1fr 1fr 80px 80px 40px;gap:8px">
|
||
<span class="form-label" style="margin-bottom:0">Key</span><span class="form-label" style="margin-bottom:0">Value</span><span class="form-label" style="margin-bottom:0">Build</span><span class="form-label" style="margin-bottom:0">Secret</span><span></span>
|
||
</div>
|
||
${vars.map((v, i) => `
|
||
<div class="form-row" style="grid-template-columns:1fr 1fr 80px 80px 40px;gap:8px;align-items:center">
|
||
<input class="form-input mono" value="${esc(v.key)}" data-env="${i}" data-field="key" placeholder="KEY_NAME" style="padding:8px 10px;font-size:0.8rem">
|
||
<div style="position:relative">
|
||
<input class="form-input mono" type="${v.is_secret && !revealedKeys.has(v.key) ? 'password' : 'text'}" value="${esc(v.value)}" data-env="${i}" data-field="value" placeholder="${v.original_secret ? '(invariato)' : 'value'}" style="padding:8px 10px;font-size:0.8rem;padding-right:32px">
|
||
${v.is_secret ? `<button class="env-reveal" data-key="${esc(v.key)}" style="position:absolute;right:6px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text-tertiary);cursor:pointer">${revealedKeys.has(v.key) ? icons.eyeOff(14) : icons.eye(14)}</button>` : ''}
|
||
</div>
|
||
<label class="form-checkbox" style="justify-content:center"><input type="checkbox" data-env="${i}" data-field="is_build_arg" ${v.is_build_arg ? 'checked' : ''}></label>
|
||
<label class="form-checkbox" style="justify-content:center"><input type="checkbox" data-env="${i}" data-field="is_secret" ${v.is_secret ? 'checked' : ''}></label>
|
||
<button class="btn btn-danger btn-sm btn-icon" data-env-rm="${i}">${icons.trash2(12)}</button>
|
||
</div>`).join('')}
|
||
</div>`}
|
||
<div class="text-xs text-muted mt-4"><strong>Build</strong>: variabile passata come build arg al Dockerfile.<br><strong>Secret</strong>: il valore viene mascherato nella UI. Redeploy necessario per applicare le modifiche.</div>
|
||
</div>`;
|
||
|
||
el.querySelector('#env-add').onclick = () => { vars.push({ key: '', value: '', is_build_arg: false, is_secret: false }); renderEnv(); };
|
||
el.querySelector('#env-save').onclick = async () => {
|
||
envSaving = true; renderEnv();
|
||
try {
|
||
const valid = vars.filter(v => v.key.trim());
|
||
const toSave = valid.map(v => ({ key: v.key.trim(), value: (v.original_secret && !v.value) ? '___KEEP___' : v.value, is_build_arg: v.is_build_arg, is_secret: v.is_secret })).filter(v => v.value !== '___KEEP___');
|
||
await api.setEnv(id, toSave);
|
||
await loadVars();
|
||
} catch (err) { alert('Errore: ' + err.message); }
|
||
finally { envSaving = false; renderEnv(); }
|
||
};
|
||
el.querySelectorAll('[data-env][data-field]').forEach(inp => {
|
||
const i = parseInt(inp.dataset.env), field = inp.dataset.field;
|
||
if (inp.type === 'checkbox') { inp.onchange = () => { vars[i][field] = inp.checked; renderEnv(); }; }
|
||
else { inp.oninput = () => { vars[i][field] = inp.value; }; }
|
||
});
|
||
el.querySelectorAll('[data-env-rm]').forEach(b => { b.onclick = () => { vars.splice(parseInt(b.dataset.envRm), 1); renderEnv(); }; });
|
||
el.querySelectorAll('.env-reveal').forEach(b => {
|
||
b.onclick = () => { const k = b.dataset.key; revealedKeys.has(k) ? revealedKeys.delete(k) : revealedKeys.add(k); renderEnv(); };
|
||
});
|
||
}
|
||
loadVars();
|
||
}
|
||
|
||
// ── Deploy History Tab ──
|
||
function renderDeploysTab(el) {
|
||
const deploys = service.deploys || [];
|
||
let expandedId = null;
|
||
|
||
function fmt(ms) { if (!ms) return '–'; return ms < 1000 ? `${ms}ms` : `${(ms/1000).toFixed(1)}s`; }
|
||
|
||
function renderDeploys() {
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header"><h3 class="card-title">Storico Deploy</h3><button class="btn btn-secondary btn-sm" id="deploy-refresh">Refresh</button></div>
|
||
${deploys.length === 0 ? '<div class="empty-state"><p>Nessun deploy ancora. Esegui il primo deploy!</p></div>' : `
|
||
<div class="flex flex-col gap-2">${deploys.map(d => `
|
||
<div class="card deploy-row" data-did="${d.id}" style="padding:14px 16px;background:var(--bg-glass);cursor:pointer">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-3">
|
||
<span class="status-badge ${d.status}">${d.status}</span>
|
||
<div>
|
||
<div class="text-sm font-bold">${d.trigger === 'webhook' ? '🔗 Webhook' : '👤 Manuale'}</div>
|
||
<div class="flex items-center gap-3 text-xs text-muted mt-1">
|
||
${d.commit_sha ? `<span class="text-mono">${icons.gitCommit(10)} ${d.commit_sha}</span>` : ''}
|
||
${d.commit_author ? `<span>${icons.user(10)} ${d.commit_author}</span>` : ''}
|
||
<span>${icons.clock(10)} ${fmt(d.duration_ms)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-xs text-muted">${new Date(d.created_at).toLocaleString('it-IT')}</span>
|
||
${expandedId === d.id ? icons.chevronUp(16) : icons.chevronDown(16)}
|
||
</div>
|
||
</div>
|
||
${d.commit_message ? `<p class="text-xs text-muted mt-2 truncate">${esc(d.commit_message)}</p>` : ''}
|
||
${expandedId === d.id ? `<div class="mt-4" id="deploy-log-${d.id}"></div>` : ''}
|
||
</div>`).join('')}
|
||
</div>`}
|
||
</div>`;
|
||
|
||
el.querySelector('#deploy-refresh')?.addEventListener('click', () => loadService());
|
||
el.querySelectorAll('.deploy-row').forEach(row => {
|
||
row.onclick = (e) => {
|
||
if (e.target.closest('button')) return;
|
||
const did = parseInt(row.dataset.did);
|
||
expandedId = expandedId === did ? null : did;
|
||
if (logCleanup) { logCleanup(); logCleanup = null; }
|
||
renderDeploys();
|
||
if (expandedId) {
|
||
const logEl = document.getElementById(`deploy-log-${expandedId}`);
|
||
if (logEl) logCleanup = initLogViewer(logEl, `deploy:${expandedId}`);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
renderDeploys();
|
||
}
|
||
|
||
// ── Logs Tab ──
|
||
function renderLogsTab(el) {
|
||
el.innerHTML = `<div class="card"><h3 class="card-title mb-4">Container Logs</h3><div id="log-area"></div></div>`;
|
||
logCleanup = initLogViewer(el.querySelector('#log-area'), `service:${id}`);
|
||
}
|
||
|
||
// ── Terminal Tab ──
|
||
function renderTerminalTab(el) {
|
||
if (!service.container?.id) {
|
||
el.innerHTML = `<div class="card"><h3 class="card-title mb-4">Web Terminal</h3><div class="empty-state"><p>Il container deve essere in esecuzione per usare il terminal</p></div></div>`;
|
||
return;
|
||
}
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<h3 class="card-title mb-4">Web Terminal</h3>
|
||
<div class="terminal-container">
|
||
<div class="terminal-header">
|
||
<div class="terminal-dots"><div class="terminal-dot red" id="term-dot"></div><div class="terminal-dot yellow"></div><div class="terminal-dot red"></div></div>
|
||
<span class="text-xs text-muted text-mono" id="term-status">Connecting...</span>
|
||
</div>
|
||
<div id="term-error" class="hidden" style="padding:8px 16px;background:var(--status-error-bg);color:var(--status-error);font-size:0.8rem"></div>
|
||
<div class="terminal-body" id="term-body" style="min-height:400px"></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
termCleanup = initTerminal(el, service.container.id);
|
||
}
|
||
|
||
// ── Networks Tab ──
|
||
function renderNetworksTab(el) {
|
||
let allNetworks = [];
|
||
let newName = '';
|
||
let creating = false;
|
||
|
||
el.innerHTML = `<div class="card"><p class="text-muted">Caricamento reti...</p></div>`;
|
||
|
||
async function loadNets() {
|
||
try { allNetworks = await networksApi.list(); renderNets(); }
|
||
catch (err) { el.innerHTML = `<div class="card"><p class="text-muted">Errore: ${err.message}</p></div>`; }
|
||
}
|
||
|
||
function renderNets() {
|
||
const sn = form.networks || [];
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<h3 class="card-title mb-4">${icons.network(16)} Gestione Networks</h3>
|
||
<p class="text-sm text-muted mb-4">Seleziona le reti Docker a cui connettere il servizio al deploy.</p>
|
||
<div class="table-container mb-4">
|
||
<table><thead><tr><th>Assegnata</th><th>Nome</th><th>Driver</th><th>Containers</th><th>Interna</th><th></th></tr></thead>
|
||
<tbody>${allNetworks.map(n => `
|
||
<tr>
|
||
<td><label class="form-checkbox" style="justify-content:center"><input type="checkbox" data-net="${esc(n.name)}" ${sn.includes(n.name) ? 'checked' : ''}></label></td>
|
||
<td><span class="text-mono text-sm font-bold">${n.name}</span></td>
|
||
<td class="text-xs text-muted">${n.driver}</td>
|
||
<td class="text-xs">${n.containers}</td>
|
||
<td>${n.internal ? icons.check(14) : icons.x(14)}</td>
|
||
<td>${!['meb-public','meb-private','bridge','host','none'].includes(n.name) ? `<button class="btn btn-danger btn-sm btn-icon" data-del-net="${esc(n.name)}">${icons.trash2(12)}</button>` : ''}</td>
|
||
</tr>`).join('')}
|
||
</tbody></table>
|
||
</div>
|
||
<div class="flex gap-2 items-center">
|
||
<input class="form-input mono" id="new-net" value="${esc(newName)}" placeholder="nome-nuova-rete" style="max-width:300px">
|
||
<button class="btn btn-secondary btn-sm" id="create-net" ${creating ? 'disabled' : ''}>${icons.plus(14)} Crea Rete</button>
|
||
</div>
|
||
<div class="mt-4"><button class="btn btn-primary" id="save-nets">💾 Salva Networks</button></div>
|
||
</div>`;
|
||
|
||
el.querySelectorAll('[data-net]').forEach(cb => {
|
||
cb.onchange = () => {
|
||
const name = cb.dataset.net;
|
||
if (cb.checked) { if (!form.networks.includes(name)) form.networks.push(name); }
|
||
else { form.networks = form.networks.filter(n => n !== name); }
|
||
};
|
||
});
|
||
el.querySelectorAll('[data-del-net]').forEach(b => {
|
||
b.onclick = async () => {
|
||
if (!confirm(`Eliminare la rete "${b.dataset.delNet}"?`)) return;
|
||
try { await networksApi.remove(b.dataset.delNet); await loadNets(); }
|
||
catch (err) { alert('Errore: ' + err.message); }
|
||
};
|
||
});
|
||
el.querySelector('#new-net').oninput = (e) => { newName = e.target.value; };
|
||
el.querySelector('#create-net').onclick = async () => {
|
||
if (!newName.trim()) return;
|
||
creating = true; renderNets();
|
||
try { await networksApi.create(newName.trim()); newName = ''; await loadNets(); }
|
||
catch (err) { alert('Errore: ' + err.message); }
|
||
finally { creating = false; renderNets(); }
|
||
};
|
||
el.querySelector('#save-nets').onclick = handleSave;
|
||
}
|
||
loadNets();
|
||
}
|
||
|
||
// ── Webhook Tab ──
|
||
function renderWebhookTab(el) {
|
||
const webhookUrl = `${window.location.origin}/api/webhooks/${service.webhook_id}`;
|
||
el.innerHTML = `
|
||
<div class="card">
|
||
<h3 class="card-title mb-4">${icons.shield(16)} Configurazione Webhook</h3>
|
||
<p class="text-sm text-muted mb-4">Configura questo webhook nel tuo repository Gitea per abilitare il deploy automatico ad ogni push.</p>
|
||
<div class="form-group">
|
||
<label class="form-label">Webhook URL</label>
|
||
<div class="flex gap-2"><input class="form-input mono" value="${webhookUrl}" readonly style="flex:1" id="wh-url"><button class="btn btn-secondary btn-sm" id="wh-copy">${icons.copy(14)}</button></div>
|
||
<div class="form-hint">Usa questo URL come Target URL nel webhook di Gitea</div>
|
||
</div>
|
||
<div class="form-group"><label class="form-label">Webhook ID</label><input class="form-input mono text-xs" value="${service.webhook_id}" readonly></div>
|
||
<div class="card mt-4" style="background:var(--bg-glass);padding:20px">
|
||
<h4 class="card-title mb-2">📋 Istruzioni Setup</h4>
|
||
<ol class="text-sm" style="padding-left:20px;line-height:2">
|
||
<li>Vai al repository su Gitea → <strong>Settings</strong> → <strong>Webhooks</strong></li>
|
||
<li>Clicca <strong>Add Webhook</strong> → seleziona <strong>Gitea</strong></li>
|
||
<li>Incolla il <strong>Target URL</strong> sopra</li>
|
||
<li>Imposta il <strong>Secret</strong> uguale al <code>WEBHOOK_SECRET</code> configurato nel <code>.env</code></li>
|
||
<li>In <strong>Trigger On</strong>, seleziona <strong>Push Events</strong></li>
|
||
<li>Assicurati che il <strong>branch</strong> (<code>${service.gitea_branch}</code>) corrisponda al branch monitorato</li>
|
||
<li>Clicca <strong>Add Webhook</strong></li>
|
||
</ol>
|
||
</div>
|
||
<div class="card mt-4" style="background:var(--accent-muted);padding:16px;border-color:rgba(99,102,241,0.2)">
|
||
<p class="text-sm"><strong>💡 Nota:</strong> Se Gitea e AutoDeployer sono sulla stessa rete Docker (<code>meb-public</code>), il webhook bypassa Cloudflare automaticamente. L'URL interno sarà: <code class="text-mono">http://autodeployer:3000/api/webhooks/${service.webhook_id}</code></p>
|
||
</div>
|
||
</div>`;
|
||
|
||
el.querySelector('#wh-copy').onclick = () => navigator.clipboard.writeText(webhookUrl);
|
||
}
|
||
|
||
loadService();
|
||
return () => cleanupTab();
|
||
}
|
||
|
||
// ── Log Viewer (reusable) ──
|
||
function initLogViewer(el, target) {
|
||
let lines = [];
|
||
let paused = false;
|
||
|
||
el.innerHTML = `
|
||
<div class="flex items-center justify-between mb-2">
|
||
<span class="text-xs text-muted" id="lv-count">0 righe</span>
|
||
<div class="flex gap-2">
|
||
<button class="btn btn-secondary btn-sm" id="lv-pause">${icons.pause(12)} Pause</button>
|
||
<button class="btn btn-secondary btn-sm" id="lv-download">${icons.download(12)} Download</button>
|
||
</div>
|
||
</div>
|
||
<div class="log-viewer" id="lv-container"><div class="log-line info">In attesa di log...</div></div>`;
|
||
|
||
const ws = createLogSocket(target);
|
||
const container = el.querySelector('#lv-container');
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
if (msg.type === 'log' || msg.type === 'info' || msg.type === 'error') {
|
||
lines.push(msg);
|
||
if (lines.length > 2000) lines = lines.slice(-1500);
|
||
const div = document.createElement('div');
|
||
div.className = `log-line ${msg.type}`;
|
||
div.textContent = msg.data;
|
||
if (container.firstChild?.classList?.contains('info') && lines.length === 1) container.innerHTML = '';
|
||
container.appendChild(div);
|
||
el.querySelector('#lv-count').textContent = `${lines.length} righe`;
|
||
if (!paused) container.scrollTop = container.scrollHeight;
|
||
}
|
||
} catch {}
|
||
};
|
||
ws.onerror = () => { appendLine('error', 'WebSocket connection error'); };
|
||
ws.onclose = () => { appendLine('info', '— Stream ended —'); };
|
||
|
||
function appendLine(type, text) {
|
||
lines.push({ type, data: text });
|
||
const div = document.createElement('div');
|
||
div.className = `log-line ${type}`;
|
||
div.textContent = text;
|
||
container.appendChild(div);
|
||
if (!paused) container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
el.querySelector('#lv-pause').onclick = () => {
|
||
paused = !paused;
|
||
el.querySelector('#lv-pause').innerHTML = paused ? `${icons.play(12)} Resume` : `${icons.pause(12)} Pause`;
|
||
};
|
||
el.querySelector('#lv-download').onclick = () => {
|
||
const blob = new Blob([lines.map(l => l.data).join('\n')], { type: 'text/plain' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `logs-${target.replace(':', '-')}-${Date.now()}.txt`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
};
|
||
|
||
return () => { ws.close(); };
|
||
}
|
||
|
||
// ── Terminal (xterm.js) ──
|
||
function initTerminal(el, containerId) {
|
||
let ws, term, fitAddon, resizeObs;
|
||
|
||
async function init() {
|
||
try {
|
||
const Terminal = globalThis.Terminal;
|
||
const FitAddon = globalThis.FitAddon;
|
||
|
||
term = new Terminal({
|
||
theme: {
|
||
background: '#0d1117', foreground: '#c9d1d9', cursor: '#f0f6fc',
|
||
cursorAccent: '#0d1117', selectionBackground: 'rgba(56,139,253,0.3)',
|
||
black: '#0d1117', red: '#ff7b72', green: '#7ee787', yellow: '#d29922',
|
||
blue: '#79c0ff', magenta: '#d2a8ff', cyan: '#a5d6ff', white: '#c9d1d9',
|
||
},
|
||
fontFamily: "'JetBrains Mono','Fira Code',monospace",
|
||
fontSize: 13, lineHeight: 1.4, cursorBlink: true, cursorStyle: 'bar', scrollback: 5000,
|
||
});
|
||
|
||
fitAddon = new FitAddon();
|
||
term.loadAddon(fitAddon);
|
||
term.open(el.querySelector('#term-body'));
|
||
fitAddon.fit();
|
||
|
||
ws = createTerminalSocket(containerId);
|
||
ws.onopen = () => {
|
||
el.querySelector('#term-dot').className = 'terminal-dot green';
|
||
el.querySelector('#term-status').textContent = `Connected — ${containerId.slice(0, 12)}`;
|
||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||
};
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
if (msg.type === 'output') term.write(msg.data);
|
||
else if (msg.type === 'info') term.writeln(`\r\n\x1b[36m${msg.data}\x1b[0m\r\n`);
|
||
else if (msg.type === 'error') { term.writeln(`\r\n\x1b[31m${msg.data}\x1b[0m\r\n`); showTermError(msg.data); }
|
||
} catch { term.write(event.data); }
|
||
};
|
||
ws.onclose = () => {
|
||
el.querySelector('#term-dot').className = 'terminal-dot red';
|
||
el.querySelector('#term-status').textContent = 'Disconnected';
|
||
term.writeln('\r\n\x1b[33m— Session closed —\x1b[0m');
|
||
};
|
||
ws.onerror = () => showTermError('WebSocket connection failed');
|
||
|
||
term.onData((data) => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data })); });
|
||
|
||
resizeObs = new ResizeObserver(() => {
|
||
fitAddon.fit();
|
||
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||
});
|
||
resizeObs.observe(el.querySelector('#term-body'));
|
||
} catch (err) {
|
||
showTermError('Failed to initialize terminal: ' + err.message);
|
||
}
|
||
}
|
||
|
||
function showTermError(msg) {
|
||
const errEl = el.querySelector('#term-error');
|
||
errEl.textContent = msg;
|
||
errEl.classList.remove('hidden');
|
||
}
|
||
|
||
init();
|
||
return () => {
|
||
if (resizeObs) resizeObs.disconnect();
|
||
if (ws) ws.close();
|
||
if (term) term.dispose();
|
||
};
|
||
}
|
||
|
||
function esc(str) {
|
||
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|