diff --git a/.gitignore b/.gitignore index aafcb34..4392d8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ .DS_Store *.log +.superpowers/ +docs/superpowers/ diff --git a/src/data.js b/src/data.js index 28d8a5d..c50eaef 100644 --- a/src/data.js +++ b/src/data.js @@ -21,14 +21,14 @@ function scanOpenCodeSessions() { try { // Use sqlite3 CLI to avoid Node version dependency const rows = execSync( - `sqlite3 "${OPENCODE_DB}" "SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, COUNT(m.id) as msg_count FROM session s LEFT JOIN message m ON m.session_id = s.id GROUP BY s.id ORDER BY s.time_updated DESC"`, + `sqlite3 -separator $'\\t' "${OPENCODE_DB}" "SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, COUNT(m.id) as msg_count FROM session s LEFT JOIN message m ON m.session_id = s.id GROUP BY s.id ORDER BY s.time_updated DESC"`, { encoding: 'utf8', timeout: 5000 } ).trim(); if (!rows) return sessions; for (const row of rows.split('\n')) { - const parts = row.split('|'); + const parts = row.split('\t'); if (parts.length < 6) continue; const [id, title, directory, timeCreated, timeUpdated, msgCount] = parts; @@ -128,14 +128,14 @@ function scanKiroSessions() { try { const rows = execSync( - `sqlite3 "${KIRO_DB}" "SELECT key, conversation_id, created_at, updated_at, substr(value, 1, 500) FROM conversations_v2 ORDER BY updated_at DESC"`, + `sqlite3 -separator $'\\t' "${KIRO_DB}" "SELECT key, conversation_id, created_at, updated_at, substr(value, 1, 500) FROM conversations_v2 ORDER BY updated_at DESC"`, { encoding: 'utf8', timeout: 5000 } ).trim(); if (!rows) return sessions; for (const row of rows.split('\n')) { - const parts = row.split('|'); + const parts = row.split('\t'); if (parts.length < 5) continue; const [directory, convId, createdAt, updatedAt, valuePeek] = parts; @@ -262,6 +262,26 @@ function scanCodexSessions() { } } catch {} + // Extract MCP/Skill from Codex session file + const mcpSet = new Set(); + const skillSet = new Set(); + try { + const sLines = fs.readFileSync(f, 'utf8').split('\n').filter(Boolean); + for (const sl of sLines) { + try { + const entry = JSON.parse(sl); + if (entry.type === 'response_item' && entry.payload) { + const pType = entry.payload.type; + const pName = entry.payload.name || ''; + if (pType === 'function_call' && pName.startsWith('mcp__')) { + const parts = pName.split('__'); + if (parts.length >= 3) mcpSet.add(parts[1]); + } + } + } catch {} + } + } catch {} + const existing = sessions.find(s => s.id === sid); if (existing) { existing.has_detail = true; @@ -270,6 +290,8 @@ function scanCodexSessions() { existing.project = cwd; existing.project_short = cwd.replace(os.homedir(), '~'); } + if (mcpSet.size > 0) existing.mcp_servers = Array.from(mcpSet); + if (skillSet.size > 0) existing.skills = Array.from(skillSet); } else { sessions.push({ id: sid, @@ -283,6 +305,8 @@ function scanCodexSessions() { has_detail: true, file_size: stat.size, detail_messages: 0, + mcp_servers: Array.from(mcpSet), + skills: Array.from(skillSet), }); } } @@ -294,7 +318,14 @@ function scanCodexSessions() { // ── Public API ───────────────────────────────────────────── -function loadSessions() { +let sessionsCache = null; +let sessionsCacheAt = 0; +const SESSIONS_TTL = 30000; // 30 seconds + +function loadSessions(forceRefresh) { + if (!forceRefresh && sessionsCache && (Date.now() - sessionsCacheAt) < SESSIONS_TTL) { + return sessionsCache; + } const sessions = {}; // Load Claude Code sessions @@ -367,22 +398,50 @@ function loadSessions() { s.file_size = fs.statSync(sessionFile).size; try { let msgCount = 0; + const mcpSet = new Set(); + const skillSet = new Set(); const sLines = fs.readFileSync(sessionFile, 'utf8').split('\n').filter(Boolean); for (const sl of sLines) { try { const entry = JSON.parse(sl); if (entry.type === 'user' || entry.type === 'assistant') msgCount++; + if (entry.type === 'assistant') { + const content = (entry.message || {}).content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type !== 'tool_use') continue; + const name = block.name || ''; + if (name.startsWith('mcp__')) { + const parts = name.split('__'); + if (parts.length >= 3) mcpSet.add(parts[1]); + } else if (name === 'Skill') { + const skill = (block.input || {}).skill; + if (skill) skillSet.add(skill.includes(':') ? skill.split(':')[0] : skill); + } + } + } + } } catch {} } s.detail_messages = msgCount; - } catch { s.detail_messages = 0; } + s.mcp_servers = Array.from(mcpSet); + s.skills = Array.from(skillSet); + } catch { s.detail_messages = 0; s.mcp_servers = []; s.skills = []; } } else { s.has_detail = false; s.file_size = 0; s.detail_messages = 0; + s.mcp_servers = []; + s.skills = []; } } + // Ensure all sessions have mcp_servers/skills (non-Claude sessions skip enrichment loop) + for (const s of Object.values(sessions)) { + if (!s.mcp_servers) s.mcp_servers = []; + if (!s.skills) s.skills = []; + } + const result = Object.values(sessions).sort((a, b) => b.last_ts - a.last_ts); for (const s of result) { @@ -392,6 +451,8 @@ function loadSessions() { s.date = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0'); } + sessionsCache = result; + sessionsCacheAt = Date.now(); return result; } @@ -420,11 +481,20 @@ function loadSessionDetail(sessionId, project) { if (entry.type === 'user' || entry.type === 'assistant') { const content = extractContent((entry.message || {}).content); if (content) { - messages.push({ role: entry.type, content: content.slice(0, 2000), uuid: entry.uuid || '' }); + const msg = { role: entry.type, content: content.slice(0, 2000), uuid: entry.uuid || '' }; + if (entry.type === 'assistant') { + const rawContent = (entry.message || {}).content; + if (Array.isArray(rawContent)) { + const tools = extractTools(rawContent); + if (tools.length > 0) msg.tools = tools; + } + } + messages.push(msg); } } } else { if (entry.type === 'response_item' && entry.payload) { + const pType = entry.payload.type; const role = entry.payload.role; if (role === 'user' || role === 'assistant') { const content = extractContent(entry.payload.content); @@ -432,11 +502,36 @@ function loadSessionDetail(sessionId, project) { messages.push({ role: role, content: content.slice(0, 2000), uuid: '' }); } } + // Collect Codex function_call tools for the last assistant message + if (pType === 'function_call') { + const pName = entry.payload.name || ''; + const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null; + if (lastMsg && lastMsg.role === 'assistant') { + if (!lastMsg.tools) lastMsg.tools = []; + if (pName.startsWith('mcp__')) { + const parts = pName.split('__'); + if (parts.length >= 3) { + const key = 'mcp:' + parts[1] + ':' + parts.slice(2).join('__'); + if (!lastMsg._toolSeen) lastMsg._toolSeen = new Set(); + if (!lastMsg._toolSeen.has(key)) { + lastMsg._toolSeen.add(key); + lastMsg.tools.push({ type: 'mcp', server: parts[1], tool: parts.slice(2).join('__') }); + } + } + } + } + } } } } catch {} } + // Clean up internal dedup markers + for (const m of messages) { + if (m._toolSeen) delete m._toolSeen; + if (m.tools && m.tools.length === 0) delete m.tools; + } + return { messages: messages.slice(0, 200) }; } @@ -617,6 +712,32 @@ function isSystemMessage(text) { return false; } +function extractTools(contentBlocks) { + const tools = []; + const seen = new Set(); + for (const block of contentBlocks) { + if (block.type !== 'tool_use') continue; + const name = block.name || ''; + if (name.startsWith('mcp__')) { + const parts = name.split('__'); + if (parts.length >= 3) { + const key = 'mcp:' + parts[1] + ':' + parts.slice(2).join('__'); + if (!seen.has(key)) { + seen.add(key); + tools.push({ type: 'mcp', server: parts[1], tool: parts.slice(2).join('__') }); + } + } + } else if (name === 'Skill') { + const skill = (block.input || {}).skill; + if (skill && !seen.has('skill:' + skill)) { + seen.add('skill:' + skill); + tools.push({ type: 'skill', skill: skill.includes(':') ? skill.split(':')[0] : skill }); + } + } + } + return tools; +} + function extractContent(raw) { if (!raw) return ''; if (typeof raw === 'string') return raw; @@ -864,10 +985,25 @@ function getModelPricing(model) { // ── Compute real cost from session file token usage ──────── -function computeSessionCost(sessionId, project) { +function computeSessionCost(sessionId, project, maxFileSize) { const found = findSessionFile(sessionId, project); if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, model: '' }; + // Skip very large files in bulk operations (analytics) + if (maxFileSize) { + try { + const stat = fs.statSync(found.file); + if (stat.size > maxFileSize) { + // Estimate from file size instead + const tokens = stat.size / 4; + const pricing = getModelPricing(''); + const inp = Math.round(tokens * 0.3); + const out = Math.round(tokens * 0.7); + return { cost: inp * pricing.input + out * pricing.output, inputTokens: inp, outputTokens: out, model: 'estimated' }; + } + } catch {} + } + let totalCost = 0; let totalInput = 0; let totalOutput = 0; @@ -919,7 +1055,13 @@ function computeSessionCost(sessionId, project) { // ── Cost analytics ──────────────────────────────────────── +let analyticsCache = null; +let analyticsCacheAt = 0; +const ANALYTICS_TTL = 120000; // 2 minutes + function getCostAnalytics(sessions) { + const now = Date.now(); + if (analyticsCache && (now - analyticsCacheAt) < ANALYTICS_TTL) return analyticsCache; const byDay = {}; const byProject = {}; const byWeek = {}; @@ -928,7 +1070,7 @@ function getCostAnalytics(sessions) { const sessionCosts = []; for (const s of sessions) { - const costData = computeSessionCost(s.id, s.project); + const costData = computeSessionCost(s.id, s.project, 10 * 1024 * 1024); const cost = costData.cost; const tokens = costData.inputTokens + costData.outputTokens; if (cost === 0 && tokens === 0) continue; @@ -966,7 +1108,7 @@ function getCostAnalytics(sessions) { // Sort top sessions by cost sessionCosts.sort((a, b) => b.cost - a.cost); - return { + analyticsCache = { totalCost, totalTokens, totalSessions: sessions.length, @@ -975,6 +1117,8 @@ function getCostAnalytics(sessions) { byProject, topSessions: sessionCosts.slice(0, 10), }; + analyticsCacheAt = Date.now(); + return analyticsCache; } // ── Active sessions detection ───────────────────────────── diff --git a/src/frontend/app.js b/src/frontend/app.js index be84c84..a1025d7 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -160,9 +160,11 @@ function toggleStar(id) { // ── Data loading ─────────────────────────────────────────────── -async function loadSessions() { +async function loadSessions(refresh) { try { - var resp = await fetch('/api/sessions'); + var url = '/api/sessions'; + if (refresh) url += '?refresh=1'; + var resp = await fetch(url); allSessions = await resp.json(); applyFilters(); } catch (e) { @@ -171,10 +173,16 @@ async function loadSessions() { } function refreshData() { - loadSessions(); + loadSessions(true); showToast('Refreshed'); } +function clearSearch() { + var box = document.getElementById('searchBox'); + if (box) box.value = ''; + onSearch(''); +} + async function loadTerminals() { try { var resp = await fetch('/api/terminals'); @@ -378,6 +386,8 @@ function applyFilters() { function onSearch(val) { searchQuery = val; + var clearBtn = document.getElementById('searchClear'); + if (clearBtn) clearBtn.style.display = val ? 'block' : 'none'; applyFilters(); // Trigger deep search after debounce @@ -393,9 +403,177 @@ function onTagFilter(val) { } function onDateFilter() { - dateFrom = document.getElementById('dateFrom').value || ''; - dateTo = document.getElementById('dateTo').value || ''; applyFilters(); + updateDateBtn(); +} + +// ── Calendar ───────────────────────────────────────────────── + +var calYear = new Date().getFullYear(); +var calMonth = new Date().getMonth(); +var calStart = null; +var calEnd = null; +var calSelecting = false; + +function toggleCalendar() { + var popup = document.getElementById('calendarPopup'); + if (!popup) return; + if (popup.classList.contains('open')) { + popup.classList.remove('open'); + return; + } + renderCalendar(); + popup.classList.add('open'); + // Close on outside click + setTimeout(function() { + document.addEventListener('click', closeCalendarOutside, { once: true }); + }, 0); +} + +function closeCalendarOutside(e) { + var popup = document.getElementById('calendarPopup'); + var btn = document.getElementById('dateBtn'); + if (popup && !popup.contains(e.target) && btn && !btn.contains(e.target)) { + popup.classList.remove('open'); + } else if (popup && popup.classList.contains('open')) { + document.addEventListener('click', closeCalendarOutside, { once: true }); + } +} + +function renderCalendar() { + var popup = document.getElementById('calendarPopup'); + if (!popup) return; + + var monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; + var firstDay = new Date(calYear, calMonth, 1); + var lastDay = new Date(calYear, calMonth + 1, 0); + var startWeekday = (firstDay.getDay() + 6) % 7; // Monday=0 + var daysInMonth = lastDay.getDate(); + var today = new Date(); + var todayStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0'); + + var html = '
'; + html += ''; + html += '' + monthNames[calMonth] + ' ' + calYear + ''; + html += ''; + html += '
'; + + html += '
MoTuWeThFrSaSu
'; + html += '
'; + + // Previous month padding + var prevLastDay = new Date(calYear, calMonth, 0).getDate(); + for (var i = startWeekday - 1; i >= 0; i--) { + html += '
' + (prevLastDay - i) + '
'; + } + + // Current month days + for (var d = 1; d <= daysInMonth; d++) { + var dateStr = calYear + '-' + String(calMonth+1).padStart(2,'0') + '-' + String(d).padStart(2,'0'); + var cls = 'cal-day'; + if (dateStr === todayStr) cls += ' today'; + if (calStart && calEnd) { + if (dateStr === calStart) cls += ' range-start'; + if (dateStr === calEnd) cls += ' range-end'; + if (dateStr > calStart && dateStr < calEnd) cls += ' in-range'; + if (calStart === calEnd && dateStr === calStart) cls += ' range-start range-end'; + } else if (calStart && dateStr === calStart) { + cls += ' range-start range-end'; + } + html += '
' + d + '
'; + } + + // Next month padding + var totalCells = startWeekday + daysInMonth; + var remaining = (7 - (totalCells % 7)) % 7; + for (var n = 1; n <= remaining; n++) { + html += '
' + n + '
'; + } + html += '
'; + + // Presets + html += '
'; + var presets = [['All',''],['Today','0'],['7d','7'],['30d','30'],['90d','90']]; + presets.forEach(function(p) { + html += ''; + }); + html += '
'; + + popup.innerHTML = html; +} + +function calNav(dir) { + calMonth += dir; + if (calMonth < 0) { calMonth = 11; calYear--; } + if (calMonth > 11) { calMonth = 0; calYear++; } + renderCalendar(); +} + +function calPickDay(dateStr) { + if (!calSelecting) { + // First click — set start + calStart = dateStr; + calEnd = null; + calSelecting = true; + } else { + // Second click — set end + if (dateStr < calStart) { + calEnd = calStart; + calStart = dateStr; + } else { + calEnd = dateStr; + } + calSelecting = false; + } + renderCalendar(); + // Apply filter + dateFrom = calStart || ''; + dateTo = calEnd || calStart || ''; + onDateFilter(); +} + +function calPreset(days) { + calSelecting = false; + if (!days) { + calStart = null; + calEnd = null; + dateFrom = ''; + dateTo = ''; + } else { + var now = new Date(); + calEnd = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); + if (days === '0') { + calStart = calEnd; + } else { + var from = new Date(now.getTime() - parseInt(days) * 86400000); + calStart = from.getFullYear() + '-' + String(from.getMonth()+1).padStart(2,'0') + '-' + String(from.getDate()).padStart(2,'0'); + } + dateFrom = calStart; + dateTo = calEnd; + } + renderCalendar(); + onDateFilter(); + // Close popup + var popup = document.getElementById('calendarPopup'); + if (popup) popup.classList.remove('open'); +} + +function updateDateBtn() { + var btn = document.getElementById('dateBtn'); + var label = document.getElementById('dateBtnLabel'); + if (!btn || !label) return; + if (!dateFrom && !dateTo) { + label.textContent = 'All time'; + btn.classList.remove('has-filter'); + } else if (dateFrom === dateTo) { + label.textContent = dateFrom; + btn.classList.add('has-filter'); + } else { + var f = dateFrom.slice(5) || ''; + var t = dateTo.slice(5) || ''; + label.textContent = f + ' \u2014 ' + t; + btn.classList.add('has-filter'); + } } function toggleGroup() { @@ -489,6 +667,21 @@ function renderCard(s, idx) { html += ''; } html += ''; + // MCP/Skills footer + if ((s.mcp_servers && s.mcp_servers.length > 0) || (s.skills && s.skills.length > 0)) { + html += '
'; + if (s.mcp_servers) { + s.mcp_servers.forEach(function(m) { + html += '' + escHtml(m) + ''; + }); + } + if (s.skills) { + s.skills.forEach(function(sk) { + html += '' + escHtml(sk) + ''; + }); + } + html += '
'; + } // Expandable preview area (hidden by default) html += '
'; html += ''; @@ -502,9 +695,7 @@ function toggleLayout() { if (btn) btn.classList.toggle('active', layout === 'list'); var icon = document.getElementById('layoutIcon'); if (icon) { - icon.innerHTML = layout === 'list' - ? '' - : ''; + icon.innerHTML = ''; } render(); } @@ -522,6 +713,16 @@ function renderListCard(s, idx) { var html = '
'; html += '' + escHtml(s.tool) + ''; + if (s.mcp_servers && s.mcp_servers.length > 0) { + s.mcp_servers.forEach(function(m) { + html += '' + escHtml(m) + ''; + }); + } + if (s.skills && s.skills.length > 0) { + s.skills.forEach(function(sk) { + html += '' + escHtml(sk) + ''; + }); + } html += '' + escHtml(projName) + ''; html += '' + escHtml((s.first_message || '').slice(0, 80)) + ''; html += '' + s.messages + ' msgs'; @@ -743,6 +944,11 @@ function render() { return; } + if (currentView === 'settings') { + renderSettings(content); + return; + } + if (currentView === 'running') { renderRunning(content, sessions); return; @@ -755,7 +961,9 @@ function render() { return; } var idx = 0; - content.innerHTML = starredSessions.map(function(s) { return renderCard(s, idx++); }).join(''); + var renderFn = layout === 'list' ? renderListCard : renderCard; + var wrapClass = layout === 'list' ? 'list-view' : 'grid-view'; + content.innerHTML = '
' + starredSessions.map(function(s) { return renderFn(s, idx++); }).join('') + '
'; return; } @@ -869,7 +1077,8 @@ function renderProjects(container, sessions) { return; } - var html = '
'; + var isList = layout === 'list'; + var html = '
'; sorted.forEach(function(entry) { var name = entry[0]; var info = entry[1]; @@ -878,18 +1087,29 @@ function renderProjects(container, sessions) { var totalSize = info.sessions.reduce(function(sum, s) { return sum + (s.file_size || 0); }, 0); var latest = info.sessions[0]; - html += '
'; - html += '
'; - html += ''; - html += '' + escHtml(name) + ''; - html += '
'; - html += '
'; - html += '' + info.sessions.length + ' sessions'; - html += '' + totalMsgs + ' msgs'; - html += '' + formatBytes(totalSize) + ''; - html += '
'; - html += '
Last: ' + timeAgo(latest.last_ts) + '
'; - html += '
'; + if (isList) { + html += '
'; + html += ''; + html += '' + escHtml(name) + ''; + html += '' + info.sessions.length + ' sessions'; + html += '' + totalMsgs + ' msgs'; + html += '' + formatBytes(totalSize) + ''; + html += '' + timeAgo(latest.last_ts) + ''; + html += '
'; + } else { + html += '
'; + html += '
'; + html += ''; + html += '' + escHtml(name) + ''; + html += '
'; + html += '
'; + html += '' + info.sessions.length + ' sessions'; + html += '' + totalMsgs + ' msgs'; + html += '' + formatBytes(totalSize) + ''; + html += '
'; + html += '
Last: ' + timeAgo(latest.last_ts) + '
'; + html += '
'; + } }); html += '
'; container.innerHTML = html; @@ -1087,6 +1307,22 @@ async function openDetail(s) { }); infoHtml += ''; infoHtml += '
'; + // MCP servers + if (s.mcp_servers && s.mcp_servers.length > 0) { + infoHtml += '
MCP'; + s.mcp_servers.forEach(function(m) { + infoHtml += '' + escHtml(m) + ''; + }); + infoHtml += '
'; + } + // Skills + if (s.skills && s.skills.length > 0) { + infoHtml += '
Skills'; + s.skills.forEach(function(sk) { + infoHtml += '' + escHtml(sk) + ''; + }); + infoHtml += '
'; + } infoHtml += '
'; // Action buttons @@ -1128,10 +1364,24 @@ async function openDetail(s) { data.messages.forEach(function(m) { var roleClass = m.role === 'user' ? 'msg-user' : 'msg-assistant'; var roleLabel = m.role === 'user' ? 'You' : 'Assistant'; - msgsHtml += '
'; + var hasTools = m.tools && m.tools.length > 0; + msgsHtml += '
'; + msgsHtml += '
'; msgsHtml += '
' + roleLabel + '
'; msgsHtml += '
' + escHtml(m.content) + '
'; msgsHtml += '
'; + if (hasTools) { + msgsHtml += '
'; + m.tools.forEach(function(t) { + if (t.type === 'mcp') { + msgsHtml += '' + escHtml(t.tool) + ''; + } else if (t.type === 'skill') { + msgsHtml += '' + escHtml(t.skill) + ''; + } + }); + msgsHtml += '
'; + } + msgsHtml += '
'; }); msgContainer.innerHTML = msgsHtml; } else { @@ -1557,9 +1807,11 @@ function renderRunning(container, sessions) { var recentInactive = sessions.filter(function(s) { return !activeSessions[s.id]; }).slice(0, 6); if (recentInactive.length > 0) { html += '

Recently Inactive

'; - html += '
'; + var renderFn = layout === 'list' ? renderListCard : renderCard; + var wrapClass = layout === 'list' ? 'list-view' : 'grid-view'; + html += '
'; var idx = 0; - recentInactive.forEach(function(s) { html += renderCard(s, idx++); }); + recentInactive.forEach(function(s) { html += renderFn(s, idx++); }); html += '
'; } @@ -1791,6 +2043,47 @@ function focusSession(sessionId) { // ── Changelog view ──────────────────────────────────────────── +function renderSettings(container) { + var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; + var savedTerminal = localStorage.getItem('codedash-terminal') || ''; + + var html = '
'; + html += '

Settings

'; + + // Theme + html += '
'; + html += ''; + html += '
'; + ['dark', 'light', 'system'].forEach(function(t) { + var active = savedTheme === t ? ' active' : ''; + html += ''; + }); + html += '
'; + html += '
'; + + // Terminal + html += '
'; + html += ''; + html += ''; + html += '
'; + + // Version + html += '
'; + var badge = document.getElementById('versionBadge'); + var ver = badge ? badge.textContent : ''; + html += 'codedash ' + escHtml(ver) + ''; + html += '
'; + + html += '
'; + container.innerHTML = html; +} + async function renderChangelog(container) { container.innerHTML = '
Loading changelog...
'; try { @@ -1987,6 +2280,30 @@ function dismissUpdate() { // ── Initialization ───────────────────────────────────────────── (function init() { + // Show skeleton preloader + var content = document.getElementById('content'); + if (content) { + var skeletonHtml = ''; + for (var i = 0; i < 6; i++) { + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + skeletonHtml += '
'; + } + content.innerHTML = skeletonHtml; + } + // Load data loadSessions(); loadTerminals(); diff --git a/src/frontend/index.html b/src/frontend/index.html index 0687c18..5a4331d 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -8,105 +8,122 @@ + + + + + + + + + + + + + + + + + + + + + +