/**
* Hermes Hub — Client Application & Native Chat Engine
*/
document.addEventListener('DOMContentLoaded', () => {
// Elements
const sidebar = document.getElementById('hub-sidebar');
const btnToggleSidebar = document.getElementById('btn-toggle-sidebar');
const convSidebar = document.getElementById('hub-conv-sidebar');
const btnToggleConvSidebar = document.getElementById('btn-toggle-conv-sidebar');
const btnExpandConvSidebar = document.getElementById('btn-expand-conv-sidebar');
const universeBtns = document.querySelectorAll('.hub-universe-btn');
const activeTitle = document.getElementById('active-universe-name');
const activeScope = document.getElementById('active-universe-scope');
const activeTagline = document.getElementById('active-universe-tagline');
const dshModeToggle = document.getElementById('dsh-mode-toggle');
const tabSession = document.getElementById('tab-session');
const tabFiles = document.getElementById('tab-files');
// Containers
const nativeChatLayout = document.getElementById('hub-native-chat');
const iframeWrapper = document.getElementById('hub-iframe-wrapper');
const iframe = document.getElementById('workspace-iframe');
const loader = document.getElementById('hub-loader');
// Native Chat Elements
const btnNewChat = document.getElementById('btn-new-chat');
const convSearch = document.getElementById('hub-conv-search');
const convList = document.getElementById('hub-conv-list');
const chatHeaderTitle = document.getElementById('current-chat-title');
const chatHeaderStatus = document.getElementById('current-chat-status');
const messagesContainer = document.getElementById('hub-messages-container');
const chatForm = document.getElementById('hub-chat-form');
const chatInput = document.getElementById('hub-chat-input');
const btnSend = document.getElementById('btn-send-msg');
const btnStop = document.getElementById('btn-stop-msg');
const emptyStateName = document.getElementById('empty-state-name');
const btnPinActive = document.getElementById('btn-pin-active');
const btnRenameActive = document.getElementById('btn-rename-active');
const btnDeleteActive = document.getElementById('btn-delete-active');
// Modals
const personaModal = document.getElementById('persona-modal');
const cloneModal = document.getElementById('clone-modal');
const btnEditPersona = document.getElementById('btn-edit-persona');
const btnCloneUniverse = document.getElementById('btn-clone-universe');
const btnSavePersona = document.getElementById('btn-save-persona');
const btnSubmitClone = document.getElementById('btn-submit-clone');
// Application State
let currentUniverse = 'tt';
let currentSessionKey = null;
let currentConversations = [];
let isStreaming = false;
let currentAbortController = null;
let currentMode = 'chat'; // 'chat' or 'files'
// Sidebar Collapse Persistence
const isCollapsed = localStorage.getItem('hermes_hub_sidebar_collapsed') === 'true';
if (isCollapsed && sidebar) {
sidebar.classList.add('collapsed');
}
if (btnToggleSidebar && sidebar) {
btnToggleSidebar.addEventListener('click', (e) => {
e.stopPropagation();
sidebar.classList.toggle('collapsed');
localStorage.setItem('hermes_hub_sidebar_collapsed', sidebar.classList.contains('collapsed'));
});
}
// Conversation Sub-Sidebar Collapse Persistence
const isConvCollapsed = localStorage.getItem('hermes_hub_conv_collapsed') === 'true';
if (isConvCollapsed && convSidebar) {
convSidebar.classList.add('collapsed');
if (btnExpandConvSidebar) btnExpandConvSidebar.style.display = 'inline-flex';
}
if (btnToggleConvSidebar && convSidebar) {
btnToggleConvSidebar.addEventListener('click', (e) => {
e.stopPropagation();
convSidebar.classList.add('collapsed');
if (btnExpandConvSidebar) btnExpandConvSidebar.style.display = 'inline-flex';
localStorage.setItem('hermes_hub_conv_collapsed', 'true');
});
}
if (btnExpandConvSidebar && convSidebar) {
btnExpandConvSidebar.addEventListener('click', (e) => {
e.stopPropagation();
convSidebar.classList.remove('collapsed');
btnExpandConvSidebar.style.display = 'none';
localStorage.setItem('hermes_hub_conv_collapsed', 'false');
});
}
// Accent Mapping
const ACCENT_MAP = {
'tt': { accent: 'var(--accent-tt)', glow: 'var(--accent-tt-glow)' },
'nyora': { accent: 'var(--accent-nyora)', glow: 'var(--accent-nyora-glow)' },
'perso': { accent: 'var(--accent-perso)', glow: 'var(--accent-perso-glow)' },
'nabil': { accent: 'var(--accent-nabil)', glow: 'var(--accent-nabil-glow)' },
'dsh': { accent: 'var(--accent-dsh)', glow: 'var(--accent-dsh-glow)' }
};
function updateThemeAccent(universeId) {
const config = ACCENT_MAP[universeId] || ACCENT_MAP['tt'];
document.documentElement.style.setProperty('--hub-accent-current', config.accent);
document.documentElement.style.setProperty('--hub-accent-glow-current', config.glow);
}
// Escape HTML helper
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// Basic Markdown Parser (Code blocks, bold, italic, line breaks)
function renderMarkdown(text) {
if (!text) return '';
let html = escapeHtml(text);
// Code blocks with syntax box
html = html.replace(/```([a-zA-Z0-9_\-\.]*)\n([\s\S]*?)```/g, (match, lang, code) => {
return `
${code}
`;
});
// Inline code
html = html.replace(/`([^`]+)`/g, '$1');
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
// Italic
html = html.replace(/\*([^*]+)\*/g, '$1');
// Line breaks
html = html.replace(/\n/g, '
');
return html;
}
// Switch Universe
function switchUniverse(universeId) {
currentUniverse = universeId;
currentSessionKey = null;
currentMode = 'chat';
// Update active button
universeBtns.forEach(btn => {
const isTarget = btn.getAttribute('data-universe') === universeId;
btn.classList.toggle('active', isTarget);
if (isTarget) {
activeTitle.textContent = btn.getAttribute('data-name');
activeScope.textContent = `Scope: ${btn.getAttribute('data-scope')}`;
activeTagline.textContent = btn.getAttribute('data-tagline');
if (emptyStateName) emptyStateName.textContent = btn.getAttribute('data-name');
}
});
updateThemeAccent(universeId);
// Show/hide DSH Mode Toggle (Exclusif à l'univers DSH)
if (universeId === 'dsh') {
if (dshModeToggle) dshModeToggle.style.display = 'inline-flex';
if (tabSession) tabSession.classList.add('active');
if (tabFiles) tabFiles.classList.remove('active');
showIframeView(`https://dsh-hub.yesminedor.tn/`);
} else {
if (dshModeToggle) dshModeToggle.style.display = 'none';
showNativeChatView();
loadConversations();
}
}
// Iframe Recovery Elements
const iframeExternalBtn = document.getElementById('btn-iframe-external');
const iframeReloadBtn = document.getElementById('btn-iframe-reload');
const iframeTargetLabel = document.getElementById('hub-iframe-target-label');
const iframeAuthNotice = document.getElementById('hub-iframe-auth-notice');
const btnAuthNoticeOpen = document.getElementById('btn-auth-notice-open');
const btnAuthNoticeReload = document.getElementById('btn-auth-notice-reload');
const btnAuthNoticeDismiss = document.getElementById('btn-auth-notice-dismiss');
let currentIframeUrl = '';
let iframeLoadTimeout = null;
function showNativeChatView() {
nativeChatLayout.style.display = 'flex';
iframeWrapper.style.display = 'none';
if (iframeLoadTimeout) clearTimeout(iframeLoadTimeout);
if (iframeAuthNotice) iframeAuthNotice.style.display = 'none';
if (iframe) iframe.src = 'about:blank';
}
function showIframeView(url) {
nativeChatLayout.style.display = 'none';
iframeWrapper.style.display = 'flex';
currentIframeUrl = url;
const isFiles = url.includes('files-hub');
if (iframeTargetLabel) {
iframeTargetLabel.textContent = isFiles
? 'Explorateur de Fichiers (files-hub.yesminedor.tn)'
: 'Dialogue DeepSeek Harness (dsh-hub.yesminedor.tn)';
}
if (iframeExternalBtn) iframeExternalBtn.href = url;
if (btnAuthNoticeOpen) btnAuthNoticeOpen.href = url;
if (iframeAuthNotice) iframeAuthNotice.style.display = 'none';
if (loader) loader.classList.remove('hidden');
if (iframeLoadTimeout) clearTimeout(iframeLoadTimeout);
iframeLoadTimeout = setTimeout(() => {
if (loader) loader.classList.add('hidden');
if (iframeAuthNotice) iframeAuthNotice.style.display = 'block';
}, 4500);
if (iframe) {
iframe.src = url;
iframe.onload = () => {
if (iframeLoadTimeout) {
clearTimeout(iframeLoadTimeout);
iframeLoadTimeout = null;
}
if (loader) loader.classList.add('hidden');
try {
if (iframe.contentDocument && iframe.contentDocument.title.toLowerCase().includes('cloudflare')) {
if (iframeAuthNotice) iframeAuthNotice.style.display = 'block';
}
} catch (e) {
// Cross-origin restriction (expected under Access redirect)
}
};
}
}
if (iframeReloadBtn) {
iframeReloadBtn.addEventListener('click', () => {
if (currentIframeUrl) showIframeView(currentIframeUrl);
});
}
if (btnAuthNoticeReload) {
btnAuthNoticeReload.addEventListener('click', () => {
if (currentIframeUrl) showIframeView(currentIframeUrl);
});
}
if (btnAuthNoticeDismiss) {
btnAuthNoticeDismiss.addEventListener('click', () => {
if (iframeAuthNotice) iframeAuthNotice.style.display = 'none';
});
}
// Load Conversations List
async function loadConversations() {
convList.innerHTML = 'Chargement...
';
try {
const res = await fetch(`/api/chat/${currentUniverse}/conversations`);
const data = await res.json();
currentConversations = data.conversations || [];
renderConversationsList();
if (currentConversations.length > 0 && !currentSessionKey) {
selectConversation(currentConversations[0].session_key);
} else if (!currentSessionKey) {
startNewConversation(false);
}
} catch (err) {
convList.innerHTML = 'Erreur de chargement
';
}
}
function renderConversationsList() {
const filter = (convSearch ? convSearch.value.trim().toLowerCase() : '');
const filtered = currentConversations.filter(c => !filter || (c.title && c.title.toLowerCase().includes(filter)));
if (filtered.length === 0) {
convList.innerHTML = 'Aucune discussion
';
return;
}
const pinned = filtered.filter(c => c.pinned);
const recents = filtered.filter(c => !c.pinned);
let html = '';
if (pinned.length > 0) {
html += '📌 Épinglées
';
pinned.forEach(c => {
html += renderConvItem(c);
});
}
if (recents.length > 0) {
if (pinned.length > 0) html += 'Discussions récentes
';
recents.forEach(c => {
html += renderConvItem(c);
});
}
convList.innerHTML = html;
// Attach click events
convList.querySelectorAll('.hub-conv-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.hub-conv-action-btn')) return;
const key = item.getAttribute('data-session-key');
selectConversation(key);
});
});
// Attach actions
convList.querySelectorAll('.btn-pin-conv').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const key = btn.getAttribute('data-session-key');
const isPinned = btn.getAttribute('data-pinned') === '1';
await togglePin(key, !isPinned);
});
});
convList.querySelectorAll('.btn-rename-conv').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const key = btn.getAttribute('data-session-key');
const currentT = btn.getAttribute('data-title');
const newTitle = prompt('Nouveau titre de la discussion :', currentT);
if (newTitle && newTitle.trim() && newTitle.trim() !== currentT) {
await renameConv(key, newTitle.trim());
}
});
});
convList.querySelectorAll('.btn-delete-conv').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const key = btn.getAttribute('data-session-key');
if (confirm('Supprimer définitivement cette discussion ?')) {
await deleteConv(key);
}
});
});
}
function renderConvItem(c) {
const isActive = c.session_key === currentSessionKey;
const timeStr = c.updated_at ? new Date(c.updated_at).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' }) : '';
return `
${c.pinned ? '📌 ' : ''}${escapeHtml(c.title || 'Discussion')}
${timeStr}
`;
}
// Select Conversation
async function selectConversation(sessionKey) {
currentSessionKey = sessionKey;
renderConversationsList();
const conv = currentConversations.find(c => c.session_key === sessionKey);
if (conv) {
chatHeaderTitle.textContent = conv.title || 'Discussion';
if (btnPinActive) btnPinActive.textContent = conv.pinned ? '📍' : '📌';
}
messagesContainer.innerHTML = 'Chargement des messages...
';
try {
const res = await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}/messages`);
const data = await res.json();
renderMessages(data.messages || []);
} catch (err) {
messagesContainer.innerHTML = 'Erreur de chargement des messages
';
}
}
// Start New Conversation
async function startNewConversation(focus = true) {
try {
const res = await fetch(`/api/chat/${currentUniverse}/conversations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Nouvelle conversation' })
});
const data = await res.json();
if (data.ok && data.conversation) {
currentSessionKey = data.conversation.session_key;
currentConversations.unshift(data.conversation);
renderConversationsList();
chatHeaderTitle.textContent = 'Nouvelle conversation';
renderMessages([]);
if (focus && chatInput) chatInput.focus();
}
} catch (err) {
console.error('Failed to create new conversation:', err);
}
}
// Render Messages
function renderMessages(messages) {
if (!messages || messages.length === 0) {
messagesContainer.innerHTML = `
💬
Discussion avec ${escapeHtml(activeTitle.textContent)}
Posez une question ou donnez une instruction pour démarrer la session.
`;
return;
}
let html = '';
messages.forEach(m => {
const isUser = m.role === 'user';
const avatarText = isUser ? 'VOUS' : currentUniverse.toUpperCase().slice(0, 2);
let reasoningHtml = '';
if (m.reasoning) {
reasoningHtml = `
Raisonnement de l'agent
${escapeHtml(m.reasoning)}
`;
}
html += `
${avatarText}
${reasoningHtml}
${renderMarkdown(m.content)}
`;
});
messagesContainer.innerHTML = html;
scrollToBottom();
}
function scrollToBottom() {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Send Message & Handle SSE Streaming
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isStreaming) return;
if (!currentSessionKey) {
await startNewConversation(false);
}
// Append user message immediately
const userMsgHtml = `
`;
// Remove empty state if present
const emptyState = messagesContainer.querySelector('.hub-empty-state');
if (emptyState) emptyState.remove();
messagesContainer.insertAdjacentHTML('beforeend', userMsgHtml);
chatInput.value = '';
chatInput.style.height = 'auto';
scrollToBottom();
// Prepare assistant message bubble with streaming cursor
const assistantId = `msg-stream-${Date.now()}`;
const assistantMsgHtml = `
${currentUniverse.toUpperCase().slice(0, 2)}
`;
messagesContainer.insertAdjacentHTML('beforeend', assistantMsgHtml);
scrollToBottom();
const streamBubble = document.getElementById(assistantId);
const streamTextEl = streamBubble.querySelector('.stream-text');
const cursorEl = streamBubble.querySelector('.hub-streaming-cursor');
// UI Streaming state
setStreamingState(true);
chatHeaderStatus.textContent = 'En train de répondre...';
currentAbortController = new AbortController();
try {
const response = await fetch(`/api/chat/${currentUniverse}/conversations/${currentSessionKey}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
signal: currentAbortController.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let accumulatedText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep last partial line
for (const line of lines) {
if (line.startsWith('data: ')) {
const rawData = line.slice(6).trim();
if (!rawData) continue;
try {
const data = JSON.parse(rawData);
if (data.fullReplace && data.text !== undefined) {
// Workspace chunk with fullReplace: true
accumulatedText = data.text;
streamTextEl.innerHTML = renderMarkdown(accumulatedText);
} else if (data.text) {
accumulatedText = data.text;
streamTextEl.innerHTML = renderMarkdown(accumulatedText);
} else if (data.chunk) {
accumulatedText += data.chunk;
streamTextEl.innerHTML = renderMarkdown(accumulatedText);
}
scrollToBottom();
} catch (e) {}
}
}
}
// Final rendering
if (cursorEl) cursorEl.remove();
streamTextEl.innerHTML = renderMarkdown(accumulatedText || 'Message reçu.');
chatHeaderStatus.textContent = 'Prêt';
// Refresh conversations list to update title / timestamp
loadConversations();
} catch (err) {
if (err.name === 'AbortError') {
if (cursorEl) cursorEl.remove();
streamTextEl.innerHTML += '
[Réponse interrompue]';
} else {
if (cursorEl) cursorEl.remove();
streamTextEl.innerHTML = `Erreur lors de la réponse (${err.message}). Vérifiez l'instance.`;
}
chatHeaderStatus.textContent = 'Prêt';
} finally {
setStreamingState(false);
currentAbortController = null;
scrollToBottom();
}
}
function setStreamingState(streaming) {
isStreaming = streaming;
btnSend.style.display = streaming ? 'none' : 'flex';
btnStop.style.display = streaming ? 'flex' : 'none';
}
function stopStreaming() {
if (currentAbortController) {
currentAbortController.abort();
}
}
// Conversation Actions (Pin, Rename, Delete)
async function togglePin(sessionKey, pinned) {
try {
await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned: pinned })
});
loadConversations();
} catch (err) {
console.error('Failed to toggle pin:', err);
}
}
async function renameConv(sessionKey, title) {
try {
await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title })
});
if (sessionKey === currentSessionKey) {
chatHeaderTitle.textContent = title;
}
loadConversations();
} catch (err) {
console.error('Failed to rename conversation:', err);
}
}
async function deleteConv(sessionKey) {
try {
await fetch(`/api/chat/${currentUniverse}/conversations/${sessionKey}`, {
method: 'DELETE'
});
if (sessionKey === currentSessionKey) {
currentSessionKey = null;
}
loadConversations();
} catch (err) {
console.error('Failed to delete conversation:', err);
}
}
// Event Listeners
if (btnNewChat) {
btnNewChat.addEventListener('click', () => startNewConversation(true));
}
if (convSearch) {
convSearch.addEventListener('input', () => renderConversationsList());
}
if (chatForm) {
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
sendMessage();
});
}
if (chatInput) {
// Auto-resize textarea
chatInput.addEventListener('input', () => {
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 160) + 'px';
});
// Enter to submit (Shift+Enter for newline)
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
}
if (btnStop) {
btnStop.addEventListener('click', () => stopStreaming());
}
if (btnPinActive) {
btnPinActive.addEventListener('click', () => {
if (!currentSessionKey) return;
const conv = currentConversations.find(c => c.session_key === currentSessionKey);
if (conv) togglePin(currentSessionKey, !conv.pinned);
});
}
if (btnRenameActive) {
btnRenameActive.addEventListener('click', () => {
if (!currentSessionKey) return;
const conv = currentConversations.find(c => c.session_key === currentSessionKey);
const currentT = conv ? conv.title : 'Discussion';
const newTitle = prompt('Nouveau titre de la discussion :', currentT);
if (newTitle && newTitle.trim()) renameConv(currentSessionKey, newTitle.trim());
});
}
if (btnDeleteActive) {
btnDeleteActive.addEventListener('click', () => {
if (!currentSessionKey) return;
if (confirm('Supprimer définitivement cette discussion ?')) {
deleteConv(currentSessionKey);
}
});
}
// DSH Mode Switcher (Dialogue DSH vs Explorateur de Fichiers)
if (tabSession) {
tabSession.addEventListener('click', () => {
tabSession.classList.add('active');
tabFiles.classList.remove('active');
showIframeView(`https://dsh-hub.yesminedor.tn/`);
});
}
if (tabFiles) {
tabFiles.addEventListener('click', () => {
tabFiles.classList.add('active');
tabSession.classList.remove('active');
showIframeView(`https://files-hub.yesminedor.tn/`);
});
}
// Universe Switcher Buttons
universeBtns.forEach(btn => {
btn.addEventListener('click', () => {
const uId = btn.getAttribute('data-universe');
switchUniverse(uId);
});
});
// Persona Modal Handlers
if (btnEditPersona && personaModal) {
btnEditPersona.addEventListener('click', async () => {
try {
const res = await fetch(`/api/personas/${currentUniverse}`);
const data = await res.json();
document.getElementById('persona-name').value = data.name || '';
document.getElementById('persona-tagline').value = data.tagline || '';
document.getElementById('persona-tone').value = data.tone || '';
document.getElementById('persona-style').value = data.style || '';
document.getElementById('persona-principles').value = (data.principles || []).join('\n');
document.getElementById('persona-prompt').value = data.system_prompt || '';
personaModal.classList.add('active');
} catch (err) {
alert('Impossible de charger le profil de cet univers.');
}
});
}
if (btnSavePersona && personaModal) {
btnSavePersona.addEventListener('click', async () => {
const payload = {
name: document.getElementById('persona-name').value.trim(),
tagline: document.getElementById('persona-tagline').value.trim(),
tone: document.getElementById('persona-tone').value.trim(),
style: document.getElementById('persona-style').value.trim(),
principles: document.getElementById('persona-principles').value.split('\n').map(l => l.trim()).filter(Boolean),
system_prompt: document.getElementById('persona-prompt').value.trim()
};
try {
const res = await fetch(`/api/personas/${currentUniverse}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
personaModal.classList.remove('active');
alert('Personnalité mise à jour avec succès.');
}
} catch (err) {
alert('Erreur lors de la sauvegarde.');
}
});
}
// Clone Modal Handlers
if (btnCloneUniverse && cloneModal) {
btnCloneUniverse.addEventListener('click', () => {
document.getElementById('clone-source-name').textContent = activeTitle.textContent;
document.getElementById('clone-name').value = '';
cloneModal.classList.add('active');
});
}
if (btnSubmitClone && cloneModal) {
btnSubmitClone.addEventListener('click', async () => {
const cloneName = document.getElementById('clone-name').value.trim();
if (!cloneName) return;
try {
const res = await fetch(`/api/clones`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_universe_id: currentUniverse,
clone_name: cloneName
})
});
if (res.ok) {
cloneModal.classList.remove('active');
alert('Clone créé avec succès.');
}
} catch (err) {
alert('Erreur lors du clonage.');
}
});
}
// Close modals
document.querySelectorAll('.hub-modal-close').forEach(btn => {
btn.addEventListener('click', () => {
if (personaModal) personaModal.classList.remove('active');
if (cloneModal) cloneModal.classList.remove('active');
});
});
// Universe Health Monitoring Probe
async function probeUniverseStatuses() {
try {
const res = await fetch('/api/universes');
if (!res.ok) return;
const data = await res.json();
data.forEach(u => {
const dot = document.getElementById(`status-dot-${u.id}`);
if (dot) {
dot.className = `hub-status-dot ${u.status}`;
dot.title = `Statut: ${u.status} (${u.status_code || 'N/A'})`;
}
});
} catch (e) {}
}
// Initialize
probeUniverseStatuses();
setInterval(probeUniverseStatuses, 15000);
switchUniverse('tt');
});