Files
esp32-bmc/main/html/index.html
Valentin Haudiquet 6e88ce1137
All checks were successful
Build ESP32 BMC Firmware / build (push) Successful in 53s
log: add log query API endpoints and display logs on web interface
2026-03-12 16:20:01 +01:00

1296 lines
42 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VisionFive2 BMC Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
backdrop-filter: blur(10px);
}
h1 {
font-size: 2.5em;
margin-bottom: 10px;
background: linear-gradient(90deg, #00d4ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.status-bar {
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff4444;
animation: pulse 2s infinite;
}
.status-dot.connected {
background: #00ff88;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
padding: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card h2 {
font-size: 1.3em;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 10px;
}
.card h2 .icon {
font-size: 1.5em;
}
/* Power Control */
.power-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1em;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
}
.btn:active {
transform: translateY(0);
}
.btn-power-on {
background: linear-gradient(135deg, #00c853, #00e676);
color: #000;
}
.btn-power-off {
background: linear-gradient(135deg, #ff1744, #ff5252);
color: #fff;
}
.btn-reset {
background: linear-gradient(135deg, #ff9100, #ffab40);
color: #000;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.power-status {
margin-top: 15px;
padding: 15px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
}
.power-indicator {
width: 20px;
height: 20px;
border-radius: 50%;
background: #ff4444;
}
.power-indicator.on {
background: #00ff88;
box-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
}
/* GPIO Monitor */
.gpio-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.gpio-item {
background: rgba(0, 0, 0, 0.2);
padding: 10px;
border-radius: 8px;
text-align: center;
}
.gpio-name {
font-size: 0.8em;
color: #888;
margin-bottom: 5px;
}
.gpio-pin {
font-size: 0.9em;
color: #00d4ff;
margin-bottom: 5px;
}
.gpio-value {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
}
.gpio-led {
width: 16px;
height: 16px;
border-radius: 50%;
background: #333;
border: 2px solid #555;
}
.gpio-led.high {
background: #00ff88;
border-color: #00ff88;
box-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
}
.gpio-toggle {
padding: 4px 8px;
font-size: 0.8em;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 4px;
cursor: pointer;
}
.gpio-toggle:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Serial Console */
.console-container {
display: flex;
flex-direction: column;
height: 400px;
}
.console-output {
flex: 1;
background: #0a0a0a;
border-radius: 8px;
padding: 15px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
overflow-y: auto;
margin-bottom: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: text;
}
.console-output:focus {
outline: none;
border-color: #00d4ff;
}
.console-output pre {
white-space: pre-wrap;
word-wrap: break-word;
}
.console-output .input-line {
color: #00ff88;
}
.console-output .cursor {
display: inline-block;
width: 8px;
height: 1em;
background: #00ff88;
animation: blink 1s step-end infinite;
vertical-align: middle;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.console-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.console-controls select {
padding: 8px 12px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
}
/* System Info */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.info-item {
padding: 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.info-label {
font-size: 0.8em;
color: #888;
margin-bottom: 3px;
}
.info-value {
font-size: 1em;
color: #00d4ff;
font-family: 'Courier New', monospace;
}
/* Toast Notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.toast {
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 10px;
animation: slideIn 0.3s ease;
max-width: 300px;
}
.toast.success {
background: rgba(0, 255, 136, 0.2);
border: 1px solid #00ff88;
}
.toast.error {
background: rgba(255, 68, 68, 0.2);
border: 1px solid #ff4444;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* OTA Update Styles */
.ota-container {
display: flex;
flex-direction: column;
gap: 15px;
}
.ota-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.ota-form label {
font-weight: 600;
color: #555;
}
.ota-form input {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.ota-form input:focus {
outline: none;
border-color: #2196F3;
}
.ota-progress {
display: flex;
align-items: center;
gap: 10px;
}
.progress-bar {
flex: 1;
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #8BC34A);
border-radius: 10px;
transition: width 0.3s ease;
width: 0%;
}
#ota-progress-text {
font-weight: 600;
min-width: 45px;
}
.ota-info {
margin-bottom: 10px;
}
/* OTA Upload Section */
.ota-upload-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.drop-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 30px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #fafafa;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: #2196F3;
background: #e3f2fd;
}
.drop-zone.drag-over {
transform: scale(1.02);
}
.drop-zone-icon {
font-size: 48px;
display: block;
margin-bottom: 10px;
}
.drop-zone p {
margin: 0;
font-weight: 600;
color: #333;
}
.drop-zone-hint {
font-size: 12px;
color: #666;
margin-top: 5px;
display: block;
}
.file-info {
display: flex;
justify-content: space-between;
padding: 10px;
background: #e8f5e9;
border-radius: 4px;
font-size: 14px;
}
.file-info #file-name {
font-weight: 600;
color: #2e7d32;
}
.file-info #file-size {
color: #666;
}
.ota-divider {
display: flex;
align-items: center;
text-align: center;
margin: 15px 0;
}
.ota-divider::before,
.ota-divider::after {
content: '';
flex: 1;
border-bottom: 1px solid #ddd;
}
.ota-divider span {
padding: 0 15px;
color: #999;
font-size: 12px;
font-weight: 600;
}
/* Responsive */
@media (max-width: 600px) {
h1 {
font-size: 1.8em;
}
.status-bar {
flex-direction: column;
align-items: center;
}
.power-buttons {
flex-direction: column;
}
.btn {
width: 100%;
}
.info-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🖥️ VisionFive2 BMC Dashboard</h1>
<div class="status-bar">
<div class="status-item">
<div class="status-dot" id="eth-status"></div>
<span>Ethernet: <span id="eth-text">Disconnected</span></span>
</div>
<div class="status-item">
<span>IP: <span id="ip-address">-</span></span>
</div>
<div class="status-item">
<span>UART: <span id="uart-baud">115200</span> baud</span>
</div>
</div>
</header>
<div class="grid">
<!-- Power Control Card -->
<div class="card">
<h2><span class="icon"></span> Power Control</h2>
<div class="power-buttons">
<button class="btn btn-power-on" onclick="powerOn()">Power On</button>
<button class="btn btn-power-off" onclick="powerOff()">Power Off</button>
<button class="btn btn-reset" onclick="reset()">Reset</button>
</div>
<div class="power-status">
<div class="power-indicator" id="power-indicator"></div>
<span>Power Status: <strong id="power-status">Unknown</strong></span>
</div>
</div>
<!-- System Info Card -->
<div class="card">
<h2><span class="icon"></span> System Info</h2>
<div class="info-grid">
<div class="info-item">
<div class="info-label">MAC Address</div>
<div class="info-value" id="mac-address">-</div>
</div>
<div class="info-item">
<div class="info-label">Netmask</div>
<div class="info-value" id="netmask">-</div>
</div>
<div class="info-item">
<div class="info-label">Gateway</div>
<div class="info-value" id="gateway">-</div>
</div>
<div class="info-item">
<div class="info-label">Link Speed</div>
<div class="info-value" id="link-speed">-</div>
</div>
</div>
</div>
</div>
<div class="grid">
<!-- OTA Update Card -->
<div class="card">
<h2><span class="icon">🔄</span> OTA Update</h2>
<div class="ota-container">
<div class="ota-info">
<div class="info-item">
<div class="info-label">Running Partition</div>
<div class="info-value" id="ota-partition">-</div>
</div>
</div>
<!-- File Upload Section -->
<div class="ota-upload-section">
<div class="drop-zone" id="drop-zone">
<div class="drop-zone-content">
<span class="drop-zone-icon">📁</span>
<p>Drag & drop firmware file here</p>
<span class="drop-zone-hint">or click to browse</span>
</div>
<input type="file" id="ota-file-input" accept=".bin" style="display: none;" />
</div>
<div class="file-info" id="file-info" style="display: none;">
<span id="file-name">-</span>
<span id="file-size">-</span>
</div>
<button class="btn btn-primary" id="ota-upload-btn" onclick="uploadFirmware()" disabled>Upload Firmware</button>
</div>
<div class="ota-divider">
<span>OR</span>
</div>
<div class="ota-form">
<label for="ota-url">Firmware URL:</label>
<input type="text" id="ota-url" placeholder="http://server/firmware.bin" />
<button class="btn btn-secondary" id="ota-btn" onclick="startOTAUpdate()">Download from URL</button>
</div>
<div class="ota-progress" id="ota-progress-container" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="ota-progress-bar"></div>
</div>
<span id="ota-progress-text">0%</span>
</div>
</div>
</div>
<!-- System Info Card (Additional) -->
<div class="card">
<h2><span class="icon">📊</span> System Status</h2>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Free Heap</div>
<div class="info-value" id="free-heap">-</div>
</div>
<div class="info-item">
<div class="info-label">Uptime</div>
<div class="info-value" id="uptime">-</div>
</div>
<div class="info-item">
<div class="info-label">WiFi Mode</div>
<div class="info-value" id="wifi-mode">Ethernet</div>
</div>
<div class="info-item">
<div class="info-label">IP Address</div>
<div class="info-value" id="ip-address">-</div>
</div>
</div>
</div>
</div>
<div class="grid">
<!-- GPIO Monitor Card -->
<div class="card">
<h2><span class="icon">🔌</span> GPIO Monitor</h2>
<div class="gpio-grid" id="gpio-grid">
<!-- GPIO items will be populated by JavaScript -->
</div>
</div>
<!-- Serial Console Card -->
<div class="card">
<h2><span class="icon">💻</span> Serial Console</h2>
<div class="console-container">
<div class="console-output" id="console-output" tabindex="0">
<pre>Connecting to serial console...</pre>
</div>
<div class="console-controls">
<label>Baud Rate:</label>
<select id="baud-select" onchange="setBaudRate()">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200" selected>115200</option>
<option value="230400">230400</option>
<option value="460800">460800</option>
<option value="921600">921600</option>
</select>
<button class="btn btn-secondary" onclick="clearConsole()">Clear</button>
<button class="btn btn-secondary" onclick="connectWebSocket()">Reconnect</button>
</div>
</div>
</div>
</div>
<div class="grid">
<!-- System Logs Card -->
<div class="card">
<h2><span class="icon">📋</span> System Logs</h2>
<div class="console-container">
<div class="console-output" id="logs-output">
<pre>Loading logs...</pre>
</div>
<div class="console-controls">
<button class="btn btn-secondary" onclick="refreshLogs()">Refresh</button>
<button class="btn btn-secondary" onclick="clearLogs()">Clear Logs</button>
<label><input type="checkbox" id="auto-refresh-logs" onchange="toggleAutoRefreshLogs()" /> Auto-refresh</label>
</div>
</div>
</div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<script>
// WebSocket connection
let ws = null;
let wsConnected = false;
// API base URL
const API_BASE = '';
// Toast notification
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// API helper
async function apiCall(endpoint, method = 'GET', data = null) {
const options = {
method: method,
headers: {
'Content-Type': 'application/json'
}
};
if (data) {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(`${API_BASE}${endpoint}`, options);
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Unknown error');
}
return result;
} catch (error) {
showToast(error.message, 'error');
throw error;
}
}
// Power control functions
async function powerOn() {
try {
await apiCall('/api/power/on', 'POST');
showToast('Power on sequence initiated');
updatePowerStatus();
} catch (error) {
console.error('Power on failed:', error);
}
}
async function powerOff() {
try {
await apiCall('/api/power/off', 'POST');
showToast('Power off sequence initiated');
updatePowerStatus();
} catch (error) {
console.error('Power off failed:', error);
}
}
async function reset() {
try {
await apiCall('/api/power/reset', 'POST');
showToast('Reset sequence initiated');
} catch (error) {
console.error('Reset failed:', error);
}
}
async function updatePowerStatus() {
try {
const result = await apiCall('/api/power/status');
const indicator = document.getElementById('power-indicator');
const status = document.getElementById('power-status');
if (result.data.power_good) {
indicator.classList.add('on');
status.textContent = 'ON';
} else {
indicator.classList.remove('on');
status.textContent = 'OFF';
}
} catch (error) {
console.error('Failed to get power status:', error);
}
}
// GPIO functions
async function updateGPIOStates() {
try {
const result = await apiCall('/api/gpio');
const grid = document.getElementById('gpio-grid');
grid.innerHTML = '';
result.gpios.forEach(gpio => {
const item = document.createElement('div');
item.className = 'gpio-item';
const isOutput = gpio.mode === 'output';
item.innerHTML = `
<div class="gpio-name">${gpio.name}</div>
<div class="gpio-pin">GPIO ${gpio.pin}</div>
<div class="gpio-value">
<div class="gpio-led ${gpio.value ? 'high' : ''}"></div>
<span>${gpio.value}</span>
${isOutput ? `<button class="gpio-toggle" onclick="toggleGPIO(${gpio.pin})">Toggle</button>` : ''}
</div>
`;
grid.appendChild(item);
});
} catch (error) {
console.error('Failed to get GPIO states:', error);
}
}
async function toggleGPIO(pin) {
try {
// Get current state
const result = await apiCall('/api/gpio');
const gpio = result.gpios.find(g => g.pin === pin);
if (gpio) {
const newValue = gpio.value ? 0 : 1;
await apiCall(`/api/gpio/set?pin=${pin}`, 'POST', { value: newValue });
updateGPIOStates();
}
} catch (error) {
console.error('Failed to toggle GPIO:', error);
}
}
// Serial console functions
let inputBuffer = '';
let cursorVisible = true;
function connectWebSocket() {
const protocol = window.location.protocol === 'https' ? 'wss' : 'ws';
const wsUrl = `${protocol}://${window.location.host}/api/serial/ws`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
wsConnected = true;
clearConsole();
appendConsole('Connected to serial console\n');
updateCursor();
};
ws.onmessage = (event) => {
appendConsole(event.data);
};
ws.onclose = () => {
wsConnected = false;
appendConsole('\nDisconnected from serial console\n');
// Try to reconnect after 3 seconds
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
appendConsole('WebSocket error\n');
};
}
// Strip ANSI escape sequences from text
function stripAnsi(text) {
// Remove ANSI escape sequences (cursor movement, colors, etc.)
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/\x1b\][^\x07]*\x07/g, '')
.replace(/\x1b[()][AB012]/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
}
function appendConsole(data) {
const output = document.getElementById('console-output');
const pre = output.querySelector('pre') || document.createElement('pre');
if (!output.contains(pre)) {
output.innerHTML = '';
output.appendChild(pre);
}
// Remove cursor before appending, then re-add
const cursor = pre.querySelector('.cursor');
if (cursor) cursor.remove();
// Strip ANSI escape sequences for cleaner display
const cleanData = stripAnsi(data);
pre.textContent += cleanData;
updateCursor();
output.scrollTop = output.scrollHeight;
}
function updateCursor() {
const output = document.getElementById('console-output');
const pre = output.querySelector('pre');
if (!pre) return;
// Remove existing cursor
const existingCursor = pre.querySelector('.cursor');
if (existingCursor) existingCursor.remove();
// Add cursor at end
if (wsConnected) {
const cursor = document.createElement('span');
cursor.className = 'cursor';
pre.appendChild(cursor);
}
}
function sendChar(char) {
if (wsConnected) {
ws.send(char);
}
}
function sendBuffer() {
if (inputBuffer && wsConnected) {
ws.send(inputBuffer + '\n');
inputBuffer = '';
updateCursor();
}
}
function handleConsoleKeyDown(event) {
if (!wsConnected) return;
const output = document.getElementById('console-output');
// Handle special keys
if (event.key === 'Enter') {
event.preventDefault();
sendChar('\n');
inputBuffer = '';
} else if (event.key === 'Backspace') {
event.preventDefault();
if (inputBuffer.length > 0) {
inputBuffer = inputBuffer.slice(0, -1);
sendChar('\b'); // Send backspace to remote
}
} else if (event.key === 'Tab') {
event.preventDefault();
sendChar('\t');
inputBuffer += '\t';
} else if (event.key === 'Escape') {
event.preventDefault();
sendChar('\x1b');
} else if (event.ctrlKey && event.key.length === 1) {
// Handle Ctrl+C, Ctrl+D, etc.
event.preventDefault();
const ctrlChar = String.fromCharCode(event.key.charCodeAt(0) - 96);
sendChar(ctrlChar);
} else if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
// Regular character
event.preventDefault();
sendChar(event.key);
inputBuffer += event.key;
}
}
function clearConsole() {
const output = document.getElementById('console-output');
output.innerHTML = '<pre></pre>';
inputBuffer = '';
updateCursor();
}
// Initialize console keyboard handling
document.addEventListener('DOMContentLoaded', () => {
const output = document.getElementById('console-output');
output.addEventListener('keydown', handleConsoleKeyDown);
// Focus console on click
output.addEventListener('click', () => {
output.focus();
});
});
async function setBaudRate() {
const baud = document.getElementById('baud-select').value;
try {
await apiCall('/api/serial/config', 'PUT', { baud_rate: parseInt(baud) });
document.getElementById('uart-baud').textContent = baud;
showToast(`Baud rate set to ${baud}`);
} catch (error) {
console.error('Failed to set baud rate:', error);
}
}
// System info functions
async function updateSystemInfo() {
try {
const result = await apiCall('/api/system/info');
document.getElementById('ip-address').textContent = result.data.ip || '-';
document.getElementById('mac-address').textContent = result.data.mac || '-';
document.getElementById('netmask').textContent = result.data.netmask || '-';
document.getElementById('gateway').textContent = result.data.gateway || '-';
document.getElementById('link-speed').textContent = result.data.link_speed ? `${result.data.link_speed} Mbps` : '-';
// Update Ethernet status
const ethStatus = document.getElementById('eth-status');
const ethText = document.getElementById('eth-text');
if (result.data.ethernet_connected) {
ethStatus.classList.add('connected');
ethText.textContent = 'Connected';
} else {
ethStatus.classList.remove('connected');
ethText.textContent = 'Disconnected';
}
document.getElementById('uart-baud').textContent = result.data.uart_baud_rate;
} catch (error) {
console.error('Failed to get system info:', error);
}
}
// OTA Update Functions
let otaPollInterval = null;
async function updateOTAStatus() {
try {
const response = await fetch(`${API_BASE}/api/ota/status`);
const result = await response.json();
if (result.success && result.data) {
document.getElementById('ota-partition').textContent = result.data.partition || '-';
if (result.data.updating) {
document.getElementById('ota-progress-container').style.display = 'flex';
const progress = result.data.progress || 0;
document.getElementById('ota-progress-bar').style.width = `${progress}%`;
document.getElementById('ota-progress-text').textContent = `${progress}%`;
document.getElementById('ota-btn').disabled = true;
document.getElementById('ota-btn').textContent = 'Updating...';
} else {
document.getElementById('ota-progress-container').style.display = 'none';
document.getElementById('ota-btn').disabled = false;
document.getElementById('ota-btn').textContent = 'Start Update';
if (otaPollInterval) {
clearInterval(otaPollInterval);
otaPollInterval = null;
}
}
}
} catch (error) {
console.error('Failed to get OTA status:', error);
}
}
async function startOTAUpdate() {
const url = document.getElementById('ota-url').value.trim();
if (!url) {
showToast('Please enter a firmware URL', 'error');
return;
}
try {
const response = await fetch(`${API_BASE}/api/ota/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ url: url })
});
const result = await response.json();
if (result.success) {
showToast('OTA update started', 'success');
document.getElementById('ota-btn').disabled = true;
document.getElementById('ota-btn').textContent = 'Updating...';
document.getElementById('ota-progress-container').style.display = 'flex';
// Start polling for progress
otaPollInterval = setInterval(updateOTAStatus, 1000);
} else {
showToast(result.error || 'Failed to start OTA update', 'error');
}
} catch (error) {
console.error('OTA update error:', error);
showToast('Failed to start OTA update', 'error');
}
}
// File Upload Functions
let selectedFile = null;
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function handleFileSelect(file) {
if (!file) return;
if (!file.name.endsWith('.bin')) {
showToast('Please select a .bin firmware file', 'error');
return;
}
selectedFile = file;
document.getElementById('file-info').style.display = 'flex';
document.getElementById('file-name').textContent = file.name;
document.getElementById('file-size').textContent = formatFileSize(file.size);
document.getElementById('ota-upload-btn').disabled = false;
}
async function uploadFirmware() {
if (!selectedFile) {
showToast('Please select a firmware file first', 'error');
return;
}
const uploadBtn = document.getElementById('ota-upload-btn');
uploadBtn.disabled = true;
uploadBtn.textContent = 'Uploading...';
document.getElementById('ota-progress-container').style.display = 'flex';
document.getElementById('ota-progress-bar').style.width = '0%';
document.getElementById('ota-progress-text').textContent = '0%';
try {
const formData = new FormData();
formData.append('firmware', selectedFile);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
document.getElementById('ota-progress-bar').style.width = percent + '%';
document.getElementById('ota-progress-text').textContent = percent + '%';
}
};
xhr.onload = () => {
if (xhr.status === 200) {
showToast('Firmware uploaded successfully! Device will restart...', 'success');
document.getElementById('ota-progress-bar').style.width = '100%';
document.getElementById('ota-progress-text').textContent = '100%';
} else {
let errorMsg = 'Upload failed';
try {
const result = JSON.parse(xhr.responseText);
errorMsg = result.error || errorMsg;
} catch (e) {}
showToast(errorMsg, 'error');
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload Firmware';
}
};
xhr.onerror = () => {
showToast('Upload failed - network error', 'error');
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload Firmware';
};
xhr.open('POST', `${API_BASE}/api/ota/upload`);
xhr.send(formData);
} catch (error) {
console.error('Upload error:', error);
showToast('Failed to upload firmware', 'error');
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload Firmware';
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
updateSystemInfo();
updateGPIOStates();
updatePowerStatus();
updateOTAStatus();
connectWebSocket();
// Setup drag and drop
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('ota-file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileSelect(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileSelect(e.target.files[0]);
}
});
// Initialize logs
refreshLogs();
// Periodic updates
setInterval(updateSystemInfo, 5000);
setInterval(updateGPIOStates, 2000);
setInterval(updatePowerStatus, 2000);
setInterval(updateOTAStatus, 5000);
});
// Log viewer functions
let logsAutoRefreshInterval = null;
async function refreshLogs() {
try {
const response = await fetch(`${API_BASE}/api/logs`);
const logs = await response.text();
const logsOutput = document.getElementById('logs-output');
logsOutput.innerHTML = `<pre>${escapeHtml(logs)}</pre>`;
// Scroll to bottom
logsOutput.scrollTop = logsOutput.scrollHeight;
} catch (error) {
console.error('Failed to fetch logs:', error);
document.getElementById('logs-output').innerHTML = '<pre>Failed to load logs</pre>';
}
}
async function clearLogs() {
try {
await fetch(`${API_BASE}/api/logs/clear`, { method: 'POST' });
refreshLogs();
showToast('Logs cleared', 'success');
} catch (error) {
console.error('Failed to clear logs:', error);
showToast('Failed to clear logs', 'error');
}
}
function toggleAutoRefreshLogs() {
const checkbox = document.getElementById('auto-refresh-logs');
if (checkbox.checked) {
logsAutoRefreshInterval = setInterval(refreshLogs, 2000);
} else {
if (logsAutoRefreshInterval) {
clearInterval(logsAutoRefreshInterval);
logsAutoRefreshInterval = null;
}
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>