#!/usr/bin/env python3
"""
서버 모니터링 대시보드 - 웹 기반 실시간 모니터링
네트워크 토폴로지 자동 감지
"""

import json
import subprocess
import threading
import time
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler, ThreadingHTTPServer
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
import os

PORT = int(os.environ.get("DASHBOARD_PORT", "8721"))
VPN_SERVER_URL = os.environ.get("WIRE_SERVER_URL", "http://localhost:8786")

# 릴레이 서버 목록 (공인 IP)
RELAY_IPS = os.environ.get("WIRE_RELAY_IPS", "").split(",") if os.environ.get("WIRE_RELAY_IPS") else []

# Server list - loaded from config, auto-updated from VPN peers
# Config file: ~/.mpop/servers.json or /etc/meshpop/servers.json
_SERVER_CONFIG_PATHS = [
    os.path.expanduser("~/.mpop/servers.json"),
    "/etc/meshpop/servers.json",
]

def _load_server_config():
    """Load initial server list from config file."""
    for p in _SERVER_CONFIG_PATHS:
        try:
            with open(p) as f:
                return json.load(f)
        except (FileNotFoundError, json.JSONDecodeError):
            continue
    return {}

SERVERS = _load_server_config()
SERVERS_LOCK = threading.Lock()

# Hostname to friendly name mapping
HOSTNAME_MAP = {
    "d1": "d1", "d2": "d2", "g1": "g1", "g2": "g2", "g3": "g3", "g4": "g4",
    "m1": "m1", "MacBook-Pro": "m1", "Mac": "m1",
    "vultr": "v1", "v1": "v1", "v2": "v2", "v3": "v3",
}


def fetch_vpn_peers():
    """VPN 서버에서 피어 목록 가져오기"""
    global SERVERS, HOSTNAME_MAP
    try:
        req = urllib.request.Request(f"{VPN_SERVER_URL}/peers")
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read())
            peers = data.get("peers", [])

            new_servers = {}
            new_hostname_map = {}

            for peer in peers:
                vpn_ip = peer.get("vpn_ip", "")
                public_ip = peer.get("public_ip", "")
                port = peer.get("port", 51820)
                node_id = peer.get("node_id", "")[:8]

                # 이름 결정 (hostname 기반)
                name = guess_name_from_ip(vpn_ip, public_ip)

                # 릴레이 여부
                is_relay = public_ip in RELAY_IPS

                # SSH 호스트 결정
                if vpn_ip == get_my_vpn_ip():
                    host = "localhost"
                    local = True
                else:
                    # 기본 사용자 추측
                    user = "root" if is_relay else "dragon"
                    host = f"{user}@{vpn_ip}"
                    local = False

                new_servers[name] = {
                    "host": host,
                    "vpn_ip": vpn_ip,
                    "public_ip": public_ip,
                    "port": port,
                    "node_id": node_id,
                    "desc": f"{'릴레이' if is_relay else '클라이언트'} @ {public_ip}",
                    "is_relay": is_relay,
                    "icon": "☁️" if is_relay else "🖥️",
                    "local": local if 'local' in dir() else False,
                }

                # hostname map 업데이트
                new_hostname_map[name] = name
                new_hostname_map[name.lower()] = name

            # 실제 호스트명 → 서버이름 매핑 추가
            REAL_HOSTNAMES = {
                "MacBook-Pro": "m1",
                "Mac": "m1",
                "m1": "m1",
                "vultr": "v1",
            }
            new_hostname_map.update(REAL_HOSTNAMES)

            with SERVERS_LOCK:
                SERVERS = new_servers
                HOSTNAME_MAP = new_hostname_map

            return len(peers)
    except Exception as e:
        print(f"Failed to fetch VPN peers: {e}")
        return 0


def guess_name_from_ip(vpn_ip: str, public_ip: str) -> str:
    """VPN IP로 서버 이름 추측"""
    # 알려진 패턴
    known = {
        "10.99.85.143": "v1",
        "10.99.74.131": "v2",
        "10.99.249.158": "v3",
        "10.99.74.111": "g1",
        "10.99.106.230": "g2",
        "10.99.202.68": "g3",
        "10.99.175.0": "g4",
        "10.99.208.200": "d1",
        "10.99.99.181": "d2",
        "10.99.84.191": "m1",
    }
    if vpn_ip in known:
        return known[vpn_ip]

    # 공인 IP로 추측 (VPS)
    if public_ip in RELAY_IPS:
        idx = RELAY_IPS.index(public_ip) + 1
        return f"v{idx}"

    # 마지막 옥텟 사용
    last_octet = vpn_ip.split(".")[-1]
    return f"node-{last_octet}"


def get_my_vpn_ip() -> str:
    """현재 서버의 VPN IP"""
    try:
        result = subprocess.run(
            "ip addr show wire0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1",
            shell=True, capture_output=True, text=True
        )
        return result.stdout.strip()
    except:
        return ""


def get_network_topology():
    """네트워크 토폴로지 정보"""
    with SERVERS_LOCK:
        servers = dict(SERVERS)

    relays = []
    clients = []

    for name, info in servers.items():
        node = {
            "name": name,
            "vpn_ip": info.get("vpn_ip", ""),
            "public_ip": info.get("public_ip", ""),
            "is_relay": info.get("is_relay", False),
            "icon": info.get("icon", "🖥️"),
        }
        if info.get("is_relay"):
            relays.append(node)
        else:
            clients.append(node)

    return {
        "relays": relays,
        "clients": clients,
        "total": len(servers),
    }

# Agent 설치 스크립트
INSTALL_SCRIPT = '''#!/bin/bash
set -e
AGENT_DIR="/opt/server-agent"
echo "=== Server Agent 설치 ==="
sudo mkdir -p $AGENT_DIR
echo "Agent 다운로드 중..."
sudo curl -sSL http://10.99.85.143:8800/agent.py -o $AGENT_DIR/agent-reporter.py
sudo chmod +x $AGENT_DIR/agent-reporter.py
echo "systemd 서비스 설정 중..."
sudo tee /etc/systemd/system/server-agent.service > /dev/null << 'SERVICEEOF'
[Unit]
Description=Server Monitoring Agent
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/server-agent/agent-reporter.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
SERVICEEOF
sudo systemctl daemon-reload
sudo systemctl enable server-agent
sudo systemctl restart server-agent
echo "=== 설치 완료 ==="
sudo systemctl status server-agent --no-pager | head -5
'''

