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 = `
`;
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 = `
Servizio non trovato Torna alla Dashboard `;
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 = `
${TABS.map(t => `${t.label} `).join('')}
`;
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 = `
`;
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 = `
`;
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) => `
${mw.type === 'reference' ? `${mw.name || '?'}@${mw.provider || 'file'}` : mw.type}
${icons.trash2(12)}
${renderMwFields(mw, i)}
`).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 =>
`${icons.plus(12)} ${p.label} `
).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 =>
`${icons.plus(12)} ${mt.label} `
).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 ``;
case 'ratelimit': return ``;
case 'headers': return `${[['stsIncludeSubdomains','STS Subdomains'],['forceSTSHeader','Force STS'],['contentTypeNosniff','No Sniff'],['frameDeny','Frame Deny'],['browserXssFilter','XSS Filter']].map(([k,l]) => ` ${l} `).join('')}
`;
case 'redirectscheme': return ``;
case 'stripprefix': return `Prefissi (separati da virgola)
`;
case 'basicauth': return `Users (formato htpasswd)
`;
case 'ipallowlist': return `IP Range (uno per riga)
`;
case 'compress': return `Compressione gzip attivata. Nessuna configurazione aggiuntiva.
`;
default: return `${JSON.stringify(mw, null, 2)} `;
}
}
// ── Env Tab ──
function renderEnvTab(el) {
let vars = [];
let envSaving = false;
const revealedKeys = new Set();
el.innerHTML = ``;
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 = ``; }
}
function renderEnv() {
el.innerHTML = `
${vars.length === 0 ? `
Nessuna variabile configurata
` : `
Key Value Build Secret
${vars.map((v, i) => `
`).join('')}
`}
Build : variabile passata come build arg al Dockerfile.Secret : il valore viene mascherato nella UI. Redeploy necessario per applicare le modifiche.
`;
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 = `
${deploys.length === 0 ? '
Nessun deploy ancora. Esegui il primo deploy!
' : `
${deploys.map(d => `
${d.status}
${d.trigger === 'webhook' ? '🔗 Webhook' : '👤 Manuale'}
${d.commit_sha ? `${icons.gitCommit(10)} ${d.commit_sha} ` : ''}
${d.commit_author ? `${icons.user(10)} ${d.commit_author} ` : ''}
${icons.clock(10)} ${fmt(d.duration_ms)}
${new Date(d.created_at).toLocaleString('it-IT')}
${expandedId === d.id ? icons.chevronUp(16) : icons.chevronDown(16)}
${d.commit_message ? `
${esc(d.commit_message)}
` : ''}
${expandedId === d.id ? `
` : ''}
`).join('')}
`}
`;
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 = ``;
logCleanup = initLogViewer(el.querySelector('#log-area'), `service:${id}`);
}
// ── Terminal Tab ──
function renderTerminalTab(el) {
if (!service.container?.id) {
el.innerHTML = `Web Terminal Il container deve essere in esecuzione per usare il terminal
`;
return;
}
el.innerHTML = `
`;
termCleanup = initTerminal(el, service.container.id);
}
// ── Networks Tab ──
function renderNetworksTab(el) {
let allNetworks = [];
let newName = '';
let creating = false;
el.innerHTML = ``;
async function loadNets() {
try { allNetworks = await networksApi.list(); renderNets(); }
catch (err) { el.innerHTML = ``; }
}
function renderNets() {
const sn = form.networks || [];
el.innerHTML = `
${icons.network(16)} Gestione Networks
Seleziona le reti Docker a cui connettere il servizio al deploy.
${icons.plus(14)} Crea Rete
💾 Salva Networks
`;
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 = `
${icons.shield(16)} Configurazione Webhook
Configura questo webhook nel tuo repository Gitea per abilitare il deploy automatico ad ogni push.
Webhook ID
📋 Istruzioni Setup
Vai al repository su Gitea → Settings → Webhooks
Clicca Add Webhook → seleziona Gitea
Incolla il Target URL sopra
Imposta il Secret uguale al WEBHOOK_SECRET configurato nel .env
In Trigger On , seleziona Push Events
Assicurati che il branch (${service.gitea_branch}) corrisponda al branch monitorato
Clicca Add Webhook
💡 Nota: Se Gitea e AutoDeployer sono sulla stessa rete Docker (meb-public), il webhook bypassa Cloudflare automaticamente. L'URL interno sarà: http://autodeployer:3000/api/webhooks/${service.webhook_id}
`;
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 = `
0 righe
${icons.pause(12)} Pause
${icons.download(12)} Download
`;
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, '>');
}