24
page/.gitignore
vendored
Normal file
24
page/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
319
page/index.html
Normal file
319
page/index.html
Normal file
@@ -0,0 +1,319 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ODROID Remote</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<main class="container">
|
||||
<header class="d-flex justify-content-between align-items-center mb-3 main-header">
|
||||
<div class="order-md-1" style="flex: 1;">
|
||||
<div class="d-flex align-items-center">
|
||||
<span id="wifi-status" class="d-flex align-items-center text-muted">
|
||||
<i id="wifi-icon" class="bi bi-wifi-off me-2"></i>
|
||||
<span id="wifi-ssid-status">Disconnected</span>
|
||||
</span>
|
||||
<span id="websocket-status" class="d-flex align-items-center text-danger ms-3">
|
||||
<i id="websocket-icon" class="bi bi-x-circle-fill me-2"></i>
|
||||
<span id="websocket-status-text">Offline</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-monospace mt-1 d-none d-md-inline">
|
||||
<span id="voltage-display" class="text-primary">--.-- V</span> |
|
||||
<span id="current-display" class="text-primary">--.-- A</span> |
|
||||
<span id="power-display" class="text-primary">--.-- W</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-primary text-center order-md-2 mx-auto">ODROID Power Mate</h1>
|
||||
<div class="d-flex align-items-center justify-content-end order-md-3 header-controls" style="flex: 1;">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="theme-toggle">
|
||||
<label class="form-check-label" for="theme-toggle"><i id="theme-icon" class="bi bi-moon-stars-fill"></i></label>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary ms-3" id="settings-button" data-bs-toggle="modal"
|
||||
data-bs-target="#settingsModal">
|
||||
<i class="bi bi-gear"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="nav nav-tabs" id="main-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="terminal-tab-btn" data-bs-toggle="tab"
|
||||
data-bs-target="#terminal-tab-pane" type="button" role="tab">Terminal
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="graph-tab-btn" data-bs-toggle="tab" data-bs-target="#graph-tab-pane"
|
||||
type="button" role="tab">Metrics
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="myTabContent">
|
||||
<div class="tab-pane fade show active" id="terminal-tab-pane" role="tabpanel">
|
||||
<div class="card border-top-0 rounded-0 rounded-bottom">
|
||||
<div class="card-body">
|
||||
<div id="terminal-wrapper">
|
||||
<div id="terminal-container" class="border rounded">
|
||||
<div id="terminal"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button id="download-button" class="btn btn-primary me-2"><i class="bi bi-download me-1"></i>Download Log</button>
|
||||
<button id="clear-button" class="btn btn-secondary">Clear Terminal</button>
|
||||
</div>
|
||||
|
||||
<!-- Moved from Control Tab -->
|
||||
<div class="mt-4">
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center control-list-item">
|
||||
Main Power (12V)
|
||||
<div class="control-wrapper">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="main-power-toggle">
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center control-list-item">
|
||||
USB Power (5V)
|
||||
<div class="control-wrapper">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="usb-power-toggle">
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center control-list-item">
|
||||
Power Actions
|
||||
<div class="control-wrapper">
|
||||
<button id="reset-button" class="btn btn-secondary">Reset</button>
|
||||
<button id="power-action-button" class="btn btn-danger ms-2">Power</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
System Uptime
|
||||
<div class="control-wrapper">
|
||||
<span id="uptime-display" class="font-monospace text-success">--:--:--</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="graph-tab-pane" role="tabpanel">
|
||||
<div class="card border-top-0 rounded-0 rounded-bottom">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<a href="/datalog.csv" class="btn btn-primary" download="datalog.csv"><i class="bi bi-download me-1"></i> Download CSV</a>
|
||||
</div>
|
||||
<h5 class="card-title text-center mb-3">Power Input</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3 mb-md-0">
|
||||
<canvas id="powerChart" class="chart-canvas"></canvas>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3 mb-md-0">
|
||||
<canvas id="voltageChart" class="chart-canvas"></canvas>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<canvas id="currentChart" class="chart-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="bg-body-tertiary text-center p-3">
|
||||
<a href="https://www.hardkernel.com/" target="_blank" class="link-secondary">Hardkernel</a> |
|
||||
<a href="https://wiki.odroid.com/start" target="_blank" class="link-secondary">Wiki</a>
|
||||
</footer>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="settingsModalLabel">Settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="nav nav-tabs" id="settingsTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="wifi-settings-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#wifi-settings-pane" type="button" role="tab">Wi-Fi
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="network-settings-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#network-settings-pane" type="button" role="tab">Network
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="ap-mode-settings-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#ap-mode-settings-pane" type="button" role="tab">AP Mode
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="device-settings-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#device-settings-pane" type="button" role="tab">Device
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content pt-3" id="settingsTabContent">
|
||||
<div class="tab-pane fade show active" id="wifi-settings-pane" role="tabpanel">
|
||||
<div class="alert alert-danger mt-3" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>Warning:</strong> Disconnecting or connecting to a new network will restart the Wi-Fi service. You may need to reconnect to the device.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<h6>Current Connection</h6>
|
||||
<div class="d-flex justify-content-between align-items-center p-2 rounded bg-body-secondary">
|
||||
<div>
|
||||
<strong>Connected to: </strong><span id="current-wifi-ssid">MyHome_WiFi</span><br>
|
||||
<small class="text-muted" id="current-wifi-ip">IP Address: -</small>
|
||||
</div>
|
||||
<!-- <button class="btn btn-warning btn-sm" id="wifi-disconnect-button">Disconnect</button>-->
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<h6>Available Wi-Fi Networks</h6>
|
||||
<div class="table-responsive" style="max-height: 200px;">
|
||||
<table class="table table-hover table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">SSID</th>
|
||||
<th scope="col">Signal</th>
|
||||
<th scope="col">Security</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="wifi-ap-list">
|
||||
<tr><td colspan="3" class="text-center text-muted">Click 'Scan' to find networks.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end pt-3 border-top mt-3">
|
||||
<button type="button" class="btn btn-primary me-2" id="scan-wifi-button">Scan</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="network-settings-pane" role="tabpanel">
|
||||
<div class="alert alert-danger mt-3" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>Warning:</strong> Applying these settings will restart the Wi-Fi service. You may need to reconnect to the device.
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="static-ip-toggle">
|
||||
<label class="form-check-label" for="static-ip-toggle">Use Static IP</label>
|
||||
</div>
|
||||
<div id="static-ip-config" style="display: none;">
|
||||
<div class="mb-3">
|
||||
<label for="ip-address" class="form-label">IP Address</label>
|
||||
<input type="text" class="form-control" id="ip-address" placeholder="192.168.0.100">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="gateway" class="form-label">Gateway</label>
|
||||
<input type="text" class="form-control" id="gateway" placeholder="192.168.0.1">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="netmask" class="form-label">Netmask</label>
|
||||
<input type="text" class="form-control" id="netmask" placeholder="255.255.255.0">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="dns1" class="form-label">DNS Server</label>
|
||||
<input type="text" class="form-control" id="dns1" placeholder="8.8.8.8">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="dns2" class="form-label">Secondary DNS Server</label>
|
||||
<input type="text" class="form-control" id="dns2" placeholder="8.8.4.4">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end pt-3 border-top mt-3">
|
||||
<button type="button" class="btn btn-primary me-2" id="network-apply-button">Apply</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="ap-mode-settings-pane" role="tabpanel">
|
||||
<div class="alert alert-danger mt-3" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>Warning:</strong> Applying these settings will restart the Wi-Fi service. You may need to reconnect to the device.
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="ap-mode-toggle">
|
||||
<label class="form-check-label" for="ap-mode-toggle">Enable AP+STA Mode</label>
|
||||
</div>
|
||||
<p class="text-muted small">Disable to use Station (STA) mode only. Enable to use Access Point + Station (APSTA) mode, allowing other devices to connect to this one.</p>
|
||||
<div id="ap-mode-config" style="display: none;">
|
||||
<div class="mb-3">
|
||||
<label for="ap-ssid" class="form-label">AP SSID</label>
|
||||
<input type="text" class="form-control" id="ap-ssid" placeholder="ODROID-Remote-AP">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ap-password" class="form-label">AP Password</label>
|
||||
<input type="password" class="form-control" id="ap-password" placeholder="Leave blank for open network">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end pt-3 border-top mt-3">
|
||||
<button type="button" class="btn btn-primary me-2" id="ap-mode-apply-button">Apply</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="device-settings-pane" role="tabpanel">
|
||||
<div class="mb-3">
|
||||
<label for="baud-rate-select" class="form-label">UART Baud Rate</label>
|
||||
<select class="form-select" id="baud-rate-select">
|
||||
<option value="9600">9600</option>
|
||||
<option value="19200">19200</option>
|
||||
<option value="38400">38400</option>
|
||||
<option value="57600">57600</option>
|
||||
<option value="115200">115200</option>
|
||||
<option value="230400">230400</option>
|
||||
<option value="460800">460800</option>
|
||||
<option value="921600">921600</option>
|
||||
<option value="1500000" selected>1500000</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end pt-3 border-top mt-3">
|
||||
<button type="button" class="btn btn-primary me-2" id="baud-rate-apply-button">Apply</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wi-Fi Connection Modal -->
|
||||
<div class="modal fade" id="wifiModal" tabindex="-1" aria-labelledby="wifiModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="wifiModalLabel">Connect to Wi-Fi</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="wifi-ssid-connect" class="form-label">SSID</label>
|
||||
<input type="text" class="form-control" id="wifi-ssid-connect" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="wifi-password-connect" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="wifi-password-connect">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="wifi-connect-button">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
1283
page/package-lock.json
generated
Normal file
1283
page/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
page/package.json
Normal file
23
page/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "odroid-remote-page",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.0.4",
|
||||
"vite-plugin-singlefile": "^2.0.1",
|
||||
"vite-plugin-compression": "^0.5.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"@xterm/addon-fit": "^0.9.0",
|
||||
"chart.js": "^4.4.3"
|
||||
}
|
||||
}
|
||||
112
page/src/api.js
Normal file
112
page/src/api.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @file api.js
|
||||
* @description This module centralizes all API calls to the server's RESTful endpoints.
|
||||
* It abstracts the fetch logic, error handling, and JSON parsing for network and control operations.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches the list of available Wi-Fi networks from the server.
|
||||
* @returns {Promise<Array<Object>>} A promise that resolves to an array of Wi-Fi access point objects.
|
||||
* @throws {Error} Throws an error if the network request fails.
|
||||
*/
|
||||
export async function fetchWifiScan() {
|
||||
const response = await fetch('/api/wifi/scan');
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to connect to a specific Wi-Fi network.
|
||||
* @param {string} ssid The SSID of the network to connect to.
|
||||
* @param {string} password The password for the network.
|
||||
* @returns {Promise<Object>} A promise that resolves to the server's JSON response.
|
||||
* @throws {Error} Throws an error if the connection request fails.
|
||||
*/
|
||||
export async function postWifiConnect(ssid, password) {
|
||||
const response = await fetch('/api/setting', { // Updated URL
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ssid, password }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `Connection failed with status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts updated network settings (e.g., static IP, DHCP, AP mode) to the server.
|
||||
* @param {Object} payload The settings object to be sent.
|
||||
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
|
||||
* @throws {Error} Throws an error if the request fails.
|
||||
*/
|
||||
export async function postNetworkSettings(payload) {
|
||||
const response = await fetch('/api/setting', { // Updated URL
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `Failed to apply settings with status: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the selected UART baud rate to the server.
|
||||
* @param {string} baudrate The selected baud rate.
|
||||
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
|
||||
* @throws {Error} Throws an error if the request fails.
|
||||
*/
|
||||
export async function postBaudRateSetting(baudrate) {
|
||||
const response = await fetch('/api/setting', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baudrate }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `Failed to apply baudrate with status: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current network settings and Wi-Fi status from the server.
|
||||
* @returns {Promise<Object>} A promise that resolves to an object containing the current settings.
|
||||
* @throws {Error} Throws an error if the network request fails.
|
||||
*/
|
||||
export async function fetchSettings() {
|
||||
const response = await fetch('/api/setting'); // Updated URL
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current status of the power control relays (12V and 5V).
|
||||
* @returns {Promise<Object>} A promise that resolves to an object with the power status.
|
||||
* @throws {Error} Throws an error if the network request fails.
|
||||
*/
|
||||
export async function fetchControlStatus() {
|
||||
const response = await fetch('/api/control');
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a command to the server to control power functions (e.g., toggle relays, trigger reset).
|
||||
* @param {Object} command The control command object.
|
||||
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
|
||||
* @throws {Error} Throws an error if the request fails.
|
||||
*/
|
||||
export async function postControlCommand(command) {
|
||||
const response = await fetch('/api/control', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(command)
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return response;
|
||||
}
|
||||
211
page/src/chart.js
Normal file
211
page/src/chart.js
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @file chart.js
|
||||
* @description This module manages the Chart.js instances for visualizing sensor data.
|
||||
* It handles initialization, theme updates, data updates, and resizing for the three separate charts.
|
||||
*/
|
||||
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import { powerChartCtx, voltageChartCtx, currentChartCtx, htmlEl, graphTabPane } from './dom.js';
|
||||
|
||||
// Register all necessary Chart.js components
|
||||
Chart.register(...registerables);
|
||||
|
||||
// Store chart instances in an object
|
||||
export const charts = {
|
||||
power: null,
|
||||
voltage: null,
|
||||
current: null
|
||||
};
|
||||
|
||||
const CHART_DATA_POINTS = 30; // Number of data points to display on the chart
|
||||
|
||||
/**
|
||||
* Creates an array of empty labels for initial chart rendering.
|
||||
* @returns {Array<string>} An array of empty strings.
|
||||
*/
|
||||
function initialLabels() {
|
||||
return Array(CHART_DATA_POINTS).fill('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an array of null data points for initial chart rendering.
|
||||
* @returns {Array<null>} An array of nulls.
|
||||
*/
|
||||
function initialData() {
|
||||
return Array(CHART_DATA_POINTS).fill(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a common configuration object for a single line chart.
|
||||
* @param {string} title - The title of the chart (e.g., 'Power (W)').
|
||||
* @returns {Object} A Chart.js options object.
|
||||
*/
|
||||
function createChartOptions(title) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: true, text: title }
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { autoSkipPadding: 10, maxRotation: 0, minRotation: 0 } },
|
||||
y: { }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all three charts (Power, Voltage, Current).
|
||||
* If chart instances already exist, they are destroyed and new ones are created.
|
||||
*/
|
||||
export function initCharts() {
|
||||
// Destroy existing charts if they exist
|
||||
for (const key in charts) {
|
||||
if (charts[key]) {
|
||||
charts[key].destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Create Power Chart
|
||||
if (powerChartCtx) {
|
||||
charts.power = new Chart(powerChartCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: initialLabels(),
|
||||
datasets: [
|
||||
{ label: 'Power (W)', data: initialData(), borderWidth: 2, fill: false, tension: 0.2, pointRadius: 2 },
|
||||
{ label: 'Avg Power', data: initialData(), borderWidth: 1.5, borderDash: [10, 5], fill: false, tension: 0, pointRadius: 0 }
|
||||
]
|
||||
},
|
||||
options: createChartOptions('Power')
|
||||
});
|
||||
}
|
||||
|
||||
// Create Voltage Chart
|
||||
if (voltageChartCtx) {
|
||||
charts.voltage = new Chart(voltageChartCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: initialLabels(),
|
||||
datasets: [
|
||||
{ label: 'Voltage (V)', data: initialData(), borderWidth: 2, fill: false, tension: 0.2, pointRadius: 2 },
|
||||
{ label: 'Avg Voltage', data: initialData(), borderWidth: 1.5, borderDash: [10, 5], fill: false, tension: 0, pointRadius: 0 }
|
||||
]
|
||||
},
|
||||
options: createChartOptions('Voltage')
|
||||
});
|
||||
}
|
||||
|
||||
// Create Current Chart
|
||||
if (currentChartCtx) {
|
||||
charts.current = new Chart(currentChartCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: initialLabels(),
|
||||
datasets: [
|
||||
{ label: 'Current (A)', data: initialData(), borderWidth: 2, fill: false, tension: 0.2, pointRadius: 2 },
|
||||
{ label: 'Avg Current', data: initialData(), borderWidth: 1.5, borderDash: [10, 5], fill: false, tension: 0, pointRadius: 0 }
|
||||
]
|
||||
},
|
||||
options: createChartOptions('Current')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a new theme (light or dark) to all charts.
|
||||
* @param {string} themeName - The name of the theme to apply ('light' or 'dark').
|
||||
*/
|
||||
export function applyChartsTheme(themeName) {
|
||||
const isDark = themeName === 'dark';
|
||||
const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
const labelColor = isDark ? '#dee2e6' : '#212529';
|
||||
|
||||
const powerColor = getComputedStyle(htmlEl).getPropertyValue('--chart-power-color');
|
||||
const voltageColor = getComputedStyle(htmlEl).getPropertyValue('--chart-voltage-color');
|
||||
const currentColor = getComputedStyle(htmlEl).getPropertyValue('--chart-current-color');
|
||||
|
||||
const updateThemeForChart = (chart, color) => {
|
||||
if (!chart) return;
|
||||
chart.options.scales.x.grid.color = gridColor;
|
||||
chart.options.scales.y.grid.color = gridColor;
|
||||
chart.options.scales.x.ticks.color = labelColor;
|
||||
chart.options.scales.y.ticks.color = labelColor;
|
||||
chart.options.plugins.legend.labels.color = labelColor;
|
||||
chart.options.plugins.title.color = labelColor;
|
||||
chart.data.datasets[0].borderColor = color;
|
||||
chart.data.datasets[1].borderColor = color;
|
||||
chart.data.datasets[1].borderDash = [10, 5];
|
||||
chart.update('none');
|
||||
};
|
||||
|
||||
updateThemeForChart(charts.power, powerColor);
|
||||
updateThemeForChart(charts.voltage, voltageColor);
|
||||
updateThemeForChart(charts.current, currentColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all charts with new sensor data.
|
||||
* @param {Object} data - The new sensor data object from the WebSocket.
|
||||
*/
|
||||
export function updateCharts(data) {
|
||||
const timeLabel = new Date(data.timestamp * 1000).toLocaleTimeString();
|
||||
|
||||
const updateSingleChart = (chart, value) => {
|
||||
if (!chart) return;
|
||||
|
||||
// Shift old data
|
||||
chart.data.labels.shift();
|
||||
chart.data.datasets.forEach(dataset => dataset.data.shift());
|
||||
|
||||
// Push new data
|
||||
chart.data.labels.push(timeLabel);
|
||||
chart.data.datasets[0].data.push(value.toFixed(2));
|
||||
|
||||
// Calculate average and adjust Y-axis scale
|
||||
const dataArray = chart.data.datasets[0].data.filter(v => v !== null).map(v => parseFloat(v));
|
||||
if (dataArray.length > 0) {
|
||||
const sum = dataArray.reduce((acc, val) => acc + val, 0);
|
||||
const avg = (sum / dataArray.length).toFixed(2);
|
||||
chart.data.datasets[1].data.push(avg);
|
||||
|
||||
// Adjust Y-axis scale for centering
|
||||
const minVal = Math.min(...dataArray);
|
||||
const maxVal = Math.max(...dataArray);
|
||||
let padding = (maxVal - minVal) * 0.1; // 10% padding of the range
|
||||
|
||||
if (padding === 0) {
|
||||
// If all values are the same, add 10% padding of the value itself, or a small default
|
||||
padding = maxVal > 0 ? maxVal * 0.1 : 0.1;
|
||||
}
|
||||
|
||||
chart.options.scales.y.min = Math.max(0, minVal - padding);
|
||||
chart.options.scales.y.max = maxVal + padding;
|
||||
|
||||
} else {
|
||||
chart.data.datasets[1].data.push(null);
|
||||
}
|
||||
|
||||
// Only update the chart if the tab is visible
|
||||
if (graphTabPane.classList.contains('show')) {
|
||||
chart.update('none');
|
||||
}
|
||||
};
|
||||
|
||||
updateSingleChart(charts.power, data.power);
|
||||
updateSingleChart(charts.voltage, data.voltage);
|
||||
updateSingleChart(charts.current, data.current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes all chart canvases. This is typically called on window resize events.
|
||||
*/
|
||||
export function resizeCharts() {
|
||||
for (const key in charts) {
|
||||
if (charts[key]) {
|
||||
charts[key].resize();
|
||||
}
|
||||
}
|
||||
}
|
||||
78
page/src/dom.js
Normal file
78
page/src/dom.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file dom.js
|
||||
* @description This module selects and exports all necessary DOM elements for the application.
|
||||
* This centralizes DOM access, making it easier to manage and modify element selectors.
|
||||
*/
|
||||
|
||||
// --- Theme Elements ---
|
||||
export const themeToggle = document.getElementById('theme-toggle');
|
||||
export const themeIcon = document.getElementById('theme-icon');
|
||||
export const htmlEl = document.documentElement;
|
||||
|
||||
// --- Control & Status Elements ---
|
||||
export const mainPowerToggle = document.getElementById('main-power-toggle');
|
||||
export const usbPowerToggle = document.getElementById('usb-power-toggle');
|
||||
export const resetButton = document.getElementById('reset-button');
|
||||
export const powerActionButton = document.getElementById('power-action-button');
|
||||
export const voltageDisplay = document.getElementById('voltage-display');
|
||||
export const currentDisplay = document.getElementById('current-display');
|
||||
export const powerDisplay = document.getElementById('power-display');
|
||||
export const uptimeDisplay = document.getElementById('uptime-display');
|
||||
|
||||
// --- Terminal Elements ---
|
||||
export const terminalContainer = document.getElementById('terminal');
|
||||
export const clearButton = document.getElementById('clear-button');
|
||||
export const downloadButton = document.getElementById('download-button');
|
||||
|
||||
// --- Chart & Graph Elements ---
|
||||
export const graphTabPane = document.getElementById('graph-tab-pane');
|
||||
export const powerChartCtx = document.getElementById('powerChart')?.getContext('2d');
|
||||
export const voltageChartCtx = document.getElementById('voltageChart')?.getContext('2d');
|
||||
export const currentChartCtx = document.getElementById('currentChart')?.getContext('2d');
|
||||
|
||||
// --- WebSocket Status Elements ---
|
||||
export const websocketStatus = document.getElementById('websocket-status');
|
||||
export const websocketIcon = document.getElementById('websocket-icon');
|
||||
export const websocketStatusText = document.getElementById('websocket-status-text');
|
||||
|
||||
// --- Header Wi-Fi Status ---
|
||||
export const wifiStatus = document.getElementById('wifi-status');
|
||||
export const wifiIcon = document.getElementById('wifi-icon');
|
||||
export const wifiSsidStatus = document.getElementById('wifi-ssid-status');
|
||||
|
||||
// --- Settings Modal & General ---
|
||||
export const settingsButton = document.getElementById('settings-button');
|
||||
export const settingsModal = document.getElementById('settingsModal');
|
||||
|
||||
// --- Wi-Fi Settings Elements ---
|
||||
export const wifiApList = document.getElementById('wifi-ap-list');
|
||||
export const scanWifiButton = document.getElementById('scan-wifi-button');
|
||||
export const currentWifiSsid = document.getElementById('current-wifi-ssid');
|
||||
export const currentWifiIp = document.getElementById('current-wifi-ip');
|
||||
|
||||
// --- Wi-Fi Connection Modal Elements ---
|
||||
export const wifiModalEl = document.getElementById('wifiModal');
|
||||
export const wifiSsidConnectInput = document.getElementById('wifi-ssid-connect');
|
||||
export const wifiPasswordConnectInput = document.getElementById('wifi-password-connect');
|
||||
export const wifiConnectButton = document.getElementById('wifi-connect-button');
|
||||
|
||||
// --- Static IP Config Elements ---
|
||||
export const staticIpToggle = document.getElementById('static-ip-toggle');
|
||||
export const staticIpConfig = document.getElementById('static-ip-config');
|
||||
export const networkApplyButton = document.getElementById('network-apply-button');
|
||||
export const staticIpInput = document.getElementById('ip-address');
|
||||
export const staticGatewayInput = document.getElementById('gateway');
|
||||
export const staticNetmaskInput = document.getElementById('netmask');
|
||||
export const dns1Input = document.getElementById('dns1');
|
||||
export const dns2Input = document.getElementById('dns2');
|
||||
|
||||
// --- AP Mode Config Elements ---
|
||||
export const apModeToggle = document.getElementById('ap-mode-toggle');
|
||||
export const apModeConfig = document.getElementById('ap-mode-config');
|
||||
export const apModeApplyButton = document.getElementById('ap-mode-apply-button');
|
||||
export const apSsidInput = document.getElementById('ap-ssid');
|
||||
export const apPasswordInput = document.getElementById('ap-password');
|
||||
|
||||
// --- Device Settings Elements ---
|
||||
export const baudRateSelect = document.getElementById('baud-rate-select');
|
||||
export const baudRateApplyButton = document.getElementById('baud-rate-apply-button');
|
||||
93
page/src/events.js
Normal file
93
page/src/events.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file events.js
|
||||
* @description This module sets up all the event listeners for the application.
|
||||
* It connects user interactions (like clicks and toggles) to the corresponding
|
||||
* functions in other modules (UI, API, etc.).
|
||||
*/
|
||||
|
||||
import * as dom from './dom.js';
|
||||
import * as api from './api.js';
|
||||
import * as ui from './ui.js';
|
||||
import { clearTerminal, fitTerminal, downloadTerminalOutput } from './terminal.js';
|
||||
import { debounce, isMobile } from './utils.js';
|
||||
|
||||
// A flag to track if charts have been initialized
|
||||
let chartsInitialized = false;
|
||||
|
||||
/**
|
||||
* Sets up all event listeners for the application's interactive elements.
|
||||
*/
|
||||
export function setupEventListeners() {
|
||||
// --- Theme Toggle ---
|
||||
dom.themeToggle.addEventListener('change', () => {
|
||||
const newTheme = dom.themeToggle.checked ? 'dark' : 'light';
|
||||
localStorage.setItem('theme', newTheme);
|
||||
ui.applyTheme(newTheme);
|
||||
});
|
||||
|
||||
// --- Terminal Controls ---
|
||||
dom.clearButton.addEventListener('click', clearTerminal);
|
||||
dom.downloadButton.addEventListener('click', downloadTerminalOutput);
|
||||
|
||||
// --- Power Controls ---
|
||||
dom.mainPowerToggle.addEventListener('change', () => api.postControlCommand({'load_12v_on': dom.mainPowerToggle.checked}).then(ui.updateControlStatus));
|
||||
dom.usbPowerToggle.addEventListener('change', () => api.postControlCommand({'load_5v_on': dom.usbPowerToggle.checked}).then(ui.updateControlStatus));
|
||||
dom.resetButton.addEventListener('click', () => api.postControlCommand({'reset_trigger': true}));
|
||||
dom.powerActionButton.addEventListener('click', () => api.postControlCommand({'power_trigger': true}));
|
||||
|
||||
// --- Settings Modal Controls ---
|
||||
dom.scanWifiButton.addEventListener('click', ui.scanForWifi);
|
||||
dom.wifiConnectButton.addEventListener('click', ui.connectToWifi);
|
||||
dom.networkApplyButton.addEventListener('click', ui.applyNetworkSettings);
|
||||
dom.apModeApplyButton.addEventListener('click', ui.applyApModeSettings);
|
||||
dom.baudRateApplyButton.addEventListener('click', ui.applyBaudRateSettings);
|
||||
|
||||
// --- Settings Modal Toggles (for showing/hiding sections) ---
|
||||
dom.apModeToggle.addEventListener('change', () => {
|
||||
dom.apModeConfig.style.display = dom.apModeToggle.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
dom.staticIpToggle.addEventListener('change', () => {
|
||||
dom.staticIpConfig.style.display = dom.staticIpToggle.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// --- General App Listeners ---
|
||||
dom.settingsButton.addEventListener('click', ui.initializeSettings);
|
||||
|
||||
// --- Accessibility: Remove focus from modal elements before hiding ---
|
||||
const blurActiveElement = () => {
|
||||
if (document.activeElement && typeof document.activeElement.blur === 'function') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
dom.settingsModal.addEventListener('hide.bs.modal', blurActiveElement);
|
||||
dom.wifiModalEl.addEventListener('hide.bs.modal', blurActiveElement);
|
||||
|
||||
// --- Bootstrap Tab Events ---
|
||||
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(tabEl => {
|
||||
tabEl.addEventListener('shown.bs.tab', async (event) => {
|
||||
const tabId = event.target.getAttribute('data-bs-target');
|
||||
|
||||
if (tabId === '#graph-tab-pane') {
|
||||
// Dynamically import the chart module only when the tab is shown
|
||||
const chartModule = await import('./chart.js');
|
||||
|
||||
if (!chartsInitialized) {
|
||||
chartModule.initCharts();
|
||||
chartsInitialized = true;
|
||||
} else {
|
||||
chartModule.resizeCharts();
|
||||
}
|
||||
} else if (tabId === '#terminal-tab-pane') {
|
||||
// Fit the terminal when its tab is shown, especially for mobile.
|
||||
if (isMobile()) {
|
||||
fitTerminal();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Window Resize Event ---
|
||||
// Debounced to avoid excessive calls during resizing.
|
||||
window.addEventListener('resize', debounce(ui.handleResize, 150));
|
||||
}
|
||||
109
page/src/main.js
Normal file
109
page/src/main.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file main.js
|
||||
* @description The main entry point for the web application.
|
||||
* This file imports all necessary modules, sets up the application structure,
|
||||
* initializes components, and establishes the WebSocket connection.
|
||||
*/
|
||||
|
||||
// --- Stylesheets ---
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
import './style.css';
|
||||
|
||||
// --- Module Imports ---
|
||||
import { initWebSocket } from './websocket.js';
|
||||
import { setupTerminal, term } from './terminal.js';
|
||||
import {
|
||||
applyTheme,
|
||||
initUI,
|
||||
updateControlStatus,
|
||||
updateSensorUI,
|
||||
updateWifiStatusUI,
|
||||
updateWebsocketStatus
|
||||
} from './ui.js';
|
||||
import { setupEventListeners } from './events.js';
|
||||
|
||||
// --- WebSocket Event Handlers ---
|
||||
|
||||
/**
|
||||
* Callback function for when the WebSocket connection is successfully opened.
|
||||
* Updates the UI to show an 'Online' status and fetches the initial control status.
|
||||
*/
|
||||
function onWsOpen() {
|
||||
updateWebsocketStatus(true);
|
||||
if (term) {
|
||||
term.write('\x1b[32mConnected to WebSocket Server\x1b[0m\r\n');
|
||||
}
|
||||
updateControlStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for when the WebSocket connection is closed.
|
||||
* Updates the UI to show an 'Offline' status and attempts to reconnect after a delay.
|
||||
*/
|
||||
function onWsClose() {
|
||||
updateWebsocketStatus(false);
|
||||
if (term) {
|
||||
term.write('\r\n\x1b[31mConnection closed. Reconnecting...\x1b[0m\r\n');
|
||||
}
|
||||
// Attempt to re-establish the connection after 2 seconds
|
||||
setTimeout(initialize, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for when a message is received from the WebSocket server.
|
||||
* It handles both JSON messages (for sensor and status updates) and binary data (for the terminal).
|
||||
* @param {MessageEvent} event - The WebSocket message event.
|
||||
*/
|
||||
function onWsMessage(event) {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'sensor_data') {
|
||||
updateSensorUI(message);
|
||||
} else if (message.type === 'wifi_status') {
|
||||
updateWifiStatusUI(message);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore non-JSON string messages
|
||||
}
|
||||
} else if (term && event.data instanceof ArrayBuffer) {
|
||||
// Write raw UART data to the terminal
|
||||
const data = new Uint8Array(event.data);
|
||||
term.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Application Initialization ---
|
||||
|
||||
/**
|
||||
* Initializes the entire application.
|
||||
* This function sets up the UI, theme, terminal, chart, WebSocket connection, and event listeners.
|
||||
*/
|
||||
function initialize() {
|
||||
// Initialize basic UI components
|
||||
initUI();
|
||||
|
||||
// Set up the interactive components first
|
||||
setupTerminal();
|
||||
|
||||
// Apply the saved theme or detect the user's preferred theme.
|
||||
// This must be done AFTER the chart and terminal are initialized.
|
||||
const savedTheme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
applyTheme(savedTheme);
|
||||
|
||||
// Establish the WebSocket connection with the defined handlers
|
||||
initWebSocket({
|
||||
onOpen: onWsOpen,
|
||||
onClose: onWsClose,
|
||||
onMessage: onWsMessage
|
||||
});
|
||||
|
||||
// Attach all event listeners to the DOM elements
|
||||
setupEventListeners();
|
||||
}
|
||||
|
||||
// --- Start Application ---
|
||||
|
||||
// Wait for the DOM to be fully loaded before initializing the application.
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
153
page/src/style.css
Normal file
153
page/src/style.css
Normal file
@@ -0,0 +1,153 @@
|
||||
|
||||
:root {
|
||||
--bs-body-font-family: 'Courier New', Courier, monospace;
|
||||
--chart-power-color: #007bff;
|
||||
--chart-voltage-color: #28a745;
|
||||
--chart-current-color: #ffc107;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--chart-power-color: #569cd6;
|
||||
--chart-voltage-color: #4ec9b0;
|
||||
--chart-current-color: #dcdcaa;
|
||||
}
|
||||
|
||||
body, .card, .modal-content, .list-group-item, .nav-tabs .nav-link {
|
||||
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
main {
|
||||
flex-grow: 1;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
#terminal-tab-pane .card-body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#terminal-container {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bs-body-bg);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#terminal-wrapper {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
/* Removed height: 100% to allow shrink-wrapping */
|
||||
}
|
||||
|
||||
#graph-tab-pane .card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center; /* Vertically center the chart content */
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.xterm-viewport::-webkit-scrollbar-track {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
border-radius: 10px;
|
||||
border: 3px solid var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--bs-secondary-bg) var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
height: 30rem !important;
|
||||
}
|
||||
|
||||
.control-wrapper {
|
||||
min-height: 38px; /* Match button height for alignment */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.control-wrapper .form-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
footer {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
footer a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wifi-ap-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 767.98px) {
|
||||
.main-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
width: 100%;
|
||||
justify-content: space-between !important;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.main-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.control-list-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.control-list-item > .control-wrapper {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.control-list-item > .control-wrapper > .btn {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#terminal-wrapper, #terminal-container {
|
||||
width: 100%;
|
||||
/* Removed height: 100% */
|
||||
}
|
||||
|
||||
#terminal-tab-pane .card-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
135
page/src/terminal.js
Normal file
135
page/src/terminal.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file terminal.js
|
||||
* @description This module manages the Xterm.js terminal instance, including setup,
|
||||
* theme handling, and data communication with the WebSocket.
|
||||
*/
|
||||
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { terminalContainer } from './dom.js';
|
||||
import { isMobile } from './utils.js';
|
||||
import { websocket, sendWebsocketMessage } from './websocket.js';
|
||||
|
||||
// Exported terminal instance and addon for global access
|
||||
export let term;
|
||||
export let fitAddon;
|
||||
|
||||
// Theme definitions for the terminal
|
||||
const lightTheme = {
|
||||
background: 'transparent',
|
||||
foreground: '#212529',
|
||||
cursor: '#212529',
|
||||
selectionBackground: 'rgba(0,0,0,0.1)'
|
||||
};
|
||||
|
||||
const darkTheme = {
|
||||
background: 'transparent',
|
||||
foreground: '#dee2e6',
|
||||
cursor: '#dee2e6',
|
||||
selectionBackground: 'rgba(255,255,255,0.1)'
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes the Xterm.js terminal, loads addons, and attaches it to the DOM.
|
||||
* Sets up the initial size and the data handler for sending input to the WebSocket.
|
||||
*/
|
||||
export function setupTerminal() {
|
||||
// Ensure the container is empty before creating a new terminal
|
||||
while (terminalContainer.firstChild) {
|
||||
terminalContainer.removeChild(terminalContainer.firstChild);
|
||||
}
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
term = new Terminal({ convertEol: true, cursorBlink: true });
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(terminalContainer);
|
||||
|
||||
// Adjust terminal size based on device type
|
||||
if (isMobile()) {
|
||||
fitAddon.fit();
|
||||
} else {
|
||||
term.resize(80, 24); // Default size for desktop
|
||||
}
|
||||
|
||||
// Handle user input and send it over the WebSocket
|
||||
term.onData(data => {
|
||||
sendWebsocketMessage(data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a new theme (light or dark) to the terminal.
|
||||
* @param {string} themeName - The name of the theme to apply ('light' or 'dark').
|
||||
*/
|
||||
export function applyTerminalTheme(themeName) {
|
||||
if (!term) return;
|
||||
const isDark = themeName === 'dark';
|
||||
term.options.theme = isDark ? darkTheme : lightTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the terminal screen.
|
||||
*/
|
||||
export function clearTerminal() {
|
||||
if (term) {
|
||||
term.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fits the terminal to the size of its container element.
|
||||
* Useful for responsive adjustments on window resize.
|
||||
*/
|
||||
export function fitTerminal() {
|
||||
if (fitAddon) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers all text from the terminal buffer and downloads it as a .log file.
|
||||
*/
|
||||
export function downloadTerminalOutput() {
|
||||
if (!term) {
|
||||
console.error("Terminal is not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = term.buffer.active;
|
||||
let fullText = '';
|
||||
|
||||
// Iterate through the buffer to get all lines
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line) {
|
||||
fullText += line.translateToString() + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (fullText.trim() === '') {
|
||||
console.warn("Terminal is empty, nothing to download.");
|
||||
// Optionally, provide user feedback here (e.g., a toast notification)
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a blob from the text content
|
||||
const blob = new Blob([fullText], { type: 'text/plain;charset=utf-8' });
|
||||
|
||||
// Create a link element to trigger the download
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Generate a filename with a timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
link.download = `odroid-log-${timestamp}.log`;
|
||||
link.href = url;
|
||||
|
||||
// Append to the document, click, and then remove
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up the object URL
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
346
page/src/ui.js
Normal file
346
page/src/ui.js
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* @file ui.js
|
||||
* @description This module manages all UI interactions and updates.
|
||||
* It bridges the gap between backend data (from API and WebSockets) and the user-facing components,
|
||||
* handling everything from theme changes to dynamic content updates.
|
||||
*/
|
||||
|
||||
import * as bootstrap from 'bootstrap';
|
||||
import * as dom from './dom.js';
|
||||
import * as api from './api.js';
|
||||
import { formatUptime, isMobile } from './utils.js';
|
||||
import { applyTerminalTheme, fitTerminal } from './terminal.js';
|
||||
import { applyChartsTheme, resizeCharts, updateCharts } from './chart.js';
|
||||
|
||||
// Instance of the Bootstrap Modal for Wi-Fi connection
|
||||
let wifiModal;
|
||||
|
||||
/**
|
||||
* Initializes the UI components, such as the Bootstrap modal.
|
||||
*/
|
||||
export function initUI() {
|
||||
wifiModal = new bootstrap.Modal(dom.wifiModalEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the selected theme (light or dark) to the entire application.
|
||||
* @param {string} themeName - The name of the theme ('light' or 'dark').
|
||||
*/
|
||||
export function applyTheme(themeName) {
|
||||
const isDark = themeName === 'dark';
|
||||
dom.htmlEl.setAttribute('data-bs-theme', themeName);
|
||||
dom.themeIcon.className = isDark ? 'bi bi-moon-stars-fill' : 'bi bi-sun-fill';
|
||||
dom.themeToggle.checked = isDark;
|
||||
applyTerminalTheme(themeName);
|
||||
applyChartsTheme(themeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the UI with the latest sensor data.
|
||||
* @param {Object} data - The sensor data object from the WebSocket.
|
||||
*/
|
||||
export function updateSensorUI(data) {
|
||||
dom.voltageDisplay.textContent = `${data.voltage.toFixed(2)} V`;
|
||||
dom.currentDisplay.textContent = `${data.current.toFixed(2)} A`;
|
||||
dom.powerDisplay.textContent = `${data.power.toFixed(2)} W`;
|
||||
if (data.uptime_sec !== undefined) {
|
||||
dom.uptimeDisplay.textContent = formatUptime(data.uptime_sec);
|
||||
}
|
||||
updateCharts(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the Wi-Fi status indicator in the header.
|
||||
* @param {Object} data - The Wi-Fi status object from the WebSocket.
|
||||
*/
|
||||
export function updateWifiStatusUI(data) {
|
||||
if (data.connected) {
|
||||
dom.wifiSsidStatus.textContent = data.ssid;
|
||||
dom.wifiStatus.title = `Signal Strength: ${data.rssi} dBm`;
|
||||
let iconClass = 'bi me-2 ';
|
||||
if (data.rssi >= -60) iconClass += 'bi-wifi';
|
||||
else if (data.rssi >= -75) iconClass += 'bi-wifi-2';
|
||||
else iconClass += 'bi-wifi-1';
|
||||
dom.wifiIcon.className = iconClass;
|
||||
dom.wifiStatus.classList.replace('text-muted', 'text-success');
|
||||
dom.wifiStatus.classList.remove('text-danger');
|
||||
} else {
|
||||
dom.wifiSsidStatus.textContent = 'Disconnected';
|
||||
dom.wifiStatus.title = '';
|
||||
dom.wifiIcon.className = 'bi bi-wifi-off me-2';
|
||||
dom.wifiStatus.classList.replace('text-success', 'text-muted');
|
||||
dom.wifiStatus.classList.remove('text-danger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a Wi-Fi scan and updates the settings modal with the results.
|
||||
*/
|
||||
export async function scanForWifi() {
|
||||
dom.scanWifiButton.disabled = true;
|
||||
dom.scanWifiButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Scanning...`;
|
||||
dom.wifiApList.innerHTML = '<tr><td colspan="3" class="text-center">Scanning for networks...</td></tr>';
|
||||
|
||||
try {
|
||||
const apRecords = await api.fetchWifiScan();
|
||||
dom.wifiApList.innerHTML = ''; // Clear loading message
|
||||
|
||||
if (apRecords.length === 0) {
|
||||
dom.wifiApList.innerHTML = '<tr><td colspan="3" class="text-center">No networks found.</td></tr>';
|
||||
} else {
|
||||
apRecords.forEach(ap => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'wifi-ap-row';
|
||||
|
||||
let rssiIcon;
|
||||
if (ap.rssi >= -60) rssiIcon = 'bi-wifi';
|
||||
else if (ap.rssi >= -75) rssiIcon = 'bi-wifi-2';
|
||||
else rssiIcon = 'bi-wifi-1';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${ap.ssid}</td>
|
||||
<td class="text-center"><i class="bi ${rssiIcon}"></i></td>
|
||||
<td>${ap.authmode}</td>
|
||||
`;
|
||||
|
||||
row.addEventListener('click', () => {
|
||||
dom.wifiSsidConnectInput.value = ap.ssid;
|
||||
dom.wifiPasswordConnectInput.value = '';
|
||||
wifiModal.show();
|
||||
dom.wifiModalEl.addEventListener('shown.bs.modal', () => {
|
||||
dom.wifiPasswordConnectInput.focus();
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
dom.wifiApList.appendChild(row);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scanning for Wi-Fi:', error);
|
||||
dom.wifiApList.innerHTML = `<tr><td colspan="3" class="text-center text-danger">Scan failed: ${error.message}</td></tr>`;
|
||||
} finally {
|
||||
dom.scanWifiButton.disabled = false;
|
||||
dom.scanWifiButton.innerHTML = 'Scan';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the Wi-Fi connection process, sending credentials to the server.
|
||||
*/
|
||||
export async function connectToWifi() {
|
||||
const ssid = dom.wifiSsidConnectInput.value;
|
||||
const password = dom.wifiPasswordConnectInput.value;
|
||||
if (!ssid) return;
|
||||
|
||||
dom.wifiConnectButton.disabled = true;
|
||||
dom.wifiConnectButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Connecting...`;
|
||||
|
||||
try {
|
||||
const result = await api.postWifiConnect(ssid, password);
|
||||
if (result.status === 'connection_initiated') {
|
||||
wifiModal.hide();
|
||||
setTimeout(() => {
|
||||
alert(`Connection to "${ssid}" initiated. The device will try to reconnect. Please check the Wi-Fi status icon.`);
|
||||
}, 500);
|
||||
} else {
|
||||
throw new Error(result.message || 'Unknown server response.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error connecting to Wi-Fi:', error);
|
||||
alert(`Failed to connect: ${error.message}`);
|
||||
} finally {
|
||||
dom.wifiConnectButton.disabled = false;
|
||||
dom.wifiConnectButton.innerHTML = 'Connect';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies network settings (Static IP or DHCP) by sending the configuration to the server.
|
||||
*/
|
||||
export async function applyNetworkSettings() {
|
||||
const useStatic = dom.staticIpToggle.checked;
|
||||
let payload;
|
||||
|
||||
dom.networkApplyButton.disabled = true;
|
||||
dom.networkApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
|
||||
|
||||
if (useStatic) {
|
||||
const ip = dom.staticIpInput.value;
|
||||
const gateway = dom.staticGatewayInput.value;
|
||||
const subnet = dom.staticNetmaskInput.value;
|
||||
const dns1 = dom.dns1Input.value;
|
||||
const dns2 = dom.dns2Input.value;
|
||||
|
||||
if (!ip || !gateway || !subnet || !dns1) {
|
||||
alert('For static IP, you must provide IP Address, Gateway, Netmask, and DNS Server.');
|
||||
dom.networkApplyButton.disabled = false;
|
||||
dom.networkApplyButton.innerHTML = 'Apply';
|
||||
return;
|
||||
}
|
||||
|
||||
payload = { net_type: 'static', ip, gateway, subnet, dns1 };
|
||||
if (dns2) payload.dns2 = dns2;
|
||||
} else {
|
||||
payload = { net_type: 'dhcp' };
|
||||
}
|
||||
|
||||
try {
|
||||
await api.postNetworkSettings(payload);
|
||||
alert('Network settings applied. Reconnect to the network for changes to take effect.');
|
||||
initializeSettings();
|
||||
} catch (error) {
|
||||
console.error('Error applying network settings:', error);
|
||||
alert(`Failed to apply settings: ${error.message}`);
|
||||
} finally {
|
||||
dom.networkApplyButton.disabled = false;
|
||||
dom.networkApplyButton.innerHTML = 'Apply';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies AP Mode settings (AP+STA or STA) by sending the configuration to the server.
|
||||
*/
|
||||
export async function applyApModeSettings() {
|
||||
const mode = dom.apModeToggle.checked ? 'apsta' : 'sta';
|
||||
let payload = { mode };
|
||||
|
||||
dom.apModeApplyButton.disabled = true;
|
||||
dom.apModeApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
|
||||
|
||||
if (mode === 'apsta') {
|
||||
const ap_ssid = dom.apSsidInput.value;
|
||||
const ap_password = dom.apPasswordInput.value;
|
||||
|
||||
if (!ap_ssid) {
|
||||
alert('AP SSID cannot be empty when enabling APSTA mode.');
|
||||
dom.apModeApplyButton.disabled = false;
|
||||
dom.apModeApplyButton.innerHTML = 'Apply';
|
||||
return;
|
||||
}
|
||||
payload.ap_ssid = ap_ssid;
|
||||
if (ap_password) {
|
||||
payload.ap_password = ap_password;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await api.postNetworkSettings(payload); // Reuses the same API endpoint
|
||||
alert(`Successfully switched mode to ${mode}. The device will now reconfigure.`);
|
||||
initializeSettings();
|
||||
} catch (error) {
|
||||
console.error('Error switching Wi-Fi mode:', error);
|
||||
alert(`Failed to switch mode: ${error.message}`);
|
||||
} finally {
|
||||
dom.apModeApplyButton.disabled = false;
|
||||
dom.apModeApplyButton.innerHTML = 'Apply';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the selected UART baud rate by sending it to the server.
|
||||
*/
|
||||
export async function applyBaudRateSettings() {
|
||||
const baudrate = dom.baudRateSelect.value;
|
||||
dom.baudRateApplyButton.disabled = true;
|
||||
dom.baudRateApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
|
||||
|
||||
try {
|
||||
await api.postBaudRateSetting(baudrate);
|
||||
} catch (error) {
|
||||
console.error('Error applying baud rate:', error);
|
||||
} finally {
|
||||
dom.baudRateApplyButton.disabled = false;
|
||||
dom.baudRateApplyButton.innerHTML = 'Apply';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and displays the current network and device settings in the settings modal.
|
||||
*/
|
||||
export async function initializeSettings() {
|
||||
try {
|
||||
const data = await api.fetchSettings();
|
||||
|
||||
// Wi-Fi Connection Status
|
||||
if (data.connected) {
|
||||
dom.currentWifiSsid.textContent = data.ssid;
|
||||
dom.currentWifiIp.textContent = `IP Address: ${data.ip ? data.ip.ip : 'N/A'}`;
|
||||
} else {
|
||||
dom.currentWifiSsid.textContent = 'Not Connected';
|
||||
dom.currentWifiIp.textContent = 'IP Address: -';
|
||||
}
|
||||
|
||||
// Network (Static/DHCP) Settings
|
||||
if (data.ip) {
|
||||
dom.staticIpInput.value = data.ip.ip || '';
|
||||
dom.staticGatewayInput.value = data.ip.gateway || '';
|
||||
dom.staticNetmaskInput.value = data.ip.subnet || '';
|
||||
dom.dns1Input.value = data.ip.dns1 || '';
|
||||
dom.dns2Input.value = data.ip.dns2 || '';
|
||||
}
|
||||
dom.staticIpToggle.checked = data.net_type === 'static';
|
||||
dom.staticIpConfig.style.display = dom.staticIpToggle.checked ? 'block' : 'none';
|
||||
|
||||
// AP Mode Settings
|
||||
dom.apModeToggle.checked = data.mode === 'apsta';
|
||||
dom.apModeConfig.style.display = dom.apModeToggle.checked ? 'block' : 'none';
|
||||
dom.apSsidInput.value = ''; // For security, don't pre-fill
|
||||
dom.apPasswordInput.value = '';
|
||||
|
||||
// Device Settings
|
||||
if (data.baudrate) {
|
||||
dom.baudRateSelect.value = data.baudrate;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing settings:', error);
|
||||
// Reset fields on error
|
||||
dom.currentWifiSsid.textContent = 'Status Unknown';
|
||||
dom.currentWifiIp.textContent = 'IP Address: -';
|
||||
dom.staticIpToggle.checked = false;
|
||||
dom.staticIpConfig.style.display = 'none';
|
||||
dom.apModeToggle.checked = false;
|
||||
dom.apModeConfig.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and updates the status of the power control toggles.
|
||||
*/
|
||||
export async function updateControlStatus() {
|
||||
try {
|
||||
const status = await api.fetchControlStatus();
|
||||
dom.mainPowerToggle.checked = status.load_12v_on;
|
||||
dom.usbPowerToggle.checked = status.load_5v_on;
|
||||
} catch (error) {
|
||||
console.error('Error fetching control status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles window resize events to make components responsive.
|
||||
*/
|
||||
export function handleResize() {
|
||||
if (isMobile()) {
|
||||
fitTerminal();
|
||||
}
|
||||
resizeCharts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the WebSocket connection status indicator in the header.
|
||||
* @param {boolean} isConnected - True if the WebSocket is connected, false otherwise.
|
||||
*/
|
||||
export function updateWebsocketStatus(isConnected) {
|
||||
if (isConnected) {
|
||||
dom.websocketStatusText.textContent = 'Online';
|
||||
dom.websocketIcon.className = 'bi bi-check-circle-fill me-2';
|
||||
dom.websocketStatus.classList.remove('text-danger');
|
||||
dom.websocketStatus.classList.add('text-success');
|
||||
} else {
|
||||
dom.websocketStatusText.textContent = 'Offline';
|
||||
dom.websocketIcon.className = 'bi bi-x-circle-fill me-2';
|
||||
dom.websocketStatus.classList.remove('text-success');
|
||||
dom.websocketStatus.classList.add('text-danger');
|
||||
}
|
||||
}
|
||||
44
page/src/utils.js
Normal file
44
page/src/utils.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file utils.js
|
||||
* @description Provides utility functions used throughout the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a debounced function that delays invoking `func` until after `delay` milliseconds
|
||||
* have elapsed since the last time the debounced function was invoked.
|
||||
* @param {Function} func The function to debounce.
|
||||
* @param {number} delay The number of milliseconds to delay.
|
||||
* @returns {Function} Returns the new debounced function.
|
||||
*/
|
||||
export function debounce(func, delay) {
|
||||
let timeout;
|
||||
return function (...args) {
|
||||
const context = this;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a duration in total seconds into a human-readable string (e.g., "1d 02:30:15").
|
||||
* @param {number} totalSeconds The total seconds to format.
|
||||
* @returns {string} The formatted uptime string.
|
||||
*/
|
||||
export function formatUptime(totalSeconds) {
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const pad = (num) => String(num).padStart(2, '0');
|
||||
const timeString = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
return days > 0 ? `${days}d ${timeString}` : timeString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current device is likely a mobile device based on screen width.
|
||||
* @returns {boolean} True if the window width is less than or equal to 767.98px, false otherwise.
|
||||
*/
|
||||
export function isMobile() {
|
||||
// The 767.98px breakpoint is typically used by Bootstrap for medium (md) devices.
|
||||
return window.innerWidth <= 767.98;
|
||||
}
|
||||
41
page/src/websocket.js
Normal file
41
page/src/websocket.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file websocket.js
|
||||
* @description This module handles the WebSocket connection for real-time, two-way
|
||||
* communication with the server. It provides functions to initialize the connection
|
||||
* and send messages.
|
||||
*/
|
||||
|
||||
// The WebSocket instance, exported for potential direct access if needed.
|
||||
export let websocket;
|
||||
|
||||
// The WebSocket server address, derived from the current page's hostname.
|
||||
const gateway = `ws://${window.location.hostname}/ws`;
|
||||
|
||||
/**
|
||||
* Initializes the WebSocket connection and sets up event handlers.
|
||||
* @param {Object} callbacks - An object containing callback functions for WebSocket events.
|
||||
* @param {function} callbacks.onOpen - Called when the connection is successfully opened.
|
||||
* @param {function} callbacks.onClose - Called when the connection is closed.
|
||||
* @param {function} callbacks.onMessage - Called when a message is received from the server.
|
||||
*/
|
||||
export function initWebSocket({ onOpen, onClose, onMessage }) {
|
||||
console.log(`Trying to open a WebSocket connection to ${gateway}...`);
|
||||
websocket = new WebSocket(gateway);
|
||||
// Set binary type to arraybuffer to handle raw binary data from the UART.
|
||||
websocket.binaryType = "arraybuffer";
|
||||
|
||||
// Assign event handlers from the provided callbacks
|
||||
if (onOpen) websocket.onopen = onOpen;
|
||||
if (onClose) websocket.onclose = onClose;
|
||||
if (onMessage) websocket.onmessage = onMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends data over the WebSocket connection if it is open.
|
||||
* @param {string | ArrayBuffer} data - The data to send to the server.
|
||||
*/
|
||||
export function sendWebsocketMessage(data) {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
websocket.send(data);
|
||||
}
|
||||
}
|
||||
10
page/vite.config.js
Normal file
10
page/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
viteSingleFile(),
|
||||
viteCompression(),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user