# Agent 코드 v2 - 향상된 모니터링 (macOS/Linux)
AGENT_CODE = '''#!/usr/bin/env python3
"""Server Agent v2 - Enhanced monitoring with macOS support"""
import subprocess, json, socket, time, urllib.request, urllib.error, platform, sys

# Dashboard URL read from config (set by mpop setup agent)
def _get_dashboard_url():
    import os as _os, json as _json
    url = _os.environ.get("DASHBOARD_URL", "").rstrip("/")
    if url:
        return url + "/api/report"
    cfg_path = _os.path.expanduser("~/.mpop/config.json")
    if _os.path.exists(cfg_path):
        try:
            with open(cfg_path) as _f:
                cfg = _json.load(_f)
            url = cfg.get("dashboard_url", "").rstrip("/")
            if url:
                return url + "/api/report"
        except Exception:
            pass
    return ""
DASHBoard_URL = _get_dashboard_url()
REPORT_INTERVAL = 30
IS_MACOS = platform.system() == "Darwin"
IS_LINUX = platform.system() == "Linux"

def run_cmd(cmd, timeout=10):
    try:
        result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
        return result.stdout.strip()
    except: return ""

def get_uptime():
    if IS_MACOS:
        boot = run_cmd("sysctl -n kern.boottime | awk '{print $4}' | tr -d ','")
        if boot:
            try:
                uptime_sec = int(time.time()) - int(boot)
                days, hours, mins = uptime_sec // 86400, (uptime_sec % 86400) // 3600, (uptime_sec % 3600) // 60
                return f"{days}d {hours}h {mins}m" if days > 0 else f"{hours}h {mins}m" if hours > 0 else f"{mins}m"
            except: pass
        return run_cmd("uptime | awk '{print $3,$4}'").replace(",", "")
    return run_cmd("uptime -p 2>/dev/null").replace("up ", "") or "?"

def get_memory():
    result = {"memory": "?", "mem_pct": 0, "mem_used": 0, "mem_total": 0}
    if IS_MACOS:
        mem_info = run_cmd("""
page_size=$(pagesize 2>/dev/null || echo 16384)
stats=$(vm_stat 2>/dev/null)
total=$(sysctl -n hw.memsize 2>/dev/null)
if [ -n "$stats" ] && [ -n "$total" ]; then
  active=$(echo "$stats" | awk '/Pages active/ {gsub(/\\./, "", $3); print $3}')
  wired=$(echo "$stats" | awk '/Pages wired/ {gsub(/\\./, "", $4); print $4}')
  compressed=$(echo "$stats" | awk '/Pages occupied by compressor/ {gsub(/\\./, "", $5); print $5}')
  used=$(( (${active:-0} + ${wired:-0} + ${compressed:-0}) * $page_size ))
  total_gb=$((total / 1073741824))
  used_gb=$((used / 1073741824))
  pct=$((used * 100 / total))
  echo "${used_gb}G/${total_gb}G $used $total $pct"
fi""")
        if mem_info:
            parts = mem_info.split()
            if len(parts) >= 4:
                result["memory"] = parts[0]
                result["mem_used"] = int(parts[1]) if parts[1].isdigit() else 0
                result["mem_total"] = int(parts[2]) if parts[2].isdigit() else 0
                result["mem_pct"] = int(parts[3]) if parts[3].isdigit() else 0
    else:
        mem = run_cmd("LANG=C free -b 2>/dev/null | grep Mem | awk '{print $3, $2, int($3/$2*100)}'")
        if mem:
            parts = mem.split()
            if len(parts) >= 3:
                used, total, pct = int(parts[0]), int(parts[1]), int(parts[2])
                result["mem_used"], result["mem_total"], result["mem_pct"] = used, total, pct
                result["memory"] = f"{used//1073741824}Gi/{total//1073741824}Gi" if total > 1073741824 else f"{used//1048576}Mi/{total//1048576}Mi"
    return result

def get_disk():
    result = {"disk_used": "?", "disk_pct": 0, "disk_free": "?"}
    disk = run_cmd("df -h / | tail -1 | awk '{print $5, $4, $3, $2}'").split()
    if disk:
        result["disk_used"] = disk[0]
        try: result["disk_pct"] = int(disk[0].replace("%", ""))
        except: pass
        if len(disk) > 1: result["disk_free"] = disk[1]
    return result

def get_all_disks():
    disks = []
    if IS_MACOS:
        # macOS df has more columns: Filesystem Size Used Avail Capacity iused ifree %iused Mounted
        df_output = run_cmd("df -h | grep -E '^/dev/' | grep -v 'devfs'")
        if df_output:
            for line in df_output.strip().split('\\n'):
                parts = line.split()
                if len(parts) >= 9:
                    try:
                        disks.append({"device": parts[0], "size": parts[1], "used": parts[2],
                                      "available": parts[3], "percent": int(parts[4].replace("%", "")), "mount": parts[-1]})
                    except: pass
    else:
        df_output = run_cmd("df -h -x tmpfs -x devtmpfs -x squashfs -x efivarfs | tail -n +2")
        if df_output:
            for line in df_output.strip().split('\\n'):
                parts = line.split()
                if len(parts) >= 6:
                    try:
                        disks.append({"device": parts[0], "size": parts[1], "used": parts[2],
                                      "available": parts[3], "percent": int(parts[4].replace("%", "")), "mount": parts[5]})
                    except: pass
    return disks

def get_load():
    if IS_MACOS:
        return run_cmd("sysctl -n vm.loadavg | awk '{print $2}'") or "?"
    return run_cmd("cat /proc/loadavg | awk '{print $1}'") or "?"

def get_cpu_usage():
    if IS_MACOS:
        cpu = run_cmd("top -l 1 -n 0 | grep 'CPU usage' | awk '{print $3}' | tr -d '%'")
    else:
        cpu = run_cmd("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
    try: return int(float(cpu))
    except: return 0

def get_vpn_ip():
    if IS_MACOS:
        vpn = run_cmd("for i in utun0 utun1 utun2 utun3 utun4 utun5; do IP=$(ifconfig $i 2>/dev/null | grep 'inet 10.99' | awk '{print $2}'); [ -n \\"$IP\\" ] && echo $IP && break; done")
    else:
        vpn = run_cmd("ip addr show wire0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1")
    return vpn if vpn and "10.99" in vpn else ""

def get_docker():
    containers = run_cmd("docker ps --format '{{.Names}}:{{.Status}}' 2>/dev/null | head -10")
    if containers:
        result = []
        for line in containers.split('\\n'):
            if ':' in line:
                name, status = line.split(':', 1)
                result.append({"name": name, "status": "up" if "Up" in status else "down"})
        return result
    return []

def get_databases():
    dbs = {}
    if run_cmd("pgrep -x mysqld >/dev/null && echo yes") == "yes":
        dbs["mysql"] = {"running": True, "connectable": run_cmd("mysqladmin ping 2>/dev/null | grep -q alive && echo ok") == "ok"}
    if run_cmd("pgrep -x postgres >/dev/null && echo yes") == "yes":
        dbs["postgresql"] = {"running": True, "connectable": run_cmd("pg_isready 2>/dev/null | grep -q accepting && echo ok") == "ok"}
    if run_cmd("pgrep -x redis-server >/dev/null && echo yes") == "yes":
        dbs["redis"] = {"running": True, "connectable": run_cmd("redis-cli ping 2>/dev/null | grep -q PONG && echo ok") == "ok"}
    if run_cmd("pgrep -x mongod >/dev/null && echo yes") == "yes":
        dbs["mongodb"] = {"running": True}
    return dbs

def get_top_processes():
    if IS_MACOS:
        procs = run_cmd("ps -eo pid,pcpu,pmem,comm | sort -k2 -nr | head -6 | tail -5")
    else:
        procs = run_cmd("ps -eo pid,pcpu,pmem,comm --sort=-%cpu | head -6 | tail -5")
    result = []
    if procs:
        for line in procs.strip().split('\\n'):
            parts = line.split()
            if len(parts) >= 4:
                result.append({"pid": parts[0], "cpu": parts[1], "mem": parts[2], "name": parts[3]})
    return result

def get_latency():
    targets = {"v1": "10.99.85.143", "google": "8.8.8.8"}
    result = {}
    for name, ip in targets.items():
        ping = run_cmd(f"ping -c 1 -W 2 {ip} 2>/dev/null | grep 'time=' | awk -F'time=' '{{print $2}}' | awk '{{print $1}}'")
        try: result[name] = float(ping)
        except: result[name] = -1
    return result

def get_ports():
    if IS_MACOS:
        ports = run_cmd("lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | awk 'NR>1 {split($9,a,\\":\\"); print a[length(a)]}' | sort -nu | head -15")
    else:
        ports = run_cmd("ss -tlnp 2>/dev/null | awk 'NR>1 {split($4,a,\\":\\"); port=a[length(a)]; if(port>0 && port<65536) print port}' | sort -nu | head -15")
    return [int(p) for p in ports.split('\\n') if p.strip().isdigit()]

def get_services():
    services = {}
    for svc in ["wire", "docker", "nginx", "postgresql", "redis", "coturn", "icecast2", "liquidsoap-radio"]:
        if IS_LINUX:
            status = run_cmd(f"systemctl is-active {svc} 2>/dev/null")
            if status == "active": services[svc] = True
            elif status in ["inactive", "failed"]: services[svc] = False
        elif IS_MACOS:
            if run_cmd(f"pgrep -x {svc} >/dev/null && echo yes") == "yes": services[svc] = True
    return services

def get_security():
    result = {}
    ssh_sessions = run_cmd("who | grep -c '.' 2>/dev/null || echo 0")
    result["ssh_sessions"] = int(ssh_sessions) if ssh_sessions.isdigit() else 0
    if IS_LINUX:
        failed = run_cmd("journalctl -u sshd --since '1 hour ago' 2>/dev/null | grep -c 'Failed password' || echo 0")
        result["failed_logins_1h"] = int(failed) if failed.isdigit() else 0
    result["last_login"] = run_cmd("last -1 2>/dev/null | head -1 | awk '{print $1, $4, $5, $6}'") or "?"
    return result

def get_network_traffic():
    """Get network interface traffic (bytes in/out)"""
    result = {"interfaces": [], "total_rx": 0, "total_tx": 0}
    if IS_MACOS:
        # macOS: netstat -ib
        net = run_cmd("netstat -ib | grep -E '^en[0-9]|^utun' | awk '{print $1, $7, $10}'")
        if net:
            for line in net.strip().split('\\n'):
                parts = line.split()
                if len(parts) >= 3:
                    try:
                        iface, rx, tx = parts[0], int(parts[1]), int(parts[2])
                        result["interfaces"].append({"name": iface, "rx": rx, "tx": tx})
                        if iface.startswith("en"):
                            result["total_rx"] += rx
                            result["total_tx"] += tx
                    except: pass
    else:
        # Linux: /proc/net/dev
        net = run_cmd("cat /proc/net/dev | grep -E 'eth|ens|enp|wlan|wire' | awk '{gsub(/:/, \\\"\\\"); print $1, $2, $10}'")
        if net:
            for line in net.strip().split('\\n'):
                parts = line.split()
                if len(parts) >= 3:
                    try:
                        iface, rx, tx = parts[0], int(parts[1]), int(parts[2])
                        result["interfaces"].append({"name": iface, "rx": rx, "tx": tx})
                        result["total_rx"] += rx
                        result["total_tx"] += tx
                    except: pass
    return result

def get_icecast_stats():
    """Get Icecast streaming stats (for radio servers)"""
    result = {}
    # Check if icecast is running
    if run_cmd("pgrep icecast >/dev/null && echo yes") != "yes":
        return result
    # Try to get stats from icecast admin
    stats = run_cmd("curl -s --connect-timeout 2 http://localhost:8000/status-json.xsl 2>/dev/null")
    if stats:
        try:
            import json as _json
            data = _json.loads(stats)
            source = data.get("icestats", {}).get("source", {})
            if isinstance(source, list):
                source = source[0] if source else {}
            result["listeners"] = source.get("listeners", 0)
            result["peak_listeners"] = source.get("listener_peak", 0)
            result["title"] = source.get("title", "")
            result["bitrate"] = source.get("audio_bitrate", 0)
        except: pass
    return result

def get_status():
    status = {"hostname": socket.gethostname(), "timestamp": time.time(), "online": True,
              "os": "macOS" if IS_MACOS else "Linux", "uptime": get_uptime(), "load": get_load(), "cpu_pct": get_cpu_usage()}
    status.update(get_memory())
    status.update(get_disk())
    status["disks"] = get_all_disks()
    status["vpn_ip"] = get_vpn_ip()
    status["public_ip"] = run_cmd("curl -s --connect-timeout 3 ifconfig.me 2>/dev/null") or "?"
    status["latency"] = get_latency()
    status["ports"] = get_ports()
    status["services"] = get_services()
    status["docker"] = get_docker()
    status["databases"] = get_databases()
    status["top_processes"] = get_top_processes()
    status["security"] = get_security()
    status["network"] = get_network_traffic()
    status["icecast"] = get_icecast_stats()
    return status

def send_report(status):
    try:
        data = json.dumps(status).encode()
        req = urllib.request.Request(DASHBOARD_URL, data=data, headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=10) as resp: return resp.status == 200
    except Exception as e:
        print(f"Report failed: {e}")
        return False

if __name__ == "__main__":
    print(f"Server Agent v2 starting on {socket.gethostname()}")
    print(f"OS: {'macOS' if IS_MACOS else 'Linux'}, Reporting to: {DASHBOARD_URL}")
    sys.stdout.flush()
    while True:
        try:
            status = get_status()
            ok = send_report(status)
            print(f"[{time.strftime('%H:%M:%S')}] {'OK' if ok else 'FAIL'} | mem={status.get('memory')} disk={status.get('disk_used')} load={status.get('load')}")
            sys.stdout.flush()
        except Exception as e:
            print(f"Error: {e}")
            sys.stdout.flush()
        time.sleep(REPORT_INTERVAL)
'''

# 캐시
cache = {"servers": [], "vpn": "", "last_update": None, "agent_reports": {}}
cache_lock = threading.Lock()

# SSH 실패 캐시 (30초간 재시도 안함)
ssh_fail_cache = {}
ssh_fail_lock = threading.Lock()

# ==================== 히스토리 기능 ====================
HISTORY_DIR = "/var/lib/server-agent"
HISTORY_FILE = os.path.join(HISTORY_DIR, "history.jsonl")
HISTORY_INTERVAL = 300  # 5분마다 저장
HISTORY_MAX_DAYS = 7    # 7일간 보관
history_lock = threading.Lock()

def ensure_history_dir():
    """히스토리 디렉토리 생성"""
    try:
        os.makedirs(HISTORY_DIR, exist_ok=True)
    except:
        pass

def save_history_snapshot():
    """현재 상태를 히스토리에 저장"""
    ensure_history_dir()
    with cache_lock:
        servers = cache.get("servers", [])
        if not servers:
            return
        snapshot = {
            "ts": datetime.now().isoformat(),
            "servers": []
        }
        for s in servers:
            snapshot["servers"].append({
                "name": s.get("name"),
                "online": s.get("online", False),
                "mem_pct": s.get("mem_pct", 0),
                "disk_pct": int(str(s.get("disk_used", "0")).replace("%", "") or 0),
                "load": s.get("load", "0"),
                "source": s.get("source", "ssh"),
            })
    try:
        with history_lock:
            with open(HISTORY_FILE, "a") as f:
                f.write(json.dumps(snapshot) + "\n")
    except Exception as e:
        print(f"[History] Save error: {e}")

def cleanup_old_history():
    """오래된 히스토리 정리 (7일 이상)"""
    try:
        cutoff = datetime.now() - timedelta(days=HISTORY_MAX_DAYS)
        cutoff_str = cutoff.isoformat()

        with history_lock:
            if not os.path.exists(HISTORY_FILE):
                return

            # 읽고 필터링
            lines = []
            with open(HISTORY_FILE, "r") as f:
                for line in f:
                    try:
                        record = json.loads(line.strip())
                        if record.get("ts", "") >= cutoff_str:
                            lines.append(line)
                    except:
                        pass

            # 다시 쓰기
            with open(HISTORY_FILE, "w") as f:
                f.writelines(lines)

        print(f"[History] Cleanup done, kept {len(lines)} records")
    except Exception as e:
        print(f"[History] Cleanup error: {e}")

def load_history(hours: int = 24) -> list:
    """최근 N시간 히스토리 로드"""
    try:
        cutoff = datetime.now() - timedelta(hours=hours)
        cutoff_str = cutoff.isoformat()

        records = []
        with history_lock:
            if not os.path.exists(HISTORY_FILE):
                return []
            with open(HISTORY_FILE, "r") as f:
                for line in f:
                    try:
                        record = json.loads(line.strip())
                        if record.get("ts", "") >= cutoff_str:
                            records.append(record)
                    except:
                        pass
        return records
    except:
        return []

def get_trends(hours: int = 24) -> dict:
    """서버별 트렌드 데이터 생성"""
    records = load_history(hours)
    if not records:
        return {"error": "No history data"}

    # 서버별로 시계열 데이터 정리
    trends = {}
    for record in records:
        ts = record.get("ts", "")[:16]  # YYYY-MM-DDTHH:MM
        for server in record.get("servers", []):
            name = server.get("name")
            if name not in trends:
                trends[name] = {"timestamps": [], "mem": [], "disk": [], "online": []}
            trends[name]["timestamps"].append(ts)
            trends[name]["mem"].append(server.get("mem_pct", 0))
            trends[name]["disk"].append(server.get("disk_pct", 0))
            trends[name]["online"].append(1 if server.get("online") else 0)

    # 통계 계산
    for name, data in trends.items():
        if data["mem"]:
            data["mem_avg"] = round(sum(data["mem"]) / len(data["mem"]), 1)
            data["mem_max"] = max(data["mem"])
        if data["disk"]:
            data["disk_avg"] = round(sum(data["disk"]) / len(data["disk"]), 1)
            data["disk_max"] = max(data["disk"])
        if data["online"]:
            data["uptime_pct"] = round(sum(data["online"]) / len(data["online"]) * 100, 1)
        data["records"] = len(data["timestamps"])

    return {
        "hours": hours,
        "total_records": len(records),
        "servers": trends
    }

