/* 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 += `${escapeHtml(selected)} · доступность не подтверждена `;
if (!models.length) html += `${received ? 'Доступных моделей нет' : 'Список моделей ещё не получен'} `;
html += models.map(model => `${escapeHtml(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');
const defRoleSelect = document.getElementById('routing-default-role-select');
if (defRoleSelect) {
const currentDef = snapshot.metrics?.default_role || 'manager';
defRoleSelect.value = currentDef;
defRoleSelect.onchange = async () => {
const res = await executeAction('set_default_role', { default_role: defRoleSelect.value });
if (res && res.ok) {
showToast(`Роль по умолчанию изменена на '${defRoleSelect.value}'`, 'success');
await fetchSnapshot();
}
};
}
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);
let responderStatusHtml = '';
if (!pipeline.will_bypass && pipeline.effective_answering_profile) {
const respProfile = profiles[pipeline.effective_answering_profile] || { profile_id: pipeline.effective_answering_profile };
const respModel = pipeline.effective_answering_model || 'default';
const nodeIdx = (pipeline.nodes || []).findIndex(n => n.profile_id === pipeline.effective_answering_profile);
const prioLabel = nodeIdx === 0 ? 'Основной (#1)' : `Запасной (#${nodeIdx + 1})`;
responderStatusHtml = `🟢 Сейчас ответит: ${escapeHtml(respProfile.display_name || pipeline.effective_answering_profile)} (${escapeHtml(respModel)}) — ${prioLabel}
`;
} else {
const reason = pipeline.bypass_reason || 'цепочка пуста — вызов уйдёт мимо хаба в Hermes';
responderStatusHtml = `⚠️ Сейчас ответит: никто (${escapeHtml(reason)})
`;
}
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)}
${modelOptions(snapshot,profile,node.model)} ${routeQuota(profile,snapshot)}
${escapeHtml(reset || 'Н/Д: нет времени сброса')}
${escapeHtml(node.status_label_ru || 'Не проверялся')}${node.is_active ? ' Используется' : ''}
× `;
}).join('');
return `${responderStatusHtml}${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 = `${title} `+[...new Set(profiles.map(p => p[field]))].map(value=>`${escapeHtml(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])=>`${escapeHtml(route.role_name_ru || id)} `).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 = 'Live API (/api/snapshot) ';
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-default-role','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');
}