You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
332 lines
11 KiB
JavaScript
332 lines
11 KiB
JavaScript
const state = {
|
|
result: null,
|
|
frequencies: null,
|
|
health: null,
|
|
config: null,
|
|
writeToken: "",
|
|
activeSection: "overview",
|
|
selectedReceiverIndex: 0,
|
|
receiverDrafts: [],
|
|
};
|
|
const HZ_IN_MHZ = 1_000_000;
|
|
|
|
function byId(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function fmt(value, digits = 6) {
|
|
if (value === null || value === undefined) return "-";
|
|
if (typeof value !== "number") return String(value);
|
|
return Number.isFinite(value) ? value.toFixed(digits) : String(value);
|
|
}
|
|
|
|
function hzToMhz(value) {
|
|
if (value === null || value === undefined) return null;
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) return null;
|
|
return numeric / HZ_IN_MHZ;
|
|
}
|
|
|
|
function authHeaders() {
|
|
const token = state.writeToken || "";
|
|
if (!token) return {};
|
|
return {
|
|
"X-API-Token": token,
|
|
Authorization: `Bearer ${token}`,
|
|
};
|
|
}
|
|
|
|
function setActiveSection(section) {
|
|
state.activeSection = section;
|
|
document.querySelectorAll(".panel").forEach((panel) => {
|
|
panel.classList.toggle("panel-active", panel.id === `section-${section}`);
|
|
});
|
|
document.querySelectorAll(".menu-item").forEach((item) => {
|
|
item.classList.toggle("menu-item-active", item.dataset.section === section);
|
|
});
|
|
}
|
|
|
|
function setMenuOpen(isOpen) {
|
|
byId("menu-list").classList.toggle("menu-list-open", isOpen);
|
|
}
|
|
|
|
function normalizeReceiverDraft(receiver) {
|
|
const filter = receiver?.input_filter || {};
|
|
return {
|
|
receiver_id: receiver?.receiver_id || "",
|
|
source_url: receiver?.source_url || "",
|
|
input_filter: {
|
|
enabled: Boolean(filter.enabled),
|
|
min_frequency_mhz: filter.min_frequency_mhz ?? hzToMhz(filter.min_frequency_hz) ?? 0,
|
|
max_frequency_mhz: filter.max_frequency_mhz ?? hzToMhz(filter.max_frequency_hz) ?? 0,
|
|
min_rssi_dbm: filter.min_rssi_dbm ?? -200,
|
|
max_rssi_dbm: filter.max_rssi_dbm ?? 50,
|
|
},
|
|
};
|
|
}
|
|
|
|
function saveCurrentReceiverDraftFromInputs() {
|
|
const idx = state.selectedReceiverIndex;
|
|
if (!state.receiverDrafts[idx]) return;
|
|
state.receiverDrafts[idx] = {
|
|
...state.receiverDrafts[idx],
|
|
source_url: byId("rx-url").value.trim(),
|
|
input_filter: {
|
|
enabled: byId("rx-filter-enabled").value === "true",
|
|
min_frequency_mhz: Number(byId("rx-min-freq").value),
|
|
max_frequency_mhz: Number(byId("rx-max-freq").value),
|
|
min_rssi_dbm: Number(byId("rx-min-rssi").value),
|
|
max_rssi_dbm: Number(byId("rx-max-rssi").value),
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderSelectedReceiverDraft() {
|
|
const draft = state.receiverDrafts[state.selectedReceiverIndex];
|
|
if (!draft) return;
|
|
byId("rx-url").value = draft.source_url;
|
|
byId("rx-filter-enabled").value = String(Boolean(draft.input_filter.enabled));
|
|
byId("rx-min-freq").value = draft.input_filter.min_frequency_mhz;
|
|
byId("rx-max-freq").value = draft.input_filter.max_frequency_mhz;
|
|
byId("rx-min-rssi").value = draft.input_filter.min_rssi_dbm;
|
|
byId("rx-max-rssi").value = draft.input_filter.max_rssi_dbm;
|
|
}
|
|
|
|
function fillReceiverSelect() {
|
|
const select = byId("receiver-select");
|
|
select.innerHTML = "";
|
|
state.receiverDrafts.forEach((draft, index) => {
|
|
const option = document.createElement("option");
|
|
option.value = String(index);
|
|
option.textContent = draft.receiver_id || `receiver_${index + 1}`;
|
|
select.appendChild(option);
|
|
});
|
|
if (state.selectedReceiverIndex >= state.receiverDrafts.length) {
|
|
state.selectedReceiverIndex = 0;
|
|
}
|
|
select.value = String(state.selectedReceiverIndex);
|
|
}
|
|
|
|
async function getJson(url) {
|
|
const res = await fetch(url);
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(data.error || data.status || `HTTP ${res.status}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function postJson(url, payload) {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(data.error || data.status || `HTTP ${res.status}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function render() {
|
|
const data = state.result?.data;
|
|
const delivery = state.result?.output_delivery || state.frequencies?.output_delivery;
|
|
byId("updated-at").textContent = `updated: ${state.result?.updated_at_utc || "n/a"}`;
|
|
byId("health-status").textContent = `health: ${state.health?.status || "n/a"}`;
|
|
byId("delivery-status").textContent = `delivery: ${delivery?.status || "n/a"}`;
|
|
|
|
if (!data) {
|
|
byId("selected-freq").textContent = "-";
|
|
byId("pos-x").textContent = "-";
|
|
byId("pos-y").textContent = "-";
|
|
byId("pos-z").textContent = "-";
|
|
byId("rmse").textContent = "-";
|
|
byId("receivers-list").textContent = "Нет данных";
|
|
byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2);
|
|
byId("freq-table").querySelector("tbody").innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const selectedMhz = data.selected_frequency_mhz ?? hzToMhz(data.selected_frequency_hz);
|
|
byId("selected-freq").textContent =
|
|
selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} MHz`;
|
|
byId("pos-x").textContent = fmt(data.position?.x);
|
|
byId("pos-y").textContent = fmt(data.position?.y);
|
|
byId("pos-z").textContent = fmt(data.position?.z);
|
|
byId("rmse").textContent = fmt(data.rmse_m);
|
|
|
|
const receivers = data.receivers || [];
|
|
byId("receivers-list").textContent = JSON.stringify(receivers, null, 2);
|
|
byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2);
|
|
|
|
const rows = data.frequency_table || [];
|
|
const tbody = byId("freq-table").querySelector("tbody");
|
|
tbody.innerHTML = rows
|
|
.map(
|
|
(row) => `
|
|
<tr>
|
|
<td>${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)}</td>
|
|
<td>${fmt(row.position?.x)}</td>
|
|
<td>${fmt(row.position?.y)}</td>
|
|
<td>${fmt(row.position?.z)}</td>
|
|
<td>${fmt(row.rmse_m)}</td>
|
|
<td>${row.exact ? "yes" : "no"}</td>
|
|
</tr>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function loadAll() {
|
|
const [healthRes, resultRes, freqRes] = await Promise.allSettled([
|
|
getJson("/health"),
|
|
getJson("/result"),
|
|
getJson("/frequencies"),
|
|
]);
|
|
state.health = healthRes.status === "fulfilled" ? healthRes.value : { status: "error" };
|
|
state.result = resultRes.status === "fulfilled" ? resultRes.value : null;
|
|
state.frequencies = freqRes.status === "fulfilled" ? freqRes.value : null;
|
|
render();
|
|
}
|
|
|
|
async function refreshNow() {
|
|
await postJson("/refresh", {});
|
|
await loadAll();
|
|
}
|
|
|
|
async function loadConfig() {
|
|
try {
|
|
const config = await getJson("/config");
|
|
state.config = config.config || null;
|
|
byId("config-editor").value = JSON.stringify(config.config, null, 2);
|
|
fillServerForm();
|
|
byId("config-state").textContent = "config: loaded";
|
|
byId("servers-state").textContent = "servers: loaded";
|
|
} catch (err) {
|
|
byId("config-state").textContent = `config: ${err.message}`;
|
|
byId("servers-state").textContent = `servers: ${err.message}`;
|
|
}
|
|
}
|
|
|
|
async function saveConfig() {
|
|
const raw = byId("config-editor").value.trim();
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
const result = await postJson("/config", parsed);
|
|
state.config = parsed;
|
|
byId("config-state").textContent = result.restart_required
|
|
? "config: saved, restart required"
|
|
: "config: saved";
|
|
} catch (err) {
|
|
byId("config-state").textContent = `config: ${err.message}`;
|
|
}
|
|
}
|
|
|
|
function fillServerForm() {
|
|
const cfg = state.config;
|
|
if (!cfg) return;
|
|
const receivers = cfg.input?.receivers || [];
|
|
state.receiverDrafts = receivers.map((receiver) => normalizeReceiverDraft(receiver));
|
|
fillReceiverSelect();
|
|
renderSelectedReceiverDraft();
|
|
const out = cfg.runtime?.output_server || {};
|
|
byId("write-token").value = "";
|
|
byId("out-enabled").value = String(Boolean(out.enabled));
|
|
byId("out-freq-filter-enabled").value = String(Boolean(out.frequency_filter_enabled));
|
|
const minMhz = out.min_frequency_mhz ?? hzToMhz(out.min_frequency_hz) ?? 0;
|
|
const maxMhz = out.max_frequency_mhz ?? hzToMhz(out.max_frequency_hz) ?? 0;
|
|
byId("out-min-freq").value = minMhz;
|
|
byId("out-max-freq").value = maxMhz;
|
|
byId("out-ip").value = out.ip || "";
|
|
byId("out-port").value = out.port ?? 8080;
|
|
byId("out-path").value = out.path || "/triangulation";
|
|
byId("out-timeout").value = out.timeout_s ?? 3.0;
|
|
}
|
|
|
|
async function saveServers() {
|
|
try {
|
|
if (!state.config) {
|
|
await loadConfig();
|
|
}
|
|
saveCurrentReceiverDraftFromInputs();
|
|
const cfg = structuredClone(state.config);
|
|
cfg.input = cfg.input || {};
|
|
cfg.input.receivers = cfg.input.receivers || [{}, {}, {}];
|
|
cfg.runtime = cfg.runtime || {};
|
|
cfg.runtime.output_server = cfg.runtime.output_server || {};
|
|
|
|
for (let i = 0; i < cfg.input.receivers.length; i += 1) {
|
|
const draft = state.receiverDrafts[i] || normalizeReceiverDraft(cfg.input.receivers[i]);
|
|
cfg.input.receivers[i].source_url = draft.source_url;
|
|
cfg.input.receivers[i].input_filter = { ...draft.input_filter };
|
|
}
|
|
cfg.runtime.output_server.enabled = byId("out-enabled").value === "true";
|
|
cfg.runtime.output_server.frequency_filter_enabled =
|
|
byId("out-freq-filter-enabled").value === "true";
|
|
cfg.runtime.output_server.min_frequency_mhz = Number(byId("out-min-freq").value);
|
|
cfg.runtime.output_server.max_frequency_mhz = Number(byId("out-max-freq").value);
|
|
cfg.runtime.output_server.ip = byId("out-ip").value.trim();
|
|
cfg.runtime.output_server.port = Number(byId("out-port").value);
|
|
cfg.runtime.output_server.path = byId("out-path").value.trim() || "/triangulation";
|
|
cfg.runtime.output_server.timeout_s = Number(byId("out-timeout").value);
|
|
|
|
const result = await postJson("/config", cfg);
|
|
state.config = cfg;
|
|
byId("config-editor").value = JSON.stringify(cfg, null, 2);
|
|
byId("servers-state").textContent = result.restart_required
|
|
? "servers: saved, restart required"
|
|
: "servers: saved";
|
|
} catch (err) {
|
|
byId("servers-state").textContent = `servers: ${err.message}`;
|
|
}
|
|
}
|
|
|
|
function bindUi() {
|
|
byId("refresh-now").addEventListener("click", refreshNow);
|
|
byId("load-config").addEventListener("click", loadConfig);
|
|
byId("save-config").addEventListener("click", saveConfig);
|
|
byId("load-servers").addEventListener("click", loadConfig);
|
|
byId("save-servers").addEventListener("click", saveServers);
|
|
byId("write-token").addEventListener("input", (event) => {
|
|
state.writeToken = event.target.value;
|
|
});
|
|
byId("receiver-select").addEventListener("change", (event) => {
|
|
saveCurrentReceiverDraftFromInputs();
|
|
state.selectedReceiverIndex = Number(event.target.value);
|
|
renderSelectedReceiverDraft();
|
|
});
|
|
byId("menu-toggle").addEventListener("click", () => {
|
|
const open = !byId("menu-list").classList.contains("menu-list-open");
|
|
setMenuOpen(open);
|
|
});
|
|
document.querySelectorAll(".menu-item").forEach((item) => {
|
|
item.addEventListener("click", () => {
|
|
setActiveSection(item.dataset.section);
|
|
setMenuOpen(false);
|
|
});
|
|
});
|
|
document.addEventListener("click", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) return;
|
|
if (
|
|
target.closest("#menu-toggle") ||
|
|
target.closest("#menu-list")
|
|
) {
|
|
return;
|
|
}
|
|
setMenuOpen(false);
|
|
});
|
|
}
|
|
|
|
async function boot() {
|
|
bindUi();
|
|
setActiveSection(state.activeSection);
|
|
await loadConfig();
|
|
await loadAll();
|
|
setInterval(loadAll, 2000);
|
|
}
|
|
|
|
boot().catch((err) => {
|
|
byId("health-status").textContent = `health: ${err.message}`;
|
|
});
|