def history_worker():
    """백그라운드 히스토리 저장 워커"""
    cleanup_counter = 0
    while True:
        time.sleep(HISTORY_INTERVAL)
        try:
            save_history_snapshot()
            cleanup_counter += 1
            # 12시간마다 정리 (12 * 5분 = 144회)
            if cleanup_counter >= 144:
                cleanup_old_history()
                cleanup_counter = 0
        except Exception as e:
            print(f"[History] Worker error: {e}")

def ssh_cmd(host: str, cmd: str, timeout: int = 8, local: bool = False) -> str:
    # 최근 실패한 호스트는 빠르게 스킵
    with ssh_fail_lock:
        if host in ssh_fail_cache:
            if time.time() - ssh_fail_cache[host] < 30:
                return "TIMEOUT"
    try:
        if local or host == "localhost":
            result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
        else:
            result = subprocess.run(
                ["ssh", "-o", "ConnectTimeout=2", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", host, cmd],
                capture_output=True, text=True, timeout=timeout
            )
        # 성공하면 실패 캐시에서 제거
        with ssh_fail_lock:
            ssh_fail_cache.pop(host, None)
        return result.stdout.strip()
    except subprocess.TimeoutExpired:
        with ssh_fail_lock:
            ssh_fail_cache[host] = time.time()
        return "TIMEOUT"
    except Exception as e:
        with ssh_fail_lock:
            ssh_fail_cache[host] = time.time()
        return f"ERROR: {e}"

def get_server_status(name: str, info: dict) -> dict:
    host = info["host"]
    local = info.get("local", False)
    status = {
        "name": name,
        "host": host,
        "desc": info["desc"],
        "icon": info.get("icon", "🖥️"),
        "online": False
    }

    uptime = ssh_cmd(host, "uptime -p 2>/dev/null || uptime | awk '{print $3,$4}'", local=local)
    if uptime.startswith("ERROR") or uptime == "TIMEOUT":
        status["error"] = uptime
        return status

    status["online"] = True
    status["uptime"] = uptime.replace("up ", "")

    # 디스크 (루트)
    disk = ssh_cmd(host, "df -h / | tail -1 | awk '{print $5, $4, $3, $2}'", local=local)
    if disk and not disk.startswith("ERROR"):
        parts = disk.split()
        status["disk_used"] = parts[0] if parts else "?"
        status["disk_free"] = parts[1] if len(parts) > 1 else "?"
        status["disk_used_size"] = parts[2] if len(parts) > 2 else "?"
        status["disk_total"] = parts[3] if len(parts) > 3 else "?"
        try:
            status["disk_pct"] = int(parts[0].replace("%", ""))
        except:
            status["disk_pct"] = 0

    # 모든 디스크 (SSH 모드에서도 수집)
    all_disks = ssh_cmd(host, "df -h -x tmpfs -x devtmpfs -x squashfs -x efivarfs 2>/dev/null | tail -n +2 | awk '{print $1,$2,$3,$4,$5,$6}'", local=local)
    if all_disks and not all_disks.startswith("ERROR"):
        disks = []
        for line in all_disks.strip().split('\n'):
            parts = line.split()
            if len(parts) >= 6:
                try:
                    pct = int(parts[4].replace("%", ""))
                    disks.append({
                        "device": parts[0],
                        "size": parts[1],
                        "used": parts[2],
                        "available": parts[3],
                        "percent": pct,
                        "mount": parts[5]
                    })
                except:
                    pass
        if disks:
            status["disks"] = disks

    # 메모리 (LANG=C로 영어 출력 강제)
    mem = ssh_cmd(host, "LANG=C free -h 2>/dev/null | grep Mem | awk '{print $3\"/\"$2, $3, $2}'", local=local)
    if not mem or mem.startswith("ERROR") or "free:" in mem.lower():
        # macOS: vm_stat + sysctl로 메모리 계산
        mac_mem = ssh_cmd(host, """
            page_size=$(pagesize 2>/dev/null || echo 4096)
            stats=$(vm_stat 2>/dev/null)
            if [ -n "$stats" ]; then
                pages_free=$(echo "$stats" | awk '/Pages free/ {gsub(/\\./, "", $3); print $3}')
                pages_active=$(echo "$stats" | awk '/Pages active/ {gsub(/\\./, "", $3); print $3}')
                pages_inactive=$(echo "$stats" | awk '/Pages inactive/ {gsub(/\\./, "", $3); print $3}')
                pages_wired=$(echo "$stats" | awk '/Pages wired/ {gsub(/\\./, "", $4); print $4}')
                pages_compressed=$(echo "$stats" | awk '/Pages occupied by compressor/ {gsub(/\\./, "", $5); print $5}')
                total_bytes=$(sysctl -n hw.memsize 2>/dev/null)
                used=$((($pages_active + $pages_wired + ${pages_compressed:-0}) * $page_size))
                total_gb=$((total_bytes / 1073741824))
                used_gb=$((used / 1073741824))
                pct=$((used * 100 / total_bytes))
                echo "${used_gb}G/${total_gb}G $pct"
            fi
        """, local=local)
        if mac_mem and not mac_mem.startswith("ERROR"):
            parts = mac_mem.strip().split()
            status["memory"] = parts[0] if parts else "?"
            try:
                status["mem_pct"] = int(parts[1]) if len(parts) > 1 else 0
            except:
                status["mem_pct"] = 0
        else:
            status["memory"] = "?"
            status["mem_pct"] = 0
    else:
        parts = mem.split()
        status["memory"] = parts[0] if parts else "?"
        # 메모리 퍼센트 계산
        try:
            used = parts[1] if len(parts) > 1 else "0"
            total = parts[2] if len(parts) > 2 else "1"
            def parse_mem(s):
                s = s.lower().replace("i", "")
                if "g" in s: return float(s.replace("g", "")) * 1024
                if "m" in s: return float(s.replace("m", ""))
                return float(s)
            status["mem_pct"] = int(parse_mem(used) / parse_mem(total) * 100)
        except:
            status["mem_pct"] = 0

    # CPU 로드
    load = ssh_cmd(host, "cat /proc/loadavg 2>/dev/null | awk '{print $1}' || sysctl -n vm.loadavg 2>/dev/null | awk '{print $2}'", local=local)
    status["load"] = load if load and not load.startswith("ERROR") else "?"

    # 서비스 상태
    services = info.get("services", [])
    status["services"] = {}
    for svc in services:
        svc_status = ssh_cmd(host, f"systemctl is-active {svc} 2>/dev/null || echo 'inactive'", local=local)
        status["services"][svc] = svc_status == "active"

    # 공개 IP
    public_ip = ssh_cmd(host, "curl -s --connect-timeout 2 ifconfig.me 2>/dev/null || curl -s --connect-timeout 2 icanhazip.com 2>/dev/null", local=local)
    status["public_ip"] = public_ip if public_ip and not public_ip.startswith("ERROR") else "?"

    # VPN IP
    vpn_ip = ssh_cmd(host, """
        ip addr show wire0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1 || \
        ip addr show wg0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1 || \
        for i in utun0 utun1 utun2 utun3 utun4; do
            IP=$(ifconfig $i 2>/dev/null | grep 'inet 10.99' | awk '{print $2}')
            [ -n "$IP" ] && echo $IP && break
        done
    """, local=local)
    vpn = vpn_ip.strip() if vpn_ip else ""
    status["vpn_ip"] = vpn if "10.99" in vpn else ""

    # 주요 프로세스/인스턴스
    processes = ssh_cmd(host, """
        # Docker 컨테이너
        docker ps --format '{{.Names}}' 2>/dev/null | head -5 | while read c; do echo "🐳 $c"; done
        # Node.js
        pgrep -fa 'node ' 2>/dev/null | grep -v grep | awk '{print "📦 " $NF}' | head -3
        # Python 서버
        pgrep -fa 'python.*serve|python.*server|uvicorn|gunicorn' 2>/dev/null | grep -v grep | awk '{for(i=2;i<=NF;i++) if($i ~ /\.py|serve|server/) {print "🐍 " $i; break}}' | head -3
        # Nginx
        pgrep nginx >/dev/null 2>&1 && echo "🌐 nginx"
        # PostgreSQL
        pgrep postgres >/dev/null 2>&1 && echo "🐘 postgres"
        # Redis
        pgrep redis >/dev/null 2>&1 && echo "📮 redis"
        # MySQL/MariaDB
        pgrep -f 'mysql|mariadb' >/dev/null 2>&1 && echo "🗃️ mysql"
        # Icecast
        pgrep icecast >/dev/null 2>&1 && echo "📻 icecast"
        # Liquidsoap
        pgrep liquidsoap >/dev/null 2>&1 && echo "🎵 liquidsoap"
    """, timeout=15, local=local)
    if processes and not processes.startswith("ERROR"):
        status["processes"] = [p.strip() for p in processes.strip().split('\n') if p.strip()][:10]
    else:
        status["processes"] = []

    # 열린 포트 (주요 서비스)
    ports = ssh_cmd(host, """
        ss -tlnp 2>/dev/null | awk 'NR>1 {split($4,a,":"); port=a[length(a)]; if(port ~ /^[0-9]+$/ && port > 0 && port < 65536) print port}' | sort -nu | head -15 || \
        netstat -tlnp 2>/dev/null | awk 'NR>2 {split($4,a,":"); print a[length(a)]}' | sort -nu | head -15 || \
        lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | awk 'NR>1 {split($9,a,":"); print a[length(a)]}' | sort -nu | head -15
    """, timeout=10, local=local)
    if ports and not ports.startswith("ERROR"):
        status["ports"] = [int(p) for p in ports.strip().split('\n') if p.strip().isdigit()][:15]
    else:
        status["ports"] = []

    # 방화벽 상태
    firewall = ssh_cmd(host, """
        if command -v ufw >/dev/null 2>&1; then
            status=$(ufw status 2>/dev/null | head -1)
            if echo "$status" | grep -q "active"; then
                echo "ufw:active"
            else
                echo "ufw:inactive"
            fi
        elif command -v firewall-cmd >/dev/null 2>&1; then
            if firewall-cmd --state 2>/dev/null | grep -q running; then
                echo "firewalld:active"
            else
                echo "firewalld:inactive"
            fi
        elif [ -f /etc/pf.conf ]; then
            echo "pf:macOS"
        else
            echo "none"
        fi
    """, timeout=5, local=local)
    status["firewall"] = firewall.strip() if firewall and not firewall.startswith("ERROR") else "unknown"

    # 최근 백업 (일반적인 백업 디렉토리 확인)
    backup = ssh_cmd(host, """
        for dir in /root/backups /var/backups /backup ~/backups; do
            if [ -d "$dir" ]; then
                latest=$(ls -t "$dir" 2>/dev/null | head -1)
                if [ -n "$latest" ]; then
                    date=$(stat -c %Y "$dir/$latest" 2>/dev/null || stat -f %m "$dir/$latest" 2>/dev/null)
                    if [ -n "$date" ]; then
                        days=$(( ($(date +%s) - $date) / 86400 ))
                        echo "$dir:$days days ago"
                        exit 0
                    fi
                fi
            fi
        done
        echo "none"
    """, timeout=10, local=local)
    status["backup"] = backup.strip() if backup and not backup.startswith("ERROR") else "unknown"

    return status

def get_wire_status() -> str:
    try:
        result = subprocess.run(
            ["python3", "-m", "wire", "status"],
            capture_output=True, text=True, timeout=30,
            cwd=os.path.expanduser("~/wire")
        )
        return result.stdout.strip()
    except Exception as e:
        return f"Error: {e}"

def update_cache():
    """백그라운드에서 캐시 업데이트 - Agent 리포트 우선, SSH 폴백"""
    while True:
        try:
            # VPN 피어 목록 자동 갱신
            peer_count = fetch_vpn_peers()
            if peer_count > 0:
                print(f"[VPN] {peer_count} peers discovered")

            results = []
            now = time.time()

            # Agent 리포트 확인 (60초 이내면 유효)
            with cache_lock:
                agent_reports = cache.get("agent_reports", {})

            # 각 서버에 대해: agent 리포트 있으면 사용, 없으면 SSH 폴링
            servers_to_poll = []
            for name, info in SERVERS.items():
                # hostname으로 agent 리포트 찾기
                agent_data = None
                for hostname, server_name in HOSTNAME_MAP.items():
                    if server_name == name and hostname in agent_reports:
                        report = agent_reports[hostname]
                        if now - report.get('last_seen', 0) < 60:
                            agent_data = report
                            break

                if agent_data:
                    # Agent 리포트 사용
                    status = {
                        "name": name,
                        "host": info["host"],
                        "desc": info["desc"],
                        "icon": info.get("icon", "🖥️"),
                        "online": True,
                        "source": "agent",
                        **{k: v for k, v in agent_data.items() if k not in ['hostname', 'timestamp', 'last_seen']}
                    }
                    results.append(status)
                else:
                    servers_to_poll.append((name, info))

            # SSH 폴링 (agent 리포트 없는 서버들만)
            if servers_to_poll:
                with ThreadPoolExecutor(max_workers=6) as executor:
                    futures = {executor.submit(get_server_status, name, info): name
                               for name, info in servers_to_poll}
                    for future in as_completed(futures):
                        result = future.result()
                        result["source"] = "ssh"
                        results.append(result)

            results.sort(key=lambda x: x["name"])

            vpn_status = get_wire_status()

            with cache_lock:
                cache["servers"] = results
                cache["vpn"] = vpn_status
                cache["last_update"] = datetime.now().isoformat()
        except Exception as e:
            print(f"Cache update error: {e}")

        time.sleep(30)  # 30초마다 업데이트

HTML = '''<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Server Dashboard</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            color: #eee;
            padding: 20px;
        }
        .header {
            text-align: center;
            margin-bottom: 30px;
        }
        .header h1 {
            font-size: 2em;
            background: linear-gradient(90deg, #00d4ff, #00ff88);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            margin-bottom: 5px;
        }
        .header .update-time {
            color: #888;
            font-size: 0.9em;
        }
        .grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
            gap: 20px;
            max-width: 1400px;
            margin: 0 auto;
        }
        .card {
            background: rgba(255,255,255,0.05);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(255,255,255,0.1);
            transition: transform 0.2s, box-shadow 0.2s;
        }
        .card:hover {
            transform: translateY(-5px);
            box-shadow: 0 10px 40px rgba(0,0,0,0.3);
        }
        .card.offline {
            opacity: 0.5;
            border-color: #ff4757;
        }
        .card-header {
            display: flex;
            align-items: center;
            gap: 12px;
            margin-bottom: 15px;
        }
        .card-icon {
            font-size: 2em;
        }
        .card-title {
            flex: 1;
        }
        .card-title h2 {
            font-size: 1.3em;
            margin-bottom: 2px;
        }
        .card-title .desc {
            color: #888;
            font-size: 0.85em;
        }
        .status-badge {
            padding: 4px 12px;
            border-radius: 20px;
            font-size: 0.8em;
            font-weight: 600;
        }
        .status-online { background: #00b894; color: #fff; }
        .status-warning { background: #fdcb6e; color: #2d3436; }
        .status-danger { background: #ff4757; color: #fff; }
        .status-offline { background: #636e72; color: #fff; }

        .metrics {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 12px;
            margin-bottom: 15px;
        }
        .metric {
            background: rgba(0,0,0,0.2);
            padding: 12px;
            border-radius: 10px;
        }
        .metric-label {
            font-size: 0.75em;
            color: #888;
            text-transform: uppercase;
            margin-bottom: 4px;
        }
        .metric-value {
            font-size: 1.1em;
            font-weight: 600;
        }
        .progress-bar {
            height: 6px;
            background: rgba(255,255,255,0.1);
            border-radius: 3px;
            margin-top: 8px;
            overflow: hidden;
        }
        .progress-fill {
            height: 100%;
            border-radius: 3px;
            transition: width 0.3s;
        }
        .progress-ok { background: linear-gradient(90deg, #00b894, #00cec9); }
        .progress-warn { background: linear-gradient(90deg, #fdcb6e, #e17055); }
        .progress-danger { background: linear-gradient(90deg, #ff4757, #c0392b); }

        .services {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
        }
        .service-tag {
            padding: 4px 10px;
            border-radius: 6px;
            font-size: 0.8em;
            display: flex;
            align-items: center;
            gap: 5px;
        }
        .service-active { background: rgba(0,184,148,0.2); color: #00b894; }
        .service-inactive { background: rgba(255,71,87,0.2); color: #ff4757; }

        .processes {
            margin-top: 12px;
            padding: 10px;
            background: rgba(0,0,0,0.2);
            border-radius: 8px;
        }
        .processes-title {
            font-size: 0.75em;
            color: #888;
            text-transform: uppercase;
            margin-bottom: 6px;
        }
        .process-list {
            display: flex;
            flex-wrap: wrap;
            gap: 6px;
        }
        .process-tag {
            padding: 3px 8px;
            background: rgba(0,212,255,0.15);
            color: #00d4ff;
            border-radius: 4px;
            font-size: 0.8em;
        }

        .ip-info {
            margin-top: 12px;
            padding-top: 12px;
            border-top: 1px solid rgba(255,255,255,0.1);
            font-size: 0.85em;
            color: #aaa;
        }
        .ip-info span { margin-right: 15px; }

        .extra-info {
            margin-top: 10px;
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            font-size: 0.75em;
        }
        .info-tag {
            padding: 3px 8px;
            border-radius: 4px;
            background: rgba(255,255,255,0.1);
        }
        .info-tag.ok { background: rgba(0,184,148,0.2); color: #00b894; }
        .info-tag.warn { background: rgba(253,203,110,0.2); color: #fdcb6e; }
        .info-tag.danger { background: rgba(255,71,87,0.2); color: #ff4757; }

        .topology-section, .vpn-section, .ai-section {
            max-width: 1400px;
            margin: 30px auto 0;
            background: rgba(255,255,255,0.05);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(255,255,255,0.1);
        }
        .topology-section h3, .vpn-section h3, .ai-section h3 {
            margin-bottom: 15px;
            color: #00d4ff;
        }
        .topology-container {
            position: relative;
            width: 100%;
            height: 500px;
            background: linear-gradient(180deg, rgba(0,20,40,0.8) 0%, rgba(0,10,30,0.9) 100%);
            border-radius: 12px;
            overflow: hidden;
        }
        .topology-svg {
            width: 100%;
            height: 100%;
        }
        .topo-group-bg {
            fill: rgba(255,255,255,0.02);
            stroke: rgba(255,255,255,0.08);
            stroke-width: 1;
            stroke-dasharray: 5,5;
        }
        .topo-group-label {
            fill: rgba(255,255,255,0.4);
            font-size: 11px;
            text-transform: uppercase;
            letter-spacing: 2px;
        }
        .topo-link {
            fill: none;
            stroke-linecap: round;
        }
        .topo-link-bg {
            stroke: rgba(0,212,255,0.1);
            stroke-width: 8;
        }
        .topo-link-line {
            stroke: rgba(0,212,255,0.4);
            stroke-width: 2;
        }
        .topo-link-active .topo-link-line {
            stroke: #00d4ff;
            stroke-width: 3;
            filter: drop-shadow(0 0 6px #00d4ff);
        }
        .topo-link-flow {
            stroke: #00ff88;
            stroke-width: 4;
            stroke-dasharray: 8,12;
            animation: flow 1s linear infinite;
        }
        @keyframes flow {
            to { stroke-dashoffset: -20; }
        }
        .topo-node {
            cursor: pointer;
        }
        .topo-node-glow {
            fill: none;
            stroke-width: 2;
            opacity: 0.5;
            animation: glow 2s ease-in-out infinite;
        }
        @keyframes glow {
            0%, 100% { opacity: 0.3; stroke-width: 2; }
            50% { opacity: 0.7; stroke-width: 4; }
        }
        .topo-node-ring {
            fill: none;
            stroke-width: 3;
            opacity: 0.8;
        }
        .topo-node-core {
            filter: drop-shadow(0 4px 8px rgba(0,0,0,0.5));
        }
        .topo-node-icon {
            font-size: 20px;
            text-anchor: middle;
            dominant-baseline: middle;
        }
        .topo-node-name {
            fill: #fff;
            font-size: 13px;
            font-weight: 600;
            text-anchor: middle;
            text-shadow: 0 2px 4px rgba(0,0,0,0.8);
        }
        .topo-node-status {
            fill: #aaa;
            font-size: 10px;
            text-anchor: middle;
        }
        .topo-node-badge {
            font-size: 9px;
            fill: #fff;
        }
        .topo-tooltip {
            position: absolute;
            background: rgba(20,30,50,0.95);
            border: 1px solid rgba(0,212,255,0.5);
            border-radius: 10px;
            padding: 12px 16px;
            font-size: 12px;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.2s;
            z-index: 100;
            min-width: 180px;
            box-shadow: 0 8px 32px rgba(0,0,0,0.5);
        }
        .topo-tooltip.show { opacity: 1; }
        .topo-tooltip h4 { color: #00d4ff; margin-bottom: 8px; font-size: 14px; }
        .topo-tooltip-row { display: flex; justify-content: space-between; margin: 4px 0; }
        .topo-tooltip-label { color: #888; }
        .topo-tooltip-value { color: #fff; font-weight: 500; }
        .topo-legend {
            position: absolute;
            bottom: 15px;
            left: 15px;
            background: rgba(0,0,0,0.6);
            padding: 12px 15px;
            border-radius: 10px;
            font-size: 11px;
            backdrop-filter: blur(10px);
        }
        .topo-legend-title { color: #888; margin-bottom: 8px; text-transform: uppercase; font-size: 10px; letter-spacing: 1px; }
        .topo-legend-item { display: flex; align-items: center; gap: 8px; margin: 5px 0; }
        .topo-legend-dot { width: 12px; height: 12px; border-radius: 50%; }
        .topo-stats {
            position: absolute;
            top: 15px;
            right: 15px;
            background: rgba(0,0,0,0.6);
            padding: 12px 15px;
            border-radius: 10px;
            font-size: 12px;
            backdrop-filter: blur(10px);
        }
        .topo-stats-row { display: flex; align-items: center; gap: 10px; margin: 5px 0; }
        .topo-stats-num { font-size: 18px; font-weight: 700; color: #00d4ff; }
        .topo-stats-label { color: #888; }
        .vpn-output {
            font-family: 'Monaco', 'Menlo', monospace;
            font-size: 0.85em;
            white-space: pre;
            overflow-x: auto;
            background: rgba(0,0,0,0.3);
            padding: 15px;
            border-radius: 8px;
            line-height: 1.5;
        }

        .ai-input-row {
            display: flex;
            gap: 10px;
            margin-bottom: 15px;
        }
        .ai-input {
            flex: 1;
            padding: 12px 15px;
            border: 1px solid rgba(255,255,255,0.2);
            border-radius: 8px;
            background: rgba(0,0,0,0.3);
            color: #fff;
            font-size: 1em;
        }
        .ai-input:focus {
            outline: none;
            border-color: #00d4ff;
        }
        .ai-btn {
            padding: 12px 25px;
            background: linear-gradient(135deg, #00d4ff, #00ff88);
            border: none;
            border-radius: 8px;
            color: #1a1a2e;
            font-weight: 600;
            cursor: pointer;
            transition: transform 0.2s;
        }
        .ai-btn:hover { transform: scale(1.05); }
        .ai-btn:disabled { opacity: 0.5; cursor: not-allowed; }
        .ai-response {
            background: rgba(0,0,0,0.3);
            padding: 15px;
            border-radius: 8px;
            min-height: 100px;
            white-space: pre-wrap;
            line-height: 1.6;
        }
        .ai-response.loading {
            color: #888;
            animation: pulse 1.5s infinite;
        }
        @keyframes pulse { 50% { opacity: 0.5; } }

        .refresh-btn {
            position: fixed;
            bottom: 20px;
            right: 20px;
            width: 50px;
            height: 50px;
            border-radius: 50%;
            background: linear-gradient(135deg, #00d4ff, #00ff88);
            border: none;
            cursor: pointer;
            font-size: 1.5em;
            color: #1a1a2e;
            box-shadow: 0 4px 15px rgba(0,212,255,0.4);
            transition: transform 0.2s;
        }
        .refresh-btn:hover { transform: scale(1.1); }
        .refresh-btn.loading { animation: spin 1s linear infinite; }
        @keyframes spin { 100% { transform: rotate(360deg); } }
    </style>
</head>
<body>
    <div class="header">
        <h1>Server Dashboard</h1>
        <div class="update-time" id="updateTime">Loading...</div>
    </div>

    <div class="grid" id="serverGrid"></div>

    <div class="topology-section">
        <h3>Network Topology</h3>
        <div class="topology-container" id="topoContainer">
            <svg class="topology-svg" id="topologySvg">
                <defs>
                    <linearGradient id="linkGradient" x1="0%" y1="0%" x2="100%" y2="0%">
                        <stop offset="0%" style="stop-color:#00d4ff;stop-opacity:0.8"/>
                        <stop offset="100%" style="stop-color:#00ff88;stop-opacity:0.8"/>
                    </linearGradient>
                    <filter id="glow">
                        <feGaussianBlur stdDeviation="3" result="blur"/>
                        <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
                    </filter>
                    <radialGradient id="nodeGradient" cx="30%" cy="30%">
                        <stop offset="0%" style="stop-color:#fff;stop-opacity:0.3"/>
                        <stop offset="100%" style="stop-color:#000;stop-opacity:0.1"/>
                    </radialGradient>
                </defs>
            </svg>
            <div class="topo-tooltip" id="topoTooltip"></div>
            <div class="topo-legend">
                <div class="topo-legend-title">Status</div>
                <div class="topo-legend-item"><span class="topo-legend-dot" style="background:#00b894"></span> Online</div>
                <div class="topo-legend-item"><span class="topo-legend-dot" style="background:#fdcb6e"></span> Warning (80%+)</div>
                <div class="topo-legend-item"><span class="topo-legend-dot" style="background:#ff4757"></span> Critical (90%+)</div>
                <div class="topo-legend-item"><span class="topo-legend-dot" style="background:#636e72"></span> Offline</div>
            </div>
            <div class="topo-stats" id="topoStats"></div>
        </div>
    </div>

    <div class="vpn-section">
        <h3>Autowire VPN Status</h3>
        <div class="vpn-output" id="vpnOutput">Loading...</div>
    </div>

    <div class="ai-section">
        <h3>AI Assistant</h3>
        <div class="ai-input-row">
            <input type="text" class="ai-input" id="aiInput"
                   placeholder="서버 문제를 설명하세요... (예: g2 디스크 정리해줘, v2 메모리 부족 원인 분석)"
                   onkeypress="if(event.key==='Enter') askAI()">
            <select id="aiModel" style="padding:8px 12px;border-radius:8px;background:#1a2332;color:#4ec9b0;border:1px solid #2d3748;font-size:13px;cursor:pointer">
                <option value="ollama">🦙 Local LLM (Ollama)</option>
                <option value="gpt">🤖 GPT-4o-mini</option>
                <option value="claude">🔮 Claude CLI</option>
                <option value="template">📋 Template (No AI)</option>
            </select>
            <button class="ai-btn" onclick="askAI()" id="aiBtn">분석</button>
        </div>
        <div class="ai-response" id="aiResponse">
서버 상태를 분석하거나 문제 해결 방법을 물어보세요.

예시:
• "현재 서버 상태 요약해줘"
• "디스크 사용량이 높은 서버 확인"
• "v2 서버 디스크 정리 방법"
• "백업 상태 점검"
        </div>
    </div>

    <button class="refresh-btn" onclick="refresh()" id="refreshBtn">↻</button>

    <script>
        function getStatusClass(server) {
            if (!server.online) return 'offline';
            if (server.disk_pct >= 90) return 'danger';
            if (server.disk_pct >= 80) return 'warning';
            return 'online';
        }

        function getNodeColor(server) {
            if (!server.online) return '#636e72';
            if (server.disk_pct >= 90) return '#ff4757';
            if (server.disk_pct >= 80) return '#fdcb6e';
            return '#00b894';
        }

        // 서버 그룹 분류 함수
        function classifyServer(name) {
            if (name.startsWith('v')) return 'cloud';
            if (name.startsWith('g')) return 'home';
            if (name.startsWith('d')) return 'office';
            if (name.toLowerCase().includes('mac') || name.startsWith('m')) return 'mobile';
            return 'other';
        }

        let topoServers = [];

        function renderTopology(servers) {
            topoServers = servers;
            const svg = document.getElementById('topologySvg');
            const container = document.getElementById('topoContainer');
            const rect = container.getBoundingClientRect();
            const width = rect.width || 900;
            const height = rect.height || 500;

            // 동적 위치 계산
            const groups = { cloud: [], home: [], office: [], mobile: [], other: [] };
            servers.forEach(s => {
                const group = classifyServer(s.name);
                groups[group].push(s.name);
            });

            const positions = {};
            // Cloud (상단 중앙)
            groups.cloud.forEach((name, i) => {
                positions[name] = { x: width * (0.4 + i * 0.15), y: height * 0.15 };
            });
            // Home (좌측)
            groups.home.forEach((name, i) => {
                positions[name] = { x: width * 0.15, y: height * (0.3 + i * 0.15) };
            });
            // Office (우측)
            groups.office.forEach((name, i) => {
                positions[name] = { x: width * 0.85, y: height * (0.4 + i * 0.15) };
            });
            // Mobile (하단)
            groups.mobile.forEach((name, i) => {
                positions[name] = { x: width * (0.45 + i * 0.1), y: height * 0.85 };
            });
            // Other (중앙 아래)
            groups.other.forEach((name, i) => {
                positions[name] = { x: width * (0.3 + i * 0.1), y: height * 0.7 };
            });

            // 노드 데이터 생성
            const nodes = servers.map(s => ({
                ...s,
                x: positions[s.name]?.x || width/2,
                y: positions[s.name]?.y || height/2
            }));

            // 첫 번째 온라인 릴레이를 허브로 사용
            const relayNode = nodes.find(n => n.name.startsWith('v') && n.online) || nodes.find(n => n.name.startsWith('v'));

            // SVG 생성
            let html = '';

            // 동적 그룹 배경 (서버가 있는 그룹만 표시)
            const groupLabels = {
                cloud: '☁️ Cloud (Relays)',
                home: '🏠 집 (Home)',
                office: '🏢 회사 (Office)',
                mobile: '💻 Mobile',
                other: '🖥️ Other'
            };

            const groupBoxes = {
                cloud: { x: width * 0.35, y: height * 0.05, w: width * 0.4, h: height * 0.2 },
                home: { x: width * 0.05, y: height * 0.25, w: width * 0.25, h: height * 0.5 },
                office: { x: width * 0.75, y: height * 0.35, w: width * 0.2, h: height * 0.35 },
                mobile: { x: width * 0.38, y: height * 0.78, w: width * 0.24, h: height * 0.18 },
                other: { x: width * 0.25, y: height * 0.65, w: width * 0.25, h: height * 0.15 }
            };

            Object.entries(groups).forEach(([key, members]) => {
                if (members.length === 0) return;
                const box = groupBoxes[key];
                if (!box) return;
                html += `<rect class="topo-group-bg" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" rx="15"/>`;
                html += `<text class="topo-group-label" x="${box.x + 10}" y="${box.y + 18}">${groupLabels[key]} (${members.length})</text>`;
            });

            // 연결선 (v1을 허브로)
            if (relayNode) {
                nodes.forEach(n => {
                    if (!n.name.startsWith('v')) {  // 릴레이가 아닌 노드만
                        const hasVpn = n.vpn_ip && n.vpn_ip.startsWith('10.99');
                        const isAgent = n.source && n.source.includes('age');
                        const isActive = n.online && hasVpn;

                        // 베지어 곡선으로 연결
                        const midX = (relayNode.x + n.x) / 2;
                        const midY = (relayNode.y + n.y) / 2 - 20;

                        html += `<g class="topo-link ${isActive ? 'topo-link-active' : ''}">`;
                        html += `<path class="topo-link-bg" d="M${relayNode.x},${relayNode.y} Q${midX},${midY} ${n.x},${n.y}"/>`;
                        html += `<path class="topo-link-line" d="M${relayNode.x},${relayNode.y} Q${midX},${midY} ${n.x},${n.y}"/>`;
                        if (isAgent && n.online) {
                            html += `<path class="topo-link-flow" d="M${relayNode.x},${relayNode.y} Q${midX},${midY} ${n.x},${n.y}"/>`;
                        }
                        html += `</g>`;
                    }
                });
            }

            // 노드 위치 저장 초기화
            nodePositions = {};

            // 노드 렌더링
            nodes.forEach(n => {
                const color = getNodeColor(n);
                const isV1 = n.name === 'v1';
                const r = isV1 ? 38 : 30;
                const isAgent = n.source && n.source.includes('age');

                // 위치 저장
                nodePositions[n.name] = { x: n.x, y: n.y };

                html += `<g class="topo-node" transform="translate(${n.x},${n.y})"
                           onmouseenter="showTopoTooltip(event, '${n.name}')"
                           onmouseleave="hideTopoTooltip()">`;

                // 글로우 효과 (온라인)
                if (n.online) {
                    html += `<circle class="topo-node-glow" r="${r + 8}" stroke="${color}"/>`;
                }

                // 외곽 링
                html += `<circle class="topo-node-ring" r="${r + 4}" stroke="${color}"/>`;

                // 코어 원
                html += `<circle class="topo-node-core" r="${r}" fill="${color}"/>`;
                html += `<circle r="${r}" fill="url(#nodeGradient)"/>`;

                // 아이콘
                const icon = n.icon || '🖥️';
                html += `<text class="topo-node-icon" y="-5">${icon}</text>`;

                // 이름
                html += `<text class="topo-node-name" y="${r/2 + 8}">${n.name.toUpperCase()}</text>`;

                // 상태 뱃지 (디스크 사용률)
                if (n.online) {
                    const badgeColor = n.disk_pct >= 80 ? '#ff4757' : '#00b894';
                    html += `<g transform="translate(${r-5}, ${-r+5})">
                        <circle r="12" fill="${badgeColor}"/>
                        <text class="topo-node-badge" text-anchor="middle" y="4">${n.disk_pct || 0}%</text>
                    </g>`;

                    // 에이전트 표시
                    if (isAgent) {
                        html += `<g transform="translate(${-r+5}, ${-r+5})">
                            <circle r="10" fill="#00d4ff"/>
                            <text class="topo-node-badge" text-anchor="middle" y="4">📡</text>
                        </g>`;
                    }
                }

                html += `</g>`;
            });

            // 기존 defs 유지하면서 내용 추가
            const defs = svg.querySelector('defs').outerHTML;
            svg.innerHTML = defs + html;

            // 통계 업데이트
            const online = servers.filter(s => s.online).length;
            const agents = servers.filter(s => s.source && s.source.includes('age')).length;
            const warnings = servers.filter(s => s.online && s.disk_pct >= 80).length;

            document.getElementById('topoStats').innerHTML = `
                <div class="topo-stats-row"><span class="topo-stats-num">${online}/${servers.length}</span><span class="topo-stats-label">Online</span></div>
                <div class="topo-stats-row"><span class="topo-stats-num" style="color:#00ff88">${agents}</span><span class="topo-stats-label">Agents</span></div>
                ${warnings > 0 ? `<div class="topo-stats-row"><span class="topo-stats-num" style="color:#ff4757">${warnings}</span><span class="topo-stats-label">Warnings</span></div>` : ''}
            `;
        }

        // 노드 위치 저장
        let nodePositions = {};

        function showTopoTooltip(event, name) {
            const server = topoServers.find(s => s.name === name);
            if (!server) return;

            const tooltip = document.getElementById('topoTooltip');
            const container = document.getElementById('topoContainer');
            const containerRect = container.getBoundingClientRect();

            const diskInfo = server.disks && server.disks.length > 0
                ? server.disks.slice(0, 3).map(d => `${d.mount}: ${d.percent}%`).join('<br>')
                : `Root: ${server.disk_pct || 0}%`;

            tooltip.innerHTML = `
                <h4>${server.icon || '🖥️'} ${server.name.toUpperCase()} - ${server.desc || ''}</h4>
                <div class="topo-tooltip-row"><span class="topo-tooltip-label">Status</span><span class="topo-tooltip-value">${server.online ? '🟢 Online' : '🔴 Offline'}</span></div>
                <div class="topo-tooltip-row"><span class="topo-tooltip-label">Memory</span><span class="topo-tooltip-value">${server.memory || '?'}</span></div>
                <div class="topo-tooltip-row"><span class="topo-tooltip-label">Disk</span><span class="topo-tooltip-value">${diskInfo}</span></div>
                ${server.vpn_ip ? `<div class="topo-tooltip-row"><span class="topo-tooltip-label">VPN IP</span><span class="topo-tooltip-value">${server.vpn_ip}</span></div>` : ''}
                <div class="topo-tooltip-row"><span class="topo-tooltip-label">Source</span><span class="topo-tooltip-value">${server.source?.includes('age') ? '📡 Agent' : '🔗 SSH'}</span></div>
            `;

            // 노드 위치 기반으로 툴팁 배치 (저장된 위치 사용)
            const pos = nodePositions[name];
            if (pos) {
                let left = pos.x + 45;
                let top = pos.y - 60;

                // 오른쪽 넘침 방지
                if (left + 200 > containerRect.width) {
                    left = pos.x - 200;
                }
                // 상단 넘침 방지
                if (top < 10) {
                    top = pos.y + 45;
                }

                tooltip.style.left = left + 'px';
                tooltip.style.top = top + 'px';
            }
            tooltip.classList.add('show');
        }

        function hideTopoTooltip() {
            document.getElementById('topoTooltip').classList.remove('show');
        }

        function getProgressClass(pct) {
            if (pct >= 90) return 'progress-danger';
            if (pct >= 80) return 'progress-warn';
            return 'progress-ok';
        }

        function renderServer(s) {
            const statusClass = getStatusClass(s);
            const statusText = !s.online ? 'OFFLINE' :
                              s.disk_pct >= 90 ? 'CRITICAL' :
                              s.disk_pct >= 80 ? 'WARNING' : 'ONLINE';

            let servicesHtml = '';
            if (s.services && Object.keys(s.services).length > 0) {
                // Only show active services
                const activeServices = Object.entries(s.services).filter(([name, active]) => active);
                if (activeServices.length > 0) {
                    servicesHtml = '<div class="services">' +
                        activeServices.map(([name, active]) =>
                            `<span class="service-tag service-active">✓ ${name}</span>`
                        ).join('') + '</div>';
                }
            }

            let processesHtml = '';
            if (s.processes && s.processes.length > 0) {
                processesHtml = `
                    <div class="processes">
                        <div class="processes-title">Running Processes</div>
                        <div class="process-list">
                            ${s.processes.map(p => `<span class="process-tag">${p}</span>`).join('')}
                        </div>
                    </div>`;
            }

            // Network traffic formatting
            function formatBytes(bytes) {
                if (!bytes || bytes === 0) return '0 B';
                const k = 1024;
                const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
                const i = Math.floor(Math.log(bytes) / Math.log(k));
                return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
            }

            let networkHtml = '';
            if (s.network && (s.network.total_rx > 0 || s.network.total_tx > 0)) {
                networkHtml = `
                    <div class="processes" style="margin-top:8px;">
                        <div class="processes-title">Network Traffic</div>
                        <div style="display:flex;gap:15px;font-size:0.85em;color:#aaa;">
                            <span>↓ ${formatBytes(s.network.total_rx)}</span>
                            <span>↑ ${formatBytes(s.network.total_tx)}</span>
                        </div>
                    </div>`;
            }

            let icecastHtml = '';
            if (s.icecast && s.icecast.listeners !== undefined) {
                icecastHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(0,212,255,0.1);">
                        <div class="processes-title">📻 Radio Stream</div>
                        <div style="display:flex;gap:15px;font-size:0.85em;color:#00d4ff;">
                            <span>👥 ${s.icecast.listeners} listeners</span>
                            <span>📊 Peak: ${s.icecast.peak_listeners}</span>
                            ${s.icecast.bitrate ? `<span>🎵 ${s.icecast.bitrate}kbps</span>` : ''}
                        </div>
                        ${s.icecast.title ? `<div style="font-size:0.8em;color:#888;margin-top:4px;">Now: ${s.icecast.title}</div>` : ''}
                    </div>`;
            }

            // 보안 이슈 표시 (v3.1 확장)
            let securityHtml = '';
            if (s.security) {
                const sec = s.security;
                const hasIssues = sec.failed_logins_1h > 10 || sec.suspicious_processes > 0 || sec.unique_attackers > 5;
                if (hasIssues || sec.ssh_sessions > 0 || sec.fail2ban_banned > 0) {
                    securityHtml = `
                        <div class="processes" style="margin-top:8px;background:rgba(${hasIssues?'255,71,87':'100,100,100'},0.1);border-left:3px solid ${hasIssues?'#ff4757':'#666'};">
                            <div class="processes-title" style="color:${hasIssues?'#ff4757':'#888'};">🛡️ Security</div>
                            <div style="font-size:0.8em;display:flex;flex-wrap:wrap;gap:10px;">
                                ${sec.ssh_sessions > 0 ? `<span>👥 Sessions: ${sec.ssh_sessions}</span>` : ''}
                                ${sec.failed_logins_1h > 0 ? `<span style="color:${sec.failed_logins_1h > 10 ? '#ff4757' : '#ffa502'};">⛔ Failed: ${sec.failed_logins_1h}</span>` : ''}
                                ${sec.unique_attackers > 0 ? `<span style="color:#e84393;">🎯 Attackers: ${sec.unique_attackers}</span>` : ''}
                                ${sec.fail2ban_banned > 0 ? `<span style="color:#00b894;">🚫 Banned: ${sec.fail2ban_banned}</span>` : ''}
                                ${sec.suspicious_processes > 0 ? `<span style="color:#ff0000;">⚠️ Suspicious: ${sec.suspicious_processes}</span>` : ''}
                                ${sec.sudo_commands_1h > 0 ? `<span>🔑 Sudo: ${sec.sudo_commands_1h}</span>` : ''}
                            </div>
                            ${sec.last_login ? `<div style="font-size:0.7em;color:#888;margin-top:4px;">Last: ${sec.last_login}</div>` : ''}
                        </div>`;
                }
            } else if (s.security && s.security.length > 0) {
                const levelColors = {critical: '#ff4757', warning: '#ffa502', info: '#00d4ff'};
                const levelIcons = {critical: '🚨', warning: '⚠️', info: 'ℹ️'};
                securityHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(255,71,87,0.1);border-left:3px solid #ff4757;">
                        <div class="processes-title" style="color:#ff4757;">🛡️ Security</div>
                        <div style="font-size:0.8em;">
                            ${s.security.map(issue => `
                                <div style="color:${levelColors[issue.level] || '#888'};margin:3px 0;">
                                    ${levelIcons[issue.level] || '•'} ${issue.msg}
                                </div>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // 로그 이슈 표시
            let logsHtml = '';
            if (s.logs && s.logs.length > 0) {
                const levelColors = {critical: '#ff4757', error: '#ff6b6b', warning: '#ffa502'};
                logsHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(255,165,2,0.1);border-left:3px solid #ffa502;">
                        <div class="processes-title" style="color:#ffa502;">📋 Recent Logs</div>
                        <div style="font-size:0.75em;max-height:80px;overflow-y:auto;">
                            ${s.logs.map(log => `
                                <div style="color:${levelColors[log.level] || '#888'};margin:2px 0;word-break:break-all;">
                                    ${log.msg}
                                </div>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // GPU 사용량 (v3.1)
            let gpuHtml = '';
            if (s.gpu && s.gpu.available && s.gpu.gpus && s.gpu.gpus.length > 0) {
                gpuHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(118,185,0,0.1);border-left:3px solid #76b900;">
                        <div class="processes-title" style="color:#76b900;">🎮 GPU</div>
                        <div style="font-size:0.8em;">
                            ${s.gpu.gpus.map(g => `
                                <div style="margin:3px 0;display:flex;justify-content:space-between;">
                                    <span>${g.name}</span>
                                    <span>GPU: ${g.gpu_pct}% | Mem: ${g.mem_used}/${g.mem_total}MB | ${g.temp}°C</span>
                                </div>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // 네트워크 연결 (v3)
            let connectionsHtml = '';
            if (s.network && s.network.connections) {
                const c = s.network.connections;
                connectionsHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(0,212,255,0.1);">
                        <div class="processes-title" style="color:#00d4ff;">🔌 Connections</div>
                        <div style="display:flex;gap:12px;font-size:0.8em;flex-wrap:wrap;">
                            <span>ESTAB: ${c.established || 0}</span>
                            <span>LISTEN: ${c.listen || 0}</span>
                            <span>TIME_WAIT: ${c.time_wait || 0}</span>
                            <span>CLOSE_WAIT: ${c.close_wait || 0}</span>
                        </div>
                    </div>`;
            }

            // 로그 이상 감지 (v3)
            let anomalyHtml = '';
            if (s.logs && s.logs.anomalies) {
                const a = s.logs.anomalies;
                if (a.errors > 0 || a.warnings > 0 || a.oom_kills > 0) {
                    anomalyHtml = `
                        <div class="processes" style="margin-top:8px;background:rgba(255,107,107,0.1);border-left:3px solid #ff6b6b;">
                            <div class="processes-title" style="color:#ff6b6b;">⚠️ Log Anomalies (1h)</div>
                            <div style="display:flex;gap:12px;font-size:0.8em;">
                                ${a.errors > 0 ? `<span style="color:#ff4757;">Errors: ${a.errors}</span>` : ''}
                                ${a.warnings > 0 ? `<span style="color:#ffa502;">Warnings: ${a.warnings}</span>` : ''}
                                ${a.oom_kills > 0 ? `<span style="color:#ff0000;">OOM: ${a.oom_kills}</span>` : ''}
                                ${a.auth_failures > 0 ? `<span style="color:#e84393;">Auth Fail: ${a.auth_failures}</span>` : ''}
                            </div>
                        </div>`;
                }
            }

            // 실패한 서비스 (v3.1)
            let failedHtml = '';
            if (s.failed_services && s.failed_services.length > 0) {
                failedHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(255,71,87,0.15);border-left:3px solid #ff4757;">
                        <div class="processes-title" style="color:#ff4757;">❌ Failed Services</div>
                        <div style="font-size:0.8em;color:#ff6b6b;">
                            ${s.failed_services.join(', ')}
                        </div>
                    </div>`;
            }

            // 컨테이너 상태 (v3.1)
            let containersHtml = '';
            if (s.containers && s.containers.length > 0) {
                containersHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(0,123,255,0.1);">
                        <div class="processes-title" style="color:#0d6efd;">🐳 Containers</div>
                        <div style="font-size:0.75em;display:flex;flex-wrap:wrap;gap:6px;">
                            ${s.containers.map(c => `
                                <span class="process-tag" style="background:${c.health==='healthy'?'rgba(0,184,148,0.3)':c.health==='unhealthy'?'rgba(255,71,87,0.3)':'rgba(100,100,100,0.3)'};">
                                    ${c.health==='healthy'?'✓':c.health==='unhealthy'?'✗':'•'} ${c.name}
                                </span>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // 커널/시스템 정보 (v3.1)
            let kernelHtml = '';
            if (s.kernel && s.kernel.kernel) {
                kernelHtml = `<span class="info-tag" style="background:rgba(108,92,231,0.2);color:#6c5ce7;">🐧 ${s.kernel.kernel}</span>`;
            }

            // Swap 사용량 (v3.1)
            let swapHtml = '';
            if (s.swap && s.swap.pct > 20) {
                swapHtml = `<span class="info-tag ${s.swap.pct > 80 ? 'danger' : 'warn'}">Swap: ${s.swap.pct}%</span>`;
            }

            // 웹서비스 (v3.2)
            let webHtml = '';
            if (s.web_services && s.web_services.length > 0) {
                webHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(46,204,113,0.1);border-left:3px solid #2ecc71;">
                        <div class="processes-title" style="color:#2ecc71;">🌐 Web Services</div>
                        <div style="display:flex;flex-wrap:wrap;gap:6px;font-size:0.8em;">
                            ${s.web_services.map(w => `
                                <span class="process-tag" style="background:rgba(46,204,113,0.2);">
                                    ${w.name}${w.port ? ':'+w.port : ''}
                                </span>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // Top 프로세스 (v3.2)
            let topProcHtml = '';
            if (s.top_processes && s.top_processes.length > 0) {
                topProcHtml = `
                    <div class="processes" style="margin-top:8px;">
                        <div class="processes-title">📊 Top Processes</div>
                        <div style="font-size:0.7em;display:grid;grid-template-columns:1fr 1fr;gap:2px;">
                            ${s.top_processes.slice(0,6).map(p => `
                                <span style="color:${p.cpu > 50 ? '#ff4757' : p.cpu > 20 ? '#ffa502' : '#888'};">
                                    ${p.name} (${p.cpu.toFixed(1)}%)
                                </span>
                            `).join('')}
                        </div>
                    </div>`;
            }

            // 시스템 메일 (v3.2)
            let mailHtml = '';
            if (s.mail && (s.mail.count > 0 || s.mail.cron_errors > 0)) {
                mailHtml = `
                    <div class="processes" style="margin-top:8px;background:rgba(155,89,182,0.1);border-left:3px solid #9b59b6;">
                        <div class="processes-title" style="color:#9b59b6;">📬 System Mail</div>
                        <div style="font-size:0.8em;">
                            <span>📧 ${s.mail.count} messages</span>
                            ${s.mail.cron_errors > 0 ? `<span style="color:#ff4757;"> | ⚠️ ${s.mail.cron_errors} errors</span>` : ''}
                        </div>
                        ${s.mail.recent && s.mail.recent.length > 0 ? `
                            <div style="font-size:0.7em;color:#888;margin-top:4px;max-height:40px;overflow:hidden;">
                                ${s.mail.recent.slice(0,2).map(m => `<div>• ${m}</div>`).join('')}
                            </div>` : ''}
                    </div>`;
            }

            // 보안 업데이트 (v3.2)
            let updatesHtml = '';
            if (s.updates && s.updates.security > 0) {
                updatesHtml = `<span class="info-tag danger">🔒 ${s.updates.security} security updates</span>`;
            } else if (s.updates && s.updates.available > 10) {
                updatesHtml = `<span class="info-tag warn">📦 ${s.updates.available} updates</span>`;
            }

            return `
                <div class="card ${!s.online ? 'offline' : ''}">
                    <div class="card-header">
                        <span class="card-icon">${s.icon || '🖥️'}</span>
                        <div class="card-title">
                            <h2>${s.name}</h2>
                            <div class="desc">${s.desc}</div>
                        </div>
                        <span class="status-badge status-${statusClass}">${statusText}</span>
                    </div>
                    ${s.online ? `
                        <div class="metrics">
                            <div class="metric">
                                <div class="metric-label">Disk</div>
                                <div class="metric-value">${s.disk_used || '?'}</div>
                                <div class="progress-bar">
                                    <div class="progress-fill ${getProgressClass(s.disk_pct || 0)}"
                                         style="width: ${s.disk_pct || 0}%"></div>
                                </div>
                            </div>
                            <div class="metric">
                                <div class="metric-label">Memory</div>
                                <div class="metric-value">${s.memory || '?'}</div>
                                ${s.mem_pct ? `
                                <div class="progress-bar">
                                    <div class="progress-fill ${getProgressClass(s.mem_pct)}"
                                         style="width: ${s.mem_pct}%"></div>
                                </div>` : ''}
                            </div>
                            <div class="metric">
                                <div class="metric-label">Load</div>
                                <div class="metric-value">${s.load || '?'}</div>
                            </div>
                            <div class="metric">
                                <div class="metric-label">Uptime</div>
                                <div class="metric-value">${(s.uptime || '?').substring(0, 15)}</div>
                            </div>
                        </div>
                        ${servicesHtml}
                        ${processesHtml}
                        ${networkHtml}
                        ${connectionsHtml}
                        ${gpuHtml}
                        ${containersHtml}
                        ${anomalyHtml}
                        ${failedHtml}
                        ${icecastHtml}
                        ${securityHtml}
                        ${logsHtml}
                        ${webHtml}
                        ${topProcHtml}
                        ${mailHtml}
                        <div class="extra-info">
                            ${kernelHtml}
                            ${swapHtml}
                            ${updatesHtml}
                            ${s.ports && s.ports.length > 0 ?
                                `<span class="info-tag">Ports: ${s.ports.slice(0,6).join(', ')}${s.ports.length > 6 ? '...' : ''}</span>` : ''}
                            ${s.firewall ?
                                `<span class="info-tag ${s.firewall.includes('active') ? 'ok' : s.firewall === 'none' ? 'warn' : ''}">${s.firewall}</span>` : ''}
                            ${s.backup && s.backup !== 'none' && s.backup !== 'unknown' ?
                                `<span class="info-tag ${parseInt(s.backup.match(/\\d+/)) > 7 ? 'warn' : 'ok'}">Backup: ${s.backup.split(':')[1] || s.backup}</span>` : ''}
                        </div>
                        <div class="ip-info">
                            <span>Public: ${s.public_ip || '?'}</span>
                            ${s.vpn_ip ? `<span>VPN: ${s.vpn_ip}</span>` : ''}
                        </div>
                    ` : `<p style="color:#888;margin-top:10px;">Server unreachable</p>`}
                </div>
            `;
        }

        async function loadData() {
            try {
                const res = await fetch('/api/status');
                const data = await res.json();

                document.getElementById('serverGrid').innerHTML =
                    data.servers.map(renderServer).join('');

                renderTopology(data.servers);

                document.getElementById('vpnOutput').textContent = data.vpn || 'No data';

                if (data.last_update) {
                    const d = new Date(data.last_update);
                    document.getElementById('updateTime').textContent =
                        `Last updated: ${d.toLocaleString('ko-KR')}`;
                }
            } catch (e) {
                console.error('Load error:', e);
            }
        }

        async function refresh() {
            const btn = document.getElementById('refreshBtn');
            btn.classList.add('loading');
            try {
                await fetch('/api/refresh', {method: 'POST'});
                await new Promise(r => setTimeout(r, 2000));
                await loadData();
            } finally {
                btn.classList.remove('loading');
            }
        }

        async function askAI() {
            const input = document.getElementById('aiInput');
            const btn = document.getElementById('aiBtn');
            const response = document.getElementById('aiResponse');
            const question = input.value.trim();

            if (!question) return;

            btn.disabled = true;
            response.classList.add('loading');
            response.textContent = '분석 중...';

            try {
                const model = document.getElementById('aiModel').value;
                const res = await fetch('/api/ai', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({question, model})
                });
                const data = await res.json();
                response.textContent = data.answer || data.error || 'No response';
            } catch (e) {
                response.textContent = 'Error: ' + e.message;
            } finally {
                btn.disabled = false;
                response.classList.remove('loading');
            }
        }

        loadData();
        setInterval(loadData, 30000);
    </script>
</body>
</html>
'''

class Handler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass  # 로그 끄기

    def _check_vpn_access(self):
        """VPN 내부 접근만 허용 (10.99.x.x 또는 localhost)"""
        client_ip = self.client_address[0]
        allowed = (
            client_ip.startswith("10.99.") or
            client_ip.startswith("127.") or
            client_ip == "::1" or
            client_ip.startswith("192.168.")  # 로컬 네트워크도 허용
        )
        if not allowed:
            self.send_response(403)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.end_headers()
            self.wfile.write(b'''
                <html><body style="background:#1a1a2e;color:#ff4757;font-family:sans-serif;
                display:flex;justify-content:center;align-items:center;height:100vh;margin:0;">
                <div style="text-align:center;">
                <h1>Access Denied</h1>
                <p>VPN connection required (10.99.x.x)</p>
                <p style="color:#888;">Your IP: ''' + client_ip.encode() + b'''</p>
                </div></body></html>
            ''')
            return False
        return True

    def do_GET(self):
        if not self._check_vpn_access():
            return
        if self.path == '/':
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.end_headers()
            self.wfile.write(HTML.encode())
        elif self.path == '/api/status':
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            with cache_lock:
                self.wfile.write(json.dumps(cache, ensure_ascii=False).encode())
            return
        elif self.path == '/api/topology':
            # 네트워크 토폴로지 (VPN에서 자동 감지)
            topology = get_network_topology()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps(topology, ensure_ascii=False).encode())
            return
        elif self.path == '/api/peers':
            # VPN 피어 목록 (raw)
            try:
                req = urllib.request.Request(f"{VPN_SERVER_URL}/peers")
                with urllib.request.urlopen(req, timeout=5) as resp:
                    data = resp.read()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(data)
            except Exception as e:
                self.send_response(500)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({"error": str(e)}).encode())
            return
        elif self.path.startswith('/api/mpop/'):
            # mpop 명령어 실행 API
            cmd_name = self.path.replace('/api/mpop/', '').split('?')[0]
            allowed_cmds = ['full', 'security', 'audit', 'matrix', 'services', 'servers', 'temp', 'gpu']
            if cmd_name not in allowed_cmds:
                self.send_response(400)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({"error": f"Command not allowed: {cmd_name}", "allowed": allowed_cmds}).encode())
                return
            try:
                result = subprocess.run(
                    ['/usr/local/bin/mpop', cmd_name],
                    capture_output=True, text=True, timeout=60
                )
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({
                    "command": f"mpop {cmd_name}",
                    "output": result.stdout,
                    "error": result.stderr if result.returncode != 0 else None,
                    "returncode": result.returncode
                }, ensure_ascii=False).encode())
            except subprocess.TimeoutExpired:
                self.send_response(504)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({"error": "Command timeout (60s)"}).encode())
            except Exception as e:
                self.send_response(500)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({"error": str(e)}).encode())
            return
        elif self.path == '/install.sh':
            # Agent 설치 스크립트 제공
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(INSTALL_SCRIPT.encode())
            return
        elif self.path == '/agent.py':
            # Agent 코드 제공
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(AGENT_CODE.encode())
            return
        elif self.path == '/docs' or self.path == '/docs/':
            # mpop 문서/랜딩페이지
            docs_path = '/root/mpop-landing/index.html'
            try:
                with open(docs_path, 'rb') as f:
                    content = f.read()
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.send_header('Content-Length', str(len(content)))
                self.end_headers()
                self.wfile.write(content)
            except FileNotFoundError:
                self.send_response(404)
                self.send_header('Content-Type', 'text/plain')
                self.end_headers()
                self.wfile.write(b'docs not found')
            return
        elif self.path == '/mpop' or self.path == '/mpop.py':
            # mpop CLI 제공 (self-update용)
            mpop_path = '/usr/local/bin/mpop'
            try:
                with open(mpop_path, 'rb') as f:
                    content = f.read()
                # Extract version from content
                version = '3.6.0'
                import re
                match = re.search(rb'VERSION\s*=\s*["\']([^"\']+)["\']', content)
                if match:
                    version = match.group(1).decode()
                self.send_response(200)
                self.send_header('Content-Type', 'application/octet-stream')
                self.send_header('Content-Length', str(len(content)))
                self.send_header('X-Mpop-Version', version)
                self.end_headers()
                self.wfile.write(content)
            except FileNotFoundError:
                self.send_response(404)
                self.send_header('Content-Type', 'text/plain')
                self.end_headers()
                self.wfile.write(b'mpop not found')
            return
        elif self.path in ('/vssh.py', '/vssh-amd64', '/vssh-arm64'):
            # vssh 파일 제공
            filename = self.path.lstrip('/')
            vssh_path = f'/root/server-agent/{filename}'
            try:
                with open(vssh_path, 'rb') as f:
                    content = f.read()
                self.send_response(200)
                self.send_header('Content-Type', 'application/octet-stream')
                self.send_header('Content-Length', str(len(content)))
                self.end_headers()
                self.wfile.write(content)
            except FileNotFoundError:
                self.send_response(404)
                self.send_header('Content-Type', 'text/plain')
                self.end_headers()
                self.wfile.write(f'{filename} not found'.encode())
            return
        elif self.path.startswith('/api/history'):
            # 히스토리 데이터 (최근 N시간)
            hours = 24
            if '?' in self.path:
                params = dict(p.split('=') for p in self.path.split('?')[1].split('&') if '=' in p)
                hours = int(params.get('hours', 24))
            records = load_history(hours)
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps({"hours": hours, "records": records}, ensure_ascii=False).encode())
            return
        elif self.path.startswith('/api/trends'):
            # 트렌드 데이터 (서버별 통계)
            hours = 24
            if '?' in self.path:
                params = dict(p.split('=') for p in self.path.split('?')[1].split('&') if '=' in p)
                hours = int(params.get('hours', 24))
            trends = get_trends(hours)
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps(trends, ensure_ascii=False).encode())
            return
        else:
            self.send_response(404)
            self.end_headers()

    def do_POST(self):
        if not self._check_vpn_access():
            return

        if self.path == '/api/ai':
            content_length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(content_length)) if content_length > 0 else {}
            question = body.get('question', '')
            model = body.get('model', 'ollama')

            # AI 분석 (현재 상태 기반)
            with cache_lock:
                servers = cache.get("servers", [])
                vpn = cache.get("vpn", "")

            answer = analyze_with_ai(question, servers, vpn, model=model)

            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(json.dumps({"answer": answer}, ensure_ascii=False).encode())
            return

        if self.path == '/api/refresh':
            # 즉시 캐시 업데이트 트리거
            threading.Thread(target=lambda: update_cache_once(), daemon=True).start()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(b'{"ok":true}')

        elif self.path == '/api/report':
            # Agent로부터 상태 보고 수신
            content_length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(content_length)) if content_length > 0 else {}

            hostname = body.get('hostname', '')
            if hostname:
                with cache_lock:
                    # agent_reports에 저장
                    if 'agent_reports' not in cache:
                        cache['agent_reports'] = {}
                    cache['agent_reports'][hostname] = body
                    cache['agent_reports'][hostname]['last_seen'] = time.time()

                    # 즉시 servers 배열 업데이트
                    server_name = HOSTNAME_MAP.get(hostname)
                    if server_name and server_name in SERVERS:
                        info = SERVERS[server_name]
                        new_status = {
                            "name": server_name,
                            "host": info["host"],
                            "desc": info["desc"],
                            "icon": info.get("icon", "🖥️"),
                            "online": True,
                            "source": "agent",
                            **{k: v for k, v in body.items() if k not in ['hostname', 'timestamp', 'last_seen']}
                        }
                        # servers 배열에서 해당 서버 업데이트 또는 추가
                        servers = cache.get("servers", [])
                        found = False
                        for i, s in enumerate(servers):
                            if s["name"] == server_name:
                                servers[i] = new_status
                                found = True
                                break
                        if not found:
                            servers.append(new_status)
                            servers.sort(key=lambda x: x["name"])
                        cache["servers"] = servers
                        cache["last_update"] = datetime.now().isoformat()

                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(b'{"ok":true}')
            else:
                self.send_response(400)
                self.end_headers()
        else:
            self.send_response(404)
            self.end_headers()

