Initial commit: agent-status dashboard (agent.pinksky.kr)
This commit is contained in:
+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()
|
||||
Reference in New Issue
Block a user