8af5a27b4f
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>
272 lines
10 KiB
TypeScript
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">⚙</span> Deploying...' : '▶ 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:'○', running:'<span class="spinning">⚙</span>', done:'✓', error:'✗' };
|
|
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>`;
|
|
}
|