# -*- coding: utf-8 -*-
"""
Server Manager v2.5 BATCH LOG CENTER

추가 내용:
- 오늘 수집/상세수집/초안/발행 KPI 표시
- Apache 80 포트 상태 표시
- FileZilla FTP 21 포트 상태 표시
- 5000/9000 API 응답시간 표시
- 9200 자체 상태 표시
- 전체 서버 건강도 점수 표시
"""

import os
import sys
import json
import time
import socket
import threading
import subprocess
import traceback
import webbrowser
import urllib.request
import ctypes
from datetime import datetime, timedelta
from pathlib import Path
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs

BASE_DIR = Path(__file__).resolve().parent
CONFIG_PATH = BASE_DIR / "server_manager_config.json"
LOG_DIR = BASE_DIR / "logs"
STATUS_DIR = BASE_DIR / "status"

LOG_DIR.mkdir(parents=True, exist_ok=True)
STATUS_DIR.mkdir(parents=True, exist_ok=True)

processes = {}
job_process = None
job_lock = threading.Lock()
event_log = []

# STEP101-12 scheduler state
scheduler_state = {
    "enabled": True,
    "last_tick": "",
    "last_started": {},
    "next_runs": [],
    "last_skipped": "",
}


def now_text():
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def console(message):
    print(message, flush=True)


def error_log(message):
    try:
        with open(LOG_DIR / "manager_error.log", "a", encoding="utf-8") as f:
            f.write(f"[{now_text()}] {message}\n")
    except Exception:
        pass


def load_config():
    if not CONFIG_PATH.exists():
        raise FileNotFoundError(f"config file not found: {CONFIG_PATH}")
    with open(CONFIG_PATH, "r", encoding="utf-8") as f:
        return json.load(f)


def append_event(level, message):
    item = {"time": now_text(), "level": level, "message": str(message)}
    event_log.insert(0, item)
    del event_log[100:]
    line = f"[{item['time']}] [{level}] {message}"
    console(line)
    try:
        with open(LOG_DIR / "manager.log", "a", encoding="utf-8") as f:
            f.write(line + "\n")
    except Exception:
        pass


def is_port_open(port, host="127.0.0.1", timeout=1.0):
    started = time.time()
    try:
        with socket.create_connection((host, int(port)), timeout=timeout):
            elapsed_ms = int((time.time() - started) * 1000)
            return True, elapsed_ms
    except Exception:
        elapsed_ms = int((time.time() - started) * 1000)
        return False, elapsed_ms


def check_http_url(url, timeout=2.5):
    if not url:
        return False, 0, ""
    started = time.time()
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "HongheeServerManager/1.2"})
        with urllib.request.urlopen(req, timeout=timeout) as res:
            status = getattr(res, "status", 0)
            elapsed_ms = int((time.time() - started) * 1000)
            return 200 <= int(status) < 500, elapsed_ms, str(status)
    except Exception as e:
        elapsed_ms = int((time.time() - started) * 1000)
        return False, elapsed_ms, str(e)[:120]



def run_cmd_capture(cmd):
    try:
        p = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="ignore",
            shell=True
        )
        return p.stdout.strip(), p.stderr.strip(), p.returncode
    except Exception as e:
        return "", str(e), 1


def get_pid_by_port(port):
    """
    Windows netstat 기준으로 LISTENING PID를 찾는다.
    """
    try:
        port = int(port)
    except Exception:
        return None

    out, err, code = run_cmd_capture(f'netstat -ano | findstr LISTENING | findstr :{port}')
    if not out:
        return None

    for line in out.splitlines():
        parts = line.split()
        if len(parts) < 5:
            continue

        local_addr = parts[1]
        state = parts[3]
        pid_text = parts[4]

        if state.upper() != "LISTENING":
            continue

        # 0.0.0.0:5000, 127.0.0.1:5000, [::]:5000 대응
        if local_addr.endswith(f":{port}"):
            try:
                return int(pid_text)
            except Exception:
                return None

    return None


def get_process_info(pid):
    if not pid:
        return {
            "pid": None,
            "name": "",
            "command_line": "",
            "executable_path": "",
            "exists": False,
        }

    ps_cmd = (
        'powershell -NoProfile -Command '
        f'"Get-CimInstance Win32_Process -Filter \\"ProcessId={int(pid)}\\" | '
        'Select-Object ProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress"'
    )

    out, err, code = run_cmd_capture(ps_cmd)

    if not out:
        return {
            "pid": int(pid),
            "name": "",
            "command_line": "",
            "executable_path": "",
            "exists": False,
            "error": err,
        }

    try:
        data = json.loads(out)
        if isinstance(data, list):
            data = data[0] if data else {}
        return {
            "pid": int(pid),
            "name": data.get("Name") or "",
            "command_line": data.get("CommandLine") or "",
            "executable_path": data.get("ExecutablePath") or "",
            "exists": True,
        }
    except Exception:
        return {
            "pid": int(pid),
            "name": "",
            "command_line": out[:500],
            "executable_path": "",
            "exists": bool(out),
            "error": err,
        }


def is_manager_owned_process(key, pid):
    if not pid:
        return False

    proc_info = processes.get(key)
    if not proc_info or not proc_info.get("process"):
        return False

    p = proc_info["process"]
    return p.poll() is None and int(p.pid) == int(pid)


def force_kill_pid(pid):
    try:
        pid = int(pid)
    except Exception:
        return False, "invalid pid"

    if pid <= 0:
        return False, "invalid pid"

    # 9200 manager 자신을 죽이는 실수 방지
    if pid == os.getpid():
        return False, "cannot kill server_manager itself"

    out, err, code = run_cmd_capture(f'taskkill /PID {pid} /F')
    if code == 0:
        return True, out or f"killed pid={pid}"

    return False, err or out or f"taskkill failed code={code}"


def force_stop_service_by_key(key):
    svc = get_service(key)
    if not svc:
        return False, f"service not found: {key}"

    port = int(svc.get("port") or 0)
    pid = get_pid_by_port(port)

    if not pid:
        return False, f"no pid for port={port}"

    ok, msg = force_kill_pid(pid)

    if ok:
        # manager가 들고 있던 프로세스 정보가 있으면 정리
        proc_info = processes.get(key)
        if proc_info and proc_info.get("process") and int(proc_info["process"].pid) == int(pid):
            try:
                proc_info.get("log_file").close()
            except Exception:
                pass
            processes.pop(key, None)

    return ok, msg


def restart_service(key):
    svc = get_service(key)
    if not svc:
        return False, f"service not found: {key}"

    # 먼저 포트 점유 프로세스 강제 종료
    port = int(svc.get("port") or 0)
    pid = get_pid_by_port(port)
    if pid:
        ok, msg = force_kill_pid(pid)
        append_event("warning" if ok else "error", f"{svc.get('name')} 재시작 전 종료 pid={pid}, result={msg}")
        time.sleep(2)

    # manager 내부 프로세스 정보 정리
    proc_info = processes.get(key)
    if proc_info:
        try:
            proc_info.get("log_file").close()
        except Exception:
            pass
        processes.pop(key, None)

    return start_service(key), "restart requested"

def get_service(key):
    cfg = load_config()
    for svc in cfg.get("services", []):
        if svc.get("key") == key:
            return svc
    return None


def start_service(key):
    svc = get_service(key)
    if not svc:
        append_event("error", f"서비스를 찾을 수 없습니다: {key}")
        return False

    port = int(svc.get("port") or 0)
    port_ok, _ = is_port_open(port)
    if port and port_ok:
        append_event("info", f"{svc.get('name')} 이미 실행 중 port={port}")
        return True

    cfg = load_config()
    python_exe = cfg.get("python_exe")
    script = svc.get("script")
    cwd = svc.get("cwd") or os.path.dirname(script)

    if not os.path.exists(python_exe):
        append_event("error", f"python.exe 없음: {python_exe}")
        return False

    if not os.path.exists(script):
        append_event("error", f"script 없음: {script}")
        return False

    log_path = LOG_DIR / f"{key}.log"
    log_file = open(log_path, "a", encoding="utf-8", errors="ignore")

    try:
        p = subprocess.Popen(
            [python_exe, script],
            cwd=cwd,
            stdout=log_file,
            stderr=subprocess.STDOUT,
            creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
        )
        processes[key] = {"process": p, "log_file": log_file, "started_at": now_text()}
        append_event("info", f"{svc.get('name')} 시작 pid={p.pid}, port={port}")
        return True
    except Exception as e:
        append_event("error", f"{svc.get('name')} 시작 실패: {e}")
        error_log(traceback.format_exc())
        try:
            log_file.close()
        except Exception:
            pass
        return False


def stop_service(key):
    svc = get_service(key)
    proc_info = processes.get(key)

    if proc_info and proc_info.get("process"):
        p = proc_info["process"]
        try:
            p.terminate()
            append_event("warning", f"{svc.get('name') if svc else key} 종료 요청 pid={p.pid}")
            time.sleep(2)
            if p.poll() is None:
                p.kill()
                append_event("warning", f"{svc.get('name') if svc else key} 강제 종료 pid={p.pid}")
        except Exception as e:
            append_event("error", f"{key} 종료 실패: {e}")
            error_log(traceback.format_exc())

        try:
            proc_info.get("log_file").close()
        except Exception:
            pass

        processes.pop(key, None)
        return True

    append_event("warning", f"{key}는 manager가 시작한 프로세스가 아니거나 이미 종료됨")
    return False


