149 lines
27 KiB
HTML
149 lines
27 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en"><head>
|
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Traefik Hub</title>
|
|
<style>
|
|
:root{--bg:#0d1117;--card:#161b22;--border:#30363d;--text:#c9d1d9;--muted:#8b949e;--blue:#58a6ff;--green:#3fb950;--red:#f85149;--orange:#d29922;--purple:#bc8cff}
|
|
*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;background:var(--bg);color:var(--text);font-size:14px}a{color:var(--blue);text-decoration:none}
|
|
.header{background:var(--card);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
|
|
.header h1{font-size:18px;display:flex;align-items:center;gap:8px;color:var(--blue)}.header h1 svg{width:22px;height:22px}
|
|
.header-right{display:flex;align-items:center;gap:12px}.pill{background:var(--bg);padding:4px 10px;border-radius:12px;font-size:12px;color:var(--muted)}
|
|
.btn-sm{padding:6px 12px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--muted);cursor:pointer;font-size:12px}.btn-sm:hover{color:var(--text)}
|
|
.main{display:flex;min-height:calc(100vh - 49px)}.sidebar{width:220px;background:var(--card);border-right:1px solid var(--border);padding:16px 0;flex-shrink:0}
|
|
.sidebar a{display:flex;align-items:center;gap:10px;padding:8px 20px;color:var(--muted);font-size:13px;border-left:3px solid transparent;cursor:pointer}
|
|
.sidebar a:hover,.sidebar a.active{color:var(--text);background:rgba(88,166,255,.06);border-left-color:var(--blue)}.sidebar a svg{width:16px;height:16px;flex-shrink:0}
|
|
.sidebar .sep{height:1px;background:var(--border);margin:12px 20px}.content{flex:1;padding:24px;overflow-y:auto}
|
|
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:24px}
|
|
.stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px}.stat .label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}.stat .value{font-size:26px;font-weight:700;margin-top:2px}
|
|
.stat .value.green{color:var(--green)}.stat .value.blue{color:var(--blue)}.stat .value.red{color:var(--red)}
|
|
.card{background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:16px;overflow:hidden}
|
|
.card-header{padding:12px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}.card-header h2{font-size:14px}
|
|
table{width:100%;border-collapse:collapse}th{text-align:left;padding:8px 18px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);background:rgba(0,0,0,.2);border-bottom:1px solid var(--border)}
|
|
td{padding:8px 18px;border-bottom:1px solid var(--border);font-size:13px;vertical-align:middle}tr:last-child td{border-bottom:none}tr:hover{background:rgba(88,166,255,.04)}
|
|
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
|
|
.badge-green{background:rgba(63,185,80,.15);color:var(--green)}.badge-red{background:rgba(248,81,73,.15);color:var(--red)}.badge-blue{background:rgba(88,166,255,.15);color:var(--blue)}
|
|
.badge-purple{background:rgba(188,140,255,.15);color:var(--purple)}.badge-muted{background:rgba(139,148,158,.15);color:var(--muted)}
|
|
.actions{display:flex;gap:4px}.actions button,.abtn{padding:4px 8px;border-radius:4px;border:1px solid var(--border);background:transparent;cursor:pointer;font-size:11px;color:var(--muted)}
|
|
.actions button:hover,.abtn:hover{color:var(--text)}.actions .start{color:var(--green)}.actions .stop{color:var(--red)}.actions .restart{color:var(--orange)}
|
|
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}.dot-green{background:var(--green)}.dot-red{background:var(--red)}
|
|
.log-viewer{background:#000;color:#0f0;padding:16px;font-family:Menlo,monospace;font-size:11px;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
|
|
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;align-items:center;justify-content:center}.modal-bg.show{display:flex}
|
|
.modal{background:var(--card);border:1px solid var(--border);border-radius:12px;width:700px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column}
|
|
.modal-header{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
|
.modal-body{overflow-y:auto;flex:1;padding:0}.modal .close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:20px}
|
|
.form-row{display:flex;gap:12px;margin-bottom:12px;align-items:end}.form-row .field{flex:1}
|
|
.field label{display:block;font-size:11px;color:var(--muted);margin-bottom:4px;text-transform:uppercase}
|
|
.field input,.field select{width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none}
|
|
.field input:focus{border-color:var(--blue)}
|
|
.btn{padding:8px 16px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:600}
|
|
.btn-green{background:#238636;color:#fff}.btn-green:hover{background:#2ea043}
|
|
.btn-red{background:rgba(248,81,73,.15);color:var(--red);border:1px solid rgba(248,81,73,.3)}
|
|
.btn-blue{background:rgba(88,166,255,.15);color:var(--blue);border:1px solid rgba(88,166,255,.3)}
|
|
.btn-muted{background:var(--bg);color:var(--muted);border:1px solid var(--border)}
|
|
.env-row{display:flex;gap:8px;margin-bottom:6px;align-items:center}
|
|
.env-row input{flex:1;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px}
|
|
.env-row button{padding:4px 8px;border-radius:4px;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--red);font-size:14px}
|
|
.toast{position:fixed;top:60px;right:24px;background:var(--green);color:#fff;padding:10px 18px;border-radius:8px;font-size:13px;z-index:300;display:none;animation:fadeIn .3s}
|
|
.toast.error{background:var(--red)}.toast.show{display:block}
|
|
.spinner-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:250;display:none;align-items:center;justify-content:center;flex-direction:column;gap:16px;color:var(--text)}
|
|
.spinner-overlay.show{display:flex}
|
|
.spinner-overlay .spin{width:40px;height:40px;border:4px solid var(--border);border-top-color:var(--blue);border-radius:50%;animation:spin 1s linear infinite}
|
|
@keyframes spin{to{transform:rotate(360deg)}}@keyframes fadeIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1}}
|
|
.cb{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px}.cb input{width:16px;height:16px}
|
|
@media(max-width:768px){.sidebar{display:none}.content{padding:12px}.form-row{flex-direction:column}}
|
|
</style></head><body>
|
|
<div class="header"><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Traefik Hub</h1><div class="header-right"><span class="pill" id="dV"></span><span class="pill" id="sS"></span><button class="btn-sm" onclick="loadAll()">Refresh</button><button class="btn-sm" onclick="fetch('/api/logout').then(()=>location.reload())">Logout</button></div></div>
|
|
<div class="main"><nav class="sidebar" id="nav"></nav><div class="content" id="content"></div></div>
|
|
<div class="modal-bg" id="logModal"><div class="modal"><div class="modal-header"><h2 id="logTitle">Logs</h2><button class="close" onclick="closeModal()">×</button></div><div class="modal-body"><pre class="log-viewer" id="logContent"></pre></div></div></div>
|
|
<div class="spinner-overlay" id="spinner"><div class="spin"></div><div id="spinMsg">Deploying...</div></div>
|
|
<div class="toast" id="toast"></div>
|
|
<script>
|
|
const PAGES=[
|
|
{id:"overview",icon:'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',label:"Overview"},
|
|
{id:"deploy",icon:'<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',label:"Deploy Service"},
|
|
{id:"routes",icon:'<circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 010 8.49m-8.48-.01a6 6 0 010-8.49m11.31-2.82a10 10 0 010 14.14m-14.14 0a10 10 0 010-14.14"/>',label:"Routes"},
|
|
{id:"dns",icon:'<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',label:"DNS Records"},
|
|
null,
|
|
{id:"routers",icon:'<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',label:"HTTP Routers"},
|
|
{id:"services",icon:'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>',label:"Services"},
|
|
{id:"containers",icon:'<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/>',label:"Containers"},
|
|
null,
|
|
{id:"certificates",icon:'<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/>',label:"Certificates"},
|
|
{id:"networks",icon:'<path d="M9 2v6m6-6v6M9 16v6m6-6v6M2 9h6m8 0h6M2 15h6m8 0h6"/>',label:"Networks"},
|
|
{id:"volumes",icon:'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',label:"Volumes"},
|
|
{id:"entrypoints",icon:'<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3"/>',label:"Entrypoints"},
|
|
{id:"middlewares",icon:'<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2"/>',label:"Middlewares"}
|
|
];
|
|
let DATA={},DNS={},ROUTES={},CUR="overview",envRows=[],volRows=[];
|
|
const $=s=>document.querySelector(s);
|
|
function toast(m,e){const t=$("#toast");t.textContent=m;t.className="toast show"+(e?" error":"");setTimeout(()=>t.className="toast",3000)}
|
|
function showSpinner(m){$("#spinMsg").textContent=m||"Deploying...";$("#spinner").classList.add("show")}
|
|
function hideSpinner(){$("#spinner").classList.remove("show")}
|
|
document.getElementById("nav").innerHTML=PAGES.map(p=>p?`<a data-page="${p.id}" class="${p.id==="overview"?"active":""}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${p.icon}</svg>${p.label}</a>`:'<div class="sep"></div>').join("");
|
|
document.querySelectorAll(".sidebar a").forEach(a=>a.addEventListener("click",()=>{document.querySelectorAll(".sidebar a").forEach(x=>x.classList.remove("active"));a.classList.add("active");CUR=a.dataset.page;render()}));
|
|
function bg(s){const m={enabled:"green",running:"green",up:"green",disabled:"red",exited:"red",managed:"green"};const c=Object.entries(m).find(([k])=>(s||"").toLowerCase().includes(k))?.[1]||"muted";return'<span class="badge badge-'+c+'">'+s+'</span>'}
|
|
function dot(r){return'<span class="dot dot-'+(r?"green":"red")+'"></span>'}
|
|
|
|
async function loadAll(){try{
|
|
const r=await fetch("/api/overview");if(r.status===401){location.reload();return}DATA=await r.json();
|
|
DATA.middlewares=await(await fetch("/api/middlewares")).json()||[];DATA.networks=await(await fetch("/api/networks")).json()||[];
|
|
DATA.volumes=await(await fetch("/api/volumes")).json()||[];DATA.certificates=await(await fetch("/api/certificates")).json()||[];
|
|
ROUTES=await(await fetch("/api/routes")).json()||{};DNS=await(await fetch("/api/dns")).json()||{};
|
|
$("#dV").textContent="Docker "+(DATA.system?.dockerVersion||"?");$("#sS").textContent="Swarm: "+(DATA.system?.swarm||"?");render();
|
|
}catch(e){console.error(e)}}
|
|
|
|
function render(){const c=$("#content"),R=DATA.routers||[],S=DATA.services||[],C=DATA.containers||[],E=DATA.entrypoints||[],M=DATA.middlewares||[],N=DATA.networks||[],V=DATA.volumes||[],CE=DATA.certificates||[],sys=DATA.system||{},routes=ROUTES.routes||{},dnsRecs=DNS.records||[],domain=DNS.domain||"gigafibre.ca",ip=DNS.serverIp||"96.125.196.67";
|
|
const managed=C.filter(x=>x.labels?.["hub.managed"]==="true");
|
|
switch(CUR){
|
|
case"overview":c.innerHTML='<h2 style="margin-bottom:16px">Overview</h2><div class="stats"><div class="stat"><div class="label">Running</div><div class="value green">'+C.filter(x=>x.state==="running").length+'</div></div><div class="stat"><div class="label">Stopped</div><div class="value red">'+C.filter(x=>x.state!=="running").length+'</div></div><div class="stat"><div class="label">Routers</div><div class="value blue">'+R.length+'</div></div><div class="stat"><div class="label">DNS Records</div><div class="value blue">'+dnsRecs.length+'</div></div><div class="stat"><div class="label">CPUs</div><div class="value">'+(sys.cpus||"?")+'</div></div><div class="stat"><div class="label">RAM</div><div class="value">'+(sys.memTotal?(sys.memTotal/1073741824).toFixed(1)+"G":"?")+'</div></div></div>'+(managed.length?'<div class="card"><div class="card-header"><h2>Hub-Managed Services</h2></div><div class="card-body"><table><tr><th>Name</th><th>Domain</th><th>Image</th><th>Status</th><th>Actions</th></tr>'+managed.map(x=>'<tr><td>'+dot(x.state==="running")+'<b>'+x.name+'</b></td><td><a href="https://'+(x.labels["hub.domain"]||"")+'" target="_blank">'+(x.labels["hub.domain"]||"")+'</a></td><td style="color:var(--muted)">'+x.image+'</td><td>'+bg(x.status)+'</td><td><div class="actions">'+(x.state!=="running"?'<button class="start" onclick="cAct(\''+x.id+'\',\'start\')">Start</button>':'<button class="stop" onclick="cAct(\''+x.id+'\',\'stop\')">Stop</button>')+'<button class="restart" onclick="cAct(\''+x.id+'\',\'restart\')">Restart</button><button onclick="logs(\''+x.id+'\',\''+x.name+'\')">Logs</button><button class="stop" onclick="removeService(\''+x.id+'\',\''+x.name+'\',\''+(x.labels["hub.subdomain"]||"")+'\')">Remove</button></div></td></tr>').join("")+'</table></div></div>':'')+'<div class="card"><div class="card-header"><h2>All Routers</h2></div><div class="card-body"><table><tr><th>Name</th><th>Rule</th><th>TLS</th><th>Service</th></tr>'+R.map(r=>'<tr><td><b>'+r.name+'</b></td><td><code style="color:var(--green)">'+(r.rule||"")+'</code></td><td>'+(r.tls?'<span class="badge badge-green">'+(r.tls.certResolver||"yes")+'</span>':"--")+'</td><td>'+(r.service||"")+'</td></tr>').join("")+'</table></div></div><div class="card"><div class="card-header"><h2>All Containers</h2></div><div class="card-body"><table><tr><th>Name</th><th>Image</th><th>Status</th><th>Actions</th></tr>'+C.map(x=>'<tr><td>'+dot(x.state==="running")+'<b>'+x.name+'</b></td><td style="color:var(--muted)">'+x.image+'</td><td>'+bg(x.status)+'</td><td><div class="actions">'+(x.state!=="running"?'<button class="start" onclick="cAct(\''+x.id+'\',\'start\')">Start</button>':'<button class="stop" onclick="cAct(\''+x.id+'\',\'stop\')">Stop</button>')+'<button class="restart" onclick="cAct(\''+x.id+'\',\'restart\')">Restart</button><button onclick="logs(\''+x.id+'\',\''+x.name+'\')">Logs</button></div></td></tr>').join("")+'</table></div></div>';break;
|
|
|
|
case"deploy":envRows=envRows.length?envRows:[{key:"",value:""}];volRows=volRows.length?volRows:[{host:"",container:""}];
|
|
c.innerHTML='<h2 style="margin-bottom:8px">Deploy New Service</h2><p style="color:var(--muted);margin-bottom:20px">Pull a Docker image, configure it, and deploy with automatic DNS + SSL.</p><div class="card"><div class="card-body" style="padding:20px"><div class="form-row"><div class="field"><label>Docker Image</label><input id="dp-image" placeholder="nginx:alpine, gitea/gitea:latest, postgres:14"></div><div class="field"><label>Container Name (optional)</label><input id="dp-name" placeholder="auto-generated from subdomain"></div></div><div class="form-row"><div class="field"><label>Subdomain</label><div style="display:flex"><input id="dp-sub" placeholder="myapp" style="border-radius:6px 0 0 6px"><span style="padding:8px 12px;background:var(--border);border:1px solid var(--border);border-radius:0 6px 6px 0;font-size:13px;color:var(--muted);white-space:nowrap">.'+domain+'</span></div></div><div class="field"><label>Internal Port</label><input id="dp-port" type="number" value="3000" placeholder="3000"></div></div><div style="margin-bottom:16px"><label style="display:block;font-size:11px;color:var(--muted);margin-bottom:6px;text-transform:uppercase">Environment Variables</label><div id="envContainer">'+envRows.map((e,i)=>'<div class="env-row"><input placeholder="KEY" value="'+(e.key||"")+'" onchange="envRows['+i+'].key=this.value"><input placeholder="VALUE" value="'+(e.value||"")+'" onchange="envRows['+i+'].value=this.value"><button onclick="envRows.splice('+i+',1);render()">x</button></div>').join("")+'</div><button class="btn-muted btn" style="font-size:11px;margin-top:4px" onclick="envRows.push({key:\'\',value:\'\'});render()">+ Add Variable</button></div><div style="margin-bottom:16px"><label style="display:block;font-size:11px;color:var(--muted);margin-bottom:6px;text-transform:uppercase">Volumes</label><div id="volContainer">'+volRows.map((v,i)=>'<div class="env-row"><input placeholder="/host/path or volume_name" value="'+(v.host||"")+'" onchange="volRows['+i+'].host=this.value"><span style="color:var(--muted);padding:0 4px">:</span><input placeholder="/container/path" value="'+(v.container||"")+'" onchange="volRows['+i+'].container=this.value"><button onclick="volRows.splice('+i+',1);render()">x</button></div>').join("")+'</div><button class="btn-muted btn" style="font-size:11px;margin-top:4px" onclick="volRows.push({host:\'\',container:\'\'});render()">+ Add Volume</button></div><div style="margin-bottom:16px"><label style="display:block;font-size:11px;color:var(--muted);margin-bottom:6px;text-transform:uppercase">Networks</label><div style="display:flex;gap:16px;flex-wrap:wrap">'+N.map(n=>'<label class="cb"><input type="checkbox" value="'+n.name+'" class="net-cb" '+(n.name==="proxy"?"checked":"")+'>'+n.name+'</label>').join("")+'</div></div><div style="display:flex;gap:12px;padding-top:8px;border-top:1px solid var(--border)"><button class="btn btn-green" onclick="doDeploy()">Deploy Service</button><span style="color:var(--muted);font-size:12px;align-self:center">Creates container + DNS record + Traefik route + SSL certificate</span></div></div></div>';break;
|
|
|
|
case"routes":c.innerHTML='<h2 style="margin-bottom:16px">Dynamic Routes</h2><div class="card"><div class="card-header"><h2>Add Route</h2></div><div class="card-body" style="padding:16px"><div class="form-row"><div class="field"><label>Subdomain</label><input id="rt-sub" placeholder="myapp"></div><div class="field"><label>Target</label><input id="rt-target" placeholder="container-name or IP"></div><div class="field"><label>Port</label><input id="rt-port" type="number" value="3000"></div><div style="flex:0"><button class="btn btn-green" onclick="addRoute()">Add</button></div></div></div></div><div class="card"><div class="card-header"><h2>Active Routes</h2></div><div class="card-body"><table><tr><th>Name</th><th>Domain</th><th>Target</th><th>Port</th><th></th></tr>'+Object.entries(routes).map(([n,r])=>'<tr><td><b>'+n+'</b></td><td><code style="color:var(--green)">'+r.subdomain+'.'+domain+'</code></td><td>'+r.target+'</td><td>'+r.port+'</td><td><button class="btn-red btn" style="padding:2px 8px;font-size:11px" onclick="delRoute(\''+n+'\')">Delete</button></td></tr>').join("")||'<tr><td colspan=5 style="color:var(--muted);text-align:center;padding:20px">No dynamic routes</td></tr>'+'</table></div></div>';break;
|
|
|
|
case"dns":c.innerHTML='<h2 style="margin-bottom:16px">DNS Records - '+domain+'</h2><p style="color:var(--muted);margin-bottom:16px">Managed via OpenSRS API. Server IP: <code>'+ip+'</code></p><div class="card"><div class="card-header"><h2>Add Record</h2></div><div class="card-body" style="padding:16px"><div class="form-row"><div class="field"><label>Subdomain (empty = root)</label><input id="dns-sub" placeholder="www"></div><div class="field"><label>IP Address</label><input id="dns-ip" value="'+ip+'"></div><div style="flex:0"><button class="btn btn-green" onclick="addDns()">Add</button></div></div></div></div><div class="card"><div class="card-header"><h2>A Records</h2></div><div class="card-body"><table><tr><th>Domain</th><th>Subdomain</th><th>IP</th><th></th></tr>'+dnsRecs.map(r=>'<tr><td><code style="color:var(--green)">'+r.domain+'</code></td><td>'+(r.subdomain||"@")+'</td><td><code>'+r.ip+'</code></td><td><button class="btn-red btn" style="padding:2px 8px;font-size:11px" onclick="delDns(\''+r.subdomain+'\',\''+r.ip+'\')">Delete</button></td></tr>').join("")+'</table></div></div>';break;
|
|
|
|
case"routers":c.innerHTML='<h2 style="margin-bottom:16px">HTTP Routers</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Rule</th><th>Entrypoints</th><th>TLS</th><th>Service</th><th>Status</th></tr>'+R.map(r=>'<tr><td><b>'+r.name+'</b><br><span style="color:var(--muted);font-size:11px">'+(r.provider||"")+'</span></td><td><code style="color:var(--green)">'+(r.rule||"")+'</code></td><td>'+(r.entryPoints||[]).map(e=>'<span class="badge badge-blue">'+e+'</span>').join(" ")+'</td><td>'+(r.tls?'<span class="badge badge-green">'+(r.tls.certResolver||"yes")+'</span>':"--")+'</td><td>'+(r.service||"")+'</td><td>'+bg(r.status||"enabled")+'</td></tr>').join("")+'</table></div></div>';break;
|
|
case"services":c.innerHTML='<h2 style="margin-bottom:16px">Services</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Servers</th><th>Status</th></tr>'+S.map(s=>'<tr><td><b>'+s.name+'</b></td><td>'+(s.loadBalancer?.servers||[]).map(sv=>'<code>'+sv.url+'</code>').join("<br>")||"--"+'</td><td>'+bg(s.status||"enabled")+'</td></tr>').join("")+'</table></div></div>';break;
|
|
case"containers":c.innerHTML='<h2 style="margin-bottom:16px">Containers</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Image</th><th>State</th><th>Networks</th><th>Ports</th><th>Actions</th></tr>'+C.map(x=>'<tr><td>'+dot(x.state==="running")+'<b>'+x.name+'</b><br><span style="color:var(--muted);font-size:11px">'+x.id+'</span></td><td style="color:var(--muted);font-size:12px">'+x.image+'</td><td>'+bg(x.status)+'</td><td>'+x.networks.map(n=>'<span class="badge badge-purple">'+n+'</span>').join(" ")+'</td><td>'+(x.ports.join(", ")||"--")+'</td><td><div class="actions">'+(x.state!=="running"?'<button class="start" onclick="cAct(\''+x.id+'\',\'start\')">Start</button>':'<button class="stop" onclick="cAct(\''+x.id+'\',\'stop\')">Stop</button>')+'<button class="restart" onclick="cAct(\''+x.id+'\',\'restart\')">Restart</button><button onclick="logs(\''+x.id+'\',\''+x.name+'\')">Logs</button></div></td></tr>').join("")+'</table></div></div>';break;
|
|
case"certificates":c.innerHTML='<h2 style="margin-bottom:16px">Certificates</h2><div class="card"><div class="card-body"><table><tr><th>Domain</th><th>Status</th><th>Resolver</th><th>Router</th></tr>'+CE.map(x=>'<tr><td><b>'+x.domain+'</b></td><td>'+bg(x.status)+'</td><td><span class="badge badge-blue">'+(x.resolver||"--")+'</span></td><td>'+x.router+'</td></tr>').join("")+'</table></div></div>';break;
|
|
case"networks":c.innerHTML='<h2 style="margin-bottom:16px">Networks</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Driver</th><th>Scope</th></tr>'+N.map(n=>'<tr><td><b>'+n.name+'</b></td><td><span class="badge badge-blue">'+n.driver+'</span></td><td>'+n.scope+'</td></tr>').join("")+'</table></div></div>';break;
|
|
case"volumes":c.innerHTML='<h2 style="margin-bottom:16px">Volumes</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Driver</th><th>Mountpoint</th></tr>'+V.map(v=>'<tr><td><b>'+v.name+'</b></td><td><span class="badge badge-blue">'+v.driver+'</span></td><td style="color:var(--muted);font-size:12px">'+v.mountpoint+'</td></tr>').join("")+'</table></div></div>';break;
|
|
case"entrypoints":c.innerHTML='<h2 style="margin-bottom:16px">Entrypoints</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Address</th></tr>'+E.map(e=>'<tr><td><b>'+e.name+'</b></td><td><code>'+e.address+'</code></td></tr>').join("")+'</table></div></div>';break;
|
|
case"middlewares":c.innerHTML='<h2 style="margin-bottom:16px">Middlewares</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Type</th><th>Provider</th></tr>'+M.map(m=>'<tr><td><b>'+(m.name||"")+'</b></td><td><span class="badge badge-purple">'+(m.type||"?")+'</span></td><td style="color:var(--muted)">'+(m.provider||"")+'</td></tr>').join("")+'</table></div></div>';break;
|
|
}}
|
|
|
|
async function cAct(id,a){await fetch("/api/container/"+id+"/"+a,{method:"POST"});toast(a+" sent");setTimeout(loadAll,1500)}
|
|
async function logs(id,name){$("#logTitle").textContent="Logs - "+name;const r=await fetch("/api/container/"+id+"/logs");$("#logContent").textContent=await r.text();$("#logModal").classList.add("show")}
|
|
function closeModal(){$("#logModal").classList.remove("show")}
|
|
document.addEventListener("keydown",e=>{if(e.key==="Escape")closeModal()});
|
|
|
|
async function doDeploy(){
|
|
const image=$("#dp-image").value.trim(),sub=$("#dp-sub").value.trim(),port=$("#dp-port").value,name=$("#dp-name").value.trim();
|
|
if(!image||!sub){toast("Image and subdomain required",true);return}
|
|
const nets=[...document.querySelectorAll(".net-cb:checked")].map(c=>c.value);
|
|
showSpinner("Pulling "+image+" and deploying...");
|
|
try{
|
|
const r=await fetch("/api/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({image,subdomain:sub,port:parseInt(port),name:name||undefined,envVars:envRows.filter(e=>e.key),volumes:volRows.filter(v=>v.host&&v.container),networks:nets})});
|
|
const d=await r.json();hideSpinner();
|
|
if(d.ok){toast("Deployed: "+d.url);envRows=[{key:"",value:""}];volRows=[{host:"",container:""}];loadAll()}
|
|
else toast("Error: "+(d.error||"unknown"),true);
|
|
}catch(e){hideSpinner();toast("Error: "+e.message,true)}
|
|
}
|
|
|
|
async function removeService(id,name,sub){
|
|
if(!confirm("Remove "+name+"? This will stop and delete the container."+(sub?" DNS record for "+sub+" will also be removed.":"")))return;
|
|
await fetch("/api/container/"+id+"/remove",{method:"POST"});
|
|
if(sub){try{const recs=await(await fetch("/api/dns")).json();const filtered=(recs.records||[]).filter(r=>r.subdomain!==sub);await fetch("/api/dns",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({subdomain:sub,ip:recs.serverIp})})}catch{}}
|
|
toast("Service removed");setTimeout(loadAll,1000);
|
|
}
|
|
|
|
async function addRoute(){const sub=$("#rt-sub").value.trim(),target=$("#rt-target").value.trim(),port=$("#rt-port").value;if(!sub||!target){toast("Fill all fields",true);return}await fetch("/api/routes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({subdomain:sub,target,port})});toast("Route added");loadAll()}
|
|
async function delRoute(n){if(!confirm("Delete route "+n+"?"))return;await fetch("/api/routes/"+n,{method:"DELETE"});toast("Deleted");loadAll()}
|
|
async function addDns(){const sub=$("#dns-sub").value.trim(),ip=$("#dns-ip").value.trim();if(!ip){toast("IP required",true);return}await fetch("/api/dns",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({subdomain:sub,ip})});toast("DNS added");loadAll()}
|
|
async function delDns(sub,ip){if(!confirm("Delete "+sub+" -> "+ip+"?"))return;await fetch("/api/dns",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({subdomain:sub,ip})});toast("Deleted");loadAll()}
|
|
|
|
loadAll();setInterval(loadAll,15000);
|
|
</script></body></html>
|