diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index b7f1e6d..8ad2bdd 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -143,6 +143,30 @@ def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]: except Exception as e: return False, f"Не удалось сохранить настройки: {e}" + from antigravity_provider.router.settings_service import invalidate_settings_cache + invalidate_settings_cache() + + try: + from antigravity_provider.router.router_config import load_router_config, save_router_config + rcfg = load_router_config() + updated_rcfg = False + if "quota_threshold_percent" in settings: + rcfg.quota_threshold_percent = float(settings["quota_threshold_percent"]) + updated_rcfg = True + if "quota_threshold_action" in settings: + rcfg.quota_threshold_action = str(settings["quota_threshold_action"]) + updated_rcfg = True + if updated_rcfg: + save_router_config(rcfg) + except Exception: + pass + + try: + from antigravity_provider.router.state_store import HubStateStore + HubStateStore.get().refresh(force_scan=True) + except Exception: + pass + from antigravity_provider.router.quota_collector import AccountQuotaService AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300))) diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 9e7b456..a9fde50 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -104,7 +104,10 @@ class AutoAssigner: @staticmethod def get_display_name_and_role(profile_id: str) -> Tuple[str, str, str]: """Get human-readable display name, logical role, and tier for a profile.""" - return DEFAULT_SLOT_ROLES.get(profile_id, (profile_id, "worker", "primary")) + if profile_id in DEFAULT_SLOT_ROLES: + return DEFAULT_SLOT_ROLES[profile_id] + clean_name = profile_id.replace("-", " ").title() + return (clean_name, "worker", "primary") @staticmethod def check_duplicate_identity(provider: str, email_or_id: str, exclude_profile_id: Optional[str] = None) -> Optional[str]: @@ -138,8 +141,13 @@ class AutoAssigner: @staticmethod def find_free_slot(provider: str, requested_role: str = "auto") -> Optional[str]: - """Find the optimal free internal profile slot for a provider.""" + """Find the optimal free internal profile slot for a provider. + + When all predefined candidate slots are authenticated/occupied, dynamically + generates the next free profile ID without capping account count (P0-1). + """ config = load_router_config() + provider_norm = (provider or "").strip().lower() provider_slots = { "antigravity": [ @@ -147,9 +155,13 @@ class AutoAssigner: "ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3" ], "openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"], + "codex": ["codex-orch", "codex-worker-1", "codex-worker-2"], "opencode-go": ["opengo-1", "opengo-2", "opengo-3"], + "opencode": ["opengo-1", "opengo-2", "opengo-3"], "claude": ["claude-orch", "claude-worker-1", "claude-worker-2"], + "anthropic": ["claude-orch", "claude-worker-1", "claude-worker-2"], "grok": ["grok-orch", "grok-worker-1", "grok-worker-2"], + "xai": ["grok-orch", "grok-worker-1", "grok-worker-2"], "local": ["local-1", "local-2"], "local-llm": ["local-1", "local-2"], "llama.cpp": ["local-1", "local-2"], @@ -157,18 +169,17 @@ class AutoAssigner: "vllm": ["local-1", "local-2"], } - candidates = list(provider_slots.get(provider, [])) + candidates = list(provider_slots.get(provider_norm, [])) # Priority based on requested role if requested_role == "orchestrator": - if provider == "openai-codex" and "codex-orch" in candidates: + if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates: candidates.remove("codex-orch") candidates.insert(0, "codex-orch") - elif provider == "antigravity" and "ag-orch-fallback" in candidates: + elif provider_norm == "antigravity" and "ag-orch-fallback" in candidates: candidates.remove("ag-orch-fallback") candidates.insert(0, "ag-orch-fallback") - - # Find first slot without saved auth that exists in config + # 1. First check existing candidate profiles already in config for pid in candidates: pcfg = config.get_profile(pid) if not pcfg: @@ -177,6 +188,44 @@ class AutoAssigner: if not status.get("authenticated"): return pid + # 2. Check remaining predefined slots and ensure definition + for pid in candidates: + status = ProfileAuthManager.get_profile_status(provider, pid) + if not status.get("authenticated"): + AutoAssigner.ensure_profile_definition(provider, pid) + return pid + + # 3. Predefined slots occupied: dynamically generate unlimited candidates + def _generate_candidates(prov: str): + p = prov.lower() + if p == "antigravity": + for i in range(5, 200): + yield f"ag-w{i}" + elif p in ("openai-codex", "codex"): + for i in range(4, 200): + yield f"codex-{i}" + elif p in ("opencode-go", "opencode"): + for i in range(4, 200): + yield f"opengo-{i}" + elif p in ("claude", "anthropic"): + for i in range(3, 200): + yield f"claude-worker-{i}" + elif p in ("grok", "xai"): + for i in range(3, 200): + yield f"grok-worker-{i}" + elif p in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): + for i in range(3, 200): + yield f"local-{i}" + else: + for i in range(1, 200): + yield f"{p}-{i}" + + for dynamic_pid in _generate_candidates(provider_norm): + status = ProfileAuthManager.get_profile_status(provider, dynamic_pid) + if not status.get("authenticated"): + AutoAssigner.ensure_profile_definition(provider, dynamic_pid) + return dynamic_pid + return None @staticmethod @@ -206,7 +255,7 @@ class AutoAssigner: "ollama": ["reviewer", "coding", "reasoning", "fast", "research"], "vllm": ["reviewer", "coding", "reasoning", "fast", "research"], } - capabilities = capabilities_map.get(provider, []) + capabilities = capabilities_map.get(provider, ["coding", "reasoning"]) config.profiles[profile_id] = RouterProfileConfig( profile_id=profile_id, @@ -300,9 +349,9 @@ class AutoAssigner: @staticmethod def auto_assign_all() -> Dict[str, Any]: - """Automatically distribute all authenticated profiles across canonical router roles.""" + """Automatically distribute all authenticated profiles across canonical router roles (P0-4).""" config = load_router_config() - authenticated_profiles = [] + authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = [] for pid, pcfg in config.profiles.items(): if not pcfg.enabled: continue @@ -310,13 +359,134 @@ class AutoAssigner: if st.get("authenticated"): authenticated_profiles.append((pid, pcfg)) - changes = [] - canonical_roles_order = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] - for idx, (pid, pcfg) in enumerate(authenticated_profiles): - target_role = canonical_roles_order[idx % len(canonical_roles_order)] - ok, msg = AutoAssigner.assign_profile_to_role(pid, target_role, is_primary=(idx < len(canonical_roles_order))) - if ok: - changes.append({"profile_id": pid, "role": target_role, "message": msg}) + if not authenticated_profiles: + return { + "success": False, + "message": "Нет подключённых аккаунтов для распределения", + "total_authenticated": 0, + "assigned_count": 0, + "changes": [], + } + + canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] + changes: List[Dict[str, Any]] = [] + + # Ensure canonical roles exist in config + for rname in canonical_roles: + if rname not in config.roles: + config.roles[rname] = RolePolicy(role_name=rname) + + def _norm_prov(p: str) -> str: + p = (p or "").lower().strip() + if p in ("openai-codex", "codex"): + return "codex" + if p in ("opencode-go", "opencode"): + return "opencode" + if p in ("local-llm", "llama.cpp", "ollama", "vllm", "local"): + return "local" + if p in ("claude", "anthropic"): + return "claude" + if p in ("grok", "xai"): + return "grok" + return p + + unique_providers = set(_norm_prov(pcfg.provider) for _, pcfg in authenticated_profiles) + + if len(authenticated_profiles) == 1: + # Case 1: Exactly 1 account connected -> assign as primary to all 6 roles + single_pid, _ = authenticated_profiles[0] + for role_name in canonical_roles: + config.roles[role_name].preferred_chain = [single_pid] + changes.append({ + "profile_id": single_pid, + "role": role_name, + "message": f"Профиль '{single_pid}' назначен основным во все 6 ролей", + }) + elif len(unique_providers) == 1: + # Case 2: Multiple accounts of the same provider -> distribute / rotate across roles + pids = [pid for pid, _ in authenticated_profiles] + num_accs = len(pids) + for idx, role_name in enumerate(canonical_roles): + primary_pid = pids[idx % num_accs] + fallbacks = [p for p in pids if p != primary_pid] + config.roles[role_name].preferred_chain = [primary_pid] + fallbacks + changes.append({ + "profile_id": primary_pid, + "role": role_name, + "message": f"Профиль '{primary_pid}' назначен на роль '{role_name}' с ротацией квот", + }) + else: + # Case 3: Multiple providers connected -> distribute by role provider preferences + role_provider_preferences = { + "orchestrator": ["codex", "antigravity", "opencode", "claude", "grok", "local"], + "coder-primary": ["codex", "antigravity", "opencode", "claude", "grok", "local"], + "coder-secondary": ["codex", "antigravity", "opencode", "claude", "grok", "local"], + "reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"], + "research": ["opencode", "antigravity", "grok", "claude", "codex", "local"], + "fast": ["opencode", "antigravity", "local", "grok", "codex", "claude"], + } + + by_prov: Dict[str, List[str]] = {} + for pid, pcfg in authenticated_profiles: + norm = _norm_prov(pcfg.provider) + by_prov.setdefault(norm, []).append(pid) + + prov_cursors: Dict[str, int] = {k: 0 for k in by_prov} + + for role_name in canonical_roles: + pref_order = role_provider_preferences.get(role_name, ["codex", "antigravity", "opencode"]) + chain: List[str] = [] + + # Find primary for this role + chosen_primary = None + for prov in pref_order: + if prov in by_prov and by_prov[prov]: + acc_list = by_prov[prov] + cur = prov_cursors[prov] + chosen_primary = acc_list[cur % len(acc_list)] + prov_cursors[prov] = cur + 1 + break + + if not chosen_primary: + all_pids = [pid for pid, _ in authenticated_profiles] + chosen_primary = all_pids[0] + + chain.append(chosen_primary) + + # Add remaining profiles as fallbacks in preference order + for prov in pref_order: + if prov in by_prov: + for p in by_prov[prov]: + if p not in chain: + chain.append(p) + for pid, _ in authenticated_profiles: + if pid not in chain: + chain.append(pid) + + config.roles[role_name].preferred_chain = chain + changes.append({ + "profile_id": chosen_primary, + "role": role_name, + "message": f"Профиль '{chosen_primary}' назначен основным на роль '{role_name}'", + }) + + save_router_config(config) + + try: + from antigravity_provider.router.state_store import HubStateStore + HubStateStore.get().refresh(force_scan=True) + except Exception: + pass + + try: + from antigravity_provider.router.unified_health import EventLogService + EventLogService.get().log( + "routing", + f"Авто-распределение завершено: {len(authenticated_profiles)} аккаунтов распределены по 6 ролям.", + level="info", + ) + except Exception: + pass return { "success": True, diff --git a/src/antigravity_provider/router/health_tracker.py b/src/antigravity_provider/router/health_tracker.py index d302cc7..8ab2e66 100644 --- a/src/antigravity_provider/router/health_tracker.py +++ b/src/antigravity_provider/router/health_tracker.py @@ -399,31 +399,68 @@ class HealthTracker: self._save_state() def reconcile_measured_quota(self, profile_id: str, remaining_by_family: Dict[str, float]) -> bool: - """Clear stale quota-exhausted flags when the provider reports live capacity. + """Evaluate measured quota against configured threshold and recover when capacity restores. A successful quota read is authoritative for quota exhaustion, but it must not erase unrelated authentication or runtime failures. """ - measured = {family: float(value) for family, value in remaining_by_family.items()} + measured = {family: float(value) for family, value in remaining_by_family.items() if value is not None} if not measured: return False + + from antigravity_provider.router.settings_service import get_hub_settings + settings = get_hub_settings() + threshold = float(settings.get("quota_threshold_percent", 10.0)) + action = str(settings.get("quota_threshold_action", "notify")).strip().lower() + with self._lock: record = self.get_or_create(profile_id) changed = False + now = time.time() + for family, remaining in measured.items(): family_record = record.families.get(family) - if remaining > 0 and family_record and family_record.state == QUOTA_EXHAUSTED: - family_record.state = HEALTHY - family_record.reset_at = None - family_record.reason = None - family_record.last_error = None - family_record.simulated = False - changed = True - if all(value > 0 for value in measured.values()) and record.overall_state == QUOTA_EXHAUSTED: + if remaining <= threshold: + if action == "switch": + if family not in record.families: + record.families[family] = FamilyHealthRecord(family=family) + frec = record.families[family] + if frec.state != QUOTA_EXHAUSTED: + frec.state = QUOTA_EXHAUSTED + frec.reset_at = now + 1800 + frec.reason = f"Остаток квоты {remaining:.1f}% <= порога {threshold:.1f}%" + frec.last_error = frec.reason + changed = True + elif action == "notify": + try: + from antigravity_provider.router.unified_health import EventLogService + EventLogService.get().log( + "quota", + f"Внимание: остаток квоты профиля {profile_id} ({family}) составляет {remaining:.1f}% (порог: {threshold:.1f}%).", + level="warning", + ) + except Exception: + pass + elif remaining > threshold: + if family_record and family_record.state == QUOTA_EXHAUSTED: + family_record.state = HEALTHY + family_record.reset_at = None + family_record.reason = None + family_record.last_error = None + family_record.simulated = False + changed = True + + if all(value > threshold for value in measured.values()) and record.overall_state == QUOTA_EXHAUSTED: record.overall_state = HEALTHY record.last_error = None record.simulated = False changed = True + elif any(value <= threshold for value in measured.values()) and action == "switch": + if record.overall_state == HEALTHY: + record.overall_state = QUOTA_EXHAUSTED + record.last_error = f"Остаток квоты ниже порога {threshold:.1f}%" + changed = True + if changed: self._save_state() return changed diff --git a/src/antigravity_provider/router/quota_collector.py b/src/antigravity_provider/router/quota_collector.py index 1c95fa3..704d0e4 100644 --- a/src/antigravity_provider/router/quota_collector.py +++ b/src/antigravity_provider/router/quota_collector.py @@ -166,12 +166,12 @@ class AccountQuotaService: with self._cache_lock: self._snapshots[key] = snap - if snap.source == "provider_api": + if snap.source in ("provider_api", "local_provider") or snap.buckets: measured_by_family: dict[str, float] = {} for bucket in snap.buckets: - family = bucket.model_family + family = bucket.model_family or "default" remaining = bucket.remaining_percent - if not family or remaining is None: + if remaining is None: continue measured_by_family[family] = min(measured_by_family.get(family, 100.0), float(remaining)) if measured_by_family: @@ -179,8 +179,13 @@ class AccountQuotaService: from .router_engine import get_router_engine get_router_engine().health.reconcile_measured_quota(profile_id, measured_by_family) - except Exception as exc: - logger.debug("Could not reconcile live quota health for %s: %s", profile_id, exc) + except Exception: + try: + from .health_tracker import HealthTracker + + HealthTracker().reconcile_measured_quota(profile_id, measured_by_family) + except Exception as exc: + logger.debug("Could not reconcile live quota health for %s: %s", profile_id, exc) # Notify listeners for listener in list(self._listeners): diff --git a/src/antigravity_provider/router/router_config.py b/src/antigravity_provider/router/router_config.py index 86db45a..386367d 100644 --- a/src/antigravity_provider/router/router_config.py +++ b/src/antigravity_provider/router/router_config.py @@ -42,6 +42,8 @@ class RouterConfig: cooldown_base_seconds: int = 300 cooldown_max_seconds: int = 3600 session_affinity_ttl_seconds: int = 1800 + quota_threshold_percent: float = 10.0 + quota_threshold_action: str = "notify" # "notify" | "switch" roles: dict[str, RolePolicy] = field(default_factory=dict) profiles: dict[str, RouterProfileConfig] = field(default_factory=dict) pricing: dict[str, dict[str, float]] = field(default_factory=dict) @@ -387,6 +389,13 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: session_ttl = int(r_block.get("session_affinity_ttl_seconds", data.get("session_affinity_ttl_seconds", 1800))) quota_cooldown = int(r_block.get("quota_cooldown_seconds", data.get("quota_cooldown_seconds", 1800))) rate_cooldown = int(r_block.get("rate_limit_cooldown_seconds", data.get("rate_limit_cooldown_seconds", 60))) + try: + quota_threshold_percent = float(r_block.get("quota_threshold_percent", data.get("quota_threshold_percent", 10.0))) + except (ValueError, TypeError): + quota_threshold_percent = 10.0 + quota_threshold_action = str(r_block.get("quota_threshold_action", data.get("quota_threshold_action", "notify"))).strip().lower() + if quota_threshold_action not in ("notify", "switch"): + quota_threshold_action = "notify" # Automatic Idempotent Migration (P0-0.1) # Merge missing default profiles and roles into loaded user configuration @@ -435,6 +444,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: cooldown_base_seconds=cooldown_base, cooldown_max_seconds=cooldown_max, session_affinity_ttl_seconds=session_ttl, + quota_threshold_percent=quota_threshold_percent, + quota_threshold_action=quota_threshold_action, roles=roles, profiles=profiles, pricing=pricing, @@ -453,6 +464,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: cooldown_base_seconds=cooldown_base, cooldown_max_seconds=cooldown_max, session_affinity_ttl_seconds=session_ttl, + quota_threshold_percent=quota_threshold_percent, + quota_threshold_action=quota_threshold_action, roles=roles, profiles=profiles, pricing=pricing, @@ -509,6 +522,8 @@ def save_router_config(config: RouterConfig, config_path: Optional[Path] = None) "cooldown_base_seconds": config.cooldown_base_seconds, "cooldown_max_seconds": config.cooldown_max_seconds, "session_affinity_ttl_seconds": config.session_affinity_ttl_seconds, + "quota_threshold_percent": config.quota_threshold_percent, + "quota_threshold_action": config.quota_threshold_action, }) data = { diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py index 6fcd7eb..0e7182e 100644 --- a/src/antigravity_provider/router/settings_service.py +++ b/src/antigravity_provider/router/settings_service.py @@ -20,6 +20,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "release_channel": "stable", "model_timeout_seconds": 60, "monitoring_interval_seconds": 30, + "quota_threshold_percent": 10.0, + "quota_threshold_action": "notify", } @@ -86,6 +88,16 @@ def get_hub_settings() -> Dict[str, Any]: except (ValueError, TypeError): merged["monitoring_interval_seconds"] = 30 + try: + merged["quota_threshold_percent"] = float(merged.get("quota_threshold_percent", 10.0)) + except (ValueError, TypeError): + merged["quota_threshold_percent"] = 10.0 + + action = str(merged.get("quota_threshold_action", "notify")).strip().lower() + if action not in ("notify", "switch"): + action = "notify" + merged["quota_threshold_action"] = action + _SETTINGS_CACHE = dict(merged) _SETTINGS_CACHE_MTIME = current_mtime _SETTINGS_CACHE_PATH = sfile_str diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 2bab523..2cc157b 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -590,6 +590,24 @@ class UnifiedHealthService: dead_roles += 1 warnings.append(f"Роль '{rname}' не имеет рабочих аккаунтов (все исчерпаны).") + # Quota threshold warnings (P0-5) + try: + from antigravity_provider.router.quota_collector import AccountQuotaService + from antigravity_provider.router.settings_service import get_hub_settings + settings = get_hub_settings() + threshold = float(settings.get("quota_threshold_percent", 10.0)) + for profile in configured_profiles: + if profile.auth_state == "AUTHENTICATED": + snap = AccountQuotaService.get().get_snapshot(profile.provider, profile.profile_id) + if snap and snap.buckets: + for b in snap.buckets: + if b.remaining_percent is not None and b.remaining_percent <= threshold: + warnings.append( + f"Квота аккаунта {profile.profile_id} ({b.display_name or profile.provider}) ниже порога {threshold:.1f}%: {b.remaining_percent:.1f}%." + ) + except Exception: + pass + # Determine overall state if dead_roles > 0: state = READINESS_CRITICAL diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index aeef01a..a304b70 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -36,18 +36,9 @@ app.add_middleware( def _web_settings() -> Dict[str, Any]: - """Настройки веб-API живут в hub_settings.json, а не в RouterConfig. - - Прежняя версия читала config.hub — такого атрибута у RouterConfig нет, - поэтому /api/snapshot и /api/action падали с 500, а run_server не - поднимался вовсе. Работал только /api/health, у которого нет проверки - авторизации, — из-за чего дефект и выглядел как рабочий сервер. - """ - settings_file = paths.get_hermes_home() / "hub_settings.json" - try: - return json.loads(settings_file.read_text(encoding="utf-8")) - except (OSError, ValueError, TypeError): - return {} + """Настройки веб-API живут в hub_settings.json, а не в RouterConfig.""" + from antigravity_provider.router.settings_service import get_hub_settings + return get_hub_settings() def get_auth_token(x_hub_token: str = Header(None)) -> bool: diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index d6adc46..aa9028f 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -129,6 +129,13 @@ function initEventListeners() { }); } + const btnAutoAssign = document.getElementById('btn-auto-assign'); + if (btnAutoAssign) { + btnAutoAssign.addEventListener('click', () => { + executeAction('auto_assign_all', {}); + }); + } + if (elements.modalCloseBtn) elements.modalCloseBtn.addEventListener('click', closeModal); if (elements.modalBackdrop) { elements.modalBackdrop.addEventListener('click', (e) => { @@ -290,6 +297,28 @@ function startPolling() { } } +// Подключён ли профиль на самом деле. +// +// A26 определял это как «health_state не равен not_configured», а поле +// authenticated в модели вообще отсутствует, поэтому первая половина условия +// была мертва. Через фильтр проходили холодный резерв (health_state +// "disabled") и непроверенные пустые слоты: при нуле настоящих аккаунтов +// страница показывала три карточки «Холодный резерв», а «Обзор» предлагал +// назначать роли на пустые слоты — то самое мышление слотами, ради отмены +// которого задание и делалось. +// +// Authoritative признак — auth_state: у подключённого AUTHENTICATED, у +// пустого слота и у холодного резерва NOT_CONFIGURED. Состояния +// AUTH_REQUIRED и AUTH_EXPIRED означают подключённый аккаунт, которому нужен +// повторный вход, — их показываем. +function isConnectedProfile(p) { + if (!p) return false; + const st = String(p.auth_state || '').toUpperCase(); + if (st) return st !== 'NOT_CONFIGURED'; + // Запасной путь, если поле не пришло: судим по наличию опознанного аккаунта. + return Boolean(p.email); +} + // ── КОПИРОВАНИЕ В БУФЕР ── // // navigator.clipboard существует только в защищённом контексте: HTTPS или @@ -462,10 +491,14 @@ async function executeAction(actionName, actionData = {}) { function updateGlobalHeader() { if (!currentSnapshot) return; - const totalAccounts = Object.keys(currentSnapshot.all_profiles || {}).length; - if (elements.navAccountsCount) elements.navAccountsCount.textContent = totalAccounts; - const readiness = currentSnapshot.readiness || {}; + const allProfiles = Object.values(currentSnapshot.all_profiles || {}); + const connectedAccounts = readiness.accounts_connected_count ?? allProfiles.filter( + (p) => isConnectedProfile(p) + ).length; + + if (elements.navAccountsCount) elements.navAccountsCount.textContent = connectedAccounts; + const isHealthy = readiness.state === 'healthy'; const readyRoles = readiness.roles_ready_count || 0; const totalRoles = readiness.total_roles || 6; @@ -489,8 +522,8 @@ function updateGlobalHeader() { if (kpiReadiness) kpiReadiness.textContent = readiness.title_ru || 'Работает'; if (kpiSummary) kpiSummary.textContent = readiness.summary_ru || 'Все маршруты доступны'; - if (kpiTotalAccounts) kpiTotalAccounts.textContent = totalAccounts; - if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${readiness.accounts_connected_count || totalAccounts}`; + if (kpiTotalAccounts) kpiTotalAccounts.textContent = connectedAccounts; + if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${connectedAccounts}`; if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`; if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`; if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length || 5; @@ -525,12 +558,32 @@ function renderCurrentView() { } // ═══════════════════════════════════════════════════════════════ -// 1. ACCOUNTS VIEW (Compact Fixed-Height Cards & Quotas) +// 1. ACCOUNTS VIEW (P0-2 Only Connected Accounts & Empty State) // ═══════════════════════════════════════════════════════════════ function renderAccountsView() { const container = elements.accountsContainer; if (!container || !currentSnapshot) return; + const allProfiles = Object.values(currentSnapshot.all_profiles || {}); + const totalConnectedInSystem = allProfiles.filter( + (p) => isConnectedProfile(p) + ).length; + + if (totalConnectedInSystem === 0) { + container.innerHTML = ` +
Подключите ваш первый аккаунт провайдера ИИ для распределения ролей и работы с Hermes Hub.
+ +