Added new codebook page

This commit is contained in:
2026-04-06 02:49:14 -04:00
parent 32a690d85b
commit 3cb34a226a
31 changed files with 4076 additions and 4067 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
.idea .idea
node_modules client/node_modules

1170
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

2
code/package.json → client/package.json Executable file → Normal file
View File

@@ -11,6 +11,8 @@
"license": "MIT", "license": "MIT",
"description": "Dashboard", "description": "Dashboard",
"dependencies": { "dependencies": {
"dotenv": "^17.4.1",
"express": "^5.2.1",
"node-gpsd": "^0.3.4" "node-gpsd": "^0.3.4"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Code Book</title>
<meta charset="UTF-8">
<style>
@media print {
@page {
margin: 0.5in;
}
.page-break {
page-break-before: always;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', monospace;
font-size: 10pt;
line-height: 1.3;
}
h1 {
font-size: 14pt;
margin-bottom: 0.5rem;
border-bottom: 2px solid #000;
padding-bottom: 0.25rem;
}
h2 {
font-size: 12pt;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.key-section {
margin-bottom: 1rem;
padding: 0.5rem;
border: 1px solid #000;
}
.key-value {
font-weight: bold;
word-break: break-all;
}
table {
border-collapse: collapse;
margin: 0.5rem 0;
}
th, td {
border: 1px solid #000;
padding: 0.15rem 0.25rem;
text-align: center;
font-size: 9pt;
}
th {
background: #ddd;
font-weight: bold;
}
.ascii-table {
width: 100%;
font-size: 8pt;
}
.otp-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
margin: 0.5rem 0;
}
.otp-column {
font-size: 9pt;
}
.otp-pair {
margin: 0.1rem 0;
text-align: center;
}
.auth-tables {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin: 1rem 0;
}
.auth-table-wrapper {
page-break-inside: avoid;
}
.auth-table-wrapper h3 {
font-size: 10pt;
margin-bottom: 0.25rem;
}
.auth-table-wrapper table {
width: 100%;
font-size: 8pt;
}
.pad-list {
font-size: 9pt;
}
.pad-item {
page-break-inside: avoid;
margin-bottom: 0.5rem;
}
.pad-cipher {
word-break: break-all;
margin-top: 0.25rem;
}
</style>
</head>
<body>
<!-- Page 1: Key & ASCII -->
<div>
<div class="key-section">
<h2>Encryption Key</h2>
<div class="key-value" id="print-key"></div>
</div>
<h2>ASCII Reference Table</h2>
<div id="ascii-print"></div>
</div>
<!-- Page 2+: OTP Pairs -->
<div class="page-break">
<h1>ONE-TIME PASSWORDS (HOTP)</h1>
<div class="otp-grid" id="otp-grid"></div>
</div>
<!-- Page 3+: Auth Tables -->
<div class="page-break">
<h1>AUTHENTICATION TABLES</h1>
<div class="auth-tables" id="auth-tables"></div>
</div>
<!-- Page 4+: Pad Keys -->
<div class="page-break">
<h1>ONE-TIME PAD</h1>
<div class="pad-list" id="pad-list"></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script src="encryption.js"></script>
<script>
function getKey() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('key') || localStorage.getItem('encryption_key') || '';
}
function renderASCIITable() {
let html = '<table class="ascii-table">';
html += '<tr><th>Dec</th><th>Hex</th><th>Char</th><th>Dec</th><th>Hex</th><th>Char</th><th>Dec</th><th>Hex</th><th>Char</th><th>Dec</th><th>Hex</th><th>Char</th></tr>';
for (let row = 0; row < 32; row++) {
html += '<tr>';
for (let col = 0; col < 4; col++) {
const code = row + (col * 32);
if (code < 128) {
const hex = code.toString(16).toUpperCase().padStart(2, '0');
let char = String.fromCharCode(code);
if (code < 32) {
const controlChars = ['NUL','SOH','STX','ETX','EOT','ENQ','ACK','BEL','BS','TAB','LF','VT','FF','CR','SO','SI',
'DLE','DC1','DC2','DC3','DC4','NAK','SYN','ETB','CAN','EM','SUB','ESC','FS','GS','RS','US'];
char = controlChars[code];
} else if (code === 32) {
char = 'SP';
} else if (code === 127) {
char = 'DEL';
}
html += `<td>${code}</td><td>${hex}</td><td><b>${char}</b></td>`;
}
}
html += '</tr>';
}
html += '</table>';
document.getElementById('ascii-print').innerHTML = html;
}
function renderOTPPairs(key) {
const columns = [[], [], [], [], []];
for (let i = 0; i < 250; i++) {
const nonce = i.toString().padStart(3, '0');
const code = generateHOTP(key, nonce);
const pair = `${nonce} ${code}`;
columns[Math.floor(i / 50)].push(pair);
}
let html = '';
for (let col of columns) {
html += '<div class="otp-column">';
for (let pair of col) {
html += `<div class="otp-pair">${pair}</div>`;
}
html += '</div>';
}
document.getElementById('otp-grid').innerHTML = html;
}
function renderAuthTables(key) {
let html = '';
const rows = 'NOPQRSTUVWXYZ'.split('');
const cols = 'ABCDEFGHIJKLM'.split('');
for (let tableIndex = 0; tableIndex < 26; tableIndex++) {
const table = generateAuthTable(key, tableIndex);
html += '<div class="auth-table-wrapper">';
html += `<h3>${PHONETIC[tableIndex]}</h3>`;
html += '<table>';
html += '<tr><th></th>';
for (let col of cols) {
html += `<th>${col}</th>`;
}
html += '</tr>';
for (let i = 0; i < rows.length; i++) {
html += `<tr><th>${rows[i]}</th>`;
for (let j = 0; j < cols.length; j++) {
html += `<td>${table[i][j]}</td>`;
}
html += '</tr>';
}
html += '</table>';
html += '</div>';
}
document.getElementById('auth-tables').innerHTML = html;
}
function renderPadKeys(key) {
let html = '';
for (let i = 0; i < 50; i++) {
const padKey = (i * 1000).toString().padStart(5, '0');
const cipher = generatePadDisplay(key, padKey);
html += '<div class="pad-item">';
html += `<div><b>Key: ${padKey}</b></div>`;
html += `<div class="pad-cipher">${cipher}</div>`;
html += '</div>';
}
document.getElementById('pad-list').innerHTML = html;
}
// Initialize
const key = getKey();
if (!key) {
alert('No encryption key found! Redirecting to main page...');
window.location.href = '/';
} else {
document.getElementById('print-key').textContent = key;
renderASCIITable();
renderOTPPairs(key);
renderAuthTables(key);
renderPadKeys(key);
// Auto-print after everything loads
setTimeout(() => {
window.print();
}, 500);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,669 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Code Book</title>
<link rel="icon" href="/favicon.png">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
min-height: 100vh;
color: #fff;
overflow-x: hidden;
}
hr {
border: 1px solid rgba(255, 255, 255, 0.2);
}
.header {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo-section {
display: flex;
align-items: center;
gap: 1rem;
}
.logo-section img {
height: 3rem;
width: auto;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 0.05rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.key-section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.key-input-group {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.input-wrapper {
flex: 1;
}
label {
display: block;
color: #94a3b8;
font-size: 0.85rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05rem;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.75rem;
color: #fff;
font-size: 1rem;
font-family: 'Courier New', monospace;
}
select {
padding: 0.75rem 2.5rem 0.75rem 0.75rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23fff' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
cursor: pointer;
}
select option {
background: #1e293b;
color: #fff;
}
input::placeholder {
color: #64748b;
}
button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 0.75rem 1.5rem;
color: #fff;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
}
button:hover {
background: rgba(255, 255, 255, 0.15);
}
button:active {
transform: scale(0.95);
}
button.danger {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.3);
}
button.danger:hover {
background: rgba(239, 68, 68, 0.3);
}
button.save {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
}
button.save:hover {
background: rgba(16, 185, 129, 0.3);
}
.tool-section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.tool-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.tool-description {
color: #94a3b8;
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
.hotp-row {
display: flex;
gap: 1rem;
align-items: center;
}
.hotp-row button {
flex: 0 0 auto;
}
.hotp-row input,
.hotp-code {
flex: 1;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.75rem;
color: #fff;
font-size: 1rem;
font-family: 'Courier New', monospace;
text-align: center;
}
.hotp-code {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
}
.auth-table {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
margin: 1rem 0;
width: 100%;
}
.auth-table table {
border-collapse: collapse;
width: 100%;
}
.auth-table th,
.auth-table td {
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 0.5rem;
text-align: center;
transition: background-color 0.15s ease;
}
.auth-table th {
background: rgba(255, 255, 255, 0.1);
font-weight: 600;
}
.auth-table td.highlight {
background: rgba(255, 255, 255, 0.15);
}
.auth-table th.highlight {
background: rgba(255, 255, 255, 0.2);
}
.ascii-table {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
margin: 1rem 0;
width: 100%;
}
.ascii-table table {
border-collapse: collapse;
width: 100%;
}
.ascii-table th,
.ascii-table td {
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 0.4rem;
text-align: center;
}
.ascii-table th {
background: rgba(255, 255, 255, 0.1);
font-weight: 600;
}
.pad-list {
font-family: 'Courier New', monospace;
font-size: 0.95rem;
line-height: 1.8;
}
.pad-item {
margin: 0.5rem 0;
}
textarea {
width: 100%;
min-height: 100px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.75rem;
color: #fff;
font-size: 1rem;
font-family: 'Courier New', monospace;
resize: vertical;
}
.button-group {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
.checkbox-wrapper input[type="checkbox"] {
width: auto;
cursor: pointer;
}
.checkbox-wrapper label {
margin: 0;
cursor: pointer;
text-transform: none;
}
.example-box {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 1rem;
margin: 0.75rem 0;
font-family: 'Courier New', monospace;
}
</style>
</head>
<body>
<div class="header">
<div class="header-content">
<div class="logo-section">
<img src="/favicon.png" alt="Logo">
<div class="logo">ENCRYPTION TOOLS</div>
</div>
</div>
</div>
<div class="container">
<div class="key-section">
<div class="input-wrapper">
<label>Encryption Key</label>
<div class="key-input-group">
<input type="text" id="master-key" placeholder="Enter encryption key">
<button onclick="generateNewKey()" class="danger">Random</button>
<button onclick="save()" class="save">Save</button>
</div>
</div>
</div>
<!-- HOTP Section -->
<div class="tool-section">
<div class="tool-title">One Time Password (HOTP)</div>
<details style="margin: 1rem 0; color: #888;">
<summary>Description</summary>
<div style="padding-top: 0.5rem;">
<p>Use a 3-digit challenge to generate & verify a corresponding 3-letter response.</p>
<p>Provides higher entropy and security than authentication tables while being easier to use.</p>
<div style="margin-top: 0.75rem;">
<strong>Pre-Authenticate:</strong>
<div class="example-box">
<p><strong>A->B:</strong> AUTH 123 ABC</p>
<p><strong>B->A:</strong> ACK. AUTH 456 DEF</p>
</div>
</div>
<div style="margin-top: 0.75rem;">
<strong>Challenge-Response:</strong>
<div class="example-box">
<p><strong>A->B:</strong> CONFIRM <span style="text-decoration: underline;">123</span></p>
<p><strong>B->A:</strong> ACK <span style="text-decoration: underline;">ABC</span>. CONFIRM <span style="text-decoration: underline;">456</span></p>
<p><strong>A->B:</strong> ACK <span style="text-decoration: underline;">DEF</span></p>
</div>
</div>
</div>
</details>
<div class="hotp-row">
<button onclick="randomHOTPNonce()">Random</button>
<input type="number" id="hotp-nonce" placeholder="000" min="0" max="999" oninput="updateHOTP()">
<div class="hotp-code" id="hotp-display">---</div>
</div>
</div>
<!-- Auth Table Section -->
<div class="tool-section">
<div class="tool-title">Authentication Tables</div>
<details style="margin: 1rem 0; color: #888;">
<summary>Description</summary>
<div style="padding-top: 0.5rem;">
<p>Challenge-response tables for authentication.</p>
<div style="margin-top: 0.75rem;">
<strong>Example:</strong>
<div class="example-box">
<p><strong>A->B:</strong> CONFIRM <span style="text-decoration: underline;">CEY</span> <span style="font-style: italic">(Table: Charlie, Column: E, Row: Y)</span></p>
<p><strong>B->A:</strong> ACK <span style="text-decoration: underline;">7</span>. CONFIRM <span style="text-decoration: underline;">AIT</span> <span style="font-style: italic">(Table: Alpha, Column: I, Row: T)</span></p>
<p><strong>A->B:</strong> ACK <span style="text-decoration: underline;">T</span></p>
</div>
</div>
</div>
</details>
<div>
<label>Auth Table</label>
<select id="table-select" onchange="renderAuthTable()"></select>
</div>
<div class="auth-table" id="auth-table-display"></div>
</div>
<!-- Message Encoder Section -->
<div class="tool-section">
<div class="tool-title">One-Time Pad</div>
<details style="margin: 1rem 0; color: #888;">
<summary>Description</summary>
<div style="padding-top: 0.5rem;">
<p>Use a 5-digit key to generate a corresponding fixed (paper friendly) or streamed (more secure) cipher.</p>
<p>Cipher values are added to the ASCII encoded message to create encrypted message.</p>
<div style="margin-top: 0.75rem;">
<strong>Encoding:</strong>
<div class="example-box">
Message: "HI"<br>
Pad Key: 12345<br>
Cipher: 42 85 28 71 14 ...<br><br>
H = ASCII 72 → 72 + 42 = 114 → Hex 72<br>
I = ASCII 73 → 73 + 85 = 158 → Hex 9E<br><br>
Encoded: 729E
</div>
</div>
<div style="margin-top: 0.75rem;">
<strong>Decoding:</strong>
<div class="example-box">
Encoded: 729E<br>
Pad Key: 12345<br>
Cipher: 42 85 28 71 14 ...<br><br>
72 (Hex) = 114 → 114 - 42 = 72 → ASCII 'H'<br>
9E (Hex) = 158 → 158 - 85 = 73 → ASCII 'I'<br><br>
Decoded: "HI"
</div>
</div>
</div>
</details>
<div class="input-wrapper">
<div class="key-input-group">
<button onclick="randomPadKey()">Random</button>
<input type="text" id="pad-key" placeholder="00000" maxlength="5" oninput="updatePad()" style="max-width: 100px; text-align: center;">
<div class="checkbox-wrapper">
<input type="checkbox" id="stream-cipher" checked>
<label for="stream-cipher">Stream Cipher</label>
</div>
</div>
</div>
<div class="pad-list" id="pad-display"></div>
<hr>
<div style="margin-top: 1.5rem;">
<textarea id="message-input" placeholder="Input"></textarea>
<div class="button-group">
<button style="flex: 1 0 0;" onclick="handleEncode()">Encode</button>
<button style="flex: 1 0 0;" onclick="handleDecode()">Decode</button>
</div>
<textarea id="message-output" placeholder="Output" readonly style="margin-top: 1.5rem;"></textarea>
</div>
</div>
<!-- ASCII Table Section -->
<div class="tool-section">
<div class="tool-title">ASCII Reference Table</div>
<div class="ascii-table" id="ascii-table-display"></div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script src="encryption.js"></script>
<script>
function save() {
window.open('/code-book-print.html', '_blank');
}
function loadMasterKey() {
const key = localStorage.getItem('encryption_key');
if (key) {
document.getElementById('master-key').value = key;
initializeTools();
}
}
function saveMasterKey() {
const key = document.getElementById('master-key').value;
if (key) {
localStorage.setItem('encryption_key', key);
initializeTools();
}
}
function generateNewKey() {
if (confirm('⚠️ This will replace your current encryption key. Its recommended you back it up before you continue.')) {
const key = CryptoJS.lib.WordArray.random(32).toString(CryptoJS.enc.Hex);
document.getElementById('master-key').value = key;
saveMasterKey();
}
}
function updateHOTP() {
const key = document.getElementById('master-key').value;
const nonce = document.getElementById('hotp-nonce').value.padStart(3, '0');
if (!key) return;
const code = generateHOTP(key, nonce);
document.getElementById('hotp-display').textContent = code;
}
function randomHOTPNonce() {
const nonce = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
document.getElementById('hotp-nonce').value = nonce;
updateHOTP();
}
function renderAuthTable() {
const key = document.getElementById('master-key').value;
if (!key) return;
const tableIndex = parseInt(document.getElementById('table-select').value);
const table = generateAuthTable(key, tableIndex);
const rows = 'NOPQRSTUVWXYZ'.split('');
const cols = 'ABCDEFGHIJKLM'.split('');
let html = '<table>';
html += '<tr><th></th>';
for (let col of cols) {
html += `<th class="col-header" data-col="${col}">${col}</th>`;
}
html += '</tr>';
for (let i = 0; i < rows.length; i++) {
html += `<tr><th class="row-header" data-row="${rows[i]}">${rows[i]}</th>`;
for (let j = 0; j < cols.length; j++) {
html += `<td data-col="${cols[j]}" data-row="${rows[i]}">${table[i][j]}</td>`;
}
html += '</tr>';
}
html += '</table>';
document.getElementById('auth-table-display').innerHTML = html;
// Add hover listeners
const authTable = document.querySelector('#auth-table-display table');
const cells = authTable.querySelectorAll('td');
cells.forEach(cell => {
cell.addEventListener('mouseenter', function() {
const col = this.getAttribute('data-col');
const row = this.getAttribute('data-row');
authTable.querySelectorAll(`[data-col="${col}"]`).forEach(el => el.classList.add('highlight'));
authTable.querySelectorAll(`[data-row="${row}"]`).forEach(el => el.classList.add('highlight'));
});
cell.addEventListener('mouseleave', function() {
authTable.querySelectorAll('.highlight').forEach(el => el.classList.remove('highlight'));
});
});
}
function populateTableSelect() {
const select = document.getElementById('table-select');
select.innerHTML = '';
for (let i = 0; i < 26; i++) {
const option = document.createElement('option');
option.value = i;
option.textContent = PHONETIC[i];
select.appendChild(option);
}
}
function updatePad() {
const key = document.getElementById('master-key').value;
const padKey = document.getElementById('pad-key').value.padStart(5, '0');
if (!key) return;
const pad = generatePadDisplay(key, padKey);
document.getElementById('pad-display').innerHTML = `<div class="pad-item"><strong>Cipher:</strong> ${pad}</div>`;
}
function randomPadKey() {
const key = Math.floor(Math.random() * 100000).toString().padStart(5, '0');
document.getElementById('pad-key').value = key;
updatePad();
}
function handleEncode() {
const message = document.getElementById('message-input').value;
const key = document.getElementById('master-key').value;
const padKey = document.getElementById('pad-key').value.padStart(5, '0');
const useStream = document.getElementById('stream-cipher').checked;
if (!key || !message) return;
const encoded = encodeMessage(message, key, padKey, useStream);
document.getElementById('message-output').value = encoded;
}
function handleDecode() {
const encoded = document.getElementById('message-input').value.replace(/\s/g, '');
const key = document.getElementById('master-key').value;
const padKey = document.getElementById('pad-key').value.padStart(5, '0');
const useStream = document.getElementById('stream-cipher').checked;
if (!key || !encoded) return;
const decoded = decodeMessage(encoded, key, padKey, useStream);
document.getElementById('message-output').value = decoded;
}
function renderASCIITable() {
const cols = 16;
const rows = 8;
let html = '<table>';
html += '<tr><th>Dec</th><th>Hex</th><th>Char</th><th>Dec</th><th>Hex</th><th>Char</th><th>Dec</th><th>Hex</th><th>Char</th><th>Dec</th><th>Hex</th><th>Char</th></tr>';
for (let row = 0; row < 32; row++) {
html += '<tr>';
for (let col = 0; col < 4; col++) {
const code = row + (col * 32);
if (code < 128) {
const hex = code.toString(16).toUpperCase().padStart(2, '0');
let char = String.fromCharCode(code);
// Handle non-printable characters
if (code < 32) {
const controlChars = ['NUL','SOH','STX','ETX','EOT','ENQ','ACK','BEL','BS','TAB','LF','VT','FF','CR','SO','SI',
'DLE','DC1','DC2','DC3','DC4','NAK','SYN','ETB','CAN','EM','SUB','ESC','FS','GS','RS','US'];
char = controlChars[code];
} else if (code === 32) {
char = 'SP';
} else if (code === 127) {
char = 'DEL';
}
html += `<td>${code}</td><td>${hex}</td><td style="font-weight: bolder">${char}</td>`;
}
}
html += '</tr>';
}
html += '</table>';
document.getElementById('ascii-table-display').innerHTML = html;
}
function initializeTools() {
updateHOTP();
renderAuthTable();
if (document.getElementById('pad-key').value) {
updatePad();
}
}
// Event listeners
document.getElementById('master-key').addEventListener('input', saveMasterKey);
// Initialize on load
loadMasterKey();
populateTableSelect();
renderASCIITable();
</script>
</body>
</html>

113
client/public/encryption.js Normal file
View File

@@ -0,0 +1,113 @@
// encryption.js
// Phonetic alphabet for table names
const PHONETIC = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel',
'India', 'Juliet', 'Kilo', 'Lima', 'Mike', 'November', 'Oscar', 'Papa',
'Quebec', 'Romeo', 'Sierra', 'Tango', 'Uniform', 'Victor', 'Whiskey',
'Xray', 'Yankee', 'Zulu'];
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
// Seeded RNG using SHA256
class SeededRandom {
constructor(seed) {
this.seed = seed;
this.counter = 0;
}
next() {
const hash = CryptoJS.SHA256(this.seed + ':' + this.counter);
this.counter++;
const hex = hash.toString(CryptoJS.enc.Hex);
return parseInt(hex.substring(0, 8), 16) / 0xFFFFFFFF;
}
nextInt(min, max) {
return Math.floor(this.next() * (max - min)) + min;
}
choice(arr) {
return arr[this.nextInt(0, arr.length)];
}
}
// HOTP Generation - 3 digit nonce -> 3 letter code
function generateHOTP(secret, nonce) {
const rng = new SeededRandom(secret + ':hotp:' + nonce);
let code = '';
for (let i = 0; i < 3; i++) {
code += rng.choice(LETTERS.split(''));
}
return code;
}
// Auth Tables
function generateAuthTable(seed, tableIndex) {
const rng = new SeededRandom(seed + ':table:' + tableIndex);
const rows = 'NOPQRSTUVWXYZ'.split('');
const cols = 'ABCDEFGHIJKLM'.split('');
const table = [];
for (let row of rows) {
const rowData = [];
for (let col of cols) {
rowData.push(rng.choice(CHARS.split('')));
}
table.push(rowData);
}
return table;
}
// One-Time Pad (for encode/decode)
function generatePad(seed, padKey, length = 60) {
const rng = new SeededRandom(seed + ':pad:' + padKey);
const pad = [];
for (let i = 0; i < length; i++) {
pad.push(rng.nextInt(0, 256));
}
return pad;
}
function generatePadDisplay(seed, padKey) {
const rng = new SeededRandom(seed + ':pad:' + padKey);
const groups = [];
for (let i = 0; i < 12; i++) {
const num = rng.nextInt(0, 100000).toString().padStart(5, '0');
groups.push(num);
}
return groups.join(' ');
}
function encodeMessage(message, key, padKey, useStream = true) {
const messageBytes = new TextEncoder().encode(message);
const padLength = useStream ? messageBytes.length : 60;
const pad = generatePad(key, padKey, padLength);
const encoded = [];
for (let i = 0; i < messageBytes.length; i++) {
const padByte = pad[i % pad.length];
encoded.push(messageBytes[i] ^ padByte);
}
return Array.from(encoded).map(b => b.toString(16).padStart(2, '0')).join('');
}
function decodeMessage(encoded, key, padKey, useStream = true) {
const bytes = [];
for (let i = 0; i < encoded.length; i += 2) {
bytes.push(parseInt(encoded.substr(i, 2), 16));
}
const padLength = useStream ? bytes.length : 60;
const pad = generatePad(key, padKey, padLength);
const decoded = [];
for (let i = 0; i < bytes.length; i++) {
const padByte = pad[i % pad.length];
decoded.push(bytes[i] ^ padByte);
}
return new TextDecoder().decode(new Uint8Array(decoded));
}

0
code/public/favicon.png → client/public/favicon.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M352 96C352 78.3 337.7 64 320 64C302.3 64 288 78.3 288 96L288 306.7L246.6 265.3C234.1 252.8 213.8 252.8 201.3 265.3C188.8 277.8 188.8 298.1 201.3 310.6L297.3 406.6C309.8 419.1 330.1 419.1 342.6 406.6L438.6 310.6C451.1 298.1 451.1 277.8 438.6 265.3C426.1 252.8 405.8 252.8 393.3 265.3L352 306.7L352 96zM160 384C124.7 384 96 412.7 96 448L96 480C96 515.3 124.7 544 160 544L480 544C515.3 544 544 515.3 544 480L544 448C544 412.7 515.3 384 480 384L433.1 384L376.5 440.6C345.3 471.8 294.6 471.8 263.4 440.6L206.9 384L160 384zM464 440C477.3 440 488 450.7 488 464C488 477.3 477.3 488 464 488C450.7 488 440 477.3 440 464C440 450.7 450.7 440 464 440z"/></svg>

After

Width:  |  Height:  |  Size: 872 B

View File

@@ -0,0 +1 @@
<svg class="app-icon" style="fill: #fff;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320zM352 160C352 142.3 337.7 128 320 128C302.3 128 288 142.3 288 160C288 177.7 302.3 192 320 192C337.7 192 352 177.7 352 160zM320 480C355.3 480 384 451.3 384 416C384 399.8 378 384.9 368 373.7L437.5 234.8C443.4 222.9 438.6 208.5 426.8 202.6C415 196.7 400.5 201.5 394.6 213.3L325.1 352.2C323.4 352.1 321.7 352 320 352C284.7 352 256 380.7 256 416C256 451.3 284.7 480 320 480zM240 208C240 190.3 225.7 176 208 176C190.3 176 176 190.3 176 208C176 225.7 190.3 240 208 240C225.7 240 240 225.7 240 208zM160 352C177.7 352 192 337.7 192 320C192 302.3 177.7 288 160 288C142.3 288 128 302.3 128 320C128 337.7 142.3 352 160 352zM512 320C512 302.3 497.7 288 480 288C462.3 288 448 302.3 448 320C448 337.7 462.3 352 480 352C497.7 352 512 337.7 512 320z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

1879
code/public/index.html → client/public/index.html Executable file → Normal file

File diff suppressed because it is too large Load Diff

742
code/public/index.js → client/public/index.js Executable file → Normal file
View File

@@ -1,368 +1,374 @@
class Dashboard { class Dashboard {
constructor() { constructor() {
this.statusCache = null; this.statusCache = null;
this.lastFetch = 0; this.lastFetch = 0;
this.hostname = 'localhost'; this.hostname = 'localhost';
this.serverTimeOffset = null; // Track difference between server and client time this.serverTimeOffset = null; // Track difference between server and client time
this.isTimeServerSynced = false; // Track if we're using server time this.isTimeServerSynced = false; // Track if we're using server time
this.init(); this.init();
} }
async init() { async init() {
await this.fetchHostname(); await this.fetchHostname();
this.updateHostname(); this.updateHostname();
this.startTimeUpdates(); // Start with client time immediately this.startTimeUpdates(); // Start with client time immediately
this.startStatusUpdates(); this.startStatusUpdates();
this.initializeEventListeners(); this.initializeEventListeners();
} }
async fetchHostname() { async fetchHostname() {
try { try {
const response = await fetch('/api/hostname'); const response = await fetch('/api/hostname');
const data = await response.json(); const data = await response.json();
this.hostname = data.hostname.toLowerCase(); this.hostname = data.hostname.toLowerCase();
} catch (error) { } catch (error) {
console.warn('Failed to fetch hostname:', error); console.warn('Failed to fetch hostname:', error);
} }
} }
updateHostname() { updateHostname() {
const hostnameElement = document.querySelector('.hostname'); const hostnameElement = document.querySelector('.hostname');
if (hostnameElement) { if (hostnameElement) {
hostnameElement.textContent = this.hostname; hostnameElement.textContent = this.hostname;
} }
} }
async fetchStatus() { async fetchStatus() {
try { try {
// Record start time for latency calculation // Record start time for latency calculation
const requestStart = performance.now(); const requestStart = performance.now();
const response = await fetch('/api/status'); const response = await fetch('/api/status');
const requestEnd = performance.now(); const requestEnd = performance.now();
if (!response.ok) throw new Error('Status fetch failed'); if (!response.ok) throw new Error('Status fetch failed');
const status = await response.json(); const status = await response.json();
this.statusCache = status; this.statusCache = status;
this.updateUI(status, requestStart, requestEnd); this.updateUI(status, requestStart, requestEnd);
} catch (error) { } catch (error) {
console.error('Failed to fetch status:', error); console.error('Failed to fetch status:', error);
this.showOfflineStatus(); this.showOfflineStatus();
} }
} }
updateUI(status, requestStart, requestEnd) { updateUI(status, requestStart, requestEnd) {
this.updateSystemMetrics(status.system); this.updateSystemMetrics(status.system);
this.updateGPSStatus(status.gps); this.updateGPSStatus(status.gps);
this.updateNetworkStatus(status.network); this.updateNetworkStatus(status.network);
this.updateLoRaStatus(status.lora); this.updateLoRaStatus(status.lora);
this.updateTimeSync(status.time, requestStart, requestEnd); this.updateTimeSync(status.time, requestStart, requestEnd);
} }
updateSystemMetrics(system) { updateSystemMetrics(system) {
// Update temperature // Update temperature
const tempElement = document.getElementById('temp'); const tempElement = document.getElementById('temp');
if (tempElement && system.temperature) { if (tempElement && system.temperature) {
tempElement.textContent = `${system.temperature}°C`; tempElement.textContent = `${system.temperature}°C`;
} }
// Update load average // Update load average
const loadElement = document.getElementById('load'); const loadElement = document.getElementById('load');
if (loadElement && system.load !== undefined) { if (loadElement && system.load !== undefined) {
loadElement.textContent = `${system.load.toFixed(1)} Load`; loadElement.textContent = `${system.load.toFixed(1)} Load`;
} }
// Update progress bars // Update progress bars
this.updateProgressBar('cpu-usage', system.cpu); this.updateProgressBar('cpu-usage', system.cpu);
this.updateProgressBar('memory-usage', system.memory); this.updateProgressBar('memory-usage', system.memory);
this.updateProgressBar('disk-usage', system.storage); this.updateProgressBar('disk-usage', system.storage);
} }
updateProgressBar(id, percentage) { updateProgressBar(id, percentage) {
const fillElement = document.getElementById(`${id}-fill`); const fillElement = document.getElementById(`${id}-fill`);
const textElement = document.getElementById(`${id}-text`); const textElement = document.getElementById(`${id}-text`);
if (fillElement && textElement) { if (fillElement && textElement) {
const value = Math.round(percentage); const value = Math.round(percentage);
fillElement.style.width = `${value}%`; fillElement.style.width = `${value}%`;
textElement.textContent = `${value}%`; textElement.textContent = `${value}%`;
// Update color based on usage // Update color based on usage
if (value > 80) { if (value > 80) {
fillElement.style.background = '#ef4444'; // Red fillElement.style.background = '#ef4444'; // Red
} else if (value > 60) { } else if (value > 60) {
fillElement.style.background = '#f59e0b'; // Orange fillElement.style.background = '#f59e0b'; // Orange
} else { } else {
fillElement.style.background = '#10b981'; // Green fillElement.style.background = '#10b981'; // Green
} }
} }
} }
updateGPSStatus(gps) { updateGPSStatus(gps) {
const gpsStatus = document.getElementById('gps-status'); const gpsStatus = document.getElementById('gps-status');
const gpsTile = document.getElementById('gps-tile'); const gpsTile = document.getElementById('gps-tile');
if (gpsStatus && gpsTile) { if (gpsStatus && gpsTile) {
const hasValidFix = gps.fix !== 'No Fix' && gps.latitude !== null; const hasValidFix = gps.fix !== 'No Fix' && gps.latitude !== null;
// Update status indicator // Update status indicator
if (hasValidFix) { if (hasValidFix) {
gpsStatus.classList.remove('disconnected'); gpsStatus.classList.remove('disconnected');
gpsStatus.classList.add('connected'); gpsStatus.classList.add('connected');
gpsTile.classList.remove('disconnected'); gpsTile.classList.remove('disconnected');
gpsTile.classList.add('connected'); gpsTile.classList.add('connected');
} else { } else {
gpsStatus.classList.remove('connected'); gpsStatus.classList.remove('connected');
gpsStatus.classList.add('disconnected'); gpsStatus.classList.add('disconnected');
gpsTile.classList.remove('connected'); gpsTile.classList.remove('connected');
gpsTile.classList.add('disconnected'); gpsTile.classList.add('disconnected');
} }
// Update GPS tile info // Update GPS tile info
const gpsInfo = gpsTile.querySelector('.tile-info'); const gpsInfo = gpsTile.querySelector('.tile-info');
const gpsDetail = gpsTile.querySelector('.tile-detail'); const gpsDetail = gpsTile.querySelector('.tile-detail');
if (gpsInfo && gpsDetail) { if (gpsInfo && gpsDetail) {
if (hasValidFix) { if (hasValidFix) {
gpsInfo.textContent = `${gps.latitude.toFixed(4)}, ${gps.longitude.toFixed(4)}`; gpsInfo.textContent = `${gps.latitude.toFixed(4)}, ${gps.longitude.toFixed(4)}`;
gpsDetail.textContent = `${gps.fix}${gps.satellites} sats • Alt: ${Math.round(gps.altitude)}m`; gpsDetail.textContent = `${gps.fix}${gps.satellites} sats • Alt: ${Math.round(gps.altitude)}m`;
} else { } else {
gpsInfo.textContent = gps.satellites > 0 ? 'Acquiring Fix...' : 'No Signal'; gpsInfo.textContent = gps.satellites > 0 ? 'Acquiring Fix...' : 'No Signal';
gpsDetail.textContent = `${gps.satellites} satellites`; gpsDetail.textContent = `${gps.satellites} satellites`;
} }
} }
} }
} }
updateNetworkStatus(network) { updateNetworkStatus(network) {
// Update WiFi status // Update WiFi status
const wifiTile = document.getElementById('wifi-tile'); const wifiTile = document.getElementById('wifi-tile');
if (wifiTile) { if (wifiTile) {
const wifiInfo = wifiTile.querySelector('.tile-info'); const wifiInfo = wifiTile.querySelector('.tile-info');
const wifiDetail = wifiTile.querySelector('.tile-detail'); const wifiDetail = wifiTile.querySelector('.tile-detail');
if (wifiInfo && wifiDetail) { if (wifiInfo && wifiDetail) {
if (network.wifi.mode === 'ap') { if (network.wifi.mode === 'ap') {
wifiInfo.textContent = 'Access Point'; wifiInfo.textContent = 'Access Point';
wifiDetail.textContent = `SSID: ${network.wifi.ssid}`; wifiDetail.textContent = `SSID: ${network.wifi.ssid}`;
} else { } else {
wifiInfo.textContent = 'Client Mode'; wifiInfo.textContent = 'Client Mode';
wifiDetail.textContent = `SSID: ${network.wifi.ssid}`; wifiDetail.textContent = `SSID: ${network.wifi.ssid}`;
} }
} }
} }
// Update Ethernet status // Update Ethernet status
const ethernetStatus = document.getElementById('ethernet-status'); const ethernetStatus = document.getElementById('ethernet-status');
const ethernetTile = document.getElementById('ethernet-tile'); const ethernetTile = document.getElementById('ethernet-tile');
if (ethernetStatus && ethernetTile) { if (ethernetStatus && ethernetTile) {
if (network.ethernet.connected) { if (network.ethernet.connected) {
ethernetStatus.classList.remove('disconnected'); ethernetStatus.classList.remove('disconnected');
ethernetStatus.classList.add('connected'); ethernetStatus.classList.add('connected');
ethernetTile.classList.remove('disconnected'); ethernetTile.classList.remove('disconnected');
ethernetTile.classList.add('connected'); ethernetTile.classList.add('connected');
const ethernetInfo = ethernetTile.querySelector('.tile-info'); const ethernetInfo = ethernetTile.querySelector('.tile-info');
const ethernetDetail = ethernetTile.querySelector('.tile-detail'); const ethernetDetail = ethernetTile.querySelector('.tile-detail');
if (ethernetInfo && ethernetDetail) { if (ethernetInfo && ethernetDetail) {
ethernetInfo.textContent = 'Connected'; ethernetInfo.textContent = 'Connected';
ethernetDetail.textContent = 'Link detected'; ethernetDetail.textContent = 'Link detected';
} }
} else { } else {
ethernetStatus.classList.remove('connected'); ethernetStatus.classList.remove('connected');
ethernetStatus.classList.add('disconnected'); ethernetStatus.classList.add('disconnected');
ethernetTile.classList.remove('connected'); ethernetTile.classList.remove('connected');
ethernetTile.classList.add('disconnected'); ethernetTile.classList.add('disconnected');
const ethernetInfo = ethernetTile.querySelector('.tile-info'); const ethernetInfo = ethernetTile.querySelector('.tile-info');
const ethernetDetail = ethernetTile.querySelector('.tile-detail'); const ethernetDetail = ethernetTile.querySelector('.tile-detail');
if (ethernetInfo && ethernetDetail) { if (ethernetInfo && ethernetDetail) {
ethernetInfo.textContent = 'Disconnected'; ethernetInfo.textContent = 'Disconnected';
ethernetDetail.textContent = 'No cable detected'; ethernetDetail.textContent = 'No cable detected';
} }
} }
} }
} }
updateLoRaStatus(lora) { updateLoRaStatus(lora) {
const loraStatus = document.getElementById('lora-status'); const loraStatus = document.getElementById('lora-status');
const loraTile = document.getElementById('lora-tile'); const loraTile = document.getElementById('lora-tile');
if (loraStatus && loraTile) { if (loraStatus && loraTile) {
if (lora.connected) { if (lora.connected) {
loraStatus.classList.remove('disconnected'); loraStatus.classList.remove('disconnected');
loraStatus.classList.add('connected'); loraStatus.classList.add('connected');
loraTile.classList.remove('disconnected'); loraTile.classList.remove('disconnected');
loraTile.classList.add('connected'); loraTile.classList.add('connected');
const loraInfo = loraTile.querySelector('.tile-info'); const loraInfo = loraTile.querySelector('.tile-info');
const loraDetail = loraTile.querySelector('.tile-detail'); const loraDetail = loraTile.querySelector('.tile-detail');
if (loraInfo && loraDetail) { if (loraInfo && loraDetail) {
loraInfo.textContent = `${lora.onlineNodes}/${lora.totalNodes} Online`; loraInfo.textContent = `${lora.onlineNodes}/${lora.totalNodes} Online`;
loraDetail.textContent = `CH: ${lora.channelUtilization.toFixed(1)}% • Air: ${lora.airtimeUtilization.toFixed(1)}%`; loraDetail.textContent = `CH: ${lora.channelUtilization.toFixed(1)}% • Air: ${lora.airtimeUtilization.toFixed(1)}%`;
} }
} else { } else {
loraStatus.classList.remove('connected'); loraStatus.classList.remove('connected');
loraStatus.classList.add('disconnected'); loraStatus.classList.add('disconnected');
loraTile.classList.remove('connected'); loraTile.classList.remove('connected');
loraTile.classList.add('disconnected'); loraTile.classList.add('disconnected');
const loraInfo = loraTile.querySelector('.tile-info'); const loraInfo = loraTile.querySelector('.tile-info');
const loraDetail = loraTile.querySelector('.tile-detail'); const loraDetail = loraTile.querySelector('.tile-detail');
if (loraInfo && loraDetail) { if (loraInfo && loraDetail) {
loraInfo.textContent = 'Disconnected'; loraInfo.textContent = 'Disconnected';
loraDetail.textContent = 'No device found'; loraDetail.textContent = 'No device found';
} }
} }
} }
} }
updateTimeSync(serverTimeString, requestStart, requestEnd) { updateTimeSync(serverTimeString, requestStart, requestEnd) {
if (serverTimeString && requestStart && requestEnd) { if (serverTimeString && requestStart && requestEnd) {
// Calculate network latency (round-trip time / 2) // Calculate network latency (round-trip time / 2)
const networkLatency = (requestEnd - requestStart) / 2; const networkLatency = (requestEnd - requestStart) / 2;
// Parse server time and adjust for network latency // Parse server time and adjust for network latency
const serverTime = new Date(serverTimeString); const serverTime = new Date(serverTimeString);
const adjustedServerTime = new Date(serverTime.getTime() + networkLatency); const adjustedServerTime = new Date(serverTime.getTime() + networkLatency);
// Calculate the offset between adjusted server time and client time // Calculate the offset between adjusted server time and client time
const clientTime = new Date(); const clientTime = new Date();
this.serverTimeOffset = adjustedServerTime.getTime() - clientTime.getTime(); this.serverTimeOffset = adjustedServerTime.getTime() - clientTime.getTime();
this.isTimeServerSynced = true; this.isTimeServerSynced = true;
console.log(`Time synchronized with server (latency: ${networkLatency.toFixed(1)}ms, offset: ${this.serverTimeOffset.toFixed(1)}ms)`); console.log(`Time synchronized with server (latency: ${networkLatency.toFixed(1)}ms, offset: ${this.serverTimeOffset.toFixed(1)}ms)`);
} }
// Update time displays // Update time displays
this.updateCurrentTime(); this.updateCurrentTime();
} }
updateCurrentTime() { updateCurrentTime() {
const now = new Date(); const now = new Date();
let displayTime; let displayTime;
if (this.isTimeServerSynced && this.serverTimeOffset !== null) { if (this.isTimeServerSynced && this.serverTimeOffset !== null) {
// Use server-synchronized time // Use server-synchronized time
displayTime = new Date(now.getTime() + this.serverTimeOffset); displayTime = new Date(now.getTime() + this.serverTimeOffset);
} else { } else {
// Use client time (default) // Use client time (default)
displayTime = now; displayTime = now;
} }
this.updateTimeDisplay('local', displayTime, false); this.updateTimeDisplay('local', displayTime, false);
this.updateTimeDisplay('zulu', displayTime, true); this.updateTimeDisplay('zulu', displayTime, true);
} }
updateTimeDisplay(type, date, isUtc) { updateTimeDisplay(type, date, isUtc) {
const timeElement = document.getElementById(`${type}-time`); const timeElement = document.getElementById(`${type}-time`);
const dateElement = document.getElementById(`${type}-date`); const dateElement = document.getElementById(`${type}-date`);
if (timeElement && dateElement) { if (timeElement && dateElement) {
const displayDate = isUtc ? new Date(date.getTime()) : date; const displayDate = isUtc ? new Date(date.getTime()) : date;
const timeStr = isUtc ? const timeStr = isUtc ?
displayDate.toUTCString().split(' ')[4] : displayDate.toUTCString().split(' ')[4] :
displayDate.toLocaleTimeString(); displayDate.toLocaleTimeString();
const dateStr = isUtc ? const dateStr = isUtc ?
displayDate.toUTCString().split(' ').slice(0, 4).join(' ') : displayDate.toUTCString().split(' ').slice(0, 4).join(' ') :
displayDate.toLocaleDateString('en-US', { displayDate.toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric' year: 'numeric'
}); });
timeElement.textContent = timeStr; timeElement.textContent = timeStr;
dateElement.textContent = dateStr; dateElement.textContent = dateStr;
// Color time orange when using client time, white when server-synced // Color time orange when using client time, white when server-synced
if (this.isTimeServerSynced) { if (this.isTimeServerSynced) {
timeElement.style.color = '#fff'; timeElement.style.color = '#fff';
dateElement.style.color = '#64748b'; dateElement.style.color = '#64748b';
} else { } else {
timeElement.style.color = '#f59e0b'; timeElement.style.color = '#f59e0b';
dateElement.style.color = '#f59e0b'; dateElement.style.color = '#f59e0b';
} }
} }
} }
showOfflineStatus() { showOfflineStatus() {
// Reset to client time and show orange color // Reset to client time and show orange color
this.serverTimeOffset = null; this.serverTimeOffset = null;
this.isTimeServerSynced = false; this.isTimeServerSynced = false;
console.warn('System appears offline - falling back to client time'); console.warn('System appears offline - falling back to client time');
} }
startStatusUpdates() { startStatusUpdates() {
// Initial fetch // Initial fetch
this.fetchStatus(); this.fetchStatus();
// Update every 60 seconds // Update every 60 seconds
setInterval(() => { setInterval(() => {
this.fetchStatus(); this.fetchStatus();
}, 60000); }, 60000);
} }
startTimeUpdates() { startTimeUpdates() {
// Start immediately with client time // Start immediately with client time
this.updateCurrentTime(); this.updateCurrentTime();
// Update time every second // Update time every second
setInterval(() => { setInterval(() => {
this.updateCurrentTime(); this.updateCurrentTime();
}, 1000); }, 1000);
} }
initializeEventListeners() { initializeEventListeners() {
// Expand/collapse status // Expand/collapse status
const expandToggle = document.getElementById('expand-toggle'); const expandToggle = document.getElementById('expand-toggle');
const expandedStatus = document.getElementById('expanded-status'); const expandedStatus = document.getElementById('expanded-status');
const expandIcon = document.getElementById('expand-icon'); const expandIcon = document.getElementById('expand-icon');
if (expandToggle && expandedStatus && expandIcon) { if (expandToggle && expandedStatus && expandIcon) {
expandToggle.addEventListener('click', () => { expandToggle.addEventListener('click', () => {
expandedStatus.classList.toggle('visible'); expandedStatus.classList.toggle('visible');
expandIcon.classList.toggle('expanded'); expandIcon.classList.toggle('expanded');
}); });
} }
// Search functionality // Search functionality
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const appsGrid = document.getElementById('apps-grid'); const appsGrid = document.getElementById('apps-grid');
if (searchInput && appsGrid) { if (searchInput && appsGrid) {
searchInput.addEventListener('input', (e) => { searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase(); const query = e.target.value.toLowerCase();
const apps = appsGrid.querySelectorAll('.app-item'); const apps = appsGrid.querySelectorAll('.app-item');
apps.forEach(app => { apps.forEach(app => {
const name = app.getAttribute('data-name')?.toLowerCase() || ''; const name = app.getAttribute('data-name')?.toLowerCase() || '';
app.style.display = name.includes(query) ? 'flex' : 'none'; app.style.display = name.includes(query) ? 'flex' : 'none';
}); });
}); });
} }
} }
} }
function openKiwixm(event) { function saveCodeBook() {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
window.location.href = window.location.origin.replace(/:\d+/g, '') + ':1300/admin'; window.location.href = '/code-book-print.html';
} }
// Initialize dashboard when DOM is ready function openKiwixm(event) {
document.addEventListener('DOMContentLoaded', () => { event.stopPropagation();
new Dashboard(); event.preventDefault();
window.location.href = window.location.origin.replace(/:\d+/g, '') + ':1400/admin';
document.querySelectorAll('.app-item[href^=":"]').forEach(link => { }
const port = link.getAttribute('href').substring(1); // Remove the ':'
const url = new URL(location.origin); // Initialize dashboard when DOM is ready
url.port = port; document.addEventListener('DOMContentLoaded', () => {
link.href = url.toString(); new Dashboard();
});
}); document.querySelectorAll('.app-item[href^=":"]').forEach(link => {
const port = link.getAttribute('href').substring(1); // Remove the ':'
const url = new URL(location.origin);
url.port = port;
link.href = url.toString();
});
});

