Files
DocMaster/src/web-ui.ts
T
Ondrej Trochta 8af5a27b4f Initial commit: DocMaster - Docker project manager
Web UI + CLI for managing dockerized projects (start/stop/deploy).
Features: live deploy workflow with SSE streaming, health checks, port map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:35:45 +01:00

272 lines
10 KiB
TypeScript

export function getIndexHtml(): string {
return `<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DocMaster</title>
<style>
:root {
--bg: #0f1117; --surface: #1a1d27; --surface2: #22252f;
--border: #2a2d3a; --text: #e4e4e7; --muted: #71717a;
--green: #22c55e; --red: #ef4444; --yellow: #eab308;
--blue: #3b82f6; --orange: #f97316;
--err-bg: #1c1012; --err-border: #5c2020;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background:var(--bg); color:var(--text); }
.header { background:var(--surface); border-bottom:1px solid var(--border); padding:12px 24px; display:flex; align-items:center; justify-content:space-between; }
.header h1 { font-size:18px; font-weight:600; letter-spacing:-0.5px; }
.header h1 span { color:var(--blue); }
.header-right { display:flex; align-items:center; gap:12px; }
.last-update { color:var(--muted); font-size:11px; }
.container { max-width:1100px; margin:0 auto; padding:20px; }
/* Project card */
.card {
background:var(--surface); border:1px solid var(--border); border-radius:10px;
margin-bottom:16px; transition: border-color 0.3s;
}
.card.error { border-color: var(--err-border); background: var(--err-bg); }
.card-head {
padding:14px 18px; display:flex; align-items:center; justify-content:space-between;
border-bottom:1px solid var(--border);
}
.card-left { display:flex; align-items:center; gap:14px; }
.card-title { font-size:16px; font-weight:600; }
.badge { padding:2px 9px; border-radius:10px; font-size:11px; font-weight:600; }
.badge-branch { background:rgba(59,130,246,0.15); color:var(--blue); }
.badge-ahead { background:rgba(234,179,8,0.15); color:var(--yellow); cursor:help; }
.badge-synced { background:rgba(34,197,94,0.1); color:var(--green); }
.card-actions { display:flex; gap:6px; }
.btn {
padding:5px 12px; border-radius:6px; font-size:12px; font-weight:500;
border:1px solid var(--border); background:var(--surface2); color:var(--text);
cursor:pointer; transition:all 0.15s; display:inline-flex; align-items:center; gap:5px;
}
.btn:hover { filter:brightness(1.2); }
.btn:disabled { opacity:0.4; cursor:not-allowed; }
.btn-deploy { border-color:var(--orange); color:var(--orange); }
.btn-deploy:hover { background:rgba(249,115,22,0.12); }
.btn-up { border-color:var(--green); color:var(--green); }
.btn-down { border-color:var(--red); color:var(--red); }
/* Services grid */
.services { padding:10px 18px 14px; display:flex; flex-wrap:wrap; gap:8px; }
.svc {
display:flex; align-items:center; gap:7px; padding:5px 12px;
background:var(--surface2); border-radius:6px; font-size:13px;
}
.dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
.dot.up { background:var(--green); box-shadow:0 0 5px var(--green); }
.dot.down { background:var(--red); box-shadow:0 0 5px var(--red); }
.svc-port { color:var(--muted); font-size:11px; }
.svc a { color:var(--blue); text-decoration:none; font-size:11px; }
.svc a:hover { text-decoration:underline; }
/* Deploy log panel */
.deploy-panel {
margin-top:6px; border-top:1px solid var(--border);
background:rgba(0,0,0,0.2); border-radius:0 0 10px 10px;
}
.deploy-panel.hidden { display:none; }
.deploy-steps { padding:12px 18px; }
.step {
display:flex; align-items:center; gap:10px; padding:4px 0;
font-size:13px; font-family:'SF Mono',Consolas,monospace;
}
.step-icon { width:18px; text-align:center; flex-shrink:0; }
.step-pending .step-icon { color:var(--muted); }
.step-running .step-icon { color:var(--yellow); }
.step-done .step-icon { color:var(--green); }
.step-error .step-icon { color:var(--red); }
.step-output {
font-size:11px; color:var(--muted); margin-left:28px;
white-space:pre-wrap; word-break:break-all; max-height:120px; overflow-y:auto;
}
.step-output.err { color:var(--red); }
/* Spinner animation */
@keyframes spin { to { transform:rotate(360deg); } }
.spinning { display:inline-block; animation:spin 1s linear infinite; }
/* Toast */
.toast {
position:fixed; bottom:20px; right:20px; padding:10px 18px;
background:var(--surface); border:1px solid var(--border); border-radius:8px;
font-size:13px; z-index:100; animation:fadeIn 0.2s;
}
.toast.ok { border-color:var(--green); }
.toast.fail { border-color:var(--red); }
@keyframes fadeIn { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
</style>
</head>
<body>
<div class="header">
<h1><span>Doc</span>Master</h1>
<div class="header-right">
<span class="last-update" id="ts"></span>
<button class="btn" onclick="refresh()">Refresh</button>
</div>
</div>
<div class="container" id="app">
<div style="text-align:center;padding:60px;color:var(--muted)">Loading...</div>
</div>
<script>
let projects = [];
let deployState = {}; // key -> { steps[], stepStatuses[], outputs[], running, error }
async function refresh() {
try {
const r = await fetch('/api/status');
projects = await r.json();
render();
document.getElementById('ts').textContent = new Date().toLocaleTimeString('cs-CZ');
} catch(e) { toast('Refresh failed','fail'); }
}
function render() {
const app = document.getElementById('app');
app.innerHTML = projects.map(p => {
const ds = deployState[p.key];
const hasError = ds?.error;
const cardClass = hasError ? 'card error' : 'card';
// dev ahead badge
let aheadHtml = '';
if (p.devAheadOfMain > 0) {
aheadHtml = '<span class="badge badge-ahead" title="dev is ' + p.devAheadOfMain + ' commit(s) ahead of main">dev +' + p.devAheadOfMain + '</span>';
} else {
aheadHtml = '<span class="badge badge-synced">synced</span>';
}
// services
const svcsHtml = p.services.map(s => {
const dot = s.running ? 'up' : 'down';
const link = s.healthUrl ? '<a href="'+s.healthUrl+'" target="_blank">:'+s.port+'</a>' : (s.port ? '<span class="svc-port">:'+s.port+'</span>' : '');
return '<div class="svc"><span class="dot '+dot+'"></span>'+esc(s.label)+' '+link+'</div>';
}).join('');
// deploy button
const isRunning = ds?.running;
const canDeploy = p.services.some(s => s.isApp);
const deployBtn = canDeploy
? '<button class="btn btn-deploy" onclick="deploy(\\''+p.key+'\\')" '+(isRunning?'disabled':'')+'>'+
(isRunning ? '<span class="spinning">&#9881;</span> Deploying...' : '&#9654; Deploy') + '</button>'
: '';
// deploy panel
let panelHtml = '';
if (ds && ds.steps) {
const stepsHtml = ds.steps.map((name, i) => {
const st = ds.stepStatuses[i] || 'pending';
const icons = { pending:'&#9675;', running:'<span class="spinning">&#9881;</span>', done:'&#10003;', error:'&#10007;' };
const out = ds.outputs[i] ? '<div class="step-output'+(st==='error'?' err':'')+'">'+esc(ds.outputs[i])+'</div>' : '';
return '<div class="step step-'+st+'"><span class="step-icon">'+icons[st]+'</span><span>'+esc(name)+'</span></div>'+out;
}).join('');
panelHtml = '<div class="deploy-panel"><div class="deploy-steps">'+stepsHtml+'</div></div>';
} else {
panelHtml = '<div class="deploy-panel hidden"></div>';
}
return '<div class="'+cardClass+'" id="card-'+p.key+'">'+
'<div class="card-head">'+
'<div class="card-left">'+
'<span class="card-title">'+esc(p.name)+'</span>'+
'<span class="badge badge-branch">'+esc(p.branch)+'</span>'+
aheadHtml+
'</div>'+
'<div class="card-actions">'+
deployBtn+
'<button class="btn btn-up" onclick="act(\\''+p.key+'\\',\\'up\\')" '+(isRunning?'disabled':'')+'>Start</button>'+
'<button class="btn btn-down" onclick="act(\\''+p.key+'\\',\\'down\\')" '+(isRunning?'disabled':'')+'>Stop</button>'+
'</div>'+
'</div>'+
'<div class="services">'+svcsHtml+'</div>'+
panelHtml+
'</div>';
}).join('');
}
function deploy(key) {
if (!confirm('Deploy ' + key + '? (merge dev→main, build, restart)')) return;
deployState[key] = { steps:[], stepStatuses:[], outputs:[], running:true, error:false };
render();
const es = new EventSource('/api/deploy/' + key);
es.addEventListener('init', (e) => {
const d = JSON.parse(e.data);
deployState[key].steps = d.steps;
deployState[key].stepStatuses = d.steps.map(() => 'pending');
deployState[key].outputs = d.steps.map(() => '');
render();
});
es.addEventListener('step', (e) => {
const d = JSON.parse(e.data);
deployState[key].stepStatuses[d.index] = d.status;
if (d.output) deployState[key].outputs[d.index] = d.output;
if (d.status === 'error') deployState[key].error = true;
render();
});
es.addEventListener('done', (e) => {
const d = JSON.parse(e.data);
deployState[key].running = false;
if (!d.success) {
deployState[key].error = true;
toast(d.error || 'Deploy failed', 'fail');
} else {
toast(key + ' deployed!', 'ok');
setTimeout(refresh, 1500);
}
render();
es.close();
});
es.onerror = () => {
deployState[key].running = false;
deployState[key].error = true;
render();
es.close();
};
}
async function act(key, action) {
try {
const r = await fetch('/api/action', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({project:key, action})
});
const d = await r.json();
toast(d.message||d.error, d.error?'fail':'ok');
setTimeout(refresh, 1000);
} catch(e) { toast('Error: '+e.message,'fail'); }
}
function esc(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
function toast(msg,type) {
const t = document.createElement('div');
t.className = 'toast '+ type;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(()=>t.remove(), 4000);
}
refresh();
setInterval(refresh, 30000);
</script>
</body>
</html>`;
}