def service_status():
    cfg = load_config()
    rows = []

    for svc in cfg.get("services", []):
        key = svc.get("key")
        port = int(svc.get("port") or 0)
        port_ok, port_ms = is_port_open(port) if port else (False, 0)

        health_url = svc.get("health_url") or ""
        health_ok = False
        health_ms = 0
        health_note = ""

        if health_url and port_ok:
            health_ok, health_ms, health_note = check_http_url(health_url)

        proc_info = processes.get(key)
        manager_pid = None
        started_at = ""

        if proc_info and proc_info.get("process"):
            p = proc_info["process"]
            if p.poll() is None:
                manager_pid = p.pid
                started_at = proc_info.get("started_at") or ""
            else:
                processes.pop(key, None)

        port_pid = get_pid_by_port(port) if port else None
        pid = port_pid or manager_pid
        process_info = get_process_info(pid) if pid else {
            "pid": None,
            "name": "",
            "command_line": "",
            "executable_path": "",
            "exists": False,
        }
        manager_owned = is_manager_owned_process(key, pid)

        rows.append({
            "key": key,
            "name": svc.get("name"),
            "script": svc.get("script"),
            "port": port,
            "port_ok": port_ok,
            "port_ms": port_ms,
            "health_url": health_url,
            "health_ok": health_ok if health_url else port_ok,
            "health_ms": health_ms if health_url else port_ms,
            "health_note": health_note,
            "auto_start": bool(svc.get("auto_start")),
            "pid": pid,
            "manager_pid": manager_pid,
            "port_pid": port_pid,
            "process_name": process_info.get("name") or "",
            "command_line": process_info.get("command_line") or "",
            "executable_path": process_info.get("executable_path") or "",
            "process_exists": bool(process_info.get("exists")),
            "manager_owned": manager_owned,
            "owner_label": "Manager" if manager_owned else ("External" if pid else "-"),
            "started_at": started_at,
            "managed": True,
        })

    return rows


def external_port_status():
    cfg = load_config()
    rows = []

    # external ports
    for item in cfg.get("external_ports", []):
        host = item.get("host") or "127.0.0.1"
        port = int(item.get("port") or 0)
        protocol = item.get("protocol") or "tcp"
        url = item.get("url") or ""

        ok, ms = is_port_open(port, host=host)

        health_ok = ok
        health_ms = ms
        note = ""

        if protocol == "http" and url and ok:
            health_ok, health_ms, note = check_http_url(url)

        rows.append({
            "key": item.get("key"),
            "name": item.get("name"),
            "host": host,
            "port": port,
            "protocol": protocol,
            "port_ok": ok,
            "port_ms": ms,
            "health_ok": health_ok,
            "health_ms": health_ms,
            "health_note": note,
            "managed": False,
        })

    # self manager port
    cfg_port = int(cfg.get("manager_port") or 9200)
    ok, ms = is_port_open(cfg_port)
    rows.append({
        "key": "server_manager",
        "name": "Server Manager 상황판",
        "host": "127.0.0.1",
        "port": cfg_port,
        "protocol": "http",
        "port_ok": ok,
        "port_ms": ms,
        "health_ok": ok,
        "health_ms": ms,
        "health_note": "self",
        "managed": False,
    })

    return rows


def all_health_rows():
    return external_port_status() + service_status()


def health_score(rows):
    if not rows:
        return 0
    ok_count = sum(1 for r in rows if r.get("health_ok") or r.get("port_ok"))
    return int(round((ok_count / len(rows)) * 100))



def normalize_time_text(value):
    try:
        parts = str(value or "").strip().split(":")
        if len(parts) < 2:
            return ""
        return f"{int(parts[0]):02d}:{int(parts[1]):02d}"
    except Exception:
        return ""


def build_schedule_plan(cfg=None):
    cfg = cfg or load_config()
    schedules = cfg.get("schedules") or []
    plan = []
    now = datetime.now()

    for item in schedules:
        if not item.get("enabled", True):
            continue
        key = item.get("key")
        name = item.get("name") or key
        times = item.get("times") or []
        if not key or not isinstance(times, list):
            continue

        next_dt = None
        clean_times = []
        for t in times:
            tt = normalize_time_text(t)
            if not tt:
                continue
            clean_times.append(tt)
            hh, mm = [int(x) for x in tt.split(":")]
            candidate = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
            if candidate <= now:
                candidate = candidate + timedelta(days=1)
            if next_dt is None or candidate < next_dt:
                next_dt = candidate

        if next_dt:
            plan.append({
                "key": key,
                "name": name,
                "times": clean_times,
                "next_run": next_dt.strftime("%Y-%m-%d %H:%M:%S"),
                "max_run_minutes": int(item.get("max_run_minutes") or 0),
            })

    plan.sort(key=lambda x: x.get("next_run") or "")
    return plan


def scheduler_loop():
    append_event("info", "scheduler loop started")

    while True:
        try:
            cfg = load_config()
            scheduler_state["last_tick"] = now_text()
            scheduler_state["next_runs"] = build_schedule_plan(cfg)

            now = datetime.now()
            hhmm = now.strftime("%H:%M")
            today = now.strftime("%Y-%m-%d")

            for item in cfg.get("schedules") or []:
                if not item.get("enabled", True):
                    continue

                key = item.get("key")
                name = item.get("name") or key
                times = [normalize_time_text(t) for t in (item.get("times") or [])]
                times = [t for t in times if t]

                if not key or hhmm not in times:
                    continue

                run_token = f"{today} {hhmm} {key}"
                if scheduler_state["last_started"].get(key) == run_token:
                    continue

                if job_process and job_process.poll() is None:
                    scheduler_state["last_skipped"] = f"{now_text()} {name} skipped: previous job running pid={job_process.pid}"
                    append_event("warning", f"[SCHEDULER SKIP] {name} 예약시간={hhmm}, 이전 작업 실행중 pid={job_process.pid}")
                    scheduler_state["last_started"][key] = run_token
                    continue

                append_event("info", f"[SCHEDULER RUN] {name} 예약시간={hhmm}")
                ok = run_job(key)
                scheduler_state["last_started"][key] = run_token
                if not ok:
                    scheduler_state["last_skipped"] = f"{now_text()} {name} failed to start"

        except Exception as e:
            append_event("error", f"scheduler error: {e}")
            error_log(traceback.format_exc())

        try:
            sleep_sec = int(load_config().get("scheduler_tick_seconds") or 20)
        except Exception:
            sleep_sec = 20
        time.sleep(max(5, sleep_sec))



def monitor_loop():
    append_event("info", "monitor loop started")

    while True:
        try:
            cfg = load_config()
            for svc in cfg.get("services", []):
                key = svc.get("key")
                port = int(svc.get("port") or 0)
                auto_start = bool(svc.get("auto_start"))
                port_ok, _ = is_port_open(port)

                if auto_start and port and not port_ok:
                    append_event("warning", f"{svc.get('name')} port={port} 응답 없음 → 자동 시작")
                    start_service(key)

            write_status_file()

        except Exception as e:
            append_event("error", f"monitor error: {e}")
            error_log(traceback.format_exc())

        time.sleep(int(load_config().get("check_interval_seconds") or 10))


def run_job(job_key):
    global job_process
    cfg = load_config()
    steps = (cfg.get("jobs") or {}).get(job_key)

    if not steps:
        append_event("error", f"작업 정의 없음: {job_key}")
        return False

    with job_lock:
        if job_process and job_process.poll() is None:
            append_event("warning", "이미 실행 중인 작업이 있어 새 작업을 시작하지 않습니다.")
            return False

        def worker():
            global job_process
            append_event("info", f"작업 시작: {job_key}")

            for idx, step in enumerate(steps, start=1):
                name = step.get("name") or f"step {idx}"
                cmd = step.get("cmd")
                cwd = step.get("cwd") or cfg.get("project_root")
                append_event("info", f"[{job_key}] {idx}/{len(steps)} {name} 시작")

                log_path = LOG_DIR / f"job_{job_key}.log"
                with open(log_path, "a", encoding="utf-8", errors="ignore") as log_file:
                    log_file.write(f"\n\n[{now_text()}] START {idx}/{len(steps)} {name}\n")
                    log_file.write("CMD: " + " ".join(cmd) + "\n")
                    try:
                        job_process = subprocess.Popen(
                            cmd,
                            cwd=cwd,
                            stdout=log_file,
                            stderr=subprocess.STDOUT,
                            creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
                        )
                        code = job_process.wait()
                    except Exception as e:
                        append_event("error", f"[{job_key}] {name} 실행 예외: {e}")
                        error_log(traceback.format_exc())
                        job_process = None
                        return
                    log_file.write(f"[{now_text()}] END {name} code={code}\n")

                if code != 0:
                    append_event("error", f"[{job_key}] {name} 실패 code={code}. 이후 단계 중단")
                    job_process = None
                    return

                append_event("info", f"[{job_key}] {name} 완료")

            job_process = None
            append_event("info", f"작업 완료: {job_key}")

        threading.Thread(target=worker, daemon=True).start()
        return True


