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
+241
View File
@@ -0,0 +1,241 @@
<!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}
.card{
background:#161b22;border:1px solid #21262d;border-radius:12px;
margin-bottom:16px;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}
.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}
.item-row{
display:flex;align-items:flex-start;justify-content:space-between;
padding:12px 18px;border-bottom:1px solid #1c2333;gap:12px
}
.item-row:last-child{border-bottom:none}
.item-left{display:flex;align-items:flex-start;gap:10px;min-width:0;flex:1}
.item-left .emoji{font-size:20px;flex-shrink:0;line-height:1.3}
.item-info{min-width:0}
.item-name{font-size:14px;font-weight:500;line-height:1.3}
.item-spec{font-size:11px;color:#7d8590;margin-top:1px}
.item-net{font-size:11px;color:#484f58;margin-top:1px;font-family:'SF Mono',monospace}
.item-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}
.detail{font-size:11px;color:#8b949e;margin-top:2px;font-family:'SF Mono',monospace}
.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){
.item-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>
</div>
<div class="last-updated" id="last-updated">갱신 중...</div>
</header>
<div class="tabs" id="tabs">
<button class="tab-btn active" data-tab="pc">🖥️ PC</button>
<button class="tab-btn" data-tab="nas">🗄️ NAS</button>
<button class="tab-btn" data-tab="router">📡 공유기</button>
<button class="tab-btn" data-tab="agent">🤖 에이전트</button>
<button class="tab-btn" data-tab="domain">🌐 도메인</button>
</div>
<div id="tab-pc" class="tab-content active"></div>
<div id="tab-nas" class="tab-content"></div>
<div id="tab-router" class="tab-content"></div>
<div id="tab-agent" class="tab-content"></div>
<div id="tab-domain" 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 "● 오프라인";
}
function buildCard(title, items) {
if (!items || !items.length) return `<div style="text-align:center;padding:40px;color:#8b949e">데이터 없음</div>`;
let html = `<div class="card"><div class="card-header"><div class="ch-left"><span class="ch-name">${title}</span></div></div>`;
for (const item of items) {
const cls = statusClass(item.status);
html += `<div class="item-row">`;
html += `<div class="item-left"><span class="emoji">${item.emoji || '📦'}</span>`;
html += `<div class="item-info"><div class="item-name">${item.name}</div>`;
if (item.spec) html += `<div class="item-spec">${item.spec}</div>`;
if (item.net) html += `<div class="item-net">${item.net}</div>`;
if (item.detail) html += `<div class="detail">${item.detail}</div>`;
html += `</div></div>`;
html += `<div class="item-right"><span class="ptag ${cls}">${statusLabel(item.status)}</span></div></div>`;
}
html += `</div>`;
return html;
}
function buildAgentsTab(items) {
if (!items || !items.length) return `<div style="text-align:center;padding:40px;color:#8b949e">에이전트 데이터 없음</div>`;
let html = `<div class="card"><div class="card-header"><div class="ch-left"><span class="ch-name">🤖 에이전트</span></div></div>`;
for (const a of items) {
const cls = statusClass(a.status);
const eng = a.engine && a.version ? `${a.engine}_v${a.version}` : '';
html += `<div class="item-row">`;
html += `<div class="item-left"><span class="emoji">${a.emoji || '🤖'}</span>`;
html += `<div class="item-info">`;
html += `<div class="item-name">${a.name}${eng ? ' <span style="font-size:11px;color:#8b949e;font-family:monospace">(' + eng + ')</span>' : ''}</div>`;
if (a.host) html += `<div class="item-net">${a.host}</div>`;
let infoLine = '';
if (a.uptime && a.uptime !== '?') infoLine += a.uptime;
if (a.proc_count != null) infoLine += (infoLine ? ' · ' : '') + a.proc_count + '프로세스';
if (a.mem) infoLine += (infoLine ? ' · ' : '') + a.mem;
if (infoLine) html += `<div class="detail">${infoLine}</div>`;
html += `</div></div>`;
html += `<div class="item-right"><span class="ptag ${cls}">${statusLabel(a.status)}</span></div></div>`;
}
html += `</div>`;
return html;
}
function buildDomainsTab(items) {
if (!items || !items.length) return `<div style="text-align:center;padding:40px;color:#8b949e">도메인 데이터 없음</div>`;
let html = `<div class="card"><div class="card-header"><div class="ch-left"><span class="ch-name">🌐 도메인 및 서브도메인</span></div></div>`;
for (const d of items) {
const cls = statusClass(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}">${statusLabel(d.status)}</span></div></div>`;
}
html += `</div>`;
return html;
}
function updateSummary(data) {
const all = [
...(data.pcs || []),
...(data.nas || []),
...(data.routers || []),
...(data.agents || []),
...(data.domains || [])
];
const online = all.filter(x => x.status === "online").length;
document.getElementById("online-count").textContent = online;
document.getElementById("offline-count").textContent = all.length - online;
const ts = data.timestamp ? 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-pc").innerHTML = buildCard("🖥️ PC", data.pcs || []);
document.getElementById("tab-nas").innerHTML = buildCard("🗄️ NAS", data.nas || []);
document.getElementById("tab-router").innerHTML = buildCard("📡 공유기", data.routers || []);
document.getElementById("tab-agent").innerHTML = buildAgentsTab(data.agents || []);
document.getElementById("tab-domain").innerHTML = buildDomainsTab(data.domains || []);
updateSummary(data);
} catch (e) {
document.getElementById("tab-pc").innerHTML =
`<div style="text-align:center;padding:40px;color:#f85149">❌ 데이터를 불러오지 못했습니다</div>`;
["tab-nas","tab-router","tab-agent","tab-domain"].forEach(id => document.getElementById(id).innerHTML = "");
}
}
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>