38
public/manifest.json → client/public/manifest.json Executable file → Normal file
View File

@@ -1,19 +1,19 @@
{ {
"name": "MRS", "name": "MRS",
"short_name": "MRS", "short_name": "MRS",
"description": "Multipurpose Radio Server", "description": "Multipurpose Radio Server",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#000000", "background_color": "#000000",
"theme_color": "#000000", "theme_color": "#000000",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"icons": [ "icons": [
{ {
"src": "/favicon.png", "src": "/favicon.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"lang": "en-US", "lang": "en-US",
"scope": "/" "scope": "/"
} }

102
code/src/main.mjs → client/src/main.mjs Executable file → Normal file
View File

@@ -1,51 +1,51 @@
import express from "express"; import express from "express";
import { join } from 'path'; import { join } from 'path';
import {environment} from './services/environment.mjs'; import {environment} from './services/environment.mjs';
import { statusRouter } from './services/status.mjs'; import { statusRouter } from './services/status.mjs';
(async () => { (async () => {
const app = express(); const app = express();
// Middleware // Middleware
app.use(express.json()); app.use(express.json());
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
console.error('Middleware error:', err); console.error('Middleware error:', err);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
}); });
// Routes // Routes
console.log(join(environment.root, '../public')); console.log(join(environment.root, '../public'));
app.use(express.static(join(environment.root, '../public'))); app.use(express.static(join(environment.root, '../public')));
app.use('/api', statusRouter); app.use('/api', statusRouter);
// Error handler // Error handler
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
}); });
// Startup // Startup
const server = app.listen(environment.port, () => { const server = app.listen(environment.port, () => {
console.log(`Dashboard running on http://localhost:${environment.port}`); console.log(`Dashboard running on http://localhost:${environment.port}`);
}).on('error', (err) => { }).on('error', (err) => {
console.error('Server startup error:', err); console.error('Server startup error:', err);
process.exit(1); process.exit(1);
}); });
// Shutdown // Shutdown
const gracefulShutdown = (signal) => { const gracefulShutdown = (signal) => {
console.log(`Received ${signal}, shutting down gracefully`); console.log(`Received ${signal}, shutting down gracefully`);
server.close((err) => { server.close((err) => {
if (err) { if (err) {
console.error('Error during server shutdown:', err); console.error('Error during server shutdown:', err);
process.exit(1); process.exit(1);
} }
console.log('Server closed'); console.log('Server closed');
process.exit(0); process.exit(0);
}); });
}; };
process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('SIGINT', () => gracefulShutdown('SIGINT'));
})(); })();

