hermes-hub/src/antigravity_provider/router/web/static/workspace.js

223 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Shared workspace presentation. All measurements come from HubSnapshot. */
'use strict';
document.addEventListener('DOMContentLoaded', initWorkspaceChrome);
function initWorkspaceChrome() {
document.getElementById('hub-search-open').onclick = openWorkspaceSearch;
document.getElementById('hub-settings').onclick = () => switchView('settings');
document.getElementById('hub-notifications').onclick = () => switchView('logs');
document.getElementById('analytics-refresh').onclick = fetchSnapshot;
document.addEventListener('keydown', event => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
openWorkspaceSearch();
}
});
document.querySelectorAll('input,select,textarea').forEach(input => {
if (!input.labels?.length && !input.hasAttribute('aria-label')) {
const label = input.closest('.setting-row')?.querySelector('.setting-label')?.textContent;
input.setAttribute('aria-label', label || input.placeholder || input.id);
}
});
}
function openWorkspaceSearch() {
elements.modalTitle.textContent = 'Поиск по хабу';
elements.modalBody.innerHTML = '<input id="workspace-search" class="input-text" aria-label="Поиск экранов и агентов" placeholder="Экран, агент, аккаунт…"><div class="command-results" id="workspace-results"></div>';
elements.modalFooter.innerHTML = '<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>';
showModal();
const input = document.getElementById('workspace-search');
const render = () => {
const entries = [...document.querySelectorAll('.nav-item')].map(button => ({
label: button.querySelector('.nav-label').textContent, action: () => switchView(button.dataset.view),
}));
for (const agent of currentSnapshot?.workflow?.agents || []) entries.push({
label: `Агент · ${agent.name}`, action: () => { switchView('overview'); selectWorkflowAgent(agent.id); fitWorkflowGraph(); },
});
for (const profile of Object.values(currentSnapshot?.all_profiles || {})) entries.push({
label: `Аккаунт · ${profile.display_name || profile.profile_id}`, action: () => { switchView('accounts'); openAccountDetailsModal(profile.profile_id); },
});
const target = document.getElementById('workspace-results');
target.replaceChildren();
for (const entry of entries.filter(item => item.label.toLowerCase().includes(input.value.toLowerCase())).slice(0,15)) {
const button = document.createElement('button');
button.className = 'btn btn-secondary';
button.textContent = entry.label;
button.onclick = () => { closeModal(); entry.action(); };
target.append(button);
}
if (!target.children.length) target.textContent = 'Совпадений нет';
};
input.oninput = render;
render();
input.focus();
}
function renderAccountSummary(snapshot) {
const profiles = Object.values(snapshot.all_profiles || {}).filter(isConnectedProfile);
const items = [
['Всего аккаунтов', profiles.length, 'Подключённые профили'],
['Работает', profiles.filter(p => p.health_state === 'healthy').length, 'Проверенное состояние'],
['Лимит исчерпан', profiles.filter(p => p.health_state === 'quota_exhausted').length, 'По состоянию аккаунта'],
['Отключено', profiles.filter(p => !p.enabled).length, 'Сохранены в системе'],
['Стоимость', 'Н/Д', 'API не передаёт измеренную стоимость'],
];
document.getElementById('account-summary').innerHTML = items.map(([label,value,reason]) => `<div><span>${label}</span><strong>${value}</strong><small>${reason}</small></div>`).join('');
}
function renderAnalyticsCharts(telemetry) {
renderMetricBars('analytics-token-chart', telemetry.by_provider, 'total_tokens', 'Н/Д: провайдеры ещё не вернули usage');
renderMetricBars('analytics-role-chart', telemetry.by_role, 'total_calls', 'Н/Д: вызовы по ролям ещё не измерены');
}
function renderMetricBars(id, series, key, reason) {
const entries = Object.entries(series || {}).filter(([,item]) => Number.isFinite(item[key]));
const total = entries.reduce((sum,[,item]) => sum + item[key],0);
const node = document.getElementById(id);
if (!entries.length || total === 0) { node.textContent = reason; return; }
node.innerHTML = entries.map(([name,item]) => `<div class="metric-bar"><span>${escapeHtml(name)}</span><meter min="0" max="${total}" value="${item[key]}" aria-label="${escapeHtml(name)}"></meter><strong>${new Intl.NumberFormat('ru-RU').format(item[key])}</strong></div>`).join('');
}
function renderHostResources(container, host) {
const measured = (value,unit) => Number.isFinite(value) ? `${value}${unit}` : 'Н/Д';
const items = [
['CPU', measured(host.cpu_percent,'%'), 'Загрузка процессора'],
['Память', measured(host.memory_percent,'%'), Number.isFinite(host.memory_used_mb) ? `${Math.round(host.memory_used_mb)} / ${Math.round(host.memory_total_mb)} МБ` : 'Н/Д: RAM не измерена'],
['GPU', 'Н/Д', 'API не передаёт GPU'],
['Диск', measured(host.disk_percent,'%'), Number.isFinite(host.disk_used_gb) ? `${host.disk_used_gb} / ${host.disk_total_gb} ГБ` : 'Н/Д: диск не измерен'],
['Сеть', measured(host.net_speed_mbps,' Мбит/с'), Number.isFinite(host.net_speed_mbps) ? 'Скорость между замерами счётчиков' : 'Н/Д: требуется второй замер'],
];
container.innerHTML = items.map(([label,value,reason]) => `<div class="resource-card"><div class="resource-label">${label}</div><div class="resource-value">${value}</div><div class="resource-sub">${value === 'Н/Д' ? `Н/Д: ${label} не получен от API` : reason}</div></div>`).join('');
}
function renderLogDetail(event) {
const node = document.getElementById('log-detail');
if (!node) return;
if (!event) { node.innerHTML = '<h2>Детали события</h2><p>Н/Д: в снапшоте нет событий workflow.</p>'; return; }
node.innerHTML = `<h2>Детали события</h2><p>${escapeHtml(event.message)}</p><p>Уровень: ${escapeHtml(event.level || 'Н/Д')}</p><p>Агент: ${escapeHtml(event.agent_id || 'Н/Д: не указан')}</p><p>Время: ${escapeHtml(event.timestamp)}</p><pre>${escapeHtml(JSON.stringify({type:event.type,run_id:event.run_id,iteration:event.iteration,provider:event.provider,account:event.account,model:event.model,duration_seconds:event.duration_seconds,error:event.error},null,2))}</pre>`;
}
function providerModels(snapshot, provider) {
const summary = (snapshot.providers || []).find(item => item.provider_id === provider);
return { models: summary?.discovered_models || [], received: Boolean(summary?.last_refresh_at) };
}
function modelOptions(snapshot, profile, selected) {
const { models, received } = providerModels(snapshot, profile.provider);
let html = '';
if (selected && !models.includes(selected)) html += `<option value="${escapeHtml(selected)}" selected disabled>${escapeHtml(selected)} · доступность не подтверждена</option>`;
if (!models.length) html += `<option value="" disabled ${selected ? '' : 'selected'}>${received ? 'Доступных моделей нет' : 'Список моделей ещё не получен'}</option>`;
html += models.map(model => `<option value="${escapeHtml(model)}" ${model === selected ? 'selected' : ''}>${escapeHtml(model)}</option>`).join('');
if (!selected && models.length) html = '<option value="" selected disabled>Выберите модель</option>'+html;
return html;
}
function routeQuota(profile, snapshot) {
const quota = profile.quota_snapshot || snapshot.quotas?.[profile.profile_id] || {};
if (!quota.buckets?.length) return `<div class="route-quota">${quota.is_loading ? 'Квоты загружаются' : 'Н/Д: '+escapeHtml(quota.unavailable_reason || 'провайдер не передаёт квоту')}</div>`;
return quota.buckets.map(bucket => {
const pct = bucket.remaining_percent;
if (!Number.isFinite(pct)) return `<div class="route-quota">${escapeHtml(bucket.label || 'Квота')}: Н/Д</div>`;
return `<div class="route-quota">${escapeHtml(bucket.label || 'Квота')}: ${pct}%<div class="quota-bar-track"><div class="quota-bar-fill" style="width:${Math.max(0,Math.min(100,pct))}%;background:var(--status-${pct < 20 ? 'error' : pct < 50 ? 'warning' : 'healthy'})"></div></div></div>`;
}).join('');
}
function renderAccountRouting() {
if (!currentSnapshot) return;
const snapshot = currentSnapshot;
const profiles = snapshot.all_profiles || {};
const routing = snapshot.routing || {};
const left = document.getElementById('routing-roles-container');
left.innerHTML = Object.entries(routing).map(([roleId,pipeline]) => {
const agent = (snapshot.workflow?.agents || []).find(item => item.role === roleId);
const role = (snapshot.agents || []).find(item => item.role_id === roleId);
const rows = (pipeline.nodes || []).map((node,index) => {
const profile = profiles[node.profile_id] || {profile_id:node.profile_id,provider:node.provider};
const quota = profile.quota_snapshot || snapshot.quotas?.[node.profile_id] || {};
const reset = (quota.buckets || []).map(b => b.reset_time_formatted || b.reset_after_formatted).filter(Boolean).join('; ');
return `<div class="account-row draggable-item" draggable="true" data-pid="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}"><div class="drag-handle">⠿</div><div class="route-priority">${index ? 'Резерв '+index : 'Основной'}</div><div><strong>${escapeHtml(profile.display_name || node.profile_id)}</strong><div class="text-muted">${escapeHtml(node.account_identity || profile.account_identity || node.provider)}</div></div><div class="route-model-with-logo"><img class="brand-logo" src="/static/${getModelIcon(node.model)}" alt=""><select data-route-model="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Модель ${escapeHtml(node.profile_id)}">${modelOptions(snapshot,profile,node.model)}</select></div><div>${routeQuota(profile,snapshot)}</div><div class="text-muted">${escapeHtml(reset || 'Н/Д: нет времени сброса')}</div><div><span class="status-dot ${healthDotClass(node.status)}"></span> ${escapeHtml(node.status_label_ru || 'Не проверялся')}${node.is_active ? '<br>Используется' : ''}</div><button class="route-remove" data-remove-profile="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Удалить из маршрута">×</button></div>`;
}).join('');
return `<section class="role-section"><header class="role-header"><div><h4>${escapeHtml(agent?.name || pipeline.role_name_ru || roleId)}</h4><p class="text-muted">${escapeHtml(agent?.description || role?.role_description_ru || '')}</p></div><button class="btn btn-secondary btn-sm" data-add-role="${escapeHtml(roleId)}">+ Добавить аккаунт</button></header><div class="grid-header"><span></span><span>Приоритет</span><span>Аккаунт</span><span>Модель</span><span>Квота · остаток</span><span>Сброс</span><span>Статус</span><span></span></div><div class="role-chain-list">${rows || '<p class="inspector-value">Аккаунты не назначены. Добавьте подключённый аккаунт.</p>'}</div><div class="drop-zone" data-role="${escapeHtml(roleId)}">+ Перетащите аккаунт в конец маршрута</div><p class="inspector-value">Session Affinity: ${pipeline.session_affinity ? 'включена' : 'отключена'} · Порядок применяется к следующим назначениям</p></section>`;
}).join('') || '<p class="view-header-note">Агенты ещё не созданы. Добавьте агента на «Обзоре».</p>';
left.querySelectorAll('[data-add-role]').forEach(button => button.onclick = () => openAddNodeToChainModal(button.dataset.addRole));
left.querySelectorAll('[data-remove-profile]').forEach(button => button.onclick = () => removeProfileFromChain(button.dataset.role,button.dataset.removeProfile));
left.querySelectorAll('[data-route-model]').forEach(select => select.onchange = () => executeAction('set_model',{profile_id:select.dataset.routeModel,model:select.value}));
renderAvailableRoutingAccounts(snapshot);
setupDragAndDrop();
}
function renderAvailableRoutingAccounts(snapshot) {
const profiles = Object.values(snapshot.all_profiles || {}).filter(isConnectedProfile);
const provider = document.getElementById('routing-filter-provider');
const status = document.getElementById('routing-filter-status');
for (const [select,field,title] of [[provider,'provider','Все провайдеры'],[status,'health_state','Все состояния']]) {
const selected = select.value;
select.innerHTML = `<option value="all">${title}</option>`+[...new Set(profiles.map(p => p[field]))].map(value=>`<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join('');
select.value = [...select.options].some(option => option.value === selected) ? selected : 'all';
select.onchange = renderAccountRouting;
}
const search = document.getElementById('routing-account-search');
search.oninput = renderAccountRouting;
const query = search.value.toLowerCase();
const filtered = profiles.filter(p => (provider.value === 'all' || p.provider === provider.value) && (status.value === 'all' || p.health_state === status.value) && [p.profile_id,p.display_name,p.account_identity,p.provider,...providerModels(snapshot,p.provider).models].join(' ').toLowerCase().includes(query));
document.getElementById('available-accounts-count').textContent = `${filtered.length} из ${profiles.length}`;
const container = document.getElementById('routing-available-container');
container.innerHTML = filtered.map(p=>`<article class="available-account-card draggable-item" draggable="true" data-pid="${escapeHtml(p.profile_id)}" data-source="available"><img src="/static/${getProviderIcon(p.provider)}" width="28" height="28" alt=""><div><strong>${escapeHtml(p.display_name || p.profile_id)}</strong><p class="text-muted">${escapeHtml(p.account_identity || p.provider)}</p><p>${escapeHtml(p.health_label_ru || 'Не проверялся')}</p>${routeQuota(p,snapshot)}<small>Модели: ${providerModels(snapshot,p.provider).received ? providerModels(snapshot,p.provider).models.length : 'список ещё не получен'}</small></div><button class="btn btn-secondary btn-sm" data-route-add="${escapeHtml(p.profile_id)}" aria-label="Добавить аккаунт в маршрут">+</button></article>`).join('') || '<p class="inspector-value">Нет подходящих подключённых аккаунтов. Подключите аккаунт на странице «Аккаунты».</p>';
container.querySelectorAll('[data-route-add]').forEach(button=>button.onclick=()=>{
elements.modalTitle.textContent='Выберите агента';
elements.modalBody.innerHTML='<div class="command-results">'+Object.entries(snapshot.routing || {}).map(([id,route])=>`<button class="btn btn-secondary" data-target-role="${escapeHtml(id)}">${escapeHtml(route.role_name_ru || id)}</button>`).join('')+'</div>';
elements.modalFooter.innerHTML=''; showModal();
elements.modalBody.querySelectorAll('[data-target-role]').forEach(target=>target.onclick=()=>{closeModal();handleDropMove(button.dataset.routeAdd,null,target.dataset.targetRole,-1);});
});
}
function initSnapshotSettings() {
// The snapshot intentionally has no server settings. Never present defaults
// as fetched values or write untouched defaults back to the server.
for (const id of ['setting-server-host','setting-server-port','setting-server-token-input','setting-quota-interval']) {
const input = document.getElementById(id);
input.disabled = true;
input.title = 'Н/Д: настройки сервера не входят в /api/snapshot';
if (input.tagName === 'SELECT') input.innerHTML = '<option>Н/Д: нет в снапшоте</option>';
else input.placeholder = 'Н/Д: нет в снапшоте';
}
document.getElementById('setting-token-status-badge').textContent = 'Н/Д: не передаётся';
for (const id of ['setting-quota-threshold-percent','setting-quota-threshold-action','setting-email-masking-mode']) {
const input = document.getElementById(id);
const unknown = document.createElement('option');
unknown.value = ''; unknown.textContent = 'Н/Д: текущее значение не передано'; unknown.selected = true; unknown.disabled = true;
input.prepend(unknown);
}
document.getElementById('setting-source-mode').innerHTML = '<option>Live API (/api/snapshot)</option>';
document.getElementById('setting-source-mode').disabled = true;
document.getElementById('setting-source-mode').closest('.setting-row').querySelector('.setting-desc').textContent = 'Рабочие данные поступают только из снапшота. Автоматической подмены демонстрационными данными нет.';
}
document.addEventListener('DOMContentLoaded', initSnapshotSettings);
function arrangeSettingsPanels() {
const view = document.getElementById('view-settings');
const first = view.querySelector('.settings-card');
const groups = [
['Общие настройки',['setting-theme']],
['Управление квотами',['setting-account-check-interval','setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
];
for (const [title,ids] of groups) {
const card = document.createElement('section'); card.className='settings-card';
const heading=document.createElement('h2'); heading.className='settings-group-title'; heading.textContent=title; card.append(heading);
for (const id of ids) card.append(document.getElementById(id).closest('.setting-row'));
if (title === 'Управление квотами') card.append(document.getElementById('btn-save-hub-settings').closest('.settings-actions'));
view.insertBefore(card, first);
}
first.remove();
}
document.addEventListener('DOMContentLoaded', arrangeSettingsPanels);
function renderHealthPanels(snapshot) {
const box=document.getElementById('health-workspace');
if (!box) return;
const providers=snapshot.providers || [];
box.innerHTML=`<section class="section-card"><h2 class="section-card-title">Подключения провайдеров</h2>${providers.map(provider=>`<dl><dt>${escapeHtml(provider.provider_name)}</dt><dd>${provider.connected_count} подключено · ${provider.online_count} готово</dd></dl>`).join('') || '<p>Подключений нет</p>'}</section><section class="section-card"><h2 class="section-card-title">Хранилища и температуры</h2><p>Загрузка системного диска показана выше.</p><p>Н/Д: API не передаёт список томов, температуры и состояние резервного копирования.</p></section><section class="section-card"><h2 class="section-card-title">Состояние в реальном времени</h2><dl><dt>Активных вызовов</dt><dd>${snapshot.metrics?.active_calls_total ?? 'Н/Д'}</dd><dt>Снапшот</dt><dd>${snapshot.is_stale ? 'Устарел' : 'Актуален'}</dd></dl><p>Н/Д: API не передаёт uptime отдельных служб и историю сетевой активности.</p><button class="btn btn-secondary" id="health-events-open">Журнал событий →</button></section>`;
document.getElementById('health-events-open').onclick=()=>switchView('logs');
}