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 • Speed: ${ gps.speed != null ? Math.round(gps.speed * 10) / 10 : '?'} m/s
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 `