def _load_mpop_config():
    """~/.mpop/config.json에서 설정 읽기"""
    config_paths = [
        os.path.expanduser("~/.mpop/config.json"),
        "/etc/meshpop/config.json",
    ]
    for p in config_paths:
        try:
            with open(p) as f:
                return json.load(f)
        except:
            continue
    return {}

def _build_status_summary(servers):
    """서버 상태 요약 텍스트 생성"""
    lines = []
    for s in servers:
        if not s.get("online"):
            lines.append(f"❌ {s['name']}: OFFLINE")
            continue
        issues = []
        disk_pct = s.get("disk_pct", 0)
        mem_pct = s.get("mem_pct", 0)
        if disk_pct >= 90: issues.append(f"디스크 위험 {disk_pct}%")
        elif disk_pct >= 80: issues.append(f"디스크 주의 {disk_pct}%")
        if mem_pct >= 90: issues.append(f"메모리 위험 {mem_pct}%")
        elif mem_pct >= 80: issues.append(f"메모리 주의 {mem_pct}%")
        for svc, active in s.get("services", {}).items():
            if not active: issues.append(f"{svc} 중지됨")
        if issues:
            lines.append(f"⚠️ {s['name']}: {', '.join(issues)}")
        else:
            lines.append(f"✅ {s['name']}: 정상 (디스크 {disk_pct}%, 메모리 {mem_pct}%)")
    return '\n'.join(lines)

