Initial commit: agent-status dashboard (agent.pinksky.kr)
This commit is contained in:
Binary file not shown.
+394
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"subdomains": [
|
||||
{
|
||||
"name": "wiki.pinksky.kr",
|
||||
"group": "pinksky.kr",
|
||||
"desc": "MiniCITY 지식정원",
|
||||
"status": "online",
|
||||
"detail": "HTTP 200",
|
||||
"latency_ms": 21.0
|
||||
},
|
||||
{
|
||||
"name": "openclaw.pinksky.kr",
|
||||
"group": "pinksky.kr",
|
||||
"desc": "OpenClaw 대시보드",
|
||||
"status": "online",
|
||||
"detail": "HTTP 200",
|
||||
"latency_ms": 18.0
|
||||
},
|
||||
{
|
||||
"name": "agent.pinksky.kr",
|
||||
"group": "pinksky.kr",
|
||||
"desc": "인프라 현황판",
|
||||
"status": "online",
|
||||
"detail": "HTTP 200",
|
||||
"latency_ms": 13.0
|
||||
},
|
||||
{
|
||||
"name": "search.pinksky.kr",
|
||||
"group": "pinksky.kr",
|
||||
"desc": "SearXNG 메타서치",
|
||||
"status": "online",
|
||||
"detail": "HTTP 200",
|
||||
"latency_ms": 824.0
|
||||
},
|
||||
{
|
||||
"name": "erp.pinksky.kr",
|
||||
"group": "pinksky.kr",
|
||||
"desc": "공사관리 ERP",
|
||||
"status": "online",
|
||||
"detail": "HTTP 200",
|
||||
"latency_ms": 20.0
|
||||
},
|
||||
{
|
||||
"name": "dify.pinksky.kr",
|
||||
"group": "pinksky.kr",
|
||||
"desc": "Dify 워크플로우",
|
||||
"status": "online",
|
||||
"detail": "HTTP 307",
|
||||
"latency_ms": 17.0
|
||||
},
|
||||
{
|
||||
"name": "photo.pinksky.kr",
|
||||
"group": "pinksky.kr",
|
||||
"desc": "PhotoVault 자료정리",
|
||||
"status": "online",
|
||||
"detail": "HTTP 200",
|
||||
"latency_ms": 46.0
|
||||
},
|
||||
{
|
||||
"name": "git.pinksky.kr",
|
||||
"group": "pinksky.kr",
|
||||
"desc": "Gitea Git 서버",
|
||||
"status": "online",
|
||||
"detail": "HTTP 200",
|
||||
"latency_ms": 18.0
|
||||
},
|
||||
{
|
||||
"name": "find.pinksky.kr",
|
||||
"group": "pinksky.kr",
|
||||
"desc": "Everything 검색",
|
||||
"status": "online",
|
||||
"detail": "HTTP 401",
|
||||
"latency_ms": 23.0
|
||||
},
|
||||
{
|
||||
"name": "gwenc.kr",
|
||||
"group": "gwenc.kr",
|
||||
"desc": "회사 메인 사이트",
|
||||
"status": "online",
|
||||
"detail": "HTTP 200",
|
||||
"latency_ms": 28.0
|
||||
},
|
||||
{
|
||||
"name": "minicity.kr",
|
||||
"group": "minicity.kr",
|
||||
"desc": "집 NAS 웹 UI",
|
||||
"status": "online",
|
||||
"detail": "HTTP 200",
|
||||
"latency_ms": 65.0
|
||||
}
|
||||
],
|
||||
"domains": {
|
||||
"pinksky.kr": {
|
||||
"domain": {
|
||||
"status": "online",
|
||||
"detail": "100.70.47.91"
|
||||
},
|
||||
"pcs": {
|
||||
"macmini": {
|
||||
"name": "맥미니 M4 Pro",
|
||||
"emoji": "🖥️",
|
||||
"spec": "Apple M4 Pro · 64GB · 통합 GPU",
|
||||
"dns": "pinksky.kr",
|
||||
"tailscale_ip": "100.70.47.91",
|
||||
"status": "online",
|
||||
"detail": "",
|
||||
"agents": {
|
||||
"mimi": {
|
||||
"status": "online",
|
||||
"version": "2026.5.2",
|
||||
"uptime": "02-05:04:59",
|
||||
"proc_count": 1,
|
||||
"engine": "openclaw",
|
||||
"detail": "02-05:04:59 · 1프로세스",
|
||||
"name": "미미",
|
||||
"emoji": "🦞"
|
||||
},
|
||||
"ruki": {
|
||||
"status": "online",
|
||||
"version": "v0.11.0",
|
||||
"uptime": "01-17:52:56",
|
||||
"proc_count": 2,
|
||||
"engine": "hermes",
|
||||
"detail": "01-17:52:56 · 2프로세스",
|
||||
"name": "루키",
|
||||
"emoji": "🌱"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mainpc": {
|
||||
"name": "메인컴 (PS-i14700K)",
|
||||
"emoji": "🖥️",
|
||||
"spec": "Intel Core i7-14700K · 96GB · RTX 3090 24GB",
|
||||
"dns": "mainpc-wsl.pinksky.kr",
|
||||
"tailscale_ip": "100.105.122.120",
|
||||
"status": "online",
|
||||
"detail": "alive",
|
||||
"agents": {
|
||||
"pink": {
|
||||
"status": "online",
|
||||
"version": "v0.11.0",
|
||||
"uptime": "?",
|
||||
"proc_count": 2,
|
||||
"engine": "hermes",
|
||||
"detail": "2프로세스",
|
||||
"name": "분홍",
|
||||
"emoji": "🤖"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subpc": {
|
||||
"name": "보조컴 (MH-3900x)",
|
||||
"emoji": "🖥️",
|
||||
"spec": "AMD Ryzen 9 3900X · 32GB · RTX 3080 12GB",
|
||||
"dns": "subpc-wsl.pinksky.kr",
|
||||
"tailscale_ip": "100.124.61.85",
|
||||
"status": "online",
|
||||
"detail": "alive",
|
||||
"agents": {
|
||||
"sky": {
|
||||
"status": "online",
|
||||
"version": "v0.11.0",
|
||||
"uptime": "?",
|
||||
"proc_count": 1,
|
||||
"engine": "hermes",
|
||||
"detail": "1프로세스",
|
||||
"name": "하늘",
|
||||
"emoji": "🤖"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"minicity.kr": {
|
||||
"domain": {
|
||||
"status": "online",
|
||||
"detail": "집 NAS/공유기"
|
||||
},
|
||||
"pcs": {
|
||||
"nas_home": {
|
||||
"name": "집 NAS (mh-nas)",
|
||||
"emoji": "🗄️",
|
||||
"spec": "Synology DS418+",
|
||||
"dns": "minicity.kr",
|
||||
"tailscale_ip": "100.69.107.65",
|
||||
"status": "online",
|
||||
"detail": "alive",
|
||||
"agents": {}
|
||||
},
|
||||
"router_home": {
|
||||
"name": "집 공유기",
|
||||
"emoji": "📡",
|
||||
"spec": "아이피타임 AX8004BCM",
|
||||
"dns": "pinksky.iptime.org",
|
||||
"tailscale_ip": "",
|
||||
"status": "online",
|
||||
"detail": "DDNS · 원격관리 9090",
|
||||
"agents": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gwenc.kr": {
|
||||
"domain": {
|
||||
"status": "online",
|
||||
"detail": "tcp open"
|
||||
},
|
||||
"pcs": {
|
||||
"officepc": {
|
||||
"name": "서버컴 (gw-ps-5600)",
|
||||
"emoji": "🖥️",
|
||||
"spec": "Intel Core i7-14700K · 96GB · RTX 3090",
|
||||
"dns": "gwenc.kr",
|
||||
"tailscale_ip": "",
|
||||
"status": "online",
|
||||
"detail": "tcp open",
|
||||
"agents": {}
|
||||
},
|
||||
"nas_office": {
|
||||
"name": "회사 NAS (gwenc-nas2)",
|
||||
"emoji": "🗄️",
|
||||
"spec": "Synology",
|
||||
"dns": "",
|
||||
"tailscale_ip": "100.105.95.19",
|
||||
"status": "online",
|
||||
"detail": "",
|
||||
"agents": {}
|
||||
},
|
||||
"nas_backup": {
|
||||
"name": "백업 NAS (ps-bk-nas)",
|
||||
"emoji": "🗄️",
|
||||
"spec": "14TB HDD",
|
||||
"dns": "",
|
||||
"tailscale_ip": "100.83.176.55",
|
||||
"status": "online",
|
||||
"detail": "",
|
||||
"agents": {}
|
||||
},
|
||||
"router_office": {
|
||||
"name": "회사 공유기",
|
||||
"emoji": "📡",
|
||||
"spec": "TP-LINK AX18000",
|
||||
"dns": "gwenc.kr",
|
||||
"tailscale_ip": "",
|
||||
"status": "online",
|
||||
"detail": "원격관리 9000/9443",
|
||||
"agents": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"nas_storage": {
|
||||
"nas_home": {
|
||||
"name": "집 NAS (mh-nas)",
|
||||
"model": "Synology DS418+",
|
||||
"os": "DSM 7.2",
|
||||
"status": "online",
|
||||
"volumes": [
|
||||
{
|
||||
"mount": "/volume1",
|
||||
"label": "데이터",
|
||||
"total": "1.8T",
|
||||
"used": "833G",
|
||||
"free": "952G",
|
||||
"pct": "47%"
|
||||
},
|
||||
{
|
||||
"mount": "/volume2",
|
||||
"label": "백업",
|
||||
"total": "7.0T",
|
||||
"used": "6.0T",
|
||||
"free": "1020G",
|
||||
"pct": "86%"
|
||||
}
|
||||
],
|
||||
"detail": "alive"
|
||||
},
|
||||
"nas_office": {
|
||||
"name": "회사 NAS (gwenc-nas2)",
|
||||
"model": "Synology DS923+",
|
||||
"os": "DSM 7.2",
|
||||
"status": "online",
|
||||
"volumes": [
|
||||
{
|
||||
"mount": "/volume1",
|
||||
"label": "메인",
|
||||
"total": "21G",
|
||||
"used": "2.2G",
|
||||
"free": "19G",
|
||||
"pct": "11%"
|
||||
},
|
||||
{
|
||||
"mount": "/volume3",
|
||||
"label": "SSD캐시",
|
||||
"total": "493G",
|
||||
"used": "211G",
|
||||
"free": "283G",
|
||||
"pct": "43%"
|
||||
},
|
||||
{
|
||||
"mount": "/volume2",
|
||||
"label": "대용량스토리지",
|
||||
"total": "7.3T",
|
||||
"used": "250G",
|
||||
"free": "7.0T",
|
||||
"pct": "4%"
|
||||
}
|
||||
],
|
||||
"detail": "alive"
|
||||
},
|
||||
"nas_backup": {
|
||||
"name": "백업 NAS (ps-bk-nas)",
|
||||
"model": "Synology",
|
||||
"os": "DSM 7.x",
|
||||
"status": "online",
|
||||
"volumes": [
|
||||
{
|
||||
"mount": "/volume1",
|
||||
"label": "메인백업",
|
||||
"total": "52G",
|
||||
"used": "1.4G",
|
||||
"free": "51G",
|
||||
"pct": "3%"
|
||||
},
|
||||
{
|
||||
"mount": "/volume2",
|
||||
"label": "대용량백업",
|
||||
"total": "15T",
|
||||
"used": "5.3T",
|
||||
"free": "9.2T",
|
||||
"pct": "37%"
|
||||
}
|
||||
],
|
||||
"detail": "alive"
|
||||
}
|
||||
},
|
||||
"proxmox": {
|
||||
"host": {
|
||||
"name": "서버컴 (gw-ps-5600)",
|
||||
"model": "ASRock Z790M-ITX WiFi",
|
||||
"cpu": "Intel Core i7-14700K",
|
||||
"ram": "96GB",
|
||||
"os": "Windows 11 Pro + Hyper-V",
|
||||
"role": "Hyper-V 호스트",
|
||||
"status": "online",
|
||||
"detail": "tcp open"
|
||||
},
|
||||
"proxmox": {
|
||||
"name": "Proxmox VE",
|
||||
"version": "8.x",
|
||||
"os": "Debian 12 + Proxmox",
|
||||
"cpu": "8 vCPU",
|
||||
"ram": "32GB",
|
||||
"storage": "1TB SSD",
|
||||
"role": "Hyper-V 가상머신 · VM 호스팅",
|
||||
"status": "online",
|
||||
"detail": "tcp open"
|
||||
},
|
||||
"vms": [
|
||||
{
|
||||
"name": "정환용 업무컴",
|
||||
"host": "gwenc.kr",
|
||||
"port": 13389,
|
||||
"emoji": "🖥️",
|
||||
"status": "online",
|
||||
"detail": "tcp open"
|
||||
},
|
||||
{
|
||||
"name": "주영용 업무컴",
|
||||
"host": "gwenc.kr",
|
||||
"port": 23389,
|
||||
"emoji": "🖥️",
|
||||
"status": "online",
|
||||
"detail": "tcp open"
|
||||
},
|
||||
{
|
||||
"name": "임시용 업무컴",
|
||||
"host": "gwenc.kr",
|
||||
"port": 43348,
|
||||
"emoji": "🖥️",
|
||||
"status": "offline",
|
||||
"detail": "timeout"
|
||||
}
|
||||
]
|
||||
},
|
||||
"summary": {
|
||||
"total": 27,
|
||||
"online": 26,
|
||||
"offline": 1,
|
||||
"domain_total": 3,
|
||||
"domain_online": 3
|
||||
},
|
||||
"timestamp": "2026-05-05T21:13:48.223557+09:00",
|
||||
"timestamp_epoch": 1777983228
|
||||
}
|
||||
+1781
File diff suppressed because it is too large
Load Diff
+432
@@ -0,0 +1,432 @@
|
||||
#!/usr/bin/env python3
|
||||
"""작은도시 인프라 현황 v5 — 도메인 계층 + 에이전트/서브도메인 2탭"""
|
||||
import json, subprocess, re
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
STATUS_FILE = Path(__file__).parent / "health.json"
|
||||
|
||||
PC_SPECS = {
|
||||
"macmini": {"name": "맥미니 M4 Pro", "emoji": "🖥️", "spec": "Apple M4 Pro · 64GB · 통합 GPU", "dns": "pinksky.kr"},
|
||||
"mainpc": {"name": "메인컴 (PS-i14700K)", "emoji": "🖥️", "spec": "Intel Core i7-14700K · 96GB · RTX 3090 24GB", "dns": "mainpc-wsl.pinksky.kr"},
|
||||
"subpc": {"name": "보조컴 (MH-3900x)", "emoji": "🖥️", "spec": "AMD Ryzen 9 3900X · 32GB · RTX 3080 12GB", "dns": "subpc-wsl.pinksky.kr"},
|
||||
"officepc": {"name": "서버컴 (gw-ps-5600)", "emoji": "🖥️", "spec": "Intel Core i7-14700K · 96GB · RTX 3090", "dns": "gwenc.kr"},
|
||||
"nas_home": {"name": "집 NAS (mh-nas)", "emoji": "🗄️", "spec": "Synology DS418+", "dns": "minicity.kr"},
|
||||
"nas_office": {"name": "회사 NAS (gwenc-nas2)", "emoji": "🗄️", "spec": "Synology", "dns": ""},
|
||||
"nas_backup": {"name": "백업 NAS (ps-bk-nas)", "emoji": "🗄️", "spec": "14TB HDD", "dns": ""},
|
||||
"router_home": {"name": "집 공유기", "emoji": "📡", "spec": "아이피타임 AX8004BCM", "dns": "pinksky.iptime.org"},
|
||||
"router_apt": {"name": "아지트 공유기", "emoji": "📡", "spec": "TP-LINK Archer BE550", "dns": "pinksky.kr"},
|
||||
"router_office": {"name": "회사 공유기", "emoji": "📡", "spec": "TP-LINK AX18000", "dns": "gwenc.kr"},
|
||||
}
|
||||
|
||||
# ===== 기본 체크 함수 =====
|
||||
def http_check(url, timeout=5):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}:%{time_total}",
|
||||
url, "--max-time", str(timeout)],
|
||||
capture_output=True, text=True, timeout=timeout+2)
|
||||
parts = r.stdout.strip().split(":")
|
||||
code = parts[0] if parts else "000"
|
||||
latency = round(float(parts[1])*1000, 0) if len(parts) > 1 and parts[1] else None
|
||||
return {"status": "online" if code.startswith(("1","2","3","4")) else "error",
|
||||
"detail": f"HTTP {code}", "latency_ms": latency}
|
||||
except: return {"status": "offline", "detail": "timeout", "latency_ms": None}
|
||||
|
||||
def ssh_check(host, timeout=8):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
host, "echo alive"],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
alive = r.returncode == 0 and "alive" in r.stdout
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": "alive" if alive else r.stderr.strip()[:80]}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def tailscale_ping(host, timeout=8):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["tailscale", "ping", "--c", "3", host],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
out = r.stdout + r.stderr
|
||||
alive = "pong" in out or "is local" in out
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": "" if alive else "no pong"}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def tcp_port_check(host, port, timeout=5):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["nc", "-z", "-w", str(timeout), host, str(port)],
|
||||
capture_output=True, text=True, timeout=timeout+2)
|
||||
alive = r.returncode == 0
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": "tcp open" if alive else "no response"}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def get_local_ts_ip():
|
||||
try:
|
||||
r = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip()
|
||||
except: return "?"
|
||||
|
||||
def get_remote_ts(via):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", via, "tailscale ip -4"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
return r.stdout.strip() if r.returncode == 0 else "?"
|
||||
except: return "?"
|
||||
|
||||
def get_version(label):
|
||||
try:
|
||||
if label == "openclaw":
|
||||
r = subprocess.run(["openclaw", "--version"], capture_output=True, text=True, timeout=5)
|
||||
v = r.stdout.strip().replace("OpenClaw ", "")
|
||||
v = re.sub(r'\s+\([^)]+\)', '', v).strip()
|
||||
return v if v else "?"
|
||||
elif label == "hermes":
|
||||
r = subprocess.run([
|
||||
"/Users/pinksky/.hermes/hermes-agent/venv/bin/python",
|
||||
"-m", "hermes_cli.main", "--version"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
m = re.search(r'v[\d.]+', r.stdout.strip())
|
||||
return m.group(0) if m else "?"
|
||||
except: return "?"
|
||||
|
||||
def get_remote_hermes_version(via):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", via,
|
||||
"export PATH=\"$HOME/.local/bin:$PATH\"; hermes --version"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
m = re.search(r'v[\d.]+', r.stdout.strip())
|
||||
return m.group(0) if m else "?"
|
||||
except: return "?"
|
||||
|
||||
def proc_detail(pattern, engine_name="?"):
|
||||
result = {"status": "offline", "version": "?", "uptime": "?", "proc_count": 0,
|
||||
"engine": engine_name, "detail": "not running"}
|
||||
try:
|
||||
r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, text=True, timeout=5)
|
||||
pids = [p.strip() for p in r.stdout.strip().split("\n") if p.strip()]
|
||||
if pids:
|
||||
result["status"] = "online"
|
||||
result["proc_count"] = len(pids)
|
||||
try:
|
||||
u = subprocess.run(["ps", "-p", pids[0], "-o", "etime="],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
result["uptime"] = u.stdout.strip()
|
||||
except: pass
|
||||
result["detail"] = f"{result['uptime']} · {result['proc_count']}프로세스"
|
||||
except: pass
|
||||
return result
|
||||
|
||||
def remote_proc(via, pattern, engine_name="?"):
|
||||
result = {"status": "offline", "version": "?", "uptime": "?", "proc_count": 0,
|
||||
"engine": engine_name, "detail": "not running"}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
via, f"ps aux | grep '{pattern}' | grep -v grep || true"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
|
||||
if lines:
|
||||
result["status"] = "online"
|
||||
result["proc_count"] = len(lines)
|
||||
result["detail"] = f"{result['proc_count']}프로세스"
|
||||
except: pass
|
||||
return result
|
||||
|
||||
# --- NAS storage info ---
|
||||
NAS_CONFIGS = {
|
||||
"nas_home": {
|
||||
"name": "집 NAS (mh-nas)", "model": "Synology DS418+", "os": "DSM 7.2",
|
||||
"ssh_host": "mh-nas",
|
||||
"volumes": [{"mount": "/volume1", "label": "데이터"}, {"mount": "/volume2", "label": "백업"}]
|
||||
},
|
||||
"nas_office": {
|
||||
"name": "회사 NAS (gwenc-nas2)", "model": "Synology DS923+", "os": "DSM 7.2",
|
||||
"ssh_host": "100.105.95.19",
|
||||
"ssh_port": 55,
|
||||
"ssh_user": "psbot",
|
||||
"ssh_pass": "@@Mini3388",
|
||||
"volumes": [{"mount": "/volume1", "label": "메인"}, {"mount": "/volume3", "label": "SSD캐시"}, {"mount": "/volume2", "label": "대용량스토리지"}]
|
||||
},
|
||||
"nas_backup": {
|
||||
"name": "백업 NAS (ps-bk-nas)", "model": "Synology", "os": "DSM 7.x",
|
||||
"ssh_host": "100.83.176.55",
|
||||
"ssh_port": 55,
|
||||
"ssh_user": "psbot",
|
||||
"ssh_pass": "@@Mini3388",
|
||||
"volumes": [{"mount": "/volume1", "label": "메인백업"}, {"mount": "/volume2", "label": "대용량백업"}]
|
||||
}
|
||||
}
|
||||
|
||||
def get_nas_storage(nas_key):
|
||||
cfg = NAS_CONFIGS[nas_key]
|
||||
result = {"name": cfg["name"], "model": cfg["model"], "os": cfg["os"],
|
||||
"status": "offline", "volumes": [], "detail": "not checked"}
|
||||
host = cfg.get("ssh_host")
|
||||
if not host:
|
||||
return result
|
||||
port = cfg.get("ssh_port", 55)
|
||||
user = cfg.get("ssh_user", "psbot")
|
||||
passwd = cfg.get("ssh_pass", "")
|
||||
try:
|
||||
mounts = " ".join(v["mount"] for v in cfg["volumes"])
|
||||
ssh_cmd = f"ssh -p {port} -o ConnectTimeout=5 -o StrictHostKeyChecking=no {user}@{host} 'df -h {mounts}'"
|
||||
if passwd:
|
||||
full_cmd = f"sshpass -p {passwd!r} {ssh_cmd}"
|
||||
else:
|
||||
full_cmd = ssh_cmd
|
||||
r = subprocess.run(full_cmd, shell=True, capture_output=True, text=True, timeout=10)
|
||||
if r.returncode != 0:
|
||||
result["detail"] = r.stderr.strip()[:80]
|
||||
return result
|
||||
result["status"] = "online"
|
||||
result["detail"] = "alive"
|
||||
volumes = []
|
||||
for line in r.stdout.strip().split("\n")[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 6:
|
||||
mount = parts[5]
|
||||
label = mount
|
||||
for v in cfg["volumes"]:
|
||||
if v["mount"] == mount:
|
||||
label = v["label"]
|
||||
break
|
||||
volumes.append({
|
||||
"mount": mount, "label": label,
|
||||
"total": parts[1], "used": parts[2],
|
||||
"free": parts[3], "pct": parts[4]
|
||||
})
|
||||
result["volumes"] = volumes
|
||||
except Exception as e:
|
||||
result["detail"] = str(e)[:80]
|
||||
return result
|
||||
|
||||
# --- Proxmox VM info (서버컴 Hyper-V 위) ---
|
||||
PROXMOX_CFG = {
|
||||
"host": {
|
||||
"name": "서버컴 (gw-ps-5600)", "model": "ASRock Z790M-ITX WiFi",
|
||||
"cpu": "Intel Core i7-14700K", "ram": "96GB",
|
||||
"os": "Windows 11 Pro + Hyper-V", "role": "Hyper-V 호스트"
|
||||
},
|
||||
"proxmox": {
|
||||
"name": "Proxmox VE", "version": "8.x",
|
||||
"os": "Debian 12 + Proxmox",
|
||||
"cpu": "8 vCPU", "ram": "32GB", "storage": "1TB SSD",
|
||||
"role": "Hyper-V 가상머신 · VM 호스팅"
|
||||
},
|
||||
"vms": [
|
||||
{"name": "정환용 업무컴", "host": "gwenc.kr", "port": 13389, "emoji": "🖥️"},
|
||||
{"name": "주영용 업무컴", "host": "gwenc.kr", "port": 23389, "emoji": "🖥️"},
|
||||
{"name": "임시용 업무컴", "host": "gwenc.kr", "port": 43348, "emoji": "🖥️"},
|
||||
]
|
||||
}
|
||||
|
||||
# ===== 메인 =====
|
||||
def run_checks():
|
||||
ts = datetime.now(KST)
|
||||
ts_ip = get_local_ts_ip()
|
||||
|
||||
# --- PC checks ---
|
||||
mainpc_ssh = ssh_check("mainpc-win", 6)
|
||||
mainpc_ts = get_remote_ts("mainpc-wsl")
|
||||
subpc_ssh = ssh_check("subpc-wsl", 6)
|
||||
subpc_ts = get_remote_ts("subpc-wsl")
|
||||
office_pc = tcp_port_check("gwenc.kr", 33389, 5)
|
||||
|
||||
# --- NAS checks ---
|
||||
nas_mh = ssh_check("mh-nas", 6)
|
||||
nas_gwenc2 = tailscale_ping("gwenc-nas2", 6)
|
||||
nas_bk = tailscale_ping("ps-bk-nas", 6)
|
||||
|
||||
# --- NAS storage info ---
|
||||
nas_storage = {}
|
||||
for nk in NAS_CONFIGS:
|
||||
nas_storage[nk] = get_nas_storage(nk)
|
||||
|
||||
# --- Proxmox host check (TCP RDP 포트) ---
|
||||
proxmox_host = tcp_port_check("gwenc.kr", 33389, 5)
|
||||
# --- Proxmox VE Web UI check ---
|
||||
proxmox_ve = tcp_port_check("gwenc.kr", 18006, 5)
|
||||
# --- VM RDP checks ---
|
||||
vm_results = []
|
||||
for vm in PROXMOX_CFG.get("vms", []):
|
||||
vm_results.append({**vm, **tcp_port_check(vm["host"], vm["port"], 5)})
|
||||
|
||||
# --- Agent checks ---
|
||||
mimi = proc_detail("openclaw", "openclaw")
|
||||
mimi["name"] = "미미"
|
||||
mimi["emoji"] = "🦞"
|
||||
mimi["version"] = get_version("openclaw")
|
||||
|
||||
ruki = proc_detail("hermes", "hermes")
|
||||
ruki["name"] = "루키"
|
||||
ruki["emoji"] = "🌱"
|
||||
ruki["version"] = get_version("hermes")
|
||||
|
||||
pink = remote_proc("mainpc-wsl", "hermes", "hermes")
|
||||
pink["name"] = "분홍"
|
||||
pink["emoji"] = "🤖"
|
||||
pink["version"] = get_remote_hermes_version("mainpc-wsl")
|
||||
|
||||
sky = remote_proc("subpc-wsl", "hermes", "hermes")
|
||||
sky["name"] = "하늘"
|
||||
sky["emoji"] = "🤖"
|
||||
sky["version"] = get_remote_hermes_version("subpc-wsl")
|
||||
|
||||
# --- Subdomains ---
|
||||
subdomains = [
|
||||
{"name": "wiki.pinksky.kr", "url": "https://wiki.pinksky.kr/", "group": "pinksky.kr", "desc": "MiniCITY 지식정원"},
|
||||
{"name": "openclaw.pinksky.kr", "url": "https://openclaw.pinksky.kr/", "group": "pinksky.kr", "desc": "OpenClaw 대시보드"},
|
||||
{"name": "agent.pinksky.kr", "url": "https://agent.pinksky.kr/", "group": "pinksky.kr", "desc": "인프라 현황판"},
|
||||
{"name": "search.pinksky.kr", "url": "https://search.pinksky.kr/search?q=test", "group": "pinksky.kr", "desc": "SearXNG 메타서치"},
|
||||
{"name": "erp.pinksky.kr", "url": "https://erp.pinksky.kr/", "group": "pinksky.kr", "desc": "공사관리 ERP"},
|
||||
{"name": "dify.pinksky.kr", "url": "https://dify.pinksky.kr/", "group": "pinksky.kr", "desc": "Dify 워크플로우"},
|
||||
{"name": "photo.pinksky.kr", "url": "https://photo.pinksky.kr/", "group": "pinksky.kr", "desc": "PhotoVault 자료정리"},
|
||||
{"name": "git.pinksky.kr", "url": "https://git.pinksky.kr/", "group": "pinksky.kr", "desc": "Gitea Git 서버"},
|
||||
{"name": "find.pinksky.kr", "url": "https://find.pinksky.kr/", "group": "pinksky.kr", "desc": "Everything 검색"},
|
||||
{"name": "gwenc.kr", "url": "https://gwenc.kr/", "group": "gwenc.kr", "desc": "회사 메인 사이트"},
|
||||
{"name": "minicity.kr", "url": "https://minicity.kr/", "group": "minicity.kr", "desc": "집 NAS 웹 UI"},
|
||||
]
|
||||
sub_results = []
|
||||
for s in subdomains:
|
||||
chk = http_check(s["url"], 4)
|
||||
sub_results.append({
|
||||
"name": s["name"], "group": s["group"], "desc": s["desc"],
|
||||
"status": chk["status"], "detail": chk["detail"], "latency_ms": chk.get("latency_ms")
|
||||
})
|
||||
|
||||
# --- Domain hierarchy ---
|
||||
domains = {
|
||||
"pinksky.kr": {
|
||||
"domain": {"status": "online", "detail": f"{ts_ip}"},
|
||||
"pcs": {
|
||||
"macmini": {
|
||||
**PC_SPECS["macmini"],
|
||||
"tailscale_ip": ts_ip,
|
||||
"status": "online", "detail": "",
|
||||
"agents": {
|
||||
"mimi": {**mimi},
|
||||
"ruki": {**ruki},
|
||||
}
|
||||
},
|
||||
"mainpc": {
|
||||
**PC_SPECS["mainpc"],
|
||||
"tailscale_ip": mainpc_ts,
|
||||
"status": mainpc_ssh["status"], "detail": mainpc_ssh["detail"],
|
||||
"agents": {
|
||||
"pink": {**pink},
|
||||
}
|
||||
},
|
||||
"subpc": {
|
||||
**PC_SPECS["subpc"],
|
||||
"tailscale_ip": subpc_ts,
|
||||
"status": subpc_ssh["status"], "detail": subpc_ssh["detail"],
|
||||
"agents": {
|
||||
"sky": {**sky},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"minicity.kr": {
|
||||
"domain": {"status": "online", "detail": "집 NAS/공유기"},
|
||||
"pcs": {
|
||||
"nas_home": {
|
||||
**PC_SPECS["nas_home"],
|
||||
"tailscale_ip": "100.69.107.65",
|
||||
"status": nas_mh["status"], "detail": nas_mh["detail"],
|
||||
"agents": {}
|
||||
},
|
||||
"router_home": {
|
||||
**PC_SPECS["router_home"],
|
||||
"tailscale_ip": "",
|
||||
"status": "online", "detail": "DDNS · 원격관리 9090",
|
||||
"agents": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gwenc.kr": {
|
||||
"domain": {"status": office_pc["status"], "detail": office_pc["detail"]},
|
||||
"pcs": {
|
||||
"officepc": {
|
||||
**PC_SPECS["officepc"],
|
||||
"tailscale_ip": "",
|
||||
"status": office_pc["status"], "detail": office_pc["detail"],
|
||||
"agents": {}
|
||||
},
|
||||
"nas_office": {
|
||||
**PC_SPECS["nas_office"],
|
||||
"tailscale_ip": "100.105.95.19",
|
||||
"status": nas_gwenc2["status"], "detail": nas_gwenc2["detail"],
|
||||
"agents": {}
|
||||
},
|
||||
"nas_backup": {
|
||||
**PC_SPECS["nas_backup"],
|
||||
"tailscale_ip": "100.83.176.55",
|
||||
"status": nas_bk["status"], "detail": nas_bk["detail"],
|
||||
"agents": {}
|
||||
},
|
||||
"router_office": {
|
||||
**PC_SPECS["router_office"],
|
||||
"tailscale_ip": "",
|
||||
"status": "online", "detail": "원격관리 9000/9443",
|
||||
"agents": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Summary ---
|
||||
all_items = []
|
||||
for d in domains.values():
|
||||
for pc in d["pcs"].values():
|
||||
all_items.append(pc)
|
||||
for a in pc.get("agents", {}).values():
|
||||
all_items.append(a)
|
||||
for s in sub_results:
|
||||
all_items.append(s)
|
||||
for vm in vm_results:
|
||||
all_items.append(vm)
|
||||
|
||||
online = sum(1 for x in all_items if x["status"] == "online")
|
||||
offline = sum(1 for x in all_items if x["status"] == "offline")
|
||||
|
||||
data = {
|
||||
"subdomains": sub_results,
|
||||
"domains": domains,
|
||||
"nas_storage": nas_storage,
|
||||
"proxmox": {
|
||||
"host": {
|
||||
**PROXMOX_CFG["host"],
|
||||
"status": proxmox_host["status"],
|
||||
"detail": proxmox_host["detail"]
|
||||
},
|
||||
"proxmox": {
|
||||
**PROXMOX_CFG["proxmox"],
|
||||
"status": proxmox_ve["status"],
|
||||
"detail": proxmox_ve["detail"]
|
||||
},
|
||||
"vms": vm_results
|
||||
},
|
||||
"summary": {
|
||||
"total": len(all_items),
|
||||
"online": online,
|
||||
"offline": offline,
|
||||
"domain_total": 3,
|
||||
"domain_online": sum(1 for d in domains.values() if d["domain"]["status"] == "online")
|
||||
},
|
||||
"timestamp": ts.isoformat(),
|
||||
"timestamp_epoch": int(ts.timestamp())
|
||||
}
|
||||
|
||||
STATUS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
print(f"[{ts.strftime('%H:%M:%S')}] Online {online}/{len(all_items)} · Domains {data['summary']['domain_online']}/3")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_checks()
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Agent Status Health Checker v6 — 메모리/VRAM 실시간 수집 + 사양/박스 구조"""
|
||||
import json, subprocess, time, re
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
STATUS_FILE = Path(__file__).parent / "health.json"
|
||||
|
||||
# ===== 정적 사양 정보 =====
|
||||
PC_SPECS = {
|
||||
"macmini": "M4 Pro · 64GB · 통합 GPU",
|
||||
"mainpc": "Intel Core i7-14700K · 96GB · RTX 3090 24GB",
|
||||
"subpc": "AMD Ryzen 9 3900X · 32GB · RTX 3080 12GB",
|
||||
}
|
||||
|
||||
# ===== 기본 체크 함수 =====
|
||||
def http_check(url, timeout=5):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}:%{time_total}",
|
||||
url, "--max-time", str(timeout)],
|
||||
capture_output=True, text=True, timeout=timeout+2)
|
||||
parts = r.stdout.strip().split(":")
|
||||
code = parts[0] if parts else "000"
|
||||
return {"status": "online" if code.startswith(("2","3")) else "error",
|
||||
"detail": f"HTTP {code}"}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def ssh_check(host, timeout=8):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
host, "echo alive"],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
alive = r.returncode == 0 and "alive" in r.stdout
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": "alive" if alive else r.stderr.strip()[:80]}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def tailscale_ping(host, timeout=8):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["tailscale", "ping", "--until", "3s", "--c", "1", host],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
alive = "pong" in r.stdout or "is local" in r.stdout
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": "" if alive else "no pong"}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def proc_check(process, via=None):
|
||||
try:
|
||||
if via:
|
||||
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
via, f"pgrep -f '{process}' || echo NOT_FOUND"]
|
||||
else:
|
||||
cmd = ["pgrep", "-f", process]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=8)
|
||||
out = r.stdout.strip()
|
||||
alive = out not in ["NOT_FOUND", ""] and r.returncode == 0
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": f"PID {out[:20]}" if alive else "not running"}
|
||||
except: return {"status": "offline", "detail": "error"}
|
||||
|
||||
# ===== 상태 수집 =====
|
||||
def get_local_tailscale_ip():
|
||||
try:
|
||||
r = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip()
|
||||
except: return "?"
|
||||
|
||||
def get_remote_tailscale_ip(via):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
via, "tailscale ip -4"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
return r.stdout.strip() if r.returncode == 0 else "?"
|
||||
except: return "?"
|
||||
|
||||
def get_version(label):
|
||||
try:
|
||||
if label == "openclaw":
|
||||
r = subprocess.run(["openclaw", "--version"], capture_output=True, text=True, timeout=5)
|
||||
v = r.stdout.strip().replace("OpenClaw ", "")
|
||||
v = re.sub(r'\s+\([^)]+\)', '', v).strip()
|
||||
return v if v else "?"
|
||||
elif label == "hermes":
|
||||
r = subprocess.run([
|
||||
"/Users/pinksky/.hermes/hermes-agent/venv/bin/python",
|
||||
"-m", "hermes_cli.main", "--version"
|
||||
], capture_output=True, text=True, timeout=5)
|
||||
m = re.search(r'v[\d.]+', r.stdout.strip())
|
||||
return m.group(0) if m else "?"
|
||||
except: return "?"
|
||||
|
||||
def get_uptime(pid_str):
|
||||
try:
|
||||
r = subprocess.run(["ps", "-p", pid_str, "-o", "etime="], capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip()
|
||||
except: return "?"
|
||||
|
||||
def get_local_macos_memory():
|
||||
try:
|
||||
r = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5)
|
||||
total_gb = round(int(r.stdout.strip()) / (1024**3), 1)
|
||||
r = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5)
|
||||
lines = r.stdout.strip().split("\n")
|
||||
page_size = 16384
|
||||
total_pages = 0
|
||||
for line in lines:
|
||||
m = re.search(r'Pages (\w+):\s+([0-9,]+)', line)
|
||||
if m:
|
||||
key, val = m.group(1).lower(), int(m.group(2).replace(",",""))
|
||||
if key in ("active","inactive","wired","compressed"):
|
||||
total_pages += val
|
||||
used_gb = round(total_pages * page_size / (1024**3), 1)
|
||||
return f"{used_gb}GB/{total_gb}GB"
|
||||
except: return "?.?"
|
||||
|
||||
def get_remote_memory(via):
|
||||
result = {"sys": "?", "gpu": None}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
via, "free -m | awk '/Mem:/ {printf \"%.1f/%.1f\", ($2-$7)/1024, \$2/1024}'"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
out = r.stdout.strip()
|
||||
if out and r.returncode == 0:
|
||||
result["sys"] = f"{out}GB"
|
||||
except: pass
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
via, "nvidia-smi --query-gpu=memory.used,memory.total --format=csv,noheader,nounits"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
out = r.stdout.strip()
|
||||
if out and r.returncode == 0:
|
||||
parts = [x.strip() for x in out.split(",")]
|
||||
if len(parts) >= 2:
|
||||
u, t = float(parts[0]), float(parts[1])
|
||||
result["gpu"] = f"VRAM {round(u/1024,1)}GB/{round(t/1024,1)}GB"
|
||||
except: pass
|
||||
return result
|
||||
|
||||
def proc_details(name_pattern, version_label=None, engine_name="?", via=None):
|
||||
result = {"status": "offline", "version": "?", "uptime": "?", "proc_count": 0,
|
||||
"engine": engine_name, "detail": "not running", "mem": None, "gpu": None}
|
||||
try:
|
||||
if via:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
via, f"ps aux | grep '{name_pattern}' | grep -v grep | grep -v health_check || true"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
|
||||
if lines:
|
||||
result["status"] = "online"
|
||||
result["proc_count"] = len(lines)
|
||||
if version_label:
|
||||
result["version"] = "?" # 원격 버전 체크는 복잡하므로 여기서는 생략
|
||||
mem_data = get_remote_memory(via)
|
||||
result["mem"] = mem_data["sys"]
|
||||
result["gpu"] = mem_data["gpu"]
|
||||
result["detail"] = f"{result['proc_count']}프로세스"
|
||||
else:
|
||||
r = subprocess.run(["ps", "aux"], capture_output=True, text=True, timeout=5)
|
||||
lines = [l for l in r.stdout.strip().split("\n") if name_pattern in l and "grep" not in l and "health_check" not in l]
|
||||
if lines:
|
||||
result["status"] = "online"
|
||||
result["proc_count"] = len(lines)
|
||||
first = lines[0].strip().split(None, 2)
|
||||
if len(first) >= 2:
|
||||
result["uptime"] = get_uptime(first[1])
|
||||
if version_label:
|
||||
result["version"] = get_version(version_label)
|
||||
result["mem"] = get_local_macos_memory()
|
||||
result["detail"] = f"{result['uptime']} · {result['proc_count']}프로세스"
|
||||
except: pass
|
||||
return result
|
||||
|
||||
# ===== 메인 =====
|
||||
def run_checks():
|
||||
ts = datetime.now(KST)
|
||||
ts_ip = get_local_tailscale_ip()
|
||||
|
||||
# ===== pinksky.kr (아지트) =====
|
||||
pinksky_domain = http_check("https://pinksky.kr/")
|
||||
macmini_detail = f"{ts_ip} · pinksky.kr"
|
||||
mainpc_ts = get_remote_tailscale_ip("mainpc-wsl")
|
||||
|
||||
mimi = proc_details("openclaw-gateway", "openclaw", "openclaw")
|
||||
ruki = proc_details("hermes", "hermes", "hermes")
|
||||
mainpc = ssh_check("mainpc-win", 6)
|
||||
pink_hermes = proc_details("hermes", engine_name="hermes", via="mainpc-wsl")
|
||||
|
||||
pinksky = {
|
||||
"domain": pinksky_domain,
|
||||
"pcs": {
|
||||
"macmini": {
|
||||
"name": "맥미니 M4 Pro", "emoji": "🖥️",
|
||||
"tailscale_ip": ts_ip, "dns": "pinksky.kr",
|
||||
"status": "online", "detail": macmini_detail,
|
||||
"spec": PC_SPECS["macmini"],
|
||||
"agents": {
|
||||
"mimi": {
|
||||
"name": "미미", "emoji": "🦞",
|
||||
**{k:v for k,v in mimi.items() if k not in ("status","detail")},
|
||||
"status": mimi["status"], "detail": mimi["detail"]
|
||||
},
|
||||
"ruki": {
|
||||
"name": "루키", "emoji": "🌱",
|
||||
**{k:v for k,v in ruki.items() if k not in ("status","detail")},
|
||||
"status": ruki["status"], "detail": ruki["detail"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"mainpc": {
|
||||
"name": "메인컴", "emoji": "🖥️",
|
||||
"tailscale_ip": mainpc_ts, "dns": "mainpc-wsl.pinksky.kr",
|
||||
**mainpc,
|
||||
"spec": PC_SPECS["mainpc"],
|
||||
"agents": {
|
||||
"pink_hermes": {
|
||||
"name": "분홍", "emoji": "🤖",
|
||||
**{k:v for k,v in pink_hermes.items() if k not in ("status","detail")},
|
||||
"status": pink_hermes["status"], "detail": pink_hermes["detail"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ===== minicity.kr (집 NAS) =====
|
||||
minicity_domain = http_check("https://minicity.kr/")
|
||||
nas_pc = ssh_check("mh-nas", 6)
|
||||
|
||||
minicity = {
|
||||
"domain": minicity_domain,
|
||||
"pcs": {
|
||||
"nas": {
|
||||
"name": "NAS (minicity)", "emoji": "🗄️",
|
||||
**nas_pc, "agents": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ===== pinksky.iptime.org (집 DDNS / 보조컴) =====
|
||||
subpc_ts = get_remote_tailscale_ip("subpc-wsl")
|
||||
subpc = ssh_check("subpc-wsl", 6)
|
||||
sky_hermes = proc_details("hermes", engine_name="hermes", via="subpc-wsl")
|
||||
|
||||
iptime = {
|
||||
"domain": {"status": "online" if subpc["status"] == "online" else "error", "detail": "DDNS"},
|
||||
"pcs": {
|
||||
"subpc": {
|
||||
"name": "보조컴", "emoji": "🖥️",
|
||||
"tailscale_ip": subpc_ts, "dns": "subpc-wsl.pinksky.kr",
|
||||
**subpc,
|
||||
"spec": PC_SPECS["subpc"],
|
||||
"agents": {
|
||||
"sky_hermes": {
|
||||
"name": "하늘", "emoji": "🤖",
|
||||
**{k:v for k,v in sky_hermes.items() if k not in ("status","detail")},
|
||||
"status": sky_hermes["status"], "detail": sky_hermes["detail"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ===== gwenc.kr (회사) =====
|
||||
gwenc_domain = http_check("https://gwenc.kr/")
|
||||
server5600 = tailscale_ping("gw-ps-5600", 6)
|
||||
proxmox = tailscale_ping("proxmox-minicity", 6)
|
||||
office_nas = tailscale_ping("gwenc-nas2", 6)
|
||||
backup_nas = tailscale_ping("ps-bk-nas", 6)
|
||||
office_pc = tailscale_ping("ps-i14700k-win", 6)
|
||||
office_pc_wsl = tailscale_ping("ps-i14700k-wsl", 6)
|
||||
|
||||
gwenc = {
|
||||
"domain": gwenc_domain,
|
||||
"pcs": {
|
||||
"server5600": {"name": "서버컴 5600", "emoji": "🖥️", **server5600, "agents": {}},
|
||||
"proxmox": {"name": "Proxmox", "emoji": "🔶", **proxmox, "agents": {}},
|
||||
"office_nas": {"name": "회사 NAS", "emoji": "🗄️", **office_nas, "agents": {}},
|
||||
"backup_nas": {"name": "백업 NAS", "emoji": "🗄️", **backup_nas, "agents": {}},
|
||||
"office_pc": {
|
||||
"name": "회사 메인PC", "emoji": "🖥️", **office_pc,
|
||||
"agents": {
|
||||
"office_pc_wsl": {"name": "회사 메인PC WSL", "emoji": "🖥️", **office_pc_wsl}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ===== 서브도메인 =====
|
||||
subdomains = [
|
||||
{"name": "wiki.pinksky.kr", "url": "https://wiki.pinksky.kr/", "group": "pinksky.kr", "desc": "MiniCITY 지식정원"},
|
||||
{"name": "openclaw.pinksky.kr", "url": "https://openclaw.pinksky.kr/", "group": "pinksky.kr", "desc": "OpenClaw 대시보드"},
|
||||
{"name": "agent.pinksky.kr", "url": "https://agent.pinksky.kr/", "group": "pinksky.kr", "desc": "에이전트 현황판"},
|
||||
{"name": "search.pinksky.kr", "url": "https://search.pinksky.kr/search?q=test", "group": "pinksky.kr", "desc": "SearXNG 메타서치"},
|
||||
{"name": "erp.pinksky.kr", "url": "https://erp.pinksky.kr/", "group": "pinksky.kr", "desc": "공사관리 ERP"},
|
||||
{"name": "dify.pinksky.kr", "url": "https://dify.pinksky.kr:8443/", "group": "pinksky.kr", "desc": "Dify 워크플로우"},
|
||||
{"name": "gwenc.kr", "url": "https://gwenc.kr/", "group": "gwenc.kr", "desc": "회사 메인 사이트"},
|
||||
{"name": "minicity.kr", "url": "https://minicity.kr/", "group": "minicity.kr", "desc": "집 NAS 웹 UI"},
|
||||
]
|
||||
subdomain_results = [{"name": sd["name"], **http_check(sd["url"], 4), "group": sd["group"], "desc": sd["desc"]}
|
||||
for sd in subdomains]
|
||||
|
||||
# ===== 요약 =====
|
||||
all_domains = {"pinksky.kr": pinksky, "minicity.kr": minicity,
|
||||
"pinksky.iptime.org": iptime, "gwenc.kr": gwenc}
|
||||
all_pcs = []
|
||||
for d in all_domains.values():
|
||||
for pc in d["pcs"].values():
|
||||
all_pcs.append(pc)
|
||||
all_pcs.extend(pc["agents"].values())
|
||||
|
||||
online = sum(1 for p in all_pcs if p["status"] == "online")
|
||||
total = len(all_pcs)
|
||||
domain_online = sum(1 for d in all_domains.values() if d["domain"]["status"] == "online")
|
||||
|
||||
data = {
|
||||
"subdomains": subdomain_results,
|
||||
"domains": all_domains,
|
||||
"summary": {
|
||||
"total": total, "online": online, "offline": total - online,
|
||||
"domain_total": len(all_domains), "domain_online": domain_online
|
||||
},
|
||||
"timestamp": ts.isoformat(),
|
||||
"timestamp_epoch": int(ts.timestamp())
|
||||
}
|
||||
|
||||
STATUS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
print(f"[{ts.strftime('%H:%M:%S')}] 온라인 {online}/{total} | 도메인 {domain_online}/{len(all_domains)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_checks()
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""작은도시 인프라 현황 — PC / NAS / 공유기 / 에이전트 / 도메인 5탭"""
|
||||
import json, subprocess, re
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
STATUS_FILE = Path(__file__).parent / "health.json"
|
||||
|
||||
# ===== 기본 체크 함수 =====
|
||||
def http_check(url, timeout=5):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}:%{time_total}",
|
||||
url, "--max-time", str(timeout)],
|
||||
capture_output=True, text=True, timeout=timeout+2)
|
||||
parts = r.stdout.strip().split(":")
|
||||
code = parts[0] if parts else "000"
|
||||
return {"status": "online" if code.startswith(("2","3")) else "error",
|
||||
"detail": f"HTTP {code}",
|
||||
"latency_ms": round(float(parts[1])*1000,0) if len(parts)>1 and parts[1] else None}
|
||||
except: return {"status": "offline", "detail": "timeout", "latency_ms": None}
|
||||
|
||||
def ssh_check(host, timeout=8):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
host, "echo alive"],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
alive = r.returncode == 0 and "alive" in r.stdout
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": "alive" if alive else r.stderr.strip()[:80]}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def tailscale_ping(host, timeout=8):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["tailscale", "ping", "--c", "3", host],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
out = r.stdout + r.stderr
|
||||
alive = "pong" in out or "is local" in out
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": "" if alive else "no pong"}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def proc_check(name_pattern, engine_name="?"):
|
||||
result = {"status": "offline", "version": "?", "uptime": "?", "proc_count": 0,
|
||||
"engine": engine_name, "detail": "not running", "mem": None}
|
||||
try:
|
||||
r = subprocess.run(["pgrep", "-f", name_pattern], capture_output=True, text=True, timeout=5)
|
||||
pids = [p.strip() for p in r.stdout.strip().split("\n") if p.strip()]
|
||||
if pids:
|
||||
result["status"] = "online"
|
||||
result["proc_count"] = len(pids)
|
||||
try:
|
||||
u = subprocess.run(["ps", "-p", pids[0], "-o", "etime="], capture_output=True, text=True, timeout=5)
|
||||
result["uptime"] = u.stdout.strip()
|
||||
except: pass
|
||||
result["detail"] = f"{result['uptime']} · {result['proc_count']}프로세스"
|
||||
except: pass
|
||||
return result
|
||||
|
||||
def remote_proc(via, name_pattern, engine_name="?"):
|
||||
result = {"status": "offline", "version": "?", "uptime": "?", "proc_count": 0,
|
||||
"engine": engine_name, "detail": "not running", "mem": None}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", via,
|
||||
f"ps aux | grep '{name_pattern}' | grep -v grep || true"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
|
||||
if lines:
|
||||
result["status"] = "online"
|
||||
result["proc_count"] = len(lines)
|
||||
result["detail"] = f"{result['proc_count']}프로세스"
|
||||
except: pass
|
||||
return result
|
||||
|
||||
# ===== 버전/TS IP =====
|
||||
def get_local_ts_ip():
|
||||
try:
|
||||
r = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip()
|
||||
except: return "?"
|
||||
|
||||
def get_remote_ts(via):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", via, "tailscale ip -4"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
return r.stdout.strip() if r.returncode == 0 else "?"
|
||||
except: return "?"
|
||||
|
||||
def get_local_version(label):
|
||||
try:
|
||||
if label == "openclaw":
|
||||
r = subprocess.run(["openclaw", "--version"], capture_output=True, text=True, timeout=5)
|
||||
v = r.stdout.strip().replace("OpenClaw ", "")
|
||||
v = re.sub(r'\s+\([^)]+\)', '', v).strip()
|
||||
return v if v else "?"
|
||||
elif label == "hermes":
|
||||
r = subprocess.run(["/Users/pinksky/.hermes/hermes-agent/venv/bin/python","-m","hermes_cli.main","--version"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
m = re.search(r'v[\d.]+', r.stdout.strip())
|
||||
return m.group(0) if m else "?"
|
||||
except: return "?"
|
||||
|
||||
def get_remote_hermes_version(via):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", via,
|
||||
"export PATH=\"$HOME/.local/bin:$PATH\"; hermes --version"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
m = re.search(r'v[\d.]+', r.stdout.strip())
|
||||
return m.group(0) if m else "?"
|
||||
except: return "?"
|
||||
|
||||
# ===== 메인 =====
|
||||
def run_checks():
|
||||
ts = datetime.now(KST)
|
||||
ts_ip = get_local_ts_ip()
|
||||
|
||||
# --- PC ---
|
||||
mainpc_ssh = ssh_check("mainpc-win", 6)
|
||||
mainpc_ts = get_remote_ts("mainpc-wsl")
|
||||
subpc_ssh = ssh_check("subpc-wsl", 6)
|
||||
subpc_ts = get_remote_ts("subpc-wsl")
|
||||
office_pc = tailscale_ping("office-pc", 6)
|
||||
|
||||
pcs = [
|
||||
{"name": "맥미니 M4 Pro", "emoji": "🖥️", "spec": "Apple M4 Pro · 64GB · 통합 GPU",
|
||||
"net": f"{ts_ip} · pinksky.kr", "status": "online", "detail": ""},
|
||||
{"name": "메인컴 (PS-i14700K)", "emoji": "🖥️", "spec": "Intel Core i7-14700K · 96GB · RTX 3090 24GB",
|
||||
"net": f"{mainpc_ts} · mainpc-wsl.pinksky.kr", **mainpc_ssh},
|
||||
{"name": "보조컴 (MH-3900x)", "emoji": "🖥️", "spec": "AMD Ryzen 9 3900X · 32GB · RTX 3080 12GB",
|
||||
"net": f"{subpc_ts} · subpc-wsl.pinksky.kr", **subpc_ssh},
|
||||
{"name": "회사 메인PC (PS-i14700K)", "emoji": "🖥️", "spec": "Intel Core i7-14700K · 96GB · RTX 3090",
|
||||
"net": "", **office_pc},
|
||||
]
|
||||
|
||||
# --- NAS ---
|
||||
nas_mh = ssh_check("mh-nas", 6)
|
||||
nas_gwenc2 = tailscale_ping("gwenc-nas2", 6)
|
||||
nas_bk = tailscale_ping("ps-bk-nas", 6)
|
||||
|
||||
nas = [
|
||||
{"name": "집 NAS (mh-nas)", "emoji": "🗄️", "spec": "Synology DS418+",
|
||||
"net": "100.69.107.65 · minicity.kr", **nas_mh},
|
||||
{"name": "회사 NAS (gwenc-nas2)", "emoji": "🗄️", "spec": "Synology",
|
||||
"net": "100.105.95.19", **nas_gwenc2},
|
||||
{"name": "백업 NAS (ps-bk-nas)", "emoji": "🗄️", "spec": "14TB HDD",
|
||||
"net": "100.83.176.55", **nas_bk},
|
||||
]
|
||||
|
||||
# --- 공유기 ---
|
||||
routers = [
|
||||
{"name": "집 공유기", "emoji": "📡", "spec": "아이피타임 AX8004BCM",
|
||||
"net": "pinksky.iptime.org", "status": "online", "detail": "DDNS · 원격관리 9090"},
|
||||
{"name": "아지트 공유기", "emoji": "📡", "spec": "TP-LINK Archer BE550",
|
||||
"net": "pinksky.kr", "status": "online", "detail": "헬로비전 · TP-Link 앱"},
|
||||
{"name": "회사 공유기", "emoji": "📡", "spec": "TP-LINK AX18000",
|
||||
"net": "gwenc.kr", "status": "online", "detail": "회사 자산 · 원격관리 9000/9443"},
|
||||
]
|
||||
|
||||
# --- 에이전트 ---
|
||||
mimi = proc_check("openclaw-gateway", "openclaw")
|
||||
mimi["name"] = "미미"
|
||||
mimi["emoji"] = "🦞"
|
||||
mimi["host"] = "맥미니 (openclaw)"
|
||||
mimi["version"] = get_local_version("openclaw")
|
||||
|
||||
ruki = proc_check("hermes", "hermes")
|
||||
ruki["name"] = "루키"
|
||||
ruki["emoji"] = "🌱"
|
||||
ruki["host"] = "맥미니 (hermes)"
|
||||
ruki["version"] = get_local_version("hermes")
|
||||
|
||||
pink = remote_proc("mainpc-wsl", "hermes", "hermes")
|
||||
pink["name"] = "분홍"
|
||||
pink["emoji"] = "🤖"
|
||||
pink["host"] = "메인컴 WSL"
|
||||
pink["version"] = get_remote_hermes_version("mainpc-wsl")
|
||||
|
||||
sky = remote_proc("subpc-wsl", "hermes", "hermes")
|
||||
sky["name"] = "하늘"
|
||||
sky["emoji"] = "🤖"
|
||||
sky["host"] = "보조컴 WSL"
|
||||
sky["version"] = get_remote_hermes_version("subpc-wsl")
|
||||
|
||||
agents = [a for a in [mimi, ruki, pink, sky]]
|
||||
|
||||
# --- 도메인 ---
|
||||
domains = [
|
||||
{"name": "wiki.pinksky.kr", "url": "https://wiki.pinksky.kr/", "group": "pinksky.kr", "desc": "MiniCITY 지식정원"},
|
||||
{"name": "openclaw.pinksky.kr", "url": "https://openclaw.pinksky.kr/", "group": "pinksky.kr", "desc": "OpenClaw 대시보드"},
|
||||
{"name": "agent.pinksky.kr", "url": "https://agent.pinksky.kr/", "group": "pinksky.kr", "desc": "인프라 현황판"},
|
||||
{"name": "search.pinksky.kr", "url": "https://search.pinksky.kr/search?q=test", "group": "pinksky.kr", "desc": "SearXNG 메타서치"},
|
||||
{"name": "erp.pinksky.kr", "url": "https://erp.pinksky.kr/", "group": "pinksky.kr", "desc": "공사관리 ERP"},
|
||||
{"name": "dify.pinksky.kr:8443", "url": "https://dify.pinksky.kr:8443/", "group": "pinksky.kr", "desc": "Dify 워크플로우"},
|
||||
{"name": "gwenc.kr", "url": "https://gwenc.kr/", "group": "gwenc.kr", "desc": "회사 메인 사이트"},
|
||||
{"name": "minicity.kr", "url": "https://minicity.kr/", "group": "minicity.kr", "desc": "집 NAS 웹 UI"},
|
||||
]
|
||||
domain_results = []
|
||||
for d in domains:
|
||||
chk = http_check(d["url"], 4)
|
||||
domain_results.append({
|
||||
"name": d["name"], "group": d["group"], "desc": d["desc"],
|
||||
"status": chk["status"], "detail": chk["detail"],
|
||||
"latency_ms": chk.get("latency_ms")
|
||||
})
|
||||
|
||||
# --- 요약 ---
|
||||
all_items = pcs + nas + routers + agents + domain_results
|
||||
online = sum(1 for x in all_items if x["status"] == "online")
|
||||
|
||||
data = {
|
||||
"pcs": pcs,
|
||||
"nas": nas,
|
||||
"routers": routers,
|
||||
"agents": agents,
|
||||
"domains": domain_results,
|
||||
"timestamp": ts.isoformat(),
|
||||
"timestamp_epoch": int(ts.timestamp()),
|
||||
"summary": {"online": online, "total": len(all_items)}
|
||||
}
|
||||
|
||||
STATUS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
print(f"[{ts.strftime('%H:%M:%S')}] 온라인 {online}/{len(all_items)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_checks()
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""작은도시 인프라 현황 v5 — 도메인 계층 + 에이전트/서브도메인 2탭"""
|
||||
import json, subprocess, re
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
STATUS_FILE = Path(__file__).parent / "health.json"
|
||||
|
||||
PC_SPECS = {
|
||||
"macmini": {"name": "맥미니 M4 Pro", "emoji": "🖥️", "spec": "Apple M4 Pro · 64GB · 통합 GPU", "dns": "pinksky.kr"},
|
||||
"mainpc": {"name": "메인컴 (PS-i14700K)", "emoji": "🖥️", "spec": "Intel Core i7-14700K · 96GB · RTX 3090 24GB", "dns": "mainpc-wsl.pinksky.kr"},
|
||||
"subpc": {"name": "보조컴 (MH-3900x)", "emoji": "🖥️", "spec": "AMD Ryzen 9 3900X · 32GB · RTX 3080 12GB", "dns": "subpc-wsl.pinksky.kr"},
|
||||
"officepc": {"name": "회사 메인PC (PS-i14700K)", "emoji": "🖥️", "spec": "Intel Core i7-14700K · 96GB · RTX 3090", "dns": ""},
|
||||
"nas_home": {"name": "집 NAS (mh-nas)", "emoji": "🗄️", "spec": "Synology DS418+", "dns": "minicity.kr"},
|
||||
"nas_office": {"name": "회사 NAS (gwenc-nas2)", "emoji": "🗄️", "spec": "Synology", "dns": ""},
|
||||
"nas_backup": {"name": "백업 NAS (ps-bk-nas)", "emoji": "🗄️", "spec": "14TB HDD", "dns": ""},
|
||||
"router_home": {"name": "집 공유기", "emoji": "📡", "spec": "아이피타임 AX8004BCM", "dns": "pinksky.iptime.org"},
|
||||
"router_apt": {"name": "아지트 공유기", "emoji": "📡", "spec": "TP-LINK Archer BE550", "dns": "pinksky.kr"},
|
||||
"router_office": {"name": "회사 공유기", "emoji": "📡", "spec": "TP-LINK AX18000", "dns": "gwenc.kr"},
|
||||
}
|
||||
|
||||
# ===== 기본 체크 함수 =====
|
||||
def http_check(url, timeout=5):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}:%{time_total}",
|
||||
url, "--max-time", str(timeout)],
|
||||
capture_output=True, text=True, timeout=timeout+2)
|
||||
parts = r.stdout.strip().split(":")
|
||||
code = parts[0] if parts else "000"
|
||||
latency = round(float(parts[1])*1000, 0) if len(parts) > 1 and parts[1] else None
|
||||
return {"status": "online" if code.startswith(("2","3")) else "error",
|
||||
"detail": f"HTTP {code}", "latency_ms": latency}
|
||||
except: return {"status": "offline", "detail": "timeout", "latency_ms": None}
|
||||
|
||||
def ssh_check(host, timeout=8):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
host, "echo alive"],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
alive = r.returncode == 0 and "alive" in r.stdout
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": "alive" if alive else r.stderr.strip()[:80]}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def tailscale_ping(host, timeout=8):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["tailscale", "ping", "--c", "3", host],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
out = r.stdout + r.stderr
|
||||
alive = "pong" in out or "is local" in out
|
||||
return {"status": "online" if alive else "offline",
|
||||
"detail": "" if alive else "no pong"}
|
||||
except: return {"status": "offline", "detail": "timeout"}
|
||||
|
||||
def get_local_ts_ip():
|
||||
try:
|
||||
r = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip()
|
||||
except: return "?"
|
||||
|
||||
def get_remote_ts(via):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", via, "tailscale ip -4"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
return r.stdout.strip() if r.returncode == 0 else "?"
|
||||
except: return "?"
|
||||
|
||||
def get_version(label):
|
||||
try:
|
||||
if label == "openclaw":
|
||||
r = subprocess.run(["openclaw", "--version"], capture_output=True, text=True, timeout=5)
|
||||
v = r.stdout.strip().replace("OpenClaw ", "")
|
||||
v = re.sub(r'\s+\([^)]+\)', '', v).strip()
|
||||
return v if v else "?"
|
||||
elif label == "hermes":
|
||||
r = subprocess.run([
|
||||
"/Users/pinksky/.hermes/hermes-agent/venv/bin/python",
|
||||
"-m", "hermes_cli.main", "--version"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
m = re.search(r'v[\d.]+', r.stdout.strip())
|
||||
return m.group(0) if m else "?"
|
||||
except: return "?"
|
||||
|
||||
def get_remote_hermes_version(via):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", via,
|
||||
"export PATH=\"$HOME/.local/bin:$PATH\"; hermes --version"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
m = re.search(r'v[\d.]+', r.stdout.strip())
|
||||
return m.group(0) if m else "?"
|
||||
except: return "?"
|
||||
|
||||
def proc_detail(pattern, engine_name="?"):
|
||||
result = {"status": "offline", "version": "?", "uptime": "?", "proc_count": 0,
|
||||
"engine": engine_name, "detail": "not running"}
|
||||
try:
|
||||
r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, text=True, timeout=5)
|
||||
pids = [p.strip() for p in r.stdout.strip().split("\n") if p.strip()]
|
||||
if pids:
|
||||
result["status"] = "online"
|
||||
result["proc_count"] = len(pids)
|
||||
try:
|
||||
u = subprocess.run(["ps", "-p", pids[0], "-o", "etime="],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
result["uptime"] = u.stdout.strip()
|
||||
except: pass
|
||||
result["detail"] = f"{result['uptime']} · {result['proc_count']}프로세스"
|
||||
except: pass
|
||||
return result
|
||||
|
||||
def remote_proc(via, pattern, engine_name="?"):
|
||||
result = {"status": "offline", "version": "?", "uptime": "?", "proc_count": 0,
|
||||
"engine": engine_name, "detail": "not running"}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no",
|
||||
via, f"ps aux | grep '{pattern}' | grep -v grep || true"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
|
||||
if lines:
|
||||
result["status"] = "online"
|
||||
result["proc_count"] = len(lines)
|
||||
result["detail"] = f"{result['proc_count']}프로세스"
|
||||
except: pass
|
||||
return result
|
||||
|
||||
# ===== 메인 =====
|
||||
def run_checks():
|
||||
ts = datetime.now(KST)
|
||||
ts_ip = get_local_ts_ip()
|
||||
|
||||
# --- PC checks ---
|
||||
mainpc_ssh = ssh_check("mainpc-win", 6)
|
||||
mainpc_ts = get_remote_ts("mainpc-wsl")
|
||||
subpc_ssh = ssh_check("subpc-wsl", 6)
|
||||
subpc_ts = get_remote_ts("subpc-wsl")
|
||||
office_pc = tailscale_ping("office-pc", 6)
|
||||
|
||||
# --- NAS checks ---
|
||||
nas_mh = ssh_check("mh-nas", 6)
|
||||
nas_gwenc2 = tailscale_ping("gwenc-nas2", 6)
|
||||
nas_bk = tailscale_ping("ps-bk-nas", 6)
|
||||
|
||||
# --- Agent checks ---
|
||||
mimi = proc_detail("openclaw", "openclaw")
|
||||
mimi["name"] = "미미"
|
||||
mimi["emoji"] = "🦞"
|
||||
mimi["version"] = get_version("openclaw")
|
||||
|
||||
ruki = proc_detail("hermes", "hermes")
|
||||
ruki["name"] = "루키"
|
||||
ruki["emoji"] = "🌱"
|
||||
ruki["version"] = get_version("hermes")
|
||||
|
||||
pink = remote_proc("mainpc-wsl", "hermes", "hermes")
|
||||
pink["name"] = "분홍"
|
||||
pink["emoji"] = "🤖"
|
||||
pink["version"] = get_remote_hermes_version("mainpc-wsl")
|
||||
|
||||
sky = remote_proc("subpc-wsl", "hermes", "hermes")
|
||||
sky["name"] = "하늘"
|
||||
sky["emoji"] = "🤖"
|
||||
sky["version"] = get_remote_hermes_version("subpc-wsl")
|
||||
|
||||
# --- Subdomains ---
|
||||
subdomains = [
|
||||
{"name": "wiki.pinksky.kr", "url": "https://wiki.pinksky.kr/", "group": "pinksky.kr", "desc": "MiniCITY 지식정원"},
|
||||
{"name": "openclaw.pinksky.kr", "url": "https://openclaw.pinksky.kr/", "group": "pinksky.kr", "desc": "OpenClaw 대시보드"},
|
||||
{"name": "agent.pinksky.kr", "url": "https://agent.pinksky.kr/", "group": "pinksky.kr", "desc": "인프라 현황판"},
|
||||
{"name": "search.pinksky.kr", "url": "https://search.pinksky.kr/search?q=test", "group": "pinksky.kr", "desc": "SearXNG 메타서치"},
|
||||
{"name": "erp.pinksky.kr", "url": "https://erp.pinksky.kr/", "group": "pinksky.kr", "desc": "공사관리 ERP"},
|
||||
{"name": "dify.pinksky.kr", "url": "https://dify.pinksky.kr/", "group": "pinksky.kr", "desc": "Dify 워크플로우"},
|
||||
{"name": "photo.pinksky.kr", "url": "https://photo.pinksky.kr/", "group": "pinksky.kr", "desc": "PhotoVault 자료정리"},
|
||||
{"name": "gwenc.kr", "url": "https://gwenc.kr/", "group": "gwenc.kr", "desc": "회사 메인 사이트"},
|
||||
{"name": "minicity.kr", "url": "https://minicity.kr/", "group": "minicity.kr", "desc": "집 NAS 웹 UI"},
|
||||
]
|
||||
sub_results = []
|
||||
for s in subdomains:
|
||||
chk = http_check(s["url"], 4)
|
||||
sub_results.append({
|
||||
"name": s["name"], "group": s["group"], "desc": s["desc"],
|
||||
"status": chk["status"], "detail": chk["detail"], "latency_ms": chk.get("latency_ms")
|
||||
})
|
||||
|
||||
# --- Domain hierarchy ---
|
||||
domains = {
|
||||
"pinksky.kr": {
|
||||
"domain": {"status": "online", "detail": f"{ts_ip}"},
|
||||
"pcs": {
|
||||
"macmini": {
|
||||
**PC_SPECS["macmini"],
|
||||
"tailscale_ip": ts_ip,
|
||||
"status": "online", "detail": "",
|
||||
"agents": {
|
||||
"mimi": {**mimi},
|
||||
"ruki": {**ruki},
|
||||
}
|
||||
},
|
||||
"mainpc": {
|
||||
**PC_SPECS["mainpc"],
|
||||
"tailscale_ip": mainpc_ts,
|
||||
"status": mainpc_ssh["status"], "detail": mainpc_ssh["detail"],
|
||||
"agents": {
|
||||
"pink": {**pink},
|
||||
}
|
||||
},
|
||||
"subpc": {
|
||||
**PC_SPECS["subpc"],
|
||||
"tailscale_ip": subpc_ts,
|
||||
"status": subpc_ssh["status"], "detail": subpc_ssh["detail"],
|
||||
"agents": {
|
||||
"sky": {**sky},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"minicity.kr": {
|
||||
"domain": {"status": "online", "detail": "집 NAS/공유기"},
|
||||
"pcs": {
|
||||
"nas_home": {
|
||||
**PC_SPECS["nas_home"],
|
||||
"tailscale_ip": "100.69.107.65",
|
||||
"status": nas_mh["status"], "detail": nas_mh["detail"],
|
||||
"agents": {}
|
||||
},
|
||||
"router_home": {
|
||||
**PC_SPECS["router_home"],
|
||||
"tailscale_ip": "",
|
||||
"status": "online", "detail": "DDNS · 원격관리 9090",
|
||||
"agents": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gwenc.kr": {
|
||||
"domain": {"status": office_pc["status"], "detail": office_pc["detail"]},
|
||||
"pcs": {
|
||||
"officepc": {
|
||||
**PC_SPECS["officepc"],
|
||||
"tailscale_ip": "",
|
||||
"status": office_pc["status"], "detail": office_pc["detail"],
|
||||
"agents": {}
|
||||
},
|
||||
"nas_office": {
|
||||
**PC_SPECS["nas_office"],
|
||||
"tailscale_ip": "100.105.95.19",
|
||||
"status": nas_gwenc2["status"], "detail": nas_gwenc2["detail"],
|
||||
"agents": {}
|
||||
},
|
||||
"nas_backup": {
|
||||
**PC_SPECS["nas_backup"],
|
||||
"tailscale_ip": "100.83.176.55",
|
||||
"status": nas_bk["status"], "detail": nas_bk["detail"],
|
||||
"agents": {}
|
||||
},
|
||||
"router_office": {
|
||||
**PC_SPECS["router_office"],
|
||||
"tailscale_ip": "",
|
||||
"status": "online", "detail": "원격관리 9000/9443",
|
||||
"agents": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Summary ---
|
||||
all_items = []
|
||||
for d in domains.values():
|
||||
for pc in d["pcs"].values():
|
||||
all_items.append(pc)
|
||||
for a in pc.get("agents", {}).values():
|
||||
all_items.append(a)
|
||||
for s in sub_results:
|
||||
all_items.append(s)
|
||||
|
||||
online = sum(1 for x in all_items if x["status"] == "online")
|
||||
offline = sum(1 for x in all_items if x["status"] == "offline")
|
||||
|
||||
data = {
|
||||
"subdomains": sub_results,
|
||||
"domains": domains,
|
||||
"summary": {
|
||||
"total": len(all_items),
|
||||
"online": online,
|
||||
"offline": offline,
|
||||
"domain_total": 3,
|
||||
"domain_online": sum(1 for d in domains.values() if d["domain"]["status"] == "online")
|
||||
},
|
||||
"timestamp": ts.isoformat(),
|
||||
"timestamp_epoch": int(ts.timestamp())
|
||||
}
|
||||
|
||||
STATUS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
print(f"[{ts.strftime('%H:%M:%S')}] Online {online}/{len(all_items)} · Domains {data['summary']['domain_online']}/3")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_checks()
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
<!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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,41 @@
|
||||
# 🏙️ MiniCITY v0.5 — 의견 요청 (분홍)
|
||||
|
||||
안녕하세요 분홍! 저는 루키입니다.
|
||||
|
||||
아빠(미미아빠)의 요청으로 MiniCITY 옵시디언 볼트를 v0.5로 재구조화하려고 합니다.
|
||||
현재 문제점: 17,681개 파일 중 11,620개(66%)가 ERP 소스코드 중복입니다. 실제 문서는 5~6천 개 수준.
|
||||
|
||||
## 제안 구조
|
||||
```
|
||||
MiniCITY(v0.5_PARA_0502-)/
|
||||
├── 00_INBOX/
|
||||
├── 01_PROJECTS/
|
||||
│ ├── A_mini-happy/
|
||||
│ ├── B_선번조사툴/ ← 분홍 담당
|
||||
│ ├── C_공사관리ERP/ ← 분홍 담당
|
||||
│ ├── D_자료관리/
|
||||
│ ├── E_공가신청해지/
|
||||
│ ├── H_음성알림시스템/
|
||||
│ └── AgentStatusDash/
|
||||
├── 02_AREAS/
|
||||
│ ├── 인프라/
|
||||
│ └── 에이전트/
|
||||
│ ├── 미미/
|
||||
│ ├── 루키/
|
||||
│ ├── 분홍/ ← 분홍 개인 작업공간
|
||||
│ └── 하늘/
|
||||
├── 03_RESOURCES/
|
||||
├── 04_ARCHIVE/
|
||||
└── 98_HISTORY/
|
||||
└── 99_TEMPLATES/
|
||||
```
|
||||
|
||||
## 분홍에게 묻고 싶은 점
|
||||
1. B_선번조사툴 / C_공사관리ERP 문서를 v0.x_구상/초안/진행중 3단계로 정리하는 것 괜찮나요?
|
||||
2. 작업하던 ERP 소스코드가 옵시디언에 11,620개나 중복되어 있는데, 전부 제거해도 되나요? (git 저장소로 이동)
|
||||
3. 분홍 개인 작업공간(02_AREAS/에이전트/분홍/)이 필요하다면 어떤 내용을 담고 싶나요?
|
||||
4. 기타 제안이나 우려사항이 있으면 알려주세요.
|
||||
|
||||
회신은 이 파일 아래에 작성해주세요. (파일을 수정해서 답변을 추가해주세요)
|
||||
---
|
||||
의견:
|
||||
Reference in New Issue
Block a user