Initial commit: agent-status dashboard (agent.pinksky.kr)

This commit is contained in:
2026-05-05 21:14:29 +09:00
commit 0ef19098ce
11 changed files with 4443 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
<!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 */
.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 */
.tabs{display:flex;gap:0;margin-bottom:20px;border-bottom:1px solid #21262d}
.tab-btn{
padding:10px 20px;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 Cards */
.card{
background:#161b22;border:1px solid #21262d;border-radius:12px;
margin-bottom:20px;overflow:hidden
}
.card-header{
display:flex;align-items:center;justify-content:space-between;
padding:14px 18px;background:#1c2333;border-bottom:1px solid #21262d
}
.card-header .ch-left{display:flex;align-items:center;gap:10px}
.card-header .ch-name{font-size:16px;font-weight:700;font-family:'SF Mono',monospace}
.card-header .ch-name .proto{color:#484f58;font-weight:400}
.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 Sub-box */
.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 */
.pc-row{
display:flex;align-items:flex-start;justify-content:space-between;
padding:10px 14px 4px;gap:12px
}
.pc-left{display:flex;align-items:flex-start;gap:10px;min-width:0;flex:1}
.pc-left .emoji{font-size:20px;flex-shrink:0;line-height:1.3}
.pc-left .pcinfo{min-width:0}
.pc-left .pname{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{display:flex;align-items:flex-start;gap:8px;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 */
.agents-wrap{
padding:0 14px 10px;display:flex;flex-direction:column;gap:6px
}
.agents-wrap .agent-block:first-child{margin-top:4px}
.agent-block{
display:flex;flex-direction:column;
font-size:12px;padding:5px 10px;border-radius:8px;
border:1px solid #21262d;background:#161b22
}
.agent-top{display:flex;align-items:center;gap:6px}
.agent-top .emoji{font-size:15px}
.agent-top .aname{color:#e6edf3;font-weight:500}
.agent-top .aengine{font-size:10px;color:#8b949e;font-family:'SF Mono',monospace}
.agent-top .atag{font-size:10px;padding:1px 6px;border-radius:8px;font-weight:500}
.agent-top .atag.on{background:rgba(63,185,80,.15);color:#3fb950}
.agent-top .atag.off{background:rgba(248,81,73,.15);color:#f85149}
.agent-info{font-size:11px;color:#8b949e;margin-top:2px;margin-left:21px;font-family:'SF Mono',monospace}
.agent-mem{font-size:10px;color:#7d8590;margin-top:1px;margin-left:21px;font-family:'SF Mono',monospace}
/* Subdomain rows */
.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}
.sub-left .sname{font-size:14px;font-weight:500;font-family:'SF Mono',monospace}
.sub-left .sname .sproto{color:#484f58;font-weight:400}
.sub-left .sgroup{font-size:11px;color:#484f58;margin-left:6px}
.sub-mid{flex:0 0 auto;padding:0 10px}
.sub-mid .sdesc{font-size:12px;color:#8b949e;white-space:nowrap}
.sub-right{display:flex;align-items:center;gap:8px;flex-shrink:0}
.sub-right .slatency{font-size:11px;color:#8b949e}
.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}
.stag.er{background:rgba(210,153,34,.15);color:#d29922}
/* Footer */
.footer{text-align:center;padding:30px 0;border-top:1px solid #21262d;font-size:12px;color:#484f58}
@media(max-width:640px){
.agents-wrap{padding-left:10px}
.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 domain"></span> 도메인 <span id="domain-online">0</span>/<span id="domain-total">4</span></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>
</div>
<div id="tab-agents" class="tab-content active"></div>
<div id="tab-subdomains" class="tab-content"></div>
<footer class="footer">
작은도시 에이전트 현황 · 1분마다 자동 갱신
</footer>
</div>
<script>
function statusClass(st) {
if (st === "online") return "on";
if (st === "error") return "er";
return "off";
}
function statusLabel(st) {
if (st === "online") return "● 온라인";
if (st === "error") return "● 오류";
return "● 오프라인";
}
const DOMAIN_ICONS = {"pinksky.kr":"🏠","minicity.kr":"🏡","pinksky.iptime.org":"🏡","gwenc.kr":"🏢"};
const GROUP_ICONS = {"pinksky.kr":"🏠","minicity.kr":"🏡","pinksky.iptime.org":"🏡","gwenc.kr":"🏢"};
function buildAgents(data) {
let html = "";
for (const dname of ["pinksky.kr","minicity.kr","pinksky.iptime.org","gwenc.kr"]) {
const dom = data.domains[dname];
if (!dom) continue;
const icon = DOMAIN_ICONS[dname] || "🌐";
const tcls = statusClass(dom.domain.status);
html += `<div class="card"><div class="card-header">`;
html += `<div class="ch-left"><span>${icon}</span><span class="ch-name"><span class="proto">https://</span>${dname}</span></div>`;
html += `<span class="tag ${tcls}">${statusLabel(dom.domain.status)}</span></div>`;
for (const pc of Object.values(dom.pcs)) {
const pcls = statusClass(pc.status);
html += `<div class="pc-box">`;
html += `<div class="pc-row"><div class="pc-left"><span class="emoji">${pc.emoji}</span>`;
html += `<div class="pcinfo"><div class="pname">${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.dns}</div>`;
}
html += `</div></div>`;
html += `<div class="pc-right"><span class="ptag ${pcls}">${statusLabel(pc.status)}</span></div></div>`;
const agents = Object.values(pc.agents || {});
if (agents.length) {
html += `<div class="agents-wrap">`;
for (const a of agents) {
const acls = statusClass(a.status);
const eng = a.engine && a.version ? `${a.engine}_v${a.version}` : '';
html += `<div class="agent-block"><div class="agent-top"><span class="emoji">${a.emoji}</span><span class="aname">${a.name}</span>`;
if (eng) html += `<span class="aengine">(${eng})</span>`;
html += `<span class="atag ${acls}">${statusLabel(a.status)}</span></div>`;
// 가동시간 · 프로세스
if (a.uptime && a.uptime !== "?") {
html += `<div class="agent-info">${a.uptime} · ${a.proc_count}프로세스</div>`;
}
// 메모리 · VRAM
let memLine = "";
if (a.mem && a.mem !== "?") memLine += a.mem;
if (a.gpu && a.gpu !== "?") memLine += (memLine ? " · " : "") + a.gpu;
if (memLine) {
html += `<div class="agent-mem">${memLine}</div>`;
}
html += `</div>`;
}
html += `</div>`;
}
html += `</div>`;
}
html += `</div>`;
}
return html;
}
function buildSubdomains(data) {
if (!data.subdomains || !data.subdomains.length) {
return `<div style="text-align:center;padding:40px;color:#8b949e">서브도메인 데이터 없음</div>`;
}
let html = "";
for (const sd of data.subdomains) {
const cls = statusClass(sd.status);
const gicon = GROUP_ICONS[sd.group] || "🌐";
html += `<div class="sub-row">`;
html += `<div class="sub-left"><span class="semoji">${gicon}</span><a href="https://${sd.name}" target="_blank" rel="noopener" style="color:inherit;text-decoration:none"><span class="sname"><span class="sproto">https://</span>${sd.name}</span></a><span class="sgroup">${sd.group}</span></div>`;
html += `<div class="sub-mid"><span class="sdesc">${sd.desc || ''}</span></div>`;
html += `<div class="sub-right">`;
if (sd.latency_ms != null) html += `<span class="slatency">${sd.latency_ms}ms</span>`;
html += `<span class="stag ${cls}">${statusLabel(sd.status)}</span></div></div>`;
}
return html;
}
function updateSummary(data) {
document.getElementById("online-count").textContent = data.summary.online;
document.getElementById("offline-count").textContent = data.summary.offline;
document.getElementById("domain-online").textContent = data.summary.domain_online;
document.getElementById("domain-total").textContent = data.summary.domain_total;
const ts = new Date(data.timestamp_epoch * 1000);
document.getElementById("last-updated").textContent =
`마지막 갱신: ${ts.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" })}`;
document.title = `작은도시 에이전트 현황 (${data.summary.online}/${data.summary.total})`;
}
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");
});
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);
updateSummary(data);
} catch (e) {
document.getElementById("tab-agents").innerHTML =
`<div style="text-align:center;padding:40px;color:#f85149">❌ 데이터를 불러오지 못했습니다</div>`;
document.getElementById("tab-subdomains").innerHTML = "";
}
}
fetchStatus();
setInterval(fetchStatus, 60000);
</script>
</body>
</html>