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

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}`;
});