from __future__ import annotations from server.status import status_bp import time import re import os import json import shutil import subprocess import sys import tempfile import plistlib from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session from server.auth_helpers import api_login_required, resolve_admin_policy from server.context import with_terminal, attach_user_broadcast from server.state import ( PROJECT_STORAGE_CACHE, PROJECT_STORAGE_CACHE_TTL_SECONDS, PROJECT_MAX_STORAGE_MB, container_manager, user_manager, ) from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE from modules.host_workspace_manager import ( create_host_workspace, delete_host_workspace, load_host_workspace_catalog, rename_host_workspace, resolve_host_workspace, set_default_host_workspace, ) from utils.host_workspace_debug import write_host_workspace_debug from server.utils_common import log_conn_diag import server.state as state STATUS_DIAG_VERBOSE = os.environ.get("STATUS_DIAG_VERBOSE", "0") not in {"0", "false", "False"} STATUS_DIAG_SLOW_MS = int(os.environ.get("STATUS_DIAG_SLOW_MS", "1200") or 1200) def _close_terminal_for_key(term_key: str): terminal = state.user_terminals.pop(term_key, None) if terminal: try: if getattr(terminal, "terminal_manager", None): terminal.terminal_manager.close_all() except Exception: pass def _is_host_mode_request() -> bool: return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host" def _is_docker_project_request() -> bool: return bool(session.get("username")) and not bool(session.get("host_mode")) and not bool(session.get("is_api_user")) def _active_task_counts(username: str) -> dict: running_by_workspace = {} try: from server.tasks import task_manager for rec in task_manager.list_tasks(username): if rec.status in {"pending", "running", "cancel_requested"}: ws_id = getattr(rec, "workspace_id", None) or "default" running_by_workspace[ws_id] = running_by_workspace.get(ws_id, 0) + 1 except Exception: running_by_workspace = {} return running_by_workspace @status_bp.route('/api/health') @api_login_required def get_health(): """轻量健康检查:用于前端连接心跳,避免触发重型 status 计算。""" started_at = time.perf_counter() heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip() username = session.get("username") or "-" payload = { "success": True, "status": "ok", "server_time_ms": int(time.time() * 1000), } elapsed_ms = (time.perf_counter() - started_at) * 1000 if request.args.get("diag") == "1" or elapsed_ms >= 800: log_conn_diag( f"health hb={heartbeat_id or '-'} user={username} " f"elapsed_ms={elapsed_ms:.1f} host_mode={bool(session.get('host_mode'))} " f"workspace_id={session.get('workspace_id') or '-'}" ) return jsonify(payload) @status_bp.route('/api/status') @api_login_required @with_terminal def get_status(terminal, workspace, username): """获取系统状态(包含对话、容器、版本等信息)""" started_at = time.perf_counter() heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip() diag_requested = STATUS_DIAG_VERBOSE or request.args.get("diag") == "1" timings = { "terminal_ms": 0.0, "terminals_ms": 0.0, "conversation_meta_ms": 0.0, "container_ms": 0.0, "policy_ms": 0.0, } phase_started_at = time.perf_counter() try: status = terminal.get_status() except Exception as exc: timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000 total_ms = (time.perf_counter() - started_at) * 1000 log_conn_diag( f"status hb={heartbeat_id or '-'} user={username} phase=terminal.get_status " f"total_ms={total_ms:.1f} terminal_ms={timings['terminal_ms']:.1f} error={exc}" ) raise timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000 phase_started_at = time.perf_counter() if terminal.terminal_manager: status['terminals'] = terminal.terminal_manager.list_terminals() timings["terminals_ms"] = (time.perf_counter() - phase_started_at) * 1000 phase_started_at = time.perf_counter() try: current_conv = terminal.context_manager.current_conversation_id status.setdefault('conversation', {})['current_id'] = current_conv if current_conv and not current_conv.startswith('temp_'): current_conv_data = terminal.context_manager._get_conversation_manager_for_id(current_conv).load_conversation(current_conv) if current_conv_data: status['conversation']['title'] = current_conv_data.get('title', '未知对话') status['conversation']['created_at'] = current_conv_data.get('created_at') status['conversation']['updated_at'] = current_conv_data.get('updated_at') meta = current_conv_data.get("metadata", {}) or {} status['conversation']['compression'] = { "in_progress": bool(meta.get("compression_in_progress", False)), "mode": meta.get("compression_mode"), "stage": meta.get("compression_stage"), "error": meta.get("compression_error"), "count": int(meta.get("compression_count", 0) or 0), "job_id": meta.get("compression_job_id"), } except Exception as exc: log_conn_diag(f"status conversation-meta-failed user={username} error={exc}") timings["conversation_meta_ms"] = (time.perf_counter() - phase_started_at) * 1000 status['project_path'] = str(workspace.project_path) phase_started_at = time.perf_counter() try: # 首屏状态只需要容器是否存在/运行等轻量信息;Docker stats 很慢, # 由 /api/container-status 在用量面板需要时单独拉取。 workspace_id = getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default' container_key = ( f"host::{workspace_id}" if bool(session.get("host_mode")) or username == "host" else f"{username}::{workspace_id}" ) status['container'] = container_manager.get_container_status(container_key, include_stats=False) except Exception as exc: status['container'] = {"success": False, "error": str(exc)} log_conn_diag(f"status container-status-failed user={username} error={exc}") timings["container_ms"] = (time.perf_counter() - phase_started_at) * 1000 status['version'] = AGENT_VERSION phase_started_at = time.perf_counter() try: policy = resolve_admin_policy(user_manager.get_user(username)) status['admin_policy'] = { "ui_blocks": policy.get("ui_blocks") or {}, "disabled_models": policy.get("disabled_models") or [], "forced_category_states": policy.get("forced_category_states") or {}, "version": policy.get("updated_at"), } except Exception: pass timings["policy_ms"] = (time.perf_counter() - phase_started_at) * 1000 total_ms = (time.perf_counter() - started_at) * 1000 is_slow = total_ms >= STATUS_DIAG_SLOW_MS if diag_requested or is_slow: conversation = status.get("conversation") or {} log_conn_diag( f"status hb={heartbeat_id or '-'} user={username} host_mode={bool(session.get('host_mode'))} " f"workspace_id={session.get('workspace_id') or '-'} " f"conversation_id={conversation.get('current_id') or '-'} " f"total_ms={total_ms:.1f} slow={is_slow} " f"terminal_ms={timings['terminal_ms']:.1f} terminals_ms={timings['terminals_ms']:.1f} " f"conversation_meta_ms={timings['conversation_meta_ms']:.1f} " f"container_ms={timings['container_ms']:.1f} policy_ms={timings['policy_ms']:.1f}" ) return jsonify(status) @status_bp.route('/api/container-status') @api_login_required @with_terminal def get_container_status_api(terminal, workspace, username): try: container_key = ( f"host::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}" if bool(session.get("host_mode")) or username == "host" else f"{username}::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}" ) status = container_manager.get_container_status(container_key) return jsonify({"success": True, "data": status}) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 @status_bp.route('/api/project-storage') @api_login_required @with_terminal def get_project_storage(terminal, workspace, username): now = time.time() workspace_id = getattr(workspace, "workspace_id", None) or session.get("workspace_id") or "default" cache_key = f"{username}::{workspace_id}" cache_entry = PROJECT_STORAGE_CACHE.get(cache_key) if cache_entry and (now - cache_entry.get("ts", 0)) < PROJECT_STORAGE_CACHE_TTL_SECONDS: return jsonify({"success": True, "data": cache_entry["data"]}) try: file_manager = getattr(terminal, 'file_manager', None) if not file_manager: return jsonify({"success": False, "error": "文件管理器未初始化"}), 500 used_bytes = file_manager._get_project_size() limit_bytes = PROJECT_MAX_STORAGE_MB * 1024 * 1024 if PROJECT_MAX_STORAGE_MB else None usage_percent = (used_bytes / limit_bytes * 100) if limit_bytes else None data = { "used_bytes": used_bytes, "limit_bytes": limit_bytes, "limit_label": f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制", "usage_percent": usage_percent } PROJECT_STORAGE_CACHE[cache_key] = {"ts": now, "data": data} return jsonify({"success": True, "data": data}) except Exception as exc: stale = PROJECT_STORAGE_CACHE.get(cache_key) if stale: return jsonify({"success": True, "data": stale.get("data"), "stale": True}), 200 return jsonify({"success": False, "error": str(exc)}), 500