def stop_job():
    global job_process
    with job_lock:
        if job_process and job_process.poll() is None:
            try:
                job_process.terminate()
                append_event("warning", f"작업 종료 요청 pid={job_process.pid}")
                time.sleep(2)
                if job_process.poll() is None:
                    job_process.kill()
                    append_event("warning", f"작업 강제 종료 pid={job_process.pid}")
                job_process = None
                return True
            except Exception as e:
                append_event("error", f"작업 종료 실패: {e}")
                error_log(traceback.format_exc())
                return False

    append_event("info", "실행 중인 작업 없음")
    return False


def current_job_status():
    if job_process and job_process.poll() is None:
        return {"running": True, "pid": job_process.pid}
    return {"running": False, "pid": None}


def write_status_file():
    rows = all_health_rows()
    data = {
        "updated_at": now_text(),
        "health_score": health_score(rows),
        "health_rows": rows,
        "services": service_status(),
        "external_ports": external_port_status(),
        "job": current_job_status(),
        "scheduler": {
            "last_tick": scheduler_state.get("last_tick"),
            "next_runs": scheduler_state.get("next_runs", []),
            "last_started": scheduler_state.get("last_started", {}),
            "last_skipped": scheduler_state.get("last_skipped", ""),
        },
        "events": event_log[:30],
    }
    with open(STATUS_DIR / "server_status.json", "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def html_escape(text):
    return str(text or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


def pct(done, total):
    try:
        total = int(total or 0)
        done = int(done or 0)
        if total <= 0:
            return 0
        return max(0, min(100, int(round((done / total) * 100))))
    except Exception:
        return 0


def db_query_one(sql, params=None):
    cfg = load_config()
    project_root = cfg.get("project_root") or ""

    if project_root and project_root not in sys.path:
        sys.path.append(project_root)

    try:
        from db import get_conn
    except Exception as e:
        return None, f"db import failed: {e}"

    conn = None

    try:
        conn = get_conn()
        with conn.cursor() as cur:
            cur.execute(sql, params or [])
            return cur.fetchone(), ""
    except Exception as e:
        return None, str(e)
    finally:
        try:
            if conn:
                conn.close()
        except Exception:
            pass


def db_query_all(sql, params=None):
    cfg = load_config()
    project_root = cfg.get("project_root") or ""

    if project_root and project_root not in sys.path:
        sys.path.append(project_root)

    try:
        from db import get_conn
    except Exception as e:
        return [], f"db import failed: {e}"

    conn = None

    try:
        conn = get_conn()
        with conn.cursor() as cur:
            cur.execute(sql, params or [])
            return cur.fetchall(), ""
    except Exception as e:
        return [], str(e)
    finally:
        try:
            if conn:
                conn.close()
        except Exception:
            pass




def db_execute(sql, params=None):
    cfg = load_config()
    project_root = cfg.get("project_root") or ""

    if project_root and project_root not in sys.path:
        sys.path.append(project_root)

    try:
        from db import get_conn
    except Exception as e:
        return 0, f"db import failed: {e}"

    conn = None

    try:
        conn = get_conn()
        with conn.cursor() as cur:
            cur.execute(sql, params or [])
            affected = cur.rowcount
        conn.commit()
        return affected, ""
    except Exception as e:
        try:
            if conn:
                conn.rollback()
        except Exception:
            pass
        return 0, str(e)
    finally:
        try:
            if conn:
                conn.close()
        except Exception:
            pass


def reset_publish_queue_item(queue_id, mode):
    try:
        queue_id = int(queue_id)
    except Exception:
        return False, "queue_id invalid"

    mode = str(mode or "").strip()

    if mode == "failed_retry":
        affected, err = db_execute("""
            UPDATE blog_publish_queue
            SET
                queue_status = 'pending',
                worker_status = 'manual_retry',
                next_retry_at = NULL,
                started_at = NULL,
                finished_at = NULL,
                error_message = CONCAT('[MANUAL RETRY FROM MANAGER] ', IFNULL(error_message, '')),
                updated_at = NOW()
            WHERE id = %s
              AND queue_status = 'failed'
        """, [queue_id])

    elif mode == "hold_retry":
        affected, err = db_execute("""
            UPDATE blog_publish_queue
            SET
                queue_status = 'pending',
                worker_status = 'manual_hold_release',
                next_retry_at = NULL,
                started_at = NULL,
                finished_at = NULL,
                error_message = CONCAT('[MANUAL HOLD RELEASE FROM MANAGER] ', IFNULL(error_message, '')),
                updated_at = NOW()
            WHERE id = %s
              AND queue_status = 'hold'
        """, [queue_id])

    elif mode == "stale_retry":
        affected, err = db_execute("""
            UPDATE blog_publish_queue
            SET
                queue_status = 'pending',
                worker_status = 'manual_stale_reset',
                started_at = NULL,
                finished_at = NULL,
                error_message = CONCAT('[MANUAL STALE RESET FROM MANAGER] ', IFNULL(error_message, '')),
                updated_at = NOW()
            WHERE id = %s
              AND queue_status = 'processing'
        """, [queue_id])

    else:
        return False, "unknown reset mode"

    if err:
        return False, err

    return affected > 0, f"affected={affected}"

def get_today_pipeline_kpi():
    """
    오늘 기준 운영 KPI.
    DB 장애가 있어도 server_manager 자체는 죽지 않도록 모든 오류를 kpi_error로 반환한다.
    """
    kpi = {
        "ok": True,
        "error": "",
        "collect": {
            "title": "오늘 후보수집",
            "total": 0,
            "done": 0,
            "pending": 0,
            "processing": 0,
            "failed": 0,
            "remain": 0,
            "progress": 0,
            "link": "daily_queue_status.php?date=today",
        },
        "detail": {
            "title": "오늘 상세수집",
            "total": 0,
            "done": 0,
            "pending": 0,
            "processing": 0,
            "failed": 0,
            "remain": 0,
            "progress": 0,
            "link": "realtor_articles.php?date=today",
        },
        "draft": {
            "title": "오늘 초안생성",
            "total": 0,
            "done": 0,
            "pending": 0,
            "processing": 0,
            "failed": 0,
            "remain": 0,
            "progress": 0,
            "link": "blog_drafts.php?date=today",
        },
        "publish": {
            "title": "오늘 블로그발행",
            "total": 0,
            "done": 0,
            "pending": 0,
            "processing": 0,
            "failed": 0,
            "hold": 0,
            "remain": 0,
            "progress": 0,
            "link": "publish_queue.php?date=today",
        },
        "sessions": {
            "normal": 0,
            "expired": 0,
            "login_required": 0,
            "write_blocked": 0,
            "check_failed": 0,
            "unknown": 0,
            "risk_7": 0,
            "risk_14": 0,
            "risk_30": 0,
            "problem": 0,
        },
        "session_problem_rows": [],
        "session_risk_rows": [],
        "publish_problem": {
            "failed": 0,
            "hold": 0,
            "processing_stale": 0,
            "retry_ready": 0,
            "total_problem": 0,
        },
        "publish_problem_rows": [],
        "ops_summary": {
            "today_runs": 0,
            "running_runs": 0,
            "success_runs": 0,
            "failed_runs": 0,
            "warning_logs": 0,
            "error_logs": 0,
            "today_publish_total": 0,
            "today_publish_done": 0,
            "today_publish_failed": 0,
            "today_publish_pending": 0,
        },
        "recent_runs": [],
        "last_errors": [],
        "recent_logs": [],
    }

    # 1) 후보수집 work queue
    row, err = db_query_one("""
        SELECT
            COUNT(*) AS total,
            SUM(CASE WHEN work_status IN ('done','completed','success') THEN 1 ELSE 0 END) AS done,
            SUM(CASE WHEN work_status IN ('pending','waiting') THEN 1 ELSE 0 END) AS pending,
            SUM(CASE WHEN work_status IN ('processing','running') THEN 1 ELSE 0 END) AS processing,
            SUM(CASE WHEN work_status IN ('failed','error') THEN 1 ELSE 0 END) AS failed
        FROM blog_article_work_queue
        WHERE work_type = 'new_article'
          AND DATE(created_at) = CURDATE()
    """)

    if err:
        kpi["ok"] = False
        kpi["error"] = err
    elif row:
        total = int(row.get("total") or 0)
        done = int(row.get("done") or 0)
        pending = int(row.get("pending") or 0)
        processing = int(row.get("processing") or 0)
        failed = int(row.get("failed") or 0)
        kpi["collect"].update({
            "total": total,
            "done": done,
            "pending": pending,
            "processing": processing,
            "failed": failed,
            "remain": max(0, total - done - failed),
            "progress": pct(done, total),
        })

    # 2) 상세수집 - 오늘 생성/수정된 매물 기준
    row, err = db_query_one("""
        SELECT
            COUNT(*) AS total,
            SUM(CASE WHEN detail_collected = 1 THEN 1 ELSE 0 END) AS done,
            SUM(CASE WHEN detail_collected = 0 AND (detail_error IS NULL OR detail_error = '') THEN 1 ELSE 0 END) AS pending,
            0 AS processing,
            SUM(CASE WHEN detail_error IS NOT NULL AND detail_error <> '' THEN 1 ELSE 0 END) AS failed
        FROM blog_realtor_articles
        WHERE DATE(created_at) = CURDATE()
           OR DATE(updated_at) = CURDATE()
    """)

    if not err and row:
        total = int(row.get("total") or 0)
        done = int(row.get("done") or 0)
        pending = int(row.get("pending") or 0)
        processing = int(row.get("processing") or 0)
        failed = int(row.get("failed") or 0)
        kpi["detail"].update({
            "total": total,
            "done": done,
            "pending": pending,
            "processing": processing,
            "failed": failed,
            "remain": max(0, total - done - failed),
            "progress": pct(done, total),
        })

    # 3) 초안생성
    row, err = db_query_one("""
        SELECT
            COUNT(*) AS total,
            SUM(CASE WHEN d.id IS NOT NULL THEN 1 ELSE 0 END) AS done,
            SUM(CASE WHEN d.id IS NULL AND a.detail_collected = 1 THEN 1 ELSE 0 END) AS pending,
            0 AS processing,
            0 AS failed
        FROM blog_realtor_articles a
        LEFT JOIN blog_article_drafts d
            ON CONVERT(a.article_no USING utf8mb4) COLLATE utf8mb4_unicode_ci
             = CONVERT(d.article_no USING utf8mb4) COLLATE utf8mb4_unicode_ci
        WHERE DATE(a.created_at) = CURDATE()
           OR DATE(a.updated_at) = CURDATE()
           OR DATE(d.created_at) = CURDATE()
    """)

    if not err and row:
        total = int(row.get("total") or 0)
        done = int(row.get("done") or 0)
        pending = int(row.get("pending") or 0)
        processing = int(row.get("processing") or 0)
        failed = int(row.get("failed") or 0)
        kpi["draft"].update({
            "total": total,
            "done": done,
            "pending": pending,
            "processing": processing,
            "failed": failed,
            "remain": max(0, total - done - failed),
            "progress": pct(done, total),
        })

    # 4) 발행
    row, err = db_query_one("""
        SELECT
            COUNT(*) AS total,
            SUM(CASE WHEN queue_status IN ('published','done','success') THEN 1 ELSE 0 END) AS done,
            SUM(CASE WHEN queue_status IN ('pending','waiting') THEN 1 ELSE 0 END) AS pending,
            SUM(CASE WHEN queue_status = 'processing' THEN 1 ELSE 0 END) AS processing,
            SUM(CASE WHEN queue_status = 'failed' THEN 1 ELSE 0 END) AS failed,
            SUM(CASE WHEN queue_status = 'hold' THEN 1 ELSE 0 END) AS hold
        FROM blog_publish_queue
        WHERE DATE(created_at) = CURDATE()
           OR DATE(updated_at) = CURDATE()
           OR DATE(started_at) = CURDATE()
           OR DATE(finished_at) = CURDATE()
    """)

    if not err and row:
        total = int(row.get("total") or 0)
        done = int(row.get("done") or 0)
        pending = int(row.get("pending") or 0)
        processing = int(row.get("processing") or 0)
        failed = int(row.get("failed") or 0)
        hold = int(row.get("hold") or 0)
        kpi["publish"].update({
            "total": total,
            "done": done,
            "pending": pending,
            "processing": processing,
            "failed": failed,
            "hold": hold,
            "remain": max(0, total - done - failed - hold),
            "progress": pct(done, total),
        })

    # 5) 세션 상태 + 위험도
    row, err = db_query_one("""
        SELECT
            SUM(CASE WHEN status IN ('login_ok') THEN 1 ELSE 0 END) AS normal,
            SUM(CASE WHEN status = 'session_expired' THEN 1 ELSE 0 END) AS expired,
            SUM(CASE WHEN status = 'login_required' THEN 1 ELSE 0 END) AS login_required,
            SUM(CASE WHEN status = 'write_blocked' THEN 1 ELSE 0 END) AS write_blocked,
            SUM(CASE WHEN status = 'check_failed' THEN 1 ELSE 0 END) AS check_failed,
            SUM(CASE WHEN status = 'unknown' THEN 1 ELSE 0 END) AS unknown,
            SUM(CASE
                WHEN status = 'login_ok'
                 AND last_login_ok_at IS NOT NULL
                 AND last_login_ok_at < DATE_SUB(NOW(), INTERVAL 7 DAY)
                THEN 1 ELSE 0 END
            ) AS risk_7,
            SUM(CASE
                WHEN status = 'login_ok'
                 AND last_login_ok_at IS NOT NULL
                 AND last_login_ok_at < DATE_SUB(NOW(), INTERVAL 14 DAY)
                THEN 1 ELSE 0 END
            ) AS risk_14,
            SUM(CASE
                WHEN status = 'login_ok'
                 AND last_login_ok_at IS NOT NULL
                 AND last_login_ok_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
                THEN 1 ELSE 0 END
            ) AS risk_30
        FROM blog_realtor_session_status
    """)

    if not err and row:
        normal = int(row.get("normal") or 0)
        expired = int(row.get("expired") or 0)
        login_required = int(row.get("login_required") or 0)
        write_blocked = int(row.get("write_blocked") or 0)
        check_failed = int(row.get("check_failed") or 0)
        unknown = int(row.get("unknown") or 0)
        risk_7 = int(row.get("risk_7") or 0)
        risk_14 = int(row.get("risk_14") or 0)
        risk_30 = int(row.get("risk_30") or 0)
        kpi["sessions"].update({
            "normal": normal,
            "expired": expired,
            "login_required": login_required,
            "write_blocked": write_blocked,
            "check_failed": check_failed,
            "unknown": unknown,
            "risk_7": risk_7,
            "risk_14": risk_14,
            "risk_30": risk_30,
            "problem": expired + login_required + write_blocked + check_failed,
        })

    # 5-1) 문제 세션 TOP20
    rows, err = db_query_all("""
        SELECT
            s.realtor_id,
            s.naver_id,
            s.status,
            s.last_checked_at,
            s.last_login_ok_at,
            s.last_error_message,
            r.office_name,
            r.representative_name,
            r.office_phone,
            r.mobile_phone
        FROM blog_realtor_session_status s
        LEFT JOIN blog_realtors r
            ON r.id = s.realtor_id
        WHERE s.status IN ('session_expired','login_required','write_blocked','check_failed')
        ORDER BY
            CASE s.status
                WHEN 'write_blocked' THEN 1
                WHEN 'login_required' THEN 2
                WHEN 'session_expired' THEN 3
                WHEN 'check_failed' THEN 4
                ELSE 9
            END,
            s.last_checked_at DESC
        LIMIT 20
    """)

    if not err:
        kpi["session_problem_rows"] = rows

    # 5-2) 정상이나 장기 미확인/장기 미로그인 위험 세션 TOP20
    rows, err = db_query_all("""
        SELECT
            s.realtor_id,
            s.naver_id,
            s.status,
            s.last_checked_at,
            s.last_login_ok_at,
            DATEDIFF(NOW(), s.last_login_ok_at) AS days_from_login_ok,
            s.last_error_message,
            r.office_name,
            r.representative_name,
            r.office_phone,
            r.mobile_phone
        FROM blog_realtor_session_status s
        LEFT JOIN blog_realtors r
            ON r.id = s.realtor_id
        WHERE s.status = 'login_ok'
          AND s.last_login_ok_at IS NOT NULL
          AND s.last_login_ok_at < DATE_SUB(NOW(), INTERVAL 7 DAY)
        ORDER BY s.last_login_ok_at ASC
        LIMIT 20
    """)

    if not err:
        kpi["session_risk_rows"] = rows

    # 5-3) 발행 실패/보류/고착 큐 관제
    row, err = db_query_one("""
        SELECT
            SUM(CASE WHEN queue_status = 'failed' THEN 1 ELSE 0 END) AS failed,
            SUM(CASE WHEN queue_status = 'hold' THEN 1 ELSE 0 END) AS hold,
            SUM(CASE
                WHEN queue_status = 'processing'
                 AND started_at IS NOT NULL
                 AND started_at < DATE_SUB(NOW(), INTERVAL 90 MINUTE)
                THEN 1 ELSE 0 END
            ) AS processing_stale,
            SUM(CASE
                WHEN queue_status = 'failed'
                 AND next_retry_at IS NOT NULL
                 AND next_retry_at <= NOW()
                THEN 1 ELSE 0 END
            ) AS retry_ready
        FROM blog_publish_queue
    """)

    if not err and row:
        failed = int(row.get("failed") or 0)
        hold = int(row.get("hold") or 0)
        processing_stale = int(row.get("processing_stale") or 0)
        retry_ready = int(row.get("retry_ready") or 0)
        kpi["publish_problem"].update({
            "failed": failed,
            "hold": hold,
            "processing_stale": processing_stale,
            "retry_ready": retry_ready,
            "total_problem": failed + hold + processing_stale,
        })

    rows, err = db_query_all("""
        SELECT
            q.id,
            q.realtor_id,
            q.draft_id,
            q.article_no,
            q.queue_status,
            q.worker_status,
            q.retry_count,
            q.next_retry_at,
            q.last_error_type,
            q.error_message,
            q.started_at,
            q.finished_at,
            q.updated_at,
            r.office_name,
            r.representative_name
        FROM blog_publish_queue q
        LEFT JOIN blog_realtors r
            ON r.id = q.realtor_id
        WHERE q.queue_status IN ('failed','hold')
           OR (
                q.queue_status = 'processing'
                AND q.started_at IS NOT NULL
                AND q.started_at < DATE_SUB(NOW(), INTERVAL 90 MINUTE)
           )
        ORDER BY
            CASE
                WHEN q.queue_status = 'processing' THEN 1
                WHEN q.queue_status = 'hold' THEN 2
                WHEN q.queue_status = 'failed' THEN 3
                ELSE 9
            END,
            q.updated_at DESC
        LIMIT 20
    """)

    if not err:
        kpi["publish_problem_rows"] = rows

    # 5-4) 오늘 운영 요약
    row, err = db_query_one("""
        SELECT
            COUNT(*) AS today_runs,
            SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running_runs,
            SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_runs,
            SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_runs
        FROM blog_pipeline_runs
        WHERE DATE(created_at) = CURDATE()
           OR DATE(started_at) = CURDATE()
           OR DATE(finished_at) = CURDATE()
    """)

    if not err and row:
        kpi["ops_summary"].update({
            "today_runs": int(row.get("today_runs") or 0),
            "running_runs": int(row.get("running_runs") or 0),
            "success_runs": int(row.get("success_runs") or 0),
            "failed_runs": int(row.get("failed_runs") or 0),
        })

    row, err = db_query_one("""
        SELECT
            SUM(CASE WHEN level = 'warning' THEN 1 ELSE 0 END) AS warning_logs,
            SUM(CASE WHEN level = 'error' THEN 1 ELSE 0 END) AS error_logs
        FROM blog_pipeline_logs
        WHERE DATE(created_at) = CURDATE()
    """)

    if not err and row:
        kpi["ops_summary"].update({
            "warning_logs": int(row.get("warning_logs") or 0),
            "error_logs": int(row.get("error_logs") or 0),
        })

    row, err = db_query_one("""
        SELECT
            COUNT(*) AS total,
            SUM(CASE WHEN queue_status IN ('published','done','success') THEN 1 ELSE 0 END) AS done,
            SUM(CASE WHEN queue_status = 'failed' THEN 1 ELSE 0 END) AS failed,
            SUM(CASE WHEN queue_status IN ('pending','waiting') THEN 1 ELSE 0 END) AS pending
        FROM blog_publish_queue
        WHERE DATE(created_at) = CURDATE()
           OR DATE(updated_at) = CURDATE()
           OR DATE(started_at) = CURDATE()
           OR DATE(finished_at) = CURDATE()
    """)

    if not err and row:
        kpi["ops_summary"].update({
            "today_publish_total": int(row.get("total") or 0),
            "today_publish_done": int(row.get("done") or 0),
            "today_publish_failed": int(row.get("failed") or 0),
            "today_publish_pending": int(row.get("pending") or 0),
        })

    # 6) 최근 배치 실행
    rows, err = db_query_all("""
        SELECT run_type, status, started_at, finished_at, total_count, success_count, fail_count, message
        FROM blog_pipeline_runs
        ORDER BY id DESC
        LIMIT 10
    """)

    if not err:
        kpi["recent_runs"] = rows

    # 7) 최근 오류
    rows, err = db_query_all("""
        SELECT level, step_name, article_no, realtor_id, message, created_at
        FROM blog_pipeline_logs
        WHERE level IN ('warning','error')
        ORDER BY id DESC
        LIMIT 10
    """)

    if not err:
        kpi["last_errors"] = rows

    # 8) 최근 통합 로그 20건
    rows, err = db_query_all("""
        SELECT
            l.level,
            l.step_name,
            l.article_no,
            l.realtor_id,
            l.message,
            l.created_at,
            r.run_type,
            r.status AS run_status
        FROM blog_pipeline_logs l
        LEFT JOIN blog_pipeline_runs r
            ON r.id = l.run_id
        ORDER BY l.id DESC
        LIMIT 20
    """)

    if not err:
        kpi["recent_logs"] = rows

    return kpi


def run_type_label(value):
    labels = {
        "schedule_daily_articles": "후보수집(schedule_daily_articles)",
        "fetch_article_detail": "상세수집(fetch_article_detail)",
        "generate_blog_drafts": "초안생성(generate_blog_drafts)",
        "publish_worker": "블로그발행(publish_worker)",
    }
    return labels.get(str(value or ""), str(value or ""))


def status_label(value):
    labels = {
        "pending": "대기",
        "running": "실행중",
        "success": "성공",
        "failed": "실패",
        "stopped": "중단",
        "skipped": "건너뜀",
    }
    return labels.get(str(value or ""), str(value or ""))


def render_progress_card(item, color="#2563eb"):
    total = int(item.get("total") or 0)
    done = int(item.get("done") or 0)
    pending = int(item.get("pending") or 0)
    processing = int(item.get("processing") or 0)
    failed = int(item.get("failed") or 0)
    hold = int(item.get("hold") or 0)
    remain = int(item.get("remain") or 0)
    progress = int(item.get("progress") or 0)
    title = html_escape(item.get("title") or "")
    link = html_escape(item.get("link") or "#")

    hold_html = ""
    if hold:
        hold_html = f'<a href="{link}&status=hold"><span>보류</span><b>{hold}</b></a>'

    return f"""
    <div class="card kpi-card">
        <div class="row between">
            <div>
                <div class="label">{title}</div>
                <div class="big">{progress}%</div>
            </div>
            <a class="mini-link" href="{link}">상세보기</a>
        </div>
        <div class="bar"><div style="width:{progress}%; background:{color};"></div></div>
        <div class="kpi-grid">
            <a href="{link}"><span>총 대상</span><b>{total}</b></a>
            <a href="{link}&status=done"><span>완료</span><b>{done}</b></a>
            <a href="{link}&status=pending"><span>대기</span><b>{pending}</b></a>
            <a href="{link}&status=processing"><span>처리중</span><b>{processing}</b></a>
            <a href="{link}&status=failed"><span>실패</span><b>{failed}</b></a>
            {hold_html}
            <a href="{link}&status=remain"><span>남음</span><b>{remain}</b></a>
        </div>
    </div>
    """




def session_status_label(value):
    labels = {
        "unknown": "미확인",
        "login_ok": "정상",
        "session_expired": "세션만료",
        "login_required": "로그인필요",
        "write_blocked": "글쓰기차단",
        "check_failed": "확인실패",
    }
    return labels.get(str(value or ""), str(value or ""))


def render_session_table(rows, empty_text="표시할 계정이 없습니다."):
    if not rows:
        return f'<div class="muted">{html_escape(empty_text)}</div>'

    html = """
    <div class="mini-table">
        <div class="mini-row head">
            <span>중개사</span>
            <span>상태</span>
            <span>최근확인</span>
            <span>최근정상</span>
            <span>오류</span>
        </div>
    """

    for r in rows:
        office = r.get("office_name") or f"realtor_id={r.get('realtor_id')}"
        rep = r.get("representative_name") or ""
        naver_id = r.get("naver_id") or ""
        title = f"{office}"
        if rep:
            title += f" / {rep}"
        if naver_id:
            title += f" / {naver_id}"

        html += f"""
        <div class="mini-row">
            <span><b>{html_escape(title)}</b></span>
            <span>{html_escape(session_status_label(r.get('status')))}</span>
            <span>{html_escape(r.get('last_checked_at') or '-')}</span>
            <span>{html_escape(r.get('last_login_ok_at') or '-')}</span>
            <span>{html_escape(str(r.get('last_error_message') or '')[:80])}</span>
        </div>
        """

    html += "</div>"
    return html


def render_session_risk_table(rows):
    if not rows:
        return '<div class="muted">7일 이상 장기 미확인 정상 세션이 없습니다.</div>'

    html = """
    <div class="mini-table">
        <div class="mini-row head">
            <span>중개사</span>
            <span>네이버ID</span>
            <span>마지막 정상</span>
            <span>경과일</span>
            <span>위험도</span>
        </div>
    """

    for r in rows:
        days = int(r.get("days_from_login_ok") or 0)
        if days >= 30:
            risk = "빨강"
        elif days >= 14:
            risk = "주황"
        else:
            risk = "노랑"

        html += f"""
        <div class="mini-row">
            <span><b>{html_escape(r.get('office_name') or f"realtor_id={r.get('realtor_id')}")}</b></span>
            <span>{html_escape(r.get('naver_id') or '-')}</span>
            <span>{html_escape(r.get('last_login_ok_at') or '-')}</span>
            <span>{days}일</span>
            <span>{risk}</span>
        </div>
        """

    html += "</div>"
    return html



def render_publish_problem_table(rows):
    if not rows:
        return '<div class="muted">문제 발행 큐가 없습니다.</div>'

    html = """
    <div class="mini-table publish-problem">
        <div class="mini-row publish-head">
            <span>큐/매물</span>
            <span>중개사</span>
            <span>상태</span>
            <span>재시도</span>
            <span>최근오류</span>
            <span>처리</span>
        </div>
    """

    for r in rows:
        qid = int(r.get("id") or 0)
        status = str(r.get("queue_status") or "")
        article_no = r.get("article_no") or "-"
        office = r.get("office_name") or f"realtor_id={r.get('realtor_id')}"
        err_type = r.get("last_error_type") or r.get("worker_status") or "-"
        retry_count = int(r.get("retry_count") or 0)
        error_message = str(r.get("error_message") or "")[:90]

        if status == "failed":
            action = f'<a class="mini-btn" href="/action?type=reset_publish_queue&mode=failed_retry&id={qid}">재시도</a>'
        elif status == "hold":
            action = f'<a class="mini-btn orange" href="/action?type=reset_publish_queue&mode=hold_retry&id={qid}">보류복구</a>'
        elif status == "processing":
            action = f'<a class="mini-btn red" href="/action?type=reset_publish_queue&mode=stale_retry&id={qid}">고착복구</a>'
        else:
            action = "-"

        html += f"""
        <div class="mini-row publish-row">
            <span><b>#{qid}</b><br>{html_escape(article_no)}</span>
            <span>{html_escape(office)}</span>
            <span>{html_escape(status_label(status))}<br><small>{html_escape(err_type)}</small></span>
            <span>{retry_count}회<br><small>{html_escape(r.get('next_retry_at') or '')}</small></span>
            <span>{html_escape(error_message)}</span>
            <span>{action}<br><a class="mini-link" href="publish_queue.php?id={qid}">상세</a></span>
        </div>
        """

    html += "</div>"
    return html



def render_recent_log_table(rows):
    if not rows:
        return '<div class="muted">최근 파이프라인 로그가 없습니다.</div>'

    html = """
    <div class="mini-table recent-log-table">
        <div class="mini-row log-head">
            <span>시간</span>
            <span>단계</span>
            <span>레벨</span>
            <span>매물</span>
            <span>메시지</span>
        </div>
    """

    for r in rows:
        level = str(r.get("level") or "")
        level_class = "lv-error" if level == "error" else ("lv-warning" if level == "warning" else "lv-info")
        step = r.get("step_name") or run_type_label(r.get("run_type"))
        msg = str(r.get("message") or "")[:120]
        html += f"""
        <div class="mini-row log-row">
            <span>{html_escape(r.get('created_at') or '-')}</span>
            <span>{html_escape(step or '-')}</span>
            <span><b class="{level_class}">{html_escape(level or '-')}</b></span>
            <span>{html_escape(r.get('article_no') or '-')}</span>
            <span>{html_escape(msg)}</span>
        </div>
        """

    html += "</div>"
    return html


def render_scheduler_table(rows):
    if not rows:
        return '<div class="muted">등록된 자동 스케줄이 없습니다.</div>'

    html = """
    <div class="mini-table">
        <div class="mini-row head" style="grid-template-columns:1fr 1fr 1.5fr 1fr;">
            <span>작업</span>
            <span>예약시간</span>
            <span>다음 실행</span>
            <span>제한</span>
        </div>
    """
    for r in rows:
        html += f"""
        <div class="mini-row" style="grid-template-columns:1fr 1fr 1.5fr 1fr;">
            <span><b>{html_escape(r.get('name') or r.get('key'))}</b><br><small>{html_escape(r.get('key'))}</small></span>
            <span>{html_escape(', '.join(r.get('times') or []))}</span>
            <span>{html_escape(r.get('next_run') or '-')}</span>
            <span>{int(r.get('max_run_minutes') or 0)}분</span>
        </div>
        """
    html += "</div>"
    if scheduler_state.get("last_skipped"):
        html += f'<div class="muted">최근 SKIP: {html_escape(scheduler_state.get("last_skipped"))}</div>'
    return html


def render_dashboard():
    cfg = load_config()
    ext_rows = external_port_status()
    svc_rows = service_status()
    rows = ext_rows + svc_rows
    score = health_score(rows)
    job = current_job_status()
    kpi = get_today_pipeline_kpi()

    score_color = "#047857" if score >= 90 else ("#d97706" if score >= 70 else "#dc2626")

    sess = kpi.get("sessions") or {}
    publish_problem = kpi.get("publish_problem") or {}
    session_problem_count = int(sess.get("problem") or 0)
    publish_problem_count = int(publish_problem.get("total_problem") or 0)

    overall_state = "정상"
    overall_icon = "🟢"
    overall_note = "주요 서비스 정상"

    if score < 70 or session_problem_count >= 10 or publish_problem_count >= 10:
        overall_state = "장애"
        overall_icon = "🔴"
        overall_note = "즉시 확인 필요"
    elif score < 90 or session_problem_count > 0 or publish_problem_count > 0:
        overall_state = "주의"
        overall_icon = "🟡"
        overall_note = "일부 확인 필요"

    infra_cards = ""
    for r in ext_rows:
        ok = r.get("health_ok") or r.get("port_ok")
        dot = "🟢" if ok else "🔴"
        state = "정상" if ok else "중지"
        infra_cards += f"""
        <div class="mini-status-card">
            <div>
                <div class="label">{html_escape(r.get('name'))}</div>
                <div class="status-title">{dot} {state}</div>
            </div>
            <div class="status-meta">port {r.get('port')}<br>{r.get('health_ms', 0)}ms</div>
        </div>
        """

    service_cards = ""
    for svc in svc_rows:
        ok = svc.get("health_ok") or svc.get("port_ok")
        dot = "🟢" if ok else "🔴"
        badge = "정상" if ok else "중지"
        owner = svc.get("owner_label") or "-"
        cmd_line = svc.get("command_line") or ""
        short_cmd = cmd_line
        if len(short_cmd) > 80:
            short_cmd = short_cmd[:80] + "..."

        service_cards += f"""
        <div class="mini-status-card service-trace-card">
            <div>
                <div class="label">{html_escape(svc.get('name'))}</div>
                <div class="status-title">{dot} {badge}</div>
                <div class="muted">PID {svc.get('pid') or '-'} / {html_escape(owner)}</div>
                <div class="muted trace-line">{html_escape(short_cmd or '-')}</div>
            </div>
            <div class="status-actions">
                <div class="status-meta">port {svc.get('port')}<br>{svc.get('health_ms', 0)}ms</div>
                <div class="inline-actions">
                    <a class="tiny-btn" href="/action?type=start_service&key={svc.get('key')}">ON</a>
                    <a class="tiny-btn danger" href="/action?type=stop_service&key={svc.get('key')}">OFF</a>
                </div>
                <div class="inline-actions">
                    <a class="tiny-btn orange" href="/action?type=restart_service&key={svc.get('key')}">재시작</a>
                    <a class="tiny-btn red" href="/action?type=force_stop_service&key={svc.get('key')}">강제</a>
                </div>
            </div>
        </div>
        """

    kpi_warning = ""
    if not kpi.get("ok"):
        kpi_warning = f"""
        <div class="card warn-card section">
            <div class="label">DB KPI 조회 경고</div>
            <div class="big">⚠ 일부 조회 실패</div>
            <div class="muted">{html_escape(kpi.get('error'))}</div>
        </div>
        """

    pipeline_cards = (
        render_progress_card(kpi["collect"], "#047857") +
        render_progress_card(kpi["detail"], "#2563eb") +
        render_progress_card(kpi["draft"], "#7c3aed") +
        render_progress_card(kpi["publish"], "#dc2626")
    )

    job_badge = "🟢 실행 중" if job["running"] else "⚪ 대기"
    job_pid = job["pid"] or "-"
    schedule_plan = build_schedule_plan(cfg)
    next_schedule = schedule_plan[0] if schedule_plan else {}
    next_schedule_name = next_schedule.get("name") or "-"
    next_schedule_time = next_schedule.get("next_run") or "-"
    last_tick = scheduler_state.get("last_tick") or "-"

    top_cards = f"""
    <div class="summary-card">
        <div class="label">전체 상태</div>
        <div class="summary-title">{overall_icon} {overall_state}</div>
        <div class="muted">{html_escape(overall_note)}</div>
    </div>
    <div class="summary-card">
        <div class="label">서버 건강도</div>
        <div class="score">{score}점</div>
        <div class="muted">Apache / FTP / API</div>
    </div>
    <div class="summary-card">
        <div class="label">현재 배치</div>
        <div class="summary-title">{job_badge}</div>
        <div class="muted">pid {job_pid}</div>
    </div>
    <div class="summary-card">
        <div class="label">다음 예약</div>
        <div class="summary-title">{html_escape(next_schedule_name)}</div>
        <div class="muted">{html_escape(next_schedule_time)}<br>tick {html_escape(last_tick)}</div>
    </div>
    """

    ops = kpi.get("ops_summary") or {}
    ops_summary_card = f"""
    <div class="card ops-summary-card">
        <div class="section-head">
            <div>
                <div class="label">오늘 배치 실행 요약</div>
                <div class="big">실행 {int(ops.get('today_runs') or 0)}건</div>
            </div>
            <a class="mini-link" href="dashboard.php">관리자</a>
        </div>
        <div class="metric-grid four">
            <a href="#"><span>실행중</span><b>{int(ops.get('running_runs') or 0)}</b></a>
            <a href="#"><span>성공</span><b>{int(ops.get('success_runs') or 0)}</b></a>
            <a href="#"><span>실패</span><b>{int(ops.get('failed_runs') or 0)}</b></a>
            <a href="#"><span>오류로그</span><b>{int(ops.get('error_logs') or 0)}</b></a>
        </div>
    </div>
    """

    today_publish_card = f"""
    <div class="card ops-summary-card">
        <div class="section-head">
            <div>
                <div class="label">오늘 발행 요약</div>
                <div class="big">{int(ops.get('today_publish_done') or 0)} / {int(ops.get('today_publish_total') or 0)}</div>
            </div>
            <a class="mini-link" href="publish_queue.php?date=today">발행큐</a>
        </div>
        <div class="metric-grid four">
            <a href="publish_queue.php?date=today"><span>전체</span><b>{int(ops.get('today_publish_total') or 0)}</b></a>
            <a href="publish_queue.php?date=today&status=done"><span>완료</span><b>{int(ops.get('today_publish_done') or 0)}</b></a>
            <a href="publish_queue.php?date=today&status=pending"><span>대기</span><b>{int(ops.get('today_publish_pending') or 0)}</b></a>
            <a href="publish_queue.php?date=today&status=failed"><span>실패</span><b>{int(ops.get('today_publish_failed') or 0)}</b></a>
        </div>
    </div>
    """

    session_card = f"""
    <div class="card">
        <div class="section-head">
            <div>
                <div class="label">네이버 세션 관제</div>
                <div class="big">문제 {session_problem_count}건</div>
            </div>
            <a class="mini-link" href="realtor_list.php">목록</a>
        </div>
        <div class="metric-grid two">
            <a href="realtor_list.php?session_status=login_ok"><span>정상</span><b>{int(sess.get('normal') or 0)}</b></a>
            <a href="realtor_list.php?session_status=session_expired"><span>만료</span><b>{int(sess.get('expired') or 0)}</b></a>
            <a href="realtor_list.php?session_status=login_required"><span>로그인필요</span><b>{int(sess.get('login_required') or 0)}</b></a>
            <a href="realtor_list.php?session_status=write_blocked"><span>글쓰기차단</span><b>{int(sess.get('write_blocked') or 0)}</b></a>
            <a href="realtor_list.php?session_status=check_failed"><span>확인실패</span><b>{int(sess.get('check_failed') or 0)}</b></a>
            <a href="realtor_list.php?session_status=unknown"><span>미확인</span><b>{int(sess.get('unknown') or 0)}</b></a>
        </div>
        <div class="label mt">세션 위험도</div>
        <div class="metric-grid three">
            <a href="realtor_list.php?session_risk=7"><span>7일+</span><b>{int(sess.get('risk_7') or 0)}</b></a>
            <a href="realtor_list.php?session_risk=14"><span>14일+</span><b>{int(sess.get('risk_14') or 0)}</b></a>
            <a href="realtor_list.php?session_risk=30"><span>30일+</span><b>{int(sess.get('risk_30') or 0)}</b></a>
        </div>
    </div>
    """

    publish_problem_card = f"""
    <div class="card">
        <div class="section-head">
            <div>
                <div class="label">발행 실패/보류 관제</div>
                <div class="big">문제 {publish_problem_count}건</div>
            </div>
            <a class="mini-link" href="publish_queue.php">큐</a>
        </div>
        <div class="metric-grid two">
            <a href="publish_queue.php?status=failed"><span>실패</span><b>{int(publish_problem.get('failed') or 0)}</b></a>
            <a href="publish_queue.php?status=hold"><span>보류</span><b>{int(publish_problem.get('hold') or 0)}</b></a>
            <a href="publish_queue.php?status=processing"><span>고착</span><b>{int(publish_problem.get('processing_stale') or 0)}</b></a>
            <a href="publish_queue.php?status=failed&retry=ready"><span>재시도가능</span><b>{int(publish_problem.get('retry_ready') or 0)}</b></a>
        </div>
    </div>
    """

    recent_runs_html = ""
    for r in kpi.get("recent_runs") or []:
        recent_runs_html += f"""
        <div class="event">
            <span>{html_escape(r.get('started_at'))}</span>
            <b>{html_escape(run_type_label(r.get('run_type')))}</b>
            <p>{html_escape(status_label(r.get('status')))} / 성공 {int(r.get('success_count') or 0)} / 실패 {int(r.get('fail_count') or 0)}</p>
        </div>
        """

    last_errors_html = ""
    for r in kpi.get("last_errors") or []:
        last_errors_html += f"""
        <div class="event">
            <span>{html_escape(r.get('created_at'))}</span>
            <b>{html_escape(r.get('level'))}</b>
            <p>{html_escape(r.get('step_name'))} / {html_escape(r.get('article_no'))} / {html_escape(r.get('message'))}</p>
        </div>
        """

    events_html = ""
    for ev in event_log[:20]:
        events_html += f"""
        <div class="event">
            <span>{html_escape(ev.get('time'))}</span>
            <b>{html_escape(ev.get('level'))}</b>
            <p>{html_escape(ev.get('message'))}</p>
        </div>
        """

    recent_logs_html = render_recent_log_table(kpi.get("recent_logs") or [])

    return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Server Manager</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="10">
<style>
:root {{
    --bg:#eef2f7;
    --card:#ffffff;
    --line:#e5e7eb;
    --text:#111827;
    --muted:#6b7280;
}}
body {{ margin:0; background:var(--bg); color:var(--text); font-family:Arial,'Malgun Gothic',sans-serif; font-size:13px; }}
.wrap {{ max-width:1320px; margin:0 auto; padding:18px; }}
.header {{ display:flex; justify-content:space-between; align-items:flex-end; gap:12px; margin-bottom:14px; }}
h1 {{ margin:0; font-size:22px; letter-spacing:-.5px; }}
.sub {{ color:var(--muted); font-size:11px; margin-top:4px; }}
.card, .summary-card {{
    background:var(--card);
    border:1px solid var(--line);
    border-radius:16px;
    padding:14px;
    box-shadow:0 8px 20px rgba(15,23,42,.045);
}}
.summary-card {{ min-height:84px; }}
.warn-card {{ border-color:#f59e0b; background:#fffbeb; }}
.section {{ margin-top:14px; }}
.section-title {{ margin:18px 0 10px; font-size:15px; font-weight:900; letter-spacing:-.3px; }}
.grid-top {{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }}
.grid2 {{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }}
.ops-grid {{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }}
.status-grid {{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }}

.api-service-list {{
    display:grid;
    grid-template-columns:1fr;
    gap:8px;
}}
.api-service-list .mini-status-card {{
    display:grid;
    grid-template-columns:1fr auto;
    align-items:start;
}}
.api-service-list .status-actions {{
    min-width:88px;
}}
.api-service-list .trace-line {{
    max-width:100%;
    white-space:normal;
    line-height:1.35;
}}
.api-service-list .inline-actions {{
    justify-content:flex-end;
}}

.row {{ display:flex; gap:10px; align-items:center; }}
.between {{ justify-content:space-between; }}
.section-head {{ display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:6px; }}
.label {{ color:var(--muted); font-size:11px; margin-bottom:5px; }}
.big {{ font-size:20px; font-weight:900; letter-spacing:-.4px; }}
.summary-title {{ font-size:21px; font-weight:900; letter-spacing:-.5px; }}
.score {{ font-size:32px; font-weight:900; color:{score_color}; letter-spacing:-.8px; }}
.muted {{ color:var(--muted); font-size:10.5px; margin-top:5px; word-break:break-all; line-height:1.35; }}
.actions {{ display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }}
.btn {{ display:inline-block; text-decoration:none; background:#111827; color:white; padding:7px 10px; border-radius:10px; font-size:11px; font-weight:800; }}
.btn.green {{ background:#047857; }}
.btn.blue {{ background:#2563eb; }}
.btn.orange {{ background:#d97706; }}
.btn.danger {{ background:#dc2626; }}
.mini-status-card {{
    display:flex;
    justify-content:space-between;
    align-items:flex-start;
    gap:8px;
    padding:10px;
    border:1px solid var(--line);
    border-radius:13px;
    background:#f9fafb;
}}
.status-title {{ font-size:15px; font-weight:900; }}
.status-meta {{ color:var(--muted); font-size:10px; text-align:right; line-height:1.35; white-space:nowrap; }}
.status-actions {{ display:flex; flex-direction:column; align-items:flex-end; gap:6px; }}
.inline-actions {{ display:flex; gap:4px; }}
.tiny-btn {{ display:inline-block; padding:4px 7px; border-radius:7px; background:#111827; color:#fff; text-decoration:none; font-size:10px; font-weight:800; }}
.tiny-btn.danger {{ background:#dc2626; }}
.tiny-btn.orange {{ background:#d97706; }}
.tiny-btn.red {{ background:#991b1b; }}
.service-trace-card {{ align-items:stretch; }}
.trace-line {{ max-width:240px; }}
.event {{ border-top:1px solid var(--line); padding:8px 0; font-size:11px; }}
.event span {{ color:var(--muted); margin-right:6px; }}
.event b {{ margin-right:6px; }}
.event p {{ display:inline; }}
.bar {{ width:100%; height:8px; background:#e5e7eb; border-radius:999px; overflow:hidden; margin:10px 0; }}
.bar > div {{ height:100%; border-radius:999px; }}
.kpi-grid, .metric-grid {{ display:grid; gap:6px; margin-top:9px; }}
.kpi-grid {{ grid-template-columns:repeat(3,minmax(0,1fr)); }}
.metric-grid.two {{ grid-template-columns:repeat(2,minmax(0,1fr)); }}
.metric-grid.three {{ grid-template-columns:repeat(3,minmax(0,1fr)); }}
.metric-grid.four {{ grid-template-columns:repeat(4,minmax(0,1fr)); }}
.kpi-grid a, .metric-grid a {{
    text-decoration:none;
    color:var(--text);
    background:#f9fafb;
    border:1px solid var(--line);
    border-radius:11px;
    padding:8px;
}}
.kpi-grid span, .metric-grid span {{ display:block; color:var(--muted); font-size:10px; }}
.kpi-grid b, .metric-grid b {{ display:block; font-size:15px; margin-top:3px; }}
.mini-link {{ text-decoration:none; color:#2563eb; font-size:11px; font-weight:800; }}
.mt {{ margin-top:12px; }}
.mini-table {{ width:100%; overflow:auto; }}
.mini-row {{ display:grid; grid-template-columns:1.5fr .8fr 1fr 1fr 1.6fr; gap:6px; padding:7px 0; border-top:1px solid var(--line); font-size:10.5px; align-items:center; }}
.mini-row.head {{ color:var(--muted); font-weight:700; }}
.mini-row span {{ word-break:break-all; }}
.publish-problem .mini-row {{ grid-template-columns:1fr 1.2fr .8fr .8fr 1.5fr .7fr; }}
.recent-log-table .mini-row {{ grid-template-columns:1fr 1fr .6fr .8fr 2.4fr; }}
.lv-error {{ color:#dc2626; }}
.lv-warning {{ color:#d97706; }}
.lv-info {{ color:#2563eb; }}
.publish-head {{ color:var(--muted); font-weight:700; }}
.mini-btn {{ display:inline-block; padding:4px 7px; border-radius:7px; background:#2563eb; color:white; text-decoration:none; font-size:10px; }}
.mini-btn.orange {{ background:#d97706; }}
.mini-btn.red {{ background:#dc2626; }}

@media (max-width:720px) {{
    .wrap {{ padding:12px; min-width:360px; }}
    .header {{ display:block; }}
    .actions {{ justify-content:flex-start; margin-top:10px; }}
    .grid-top {{ grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }}
    .grid2,.ops-grid {{ grid-template-columns:1fr; gap:10px; }}
    .status-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); }}
    .summary-card {{ min-height:74px; padding:11px; }}
    .summary-title {{ font-size:18px; }}
    .score {{ font-size:27px; }}
    .big {{ font-size:18px; }}
    .kpi-grid {{ grid-template-columns:repeat(3,minmax(0,1fr)); }}
    .metric-grid.two {{ grid-template-columns:repeat(2,minmax(0,1fr)); }}
    .metric-grid.three {{ grid-template-columns:repeat(3,minmax(0,1fr)); }}
    .metric-grid.four {{ grid-template-columns:repeat(2,minmax(0,1fr)); }}
.metric-grid.four {{ grid-template-columns:repeat(4,minmax(0,1fr)); }}
}}

.api-service-list .status-title {{ font-size:14px; }}
.api-service-list .muted {{ font-size:10px; }}
@media (max-width:420px) {{
    .wrap {{ padding:10px; min-width:340px; }}
    h1 {{ font-size:20px; }}
    .btn {{ padding:6px 8px; font-size:10px; }}
    .grid-top {{ grid-template-columns:repeat(2,minmax(0,1fr)); }}
    .status-grid {{ grid-template-columns:1fr; }}
    .summary-title {{ font-size:17px; }}
    .score {{ font-size:25px; }}
    .label {{ font-size:10px; }}
}}
</style>
</head>
<body>
<div class="wrap">
    <div class="header">
        <div>
            <h1>서버 관제센터</h1>
            <div class="sub">포트: {cfg.get('manager_port')} / 새로고침 10초 / {now_text()}</div>
        </div>
        <div class="actions">
            <a class="btn green" href="/action?type=run_job&key=day_pipeline">낮 파이프라인</a>
            <a class="btn blue" href="/action?type=run_job&key=night_publish">야간 발행</a>
            <a class="btn orange" href="/action?type=run_job&key=test_2_realtors">2개 테스트</a>
            <a class="btn danger" href="/action?type=stop_job">작업 중지</a>
        </div>
    </div>

    <div class="grid-top">
        {top_cards}
    </div>

    <div class="section card">
        <div class="section-head">
            <div>
                <div class="label">자동 스케줄러</div>
                <div class="big">예약 실행 상태</div>
                <div class="muted">Server Manager 내부 스케줄러가 낮 파이프라인/야간 발행을 자동 실행합니다.</div>
            </div>
        </div>
        {render_scheduler_table(schedule_plan)}
    </div>

    <div class="section grid2">
        {ops_summary_card}
        {today_publish_card}
    </div>

    <div class="section grid2">
        <div class="card">
            <div class="label">인프라 상태</div>
            <div class="status-grid">{infra_cards}</div>
        </div>
        <div class="card">
            <div class="label">API 서비스 상태</div>
            <div class="api-service-list">{service_cards}</div>
        </div>
    </div>

    {kpi_warning}

    <div class="section-title">오늘 파이프라인</div>
    <div class="ops-grid">
        {pipeline_cards}
    </div>

    <div class="section grid2">
        {session_card}
        {publish_problem_card}
    </div>

    <div class="section grid2">
        <div class="card">
            <div class="label">문제 발행 큐 TOP20</div>
            {render_publish_problem_table(kpi.get('publish_problem_rows') or [])}
        </div>
        <div class="card">
            <div class="label">문제 세션 TOP20</div>
            {render_session_table(kpi.get('session_problem_rows') or [], '문제 세션이 없습니다.')}
        </div>
    </div>

    <div class="section card">
        <div class="section-head">
            <div>
                <div class="label">최근 파이프라인 로그 20건</div>
                <div class="big">배치 실행 로그 센터</div>
            </div>
        </div>
        {recent_logs_html}
    </div>

    <div class="section grid2">
        <div class="card">
            <div class="label">최근 배치 실행</div>
            {recent_runs_html or '<div class="muted">최근 실행 기록 없음</div>'}
        </div>
        <div class="card">
            <div class="label">최근 파이프라인 경고/오류</div>
            {last_errors_html or '<div class="muted">최근 오류 없음</div>'}
        </div>
    </div>

    <div class="section card">
        <div class="label">Server Manager 이벤트</div>
        {events_html or '<div class="muted">이벤트 없음</div>'}
    </div>
</div>
</body>
</html>"""


class ManagerHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        parsed = urlparse(self.path)

        if parsed.path == "/ping":
            payload = b"OK"
            self.send_response(200)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
            return

        if parsed.path == "/":
            html = render_dashboard().encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(html)))
            self.end_headers()
            try:
                self.wfile.write(html)
            except (ConnectionAbortedError, BrokenPipeError):
                pass
            return

        if parsed.path == "/status.json":
            write_status_file()
            payload = (STATUS_DIR / "server_status.json").read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
            return

        if parsed.path == "/action":
            qs = parse_qs(parsed.query)
            action_type = (qs.get("type") or [""])[0]
            key = (qs.get("key") or [""])[0]

            if action_type == "start_service":
                start_service(key)
            elif action_type == "stop_service":
                stop_service(key)
            elif action_type == "force_stop_service":
                ok, msg = force_stop_service_by_key(key)
                append_event("warning" if ok else "error", f"서비스 강제종료 요청 key={key}, result={msg}")
            elif action_type == "restart_service":
                ok, msg = restart_service(key)
                append_event("info" if ok else "error", f"서비스 재시작 요청 key={key}, result={msg}")
            elif action_type == "run_job":
                run_job(key)
            elif action_type == "stop_job":
                stop_job()
            elif action_type == "reset_publish_queue":
                queue_id = (qs.get("id") or [""])[0]
                mode = (qs.get("mode") or [""])[0]
                ok, msg = reset_publish_queue_item(queue_id, mode)
                append_event("info" if ok else "error", f"발행 큐 복구 요청 queue_id={queue_id}, mode={mode}, result={msg}")
            else:
                append_event("warning", f"알 수 없는 action: {action_type}")

            self.send_response(302)
            self.send_header("Location", "/")
            self.end_headers()
            return

        self.send_response(404)
        self.end_headers()

    def log_message(self, fmt, *args):
        return


def main():
    try:
        cfg = load_config()

        console("=" * 70)
        console("Honghee Server Manager v2.5 BATCH LOG CENTER")
        console(f"BASE_DIR = {BASE_DIR}")
        console(f"CONFIG   = {CONFIG_PATH}")
        console("=" * 70)

        append_event("info", "Server Manager started")

        for svc in cfg.get("services", []):
            if svc.get("auto_start"):
                start_service(svc.get("key"))

        threading.Thread(target=monitor_loop, daemon=True).start()
        threading.Thread(target=scheduler_loop, daemon=True).start()

        host = cfg.get("manager_host") or "0.0.0.0"
        port = int(cfg.get("manager_port") or 9200)

        append_event("info", f"Dashboard binding host={host}, port={port}")

        try:
            server = ThreadingHTTPServer((host, port), ManagerHandler)
        except Exception:
            err = traceback.format_exc()
            console(err)
            error_log(err)
            input("HTTPServer 시작 실패. Enter를 누르면 종료합니다...")
            return

        append_event("info", f"Dashboard listening http://127.0.0.1:{port}")

        if bool(cfg.get("open_browser_on_start", False)):
            try:
                webbrowser.open(f"http://127.0.0.1:{port}")
            except Exception:
                pass

        server.serve_forever()

    except KeyboardInterrupt:
        append_event("warning", "Server Manager stopped by Ctrl+C")
    except Exception:
        err = traceback.format_exc()
        console(err)
        error_log(err)
        input("Server Manager 오류 발생. Enter를 누르면 종료합니다...")


if __name__ == "__main__":
    main()
