feat: add web frontend to control the API

Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) <aider@aider.chat>
This commit is contained in:
Chaos Rogers 2025-07-21 19:00:19 +01:00
parent 666ce4308f
commit c48ef1cf3f
5 changed files with 279 additions and 0 deletions

62
static/index.html Normal file
View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NTP TimeTurner</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>NTP TimeTurner</h1>
<div class="grid">
<!-- LTC Status -->
<div class="card">
<h2>LTC Status</h2>
<p id="ltc-status">--</p>
<p id="ltc-timecode">--:--:--:--</p>
<p id="frame-rate">-- fps</p>
<p>Lock Ratio: <span id="lock-ratio">--</span>%</p>
</div>
<!-- System Clock & Sync -->
<div class="card">
<h2>System Clock</h2>
<p id="system-clock">--:--:--.---</p>
<p>NTP Service: <span id="ntp-active">--</span></p>
<p>Sync Status: <span id="sync-status">--</span></p>
</div>
<!-- Delta & Jitter -->
<div class="card">
<h2>Clock Offset</h2>
<p>Delta: <span id="delta-ms">--</span> ms (<span id="delta-frames">--</span> frames)</p>
<p>Jitter: <span id="jitter-status">--</span></p>
</div>
<!-- Network Interfaces -->
<div class="card">
<h2>Network</h2>
<ul id="interfaces">
<li>--</li>
</ul>
</div>
<!-- Controls -->
<div class="card full-width">
<h2>Controls</h2>
<div class="control-group">
<label for="hw-offset">Hardware Offset (ms):</label>
<input type="number" id="hw-offset" name="hw-offset">
<button id="save-offset">Save</button>
</div>
<div class="control-group">
<button id="manual-sync">Manual Sync</button>
<span id="sync-message"></span>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

123
static/script.js Normal file
View file

@ -0,0 +1,123 @@
document.addEventListener('DOMContentLoaded', () => {
const statusElements = {
ltcStatus: document.getElementById('ltc-status'),
ltcTimecode: document.getElementById('ltc-timecode'),
frameRate: document.getElementById('frame-rate'),
lockRatio: document.getElementById('lock-ratio'),
systemClock: document.getElementById('system-clock'),
ntpActive: document.getElementById('ntp-active'),
syncStatus: document.getElementById('sync-status'),
deltaMs: document.getElementById('delta-ms'),
deltaFrames: document.getElementById('delta-frames'),
jitterStatus: document.getElementById('jitter-status'),
interfaces: document.getElementById('interfaces'),
};
const hwOffsetInput = document.getElementById('hw-offset');
const saveOffsetButton = document.getElementById('save-offset');
const manualSyncButton = document.getElementById('manual-sync');
const syncMessage = document.getElementById('sync-message');
function updateStatus(data) {
statusElements.ltcStatus.textContent = data.ltc_status;
statusElements.ltcTimecode.textContent = data.ltc_timecode;
statusElements.frameRate.textContent = data.frame_rate;
statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2);
statusElements.systemClock.textContent = data.system_clock;
statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive';
statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive';
statusElements.syncStatus.textContent = data.sync_status;
statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase();
statusElements.deltaMs.textContent = data.timecode_delta_ms;
statusElements.deltaFrames.textContent = data.timecode_delta_frames;
statusElements.jitterStatus.textContent = data.jitter_status;
statusElements.jitterStatus.className = data.jitter_status.toLowerCase();
statusElements.interfaces.innerHTML = '';
if (data.interfaces.length > 0) {
data.interfaces.forEach(ip => {
const li = document.createElement('li');
li.textContent = ip;
statusElements.interfaces.appendChild(li);
});
} else {
const li = document.createElement('li');
li.textContent = 'No active interfaces found.';
statusElements.interfaces.appendChild(li);
}
}
async function fetchStatus() {
try {
const response = await fetch('/api/status');
if (!response.ok) throw new Error('Failed to fetch status');
const data = await response.json();
updateStatus(data);
} catch (error) {
console.error('Error fetching status:', error);
}
}
async function fetchConfig() {
try {
const response = await fetch('/api/config');
if (!response.ok) throw new Error('Failed to fetch config');
const data = await response.json();
hwOffsetInput.value = data.hardware_offset_ms;
} catch (error) {
console.error('Error fetching config:', error);
}
}
async function saveConfig() {
const offset = parseInt(hwOffsetInput.value, 10);
if (isNaN(offset)) {
alert('Invalid hardware offset value.');
return;
}
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hardware_offset_ms: offset }),
});
if (!response.ok) throw new Error('Failed to save config');
alert('Configuration saved.');
} catch (error) {
console.error('Error saving config:', error);
alert('Error saving configuration.');
}
}
async function triggerManualSync() {
syncMessage.textContent = 'Issuing sync command...';
try {
const response = await fetch('/api/sync', { method: 'POST' });
const data = await response.json();
if (response.ok) {
syncMessage.textContent = `Success: ${data.message}`;
} else {
syncMessage.textContent = `Error: ${data.message}`;
}
} catch (error) {
console.error('Error triggering sync:', error);
syncMessage.textContent = 'Failed to send sync command.';
}
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
}
saveOffsetButton.addEventListener('click', saveConfig);
manualSyncButton.addEventListener('click', triggerManualSync);
// Initial data load
fetchStatus();
fetchConfig();
// Refresh data every 2 seconds
setInterval(fetchStatus, 2000);
});

90
static/style.css Normal file
View file

@ -0,0 +1,90 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f4f4f9;
color: #333;
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
}
.container {
width: 100%;
max-width: 960px;
}
h1 {
text-align: center;
color: #444;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h2 {
margin-top: 0;
color: #0056b3;
}
.card p, .card ul {
margin: 10px 0;
}
.card ul {
padding-left: 20px;
list-style: none;
}
.full-width {
grid-column: 1 / -1;
}
.control-group {
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
input[type="number"] {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 80px;
}
button {
padding: 8px 15px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
}
button:hover {
background-color: #0056b3;
}
#sync-message {
font-style: italic;
color: #555;
}
/* Status-specific colors */
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; }
#sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; }
#jitter-status.bad { font-weight: bold; color: #dc3545; }
#ntp-active.active { font-weight: bold; color: #28a745; }
#ntp-active.inactive { font-weight: bold; color: #dc3545; }