def _build_prompt(question, status_text, vpn):
    """AI 프롬프트 생성"""
    return f"""당신은 서버 관리 AI 어시스턴트입니다. 사용자 질문에 간결하게 답변하세요.

현재 서버 상태:
{status_text}

VPN 상태:
{vpn[:500] if vpn else '정보 없음'}

사용자 질문: {question}

명령어가 필요하면 ssh 명령어로 알려주세요. 간결하게 답변하세요."""

def _call_ollama(prompt):
    """Ollama 로컬 LLM 호출 (mpop 설정 사용)"""
    import urllib.request
    cfg = _load_mpop_config()
    host = cfg.get("ollama_host", "localhost")
    model = cfg.get("ollama_model", "qwen2.5:7b")

    # host가 서버이름이면 IP로 변환
    host_map = {"g1": "10.99.74.111", "g2": "10.99.106.230", "g3": "10.99.202.68", "g4": "10.99.175.0"}
    if host in host_map:
        host = host_map[host]
    if ":" not in host:
        host = f"{host}:11434"

    data = json.dumps({
        "model": model,
        "prompt": prompt,
        "stream": False,
        "options": {"temperature": 0.3, "num_predict": 500}
    }).encode()

    req = urllib.request.Request(
        f"http://{host}/api/generate",
        data=data,
        headers={"Content-Type": "application/json"}
    )
    with urllib.request.urlopen(req, timeout=120) as resp:
        result = json.loads(resp.read().decode())
        return result.get("response", "").strip()

