class DashboardHeader extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.hostname = 'localhost'; this.serverTimeOffset = null; this.isTimeServerSynced = false; this.statusCache = null; } async connectedCallback() { await this.fetchHostname(); this.render(); this.initializeHeader(); this.startTimeUpdates(); this.startStatusUpdates(); } async fetchHostname() { try { const response = await fetch('/api/hostname'); const data = await response.json(); this.hostname = data.hostname.toLowerCase(); } catch (error) { console.warn('Failed to fetch hostname:', error); this.hostname = 'localhost'; } } async fetchStatus() { try { const requestStart = performance.now(); const response = await fetch('/api/status'); const requestEnd = performance.now(); if (!response.ok) throw new Error('Status fetch failed'); const status = await response.json(); this.statusCache = status; this.updateUI(status, requestStart, requestEnd); } catch (error) { console.error('Failed to fetch status:', error); this.showOfflineStatus(); } } updateUI(status, requestStart, requestEnd) { this.updateSystemMetrics(status.system); this.updateGPSStatus(status.gps); this.updateNetworkStatus(status.network); this.updateLoRaStatus(status.lora); this.updateTimeSync(status.time, requestStart, requestEnd); } updateSystemMetrics(system) { const tempElement = this.shadowRoot.getElementById('temp'); if (tempElement && system.temperature) { tempElement.textContent = `${system.temperature}°C`; } const loadElement = this.shadowRoot.getElementById('load'); if (loadElement && system.load !== undefined) { loadElement.textContent = `${system.load.toFixed(1)} Load`; } this.updateProgressBar('cpu-usage', system.cpu); this.updateProgressBar('memory-usage', system.memory); this.updateProgressBar('disk-usage', system.storage); } updateProgressBar(id, percentage) { const fillElement = this.shadowRoot.getElementById(`${id}-fill`); const textElement = this.shadowRoot.getElementById(`${id}-text`); if (fillElement && textElement) { const value = Math.round(percentage); fillElement.style.width = `${value}%`; textElement.textContent = `${value}%`; if (value > 80) { fillElement.style.background = '#ef4444'; } else if (value > 60) { fillElement.style.background = '#f59e0b'; } else { fillElement.style.background = '#10b981'; } } } updateGPSStatus(gps) { const gpsStatus = this.shadowRoot.getElementById('gps-status'); const gpsTile = this.shadowRoot.getElementById('gps-tile'); if (gpsStatus && gpsTile) { const hasValidFix = gps.fix !== 'No Fix' && gps.latitude !== null; if (hasValidFix) { gpsStatus.classList.remove('disconnected'); gpsStatus.classList.add('connected'); gpsTile.classList.remove('disconnected'); gpsTile.classList.add('connected'); } else { gpsStatus.classList.remove('connected'); gpsStatus.classList.add('disconnected'); gpsTile.classList.remove('connected'); gpsTile.classList.add('disconnected'); } const gpsInfo = gpsTile.querySelector('.tile-info'); const gpsDetail = gpsTile.querySelector('.tile-detail'); if (gpsInfo && gpsDetail) { if (hasValidFix) { gpsInfo.textContent = `${gps.latitude.toFixed(4)}, ${gps.longitude.toFixed(4)}`; gpsDetail.innerHTML = `Alt: ${gps.altitude != null ? Math.round(gps.altitude) : '?'}m • Vel: ${ gps.speed != null ? Math.round(gps.speed * 3.6) : '?'} Kph
Acc: ${gps.accuracy ? Math.round(gps.accuracy) : 'None'}m • ${gps.satellites ?? '?'} Sats`; } else { gpsInfo.textContent = gps.satellites > 0 ? 'Acquiring Fix...' : 'No Fix'; gpsDetail.textContent = `${gps.satellites} satellites`; } } } } updateNetworkStatus(network) { const wifiTile = this.shadowRoot.getElementById('wifi-tile'); if (wifiTile) { const wifiInfo = wifiTile.querySelector('.tile-info'); const wifiDetail = wifiTile.querySelector('.tile-detail'); if (wifiInfo && wifiDetail) { if (network.wifi.mode === 'ap') { wifiInfo.textContent = 'Access Point'; wifiDetail.textContent = `SSID: ${network.wifi.ssid || this.hostname}`; } else { wifiInfo.textContent = 'Client Mode'; wifiDetail.textContent = `SSID: ${network.wifi.ssid}`; } } } const ethernetStatus = this.shadowRoot.getElementById('ethernet-status'); const ethernetTile = this.shadowRoot.getElementById('ethernet-tile'); if (ethernetStatus && ethernetTile) { if (network.ethernet.connected) { ethernetStatus.classList.remove('disconnected'); ethernetStatus.classList.add('connected'); ethernetTile.classList.remove('disconnected'); ethernetTile.classList.add('connected'); const ethernetInfo = ethernetTile.querySelector('.tile-info'); const ethernetDetail = ethernetTile.querySelector('.tile-detail'); if (ethernetInfo && ethernetDetail) { ethernetInfo.textContent = 'Connected'; ethernetDetail.textContent = 'Link detected'; } } else { ethernetStatus.classList.remove('connected'); ethernetStatus.classList.add('disconnected'); ethernetTile.classList.remove('connected'); ethernetTile.classList.add('disconnected'); const ethernetInfo = ethernetTile.querySelector('.tile-info'); const ethernetDetail = ethernetTile.querySelector('.tile-detail'); if (ethernetInfo && ethernetDetail) { ethernetInfo.textContent = 'Disconnected'; ethernetDetail.textContent = 'No cable detected'; } } } } updateLoRaStatus(lora) { const loraStatus = this.shadowRoot.getElementById('lora-status'); const loraTile = this.shadowRoot.getElementById('lora-tile'); if (loraStatus && loraTile) { if (lora.connected) { loraStatus.classList.remove('disconnected'); loraStatus.classList.add('connected'); loraTile.classList.remove('disconnected'); loraTile.classList.add('connected'); const loraInfo = loraTile.querySelector('.tile-info'); const loraDetail = loraTile.querySelector('.tile-detail'); if (loraInfo && loraDetail) { loraInfo.textContent = `${lora.onlineNodes}/${lora.totalNodes} Online`; loraDetail.textContent = `CH: ${lora.channelUtilization.toFixed(1)}% • Air: ${lora.airtimeUtilization.toFixed(1)}%`; } } else { loraStatus.classList.remove('connected'); loraStatus.classList.add('disconnected'); loraTile.classList.remove('connected'); loraTile.classList.add('disconnected'); const loraInfo = loraTile.querySelector('.tile-info'); const loraDetail = loraTile.querySelector('.tile-detail'); if (loraInfo && loraDetail) { loraInfo.textContent = 'Disconnected'; loraDetail.textContent = 'No device found'; } } } } updateTimeSync(serverTimeString, requestStart, requestEnd) { if (serverTimeString && requestStart && requestEnd) { const networkLatency = (requestEnd - requestStart) / 2; const serverTime = new Date(serverTimeString); const adjustedServerTime = new Date(serverTime.getTime() + networkLatency); const clientTime = new Date(); this.serverTimeOffset = adjustedServerTime.getTime() - clientTime.getTime(); this.isTimeServerSynced = true; } this.updateCurrentTime(); } updateCurrentTime() { const now = new Date(); let displayTime; if (this.isTimeServerSynced && this.serverTimeOffset !== null) { displayTime = new Date(now.getTime() + this.serverTimeOffset); } else { displayTime = now; } this.updateTimeDisplay('local', displayTime, false); this.updateTimeDisplay('zulu', displayTime, true); } updateTimeDisplay(type, date, isUtc) { const timeElement = this.shadowRoot.getElementById(`${type}-time`); const dateElement = this.shadowRoot.getElementById(`${type}-date`); const mobileTimeElement = this.shadowRoot.getElementById(`mobile-${type}-time`); const mobileDateElement = this.shadowRoot.getElementById(`mobile-${type}-date`); if (timeElement && dateElement) { const displayDate = isUtc ? new Date(date.getTime()) : date; const timeStr = isUtc ? displayDate.toUTCString().split(' ')[4] : displayDate.toLocaleTimeString('en-US', { hour12: false }); const dateStr = isUtc ? displayDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : displayDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); timeElement.textContent = timeStr; dateElement.textContent = dateStr; if (mobileTimeElement && mobileDateElement) { mobileTimeElement.textContent = timeStr; mobileDateElement.textContent = dateStr; } if (this.isTimeServerSynced) { timeElement.style.color = '#fff'; dateElement.style.color = '#64748b'; if (mobileTimeElement) mobileTimeElement.style.color = '#fff'; if (mobileDateElement) mobileDateElement.style.color = '#64748b'; } else { timeElement.style.color = '#f59e0b'; dateElement.style.color = '#f59e0b'; if (mobileTimeElement) mobileTimeElement.style.color = '#f59e0b'; if (mobileDateElement) mobileDateElement.style.color = '#f59e0b'; } } } showOfflineStatus() { this.serverTimeOffset = null; this.isTimeServerSynced = false; } startStatusUpdates() { this.fetchStatus(); setInterval(() => this.fetchStatus(), 60000); } startTimeUpdates() { this.updateCurrentTime(); setInterval(() => this.updateCurrentTime(), 1000); } initializeHeader() { const expandToggle = this.shadowRoot.getElementById('expand-toggle'); const expandIcon = this.shadowRoot.getElementById('expand-icon'); const expandedStatus = this.shadowRoot.getElementById('expanded-status'); expandToggle.addEventListener('click', () => { expandIcon.classList.toggle('expanded'); expandedStatus.classList.toggle('visible'); }); } render() { const title = this.getAttribute('title') || 'DASHBOARD'; this.shadowRoot.innerHTML = ` ${this.getHTML(title)} `; } getStyles() { return ` * { margin: 0; padding: 0; box-sizing: border-box; } .header { background: rgba(15, 23, 42, 0.95); backdrop-filter: blur(10px); padding: 1.5rem 2rem; border-bottom: 1px solid rgba(255, 255, 255, 0.1); position: sticky; top: 0; z-index: 100; } .header-content { max-width: 1400px; margin: 0 auto; display: grid; grid-template-columns: 1fr auto 1fr; grid-template-areas: "logo status clocks"; align-items: center; gap: 1rem; } .logo-section { grid-area: logo; display: flex; align-items: center; gap: 1rem; justify-self: start; } .logo { font-size: 1.5rem; font-weight: 700; letter-spacing: 0.05rem; color: #fff; } .hostname { color: #94a3b8; } .header-info { grid-area: clocks; justify-self: end; } .network-status-wrapper { grid-area: status; display: flex; flex-direction: column; align-items: center; justify-self: center; } .network-status { display: flex; gap: 0.5rem; align-items: center; justify-content: center; padding: 0.5rem 0; } .network-item { display: flex; align-items: center; justify-content: center; padding: 0.4rem; border-radius: 50%; background: rgba(255, 255, 255, 0.05); cursor: pointer; transition: all 0.2s ease; } .network-item:hover { background: rgba(255, 255, 255, 0.1); } .network-icon { width: 20px; height: 20px; stroke-width: 2; fill: none; transition: all 0.3s ease; } .expand-toggle { cursor: pointer; padding: 0.4rem; border-radius: 50%; background: rgba(255, 255, 255, 0.05); transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; } .expand-toggle:hover { background: rgba(255, 255, 255, 0.1); } .expand-icon { width: 20px; height: 20px; stroke: #94a3b8; stroke-width: 2; fill: none; transition: transform 0.3s ease; } .expand-icon.expanded { transform: rotate(180deg); } .expanded-status { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; } .expanded-status.visible { max-height: 300px; } .status-tiles { display: flex; justify-content: flex-start; gap: 1rem; padding: 1.5rem 0; max-width: 1400px; margin: 0 auto; overflow-x: auto; -webkit-overflow-scrolling: touch; } .status-tiles::-webkit-scrollbar { height: 8px; } .status-tiles::-webkit-scrollbar-track { background: transparent; } .status-tiles::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; } .status-tiles::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); } .status-tile { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem; justify-content: space-between; flex: 0 0 auto; width: 170px; } .tile-header { display: flex; align-items: center; gap: 0.75rem; } .tile-icon { width: 24px; height: 24px; stroke-width: 2; } .tile-name { font-size: 0.9rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05rem; color: #fff; } .tile-info { font-size: 0.85rem; color: #94a3b8; margin-top: 0.25rem; } .tile-detail { font-size: 0.75rem; color: #64748b; } .network-item.disconnected .network-icon, .status-tile.disconnected .tile-icon { stroke: #64748b; } .status-tile.disconnected .tile-name { color: #64748b; } .status-tile.connected { border-color: rgba(16, 185, 129, 0.3); } .network-item.connected .network-icon, .status-tile.connected .tile-icon { stroke: #10b981; } .status-tile.connected .tile-name { color: #10b981; } .time-display { display: flex; gap: 2rem; font-size: 0.85rem; } .time-block { display: flex; flex-direction: column; align-items: flex-end; } .time-label { color: #94a3b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05rem; margin-bottom: 0.2rem; } .time-value { font-weight: 600; font-family: 'Courier New', monospace; color: #fff; } .time-date { color: #64748b; font-size: 0.75rem; margin-top: 0.1rem; } #temp { color: #94a3b8; } .tile-wide { width: 360px; } .system-metrics { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem; } .system-metric { display: flex; align-items: center; gap: 0.75rem; } .metric-label { font-size: 0.8rem; font-weight: 500; color: #cdd5e0; width: 80px; } .metric-label span { font-size: 0.7rem; color: #94a3b8; margin-left: 4px; } .progress-bar { flex-grow: 1; height: 14px; background-color: rgba(255, 255, 255, 0.1); border-radius: 6px; position: relative; overflow: hidden; } .progress-bar-fill { height: 100%; background: #10b981; border-radius: 6px; transition: width 0.5s ease-in-out; } .progress-bar-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; font-size: 0.8rem; font-weight: 600; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); } .home-button { display: none; padding: 0.5rem; background: rgba(255, 255, 255, 0.05); border-radius: 8px; cursor: pointer; transition: all 0.2s ease; border: none; color: #fff; } .home-button:hover { background: rgba(255, 255, 255, 0.1); } .home-button svg { width: 20px; height: 20px; stroke: #fff; stroke-width: 2; fill: none; } .mobile-time-display { display: none; } @media (max-width: 800px) { .header-content { grid-template-columns: 1fr 1fr; grid-template-areas: "logo clocks" "status status"; justify-items: start; } .logo-section { justify-self: start; } .header-info { justify-self: end; } .time-display { gap: 1rem; } .network-status-wrapper { justify-self: center; grid-column: 1 / -1; } } @media (max-width: 650px) { .header { padding: 0.75rem 1rem; } .header-content { grid-template-columns: auto 1fr; grid-template-areas: "home status"; gap: 0.75rem; align-items: center; } .home-button { display: flex; grid-area: home; } .home-button svg { width: 22px; height: 22px; } .logo-section { display: none; } .header-info { display: none; } .network-status-wrapper { grid-area: status; justify-self: end; grid-column: unset; } .network-status { gap: 0.4rem; padding: 0; } .network-item { padding: 0.35rem; } .network-icon { width: 18px; height: 18px; } .expand-toggle { padding: 0.35rem; } .expand-icon { width: 18px; height: 18px; } .expanded-status.visible { max-height: 400px; } .status-tiles { padding: 1rem 0; gap: 1rem; } .mobile-time-display { display: flex; gap: 1rem; flex: 0 0 auto; } .mobile-time-block { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 12px; padding: 1rem; display: flex; flex-direction: column; flex: 0 0 auto; width: 170px; } .mobile-time-label { color: #94a3b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05rem; } .mobile-time-value { font-weight: 600; font-family: 'Courier New', monospace; color: #10b981; font-size: 1.1rem; line-height: 1.2; } .mobile-time-date { color: #64748b; font-size: 0.75rem; } } `; } getHTML(title) { return `
${this.hostname}
Local Time
00:00:00
Jan 1, 2025
Zulu / UTC
00:00:00
Jan 1, 2025
Local Time
00:00:00
Jan 1, 2025

Zulu / UTC
00:00:00
Jan 1, 2025
System ?°C
CPU - Load
0%
Memory
0%
Disk
0%
GPS
No Fix
0 Satellites
LoRa
0 / 0 Online
Ch 0% Air 0%
Mesh
Connected
900 Mhz -45 dBm
WiFi
Access Point
SSID: ${this.hostname}
Ethernet
Disconnected
No cable detected
`; } } customElements.define('notification-bar', DashboardHeader);