"""Admin operations routes.""" from __future__ import annotations import time from datetime import datetime, timezone from decimal import Decimal from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from ..auth_types import AccountStatus, RoleName from ..auth_sessions import ( ManagedUserPatch, active_session_count, deactivate_managed_user, get_managed_user, has_admin_access, is_super_admin_email, list_managed_users, update_managed_user, upsert_managed_user, ) from ..config import settings from ..db import acquire, get_pool, healthcheck from ..deps import Principal, require_admin_access from ..engine_client import engine_client from ..runtime_policy import require_runtime_fallback_allowed from ..services.voice import voice_service from ..services import rag from ..store import store router = APIRouter(prefix="/admin", tags=["admin"]) AdminPrincipal = Annotated[Principal, Depends(require_admin_access())] HealthStatus = Literal["ok", "degraded", "down"] UsageBudgetStatus = Literal["disabled", "ok", "warn", "exceeded"] TicketCategory = Literal[ "account_access", "session_review", "voice_browser", "content_scenario", "safety", "other", ] TicketPriority = Literal["low", "normal", "high", "urgent"] TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"] class AdminServiceHealth(BaseModel): key: str name: str status: HealthStatus detail: str metric: str load: float class AdminHealthResponse(BaseModel): status: HealthStatus environment: str engine_mode: str services: list[AdminServiceHealth] class AdminUsageBreakdown(BaseModel): provider: str model: str turns: int tokens_in: int tokens_out: int cost_usd: float class AdminUsageBudget(BaseModel): limit_usd: float used_ratio: float remaining_usd: float | None status: UsageBudgetStatus class AdminUsageResponse(BaseModel): source: Literal["database", "server_session_registry"] durable: bool window_days: int generated_at: float total_turns: int metered_turns: int tokens_in: int tokens_out: int cost_usd: float budget: AdminUsageBudget by_provider: list[AdminUsageBreakdown] class AdminHealthEvent(BaseModel): event_id: int observed_at: float overall_status: HealthStatus service_key: str service_name: str service_status: HealthStatus detail: str metric: str load: float class AdminUptimeServiceSummary(BaseModel): service_key: str service_name: str samples: int ok_samples: int degraded_samples: int down_samples: int latest_status: HealthStatus latest_observed_at: float | None = None class AdminUptimeResponse(BaseModel): source: Literal["database", "unavailable"] durable: bool window_hours: int generated_at: float sample_count: int ok_ratio: float degraded_events: int down_events: int last_down_at: float | None = None services: list[AdminUptimeServiceSummary] events: list[AdminHealthEvent] class AdminTicketReporter(BaseModel): user_id: str | None = None email: str display_name: str role: str class AdminSupportTicketResponse(BaseModel): ticket_id: str reporter: AdminTicketReporter category: TicketCategory priority: TicketPriority status: TicketStatus subject: str body: str source_path: str assigned_group: str resolution_note: str created_at: float updated_at: float resolved_at: float | None = None class AdminTicketSummary(BaseModel): total: int open_count: int high_priority_count: int stale_count: int by_status: dict[str, int] by_category: dict[str, int] by_priority: dict[str, int] class AdminTicketsResponse(BaseModel): source: Literal["database", "unavailable"] durable: bool generated_at: float tickets: list[AdminSupportTicketResponse] summary: AdminTicketSummary class AdminEngineConfigResponse(BaseModel): engine_mode: str engine_url: str model: str updated_by: str | None = None updated_at: float | None = None durable: bool = False source: Literal["database", "runtime_cache", "runtime_default"] = "runtime_default" class AdminEngineConfigPatch(BaseModel): engine_mode: str | None = None engine_url: str | None = None model: str | None = None class AdminTicketPatch(BaseModel): status: TicketStatus | None = None priority: TicketPriority | None = None assigned_group: str | None = Field(default=None, max_length=120) resolution_note: str | None = Field(default=None, max_length=2000) class AdminUserResponse(BaseModel): user_id: str email: str display_name: str role: RoleName admin_access: bool super_admin: bool = False account_status: AccountStatus cohort_ids: list[str] affiliation: str active_sessions: int created_at: float last_seen_at: float source: Literal["database", "server_session_registry"] class AdminUsersResponse(BaseModel): source: Literal["database", "server_session_registry"] durable: bool users: list[AdminUserResponse] class AdminUserPatch(BaseModel): display_name: str | None = Field(default=None, min_length=1, max_length=80) role: RoleName | None = None admin_access: bool | None = None account_status: AccountStatus | None = None affiliation: str | None = Field(default=None, max_length=120) cohort_ids: list[str] | None = None class RuntimeHealthMetrics(BaseModel): engine_latency_ms: float | None = None db_pool_size: int = 0 db_pool_idle: int = 0 db_pool_max: int = 0 active_users: int = 0 active_auth_sessions: int = 0 active_sessions: int = 0 ended_sessions: int = 0 pending_reviews: int = 0 def _clamp01(value: float) -> float: return round(max(0.0, min(1.0, value)), 3) def _pool_load(metrics: RuntimeHealthMetrics) -> float: if metrics.db_pool_max <= 0: return 0.0 busy = max(0, metrics.db_pool_size - metrics.db_pool_idle) return _clamp01(busy / metrics.db_pool_max) def _workload_load(count: int, expected_capacity: int) -> float: if expected_capacity <= 0: return 0.0 return _clamp01(count / expected_capacity) def _decimal_to_float(value: object) -> float: if value is None: return 0.0 if isinstance(value, Decimal): return float(value) try: return float(value) except (TypeError, ValueError): return 0.0 def _safe_usage_int(value: object) -> int: try: return int(value or 0) except (TypeError, ValueError): return 0 def _usage_budget(cost_usd: float) -> AdminUsageBudget: limit = max(0.0, float(settings.admin_usage_budget_usd or 0.0)) if limit <= 0: return AdminUsageBudget( limit_usd=0.0, used_ratio=0.0, remaining_usd=None, status="disabled", ) used_ratio = max(0.0, cost_usd / limit) status_value: UsageBudgetStatus = "ok" if used_ratio >= 1.0: status_value = "exceeded" elif used_ratio >= 0.8: status_value = "warn" return AdminUsageBudget( limit_usd=round(limit, 6), used_ratio=round(used_ratio, 4), remaining_usd=round(max(0.0, limit - cost_usd), 6), status=status_value, ) async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics: metrics = RuntimeHealthMetrics() try: pool = get_pool() metrics.db_pool_size = int(pool.get_size()) metrics.db_pool_idle = int(pool.get_idle_size()) metrics.db_pool_max = int(pool.get_max_size()) except Exception: pass if not db_ok: return metrics try: async with acquire(role="admin") as conn: row = await conn.fetchrow( """ SELECT (SELECT COUNT(*) FROM app.app_user WHERE is_active) AS active_users, ( SELECT COUNT(*) FROM app.auth_session WHERE revoked_at IS NULL AND expires_at > now() ) AS active_auth_sessions, ( SELECT COUNT(*) FROM app.sessions WHERE ended_at IS NULL ) AS active_sessions, ( SELECT COUNT(*) FROM app.sessions WHERE ended_at IS NOT NULL ) AS ended_sessions, ( SELECT COUNT(*) FROM app.sessions s LEFT JOIN app.session_summary ss ON ss.session_id = s.id WHERE s.ended_at IS NOT NULL AND ss.session_id IS NULL ) AS pending_reviews """ ) if row is not None: metrics.active_users = int(row["active_users"] or 0) metrics.active_auth_sessions = int(row["active_auth_sessions"] or 0) metrics.active_sessions = int(row["active_sessions"] or 0) metrics.ended_sessions = int(row["ended_sessions"] or 0) metrics.pending_reviews = int(row["pending_reviews"] or 0) except Exception: return metrics return metrics async def _usage_from_database(window_days: int) -> AdminUsageResponse: async with acquire(role="admin") as conn: total_row = await conn.fetchrow( """ SELECT COUNT(*) FILTER (WHERE speaker = 'client') AS total_turns, COUNT(*) FILTER ( WHERE speaker = 'client' AND ( llm_provider IS NOT NULL OR model IS NOT NULL OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL OR cost_usd IS NOT NULL ) ) AS metered_turns, COALESCE(SUM(tokens_in) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_in, COALESCE(SUM(tokens_out) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_out, COALESCE(SUM(cost_usd) FILTER (WHERE speaker = 'client'), 0)::numeric AS cost_usd FROM app.turns WHERE created_at >= now() - ($1::int * interval '1 day') """, window_days, ) rows = await conn.fetch( """ SELECT COALESCE(llm_provider, 'unknown') AS provider, COALESCE(model, 'unknown') AS model, COUNT(*) AS turns, COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in, COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out, COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd FROM app.turns WHERE created_at >= now() - ($1::int * interval '1 day') AND speaker = 'client' AND ( llm_provider IS NOT NULL OR model IS NOT NULL OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL OR cost_usd IS NOT NULL ) GROUP BY 1, 2 ORDER BY cost_usd DESC, COALESCE(SUM(tokens_in), 0) + COALESCE(SUM(tokens_out), 0) DESC, turns DESC LIMIT 12 """, window_days, ) total_cost = round(_decimal_to_float(total_row["cost_usd"] if total_row else 0), 6) return AdminUsageResponse( source="database", durable=True, window_days=window_days, generated_at=time.time(), total_turns=_safe_usage_int(total_row["total_turns"] if total_row else 0), metered_turns=_safe_usage_int(total_row["metered_turns"] if total_row else 0), tokens_in=_safe_usage_int(total_row["tokens_in"] if total_row else 0), tokens_out=_safe_usage_int(total_row["tokens_out"] if total_row else 0), cost_usd=total_cost, budget=_usage_budget(total_cost), by_provider=[ AdminUsageBreakdown( provider=str(row["provider"] or "unknown"), model=str(row["model"] or "unknown"), turns=_safe_usage_int(row["turns"]), tokens_in=_safe_usage_int(row["tokens_in"]), tokens_out=_safe_usage_int(row["tokens_out"]), cost_usd=round(_decimal_to_float(row["cost_usd"]), 6), ) for row in rows ], ) async def _record_health_events( *, principal: Principal, overall_status: HealthStatus, environment: str, engine_mode: str, services: list[AdminServiceHealth], ) -> None: if not services: return try: async with acquire(role="admin", user_id=principal.user_id) as conn: await conn.executemany( """ INSERT INTO app.admin_health_event ( overall_status, environment, engine_mode, service_key, service_name, service_status, detail, metric, load, captured_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::uuid) """, [ ( overall_status, environment, engine_mode, service.key, service.name, service.status, service.detail, service.metric, service.load, principal.user_id, ) for service in services ], ) except Exception: # 헬스 화면 자체가 장애 확인 경로라, 이력 적재 실패가 응답을 막으면 안 된다. return async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse: async with acquire(role="admin") as conn: rows = await conn.fetch( """ SELECT id, observed_at, overall_status, service_key, service_name, service_status, detail, metric, load FROM app.admin_health_event WHERE observed_at >= now() - ($1::int * interval '1 hour') ORDER BY observed_at DESC, id DESC LIMIT 240 """, window_hours, ) events = [_health_event_from_row(row) for row in rows] service_buckets: dict[str, AdminUptimeServiceSummary] = {} for event in events: current = service_buckets.get(event.service_key) if current is None: current = AdminUptimeServiceSummary( service_key=event.service_key, service_name=event.service_name, samples=0, ok_samples=0, degraded_samples=0, down_samples=0, latest_status=event.service_status, latest_observed_at=event.observed_at, ) service_buckets[event.service_key] = current current.samples += 1 if event.service_status == "ok": current.ok_samples += 1 elif event.service_status == "degraded": current.degraded_samples += 1 else: current.down_samples += 1 ok_samples = sum(1 for event in events if event.service_status == "ok") sample_count = len(events) down_times = [event.observed_at for event in events if event.service_status == "down"] return AdminUptimeResponse( source="database", durable=True, window_hours=window_hours, generated_at=time.time(), sample_count=sample_count, ok_ratio=round(ok_samples / sample_count, 4) if sample_count else 0.0, degraded_events=sum(1 for event in events if event.service_status == "degraded"), down_events=sum(1 for event in events if event.service_status == "down"), last_down_at=max(down_times) if down_times else None, services=sorted( service_buckets.values(), key=lambda item: (item.latest_status != "down", item.latest_status != "degraded", item.service_key), ), events=events, ) async def _tickets_from_database( *, ticket_status: TicketStatus | None, window_days: int, ) -> AdminTicketsResponse: async with acquire(role="admin") as conn: rows = await conn.fetch( """ SELECT id, reporter_id, reporter_email, reporter_name, reporter_role, category, priority, status, subject, body, source_path, assigned_group, resolution_note, created_at, updated_at, resolved_at FROM app.support_ticket WHERE ($1::text IS NULL OR status = $1) AND created_at >= now() - ($2::int * interval '1 day') ORDER BY CASE WHEN status = 'open' THEN 0 WHEN status = 'triaged' THEN 1 WHEN status = 'in_progress' THEN 2 WHEN status = 'resolved' THEN 3 ELSE 4 END, CASE WHEN priority = 'urgent' THEN 0 WHEN priority = 'high' THEN 1 WHEN priority = 'normal' THEN 2 ELSE 3 END, updated_at DESC LIMIT 120 """, ticket_status, window_days, ) tickets = [_ticket_from_row(row) for row in rows] return AdminTicketsResponse( source="database", durable=True, generated_at=time.time(), tickets=tickets, summary=_ticket_summary(tickets), ) def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse: window_start = time.time() - (window_days * 86400) total_turns = 0 metered_turns = 0 tokens_in = 0 tokens_out = 0 cost_usd = 0.0 buckets: dict[tuple[str, str], dict[str, int | float]] = {} for sess in store.list(): for turn in getattr(sess, "turns", []) or []: if getattr(turn, "speaker", "") != "client": continue if float(getattr(turn, "created_at", 0.0) or 0.0) < window_start: continue total_turns += 1 provider = str(getattr(turn, "llm_provider", None) or "unknown") model = str(getattr(turn, "model", None) or "unknown") turn_tokens_in = _safe_usage_int(getattr(turn, "tokens_in", 0)) turn_tokens_out = _safe_usage_int(getattr(turn, "tokens_out", 0)) turn_cost = _decimal_to_float(getattr(turn, "cost_usd", 0.0)) is_metered = ( provider != "unknown" or model != "unknown" or turn_tokens_in > 0 or turn_tokens_out > 0 or turn_cost > 0 ) if not is_metered: continue metered_turns += 1 tokens_in += turn_tokens_in tokens_out += turn_tokens_out cost_usd += turn_cost key = (provider, model) bucket = buckets.setdefault( key, {"turns": 0, "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0}, ) bucket["turns"] = int(bucket["turns"]) + 1 bucket["tokens_in"] = int(bucket["tokens_in"]) + turn_tokens_in bucket["tokens_out"] = int(bucket["tokens_out"]) + turn_tokens_out bucket["cost_usd"] = float(bucket["cost_usd"]) + turn_cost by_provider = [ AdminUsageBreakdown( provider=provider, model=model, turns=int(values["turns"]), tokens_in=int(values["tokens_in"]), tokens_out=int(values["tokens_out"]), cost_usd=round(float(values["cost_usd"]), 6), ) for (provider, model), values in sorted( buckets.items(), key=lambda item: ( -float(item[1]["cost_usd"]), -(int(item[1]["tokens_in"]) + int(item[1]["tokens_out"])), -int(item[1]["turns"]), ), )[:12] ] total_cost = round(cost_usd, 6) return AdminUsageResponse( source="server_session_registry", durable=False, window_days=window_days, generated_at=time.time(), total_turns=total_turns, metered_turns=metered_turns, tokens_in=tokens_in, tokens_out=tokens_out, cost_usd=total_cost, budget=_usage_budget(total_cost), by_provider=by_provider, ) class AdminUserCreate(BaseModel): email: str = Field(..., min_length=3, max_length=254) display_name: str = Field(..., min_length=1, max_length=80) role: RoleName = "learner" admin_access: bool = False account_status: AccountStatus = "approved" affiliation: str | None = Field(default=None, max_length=120) cohort_ids: list[str] = Field(default_factory=list) class AdminUserDeleteResponse(BaseModel): ok: bool user_id: str _ENGINE_CONFIG: AdminEngineConfigResponse | None = None ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"} ENGINE_MODE_ALIASES = {"messages_api": "claude_api"} def _normalize_email(value: str) -> str: email = value.strip().lower() if "@" not in email: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid") local, domain = email.rsplit("@", 1) if not local or not domain: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid") return email def _default_engine_config() -> AdminEngineConfigResponse: return AdminEngineConfigResponse( engine_mode=settings.engine_mode, engine_url=settings.engine_url, model="gateway-default", durable=False, source="runtime_default", ) def _normalize_engine_mode(value: str) -> str: mode = ENGINE_MODE_ALIASES.get(value.strip(), value.strip()) if mode not in ENGINE_MODES: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"unsupported engine mode {value}", ) return mode def _normalize_engine_url(value: str) -> str: url = value.strip().rstrip("/") if not (url.startswith("http://") or url.startswith("https://")): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail="engine_url must start with http:// or https://", ) return url def _updated_at_ts(value: datetime | None) -> float | None: if value is None: return None if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.timestamp() def _row_ts(value: object) -> float | None: if value is None: return None if isinstance(value, datetime): return _updated_at_ts(value) try: return float(value) except (TypeError, ValueError): return None def _engine_config_from_row(row) -> AdminEngineConfigResponse: return AdminEngineConfigResponse( engine_mode=_normalize_engine_mode(row["engine_mode"]), engine_url=_normalize_engine_url(row["engine_url"]), model=row["model"], updated_by=row["updated_by"], updated_at=_updated_at_ts(row["updated_at"]), durable=True, source="database", ) def _health_event_from_row(row) -> AdminHealthEvent: return AdminHealthEvent( event_id=int(row["id"]), observed_at=_row_ts(row["observed_at"]) or 0.0, overall_status=row["overall_status"], service_key=row["service_key"], service_name=row["service_name"], service_status=row["service_status"], detail=row["detail"], metric=row["metric"], load=_clamp01(float(row["load"] or 0.0)), ) def _ticket_from_row(row) -> AdminSupportTicketResponse: return AdminSupportTicketResponse( ticket_id=str(row["id"]), reporter=AdminTicketReporter( user_id=str(row["reporter_id"]) if row["reporter_id"] else None, email=row["reporter_email"], display_name=row["reporter_name"], role=row["reporter_role"], ), category=row["category"], priority=row["priority"], status=row["status"], subject=row["subject"], body=row["body"], source_path=row["source_path"], assigned_group=row["assigned_group"], resolution_note=row["resolution_note"], created_at=_row_ts(row["created_at"]) or 0.0, updated_at=_row_ts(row["updated_at"]) or 0.0, resolved_at=_row_ts(row["resolved_at"]), ) def _empty_ticket_summary() -> AdminTicketSummary: return AdminTicketSummary( total=0, open_count=0, high_priority_count=0, stale_count=0, by_status={}, by_category={}, by_priority={}, ) def _ticket_summary(tickets: list[AdminSupportTicketResponse]) -> AdminTicketSummary: now = time.time() by_status: dict[str, int] = {} by_category: dict[str, int] = {} by_priority: dict[str, int] = {} active_tickets = [ticket for ticket in tickets if ticket.status not in {"resolved", "closed"}] for ticket in tickets: by_status[ticket.status] = by_status.get(ticket.status, 0) + 1 by_category[ticket.category] = by_category.get(ticket.category, 0) + 1 by_priority[ticket.priority] = by_priority.get(ticket.priority, 0) + 1 return AdminTicketSummary( total=len(tickets), open_count=len(active_tickets), high_priority_count=sum( 1 for ticket in active_tickets if ticket.priority in {"high", "urgent"} ), stale_count=sum( 1 for ticket in active_tickets if now - ticket.updated_at > 86400 ), by_status=by_status, by_category=by_category, by_priority=by_priority, ) def _unavailable_uptime(window_hours: int) -> AdminUptimeResponse: return AdminUptimeResponse( source="unavailable", durable=False, window_hours=window_hours, generated_at=time.time(), sample_count=0, ok_ratio=0.0, degraded_events=0, down_events=0, last_down_at=None, services=[], events=[], ) def _unavailable_tickets() -> AdminTicketsResponse: return AdminTicketsResponse( source="unavailable", durable=False, generated_at=time.time(), tickets=[], summary=_empty_ticket_summary(), ) async def _current_engine_config() -> AdminEngineConfigResponse: if _ENGINE_CONFIG is not None: return _ENGINE_CONFIG try: pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( """ SELECT engine_mode, engine_url, model, updated_by, updated_at FROM app.admin_engine_config WHERE id = TRUE """ ) if row is not None: return _engine_config_from_row(row) return _default_engine_config() except Exception: require_runtime_fallback_allowed("admin engine config") return _default_engine_config() async def apply_engine_config_from_store() -> AdminEngineConfigResponse: """Load admin engine settings and apply them to the live engine client.""" config = await _current_engine_config() await engine_client.configure( base_url=config.engine_url, engine_mode=config.engine_mode, default_model=config.model, ) return config def _overall_status(services: list[AdminServiceHealth]) -> HealthStatus: if any(s.status == "down" for s in services): return "down" if any(s.status == "degraded" for s in services): return "degraded" return "ok" def _engine_unavailable_detail(detail: str) -> str: if detail.lstrip().startswith("{") and '"ok":false' in detail: return "Engine readiness failed" return detail def _requires_super_admin_for_privilege_change( *, current_role: RoleName | None, next_role: RoleName | None, current_admin_access: bool, next_admin_access: bool | None, ) -> bool: if next_admin_access is not None and next_admin_access != current_admin_access: return True if next_role == "admin" and current_role != "admin": return True if current_role == "admin" and next_role is not None and next_role != "admin": return True return False def _assert_super_admin(principal: Principal) -> None: if not principal.super_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="super admin required") def _assert_super_admin_target_mutable(email: str, admin_access: bool | None) -> None: if is_super_admin_email(email) and admin_access is False: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot revoke super admin access") async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse: effective_admin_access = has_admin_access(user.email, user.role, user.admin_access) return AdminUserResponse( user_id=user.user_id, email=user.email, display_name=user.display_name, role=user.role, admin_access=effective_admin_access, super_admin=is_super_admin_email(user.email), account_status=user.account_status, cohort_ids=user.cohort_ids, affiliation=user.affiliation, active_sessions=await active_session_count(user.user_id), created_at=user.created_at, last_seen_at=user.last_seen_at, source="database" if durable else "server_session_registry", ) @router.get("/health", response_model=AdminHealthResponse) async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse: """Return operational health from live backend checks.""" current_engine = await _current_engine_config() db_ok = await healthcheck() engine_started = time.perf_counter() engine = await engine_client.health_detail() engine_ok = bool(engine.get("ok")) engine_detail = _engine_unavailable_detail( str(engine.get("detail") or "engine readiness unavailable") ) engine_latency_ms = (time.perf_counter() - engine_started) * 1000 voice_ok = voice_service.is_available() metrics = await _runtime_health_metrics(db_ok=db_ok) metrics.engine_latency_ms = engine_latency_ms if engine_ok else None pool_load = _pool_load(metrics) session_load = _workload_load(metrics.active_sessions, 50) review_load = _workload_load(metrics.pending_reviews, 50) runtime_fallback_is_enabled = settings.environment == "dev" db_status: HealthStatus = "ok" if db_ok else ("degraded" if runtime_fallback_is_enabled else "down") db_detail = ( "사용자, 세션, 리뷰 저장" if db_ok else ( "DB 연결 전까지 비영구 개발 런타임 기록 사용" if runtime_fallback_is_enabled else "DB 저장소에 연결할 수 없습니다" ) ) db_metric = ( f"풀 {max(0, metrics.db_pool_size - metrics.db_pool_idle)}/{metrics.db_pool_max}" if db_ok else ("비영구 런타임 기록" if runtime_fallback_is_enabled else "저장소 중단") ) services = [ AdminServiceHealth( key="engine", name="응답 생성", status="ok" if engine_ok else "down", detail="AI 엔진 생성 준비 완료" if engine_ok else engine_detail, metric=f"{engine_latency_ms:.0f}ms" if engine_ok else "로그인/설정 필요", load=_clamp01(engine_latency_ms / 1500) if engine_ok else 0.0, ), AdminServiceHealth( key="db", name="영구 저장소", status=db_status, detail=db_detail, metric=db_metric, load=max(pool_load, session_load) if db_ok else 0.0, ), AdminServiceHealth( key="voice", name="음성 입력", status="ok" if voice_ok else "degraded", detail="음성 입력과 재생", metric="OpenAI 연결" if voice_ok else "설정 필요", load=0.05 if voice_ok else 0.0, ), AdminServiceHealth( key="evaluation", name="리뷰 생성", status="ok" if engine_ok else "degraded", detail="회기 종료 후 피드백 생성", metric=f"대기 {metrics.pending_reviews}건", load=review_load if engine_ok else 0.0, ), AdminServiceHealth( key="kb", name="지식 검색", status="ok" if db_ok else "degraded", detail=f"검색 기준값 {rag.CRAG_TOP1_THRESHOLD}", metric=( f"활성 세션 {metrics.active_sessions}건" if db_ok else "대기 중" ), load=max(pool_load, session_load) if db_ok else 0.0, ), ] response = AdminHealthResponse( status=_overall_status(services), environment=settings.environment, engine_mode=current_engine.engine_mode, services=services, ) await _record_health_events( principal=principal, overall_status=response.status, environment=response.environment, engine_mode=response.engine_mode, services=response.services, ) return response @router.get("/usage", response_model=AdminUsageResponse) async def admin_usage( principal: AdminPrincipal, window_days: Annotated[int, Query(ge=1, le=90)] = 7, ) -> AdminUsageResponse: """Return AI token/cost usage from persisted turns or dev fallback state.""" try: return await _usage_from_database(window_days) except Exception: require_runtime_fallback_allowed("admin usage") return _usage_from_runtime_store(window_days) @router.get("/uptime", response_model=AdminUptimeResponse) async def admin_uptime( principal: AdminPrincipal, window_hours: Annotated[int, Query(ge=1, le=720)] = 24, ) -> AdminUptimeResponse: """Return persisted health check samples captured by the admin console.""" try: return await _uptime_from_database(window_hours) except Exception: return _unavailable_uptime(window_hours) @router.get("/tickets", response_model=AdminTicketsResponse) async def list_tickets( principal: AdminPrincipal, ticket_status: Annotated[TicketStatus | None, Query(alias="status")] = None, window_days: Annotated[int, Query(ge=1, le=365)] = 30, ) -> AdminTicketsResponse: """Return user-submitted operational tickets without synthetic fallback rows.""" try: return await _tickets_from_database(ticket_status=ticket_status, window_days=window_days) except Exception: return _unavailable_tickets() @router.patch("/tickets/{ticket_id}", response_model=AdminSupportTicketResponse) async def patch_ticket( ticket_id: str, body: AdminTicketPatch, principal: AdminPrincipal, ) -> AdminSupportTicketResponse: """Update ticket triage state for administrators.""" try: async with acquire(role="admin", user_id=principal.user_id) as conn: row = await conn.fetchrow( """ UPDATE app.support_ticket SET status = COALESCE($2, status), priority = COALESCE($3, priority), assigned_group = COALESCE($4, assigned_group), resolution_note = COALESCE($5, resolution_note), resolved_at = CASE WHEN COALESCE($2, status) IN ('resolved', 'closed') THEN COALESCE(resolved_at, now()) WHEN $2 IS NOT NULL THEN NULL ELSE resolved_at END, updated_at = now(), last_activity_at = now() WHERE id = $1::uuid RETURNING id, reporter_id, reporter_email, reporter_name, reporter_role, category, priority, status, subject, body, source_path, assigned_group, resolution_note, created_at, updated_at, resolved_at """, ticket_id, body.status, body.priority, body.assigned_group.strip() if body.assigned_group is not None else None, body.resolution_note.strip() if body.resolution_note is not None else None, ) except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="ticket persistence unavailable", ) from exc if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found") return _ticket_from_row(row) @router.get("/engine-config", response_model=AdminEngineConfigResponse) async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse: """Return the current admin-managed engine settings.""" return await _current_engine_config() @router.patch("/engine-config", response_model=AdminEngineConfigResponse) async def patch_engine_config( body: AdminEngineConfigPatch, principal: AdminPrincipal, ) -> AdminEngineConfigResponse: """Persist engine settings for administrators.""" global _ENGINE_CONFIG current = await _current_engine_config() next_mode = _normalize_engine_mode(body.engine_mode or current.engine_mode) next_url = _normalize_engine_url(body.engine_url or current.engine_url) next_config = AdminEngineConfigResponse( engine_mode=next_mode, engine_url=next_url, model=(body.model or current.model).strip(), updated_by=principal.email, updated_at=time.time(), durable=False, source="runtime_cache", ) try: pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( """ INSERT INTO app.admin_engine_config ( id, engine_mode, engine_url, model, updated_by, updated_at ) VALUES (TRUE, $1, $2, $3, $4, now()) ON CONFLICT (id) DO UPDATE SET engine_mode = EXCLUDED.engine_mode, engine_url = EXCLUDED.engine_url, model = EXCLUDED.model, updated_by = EXCLUDED.updated_by, updated_at = now() RETURNING engine_mode, engine_url, model, updated_by, updated_at """, next_config.engine_mode, next_config.engine_url, next_config.model, principal.email, ) next_config = _engine_config_from_row(row) except Exception as exc: if settings.environment != "dev": raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="engine config persistence unavailable", ) from exc _ENGINE_CONFIG = next_config await engine_client.configure( base_url=next_config.engine_url, engine_mode=next_config.engine_mode, default_model=next_config.model, ) return next_config @router.get("/users", response_model=AdminUsersResponse) async def list_users(principal: AdminPrincipal) -> AdminUsersResponse: """Return users observed by the server-side auth/session boundary.""" users, durable = await list_managed_users() if not durable: require_runtime_fallback_allowed("admin user list") return AdminUsersResponse( source="database" if durable else "server_session_registry", durable=durable, users=[await _admin_user_response(user, durable=durable) for user in users], ) @router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED) async def create_user( body: AdminUserCreate, principal: AdminPrincipal, ) -> AdminUserResponse: """Create or reactivate a managed user without requiring that user to log in first.""" if body.role == "admin" or body.admin_access: _assert_super_admin(principal) user = await upsert_managed_user( email=_normalize_email(body.email), display_name=body.display_name, role=body.role, admin_access=body.admin_access, account_status=body.account_status, affiliation=body.affiliation, cohort_ids=body.cohort_ids, reactivate=True, ) users, durable = await list_managed_users() if not durable: require_runtime_fallback_allowed("admin user create") return await _admin_user_response(user, durable=durable) @router.patch("/users/{user_id}", response_model=AdminUserResponse) async def patch_user( user_id: str, body: AdminUserPatch, principal: AdminPrincipal, ) -> AdminUserResponse: """Update a server-known user's role/profile for the current API process.""" current = await get_managed_user(user_id) if current is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") if _requires_super_admin_for_privilege_change( current_role=current.role, next_role=body.role, current_admin_access=current.admin_access, next_admin_access=body.admin_access, ): _assert_super_admin(principal) _assert_super_admin_target_mutable(current.email, body.admin_access) next_user = await update_managed_user( user_id, ManagedUserPatch( display_name=body.display_name, role=body.role, admin_access=body.admin_access, account_status=body.account_status, affiliation=body.affiliation, cohort_ids=body.cohort_ids, ), ) if next_user is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") users, durable = await list_managed_users() if not durable: require_runtime_fallback_allowed("admin user update") return await _admin_user_response(next_user, durable=durable) @router.delete("/users/{user_id}", response_model=AdminUserDeleteResponse) async def delete_user( user_id: str, principal: AdminPrincipal, ) -> AdminUserDeleteResponse: """Deactivate a managed user and revoke any active browser sessions.""" if user_id == principal.user_id: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate current admin") current = await get_managed_user(user_id) if current is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") if is_super_admin_email(current.email): raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate super admin") if not await deactivate_managed_user(user_id): raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") return AdminUserDeleteResponse(ok=True, user_id=user_id)