|
|
const state = {
|
|
|
result: null,
|
|
|
frequencies: null,
|
|
|
health: null,
|
|
|
config: null,
|
|
|
writeToken: "",
|
|
|
activeSection: "overview",
|
|
|
selectedReceiverIndex: 0,
|
|
|
receiverDrafts: [],
|
|
|
sharedFilterDraft: {
|
|
|
enabled: false,
|
|
|
min_frequency_mhz: 0,
|
|
|
max_frequency_mhz: 0,
|
|
|
min_rssi_dbm: -200,
|
|
|
max_rssi_dbm: 50,
|
|
|
},
|
|
|
selectedOutputIndex: 0,
|
|
|
outputDrafts: [],
|
|
|
};
|
|
|
|
|
|
const HZ_IN_MHZ = 1_000_000;
|
|
|
|
|
|
function byId(id) {
|
|
|
return document.getElementById(id);
|
|
|
}
|
|
|
|
|
|
function setTextWithPulse(id, value) {
|
|
|
const el = byId(id);
|
|
|
if (!el) return;
|
|
|
const next = String(value);
|
|
|
const changed = el.textContent !== next;
|
|
|
el.textContent = next;
|
|
|
if (!changed) return;
|
|
|
el.classList.remove("value-updated");
|
|
|
void el.offsetWidth;
|
|
|
el.classList.add("value-updated");
|
|
|
}
|
|
|
|
|
|
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 localizeStatus(value) {
|
|
|
const status = String(value || "n/a");
|
|
|
const mapping = {
|
|
|
ok: "ок",
|
|
|
error: "ошибка",
|
|
|
warming_up: "прогрев",
|
|
|
partial: "частично",
|
|
|
skipped: "пропущено",
|
|
|
disabled: "отключено",
|
|
|
not_found: "не найдено",
|
|
|
n_a: "н/д",
|
|
|
"n/a": "н/д",
|
|
|
true: "включено",
|
|
|
false: "отключено",
|
|
|
};
|
|
|
return mapping[status] || status;
|
|
|
}
|
|
|
|
|
|
function localizeErrorMessage(message) {
|
|
|
const text = String(message || "неизвестная ошибка");
|
|
|
const known = {
|
|
|
"at least 3 input servers are required": "необходимо минимум 3 входных сервера",
|
|
|
"at least 1 output server is required": "необходим минимум 1 выходной сервер",
|
|
|
Unauthorized: "доступ запрещён (проверьте токен)",
|
|
|
"unauthorized: missing or invalid API token": "доступ запрещён: отсутствует или неверный API-токен",
|
|
|
warming_up: "прогрев",
|
|
|
not_found: "не найдено",
|
|
|
"no data yet": "данные пока не получены",
|
|
|
};
|
|
|
if (known[text]) return known[text];
|
|
|
if (text.startsWith("HTTP ")) return `ошибка HTTP: ${text.slice(5)}`;
|
|
|
if (text.startsWith("Config validation failed:")) {
|
|
|
return `ошибка валидации конфига: ${text.replace("Config validation failed:", "").trim()}`;
|
|
|
}
|
|
|
return text;
|
|
|
}
|
|
|
|
|
|
function formatUpdatedTimestamp(value) {
|
|
|
if (!value) {
|
|
|
return { date: "дата: н/д", time: "время: н/д" };
|
|
|
}
|
|
|
const text = String(value);
|
|
|
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:\d{2})?$/);
|
|
|
if (!match) {
|
|
|
return { date: `дата: ${text}`, time: "время: н/д" };
|
|
|
}
|
|
|
const datePart = `${match[3]}.${match[2]}.${match[1]}`;
|
|
|
const zone = match[5] || "";
|
|
|
const zoneLabel = zone === "Z" || zone === "+00:00" ? " UTC" : zone ? ` ${zone}` : "";
|
|
|
return { date: `дата: ${datePart}`, time: `время: ${match[4]}${zoneLabel}` };
|
|
|
}
|
|
|
|
|
|
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 parseMhzList(raw) {
|
|
|
const text = String(raw || "").trim();
|
|
|
if (!text) return [];
|
|
|
const values = text
|
|
|
.split(",")
|
|
|
.map((part) => Number(part.trim()))
|
|
|
.filter((value) => Number.isFinite(value) && value > 0);
|
|
|
return Array.from(new Set(values));
|
|
|
}
|
|
|
|
|
|
function formatMhzList(values) {
|
|
|
if (!Array.isArray(values) || values.length === 0) return "";
|
|
|
return values
|
|
|
.map((value) => Number(value))
|
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
|
.map((value) => value.toFixed(3).replace(/\.?0+$/, ""))
|
|
|
.join(", ");
|
|
|
}
|
|
|
|
|
|
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 normalizeInputFilter(filter) {
|
|
|
const source = filter || {};
|
|
|
return {
|
|
|
enabled: Boolean(source.enabled),
|
|
|
min_frequency_mhz: Number(source.min_frequency_mhz ?? hzToMhz(source.min_frequency_hz) ?? 0),
|
|
|
max_frequency_mhz: Number(source.max_frequency_mhz ?? hzToMhz(source.max_frequency_hz) ?? 0),
|
|
|
min_rssi_dbm: Number(source.min_rssi_dbm ?? -200),
|
|
|
max_rssi_dbm: Number(source.max_rssi_dbm ?? 50),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function createReceiverDraft(index) {
|
|
|
return {
|
|
|
receiver_id: `r${index}`,
|
|
|
source_url: "",
|
|
|
frequencies_mhz: [],
|
|
|
center: { x: 0, y: 0, z: 0 },
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function normalizeReceiverDraft(receiver, index) {
|
|
|
const center = receiver?.center || {};
|
|
|
const access = receiver?.access || {};
|
|
|
const frequencies = Array.isArray(receiver?.frequencies_mhz)
|
|
|
? receiver.frequencies_mhz
|
|
|
: [];
|
|
|
return {
|
|
|
receiver_id: receiver?.receiver_id || `r${index}`,
|
|
|
source_url: String(receiver?.source_url || access.url || access.source_url || ""),
|
|
|
frequencies_mhz: frequencies
|
|
|
.map((value) => Number(value))
|
|
|
.filter((value) => Number.isFinite(value) && value > 0),
|
|
|
center: {
|
|
|
x: Number(center.x ?? 0),
|
|
|
y: Number(center.y ?? 0),
|
|
|
z: Number(center.z ?? 0),
|
|
|
},
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function updateReceiverCountBadge() {
|
|
|
byId("receiver-count").textContent = `входов: ${state.receiverDrafts.length}`;
|
|
|
}
|
|
|
|
|
|
function saveCurrentReceiverDraftFromInputs() {
|
|
|
const idx = state.selectedReceiverIndex;
|
|
|
if (!state.receiverDrafts[idx]) return;
|
|
|
state.receiverDrafts[idx] = {
|
|
|
...state.receiverDrafts[idx],
|
|
|
receiver_id: byId("rx-id").value.trim() || `r${idx}`,
|
|
|
source_url: byId("rx-url").value.trim(),
|
|
|
frequencies_mhz: parseMhzList(byId("rx-frequencies").value),
|
|
|
center: {
|
|
|
x: Number(byId("rx-center-x").value),
|
|
|
y: Number(byId("rx-center-y").value),
|
|
|
z: Number(byId("rx-center-z").value),
|
|
|
},
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function renderSelectedReceiverDraft() {
|
|
|
const draft = state.receiverDrafts[state.selectedReceiverIndex];
|
|
|
if (!draft) return;
|
|
|
byId("rx-id").value = draft.receiver_id;
|
|
|
byId("rx-url").value = draft.source_url;
|
|
|
byId("rx-frequencies").value = formatMhzList(draft.frequencies_mhz);
|
|
|
byId("rx-center-x").value = draft.center.x;
|
|
|
byId("rx-center-y").value = draft.center.y;
|
|
|
byId("rx-center-z").value = draft.center.z;
|
|
|
}
|
|
|
|
|
|
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 || `вход_${index + 1}`;
|
|
|
select.appendChild(option);
|
|
|
});
|
|
|
if (state.selectedReceiverIndex >= state.receiverDrafts.length) {
|
|
|
state.selectedReceiverIndex = Math.max(0, state.receiverDrafts.length - 1);
|
|
|
}
|
|
|
select.value = String(state.selectedReceiverIndex);
|
|
|
updateReceiverCountBadge();
|
|
|
}
|
|
|
|
|
|
function addReceiverDraft() {
|
|
|
saveCurrentReceiverDraftFromInputs();
|
|
|
const nextIndex = state.receiverDrafts.length;
|
|
|
state.receiverDrafts.push(createReceiverDraft(nextIndex));
|
|
|
state.selectedReceiverIndex = nextIndex;
|
|
|
fillReceiverSelect();
|
|
|
renderSelectedReceiverDraft();
|
|
|
}
|
|
|
|
|
|
function removeReceiverDraft() {
|
|
|
if (state.receiverDrafts.length <= 3) {
|
|
|
byId("servers-state").textContent = "серверы: необходимо минимум 3 входа";
|
|
|
return;
|
|
|
}
|
|
|
state.receiverDrafts.splice(state.selectedReceiverIndex, 1);
|
|
|
state.selectedReceiverIndex = Math.max(0, state.selectedReceiverIndex - 1);
|
|
|
fillReceiverSelect();
|
|
|
renderSelectedReceiverDraft();
|
|
|
}
|
|
|
|
|
|
function createOutputDraft(index) {
|
|
|
return {
|
|
|
name: `выход_${index + 1}`,
|
|
|
ip: "",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function normalizeOutputDraft(output, index) {
|
|
|
const source = output || {};
|
|
|
return {
|
|
|
name: String(source.name || `выход_${index + 1}`),
|
|
|
ip: String(source.ip || ""),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function updateOutputCountBadge() {
|
|
|
byId("output-count").textContent = `выходов: ${state.outputDrafts.length}`;
|
|
|
}
|
|
|
|
|
|
function saveCurrentOutputDraftFromInputs() {
|
|
|
const idx = state.selectedOutputIndex;
|
|
|
if (!state.outputDrafts[idx]) return;
|
|
|
state.outputDrafts[idx] = {
|
|
|
...state.outputDrafts[idx],
|
|
|
name: byId("out-name").value.trim() || `выход_${idx + 1}`,
|
|
|
ip: byId("out-ip").value.trim(),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function renderSelectedOutputDraft() {
|
|
|
const draft = state.outputDrafts[state.selectedOutputIndex];
|
|
|
if (!draft) return;
|
|
|
byId("out-name").value = draft.name;
|
|
|
byId("out-ip").value = draft.ip;
|
|
|
}
|
|
|
|
|
|
function fillOutputSelect() {
|
|
|
const select = byId("output-select");
|
|
|
select.innerHTML = "";
|
|
|
state.outputDrafts.forEach((draft, index) => {
|
|
|
const option = document.createElement("option");
|
|
|
option.value = String(index);
|
|
|
option.textContent = draft.name || `выход_${index + 1}`;
|
|
|
select.appendChild(option);
|
|
|
});
|
|
|
if (state.selectedOutputIndex >= state.outputDrafts.length) {
|
|
|
state.selectedOutputIndex = Math.max(0, state.outputDrafts.length - 1);
|
|
|
}
|
|
|
select.value = String(state.selectedOutputIndex);
|
|
|
updateOutputCountBadge();
|
|
|
}
|
|
|
|
|
|
function addOutputDraft() {
|
|
|
saveCurrentOutputDraftFromInputs();
|
|
|
const nextIndex = state.outputDrafts.length;
|
|
|
state.outputDrafts.push(createOutputDraft(nextIndex));
|
|
|
state.selectedOutputIndex = nextIndex;
|
|
|
fillOutputSelect();
|
|
|
renderSelectedOutputDraft();
|
|
|
}
|
|
|
|
|
|
function removeOutputDraft() {
|
|
|
if (state.outputDrafts.length <= 1) {
|
|
|
byId("servers-state").textContent = "серверы: необходим минимум 1 выход";
|
|
|
return;
|
|
|
}
|
|
|
state.outputDrafts.splice(state.selectedOutputIndex, 1);
|
|
|
state.selectedOutputIndex = Math.max(0, state.selectedOutputIndex - 1);
|
|
|
fillOutputSelect();
|
|
|
renderSelectedOutputDraft();
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
const updated = formatUpdatedTimestamp(state.result?.updated_at_utc);
|
|
|
setTextWithPulse("updated-date", updated.date);
|
|
|
setTextWithPulse("updated-time", updated.time);
|
|
|
setTextWithPulse("health-status", `состояние: ${localizeStatus(state.health?.status)}`);
|
|
|
setTextWithPulse("delivery-status", `доставка: ${localizeStatus(delivery?.status)}`);
|
|
|
|
|
|
if (!data) {
|
|
|
setTextWithPulse("selected-freq", "-");
|
|
|
setTextWithPulse("pos-x", "-");
|
|
|
setTextWithPulse("pos-y", "-");
|
|
|
setTextWithPulse("pos-z", "-");
|
|
|
setTextWithPulse("rmse", "-");
|
|
|
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);
|
|
|
setTextWithPulse("selected-freq", selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} МГц`);
|
|
|
setTextWithPulse("pos-x", fmt(data.position?.x));
|
|
|
setTextWithPulse("pos-y", fmt(data.position?.y));
|
|
|
setTextWithPulse("pos-z", fmt(data.position?.z));
|
|
|
setTextWithPulse("rmse", 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, index) => `
|
|
|
<tr class="row-enter" style="animation-delay:${index * 40}ms">
|
|
|
<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 ? "да" : "нет"}</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 = "конфиг: загружен";
|
|
|
byId("servers-state").textContent = "серверы: загружены";
|
|
|
} catch (err) {
|
|
|
byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`;
|
|
|
byId("servers-state").textContent = `серверы: ${localizeErrorMessage(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;
|
|
|
const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : "";
|
|
|
byId("config-state").textContent = result.restart_required
|
|
|
? `конфиг: сохранён, требуется перезапуск${saveSuffix}`
|
|
|
: `конфиг: сохранён${saveSuffix}`;
|
|
|
} catch (err) {
|
|
|
byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function fillServerForm() {
|
|
|
const cfg = state.config;
|
|
|
if (!cfg) return;
|
|
|
|
|
|
const receivers = cfg.input?.receivers || [];
|
|
|
state.receiverDrafts = receivers.map((receiver, index) => normalizeReceiverDraft(receiver, index));
|
|
|
if (state.receiverDrafts.length < 3) {
|
|
|
while (state.receiverDrafts.length < 3) {
|
|
|
state.receiverDrafts.push(createReceiverDraft(state.receiverDrafts.length));
|
|
|
}
|
|
|
}
|
|
|
fillReceiverSelect();
|
|
|
renderSelectedReceiverDraft();
|
|
|
|
|
|
const sharedFilterSource =
|
|
|
cfg.input?.default_input_filter || cfg.input?.receivers?.[0]?.input_filter || {};
|
|
|
state.sharedFilterDraft = normalizeInputFilter(sharedFilterSource);
|
|
|
byId("shared-filter-enabled").value = String(Boolean(state.sharedFilterDraft.enabled));
|
|
|
byId("shared-min-freq").value = state.sharedFilterDraft.min_frequency_mhz;
|
|
|
byId("shared-max-freq").value = state.sharedFilterDraft.max_frequency_mhz;
|
|
|
byId("shared-min-rssi").value = state.sharedFilterDraft.min_rssi_dbm;
|
|
|
byId("shared-max-rssi").value = state.sharedFilterDraft.max_rssi_dbm;
|
|
|
|
|
|
const runtime = cfg.runtime || {};
|
|
|
const outputServers = Array.isArray(runtime.output_servers)
|
|
|
? runtime.output_servers
|
|
|
: [runtime.output_server || {}];
|
|
|
state.outputDrafts = outputServers.map((output, index) => normalizeOutputDraft(output, index));
|
|
|
if (state.outputDrafts.length < 1) {
|
|
|
state.outputDrafts.push(createOutputDraft(0));
|
|
|
}
|
|
|
fillOutputSelect();
|
|
|
renderSelectedOutputDraft();
|
|
|
|
|
|
byId("write-token").value = "";
|
|
|
}
|
|
|
|
|
|
async function saveServers() {
|
|
|
try {
|
|
|
if (!state.config) {
|
|
|
await loadConfig();
|
|
|
}
|
|
|
|
|
|
saveCurrentReceiverDraftFromInputs();
|
|
|
saveCurrentOutputDraftFromInputs();
|
|
|
|
|
|
if (state.receiverDrafts.length < 3) {
|
|
|
throw new Error("at least 3 input servers are required");
|
|
|
}
|
|
|
if (state.outputDrafts.length < 1) {
|
|
|
throw new Error("at least 1 output server is required");
|
|
|
}
|
|
|
|
|
|
const sharedFilter = {
|
|
|
enabled: byId("shared-filter-enabled").value === "true",
|
|
|
min_frequency_mhz: Number(byId("shared-min-freq").value),
|
|
|
max_frequency_mhz: Number(byId("shared-max-freq").value),
|
|
|
min_rssi_dbm: Number(byId("shared-min-rssi").value),
|
|
|
max_rssi_dbm: Number(byId("shared-max-rssi").value),
|
|
|
};
|
|
|
|
|
|
const cfg = structuredClone(state.config);
|
|
|
cfg.input = cfg.input || {};
|
|
|
cfg.input.default_input_filter = { ...sharedFilter };
|
|
|
cfg.input.receivers = state.receiverDrafts.map((draft, index) => ({
|
|
|
receiver_id: draft.receiver_id || `r${index}`,
|
|
|
center: {
|
|
|
x: Number(draft.center.x),
|
|
|
y: Number(draft.center.y),
|
|
|
z: Number(draft.center.z),
|
|
|
},
|
|
|
access: {
|
|
|
url: draft.source_url,
|
|
|
},
|
|
|
frequencies_mhz: Array.isArray(draft.frequencies_mhz)
|
|
|
? draft.frequencies_mhz
|
|
|
.map((value) => Number(value))
|
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
|
: [],
|
|
|
}));
|
|
|
|
|
|
cfg.runtime = cfg.runtime || {};
|
|
|
cfg.runtime.output_servers = state.outputDrafts.map((draft, index) => ({
|
|
|
name: draft.name || `выход_${index + 1}`,
|
|
|
ip: draft.ip,
|
|
|
}));
|
|
|
cfg.runtime.output_server = { ...cfg.runtime.output_servers[0] };
|
|
|
|
|
|
const result = await postJson("/config", cfg);
|
|
|
state.config = cfg;
|
|
|
byId("config-editor").value = JSON.stringify(cfg, null, 2);
|
|
|
|
|
|
const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : "";
|
|
|
byId("servers-state").textContent = result.restart_required
|
|
|
? `серверы: сохранены, требуется перезапуск${saveSuffix}`
|
|
|
: `серверы: сохранены${saveSuffix}`;
|
|
|
} catch (err) {
|
|
|
byId("servers-state").textContent = `серверы: ${localizeErrorMessage(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("add-receiver").addEventListener("click", addReceiverDraft);
|
|
|
byId("remove-receiver").addEventListener("click", removeReceiverDraft);
|
|
|
byId("receiver-select").addEventListener("change", (event) => {
|
|
|
saveCurrentReceiverDraftFromInputs();
|
|
|
state.selectedReceiverIndex = Number(event.target.value);
|
|
|
renderSelectedReceiverDraft();
|
|
|
});
|
|
|
|
|
|
byId("add-output-server").addEventListener("click", addOutputDraft);
|
|
|
byId("remove-output-server").addEventListener("click", removeOutputDraft);
|
|
|
byId("output-select").addEventListener("change", (event) => {
|
|
|
saveCurrentOutputDraftFromInputs();
|
|
|
state.selectedOutputIndex = Number(event.target.value);
|
|
|
renderSelectedOutputDraft();
|
|
|
});
|
|
|
|
|
|
byId("write-token").addEventListener("input", (event) => {
|
|
|
state.writeToken = event.target.value;
|
|
|
});
|
|
|
|
|
|
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) => {
|
|
|
setTextWithPulse("health-status", `состояние: ${localizeErrorMessage(err.message)}`);
|
|
|
});
|