View File

@@ -1,38 +1,38 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
import { execSync } from "child_process"; import { execSync } from "child_process";
dotenv.config(); dotenv.config();
export const environment = { export const environment = {
config: process.env.CONFIG || 'config.json', config: process.env.CONFIG || 'config.json',
hostname: process.env.HOSTNAME || execSync('hostname', { encoding: "utf-8" }) || 'localhost', hostname: process.env.HOSTNAME || execSync('hostname', { encoding: "utf-8" }) || 'localhost',
gpsd: process.env.GPSD || 2947, gpsd: process.env.GPSD || 2947,
port: process.env.PORT || 80, port: process.env.PORT || 80,
root: dirname(dirname(fileURLToPath(import.meta.url))), root: dirname(dirname(fileURLToPath(import.meta.url))),
settings: {}, settings: {},
} }
if(!environment.config.startsWith('/')) environment.config = `${environment.root}/${environment.config}`; if(!environment.config.startsWith('/')) environment.config = `${environment.root}/${environment.config}`;
export function loadSettings() { export function loadSettings() {
if(!fs.existsSync(environment.config)) fs.writeFileSync(environment.config, '', { flag: 'a' }); if(!fs.existsSync(environment.config)) fs.writeFileSync(environment.config, '', { flag: 'a' });
const value = fs.readFileSync(environment.config, 'utf-8'); const value = fs.readFileSync(environment.config, 'utf-8');
try { return JSON.parse(value); } try { return JSON.parse(value); }
catch (e) { catch (e) {
return { return {
lora: { lora: {
mode: 'MESH', mode: 'MESH',
telemetry: true, telemetry: true,
} }
}; };
} }
} }
export function saveSettings() { export function saveSettings() {
fs.writeFileSync(environment.config, JSON.stringify(environment.settings, null, 4)); fs.writeFileSync(environment.config, JSON.stringify(environment.settings, null, 4));
} }
loadSettings(); loadSettings();

