diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js
index 8f4742c..9fd0091 100644
--- a/src/antigravity_provider/router/web/static/app.js
+++ b/src/antigravity_provider/router/web/static/app.js
@@ -336,6 +336,16 @@ function startPolling() {
// пустого слота и у холодного резерва NOT_CONFIGURED. Состояния
// AUTH_REQUIRED и AUTH_EXPIRED означают подключённый аккаунт, которому нужен
// повторный вход, — их показываем.
+// Цвет индикатора по измеренному состоянию. Неизвестное состояние остаётся
+// серым (базовый .status-dot), а не выдаёт себя за здоровое.
+function healthDotClass(state) {
+ const st = String(state || '').toLowerCase();
+ if (st === 'healthy') return 'healthy';
+ if (['quota_exhausted', 'rate_limited', 'auth_required', 'auth_expired'].includes(st)) return 'warning';
+ if (['error', 'unhealthy'].includes(st)) return 'error';
+ return '';
+}
+
function isConnectedProfile(p) {
if (!p) return false;
const st = String(p.auth_state || '').toUpperCase();
@@ -990,7 +1000,17 @@ function renderRoutingView() {
const routing = currentSnapshot.routing || {};
const agents = currentSnapshot.agents || [];
- const profiles = currentSnapshot.profiles || {};
+ // Снапшот отдаётся как dataclasses.asdict(HubSnapshot): профили лежат в
+ // all_profiles. Ключа profiles в ответе нет — правая колонка была всегда
+ // пуста, счётчик оставался литеральным «0 аккаунтов» из разметки, а строки
+ // цепочек получали пустой профиль и рисовались иконкой-заглушкой.
+ // Показываем только подключённые аккаунты — правило A26.
+ // Для строк цепочек берём полный список: цепочка может ссылаться на
+ // аккаунт, который сейчас не подключён, и его надо показать как есть,
+ // а не подменять пустым профилем.
+ const profiles = currentSnapshot.all_profiles || {};
+ // В колонку «доступные» идут только подключённые — правило A26.
+ const availableProfiles = Object.entries(profiles).filter(([, p]) => isConnectedProfile(p));
// Render Left Column (Roles)
let rolesHtml = '';
@@ -1089,7 +1109,7 @@ function renderRoutingView() {
const q = searchEl ? searchEl.value.toLowerCase() : '';
let count = 0;
- for (const [pid, prof] of Object.entries(profiles)) {
+ for (const [pid, prof] of availableProfiles) {
if (q && !pid.toLowerCase().includes(q) && !(prof.provider||'').toLowerCase().includes(q)) continue;
count++;
const icon = getProviderIcon(prof.provider);
@@ -1101,7 +1121,10 @@ function renderRoutingView() {
${escapeHtml(prof.provider || '')}
-
+
+
diff --git a/src/antigravity_provider/router/web/static/workflow.css b/src/antigravity_provider/router/web/static/workflow.css
index 7c00f01..19de83e 100644
--- a/src/antigravity_provider/router/web/static/workflow.css
+++ b/src/antigravity_provider/router/web/static/workflow.css
@@ -31,7 +31,7 @@
.workflow-edges path { fill:none; stroke:var(--accent); stroke-width:1.6; marker-end:url(#wf-arrow); }
.workflow-edges path.success,.workflow-edges path.review_passed { stroke:var(--status-healthy); }
.workflow-edges path.review_failed,.workflow-edges path.error { stroke:var(--status-error); stroke-dasharray:7 5; }
-.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; }
+.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; text-anchor:middle; paint-order:stroke; stroke:var(--bg-base); stroke-width:3px; stroke-linejoin:round; }
.workflow-edge-hit { fill:none; stroke:transparent; stroke-width:14; pointer-events:stroke; cursor:pointer; }
.workflow-node { position:absolute; width:190px; min-height:92px; padding:11px 12px; border:1px solid var(--border-accent); border-radius:9px; background:linear-gradient(145deg,var(--surface),var(--surface-muted)); box-shadow:0 8px 22px rgba(0,0,0,.18); cursor:pointer; user-select:none; }
.workflow-node.selected { outline:2px solid var(--accent); box-shadow:0 0 18px var(--accent-dim); }
diff --git a/src/antigravity_provider/router/web/static/workflow.js b/src/antigravity_provider/router/web/static/workflow.js
index 334141a..a22c80c 100644
--- a/src/antigravity_provider/router/web/static/workflow.js
+++ b/src/antigravity_provider/router/web/static/workflow.js
@@ -386,8 +386,18 @@ function renderAgentModelTab(content, snapshot, agent) {
};
const refreshModels = () => {
const profile = profiles.find((item) => item.profile_id === accountSelect.value);
- const models = profile?.preferred_models || Object.values(profile?.model_states || {}).map((state) => state.display_name).filter(Boolean);
- modelSelect.innerHTML = models.length ? models.map((model) => ``).join('') : '';
+ // Список моделей даёт провайдер. preferred_models — это настроенный владельцем
+ // список предпочтений профиля, а model_states строится перебором того же
+ // preferred_models (unified_health.py), поэтому обе прежние ветки показывали
+ // один и тот же куцый набор, и владелец видел «модели уже распределены».
+ const summary = (snapshot.providers || []).find((item) => item.provider_id === (profile?.provider || providerSelect.value));
+ const discovered = summary?.discovered_models || [];
+ const models = discovered.length ? [...discovered] : [...(profile?.preferred_models || [])];
+ // Настроенная у агента модель обязана присутствовать в списке. Иначе ни один
+ // option не получал selected, браузер показывал первый вариант, и нажатие
+ // «Изменить конфигурацию» молча записывало агенту не ту модель.
+ if (cfg.model && !models.includes(cfg.model)) models.unshift(cfg.model);
+ modelSelect.innerHTML = models.length ? models.map((model) => ``).join('') : '';
};
providerSelect.onchange = refreshAccounts;
accountSelect.onchange = refreshModels;