From c48ef1cf3f0a810702f614b3f1dec71730ef2db8 Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 21 Jul 2025 19:00:19 +0100 Subject: [PATCH] feat: add web frontend to control the API Co-authored-by: aider (gemini/gemini-2.5-pro-preview-05-06) --- Cargo.toml | 1 + src/api.rs | 3 ++ static/index.html | 62 +++++++++++++++++++++++ static/script.js | 123 ++++++++++++++++++++++++++++++++++++++++++++++ static/style.css | 90 +++++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+) create mode 100644 static/index.html create mode 100644 static/script.js create mode 100644 static/style.css diff --git a/Cargo.toml b/Cargo.toml index f50e457..5de80bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,5 +13,6 @@ serde_json = "1.0.141" notify = "8.1.0" get_if_addrs = "0.5" actix-web = "4" +actix-files = "0.6" tokio = { version = "1", features = ["full"] } diff --git a/src/api.rs b/src/api.rs index 66b6c04..fdc4d0b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,4 +1,5 @@ +use actix_files as fs; use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder}; use chrono::{Local, Timelike}; use get_if_addrs::get_if_addrs; @@ -158,6 +159,8 @@ pub async fn start_api_server( .service(manual_sync) .service(get_config) .service(update_config) + // Serve frontend static files + .service(fs::Files::new("/", "static/").index_file("index.html")) }) .bind("0.0.0.0:8080")? .run() diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..f25d0f2 --- /dev/null +++ b/static/index.html @@ -0,0 +1,62 @@ + + + + + + NTP TimeTurner + + + +
+

NTP TimeTurner

+
+ +
+

LTC Status

+

--

+

--:--:--:--

+

-- fps

+

Lock Ratio: --%

+
+ + +
+

System Clock

+

--:--:--.---

+

NTP Service: --

+

Sync Status: --

+
+ + +
+

Clock Offset

+

Delta: -- ms (-- frames)

+

Jitter: --

+
+ + +
+

Network

+
    +
  • --
  • +
+
+ + +
+

Controls

+
+ + + +
+
+ + +
+
+
+
+ + + diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..a70045c --- /dev/null +++ b/static/script.js @@ -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); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..dc4d92c --- /dev/null +++ b/static/style.css @@ -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; }