/* 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 = '
'; elements.modalFooter.innerHTML = ''; 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]) => `
${label}${value}${reason}
`).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]) => `
${escapeHtml(name)}${new Intl.NumberFormat('ru-RU').format(item[key])}
`).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]) => `
${label}
${value}
${value === 'Н/Д' ? `Н/Д: ${label} не получен от API` : reason}
`).join(''); } function renderLogDetail(event) { const node = document.getElementById('log-detail'); if (!node) return; if (!event) { node.innerHTML = '

Детали события

Н/Д: в снапшоте нет событий workflow.

'; return; } node.innerHTML = `

Детали события

${escapeHtml(event.message)}

Уровень: ${escapeHtml(event.level || 'Н/Д')}

Агент: ${escapeHtml(event.agent_id || 'Н/Д: не указан')}

Время: ${escapeHtml(event.timestamp)}

${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))}
`; } 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 += ``; if (!models.length) html += ``; html += models.map(model => ``).join(''); if (!selected && models.length) html = ''+html; return html; } function routeQuota(profile, snapshot) { const quota = profile.quota_snapshot || snapshot.quotas?.[profile.profile_id] || {}; if (!quota.buckets?.length) return `
${quota.is_loading ? 'Квоты загружаются' : 'Н/Д: '+escapeHtml(quota.unavailable_reason || 'провайдер не передаёт квоту')}
`; return quota.buckets.map(bucket => { const pct = bucket.remaining_percent; if (!Number.isFinite(pct)) return `
${escapeHtml(bucket.label || 'Квота')}: Н/Д
`; return `
${escapeHtml(bucket.label || 'Квота')}: ${pct}%
`; }).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 `
${index ? 'Резерв '+index : 'Основной'}
${escapeHtml(profile.display_name || node.profile_id)}
${escapeHtml(node.account_identity || profile.account_identity || node.provider)}
${routeQuota(profile,snapshot)}
${escapeHtml(reset || 'Н/Д: нет времени сброса')}
${escapeHtml(node.status_label_ru || 'Не проверялся')}${node.is_active ? '
Используется' : ''}
`; }).join(''); return `

${escapeHtml(agent?.name || pipeline.role_name_ru || roleId)}

${escapeHtml(agent?.description || role?.role_description_ru || '')}

ПриоритетАккаунтМодельКвота · остатокСбросСтатус
${rows || '

Аккаунты не назначены. Добавьте подключённый аккаунт.

'}
+ Перетащите аккаунт в конец маршрута

Session Affinity: ${pipeline.session_affinity ? 'включена' : 'отключена'} · Порядок применяется к следующим назначениям

`; }).join('') || '

Агенты ещё не созданы. Добавьте агента на «Обзоре».

'; 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 = ``+[...new Set(profiles.map(p => p[field]))].map(value=>``).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=>`
${escapeHtml(p.display_name || p.profile_id)}

${escapeHtml(p.account_identity || p.provider)}

${escapeHtml(p.health_label_ru || 'Не проверялся')}

${routeQuota(p,snapshot)}Модели: ${providerModels(snapshot,p.provider).received ? providerModels(snapshot,p.provider).models.length : 'список ещё не получен'}
`).join('') || '

Нет подходящих подключённых аккаунтов. Подключите аккаунт на странице «Аккаунты».

'; container.querySelectorAll('[data-route-add]').forEach(button=>button.onclick=()=>{ elements.modalTitle.textContent='Выберите агента'; elements.modalBody.innerHTML='
'+Object.entries(snapshot.routing || {}).map(([id,route])=>``).join('')+'
'; 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 = ''; 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 = ''; 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=`

Подключения провайдеров

${providers.map(provider=>`
${escapeHtml(provider.provider_name)}
${provider.connected_count} подключено · ${provider.online_count} готово
`).join('') || '

Подключений нет

'}

Хранилища и температуры

Загрузка системного диска показана выше.

Н/Д: API не передаёт список томов, температуры и состояние резервного копирования.

Состояние в реальном времени

Активных вызовов
${snapshot.metrics?.active_calls_total ?? 'Н/Д'}
Снапшот
${snapshot.is_stale ? 'Устарел' : 'Актуален'}

Н/Д: API не передаёт uptime отдельных служб и историю сетевой активности.

`; document.getElementById('health-events-open').onclick=()=>switchView('logs'); }