def _call_gpt(prompt):
    """OpenAI GPT API 호출"""
    import urllib.request
    cfg = _load_mpop_config()
    model = cfg.get("llm_model", "gpt-4o-mini")
    api_key = os.environ.get("OPENAI_API_KEY", "")
    if not api_key:
        return "[Error: OPENAI_API_KEY 환경변수가 설정되지 않았습니다]"

    data = json.dumps({
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "temperature": 0.3,
        "max_tokens": 500
    }).encode()

    req = urllib.request.Request(
        "https://api.openai.com/v1/chat/completions",
        data=data,
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        result = json.loads(resp.read().decode())
        return result["choices"][0]["message"]["content"].strip()

def _call_claude(prompt):
    """Claude CLI 호출"""
    import subprocess
    result = subprocess.run(
        ["claude", "-p", prompt, "--max-tokens", "500"],
        capture_output=True, text=True, timeout=30,
        cwd=os.path.expanduser("~")
    )
    if result.returncode == 0 and result.stdout.strip():
        return result.stdout.strip()
    return None

def _template_response(question, servers, status_text):
    """룰 기반 템플릿 응답 (AI 없이)"""
    q = question.lower()

    if "요약" in q or "상태" in q:
        return f"서버 상태 요약:\n\n{status_text}"

    if "디스크" in q:
        issues = [s for s in servers if s.get("online") and s.get("disk_pct", 0) >= 80]
        if issues:
            r = "디스크 사용량 높은 서버:\n"
            for s in issues:
                r += f"\n• {s['name']}: {s.get('disk_used', '?')} 사용\n"
                r += f"  정리 명령: ssh {s.get('host',s['name'])} 'du -sh /* 2>/dev/null | sort -rh | head -10'\n"
            return r
        return "모든 서버 디스크 상태 양호합니다."

    if "메모리" in q:
        issues = [s for s in servers if s.get("online") and s.get("mem_pct", 0) >= 80]
        if issues:
            r = "메모리 사용량 높은 서버:\n"
            for s in issues:
                r += f"\n• {s['name']}: {s.get('memory', '?')}\n"
                r += f"  확인 명령: ssh {s.get('host',s['name'])} 'ps aux --sort=-%mem | head -10'\n"
            return r
        return "모든 서버 메모리 상태 양호합니다."

    if "백업" in q:
        r = "백업 상태:\n"
        for s in servers:
            if s.get("online"):
                r += f"• {s['name']}: {s.get('backup', 'unknown')}\n"
        return r

    if "정리" in q:
        for s in servers:
            if s["name"] in q:
                host = s.get('host', s['name'])
                return f"""{s['name']} 서버 정리 명령어:

# 디스크 사용량 확인
ssh {host} 'df -h && du -sh /* 2>/dev/null | sort -rh | head -10'

# 로그/캐시 정리
ssh {host} 'sudo journalctl --vacuum-time=3d; sudo apt-get clean; rm -rf ~/.cache/* 2>/dev/null'

# Docker 정리
ssh {host} 'docker system prune -af 2>/dev/null'
"""
        return "어떤 서버를 정리할지 지정해주세요. (예: g2 정리해줘)"

    return f"서버 상태 요약:\n\n{status_text}\n\n구체적인 질문을 해주세요."

def analyze_with_ai(question: str, servers: list, vpn: str, model: str = "ollama") -> str:
    """AI로 서버 상태 분석 — ollama(기본) / gpt / claude / template"""
    status_text = _build_status_summary(servers)

    # template 모드: AI 없이 룰 기반
    if model == "template":
        return _template_response(question, servers, status_text)

    prompt = _build_prompt(question, status_text, vpn)

    # 1순위: 선택된 모델로 시도
    try:
        if model == "ollama":
            return _call_ollama(prompt)
        elif model == "gpt":
            return _call_gpt(prompt)
        elif model == "claude":
            result = _call_claude(prompt)
            if result:
                return result
            return "[Claude CLI 없음] " + _template_response(question, servers, status_text)
    except Exception as e:
        pass

    # 2순위: 템플릿 fallback
    return f"[{model} 연결 실패 — 템플릿 응답]\n\n" + _template_response(question, servers, status_text)


def update_cache_once():
    """한 번만 캐시 업데이트 - Agent 리포트 우선, SSH 폴백"""
    try:
        results = []
        now = time.time()

        # Agent 리포트 확인 (60초 이내면 유효)
        with cache_lock:
            agent_reports = cache.get("agent_reports", {})

        # 각 서버에 대해: agent 리포트 있으면 사용, 없으면 SSH 폴링
        servers_to_poll = []
        for name, info in SERVERS.items():
            # hostname으로 agent 리포트 찾기
            agent_data = None
            for hostname, server_name in HOSTNAME_MAP.items():
                if server_name == name and hostname in agent_reports:
                    report = agent_reports[hostname]
                    if now - report.get('last_seen', 0) < 60:
                        agent_data = report
                        break

            if agent_data:
                # Agent 리포트 사용
                status = {
                    "name": name,
                    "host": info["host"],
                    "desc": info["desc"],
                    "icon": info.get("icon", "🖥️"),
                    "online": True,
                    "source": "agent",
                    **{k: v for k, v in agent_data.items() if k not in ['hostname', 'timestamp', 'last_seen']}
                }
                results.append(status)
            else:
                servers_to_poll.append((name, info))

        # SSH 폴링 (agent 리포트 없는 서버들만)
        if servers_to_poll:
            with ThreadPoolExecutor(max_workers=6) as executor:
                futures = {executor.submit(get_server_status, name, info): name
                           for name, info in servers_to_poll}
                for future in as_completed(futures):
                    result = future.result()
                    result["source"] = "ssh"
                    results.append(result)

        results.sort(key=lambda x: x["name"])

        vpn_status = get_wire_status()

        with cache_lock:
            cache["servers"] = results
            cache["vpn"] = vpn_status
            cache["last_update"] = datetime.now().isoformat()
    except Exception as e:
        print(f"Cache update error: {e}")

def main():
    # VPN IP에서만 리슨 (보안)
    bind_ip = os.environ.get("DASHBOARD_BIND", get_my_vpn_ip() or "0.0.0.0")
    print(f"Binding to {bind_ip}:{PORT}")
    server = ThreadingHTTPServer((bind_ip, PORT), Handler)

    # 초기 데이터 로드 (백그라운드)
    print("Loading initial data in background...")
    threading.Thread(target=update_cache_once, daemon=True).start()

    # 주기적 업데이트 시작
    threading.Thread(target=update_cache, daemon=True).start()

    # 히스토리 저장 워커 시작 (5분마다)
    threading.Thread(target=history_worker, daemon=True).start()
    print(f"  History: {HISTORY_FILE} (every {HISTORY_INTERVAL}s)")

    print(f"\n{'='*50}")
    print(f"  Server Dashboard (VPN only)")
    print(f"  http://{bind_ip}:{PORT}")
    print(f"{'='*50}\n")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down...")
        server.shutdown()

if __name__ == "__main__":
    main()
