[feat](trx-frontend-http): add Background Decoding Scheduler
Implements a scheduler that retunes the rig automatically when no SSE
clients are connected. Two modes are supported:
- Grayline: tunes to per-period bookmarks (dawn/day/dusk/night) based on
an inline NOAA solar algorithm given station lat/lon.
- Time Span: tunes to bookmarks within user-defined UTC windows; midnight-
spanning intervals supported.
Backend:
- SchedulerStore (PickleDB, sch:{rig_id} keys) in scheduler.rs
- spawn_scheduler_task polls every 30 s, checks context.sse_clients == 0,
sends SetFreq + SetMode via RigRequest with rig_id_override
- HTTP API: GET/PUT/DELETE /scheduler/{rig_id}, GET …/status
- sse_clients Arc<AtomicUsize> added to FrontendRuntimeContext and shared
with the SSE counter in build_server (single source of truth)
- /scheduler/ added to Read auth routes (write requires Control)
Frontend:
- Scheduler tab (clock icon, 6th position) with Grayline/TimeSpan UI
- scheduler.js plugin: loads config + bookmarks, live status polling
every 15 s, write controls hidden for Rx-role users
- CSS .sch-* component styles added to style.css
- SCHEDULER.md design document at repo root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -3347,7 +3347,7 @@ if (spectrumBwSweetBtn) {
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
const TAB_ORDER = ["main", "bookmarks", "decoders", "map", "about"];
|
||||
const TAB_ORDER = ["main", "bookmarks", "decoders", "map", "scheduler", "about"];
|
||||
|
||||
function navigateToTab(name) {
|
||||
if (authEnabled && !authRole && name !== "main") return;
|
||||
@@ -3415,6 +3415,10 @@ document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||
window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
|
||||
|
||||
// --- Auth startup sequence ---
|
||||
function getAvailableRigIds() {
|
||||
return lastRigIds || [];
|
||||
}
|
||||
|
||||
async function initializeApp() {
|
||||
showAuthGate(false);
|
||||
const authStatus = await checkAuthStatus();
|
||||
@@ -3426,6 +3430,7 @@ async function initializeApp() {
|
||||
updateAuthUI();
|
||||
connect();
|
||||
connectDecode();
|
||||
initSchedulerUI();
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
return;
|
||||
@@ -3439,6 +3444,7 @@ async function initializeApp() {
|
||||
applyAuthRestrictions();
|
||||
connect();
|
||||
connectDecode();
|
||||
initSchedulerUI();
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
} else {
|
||||
@@ -3449,6 +3455,13 @@ async function initializeApp() {
|
||||
}
|
||||
}
|
||||
|
||||
function initSchedulerUI() {
|
||||
if (typeof initScheduler === "function") {
|
||||
initScheduler(lastActiveRigId, authRole);
|
||||
wireSchedulerEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// Setup auth form
|
||||
document.getElementById("auth-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -3466,6 +3479,7 @@ document.getElementById("auth-form").addEventListener("submit", async (e) => {
|
||||
applyAuthRestrictions();
|
||||
connect();
|
||||
connectDecode();
|
||||
initSchedulerUI();
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
} catch (err) {
|
||||
@@ -3488,6 +3502,7 @@ if (guestBtn) {
|
||||
applyAuthRestrictions();
|
||||
connect();
|
||||
connectDecode();
|
||||
initSchedulerUI();
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
});
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></svg>
|
||||
<span class="tab-label">Map</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="scheduler">
|
||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 5v3l2 2"/></svg>
|
||||
<span class="tab-label">Scheduler</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="about">
|
||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 7v5"/><circle cx="8" cy="5" r="0.5" fill="currentColor" stroke="none"/></svg>
|
||||
<span class="tab-label">About</span>
|
||||
@@ -663,6 +667,91 @@
|
||||
<div id="aprs-map"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-scheduler" class="tab-panel" style="display:none;">
|
||||
<div id="scheduler-panel" class="sch-panel">
|
||||
<div class="sch-toast" id="scheduler-toast" style="display:none;"></div>
|
||||
<div class="sch-row">
|
||||
<label class="sch-label">Rig
|
||||
<select id="scheduler-rig-select" class="status-input sch-rig-select" aria-label="Select rig"></select>
|
||||
</label>
|
||||
<label class="sch-label">Mode
|
||||
<select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode">
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="grayline">Grayline</option>
|
||||
<option value="time_span">Time Span</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Grayline section -->
|
||||
<div id="scheduler-grayline-section" class="sch-section" style="display:none;">
|
||||
<div class="sch-section-title">Grayline Settings</div>
|
||||
<div class="sch-row">
|
||||
<label class="sch-label">Latitude (°)
|
||||
<input type="number" id="scheduler-gl-lat" class="status-input" step="0.001" placeholder="e.g. 54.352" />
|
||||
</label>
|
||||
<label class="sch-label">Longitude (°)
|
||||
<input type="number" id="scheduler-gl-lon" class="status-input" step="0.001" placeholder="e.g. 18.646" />
|
||||
</label>
|
||||
<label class="sch-label">Transition window (min)
|
||||
<input type="number" id="scheduler-gl-window" class="status-input" min="5" max="120" value="20" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="sch-row">
|
||||
<label class="sch-label">Dawn bookmark
|
||||
<select id="scheduler-gl-dawn" class="status-input" aria-label="Dawn bookmark"></select>
|
||||
</label>
|
||||
<label class="sch-label">Day bookmark
|
||||
<select id="scheduler-gl-day" class="status-input" aria-label="Day bookmark"></select>
|
||||
</label>
|
||||
<label class="sch-label">Dusk bookmark
|
||||
<select id="scheduler-gl-dusk" class="status-input" aria-label="Dusk bookmark"></select>
|
||||
</label>
|
||||
<label class="sch-label">Night bookmark
|
||||
<select id="scheduler-gl-night" class="status-input" aria-label="Night bookmark"></select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Span section -->
|
||||
<div id="scheduler-timespan-section" class="sch-section" style="display:none;">
|
||||
<div class="sch-section-title">Time Span Entries (UTC)</div>
|
||||
<table class="sch-ts-table">
|
||||
<thead>
|
||||
<tr><th>Start</th><th>End</th><th>Bookmark</th><th>Label</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="scheduler-ts-tbody"></tbody>
|
||||
</table>
|
||||
<div class="sch-row sch-add-row">
|
||||
<label class="sch-label">Start (UTC)
|
||||
<input type="time" id="scheduler-ts-start" class="status-input" />
|
||||
</label>
|
||||
<label class="sch-label">End (UTC)
|
||||
<input type="time" id="scheduler-ts-end" class="status-input" />
|
||||
</label>
|
||||
<label class="sch-label">Bookmark
|
||||
<select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select>
|
||||
</label>
|
||||
<label class="sch-label">Label (optional)
|
||||
<input type="text" id="scheduler-ts-label" class="status-input" placeholder="e.g. 40m FT8" />
|
||||
</label>
|
||||
<button id="scheduler-ts-add-btn" class="sch-write" type="button">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="sch-actions">
|
||||
<button id="scheduler-save-btn" class="sch-write sch-save-btn" type="button" style="display:none;">Save</button>
|
||||
<button id="scheduler-reset-btn" class="sch-write sch-reset-btn" type="button" style="display:none;">Reset to Disabled</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="sch-section">
|
||||
<div class="sch-section-title">Last Activity</div>
|
||||
<div id="scheduler-status-card" class="sch-status-card">No activity yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
||||
<table class="about-table">
|
||||
@@ -701,6 +790,7 @@
|
||||
<script src="/wspr.js"></script>
|
||||
<script src="/cw.js"></script>
|
||||
<script src="/bookmarks.js"></script>
|
||||
<script src="/scheduler.js"></script>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/leaflet-ais-tracksymbol.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
// Background Decoding Scheduler UI
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State
|
||||
// -------------------------------------------------------------------------
|
||||
let schedulerRole = null; // "control" | "rx" | null
|
||||
let currentRigId = null;
|
||||
let currentConfig = null;
|
||||
let bookmarkList = []; // [{id, name, freq_hz, mode}, ...]
|
||||
let statusInterval = null;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Init
|
||||
// -------------------------------------------------------------------------
|
||||
function initScheduler(rigId, role) {
|
||||
schedulerRole = role;
|
||||
currentRigId = rigId || null;
|
||||
renderSchedulerRigSelect();
|
||||
loadScheduler();
|
||||
startStatusPolling();
|
||||
}
|
||||
|
||||
function destroyScheduler() {
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval);
|
||||
statusInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rig selector (mirrors current rig from app state)
|
||||
// -------------------------------------------------------------------------
|
||||
function renderSchedulerRigSelect() {
|
||||
const sel = document.getElementById("scheduler-rig-select");
|
||||
if (!sel) return;
|
||||
// Populate from global rig list exposed by app.js
|
||||
const rigs = (typeof getAvailableRigIds === "function") ? getAvailableRigIds() : [];
|
||||
sel.innerHTML = "";
|
||||
if (!rigs.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.textContent = "No rigs available";
|
||||
sel.appendChild(opt);
|
||||
return;
|
||||
}
|
||||
rigs.forEach(function (id) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = id;
|
||||
if (id === currentRigId) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// -------------------------------------------------------------------------
|
||||
function apiGetScheduler(rigId) {
|
||||
return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiPutScheduler(rigId, config) {
|
||||
return fetch("/scheduler/" + encodeURIComponent(rigId), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiDeleteScheduler(rigId) {
|
||||
return fetch("/scheduler/" + encodeURIComponent(rigId), {
|
||||
method: "DELETE",
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiGetStatus(rigId) {
|
||||
return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then(
|
||||
function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function apiGetBookmarks() {
|
||||
return fetch("/bookmarks").then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Load config + bookmarks
|
||||
// -------------------------------------------------------------------------
|
||||
function loadScheduler() {
|
||||
const rig = currentRigId;
|
||||
if (!rig) return;
|
||||
|
||||
Promise.all([apiGetScheduler(rig), apiGetBookmarks()])
|
||||
.then(function ([config, bms]) {
|
||||
currentConfig = config;
|
||||
bookmarkList = Array.isArray(bms) ? bms : [];
|
||||
renderScheduler();
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error("scheduler load failed", e);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status polling
|
||||
// -------------------------------------------------------------------------
|
||||
function startStatusPolling() {
|
||||
if (statusInterval) clearInterval(statusInterval);
|
||||
statusInterval = setInterval(pollStatus, 15000);
|
||||
pollStatus();
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
const rig = currentRigId;
|
||||
if (!rig) return;
|
||||
apiGetStatus(rig)
|
||||
.then(function (st) {
|
||||
renderStatus(st);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function renderStatus(st) {
|
||||
const el = document.getElementById("scheduler-status-card");
|
||||
if (!el) return;
|
||||
if (!st || (!st.active && !st.last_bookmark_id)) {
|
||||
el.textContent = "No activity yet.";
|
||||
return;
|
||||
}
|
||||
const name = st.last_bookmark_name || st.last_bookmark_id || "—";
|
||||
let ts = "";
|
||||
if (st.last_applied_utc) {
|
||||
const d = new Date(st.last_applied_utc * 1000);
|
||||
ts = " at " + d.toUTCString();
|
||||
}
|
||||
el.textContent = "Last applied: " + name + ts;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Render the full scheduler panel
|
||||
// -------------------------------------------------------------------------
|
||||
function renderScheduler() {
|
||||
const panel = document.getElementById("scheduler-panel");
|
||||
if (!panel) return;
|
||||
|
||||
const mode = (currentConfig && currentConfig.mode) || "disabled";
|
||||
const isControl = schedulerRole === "control";
|
||||
|
||||
// Mode selector
|
||||
setSelected("scheduler-mode-select", mode);
|
||||
|
||||
// Show/hide sections
|
||||
const glSection = document.getElementById("scheduler-grayline-section");
|
||||
const tsSection = document.getElementById("scheduler-timespan-section");
|
||||
if (glSection) glSection.style.display = mode === "grayline" ? "" : "none";
|
||||
if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none";
|
||||
|
||||
// Grayline inputs
|
||||
if (mode === "grayline" && currentConfig && currentConfig.grayline) {
|
||||
const gl = currentConfig.grayline;
|
||||
setInputValue("scheduler-gl-lat", gl.lat != null ? gl.lat : "");
|
||||
setInputValue("scheduler-gl-lon", gl.lon != null ? gl.lon : "");
|
||||
setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20);
|
||||
renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id);
|
||||
renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id);
|
||||
renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id);
|
||||
renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id);
|
||||
} else {
|
||||
renderBookmarkSelect("scheduler-gl-dawn", null);
|
||||
renderBookmarkSelect("scheduler-gl-day", null);
|
||||
renderBookmarkSelect("scheduler-gl-dusk", null);
|
||||
renderBookmarkSelect("scheduler-gl-night", null);
|
||||
}
|
||||
|
||||
// TimeSpan entries
|
||||
renderTimespanEntries();
|
||||
|
||||
// Enable/disable controls
|
||||
const formEls = panel.querySelectorAll("input, select, button.sch-write");
|
||||
formEls.forEach(function (el) {
|
||||
el.disabled = !isControl;
|
||||
});
|
||||
const saveBtn = document.getElementById("scheduler-save-btn");
|
||||
if (saveBtn) {
|
||||
saveBtn.style.display = isControl ? "" : "none";
|
||||
}
|
||||
const resetBtn = document.getElementById("scheduler-reset-btn");
|
||||
if (resetBtn) {
|
||||
resetBtn.style.display = isControl ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
function setSelected(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = value;
|
||||
}
|
||||
|
||||
function setInputValue(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = value;
|
||||
}
|
||||
|
||||
function renderBookmarkSelect(id, selectedId) {
|
||||
const sel = document.getElementById(id);
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">— none —</option>';
|
||||
bookmarkList.forEach(function (bm) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = bm.id;
|
||||
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||
if (bm.id === selectedId) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function formatFreq(hz) {
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
||||
return hz + " Hz";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TimeSpan entries table
|
||||
// -------------------------------------------------------------------------
|
||||
function renderTimespanEntries() {
|
||||
const tbody = document.getElementById("scheduler-ts-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
const entries =
|
||||
currentConfig && Array.isArray(currentConfig.entries)
|
||||
? currentConfig.entries
|
||||
: [];
|
||||
entries.forEach(function (entry, idx) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML =
|
||||
'<td>' + minToHHMM(entry.start_min) + '</td>' +
|
||||
'<td>' + minToHHMM(entry.end_min) + '</td>' +
|
||||
'<td>' + bmName(entry.bookmark_id) + '</td>' +
|
||||
'<td>' + escHtml(entry.label || "") + '</td>' +
|
||||
'<td><button class="sch-write sch-remove-btn" data-idx="' + idx + '" type="button">Remove</button></td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbody.querySelectorAll(".sch-remove-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
removeEntry(parseInt(btn.dataset.idx, 10));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bmName(id) {
|
||||
const bm = bookmarkList.find(function (b) { return b.id === id; });
|
||||
return bm ? escHtml(bm.name) : escHtml(id);
|
||||
}
|
||||
|
||||
function minToHHMM(min) {
|
||||
const h = Math.floor(min / 60) % 24;
|
||||
const m = min % 60;
|
||||
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
|
||||
}
|
||||
|
||||
function hhmmToMin(str) {
|
||||
const parts = str.split(":");
|
||||
return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function removeEntry(idx) {
|
||||
if (!currentConfig || !currentConfig.entries) return;
|
||||
currentConfig.entries.splice(idx, 1);
|
||||
renderTimespanEntries();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Add entry
|
||||
// -------------------------------------------------------------------------
|
||||
function addEntry() {
|
||||
const startEl = document.getElementById("scheduler-ts-start");
|
||||
const endEl = document.getElementById("scheduler-ts-end");
|
||||
const bmEl = document.getElementById("scheduler-ts-bookmark");
|
||||
const labelEl = document.getElementById("scheduler-ts-label");
|
||||
if (!startEl || !endEl || !bmEl) return;
|
||||
|
||||
const startMin = hhmmToMin(startEl.value);
|
||||
const endMin = hhmmToMin(endEl.value);
|
||||
const bmId = bmEl.value;
|
||||
const label = labelEl ? labelEl.value.trim() : "";
|
||||
|
||||
if (!bmId) {
|
||||
alert("Please select a bookmark.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentConfig) {
|
||||
currentConfig = { rig_id: currentRigId, mode: "time_span", entries: [] };
|
||||
}
|
||||
if (!currentConfig.entries) currentConfig.entries = [];
|
||||
|
||||
const id = "ts_" + Date.now().toString(36);
|
||||
currentConfig.entries.push({
|
||||
id,
|
||||
start_min: startMin,
|
||||
end_min: endMin,
|
||||
bookmark_id: bmId,
|
||||
label: label || null,
|
||||
});
|
||||
|
||||
startEl.value = "";
|
||||
endEl.value = "";
|
||||
if (labelEl) labelEl.value = "";
|
||||
|
||||
renderTimespanEntries();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Save
|
||||
// -------------------------------------------------------------------------
|
||||
function saveScheduler() {
|
||||
const rig = currentRigId;
|
||||
if (!rig) return;
|
||||
|
||||
const modeEl = document.getElementById("scheduler-mode-select");
|
||||
const mode = modeEl ? modeEl.value : "disabled";
|
||||
|
||||
const config = {
|
||||
rig_id: rig,
|
||||
mode,
|
||||
grayline: null,
|
||||
entries: [],
|
||||
};
|
||||
|
||||
if (mode === "grayline") {
|
||||
const lat = parseFloat(document.getElementById("scheduler-gl-lat").value);
|
||||
const lon = parseFloat(document.getElementById("scheduler-gl-lon").value);
|
||||
const win = parseInt(document.getElementById("scheduler-gl-window").value, 10);
|
||||
config.grayline = {
|
||||
lat: isNaN(lat) ? 0 : lat,
|
||||
lon: isNaN(lon) ? 0 : lon,
|
||||
transition_window_min: isNaN(win) ? 20 : win,
|
||||
dawn_bookmark_id: selectVal("scheduler-gl-dawn") || null,
|
||||
day_bookmark_id: selectVal("scheduler-gl-day") || null,
|
||||
dusk_bookmark_id: selectVal("scheduler-gl-dusk") || null,
|
||||
night_bookmark_id: selectVal("scheduler-gl-night") || null,
|
||||
};
|
||||
} else if (mode === "time_span") {
|
||||
config.entries =
|
||||
currentConfig && currentConfig.entries ? currentConfig.entries : [];
|
||||
}
|
||||
|
||||
const btn = document.getElementById("scheduler-save-btn");
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
apiPutScheduler(rig, config)
|
||||
.then(function (saved) {
|
||||
currentConfig = saved;
|
||||
renderScheduler();
|
||||
showSchedulerToast("Scheduler saved.");
|
||||
})
|
||||
.catch(function (e) {
|
||||
showSchedulerToast("Save failed: " + e.message, true);
|
||||
})
|
||||
.finally(function () {
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function selectVal(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
function resetScheduler() {
|
||||
const rig = currentRigId;
|
||||
if (!rig) return;
|
||||
if (!confirm("Reset scheduler for this rig to Disabled?")) return;
|
||||
|
||||
apiDeleteScheduler(rig)
|
||||
.then(function () {
|
||||
currentConfig = {
|
||||
rig_id: rig,
|
||||
mode: "disabled",
|
||||
grayline: null,
|
||||
entries: [],
|
||||
};
|
||||
renderScheduler();
|
||||
showSchedulerToast("Scheduler reset.");
|
||||
})
|
||||
.catch(function (e) {
|
||||
showSchedulerToast("Reset failed: " + e.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Toast helper
|
||||
// -------------------------------------------------------------------------
|
||||
function showSchedulerToast(msg, isError) {
|
||||
const el = document.getElementById("scheduler-toast");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
|
||||
el.style.display = "block";
|
||||
setTimeout(function () {
|
||||
el.style.display = "none";
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Wire events (called once DOM is ready)
|
||||
// -------------------------------------------------------------------------
|
||||
function wireSchedulerEvents() {
|
||||
const modeEl = document.getElementById("scheduler-mode-select");
|
||||
if (modeEl) {
|
||||
modeEl.addEventListener("change", function () {
|
||||
if (!currentConfig) currentConfig = { rig_id: currentRigId, mode: modeEl.value, entries: [] };
|
||||
currentConfig.mode = modeEl.value;
|
||||
renderScheduler();
|
||||
});
|
||||
}
|
||||
|
||||
const rigSel = document.getElementById("scheduler-rig-select");
|
||||
if (rigSel) {
|
||||
rigSel.addEventListener("change", function () {
|
||||
currentRigId = rigSel.value;
|
||||
loadScheduler();
|
||||
pollStatus();
|
||||
});
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById("scheduler-save-btn");
|
||||
if (saveBtn) saveBtn.addEventListener("click", saveScheduler);
|
||||
|
||||
const resetBtn = document.getElementById("scheduler-reset-btn");
|
||||
if (resetBtn) resetBtn.addEventListener("click", resetScheduler);
|
||||
|
||||
const addBtn = document.getElementById("scheduler-ts-add-btn");
|
||||
if (addBtn) addBtn.addEventListener("click", addEntry);
|
||||
|
||||
// Populate add-entry bookmark selector
|
||||
const tsBookmarkEl = document.getElementById("scheduler-ts-bookmark");
|
||||
if (tsBookmarkEl) {
|
||||
tsBookmarkEl.innerHTML = '<option value="">— select bookmark —</option>';
|
||||
bookmarkList.forEach(function (bm) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = bm.id;
|
||||
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||
tsBookmarkEl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
window.initScheduler = initScheduler;
|
||||
window.destroyScheduler = destroyScheduler;
|
||||
window.wireSchedulerEvents = wireSchedulerEvents;
|
||||
window.reloadSchedulerRigSelect = renderSchedulerRigSelect;
|
||||
})();
|
||||
@@ -3330,3 +3330,130 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
--wavelength-fg: #3a7a3a;
|
||||
--spectrum-bg: #e0f0e0;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Scheduler tab
|
||||
========================================================================= */
|
||||
.sch-panel {
|
||||
padding: 1rem;
|
||||
max-width: 900px;
|
||||
}
|
||||
.sch-toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 0.4rem;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sch-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.sch-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
min-width: 9rem;
|
||||
}
|
||||
.sch-rig-select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
.sch-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.sch-section-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.sch-ts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.sch-ts-table th,
|
||||
.sch-ts-table td {
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.sch-ts-table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sch-remove-btn {
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.sch-remove-btn:hover {
|
||||
background: var(--btn-bg-hover, var(--btn-bg));
|
||||
}
|
||||
.sch-add-row {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.sch-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.sch-save-btn {
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.sch-save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.sch-reset-btn {
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.sch-status-card {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.sch-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sch-label {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user