Files
minicity-agent/index.html
T

404 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="ko" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작은도시 인프라 현황</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Pretendard',sans-serif;background:#0d1117;color:#e6edf3;min-height:100vh}
.container{max-width:1100px;margin:0 auto;padding:0 20px}
.header{padding:28px 0 16px;border-bottom:1px solid #21262d;margin-bottom:24px}
.header h1{font-size:26px;font-weight:700;display:flex;align-items:center;gap:8px}
.header h1 small{font-size:13px;font-weight:400;color:#8b949e}
.summary-bar{display:flex;gap:20px;margin-top:10px;flex-wrap:wrap}
.summary-bar .stat{display:flex;align-items:center;gap:5px;font-size:13px}
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex-shrink:0}
.dot.on{background:#3fb950;box-shadow:0 0 6px rgba(63,185,80,.4)}
.dot.off{background:#f85149;box-shadow:0 0 6px rgba(248,81,73,.4)}
.dot.warn{background:#d29922}
.last-updated{font-size:12px;color:#484f58;margin-top:6px}
.tabs{display:flex;gap:0;margin-bottom:20px;border-bottom:1px solid #21262d;flex-wrap:wrap}
.tab-btn{
padding:10px 18px;font-size:14px;font-weight:500;cursor:pointer;
border:none;background:transparent;color:#8b949e;
border-bottom:2px solid transparent;transition:all .15s
}
.tab-btn:hover{color:#e6edf3}
.tab-btn.active{color:#58a6ff;border-bottom-color:#58a6ff}
.tab-content{display:none}
.tab-content.active{display:block}
.domain-card{
background:#161b22;border:1px solid #21262d;border-radius:12px;
margin-bottom:20px;overflow:hidden
}
.domain-header{
display:flex;align-items:center;justify-content:space-between;
padding:14px 18px;background:#1c2333;border-bottom:1px solid #21262d
}
.dom-name{font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px}
.tag{
font-size:11px;padding:2px 10px;border-radius:12px;font-weight:600
}
.tag.on{background:rgba(63,185,80,.15);color:#3fb950}
.tag.off{background:rgba(248,81,73,.15);color:#f85149}
.tag.er{background:rgba(210,153,34,.15);color:#d29922}
.pc-box{
margin:12px 18px;border:1px solid #21262d;border-radius:10px;
background:#0d1117;overflow:hidden
}
.pc-box:first-child{margin-top:14px}
.pc-row{
display:flex;align-items:flex-start;justify-content:space-between;
padding:12px 16px;border-bottom:1px solid #1c2333;gap:12px
}
.pc-left{display:flex;align-items:flex-start;gap:10px;min-width:0;flex:1}
.pc-emoji{font-size:20px;flex-shrink:0;line-height:1.3}
.pc-info{min-width:0}
.pc-name{font-size:14px;font-weight:500;line-height:1.3}
.pc-spec{font-size:11px;color:#7d8590;margin-top:1px}
.pc-net{font-size:11px;color:#484f58;margin-top:1px;font-family:'SF Mono',monospace}
.pc-right{flex-shrink:0;padding-top:2px}
.ptag{font-size:11px;padding:1px 8px;border-radius:10px;font-weight:500}
.ptag.on{background:rgba(63,185,80,.15);color:#3fb950}
.ptag.off{background:rgba(248,81,73,.15);color:#f85149}
.ptag.er{background:rgba(210,153,34,.15);color:#d29922}
.agents-wrap{
padding:0 14px 12px 14px;display:flex;flex-direction:column;gap:8px
}
.agent-block{
background:#161b22;border:1px solid #1c2333;border-radius:8px;
padding:10px 12px
}
.agent-block:first-child{margin-top:6px}
.agent-top{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.agent-name{font-size:13px;font-weight:500}
.aengine{font-size:10px;color:#8b949e;font-family:'SF Mono',monospace}
.agent-info{font-size:11px;color:#8b949e;margin-top:2px;margin-left:21px;font-family:'SF Mono',monospace}
.detail-line{font-size:11px;color:#484f58;margin-top:1px;margin-left:21px}
.sub-row{
display:flex;align-items:center;justify-content:space-between;
padding:12px 18px;border-bottom:1px solid #1c2333;gap:12px
}
.sub-row:last-child{border-bottom:none}
.sub-left{display:flex;align-items:center;gap:10px;min-width:0;flex:1}
.sub-left .semoji{font-size:16px;flex-shrink:0}
.sname{font-size:14px;font-weight:500;font-family:'SF Mono',monospace}
.sname .sproto{color:#484f58;font-weight:400}
.sgroup{font-size:11px;color:#484f58;margin-left:6px}
.sub-right{display:flex;align-items:center;gap:8px;flex-shrink:0}
.stag{font-size:11px;padding:1px 8px;border-radius:10px;font-weight:500}
.stag.on{background:rgba(63,185,80,.15);color:#3fb950}
.stag.off{background:rgba(248,81,73,.15);color:#f85149}
.footer{text-align:center;padding:30px 0;border-top:1px solid #21262d;font-size:12px;color:#484f58}
@media(max-width:640px){
.pc-row,.sub-row{flex-wrap:wrap}
.header h1{font-size:20px}
.tabs{overflow-x:auto}
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>🏙️ 작은도시 인프라 현황</h1>
<div class="summary-bar" id="summary-bar">
<span class="stat"><span class="dot on"></span> <span id="online-count">0</span> Online</span>
<span class="stat"><span class="dot off"></span> <span id="offline-count">0</span> Offline</span>
<span class="stat"><span class="dot warn"></span> <span id="error-count">0</span> Error</span>
</div>
<div class="last-updated" id="last-updated">갱신 중...</div>
</header>
<div class="tabs" id="tabs">
<button class="tab-btn active" data-tab="agents">🤖 에이전트</button>
<button class="tab-btn" data-tab="subdomains">🌐 서브도메인</button>
<button class="tab-btn" data-tab="nas">🗄️ NAS</button>
<button class="tab-btn" data-tab="proxmox">🖥️ 가상PC</button>
</div>
<div id="tab-agents" class="tab-content active"></div>
<div id="tab-subdomains" class="tab-content"></div>
<div id="tab-nas" class="tab-content"></div>
<div id="tab-proxmox" class="tab-content"></div>
<footer class="footer">작은도시 인프라 현황 · 60초마다 자동 갱신</footer>
</div>
<script>
function sClass(st) {
if(st==="online") return "on";
if(st==="error") return "er";
return "off";
}
function sLabel(st) {
if(st==="online") return "● 온라인";
if(st==="error") return "● 오류";
return "● 오프라인";
}
function downloadRdp(host, port) {
const rdp = `full address:s:${host}:${port}\nscreen mode id:i:2\nuse multimon:i:0\n`;
const blob = new Blob([rdp], {type: 'application/rdp'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${host}_${port}.rdp`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function buildAgents(data) {
if(!data || !data.domains) return `<div style="text-align:center;padding:40px;color:#8b949e">데이터 없음</div>`;
let html = "";
const domainOrder = ["pinksky.kr","minicity.kr","gwenc.kr"];
for(const dname of domainOrder) {
const dom = data.domains[dname];
if(!dom) continue;
const dcls = sClass(dom.domain.status);
html += `<div class="domain-card">`;
html += `<div class="domain-header">`;
html += `<span class="dom-name">${dname}</span>`;
html += `<span class="tag ${dcls}">${sLabel(dom.domain.status)}</span>`;
html += `</div>`;
for(const [pcid, pc] of Object.entries(dom.pcs || {})) {
html += `<div class="pc-box">`;
html += `<div class="pc-row">`;
html += `<div class="pc-left">`;
html += `<span class="pc-emoji">${pc.emoji || '📦'}</span>`;
html += `<div class="pc-info">`;
html += `<div class="pc-name">${pc.name}</div>`;
if(pc.spec) html += `<div class="pc-spec">${pc.spec}</div>`;
if(pc.tailscale_ip || pc.dns) {
html += `<div class="pc-net">${pc.tailscale_ip || ''}${pc.tailscale_ip && pc.dns ? ' · ' : ''}${pc.dns || ''}</div>`;
}
html += `</div></div>`;
html += `<div class="pc-right"><span class="ptag ${sClass(pc.status)}">${sLabel(pc.status)}</span></div>`;
html += `</div>`;
const agents = Object.values(pc.agents || {});
if(agents.length > 0) {
html += `<div class="agents-wrap">`;
for(const a of agents) {
const eng = a.engine && a.version && a.version !== '?' ? `${a.engine}_${a.version}` : '';
html += `<div class="agent-block">`;
html += `<div class="agent-top">`;
html += `<span style="font-size:16px">${a.emoji || '🤖'}</span>`;
html += `<span class="agent-name">${a.name}</span>`;
if(eng) html += `<span class="aengine">(${eng})</span>`;
html += `<span class="ptag ${sClass(a.status)}">${sLabel(a.status)}</span>`;
html += `</div>`;
let info = '';
if(a.uptime && a.uptime !== '?') info += a.uptime;
if(a.proc_count != null) info += (info ? ' · ' : '') + a.proc_count + '프로세스';
if(info) html += `<div class="agent-info">${info}</div>`;
html += `</div>`;
}
html += `</div>`;
}
html += `</div>`;
}
html += `</div>`;
}
return html;
}
function buildSubdomains(data) {
if(!data || !data.subdomains) return `<div style="text-align:center;padding:40px;color:#8b949e">데이터 없음</div>`;
let html = `<div class="domain-card">`;
html += `<div class="domain-header"><span class="dom-name">🌐 서브도메인</span></div>`;
for(const d of data.subdomains) {
const cls = sClass(d.status);
const gicon = {"pinksky.kr":"🏠","minicity.kr":"🏡","gwenc.kr":"🏢"}[d.group] || "🌐";
html += `<div class="sub-row">`;
html += `<div class="sub-left"><span class="semoji">${gicon}</span>`;
html += `<a href="https://${d.name}" target="_blank" rel="noopener" style="color:inherit;text-decoration:none"><span class="sname"><span class="sproto">https://</span>${d.name}</span></a>`;
html += `<span class="sgroup">${d.group}</span>`;
html += `</div>`;
html += `<div class="sub-right">`;
if(d.latency_ms != null) html += `<span style="font-size:11px;color:#8b949e;margin-right:6px">${d.latency_ms}ms</span>`;
html += `<span class="stag ${cls}">${sLabel(d.status)}</span></div></div>`;
}
html += `</div>`;
return html;
}
function buildNAS(data) {
if(!data || !data.nas_storage) return `<div style="text-align:center;padding:40px;color:#8b949e">데이터 없음</div>`;
let html = "";
for(const [nk, ns] of Object.entries(data.nas_storage)) {
const dcls = sClass(ns.status);
html += `<div class="domain-card">`;
html += `<div class="domain-header">`;
html += `<span class="dom-name">🗄️ ${ns.name}</span>`;
html += `<span class="tag ${dcls}">${sLabel(ns.status)}</span>`;
html += `</div>`;
html += `<div class="pc-box">`;
html += `<div class="pc-row">`;
html += `<div class="pc-left">`;
html += `<div class="pc-info">`;
html += `<div class="pc-name">${ns.model}</div>`;
html += `<div class="pc-spec">${ns.os}</div>`;
html += `</div></div>`;
html += `</div>`;
if(ns.volumes && ns.volumes.length > 0) {
for(const v of ns.volumes) {
const barPct = parseInt(v.pct) || 0;
const barColor = barPct > 85 ? '#f85149' : barPct > 70 ? '#d29922' : '#3fb950';
html += `<div class="sub-row">`;
html += `<div class="sub-left"><span style="font-size:13px;font-weight:500">💾 ${v.label}</span>`;
html += ` <span style="font-size:11px;color:#484f58;font-family:'SF Mono',monospace">${v.mount}</span></div>`;
html += `<div class="sub-right" style="flex-direction:column;align-items:flex-end;gap:3px">`;
html += `<span style="font-size:11px;color:#8b949e;font-family:'SF Mono',monospace">${v.used} / ${v.total} (${v.pct})</span>`;
html += `<div style="width:140px;height:4px;background:#1c2333;border-radius:2px;overflow:hidden">`;
html += `<div style="width:${barPct}%;height:100%;background:${barColor};border-radius:2px"></div></div>`;
html += `</div></div>`;
}
}
html += `</div></div>`;
}
return html;
}
function buildProxmox(data) {
if(!data || !data.proxmox) return `<div style="text-align:center;padding:40px;color:#8b949e">데이터 없음</div>`;
const px = data.proxmox;
let html = `<div class="domain-card">`;
html += `<div class="domain-header"><span class="dom-name">🖥️ 가상PC (Hyper-V + Proxmox)</span></div>`;
// Hyper-V Host
const host = px.host;
html += `<div class="pc-box">`;
html += `<div class="pc-row">`;
html += `<div class="pc-left">`;
html += `<span class="pc-emoji">🖥️</span>`;
html += `<div class="pc-info">`;
html += `<div class="pc-name">${host.name}</div>`;
html += `<div class="pc-spec">${host.cpu} · ${host.ram} · ${host.os}</div>`;
html += `<div class="pc-spec" style="color:#484f58">${host.role}</div>`;
html += `</div></div>`;
html += `<div class="pc-right"><span class="ptag ${sClass(host.status)}">${sLabel(host.status)}</span></div>`;
html += `\u003c/div\u003e`;
// VM cards (horizontal)
if (px.vms) {
html += `\u003cdiv style="display:flex;gap:10px;margin-top:12px;padding:0 18px;flex-wrap:wrap"\u003e`;
for (const vm of px.vms) {
const vcls = sClass(vm.status);
const vstyle = vm.status === "online"
? "border-color:#238636;background:rgba(35,134,54,.08)"
: vm.status === "offline"
? "border-color:#da3633;background:rgba(218,54,51,.08)"
: "border-color:#d29922;background:rgba(210,153,34,.08)";
html += `\u003cdiv onclick="downloadRdp('${vm.host}',${vm.port})" style="cursor:pointer;flex:1;min-width:140px;max-width:200px;border:1px solid #21262d;border-radius:10px;padding:12px 14px;background:#0d1117;${vstyle};transition:.15s" onmouseenter="this.style.opacity='0.85'" onmouseleave="this.style.opacity='1'"\u003e`;
html += `\u003cdiv style="font-size:20px;margin-bottom:6px"\u003e${vm.emoji}\u003c/div\u003e`;
html += `\u003cdiv style="font-weight:600;font-size:13px;color:#c9d1d9;margin-bottom:4px"\u003e${vm.name}\u003c/div\u003e`;
html += `\u003cdiv style="display:flex;align-items:center;gap:6px"\u003e`;
html += `\u003cspan class="ptag ${vcls}" style="font-size:11px;padding:1px 6px"\u003e${vm.status}\u003c/span\u003e`;
html += `\u003c/div\u003e`;
html += `\u003c/div\u003e`;
}
html += `\u003c/div\u003e`;
}
html += `\u003c/div\u003e`;
// Arrow connecting Host → Proxmox
html += `<div style="text-align:center;padding:2px 0;color:#484f58;font-size:16px">⬇️ Hyper-V</div>`;
// Proxmox VM
const pvm = px.proxmox;
const pcls = pvm.status === "계획중" ? "warn" : sClass(pvm.status);
html += `<div class="pc-box">`;
html += `<div class="pc-row">`;
html += `<div class="pc-left">`;
html += `<span class="pc-emoji">📦</span>`;
html += `<div class="pc-info">`;
html += `<div class="pc-name">${pvm.name} ${pvm.version || ''}</div>`;
html += `<div class="pc-spec">${pvm.cpu} · ${pvm.ram} · ${pvm.storage} · ${pvm.os}</div>`;
html += `<div class="pc-spec" style="color:#484f58">${pvm.role}</div>`;
html += `</div></div>`;
html += `<div class="pc-right"><span class="ptag ${pcls}">${pvm.status}</span></div>`;
html += `</div>`;
html += `</div>`;
html += `</div>`;
return html;
}
function updateSummary(data) {
const all = [];
if(data.domains) {
for(const dom of Object.values(data.domains)) {
for(const pc of Object.values(dom.pcs || {})) {
all.push(pc);
for(const a of Object.values(pc.agents || {})) all.push(a);
}
}
}
if(data.subdomains) all.push(...data.subdomains);
if(data.nas_storage) {
for(const ns of Object.values(data.nas_storage)) {
all.push(ns);
if(ns.volumes) ns.volumes.forEach(v => all.push(v));
}
}
if(data.proxmox) {
all.push(data.proxmox.host);
all.push(data.proxmox.proxmox);
if(data.proxmox.vms) all.push(...data.proxmox.vms);
}
const online = all.filter(x => x.status==="online").length;
const offline = all.filter(x => x.status==="offline").length;
const er = all.filter(x => x.status==="error").length;
document.getElementById("online-count").textContent = online;
document.getElementById("offline-count").textContent = offline;
document.getElementById("error-count").textContent = er;
const ts = data.timestamp_epoch ? new Date(data.timestamp_epoch * 1000) : new Date();
document.getElementById("last-updated").textContent =
`마지막 갱신: ${ts.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" })}`;
document.title = `작은도시 인프라 현황 (${online}/${all.length})`;
}
async function fetchStatus() {
try {
const res = await fetch("health.json?" + Date.now());
const data = await res.json();
document.getElementById("tab-agents").innerHTML = buildAgents(data);
document.getElementById("tab-subdomains").innerHTML = buildSubdomains(data);
document.getElementById("tab-nas").innerHTML = buildNAS(data);
document.getElementById("tab-proxmox").innerHTML = buildProxmox(data);
updateSummary(data);
} catch(e) {
document.getElementById("tab-agents").innerHTML =
`<div style="text-align:center;padding:40px;color:#f85149">❌ 데이터를 불러오지 못했습니다</div>`;
document.getElementById("tab-subdomains").innerHTML = "";
console.error(e);
}
}
document.addEventListener("click", function(e) {
const btn = e.target.closest(".tab-btn");
if(!btn) return;
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach(t => t.classList.remove("active"));
btn.classList.add("active");
document.getElementById("tab-" + btn.dataset.tab).classList.add("active");
});
fetchStatus();
setInterval(fetchStatus, 60000);
</script>
</body>
</html>