Merge pull request #31 from cjfranko/webui_updates
web ui update! We are for initial release!
BIN
static/assets/HaveBlueTransWh.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
static/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
static/assets/header.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
static/assets/timeturner_2398.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_24.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_25.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/assets/timeturner_2997.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/assets/timeturner_2997DF.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/assets/timeturner_30.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/assets/timeturner_default.png
Normal file
|
After Width: | Height: | Size: 898 B |
BIN
static/assets/timeturner_lock_green.png
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
static/assets/timeturner_lock_orange.png
Normal file
|
After Width: | Height: | Size: 805 B |
BIN
static/assets/timeturner_lock_red.png
Normal file
|
After Width: | Height: | Size: 804 B |
BIN
static/assets/timeturner_timeturning.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 198 KiB |
43
static/icon-map.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
// In this file, you can define the paths to your local icon image files.
|
||||||
|
const iconMap = {
|
||||||
|
ltcStatus: {
|
||||||
|
'LOCK': { src: 'assets/timeturner_ltc_green.png', tooltip: 'LTC signal is locked and stable.' },
|
||||||
|
'FREE': { src: 'assets/timeturner_ltc_orange.png', tooltip: 'LTC signal is in freewheel mode.' },
|
||||||
|
'default': { src: 'assets/timeturner_ltc_red.png', tooltip: 'LTC signal is not detected.' }
|
||||||
|
},
|
||||||
|
ntpActive: {
|
||||||
|
true: { src: 'assets/timeturner_ntp_green.png', tooltip: 'NTP service is active.' },
|
||||||
|
false: { src: 'assets/timeturner_ntp_red.png', tooltip: 'NTP service is inactive.' }
|
||||||
|
},
|
||||||
|
syncStatus: {
|
||||||
|
'IN SYNC': { src: 'assets/timeturner_sync_green.png', tooltip: 'System clock is in sync with LTC source.' },
|
||||||
|
'CLOCK AHEAD': { src: 'assets/timeturner_sync_orange.png', tooltip: 'System clock is ahead of the LTC source.' },
|
||||||
|
'CLOCK BEHIND': { src: 'assets/timeturner_sync_orange.png', tooltip: 'System clock is behind the LTC source.' },
|
||||||
|
'TIMETURNING': { src: 'assets/timeturner_timeturning.png', tooltip: 'Timeturner offset is active.' },
|
||||||
|
'default': { src: 'assets/timeturner_sync_red.png', tooltip: 'Sync status is unknown.' }
|
||||||
|
},
|
||||||
|
jitterStatus: {
|
||||||
|
'GOOD': { src: 'assets/timeturner_jitter_green.png', tooltip: 'Clock jitter is within acceptable limits.' },
|
||||||
|
'AVERAGE': { src: 'assets/timeturner_jitter_orange.png', tooltip: 'Clock jitter is moderate.' },
|
||||||
|
'BAD': { src: 'assets/timeturner_jitter_red.png', tooltip: 'Clock jitter is high and may affect accuracy.' },
|
||||||
|
'default': { src: 'assets/timeturner_jitter_red.png', tooltip: 'Jitter status is unknown.' }
|
||||||
|
},
|
||||||
|
deltaStatus: {
|
||||||
|
'good': { src: 'assets/timeturner_delta_green.png', tooltip: 'Clock delta is 0ms.' },
|
||||||
|
'average': { src: 'assets/timeturner_delta_orange.png', tooltip: 'Clock delta is less than 10ms.' },
|
||||||
|
'bad': { src: 'assets/timeturner_delta_red.png', tooltip: 'Clock delta is 10ms or greater.' }
|
||||||
|
},
|
||||||
|
frameRate: {
|
||||||
|
'23.98fps': { src: 'assets/timeturner_2398.png', tooltip: '23.98 frames per second' },
|
||||||
|
'24.00fps': { src: 'assets/timeturner_24.png', tooltip: '24.00 frames per second' },
|
||||||
|
'25.00fps': { src: 'assets/timeturner_25.png', tooltip: '25.00 frames per second' },
|
||||||
|
'29.97fps': { src: 'assets/timeturner_2997.png', tooltip: '29.97 frames per second' },
|
||||||
|
'30.00fps': { src: 'assets/timeturner_30.png', tooltip: '30.00 frames per second' },
|
||||||
|
'default': { src: 'assets/timeturner_default.png', tooltip: 'Unknown frame rate' }
|
||||||
|
},
|
||||||
|
lockRatio: {
|
||||||
|
'good': { src: 'assets/timeturner_lock_green.png', tooltip: 'Lock ratio is 100%.' },
|
||||||
|
'average': { src: 'assets/timeturner_lock_orange.png', tooltip: 'Lock ratio is 90% or higher.' },
|
||||||
|
'bad': { src: 'assets/timeturner_lock_red.png', tooltip: 'Lock ratio is below 90%.' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -5,47 +5,63 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NTP TimeTurner</title>
|
<title>NTP TimeTurner</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="icon" href="favicon.ico" type="image/x-icon">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>NTP TimeTurner</h1>
|
<img src="assets/header.png" alt="NTP Timeturner" class="header-logo">
|
||||||
|
|
||||||
|
<!-- Mock Data Controls (hidden by default) -->
|
||||||
|
<div id="mock-controls" class="card full-width" style="display: none;">
|
||||||
|
<h2>Mock Data Controls</h2>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="mock-data-selector">Select Mock Data Scenario:</label>
|
||||||
|
<select id="mock-data-selector"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<!-- LTC Status -->
|
<!-- LTC Status -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>LTC Status</h2>
|
<h2>LTC Input</h2>
|
||||||
<p id="ltc-timecode">--:--:--:--</p>
|
<p id="ltc-timecode">--:--:--:--</p>
|
||||||
<p id="ltc-status">--</p>
|
<div class="icon-group">
|
||||||
<p id="frame-rate">-- fps</p>
|
<span id="ltc-status"></span>
|
||||||
<p>Lock Ratio: <span id="lock-ratio">--</span>%</p>
|
<span id="frame-rate"></span>
|
||||||
|
<span id="lock-ratio"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Clock & Sync -->
|
<!-- System Clock & Sync -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>System Clock</h2>
|
<h2>NTP Clock</h2>
|
||||||
<p id="system-clock">--:--:--.---</p>
|
<p id="system-clock">--:--:--.---</p>
|
||||||
<p>Date: <span id="system-date">---- -- --</span></p>
|
<p class="system-date-display"><span id="system-date">---- -- --</span></p>
|
||||||
<p>NTP Service: <span id="ntp-active">--</span></p>
|
<div class="icon-group">
|
||||||
<p>Sync Status: <span id="sync-status">--</span></p>
|
<span id="ntp-active"></span>
|
||||||
</div>
|
<span id="sync-status"></span>
|
||||||
|
<span id="jitter-status"></span>
|
||||||
<!-- Delta & Jitter -->
|
<span id="delta-status"></span>
|
||||||
<div class="card">
|
</div>
|
||||||
<h2>Clock Offset</h2>
|
<p id="delta-text">Δ -- ms (-- frames)</p>
|
||||||
<p>Delta: <span id="delta-ms">--</span> ms (<span id="delta-frames">--</span> frames)</p>
|
|
||||||
<p>Jitter: <span id="jitter-status">--</span></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Network Interfaces -->
|
<!-- Network Interfaces -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Network</h2>
|
<div class="card-header">
|
||||||
<ul id="interfaces">
|
<img src="assets/timeturner_network.png" class="header-icon" alt="Network Icon">
|
||||||
<li>--</li>
|
<h2>Network</h2>
|
||||||
</ul>
|
</div>
|
||||||
|
<p id="interfaces">--</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
<div class="card full-width">
|
<div class="card full-width collapsible-card">
|
||||||
<h2>Controls</h2>
|
<div class="toggle-header" id="controls-toggle">
|
||||||
|
<img src="assets/timeturner_controls.png" class="toggle-icon" alt="Controls Icon">
|
||||||
|
<h2>Controls</h2>
|
||||||
|
</div>
|
||||||
|
<div class="collapsible-content" id="controls-content">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="hw-offset">Hardware Offset (ms):</label>
|
<label for="hw-offset">Hardware Offset (ms):</label>
|
||||||
<input type="number" id="hw-offset" name="hw-offset">
|
<input type="number" id="hw-offset" name="hw-offset">
|
||||||
|
|
@ -97,15 +113,29 @@
|
||||||
<button id="set-date">Set Date</button>
|
<button id="set-date">Set Date</button>
|
||||||
<span id="date-message"></span>
|
<span id="date-message"></span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs -->
|
<!-- Logs -->
|
||||||
<div class="card full-width">
|
<div class="card full-width collapsible-card">
|
||||||
<h2>Logs</h2>
|
<div class="toggle-header" id="logs-toggle">
|
||||||
<pre id="logs" class="log-box"></pre>
|
<img src="assets/timeturner_logs.png" class="toggle-icon" alt="Logs Icon">
|
||||||
|
<h2>Logs</h2>
|
||||||
|
</div>
|
||||||
|
<div class="collapsible-content" id="logs-content">
|
||||||
|
<pre id="logs" class="log-box"></pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<footer>
|
||||||
|
<p>
|
||||||
|
Built by Chris Frankland-Wright and John Rogers | Have Blue Broadcast Media |
|
||||||
|
<a href="https://github.com/cjfranko/NTP-Timeturner" target="_blank" rel="noopener noreferrer">https://github.com/cjfranko/NTP-Timeturner</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="icon-map.js"></script>
|
||||||
|
<script src="mock-data.js"></script>
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
168
static/mock-data.js
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
// This file contains mock data sets for UI development and testing without a live backend.
|
||||||
|
const mockApiDataSets = {
|
||||||
|
allGood: {
|
||||||
|
status: {
|
||||||
|
ltc_status: 'LOCK',
|
||||||
|
ltc_timecode: '10:20:30:00',
|
||||||
|
frame_rate: '25.00fps',
|
||||||
|
lock_ratio: 99.5,
|
||||||
|
system_clock: '10:20:30.500',
|
||||||
|
system_date: '2025-08-07',
|
||||||
|
ntp_active: true,
|
||||||
|
sync_status: 'IN SYNC',
|
||||||
|
timecode_delta_ms: 5,
|
||||||
|
timecode_delta_frames: 0.125,
|
||||||
|
jitter_status: 'GOOD',
|
||||||
|
interfaces: ['192.168.1.100/24 (eth0)', '10.0.0.5/8 (wlan0)'],
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
hardwareOffsetMs: 10,
|
||||||
|
autoSyncEnabled: true,
|
||||||
|
defaultNudgeMs: 2,
|
||||||
|
timeturnerOffset: { hours: 1, minutes: 2, seconds: 3, frames: 4, milliseconds: 50 },
|
||||||
|
},
|
||||||
|
logs: [
|
||||||
|
'2025-08-07 10:20:30 [INFO] Starting up...',
|
||||||
|
'2025-08-07 10:20:32 [INFO] LTC LOCK detected. Frame rate: 25.00fps.',
|
||||||
|
'2025-08-07 10:20:35 [INFO] Initial sync complete. Clock adjusted by -15ms.',
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ltcFree: {
|
||||||
|
status: {
|
||||||
|
ltc_status: 'FREE',
|
||||||
|
ltc_timecode: '11:22:33:11',
|
||||||
|
frame_rate: '25.00fps',
|
||||||
|
lock_ratio: 40.2,
|
||||||
|
system_clock: '11:22:33.800',
|
||||||
|
system_date: '2025-08-07',
|
||||||
|
ntp_active: true,
|
||||||
|
sync_status: 'IN SYNC',
|
||||||
|
timecode_delta_ms: 3,
|
||||||
|
timecode_delta_frames: 0.075,
|
||||||
|
jitter_status: 'GOOD',
|
||||||
|
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
hardwareOffsetMs: 10,
|
||||||
|
autoSyncEnabled: true,
|
||||||
|
defaultNudgeMs: 2,
|
||||||
|
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||||
|
},
|
||||||
|
logs: [ '2025-08-07 11:22:30 [WARN] LTC signal lost, entering freewheel.' ]
|
||||||
|
},
|
||||||
|
clockAhead: {
|
||||||
|
status: {
|
||||||
|
ltc_status: 'LOCK',
|
||||||
|
ltc_timecode: '12:00:05:00',
|
||||||
|
frame_rate: '25.00fps',
|
||||||
|
lock_ratio: 98.1,
|
||||||
|
system_clock: '12:00:04.500',
|
||||||
|
system_date: '2025-08-07',
|
||||||
|
ntp_active: true,
|
||||||
|
sync_status: 'CLOCK AHEAD',
|
||||||
|
timecode_delta_ms: -500,
|
||||||
|
timecode_delta_frames: -12.5,
|
||||||
|
jitter_status: 'AVERAGE',
|
||||||
|
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
hardwareOffsetMs: 10,
|
||||||
|
autoSyncEnabled: true,
|
||||||
|
defaultNudgeMs: 2,
|
||||||
|
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||||
|
},
|
||||||
|
logs: [ '2025-08-07 12:00:00 [WARN] System clock is ahead of LTC source by 500ms.' ]
|
||||||
|
},
|
||||||
|
clockBehind: {
|
||||||
|
status: {
|
||||||
|
ltc_status: 'LOCK',
|
||||||
|
ltc_timecode: '13:30:10:00',
|
||||||
|
frame_rate: '25.00fps',
|
||||||
|
lock_ratio: 99.9,
|
||||||
|
system_clock: '13:30:10.800',
|
||||||
|
system_date: '2025-08-07',
|
||||||
|
ntp_active: true,
|
||||||
|
sync_status: 'CLOCK BEHIND',
|
||||||
|
timecode_delta_ms: 800,
|
||||||
|
timecode_delta_frames: 20,
|
||||||
|
jitter_status: 'AVERAGE',
|
||||||
|
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
hardwareOffsetMs: 10,
|
||||||
|
autoSyncEnabled: true,
|
||||||
|
defaultNudgeMs: 2,
|
||||||
|
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||||
|
},
|
||||||
|
logs: [ '2025-08-07 13:30:00 [WARN] System clock is behind LTC source by 800ms.' ]
|
||||||
|
},
|
||||||
|
timeturning: {
|
||||||
|
status: {
|
||||||
|
ltc_status: 'LOCK',
|
||||||
|
ltc_timecode: '14:00:00:00',
|
||||||
|
frame_rate: '25.00fps',
|
||||||
|
lock_ratio: 100,
|
||||||
|
system_clock: '15:02:03.050',
|
||||||
|
system_date: '2025-08-07',
|
||||||
|
ntp_active: true,
|
||||||
|
sync_status: 'TIMETURNING',
|
||||||
|
timecode_delta_ms: 3723050, // a big number
|
||||||
|
timecode_delta_frames: 93076,
|
||||||
|
jitter_status: 'GOOD',
|
||||||
|
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
hardwareOffsetMs: 10,
|
||||||
|
autoSyncEnabled: false,
|
||||||
|
defaultNudgeMs: 2,
|
||||||
|
timeturnerOffset: { hours: 1, minutes: 2, seconds: 3, frames: 4, milliseconds: 50 },
|
||||||
|
},
|
||||||
|
logs: [ '2025-08-07 14:00:00 [INFO] Timeturner offset is active.' ]
|
||||||
|
},
|
||||||
|
badJitter: {
|
||||||
|
status: {
|
||||||
|
ltc_status: 'LOCK',
|
||||||
|
ltc_timecode: '15:15:15:15',
|
||||||
|
frame_rate: '25.00fps',
|
||||||
|
lock_ratio: 95.0,
|
||||||
|
system_clock: '15:15:15.515',
|
||||||
|
system_date: '2025-08-07',
|
||||||
|
ntp_active: true,
|
||||||
|
sync_status: 'IN SYNC',
|
||||||
|
timecode_delta_ms: 10,
|
||||||
|
timecode_delta_frames: 0.25,
|
||||||
|
jitter_status: 'BAD',
|
||||||
|
interfaces: ['192.168.1.100/24 (eth0)'],
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
hardwareOffsetMs: 10,
|
||||||
|
autoSyncEnabled: true,
|
||||||
|
defaultNudgeMs: 2,
|
||||||
|
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||||
|
},
|
||||||
|
logs: [ '2025-08-07 15:15:00 [ERROR] High jitter detected on LTC source.' ]
|
||||||
|
},
|
||||||
|
ntpInactive: {
|
||||||
|
status: {
|
||||||
|
ltc_status: 'UNKNOWN',
|
||||||
|
ltc_timecode: '--:--:--:--',
|
||||||
|
frame_rate: '--',
|
||||||
|
lock_ratio: 0,
|
||||||
|
system_clock: '16:00:00.000',
|
||||||
|
system_date: '2025-08-07',
|
||||||
|
ntp_active: false,
|
||||||
|
sync_status: 'UNKNOWN',
|
||||||
|
timecode_delta_ms: 0,
|
||||||
|
timecode_delta_frames: 0,
|
||||||
|
jitter_status: 'UNKNOWN',
|
||||||
|
interfaces: [],
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
hardwareOffsetMs: 0,
|
||||||
|
autoSyncEnabled: false,
|
||||||
|
defaultNudgeMs: 2,
|
||||||
|
timeturnerOffset: { hours: 0, minutes: 0, seconds: 0, frames: 0, milliseconds: 0 },
|
||||||
|
},
|
||||||
|
logs: [ '2025-08-07 16:00:00 [INFO] NTP service is inactive.' ]
|
||||||
|
}
|
||||||
|
};
|
||||||
210
static/script.js
|
|
@ -1,4 +1,9 @@
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// --- Mock Data Configuration ---
|
||||||
|
// Set to true to use mock data, false for live API.
|
||||||
|
const useMockData = false;
|
||||||
|
let currentMockSetKey = 'allGood'; // Default mock data set
|
||||||
|
|
||||||
let lastApiData = null;
|
let lastApiData = null;
|
||||||
let lastApiFetchTime = null;
|
let lastApiFetchTime = null;
|
||||||
|
|
||||||
|
|
@ -11,9 +16,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
systemDate: document.getElementById('system-date'),
|
systemDate: document.getElementById('system-date'),
|
||||||
ntpActive: document.getElementById('ntp-active'),
|
ntpActive: document.getElementById('ntp-active'),
|
||||||
syncStatus: document.getElementById('sync-status'),
|
syncStatus: document.getElementById('sync-status'),
|
||||||
deltaMs: document.getElementById('delta-ms'),
|
deltaStatus: document.getElementById('delta-status'),
|
||||||
deltaFrames: document.getElementById('delta-frames'),
|
|
||||||
jitterStatus: document.getElementById('jitter-status'),
|
jitterStatus: document.getElementById('jitter-status'),
|
||||||
|
deltaText: document.getElementById('delta-text'),
|
||||||
interfaces: document.getElementById('interfaces'),
|
interfaces: document.getElementById('interfaces'),
|
||||||
logs: document.getElementById('logs'),
|
logs: document.getElementById('logs'),
|
||||||
};
|
};
|
||||||
|
|
@ -40,37 +45,110 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const setDateButton = document.getElementById('set-date');
|
const setDateButton = document.getElementById('set-date');
|
||||||
const dateMessage = document.getElementById('date-message');
|
const dateMessage = document.getElementById('date-message');
|
||||||
|
|
||||||
|
// --- Collapsible Sections ---
|
||||||
|
const controlsToggle = document.getElementById('controls-toggle');
|
||||||
|
const controlsContent = document.getElementById('controls-content');
|
||||||
|
const logsToggle = document.getElementById('logs-toggle');
|
||||||
|
const logsContent = document.getElementById('logs-content');
|
||||||
|
|
||||||
|
// --- Mock Controls Setup ---
|
||||||
|
const mockControls = document.getElementById('mock-controls');
|
||||||
|
const mockDataSelector = document.getElementById('mock-data-selector');
|
||||||
|
|
||||||
|
function setupMockControls() {
|
||||||
|
if (useMockData) {
|
||||||
|
mockControls.style.display = 'block';
|
||||||
|
|
||||||
|
// Populate dropdown
|
||||||
|
Object.keys(mockApiDataSets).forEach(key => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = key;
|
||||||
|
option.textContent = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
|
||||||
|
mockDataSelector.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDataSelector.value = currentMockSetKey;
|
||||||
|
|
||||||
|
// Handle selection change
|
||||||
|
mockDataSelector.addEventListener('change', (event) => {
|
||||||
|
currentMockSetKey = event.target.value;
|
||||||
|
// Re-fetch all data from the new mock set
|
||||||
|
fetchStatus();
|
||||||
|
fetchConfig();
|
||||||
|
fetchLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateStatus(data) {
|
function updateStatus(data) {
|
||||||
statusElements.ltcStatus.textContent = data.ltc_status;
|
const ltcStatus = data.ltc_status || 'UNKNOWN';
|
||||||
|
const ltcIconInfo = iconMap.ltcStatus[ltcStatus] || iconMap.ltcStatus.default;
|
||||||
|
statusElements.ltcStatus.innerHTML = `<img src="${ltcIconInfo.src}" class="status-icon" alt="" title="${ltcIconInfo.tooltip}">`;
|
||||||
|
statusElements.ltcStatus.className = ltcStatus.toLowerCase();
|
||||||
statusElements.ltcTimecode.textContent = data.ltc_timecode;
|
statusElements.ltcTimecode.textContent = data.ltc_timecode;
|
||||||
statusElements.frameRate.textContent = data.frame_rate;
|
|
||||||
statusElements.lockRatio.textContent = data.lock_ratio.toFixed(2);
|
const frameRate = data.frame_rate || 'unknown';
|
||||||
|
const frameRateIconInfo = iconMap.frameRate[frameRate] || iconMap.frameRate.default;
|
||||||
|
statusElements.frameRate.innerHTML = `<img src="${frameRateIconInfo.src}" class="status-icon" alt="" title="${frameRateIconInfo.tooltip}">`;
|
||||||
|
|
||||||
|
const lockRatio = data.lock_ratio;
|
||||||
|
let lockRatioCategory;
|
||||||
|
if (lockRatio === 100) {
|
||||||
|
lockRatioCategory = 'good';
|
||||||
|
} else if (lockRatio >= 90) {
|
||||||
|
lockRatioCategory = 'average';
|
||||||
|
} else {
|
||||||
|
lockRatioCategory = 'bad';
|
||||||
|
}
|
||||||
|
const lockRatioIconInfo = iconMap.lockRatio[lockRatioCategory];
|
||||||
|
statusElements.lockRatio.innerHTML = `<img src="${lockRatioIconInfo.src}" class="status-icon" alt="" title="${lockRatioIconInfo.tooltip}">`;
|
||||||
statusElements.systemClock.textContent = data.system_clock;
|
statusElements.systemClock.textContent = data.system_clock;
|
||||||
statusElements.systemDate.textContent = data.system_date;
|
statusElements.systemDate.textContent = data.system_date;
|
||||||
|
|
||||||
statusElements.ntpActive.textContent = data.ntp_active ? 'Active' : 'Inactive';
|
// Autofill the date input, but don't overwrite user edits.
|
||||||
statusElements.ntpActive.className = data.ntp_active ? 'active' : 'inactive';
|
if (!lastApiData || dateInput.value === lastApiData.system_date) {
|
||||||
|
dateInput.value = data.system_date;
|
||||||
|
}
|
||||||
|
|
||||||
statusElements.syncStatus.textContent = data.sync_status;
|
const ntpIconInfo = iconMap.ntpActive[!!data.ntp_active];
|
||||||
statusElements.syncStatus.className = data.sync_status.replace(/\s+/g, '-').toLowerCase();
|
if (data.ntp_active) {
|
||||||
|
statusElements.ntpActive.innerHTML = `<img src="${ntpIconInfo.src}" class="status-icon" alt="" title="${ntpIconInfo.tooltip}">`;
|
||||||
statusElements.deltaMs.textContent = data.timecode_delta_ms;
|
statusElements.ntpActive.className = 'active';
|
||||||
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 {
|
} else {
|
||||||
const li = document.createElement('li');
|
statusElements.ntpActive.innerHTML = `<img src="${ntpIconInfo.src}" class="status-icon" alt="" title="${ntpIconInfo.tooltip}">`;
|
||||||
li.textContent = 'No active interfaces found.';
|
statusElements.ntpActive.className = 'inactive';
|
||||||
statusElements.interfaces.appendChild(li);
|
}
|
||||||
|
|
||||||
|
const syncStatus = data.sync_status || 'UNKNOWN';
|
||||||
|
const syncIconInfo = iconMap.syncStatus[syncStatus] || iconMap.syncStatus.default;
|
||||||
|
statusElements.syncStatus.innerHTML = `<img src="${syncIconInfo.src}" class="status-icon" alt="" title="${syncIconInfo.tooltip}">`;
|
||||||
|
statusElements.syncStatus.className = syncStatus.replace(/\s+/g, '-').toLowerCase();
|
||||||
|
|
||||||
|
// Delta Status
|
||||||
|
const deltaMs = data.timecode_delta_ms;
|
||||||
|
let deltaCategory;
|
||||||
|
if (deltaMs === 0) {
|
||||||
|
deltaCategory = 'good';
|
||||||
|
} else if (Math.abs(deltaMs) < 10) {
|
||||||
|
deltaCategory = 'average';
|
||||||
|
} else {
|
||||||
|
deltaCategory = 'bad';
|
||||||
|
}
|
||||||
|
const deltaIconInfo = iconMap.deltaStatus[deltaCategory];
|
||||||
|
statusElements.deltaStatus.innerHTML = `<img src="${deltaIconInfo.src}" class="status-icon" alt="" title="${deltaIconInfo.tooltip}">`;
|
||||||
|
|
||||||
|
const deltaTextValue = `${data.timecode_delta_ms} ms (${data.timecode_delta_frames} frames)`;
|
||||||
|
statusElements.deltaText.textContent = `Δ ${deltaTextValue}`;
|
||||||
|
|
||||||
|
const jitterStatus = data.jitter_status || 'UNKNOWN';
|
||||||
|
const jitterIconInfo = iconMap.jitterStatus[jitterStatus] || iconMap.jitterStatus.default;
|
||||||
|
statusElements.jitterStatus.innerHTML = `<img src="${jitterIconInfo.src}" class="status-icon" alt="" title="${jitterIconInfo.tooltip}">`;
|
||||||
|
statusElements.jitterStatus.className = jitterStatus.toLowerCase();
|
||||||
|
|
||||||
|
if (data.interfaces.length > 0) {
|
||||||
|
statusElements.interfaces.textContent = data.interfaces.join(' | ');
|
||||||
|
} else {
|
||||||
|
statusElements.interfaces.textContent = 'No active interfaces found.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,6 +212,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
|
if (useMockData) {
|
||||||
|
const data = mockApiDataSets[currentMockSetKey].status;
|
||||||
|
updateStatus(data);
|
||||||
|
lastApiData = data;
|
||||||
|
lastApiFetchTime = new Date();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/status');
|
const response = await fetch('/api/status');
|
||||||
if (!response.ok) throw new Error('Failed to fetch status');
|
if (!response.ok) throw new Error('Failed to fetch status');
|
||||||
|
|
@ -149,6 +234,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
|
if (useMockData) {
|
||||||
|
const data = mockApiDataSets[currentMockSetKey].config;
|
||||||
|
hwOffsetInput.value = data.hardwareOffsetMs;
|
||||||
|
autoSyncCheckbox.checked = data.autoSyncEnabled;
|
||||||
|
offsetInputs.h.value = data.timeturnerOffset.hours;
|
||||||
|
offsetInputs.m.value = data.timeturnerOffset.minutes;
|
||||||
|
offsetInputs.s.value = data.timeturnerOffset.seconds;
|
||||||
|
offsetInputs.f.value = data.timeturnerOffset.frames;
|
||||||
|
offsetInputs.ms.value = data.timeturnerOffset.milliseconds || 0;
|
||||||
|
nudgeValueInput.value = data.defaultNudgeMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/config');
|
const response = await fetch('/api/config');
|
||||||
if (!response.ok) throw new Error('Failed to fetch config');
|
if (!response.ok) throw new Error('Failed to fetch config');
|
||||||
|
|
@ -180,6 +277,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
console.log('Mock save:', config);
|
||||||
|
alert('Configuration saved (mock).');
|
||||||
|
// We can also update the mock data in memory to see changes reflected
|
||||||
|
mockApiDataSets[currentMockSetKey].config = config;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/config', {
|
const response = await fetch('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -195,13 +300,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLogs() {
|
async function fetchLogs() {
|
||||||
|
if (useMockData) {
|
||||||
|
// Use a copy to avoid mutating the original mock data array
|
||||||
|
const logs = mockApiDataSets[currentMockSetKey].logs.slice();
|
||||||
|
// Show latest 20 logs, with the newest at the top.
|
||||||
|
logs.reverse();
|
||||||
|
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/logs');
|
const response = await fetch('/api/logs');
|
||||||
if (!response.ok) throw new Error('Failed to fetch logs');
|
if (!response.ok) throw new Error('Failed to fetch logs');
|
||||||
const logs = await response.json();
|
const logs = await response.json();
|
||||||
statusElements.logs.textContent = logs.join('\n');
|
// Show latest 20 logs, with the newest at the top.
|
||||||
// Auto-scroll to the bottom
|
logs.reverse();
|
||||||
statusElements.logs.scrollTop = statusElements.logs.scrollHeight;
|
statusElements.logs.textContent = logs.slice(0, 20).join('\n');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching logs:', error);
|
console.error('Error fetching logs:', error);
|
||||||
statusElements.logs.textContent = 'Error fetching logs.';
|
statusElements.logs.textContent = 'Error fetching logs.';
|
||||||
|
|
@ -210,6 +323,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
async function triggerManualSync() {
|
async function triggerManualSync() {
|
||||||
syncMessage.textContent = 'Issuing sync command...';
|
syncMessage.textContent = 'Issuing sync command...';
|
||||||
|
if (useMockData) {
|
||||||
|
syncMessage.textContent = 'Success: Manual sync triggered (mock).';
|
||||||
|
setTimeout(() => { syncMessage.textContent = ''; }, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/sync', { method: 'POST' });
|
const response = await fetch('/api/sync', { method: 'POST' });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
@ -227,6 +345,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
async function nudgeClock(ms) {
|
async function nudgeClock(ms) {
|
||||||
nudgeMessage.textContent = 'Nudging clock...';
|
nudgeMessage.textContent = 'Nudging clock...';
|
||||||
|
if (useMockData) {
|
||||||
|
nudgeMessage.textContent = `Success: Clock nudged by ${ms}ms (mock).`;
|
||||||
|
setTimeout(() => { nudgeMessage.textContent = ''; }, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/nudge_clock', {
|
const response = await fetch('/api/nudge_clock', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -254,6 +377,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
dateMessage.textContent = 'Setting date...';
|
dateMessage.textContent = 'Setting date...';
|
||||||
|
if (useMockData) {
|
||||||
|
mockApiDataSets[currentMockSetKey].status.system_date = date;
|
||||||
|
dateMessage.textContent = `Success: Date set to ${date} (mock).`;
|
||||||
|
fetchStatus(); // re-render
|
||||||
|
setTimeout(() => { dateMessage.textContent = ''; }, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/set_date', {
|
const response = await fetch('/api/set_date', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -287,13 +417,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
});
|
});
|
||||||
setDateButton.addEventListener('click', setDate);
|
setDateButton.addEventListener('click', setDate);
|
||||||
|
|
||||||
|
// --- Collapsible Section Listeners ---
|
||||||
|
controlsToggle.addEventListener('click', () => {
|
||||||
|
const isActive = controlsContent.classList.toggle('active');
|
||||||
|
controlsToggle.classList.toggle('active', isActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
logsToggle.addEventListener('click', () => {
|
||||||
|
const isActive = logsContent.classList.toggle('active');
|
||||||
|
logsToggle.classList.toggle('active', isActive);
|
||||||
|
});
|
||||||
|
|
||||||
// Initial data load
|
// Initial data load
|
||||||
|
setupMockControls();
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
|
|
||||||
// Refresh data every 2 seconds
|
// Refresh data every 2 seconds if not using mock data
|
||||||
setInterval(fetchStatus, 2000);
|
if (!useMockData) {
|
||||||
setInterval(fetchLogs, 2000);
|
setInterval(fetchStatus, 2000);
|
||||||
|
setInterval(fetchLogs, 2000);
|
||||||
|
}
|
||||||
setInterval(animateClocks, 50); // High-frequency clock animation
|
setInterval(animateClocks, 50); // High-frequency clock animation
|
||||||
});
|
});
|
||||||
|
|
|
||||||
149
static/style.css
|
|
@ -1,6 +1,21 @@
|
||||||
|
@font-face {
|
||||||
|
font-family: 'FuturaStdHeavy';
|
||||||
|
src: url('assets/FuturaStdHeavy.otf') format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Quartz';
|
||||||
|
src: url('assets/quartz-ms-regular.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: 'FuturaStdHeavy', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
background-color: #f4f4f9;
|
background-color: #221f1f;
|
||||||
|
background-image: url('assets/HaveBlueTransWh.png');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: bottom 20px right 20px;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: 300px;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
@ -13,19 +28,20 @@ body {
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.header-logo {
|
||||||
text-align: center;
|
display: block;
|
||||||
color: #444;
|
margin: 0 auto 20px auto;
|
||||||
|
max-width: 60%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: #fff;
|
background: #c5ced6;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
|
@ -33,16 +49,32 @@ h1 {
|
||||||
|
|
||||||
.card h2 {
|
.card h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #0056b3;
|
color: #1a7db6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ltc-timecode, #system-clock {
|
||||||
|
font-family: 'Quartz', monospace;
|
||||||
|
font-size: 2em;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card p, .card ul {
|
.card p, .card ul {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card ul {
|
.system-date-display {
|
||||||
padding-left: 20px;
|
text-align: center;
|
||||||
list-style: none;
|
font-size: 1.5em;
|
||||||
|
font-family: 'Quartz', monospace;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#interfaces {
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 5px; /* Add some space for the scrollbar if it appears */
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-width {
|
.full-width {
|
||||||
|
|
@ -82,6 +114,98 @@ button:hover {
|
||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-group {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delta-text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ltc-status, #ntp-active, #sync-status, #jitter-status, #frame-rate, #lock-ratio, #delta-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-card {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-card .toggle-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-card .toggle-header.active {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-card .toggle-header:hover {
|
||||||
|
background-color: #e9e9f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-content {
|
||||||
|
display: none;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #444;
|
||||||
|
color: #c5ced6;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: #1a7db6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* Status-specific colors */
|
/* Status-specific colors */
|
||||||
#sync-status.in-sync, #jitter-status.good { font-weight: bold; color: #28a745; }
|
#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; }
|
#sync-status.clock-ahead, #sync-status.clock-behind, #jitter-status.average { font-weight: bold; color: #ffc107; }
|
||||||
|
|
@ -89,3 +213,6 @@ button:hover {
|
||||||
#jitter-status.bad { font-weight: bold; color: #dc3545; }
|
#jitter-status.bad { font-weight: bold; color: #dc3545; }
|
||||||
#ntp-active.active { font-weight: bold; color: #28a745; }
|
#ntp-active.active { font-weight: bold; color: #28a745; }
|
||||||
#ntp-active.inactive { font-weight: bold; color: #dc3545; }
|
#ntp-active.inactive { font-weight: bold; color: #dc3545; }
|
||||||
|
|
||||||
|
#ltc-status.lock { font-weight: bold; color: #28a745; }
|
||||||
|
#ltc-status.free { font-weight: bold; color: #ffc107; }
|
||||||
|
|
|
||||||