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: --
+
+
+
+
+
+
+
+
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; }