View File

@@ -1,426 +1,426 @@
import express from 'express'; import express from 'express';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import pkg from 'node-gpsd'; import pkg from 'node-gpsd';
const { GPS } = pkg; const { GPS } = pkg;
import {environment} from './environment.mjs'; import {environment} from './environment.mjs';
const router = express.Router(); const router = express.Router();
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Status cache with TTL // Status cache with TTL
let statusCache = null; let statusCache = null;
let lastUpdate = 0; let lastUpdate = 0;
const CACHE_TTL = 15 * 1000; // 15 seconds const CACHE_TTL = 15 * 1000; // 15 seconds
// GPS client setup // GPS client setup
let gpsClient = null; let gpsClient = null;
let gpsData = { let gpsData = {
fix: 'No Fix', fix: 'No Fix',
latitude: null, latitude: null,
longitude: null, longitude: null,
altitude: null, altitude: null,
satellites: 0 satellites: 0
}; };
// Initialize GPS connection // Initialize GPS connection
async function initGPS() { async function initGPS() {
try { try {
gpsClient = new GPS({ hostname: 'localhost', port: 2947 }); gpsClient = new GPS({ hostname: 'localhost', port: 2947 });
gpsClient.on('TPV', (data) => { gpsClient.on('TPV', (data) => {
gpsData.latitude = data.lat || null; gpsData.latitude = data.lat || null;
gpsData.longitude = data.lon || null; gpsData.longitude = data.lon || null;
gpsData.altitude = data.alt || null; gpsData.altitude = data.alt || null;
gpsData.fix = data.mode === 3 ? '3D Fix' : data.mode === 2 ? '2D Fix' : 'No Fix'; gpsData.fix = data.mode === 3 ? '3D Fix' : data.mode === 2 ? '2D Fix' : 'No Fix';
}); });
gpsClient.on('SKY', (data) => { gpsClient.on('SKY', (data) => {
gpsData.satellites = (data.satellites || []).length; gpsData.satellites = (data.satellites || []).length;
}); });
gpsClient.on('error', (err) => { gpsClient.on('error', (err) => {
console.warn('GPS error:', err.message); console.warn('GPS error:', err.message);
}); });
await gpsClient.watch(); await gpsClient.watch();
console.log('GPS client initialized'); console.log('GPS client initialized');
} catch (error) { } catch (error) {
console.warn('GPS not available:', error.message); console.warn('GPS not available:', error.message);
} }
} }
// CLI-based system information gathering // CLI-based system information gathering
async function getSystemInfo() { async function getSystemInfo() {
const info = { const info = {
temperature: 0, temperature: 0,
load: 0, load: 0,
cpu: 0, cpu: 0,
memory: 0, memory: 0,
storage: 0 storage: 0
}; };
try { try {
// CPU temperature (Raspberry Pi specific) // CPU temperature (Raspberry Pi specific)
if (existsSync('/sys/class/thermal/thermal_zone0/temp')) { if (existsSync('/sys/class/thermal/thermal_zone0/temp')) {
const tempData = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8'); const tempData = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8');
info.temperature = Math.round(parseInt(tempData.trim()) / 1000); info.temperature = Math.round(parseInt(tempData.trim()) / 1000);
} }
// Load average // Load average
const { stdout: loadAvg } = await execAsync("cat /proc/loadavg | awk '{print $1}'"); const { stdout: loadAvg } = await execAsync("cat /proc/loadavg | awk '{print $1}'");
info.load = parseFloat(loadAvg.trim()) || 0; info.load = parseFloat(loadAvg.trim()) || 0;
// CPU usage // CPU usage
const { stdout: cpuUsage } = await execAsync("grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$3+$4+$5)} END {print usage}'"); const { stdout: cpuUsage } = await execAsync("grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$3+$4+$5)} END {print usage}'");
info.cpu = parseFloat(cpuUsage.trim()) || 0; info.cpu = parseFloat(cpuUsage.trim()) || 0;
// Memory usage percentage // Memory usage percentage
const { stdout: memUsage } = await execAsync("free | awk 'NR==2{printf \"%.1f\", $3*100/$2}'"); const { stdout: memUsage } = await execAsync("free | awk 'NR==2{printf \"%.1f\", $3*100/$2}'");
info.memory = parseFloat(memUsage.trim()) || 0; info.memory = parseFloat(memUsage.trim()) || 0;
// Disk usage percentage for root filesystem // Disk usage percentage for root filesystem
const { stdout: diskUsage } = await execAsync("df / | awk 'NR==2{printf \"%.1f\", $5}' | sed 's/%//'"); const { stdout: diskUsage } = await execAsync("df / | awk 'NR==2{printf \"%.1f\", $5}' | sed 's/%//'");
info.storage = parseFloat(diskUsage.trim()) || 0; info.storage = parseFloat(diskUsage.trim()) || 0;
} catch (error) { } catch (error) {
console.warn('System info error:', error.message); console.warn('System info error:', error.message);
} }
return info; return info;
} }
async function getGPSInfo() { async function getGPSInfo() {
const gpsInfo = { const gpsInfo = {
satellites: 0, satellites: 0,
latitude: null, latitude: null,
longitude: null, longitude: null,
altitude: null altitude: null
}; };
try { try {
// Check if gpsd is running // Check if gpsd is running
await execAsync('systemctl is-active gpsd'); await execAsync('systemctl is-active gpsd');
// Try to get GPS data using gpspipe // Try to get GPS data using gpspipe
const { stdout: gpsRaw } = await execAsync('timeout 3s gpspipe -w -n 5 | grep -E \'"class":"TPV"\' | head -1', { timeout: 4000 }); const { stdout: gpsRaw } = await execAsync('timeout 3s gpspipe -w -n 5 | grep -E \'"class":"TPV"\' | head -1', { timeout: 4000 });
if (gpsRaw.trim()) { if (gpsRaw.trim()) {
const gpsJson = JSON.parse(gpsRaw.trim()); const gpsJson = JSON.parse(gpsRaw.trim());
gpsInfo.latitude = gpsJson.lat || null; gpsInfo.latitude = gpsJson.lat || null;
gpsInfo.longitude = gpsJson.lon || null; gpsInfo.longitude = gpsJson.lon || null;
gpsInfo.altitude = gpsJson.alt || null; gpsInfo.altitude = gpsJson.alt || null;
} }
// Get satellite count // Get satellite count
try { try {
const { stdout: skyRaw } = await execAsync('timeout 2s gpspipe -w -n 3 | grep -E \'"class":"SKY"\' | head -1', { timeout: 3000 }); const { stdout: skyRaw } = await execAsync('timeout 2s gpspipe -w -n 3 | grep -E \'"class":"SKY"\' | head -1', { timeout: 3000 });
if (skyRaw.trim()) { if (skyRaw.trim()) {
const skyJson = JSON.parse(skyRaw.trim()); const skyJson = JSON.parse(skyRaw.trim());
gpsInfo.satellites = (skyJson.satellites || []).length; gpsInfo.satellites = (skyJson.satellites || []).length;
} }
} catch (skyError) { } } catch (skyError) { }
} catch (error) { } catch (error) {
console.warn('GPS info error:', error.message); console.warn('GPS info error:', error.message);
// Use cached GPS data from node-gpsd if available // Use cached GPS data from node-gpsd if available
if (gpsData.latitude !== null) { if (gpsData.latitude !== null) {
return gpsData; return gpsData;
} }
} }
return gpsInfo; return gpsInfo;
} }
async function getNetworkInfo() { async function getNetworkInfo() {
const networkInfo = { const networkInfo = {
wifi: { wifi: {
mode: 'client', mode: 'client',
ssid: 'Unknown' ssid: 'Unknown'
}, },
ethernet: { ethernet: {
connected: false connected: false
} }
}; };
try { try {
// Check if hostapd is running (AP mode) // Check if hostapd is running (AP mode)
try { try {
await execAsync('systemctl is-active hostapd'); await execAsync('systemctl is-active hostapd');
networkInfo.wifi.mode = 'ap'; networkInfo.wifi.mode = 'ap';
// Get AP SSID from hostapd config or use hostname // Get AP SSID from hostapd config or use hostname
try { try {
const { stdout: hostname } = await execAsync('hostname'); const { stdout: hostname } = await execAsync('hostname');
networkInfo.wifi.ssid = process.env.AP_SSID || hostname.trim(); networkInfo.wifi.ssid = process.env.AP_SSID || hostname.trim();
} catch { } catch {
networkInfo.wifi.ssid = 'MRS-AP'; networkInfo.wifi.ssid = 'MRS-AP';
} }
} catch { } catch {
// Not in AP mode, check if connected to WiFi // Not in AP mode, check if connected to WiFi
try { try {
// First, try iwconfig directly since it's more reliable for ESSID // First, try iwconfig directly since it's more reliable for ESSID
const { stdout: iwconfig } = await execAsync("iwconfig wlan0 2>/dev/null"); const { stdout: iwconfig } = await execAsync("iwconfig wlan0 2>/dev/null");
const essidMatch = iwconfig.match(/ESSID:"([^"]+)"/); const essidMatch = iwconfig.match(/ESSID:"([^"]+)"/);
if (essidMatch && essidMatch[1] && essidMatch[1] !== 'off/any') { if (essidMatch && essidMatch[1] && essidMatch[1] !== 'off/any') {
networkInfo.wifi.mode = 'client'; networkInfo.wifi.mode = 'client';
networkInfo.wifi.ssid = essidMatch[1]; networkInfo.wifi.ssid = essidMatch[1];
} else { } else {
// Fallback to nmcli if iwconfig doesn't work // Fallback to nmcli if iwconfig doesn't work
try { try {
const { stdout: activeWifi } = await execAsync("nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep -E '(wifi|802-11-wireless)' | cut -d':' -f1 | head -1"); const { stdout: activeWifi } = await execAsync("nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep -E '(wifi|802-11-wireless)' | cut -d':' -f1 | head -1");
if (activeWifi.trim()) { if (activeWifi.trim()) {
networkInfo.wifi.mode = 'client'; networkInfo.wifi.mode = 'client';
networkInfo.wifi.ssid = activeWifi.trim(); networkInfo.wifi.ssid = activeWifi.trim();
} else { } else {
// Try getting SSID from currently connected interface // Try getting SSID from currently connected interface
const { stdout: connectedSSID } = await execAsync("nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d':' -f2 | head -1"); const { stdout: connectedSSID } = await execAsync("nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d':' -f2 | head -1");
if (connectedSSID.trim()) { if (connectedSSID.trim()) {
networkInfo.wifi.mode = 'client'; networkInfo.wifi.mode = 'client';
networkInfo.wifi.ssid = connectedSSID.trim(); networkInfo.wifi.ssid = connectedSSID.trim();
} else { } else {
throw new Error('No active WiFi connection found'); throw new Error('No active WiFi connection found');
} }
} }
} catch { } catch {
// Final fallback: check if wlan0 interface is up and get info via ip // Final fallback: check if wlan0 interface is up and get info via ip
const { stdout: wlanStatus } = await execAsync("ip addr show wlan0 2>/dev/null | grep 'inet ' | wc -l"); const { stdout: wlanStatus } = await execAsync("ip addr show wlan0 2>/dev/null | grep 'inet ' | wc -l");
if (parseInt(wlanStatus.trim()) > 0) { if (parseInt(wlanStatus.trim()) > 0) {
// Interface has IP but we can't determine SSID // Interface has IP but we can't determine SSID
networkInfo.wifi.mode = 'client'; networkInfo.wifi.mode = 'client';
networkInfo.wifi.ssid = 'Connected (Unknown SSID)'; networkInfo.wifi.ssid = 'Connected (Unknown SSID)';
} else { } else {
networkInfo.wifi.mode = 'disconnected'; networkInfo.wifi.mode = 'disconnected';
networkInfo.wifi.ssid = 'Not Connected'; networkInfo.wifi.ssid = 'Not Connected';
} }
} }
} }
} catch (error) { } catch (error) {
console.warn('WiFi detection error:', error.message); console.warn('WiFi detection error:', error.message);
networkInfo.wifi.mode = 'disconnected'; networkInfo.wifi.mode = 'disconnected';
networkInfo.wifi.ssid = 'Not Connected'; networkInfo.wifi.ssid = 'Not Connected';
} }
} }
// Check Ethernet status // Check Ethernet status
try { try {
const { stdout: ethStatus } = await execAsync("ip link show eth0 | grep 'state UP'"); const { stdout: ethStatus } = await execAsync("ip link show eth0 | grep 'state UP'");
networkInfo.ethernet.connected = ethStatus.includes('state UP'); networkInfo.ethernet.connected = ethStatus.includes('state UP');
} catch { } catch {
networkInfo.ethernet.connected = false; networkInfo.ethernet.connected = false;
} }
} catch (error) { } catch (error) {
console.warn('Network info error:', error.message); console.warn('Network info error:', error.message);
} }
return networkInfo; return networkInfo;
} }
let loraInfo = null; let loraInfo = null;
async function getLoRaInfo() { async function getLoRaInfo() {
if(loraInfo) return loraInfo; if(loraInfo) return loraInfo;
loraInfo = { loraInfo = {
connected: false, connected: false,
onlineNodes: 0, onlineNodes: 0,
totalNodes: 0, totalNodes: 0,
channelUtilization: 0, channelUtilization: 0,
airtimeUtilization: 0 airtimeUtilization: 0
}; };
try { try {
console.log('Getting LoRa/Meshtastic status...'); console.log('Getting LoRa/Meshtastic status...');
// Get node information first // Get node information first
const { stdout: nodeList } = await execAsync('timeout 10s meshtastic --nodes 2>/dev/null', { timeout: 15000 }); const { stdout: nodeList } = await execAsync('timeout 10s meshtastic --nodes 2>/dev/null', { timeout: 15000 });
if (nodeList && nodeList.trim()) { if (nodeList && nodeList.trim()) {
loraInfo.connected = true; loraInfo.connected = true;
// Parse node list - look for actual node entries // Parse node list - look for actual node entries
const lines = nodeList.split('\n'); const lines = nodeList.split('\n');
let totalNodes = 0; let totalNodes = 0;
let onlineNodes = 0; let onlineNodes = 0;
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
// Skip header lines and separators // Skip header lines and separators
if (trimmedLine.includes('│') && if (trimmedLine.includes('│') &&
!trimmedLine.includes('User') && !trimmedLine.includes('User') &&
!trimmedLine.includes('───') && !trimmedLine.includes('───') &&
!trimmedLine.includes('Node') && !trimmedLine.includes('Node') &&
trimmedLine.length > 10) { trimmedLine.length > 10) {
totalNodes++; totalNodes++;
// Check if node is online (has recent lastHeard or SNR data) // Check if node is online (has recent lastHeard or SNR data)
if (trimmedLine.includes('SNR') || if (trimmedLine.includes('SNR') ||
trimmedLine.match(/\d+[smh]/) || // Time indicators like 5m, 2h, 30s trimmedLine.match(/\d+[smh]/) || // Time indicators like 5m, 2h, 30s
trimmedLine.includes('now')) { trimmedLine.includes('now')) {
onlineNodes++; onlineNodes++;
} }
} }
} }
loraInfo.totalNodes = totalNodes; loraInfo.totalNodes = totalNodes;
loraInfo.onlineNodes = onlineNodes; loraInfo.onlineNodes = onlineNodes;
} }
// Get channel and airtime utilization from --info // Get channel and airtime utilization from --info
try { try {
const { stdout: meshInfo } = await execAsync('timeout 10s meshtastic --info 2>/dev/null', { timeout: 15000 }); const { stdout: meshInfo } = await execAsync('timeout 10s meshtastic --info 2>/dev/null', { timeout: 15000 });
if (meshInfo && meshInfo.trim()) { if (meshInfo && meshInfo.trim()) {
const lines = meshInfo.split('\n'); const lines = meshInfo.split('\n');
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
// Look for channel utilization (various formats) // Look for channel utilization (various formats)
if (trimmedLine.includes('channelUtilization') || trimmedLine.includes('Channel utilization')) { if (trimmedLine.includes('channelUtilization') || trimmedLine.includes('Channel utilization')) {
const channelMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/); const channelMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/);
if (channelMatch) { if (channelMatch) {
loraInfo.channelUtilization = parseFloat(channelMatch[1]); loraInfo.channelUtilization = parseFloat(channelMatch[1]);
} }
} }
// Look for airtime utilization (various formats) // Look for airtime utilization (various formats)
if (trimmedLine.includes('airUtilTx') || trimmedLine.includes('Air time') || trimmedLine.includes('Airtime')) { if (trimmedLine.includes('airUtilTx') || trimmedLine.includes('Air time') || trimmedLine.includes('Airtime')) {
const airtimeMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/); const airtimeMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/);
if (airtimeMatch) { if (airtimeMatch) {
loraInfo.airtimeUtilization = parseFloat(airtimeMatch[1]); loraInfo.airtimeUtilization = parseFloat(airtimeMatch[1]);
} }
} }
} }
} }
} catch (infoError) { } catch (infoError) {
console.warn('Failed to get mesh info:', infoError.message); console.warn('Failed to get mesh info:', infoError.message);
} }
} catch (error) { } catch (error) {
console.warn('LoRa info error:', error.message); console.warn('LoRa info error:', error.message);
loraInfo.connected = false; loraInfo.connected = false;
} }
setTimeout(() => loraInfo = null, 60000) setTimeout(() => loraInfo = null, 60000)
return loraInfo; return loraInfo;
} }
// Main status generation function - matches frontend expectations // Main status generation function - matches frontend expectations
async function generateStatus() { async function generateStatus() {
try { try {
console.log('Generating fresh status...'); console.log('Generating fresh status...');
const [systemInfo, gpsInfo, networkInfo, loraInfo] = await Promise.all([ const [systemInfo, gpsInfo, networkInfo, loraInfo] = await Promise.all([
getSystemInfo(), getSystemInfo(),
getGPSInfo(), getGPSInfo(),
getNetworkInfo(), getNetworkInfo(),
getLoRaInfo() getLoRaInfo()
]); ]);
// Format exactly as the frontend expects // Format exactly as the frontend expects
const status = { const status = {
system: { system: {
temperature: systemInfo.temperature, temperature: systemInfo.temperature,
load: systemInfo.load, load: systemInfo.load,
cpu: systemInfo.cpu, cpu: systemInfo.cpu,
memory: systemInfo.memory, memory: systemInfo.memory,
storage: systemInfo.storage storage: systemInfo.storage
}, },
gps: { gps: {
fix: gpsInfo.fix, fix: gpsInfo.fix,
satellites: gpsInfo.satellites, satellites: gpsInfo.satellites,
latitude: gpsInfo.latitude, latitude: gpsInfo.latitude,
longitude: gpsInfo.longitude, longitude: gpsInfo.longitude,
altitude: gpsInfo.altitude altitude: gpsInfo.altitude
}, },
network: { network: {
wifi: networkInfo.wifi, wifi: networkInfo.wifi,
ethernet: networkInfo.ethernet ethernet: networkInfo.ethernet
}, },
lora: loraInfo, lora: loraInfo,
time: new Date().toISOString() time: new Date().toISOString()
}; };
return status; return status;
} catch (error) { } catch (error) {
console.error('Status generation error:', error); console.error('Status generation error:', error);
throw error; throw error;
} }
} }
router.get('/hostname', (req, res) => res.json({ hostname: environment.hostname })); router.get('/hostname', (req, res) => res.json({ hostname: environment.hostname }));
// Main status endpoint that matches frontend expectations // Main status endpoint that matches frontend expectations
router.get('/status', async (req, res) => { router.get('/status', async (req, res) => {
try { try {
const now = Date.now(); const now = Date.now();
// Return cached status if within TTL // Return cached status if within TTL
if (statusCache && (now - lastUpdate) < CACHE_TTL) { if (statusCache && (now - lastUpdate) < CACHE_TTL) {
console.log('Returning cached status'); console.log('Returning cached status');
return res.json(statusCache); return res.json(statusCache);
} }
// Generate fresh status // Generate fresh status
statusCache = await generateStatus(); statusCache = await generateStatus();
lastUpdate = now; lastUpdate = now;
console.log('Status updated successfully'); console.log('Status updated successfully');
res.json(statusCache); res.json(statusCache);
} catch (error) { } catch (error) {
console.error('Status API error:', error); console.error('Status API error:', error);
res.status(500).json({ res.status(500).json({
error: 'Failed to get system status', error: 'Failed to get system status',
message: error.message, message: error.message,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); });
} }
}); });
// Individual debug endpoints // Individual debug endpoints
router.get('/system', async (req, res) => { router.get('/system', async (req, res) => {
try { try {
const systemInfo = await getSystemInfo(); const systemInfo = await getSystemInfo();
res.json(systemInfo); res.json(systemInfo);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
router.get('/gps', async (req, res) => { router.get('/gps', async (req, res) => {
try { try {
const gpsInfo = await getGPSInfo(); const gpsInfo = await getGPSInfo();
res.json(gpsInfo); res.json(gpsInfo);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
router.get('/network', async (req, res) => { router.get('/network', async (req, res) => {
try { try {
const networkInfo = await getNetworkInfo(); const networkInfo = await getNetworkInfo();
res.json(networkInfo); res.json(networkInfo);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
// Add individual LoRa endpoint // Add individual LoRa endpoint
router.get('/lora', async (req, res) => { router.get('/lora', async (req, res) => {
try { try {
const loraInfo = await getLoRaInfo(); const loraInfo = await getLoRaInfo();
res.json(loraInfo); res.json(loraInfo);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
// Initialize GPS on startup // Initialize GPS on startup
initGPS().catch(err => console.warn('GPS initialization failed:', err.message)); initGPS().catch(err => console.warn('GPS initialization failed:', err.message));
export { router as statusRouter }; export { router as statusRouter };

370
code/package-lock.json generated
View File

@@ -1,370 +0,0 @@
{
"name": "dashboard",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dashboard",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"node-gpsd": "^0.3.4"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/node-gpsd": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/node-gpsd/-/node-gpsd-0.3.4.tgz",
"integrity": "sha512-sI9hPfHiaWDmhjE1oJZnhMo7UF2vQVGl3qk0K4HbN1L8BkS0I+rd6V687eHOj/6ervXiHrhwXzYYsWdXxGy0Qg==",
"engines": {
"node": ">=v0.8.0"
}
},
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
"dev": true,
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"bin": {
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nodemon"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
"dev": true,
"bin": {
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true
}
}
}

View File

@@ -1,19 +0,0 @@
{
"name": "MRS",
"short_name": "MRS",
"description": "Multipurpose Radio Server",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "/favicon.png",
"sizes": "512x512",
"type": "image/png"
}
],
"lang": "en-US",
"scope": "/"
}

View File

@@ -1,426 +0,0 @@
import express from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { readFileSync, existsSync } from 'fs';
import pkg from 'node-gpsd';
const { GPS } = pkg;
import {environment} from './environment.mjs';
const router = express.Router();
const execAsync = promisify(exec);
// Status cache with TTL
let statusCache = null;
let lastUpdate = 0;
const CACHE_TTL = 15 * 1000; // 15 seconds
// GPS client setup
let gpsClient = null;
let gpsData = {
fix: 'No Fix',
latitude: null,
longitude: null,
altitude: null,
satellites: 0
};
// Initialize GPS connection
async function initGPS() {
try {
gpsClient = new GPS({ hostname: 'localhost', port: 2947 });
gpsClient.on('TPV', (data) => {
gpsData.latitude = data.lat || null;
gpsData.longitude = data.lon || null;
gpsData.altitude = data.alt || null;
gpsData.fix = data.mode === 3 ? '3D Fix' : data.mode === 2 ? '2D Fix' : 'No Fix';
});
gpsClient.on('SKY', (data) => {
gpsData.satellites = (data.satellites || []).length;
});
gpsClient.on('error', (err) => {
console.warn('GPS error:', err.message);
});
await gpsClient.watch();
console.log('GPS client initialized');
} catch (error) {
console.warn('GPS not available:', error.message);
}
}
// CLI-based system information gathering
async function getSystemInfo() {
const info = {
temperature: 0,
load: 0,
cpu: 0,
memory: 0,
storage: 0
};
try {
// CPU temperature (Raspberry Pi specific)
if (existsSync('/sys/class/thermal/thermal_zone0/temp')) {
const tempData = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8');
info.temperature = Math.round(parseInt(tempData.trim()) / 1000);
}
// Load average
const { stdout: loadAvg } = await execAsync("cat /proc/loadavg | awk '{print $1}'");
info.load = parseFloat(loadAvg.trim()) || 0;
// CPU usage
const { stdout: cpuUsage } = await execAsync("grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$3+$4+$5)} END {print usage}'");
info.cpu = parseFloat(cpuUsage.trim()) || 0;
// Memory usage percentage
const { stdout: memUsage } = await execAsync("free | awk 'NR==2{printf \"%.1f\", $3*100/$2}'");
info.memory = parseFloat(memUsage.trim()) || 0;
// Disk usage percentage for root filesystem
const { stdout: diskUsage } = await execAsync("df / | awk 'NR==2{printf \"%.1f\", $5}' | sed 's/%//'");
info.storage = parseFloat(diskUsage.trim()) || 0;
} catch (error) {
console.warn('System info error:', error.message);
}
return info;
}
async function getGPSInfo() {
const gpsInfo = {
satellites: 0,
latitude: null,
longitude: null,
altitude: null
};
try {
// Check if gpsd is running
await execAsync('systemctl is-active gpsd');
// Try to get GPS data using gpspipe
const { stdout: gpsRaw } = await execAsync('timeout 3s gpspipe -w -n 5 | grep -E \'"class":"TPV"\' | head -1', { timeout: 4000 });
if (gpsRaw.trim()) {
const gpsJson = JSON.parse(gpsRaw.trim());
gpsInfo.latitude = gpsJson.lat || null;
gpsInfo.longitude = gpsJson.lon || null;
gpsInfo.altitude = gpsJson.alt || null;
}
// Get satellite count
try {
const { stdout: skyRaw } = await execAsync('timeout 2s gpspipe -w -n 3 | grep -E \'"class":"SKY"\' | head -1', { timeout: 3000 });
if (skyRaw.trim()) {
const skyJson = JSON.parse(skyRaw.trim());
gpsInfo.satellites = (skyJson.satellites || []).length;
}
} catch (skyError) { }
} catch (error) {
console.warn('GPS info error:', error.message);
// Use cached GPS data from node-gpsd if available
if (gpsData.latitude !== null) {
return gpsData;
}
}
return gpsInfo;
}
async function getNetworkInfo() {
const networkInfo = {
wifi: {
mode: 'client',
ssid: 'Unknown'
},
ethernet: {
connected: false
}
};
try {
// Check if hostapd is running (AP mode)
try {
await execAsync('systemctl is-active hostapd');
networkInfo.wifi.mode = 'ap';
// Get AP SSID from hostapd config or use hostname
try {
const { stdout: hostname } = await execAsync('hostname');
networkInfo.wifi.ssid = process.env.AP_SSID || hostname.trim();
} catch {
networkInfo.wifi.ssid = 'MRS-AP';
}
} catch {
// Not in AP mode, check if connected to WiFi
try {
// First, try iwconfig directly since it's more reliable for ESSID
const { stdout: iwconfig } = await execAsync("iwconfig wlan0 2>/dev/null");
const essidMatch = iwconfig.match(/ESSID:"([^"]+)"/);
if (essidMatch && essidMatch[1] && essidMatch[1] !== 'off/any') {
networkInfo.wifi.mode = 'client';
networkInfo.wifi.ssid = essidMatch[1];
} else {
// Fallback to nmcli if iwconfig doesn't work
try {
const { stdout: activeWifi } = await execAsync("nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep -E '(wifi|802-11-wireless)' | cut -d':' -f1 | head -1");
if (activeWifi.trim()) {
networkInfo.wifi.mode = 'client';
networkInfo.wifi.ssid = activeWifi.trim();
} else {
// Try getting SSID from currently connected interface
const { stdout: connectedSSID } = await execAsync("nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d':' -f2 | head -1");
if (connectedSSID.trim()) {
networkInfo.wifi.mode = 'client';
networkInfo.wifi.ssid = connectedSSID.trim();
} else {
throw new Error('No active WiFi connection found');
}
}
} catch {
// Final fallback: check if wlan0 interface is up and get info via ip
const { stdout: wlanStatus } = await execAsync("ip addr show wlan0 2>/dev/null | grep 'inet ' | wc -l");
if (parseInt(wlanStatus.trim()) > 0) {
// Interface has IP but we can't determine SSID
networkInfo.wifi.mode = 'client';
networkInfo.wifi.ssid = 'Connected (Unknown SSID)';
} else {
networkInfo.wifi.mode = 'disconnected';
networkInfo.wifi.ssid = 'Not Connected';
}
}
}
} catch (error) {
console.warn('WiFi detection error:', error.message);
networkInfo.wifi.mode = 'disconnected';
networkInfo.wifi.ssid = 'Not Connected';
}
}
// Check Ethernet status
try {
const { stdout: ethStatus } = await execAsync("ip link show eth0 | grep 'state UP'");
networkInfo.ethernet.connected = ethStatus.includes('state UP');
} catch {
networkInfo.ethernet.connected = false;
}
} catch (error) {
console.warn('Network info error:', error.message);
}
return networkInfo;
}
let loraInfo = null;
async function getLoRaInfo() {
if(loraInfo) return loraInfo;
loraInfo = {
connected: false,
onlineNodes: 0,
totalNodes: 0,
channelUtilization: 0,
airtimeUtilization: 0
};
try {
console.log('Getting LoRa/Meshtastic status...');
// Get node information first
const { stdout: nodeList } = await execAsync('timeout 10s meshtastic --nodes 2>/dev/null', { timeout: 15000 });
if (nodeList && nodeList.trim()) {
loraInfo.connected = true;
// Parse node list - look for actual node entries
const lines = nodeList.split('\n');
let totalNodes = 0;
let onlineNodes = 0;
for (const line of lines) {
const trimmedLine = line.trim();
// Skip header lines and separators
if (trimmedLine.includes('│') &&
!trimmedLine.includes('User') &&
!trimmedLine.includes('───') &&
!trimmedLine.includes('Node') &&
trimmedLine.length > 10) {
totalNodes++;
// Check if node is online (has recent lastHeard or SNR data)
if (trimmedLine.includes('SNR') ||
trimmedLine.match(/\d+[smh]/) || // Time indicators like 5m, 2h, 30s
trimmedLine.includes('now')) {
onlineNodes++;
}
}
}
loraInfo.totalNodes = totalNodes;
loraInfo.onlineNodes = onlineNodes;
}
// Get channel and airtime utilization from --info
try {
const { stdout: meshInfo } = await execAsync('timeout 10s meshtastic --info 2>/dev/null', { timeout: 15000 });
if (meshInfo && meshInfo.trim()) {
const lines = meshInfo.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
// Look for channel utilization (various formats)
if (trimmedLine.includes('channelUtilization') || trimmedLine.includes('Channel utilization')) {
const channelMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/);
if (channelMatch) {
loraInfo.channelUtilization = parseFloat(channelMatch[1]);
}
}
// Look for airtime utilization (various formats)
if (trimmedLine.includes('airUtilTx') || trimmedLine.includes('Air time') || trimmedLine.includes('Airtime')) {
const airtimeMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/);
if (airtimeMatch) {
loraInfo.airtimeUtilization = parseFloat(airtimeMatch[1]);
}
}
}
}
} catch (infoError) {
console.warn('Failed to get mesh info:', infoError.message);
}
} catch (error) {
console.warn('LoRa info error:', error.message);
loraInfo.connected = false;
}
setTimeout(() => loraInfo = null, 60000)
return loraInfo;
}
// Main status generation function - matches frontend expectations
async function generateStatus() {
try {
console.log('Generating fresh status...');
const [systemInfo, gpsInfo, networkInfo, loraInfo] = await Promise.all([
getSystemInfo(),
getGPSInfo(),
getNetworkInfo(),
getLoRaInfo()
]);
// Format exactly as the frontend expects
const status = {
system: {
temperature: systemInfo.temperature,
load: systemInfo.load,
cpu: systemInfo.cpu,
memory: systemInfo.memory,
storage: systemInfo.storage
},
gps: {
fix: gpsInfo.fix,
satellites: gpsInfo.satellites,
latitude: gpsInfo.latitude,
longitude: gpsInfo.longitude,
altitude: gpsInfo.altitude
},
network: {
wifi: networkInfo.wifi,
ethernet: networkInfo.ethernet
},
lora: loraInfo,
time: new Date().toISOString()
};
return status;
} catch (error) {
console.error('Status generation error:', error);
throw error;
}
}
router.get('/hostname', (req, res) => res.json({ hostname: environment.hostname }));
// Main status endpoint that matches frontend expectations
router.get('/status', async (req, res) => {
try {
const now = Date.now();
// Return cached status if within TTL
if (statusCache && (now - lastUpdate) < CACHE_TTL) {
console.log('Returning cached status');
return res.json(statusCache);
}
// Generate fresh status
statusCache = await generateStatus();
lastUpdate = now;
console.log('Status updated successfully');
res.json(statusCache);
} catch (error) {
console.error('Status API error:', error);
res.status(500).json({
error: 'Failed to get system status',
message: error.message,
timestamp: new Date().toISOString()
});
}
});
// Individual debug endpoints
router.get('/system', async (req, res) => {
try {
const systemInfo = await getSystemInfo();
res.json(systemInfo);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/gps', async (req, res) => {
try {
const gpsInfo = await getGPSInfo();
res.json(gpsInfo);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/network', async (req, res) => {
try {
const networkInfo = await getNetworkInfo();
res.json(networkInfo);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Add individual LoRa endpoint
router.get('/lora', async (req, res) => {
try {
const loraInfo = await getLoRaInfo();
res.json(loraInfo);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Initialize GPS on startup
initGPS().catch(err => console.warn('GPS initialization failed:', err.message));
export { router as statusRouter };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,939 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>MRS Dashboard</title>
<link rel="icon" href="/favicon.png">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
min-height: 100vh;
color: #fff;
overflow-x: hidden;
}
.header {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas: "logo status clocks";
align-items: center;
gap: 1rem;
}
.logo-section {
grid-area: logo;
display: flex;
align-items: center;
gap: 1rem;
justify-self: start;
}
.logo-section img {
height: 3rem;
width: auto;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 0.05rem;
}
.hostname {
color: #94a3b8;
}
.header-info {
grid-area: clocks;
justify-self: end;
}
.network-status-wrapper {
grid-area: status;
display: flex;
flex-direction: column;
align-items: center;
justify-self: center;
}
.network-status {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
padding: 0.5rem 0;
}
.network-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0.4rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: all 0.2s ease;
}
.network-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.network-icon {
width: 20px;
height: 20px;
stroke-width: 2;
fill: none;
transition: all 0.3s ease;
}
.expand-toggle {
cursor: pointer;
padding: 0.4rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.expand-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.expand-icon {
width: 20px;
height: 20px;
stroke: #94a3b8;
stroke-width: 2;
fill: none;
transition: transform 0.3s ease;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.expanded-status {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.expanded-status.visible {
max-height: 300px;
}
.status-tiles {
display: flex;
gap: 1rem;
padding: 1.5rem 2rem;
max-width: 1400px;
margin: 0 auto;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.status-tiles::-webkit-scrollbar {
height: 8px;
}
.status-tiles::-webkit-scrollbar-track {
background: transparent;
}
.status-tiles::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.status-tiles::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.status-tile {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: space-between;
flex: 0 0 auto;
width: 170px;
}
.tile-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.tile-icon {
width: 24px;
height: 24px;
stroke-width: 2;
}
.tile-name {
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05rem;
}
.tile-info {
font-size: 0.85rem;
color: #94a3b8;
margin-top: 0.25rem;
}
.tile-detail {
font-size: 0.75rem;
color: #64748b;
}
.network-item.disconnected .network-icon,
.status-tile.disconnected .tile-icon {
stroke: #64748b;
}
.status-tile.disconnected .tile-name {
color: #64748b;
}
.status-tile.connected {
border-color: rgba(16, 185, 129, 0.3);
}
.network-item.connected .network-icon,
.status-tile.connected .tile-icon {
stroke: #10b981;
}
.status-tile.connected .tile-name {
color: #10b981;
}
.time-display {
display: flex;
gap: 2rem;
font-size: 0.85rem;
}
.time-block {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.time-label {
color: #94a3b8;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05rem;
margin-bottom: 0.2rem;
}
.time-value {
font-weight: 600;
font-family: 'Courier New', monospace;
}
.time-date {
color: #64748b;
font-size: 0.75rem;
margin-top: 0.1rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.search-bar {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem 1.5rem;
margin-bottom: 2rem;
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.3s ease;
}
.search-bar:focus-within {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
.search-icon {
width: 20px;
height: 20px;
stroke: #64748b;
stroke-width: 2;
fill: none;
}
.search-input {
flex: 1;
background: none;
border: none;
color: #fff;
font-size: 1rem;
outline: none;
}
.search-input::placeholder {
color: #64748b;
}
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1.5rem;
}
.app-item {
text-decoration: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
transition: all 0.2s ease;
position: relative;
}
.second-action {
position: absolute;
top: -6px;
right: -10px;
display: flex;
align-items: center;
padding: 5px;
border-radius: 50%;
background: #5a6b63;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.second-action:hover {
background: #6a7b73;
transform: scale(1.05);
}
.second-action:active {
transform: scale(0.95);
}
.second-action svg {
stroke: white;
width: 18px;
height: 18px;
}
.app-badge svg {
width: 14px;
height: 14px;
stroke: #fff;
stroke-width: 2;
fill: none;
}
.app-icon-wrapper {
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
position: relative;
}
.app-item:hover .app-icon-wrapper {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.app-item:active .app-icon-wrapper {
transform: scale(0.95);
}
#temp {
color: #94a3b8;
}
.app-icon {
width: 40px;
height: 40px;
stroke: #fff;
stroke-width: 1.5;
fill: none;
}
.app-name {
color: #fff;
font-size: 0.9rem;
font-weight: 500;
text-align: center;
line-height: 1.2;
}
.tile-wide {
width: 360px;
}
.system-metrics {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.system-metric {
display: flex;
align-items: center;
gap: 0.75rem;
}
.metric-label {
font-size: 0.8rem;
font-weight: 500;
color: #cdd5e0;
width: 80px;
}
.metric-label span {
font-size: 0.7rem;
color: #94a3b8;
margin-left: 4px;
}
.progress-bar {
flex-grow: 1;
height: 14px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 6px;
position: relative;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: #10b981;
border-radius: 6px;
transition: width 0.5s ease-in-out;
}
.progress-bar-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-size: 0.8rem;
font-weight: 600;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
}
.hidden {
display: none;
}
/* Responsive breakpoints */
@media (max-width: 800px) {
.header-content {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"logo clocks"
"status status";
justify-items: start;
}
.logo-section {
justify-self: start;
}
.header-info {
justify-self: end;
}
.network-status-wrapper {
justify-self: center;
grid-column: 1 / -1;
margin-top: 0.5rem;
}
}
@media (max-width: 500px) {
.header-content {
grid-template-columns: 1fr;
grid-template-areas:
"logo"
"clocks"
"status";
justify-items: center;
}
.logo-section {
justify-self: center;
}
.header-info {
justify-self: center;
}
.time-display {
justify-content: center;
}
.time-block {
align-items: center;
}
.network-status-wrapper {
margin-top: 1rem;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-content">
<div class="logo-section">
<img src="/favicon.png" alt="Logo">
<div>
<div class="logo">DASHBOARD</div>
<div class="hostname">Hostname</div>
</div>
</div>
<div class="header-info">
<div class="time-display">
<div class="time-block">
<div class="time-label">Local</div>
<div class="time-value" id="local-time">00:00:00</div>
<div class="time-date" id="local-date">Jan 1, 2025</div>
</div>
<div class="time-block">
<div class="time-label">Zulu</div>
<div class="time-value" id="zulu-time">00:00:00</div>
<div class="time-date" id="zulu-date">Jan 1, 2025</div>
</div>
</div>
</div>
<div class="network-status-wrapper">
<div class="network-status">
<div class="network-item disconnected" id="gps-status">
<svg class="network-icon" viewBox="0 0 24 24">
<g transform="translate(2,2) rotate(-45 12 12)">
<rect x="9.5" y="1" width="5" height="8.5" rx="1" ry="1" fill="transparent"/>
<rect x="1" y="6" width="6" height="4"/>
<rect x="17" y="6" width="6" height="4"/>
<line x1="1" y1="8" x2="23" y2="8"/>
<defs>
<clipPath id="halfCircle">
<rect x="0" y="5" width="100%" height="42%" />
</clipPath>
</defs>
<circle cx="12" cy="17.5" r="6px" clip-path="url(#halfCircle)" fill="transparent" />
<line x1="12" y1="12" x2="12" y2="17" stroke-linecap="round" />
</g>
</svg>
</div>
<div class="network-item connected" id="lora-status">
<svg class="network-icon" viewBox="0 0 24 24">
<line x1="0" y1="18" x2="8" y2="6"/>
<line x1="8" y1="18" x2="16" y2="6"/>
<line x1="16" y1="6" x2="24" y2="18"/>
</svg>
</div>
<div class="network-item connected" id="mesh-status">
<svg class="network-icon" viewBox="0 0 24 24" fill="none">
<circle cx="5" cy="5" r="2" />
<line x1="6" y1="6" x2="15" y2="4" />
<line x1="6" y1="6" x2="20" y2="12" />
<line x1="6" y1="6" x2="13.5" y2="19" />
<line x1="6" y1="6" x2="5" y2="15" />
<circle cx="15.5" cy="3" r="2" />
<line x1="15" y1="4" x2="20" y2="12" />
<line x1="15" y1="4" x2="13.5" y2="19" />
<line x1="15" y1="4" x2="5" y2="15" />
<circle cx="21" cy="12" r="2" />
<line x1="20" y1="12" x2="13.5" y2="19" />
<circle cx="14" cy="20.5" r="2" />
<line x1="13.5" y1="19" x2="5" y2="15" />
<circle cx="4" cy="16" r="2" />
</svg>
</div>
<div class="network-item connected" id="wifi-status">
<svg class="network-icon" viewBox="0 0 24 24">
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<line x1="12" y1="20" x2="12.01" y2="20"/>
</svg>
</div>
<div class="network-item disconnected" id="ethernet-status">
<svg class="network-icon" viewBox="0 0 24 24" fill="none">
<rect x="5" y="9" width="14" height="10" rx="1" ry="1"/>
<rect x="9" y="5" width="6" height="4" rx="0.5" ry="0.5"/>
</svg>
</div>
<div class="expand-toggle" id="expand-toggle">
<svg class="expand-icon" id="expand-icon" viewBox="0 0 24 24">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
</div>
</div>
</div>
<div class="expanded-status" id="expanded-status">
<div class="status-tiles">
<div class="status-tile connected tile-wide" id="system-tile">
<div class="tile-header">
<svg class="tile-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="10" y1="6" x2="10.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
<line x1="10" y1="18" x2="10.01" y2="18"></line>
</svg>
<div class="tile-name">System <span id="temp">?°C</span></div>
</div>
<div class="system-metrics">
<div class="system-metric">
<div class="metric-label">CPU <span id="load">- Load</span></div>
<div class="progress-bar">
<div class="progress-bar-fill" id="cpu-usage-fill"></div>
<div class="progress-bar-text" id="cpu-usage-text">0%</div>
</div>
</div>
<div class="system-metric">
<div class="metric-label">Memory</div>
<div class="progress-bar">
<div class="progress-bar-fill" id="memory-usage-fill"></div>
<div class="progress-bar-text" id="memory-usage-text">0%</div>
</div>
</div>
<div class="system-metric">
<div class="metric-label">Disk</div>
<div class="progress-bar">
<div class="progress-bar-fill" id="disk-usage-fill"></div>
<div class="progress-bar-text" id="disk-usage-text">0%</div>
</div>
</div>
</div>
</div>
<div class="status-tile disconnected" id="gps-tile">
<div class="tile-header">
<svg class="tile-icon" viewBox="0 0 24 24">
<g transform="translate(2,2) rotate(-45 12 12)">
<rect x="9.5" y="1" width="5" height="8.5" rx="1" ry="1" fill="transparent" />
<rect x="1" y="6" width="6" height="4"/>
<rect x="17" y="6" width="6" height="4"/>
<line x1="1" y1="8" x2="23" y2="8"/>
<defs>
<clipPath id="halfCircle">
<rect x="0" y="5" width="100%" height="42%" />
</clipPath>
</defs>
<circle cx="12" cy="17.5" r="6px" clip-path="url(#halfCircle)" fill="transparent" />
<line x1="12" y1="12" x2="12" y2="17" stroke-linecap="round" />
</g>
</svg>
<div class="tile-name">GPS</div>
</div>
<div>
<div class="tile-info">39.2000, -49.0000</div>
<div class="tile-detail">No Fix</div>
</div>
</div>
<div class="status-tile disconnected" id="lora-tile">
<div class="tile-header">
<svg class="tile-icon" viewBox="0 0 24 24">
<line x1="0" y1="18" x2="8" y2="6"/>
<line x1="8" y1="18" x2="16" y2="6"/>
<line x1="16" y1="6" x2="24" y2="18"/>
</svg>
<div class="tile-name">LoRa</div>
</div>
<div>
<div class="tile-info">0 / 0 Online</div>
<div class="tile-detail">Ch 0% Air 0%</div>
</div>
</div>
<div class="status-tile connected" id="mesh-tile">
<div class="tile-header">
<svg class="tile-icon" viewBox="0 0 24 24" fill="none">
<circle cx="5" cy="5" r="2" />
<line x1="6" y1="6" x2="15" y2="4" />
<line x1="6" y1="6" x2="20" y2="12" />
<line x1="6" y1="6" x2="13.5" y2="19" />
<line x1="6" y1="6" x2="5" y2="15" />
<circle cx="15.5" cy="3" r="2" />
<line x1="15" y1="4" x2="20" y2="12" />
<line x1="15" y1="4" x2="13.5" y2="19" />
<line x1="15" y1="4" x2="5" y2="15" />
<circle cx="21" cy="12" r="2" />
<line x1="20" y1="12" x2="13.5" y2="19" />
<circle cx="14" cy="20.5" r="2" />
<line x1="13.5" y1="19" x2="5" y2="15" />
<circle cx="4" cy="16" r="2" />
</svg>
<div class="tile-name">Mesh</div>
</div>
<div>
<div class="tile-info">Connected</div>
<div class="tile-detail">900 Mhz -45 dBm</div>
</div>
</div>
<div class="status-tile connected" id="wifi-tile">
<div class="tile-header">
<svg class="tile-icon" viewBox="0 0 24 24">
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<line x1="12" y1="20" x2="12.01" y2="20"/>
</svg>
<div class="tile-name">WiFi</div>
</div>
<div>
<div class="tile-info">Access Point</div>
<div class="tile-detail">SSID: Hostname</div>
</div>
</div>
<div class="status-tile disconnected" id="ethernet-tile">
<div class="tile-header">
<svg class="tile-icon" viewBox="0 0 24 24" fill="none">
<rect x="5" y="9" width="14" height="10" rx="1" ry="1"/>
<rect x="9" y="5" width="6" height="4" rx="0.5" ry="0.5"/>
</svg>
<div class="tile-name">Ethernet</div>
</div>
<div>
<div class="tile-info">Disconnected</div>
<div class="tile-detail">No cable detected</div>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<div class="search-bar">
<svg class="search-icon" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" class="search-input" placeholder="Search apps..." id="search-input">
</div>
<div class="apps-grid" id="apps-grid">
<a href=":1000" class="app-item" data-name="AI Assistant">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" ry="2"/>
<line x1="2" y1="9" x2="6" y2="9"/>
<line x1="2" y1="12" x2="6" y2="12"/>
<line x1="2" y1="15" x2="6" y2="15"/>
<line x1="18" y1="9" x2="22" y2="9"/>
<line x1="18" y1="12" x2="22" y2="12"/>
<line x1="18" y1="15" x2="22" y2="15"/>
</svg>
</div>
<div class="app-name">AI Assistant</div>
</a>
<a href=":1100" class="app-item" data-name="ATAK">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<div class="second-action">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<line x1="12" y1="0" x2="12" y2="24" />
<line x1="4" y1="11" x2="12" y2="21" />
<line x1="20" y1="11" x2="12" y2="21" />
<line x1="1" y1="22" x2="23" y2="22" />
</svg>
</div>
</div>
<div class="app-name">ATAK</div>
</a>
<a href=":1200" class="app-item" data-name="File Browser">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="app-name">File Browser</div>
</a>
<a href=":1300" class="app-item" data-name="Library">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
<div class="second-action" onclick="openKiwixm(event)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<circle cx="12" cy="12" r="6.5" stroke-width="5" />
<line x1="12" y1="7" x2="12" y2=".5" />
<line x1="12" y1="17" x2="12" y2="23.5" />
<line x1="0.5" y1="12" x2="7" y2="12" />
<line x1="17" y1="12" x2="23.5" y2="12" />
<line x1="15" y1="15" x2="20" y2="20" />
<line x1="4" y1="4" x2="9" y2="9" />
<line x1="20" y1="4" x2="15" y2="9" />
<line x1="4" y1="20" x2="9" y2="15" />
</svg>
</div>
</div>
<div class="app-name">Library</div>
</a>
<a href=":1400" class="app-item" data-name="Maps">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3z"/>
<line x1="9" y1="4" x2="9" y2="17"/>
<line x1="15" y1="7" x2="15" y2="20"/>
</svg>
</div>
<div class="app-name">Maps</div>
</a>
<a href=":1500" class="app-item" data-name="Meshtastic">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<line x1="0" y1="18" x2="8" y2="6"/>
<line x1="8" y1="18" x2="16" y2="6"/>
<line x1="16" y1="6" x2="24" y2="18"/>
</svg>
<div class="second-action">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<line x1="12" y1="0" x2="12" y2="24" />
<line x1="4" y1="11" x2="12" y2="21" />
<line x1="20" y1="11" x2="12" y2="21" />
<line x1="1" y1="22" x2="23" y2="22" />
</svg>
</div>
</div>
<div class="app-name">Meshtastic</div>
</a>
<a href=":1600" class="app-item" data-name="Messages">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<div class="second-action">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<line x1="12" y1="0" x2="12" y2="24" />
<line x1="4" y1="11" x2="12" y2="21" />
<line x1="20" y1="11" x2="12" y2="21" />
<line x1="1" y1="22" x2="23" y2="22" />
</svg>
</div>
</div>
<div class="app-name">Messages</div>
</a>
<a href=":1700" class="app-item" data-name="Pentesting">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<div class="app-name">Pentesting</div>
</a>
<a href=":1800" class="app-item" data-name="Remote Desktop">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<div class="app-name">Remote Desktop</div>
</a>
<a href=":1900" class="app-item" data-name="Software Defined Radio">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="1.5"/>
<path d="M16.24 7.76a6 6 0 0 1 0 8.49"/>
<path d="M7.76 16.24a6 6 0 0 1 0-8.49"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
<path d="M4.93 19.07a10 10 0 0 1 0-14.14"/>
<line x1="12" y1="10" x2="12" y2="24"/>
</svg>
</div>
<div class="app-name">SDR</div>
</a>
<a href=":2100" class="app-item" data-name="Speed Test">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="app-name">Situation Room</div>
</a>
<a href=":2100" class="app-item" data-name="Speed Test">
<div class="app-icon-wrapper">
<svg class="app-icon" viewBox="0 0 24 24">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
</div>
<div class="app-name">Speed Test</div>
</a>
</div>
</div>
<script src="index.js"></script>
</body>
</html>

View File

@@ -1,368 +0,0 @@
class Dashboard {
constructor() {
this.statusCache = null;
this.lastFetch = 0;
this.hostname = 'localhost';
this.serverTimeOffset = null; // Track difference between server and client time
this.isTimeServerSynced = false; // Track if we're using server time
this.init();
}
async init() {
await this.fetchHostname();
this.updateHostname();
this.startTimeUpdates(); // Start with client time immediately
this.startStatusUpdates();
this.initializeEventListeners();
}
async fetchHostname() {
try {
const response = await fetch('/api/hostname');
const data = await response.json();
this.hostname = data.hostname;
} catch (error) {
console.warn('Failed to fetch hostname:', error);
}
}
updateHostname() {
const hostnameElement = document.querySelector('.hostname');
if (hostnameElement) {
hostnameElement.textContent = this.hostname;
}
}
async fetchStatus() {
try {
// Record start time for latency calculation
const requestStart = performance.now();
const response = await fetch('/api/status');
const requestEnd = performance.now();
if (!response.ok) throw new Error('Status fetch failed');
const status = await response.json();
this.statusCache = status;
this.updateUI(status, requestStart, requestEnd);
} catch (error) {
console.error('Failed to fetch status:', error);
this.showOfflineStatus();
}
}
updateUI(status, requestStart, requestEnd) {
this.updateSystemMetrics(status.system);
this.updateGPSStatus(status.gps);
this.updateNetworkStatus(status.network);
this.updateLoRaStatus(status.lora);
this.updateTimeSync(status.time, requestStart, requestEnd);
}
updateSystemMetrics(system) {
// Update temperature
const tempElement = document.getElementById('temp');
if (tempElement && system.temperature) {
tempElement.textContent = `${system.temperature}°C`;
}
// Update load average
const loadElement = document.getElementById('load');
if (loadElement && system.load !== undefined) {
loadElement.textContent = `${system.load.toFixed(1)} Load`;
}
// Update progress bars
this.updateProgressBar('cpu-usage', system.cpu);
this.updateProgressBar('memory-usage', system.memory);
this.updateProgressBar('disk-usage', system.storage);
}
updateProgressBar(id, percentage) {
const fillElement = document.getElementById(`${id}-fill`);
const textElement = document.getElementById(`${id}-text`);
if (fillElement && textElement) {
const value = Math.round(percentage);
fillElement.style.width = `${value}%`;
textElement.textContent = `${value}%`;
// Update color based on usage
if (value > 80) {
fillElement.style.background = '#ef4444'; // Red
} else if (value > 60) {
fillElement.style.background = '#f59e0b'; // Orange
} else {
fillElement.style.background = '#10b981'; // Green
}
}
}
updateGPSStatus(gps) {
const gpsStatus = document.getElementById('gps-status');
const gpsTile = document.getElementById('gps-tile');
if (gpsStatus && gpsTile) {
const hasValidFix = gps.fix !== 'No Fix' && gps.latitude !== null;
// Update status indicator
if (hasValidFix) {
gpsStatus.classList.remove('disconnected');
gpsStatus.classList.add('connected');
gpsTile.classList.remove('disconnected');
gpsTile.classList.add('connected');
} else {
gpsStatus.classList.remove('connected');
gpsStatus.classList.add('disconnected');
gpsTile.classList.remove('connected');
gpsTile.classList.add('disconnected');
}
// Update GPS tile info
const gpsInfo = gpsTile.querySelector('.tile-info');
const gpsDetail = gpsTile.querySelector('.tile-detail');
if (gpsInfo && gpsDetail) {
if (hasValidFix) {
gpsInfo.textContent = `${gps.latitude.toFixed(4)}, ${gps.longitude.toFixed(4)}`;
gpsDetail.textContent = `${gps.fix}${gps.satellites} sats • Alt: ${Math.round(gps.altitude)}m`;
} else {
gpsInfo.textContent = gps.satellites > 0 ? 'Acquiring Fix...' : 'No Signal';
gpsDetail.textContent = `${gps.satellites} satellites`;
}
}
}
}
updateNetworkStatus(network) {
// Update WiFi status
const wifiTile = document.getElementById('wifi-tile');
if (wifiTile) {
const wifiInfo = wifiTile.querySelector('.tile-info');
const wifiDetail = wifiTile.querySelector('.tile-detail');
if (wifiInfo && wifiDetail) {
if (network.wifi.mode === 'ap') {
wifiInfo.textContent = 'Access Point';
wifiDetail.textContent = `SSID: ${network.wifi.ssid}`;
} else {
wifiInfo.textContent = 'Client Mode';
wifiDetail.textContent = `SSID: ${network.wifi.ssid}`;
}
}
}
// Update Ethernet status
const ethernetStatus = document.getElementById('ethernet-status');
const ethernetTile = document.getElementById('ethernet-tile');
if (ethernetStatus && ethernetTile) {
if (network.ethernet.connected) {
ethernetStatus.classList.remove('disconnected');
ethernetStatus.classList.add('connected');
ethernetTile.classList.remove('disconnected');
ethernetTile.classList.add('connected');
const ethernetInfo = ethernetTile.querySelector('.tile-info');
const ethernetDetail = ethernetTile.querySelector('.tile-detail');
if (ethernetInfo && ethernetDetail) {
ethernetInfo.textContent = 'Connected';
ethernetDetail.textContent = 'Link detected';
}
} else {
ethernetStatus.classList.remove('connected');
ethernetStatus.classList.add('disconnected');
ethernetTile.classList.remove('connected');
ethernetTile.classList.add('disconnected');
const ethernetInfo = ethernetTile.querySelector('.tile-info');
const ethernetDetail = ethernetTile.querySelector('.tile-detail');
if (ethernetInfo && ethernetDetail) {
ethernetInfo.textContent = 'Disconnected';
ethernetDetail.textContent = 'No cable detected';
}
}
}
}
updateLoRaStatus(lora) {
const loraStatus = document.getElementById('lora-status');
const loraTile = document.getElementById('lora-tile');
if (loraStatus && loraTile) {
if (lora.connected) {
loraStatus.classList.remove('disconnected');
loraStatus.classList.add('connected');
loraTile.classList.remove('disconnected');
loraTile.classList.add('connected');
const loraInfo = loraTile.querySelector('.tile-info');
const loraDetail = loraTile.querySelector('.tile-detail');
if (loraInfo && loraDetail) {
loraInfo.textContent = `${lora.onlineNodes}/${lora.totalNodes} Online`;
loraDetail.textContent = `CH: ${lora.channelUtilization.toFixed(1)}% • Air: ${lora.airtimeUtilization.toFixed(1)}%`;
}
} else {
loraStatus.classList.remove('connected');
loraStatus.classList.add('disconnected');
loraTile.classList.remove('connected');
loraTile.classList.add('disconnected');
const loraInfo = loraTile.querySelector('.tile-info');
const loraDetail = loraTile.querySelector('.tile-detail');
if (loraInfo && loraDetail) {
loraInfo.textContent = 'Disconnected';
loraDetail.textContent = 'No device found';
}
}
}
}
updateTimeSync(serverTimeString, requestStart, requestEnd) {
if (serverTimeString && requestStart && requestEnd) {
// Calculate network latency (round-trip time / 2)
const networkLatency = (requestEnd - requestStart) / 2;
// Parse server time and adjust for network latency
const serverTime = new Date(serverTimeString);
const adjustedServerTime = new Date(serverTime.getTime() + networkLatency);
// Calculate the offset between adjusted server time and client time
const clientTime = new Date();
this.serverTimeOffset = adjustedServerTime.getTime() - clientTime.getTime();
this.isTimeServerSynced = true;
console.log(`Time synchronized with server (latency: ${networkLatency.toFixed(1)}ms, offset: ${this.serverTimeOffset.toFixed(1)}ms)`);
}
// Update time displays
this.updateCurrentTime();
}
updateCurrentTime() {
const now = new Date();
let displayTime;
if (this.isTimeServerSynced && this.serverTimeOffset !== null) {
// Use server-synchronized time
displayTime = new Date(now.getTime() + this.serverTimeOffset);
} else {
// Use client time (default)
displayTime = now;
}
this.updateTimeDisplay('local', displayTime, false);
this.updateTimeDisplay('zulu', displayTime, true);
}
updateTimeDisplay(type, date, isUtc) {
const timeElement = document.getElementById(`${type}-time`);
const dateElement = document.getElementById(`${type}-date`);
if (timeElement && dateElement) {
const displayDate = isUtc ? new Date(date.getTime()) : date;
const timeStr = isUtc ?
displayDate.toUTCString().split(' ')[4] :
displayDate.toLocaleTimeString();
const dateStr = isUtc ?
displayDate.toUTCString().split(' ').slice(0, 4).join(' ') :
displayDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
timeElement.textContent = timeStr;
dateElement.textContent = dateStr;
// Color time orange when using client time, white when server-synced
if (this.isTimeServerSynced) {
timeElement.style.color = '#fff';
dateElement.style.color = '#64748b';
} else {
timeElement.style.color = '#f59e0b';
dateElement.style.color = '#f59e0b';
}
}
}
showOfflineStatus() {
// Reset to client time and show orange color
this.serverTimeOffset = null;
this.isTimeServerSynced = false;
console.warn('System appears offline - falling back to client time');
}
startStatusUpdates() {
// Initial fetch
this.fetchStatus();
// Update every 60 seconds
setInterval(() => {
this.fetchStatus();
}, 60000);
}
startTimeUpdates() {
// Start immediately with client time
this.updateCurrentTime();
// Update time every second
setInterval(() => {
this.updateCurrentTime();
}, 1000);
}
initializeEventListeners() {
// Expand/collapse status
const expandToggle = document.getElementById('expand-toggle');
const expandedStatus = document.getElementById('expanded-status');
const expandIcon = document.getElementById('expand-icon');
if (expandToggle && expandedStatus && expandIcon) {
expandToggle.addEventListener('click', () => {
expandedStatus.classList.toggle('visible');
expandIcon.classList.toggle('expanded');
});
}
// Search functionality
const searchInput = document.getElementById('search-input');
const appsGrid = document.getElementById('apps-grid');
if (searchInput && appsGrid) {
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const apps = appsGrid.querySelectorAll('.app-item');
apps.forEach(app => {
const name = app.getAttribute('data-name')?.toLowerCase() || '';
app.style.display = name.includes(query) ? 'flex' : 'none';
});
});
}
}
}
function openKiwixm(event) {
event.stopPropagation();
event.preventDefault();
window.location.href = window.location.origin.replace(/:\d+/g, '') + ':1300/admin';
}
// Initialize dashboard when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
new Dashboard();
document.querySelectorAll('.app-item[href^=":"]').forEach(link => {
const port = link.getAttribute('href').substring(1); // Remove the ':'
const url = new URL(location.origin);
url.port = port;
link.href = url.toString();
});
});

View File

@@ -1,51 +0,0 @@
import express from "express";
import { join } from 'path';
import {environment} from './services/environment.mjs';
import { statusRouter } from './services/status.mjs';
(async () => {
const app = express();
// Middleware
app.use(express.json());
app.use((err, req, res, next) => {
console.error('Middleware error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// Routes
console.log(join(environment.root, '../public'));
app.use(express.static(join(environment.root, '../public')));
app.use('/api', statusRouter);
// Error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// Startup
const server = app.listen(environment.port, () => {
console.log(`Dashboard running on http://localhost:${environment.port}`);
}).on('error', (err) => {
console.error('Server startup error:', err);
process.exit(1);
});
// Shutdown
const gracefulShutdown = (signal) => {
console.log(`Received ${signal}, shutting down gracefully`);
server.close((err) => {
if (err) {
console.error('Error during server shutdown:', err);
process.exit(1);
}
console.log('Server closed');
process.exit(0);
});
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
})();

View File

@@ -1,38 +0,0 @@
import dotenv from 'dotenv';
import * as fs from 'node:fs';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { execSync } from "child_process";
dotenv.config();
export const environment = {
config: process.env.CONFIG || 'config.json',
hostname: process.env.HOSTNAME || execSync('hostname', { encoding: "utf-8" }) || 'localhost',
gpsd: process.env.GPSD || 2947,
port: process.env.PORT || 3000,
root: dirname(dirname(fileURLToPath(import.meta.url))),
settings: {},
}
if(!environment.config.startsWith('/')) environment.config = `${environment.root}/${environment.config}`;
export function loadSettings() {
if(!fs.existsSync(environment.config)) fs.writeFileSync(environment.config, '', { flag: 'a' });
const value = fs.readFileSync(environment.config, 'utf-8');
try { return JSON.parse(value); }
catch (e) {
return {
lora: {
mode: 'MESH',
telemetry: true,
}
};
}
}
export function saveSettings() {
fs.writeFileSync(environment.config, JSON.stringify(environment.settings, null, 4));
}
loadSettings();