5221 lines
173 KiB
JavaScript
5221 lines
173 KiB
JavaScript
const OPENWEBRX_TX_POLL_MS_DEFAULT = 3000;
|
|
const SWR_EXPECTED_DURATION_SEC_DEFAULT = 40;
|
|
const SWR_DETAIL_REFRESH_MS = 20000;
|
|
const DEFAULT_UI_LANGUAGE = "de";
|
|
const SUPPORTED_UI_LANGUAGES = ["de", "en"];
|
|
const LANGUAGE_STORAGE_KEY = "rms-lang";
|
|
const DEFAULT_ALLOWED_LOGIN_DOMAINS = ["arcg.at", "oevsv.at"];
|
|
|
|
let activationWatchTimer = null;
|
|
let activationWatchInFlight = false;
|
|
let activationPending = false;
|
|
let remainingUsageTimer = null;
|
|
let resumeRefreshInFlight = false;
|
|
let rotorCompassDragPointerId = null;
|
|
|
|
const state = {
|
|
user: null,
|
|
status: null,
|
|
system: {
|
|
maintenanceMode: false,
|
|
maintenanceMessage: "",
|
|
updatedAt: null,
|
|
branding: {
|
|
logoLightUrl: null,
|
|
logoDarkUrl: null
|
|
},
|
|
allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice()
|
|
},
|
|
accessToken: localStorage.getItem("rms-access-token") || "",
|
|
refreshToken: localStorage.getItem("rms-refresh-token") || "",
|
|
controls: [],
|
|
plugins: [],
|
|
providers: {},
|
|
capabilities: [],
|
|
authMethods: [],
|
|
users: [],
|
|
helpContent: null,
|
|
swrReport: null,
|
|
activityEntries: [],
|
|
activityFilter: {
|
|
query: "",
|
|
type: "all"
|
|
},
|
|
approvals: [],
|
|
approvalsFilter: {
|
|
mode: "open",
|
|
query: "",
|
|
status: "all"
|
|
},
|
|
usersFilter: {
|
|
query: "",
|
|
role: "all",
|
|
status: "all"
|
|
},
|
|
openWebRx: {
|
|
sessionUrl: "",
|
|
sessionTicket: "",
|
|
expiresAt: null,
|
|
bands: [],
|
|
selectedBand: "",
|
|
txActive: null,
|
|
powerCommandConfigured: true,
|
|
pttCommandConfigured: true,
|
|
rotor: {
|
|
azimuth: null,
|
|
rawAzimuth: null,
|
|
moving: false,
|
|
stale: false,
|
|
updatedAt: null,
|
|
min: 0,
|
|
max: 360
|
|
},
|
|
busy: false,
|
|
pollMs: OPENWEBRX_TX_POLL_MS_DEFAULT,
|
|
pttPressed: false
|
|
},
|
|
i18n: {
|
|
language: localStorage.getItem(LANGUAGE_STORAGE_KEY) || DEFAULT_UI_LANGUAGE,
|
|
dictionaries: {}
|
|
}
|
|
};
|
|
|
|
const els = {
|
|
authForm: document.getElementById("authForm"),
|
|
callsignInput: document.getElementById("callsignInput"),
|
|
emailDomainSelect: document.getElementById("emailDomainSelect"),
|
|
loginBtn: document.getElementById("loginBtn"),
|
|
authMethodSelect: document.getElementById("authMethodSelect"),
|
|
otpWrap: document.getElementById("otpWrap"),
|
|
otpCode: document.getElementById("otpCode"),
|
|
verifyOtpBtn: document.getElementById("verifyOtpBtn"),
|
|
authMessage: document.getElementById("authMessage"),
|
|
loginDomainNotice: document.getElementById("loginDomainNotice"),
|
|
brandLogo: document.getElementById("brandLogo"),
|
|
brandFallback: document.getElementById("brandFallback"),
|
|
maintenanceBanner: document.getElementById("maintenanceBanner"),
|
|
stationName: document.getElementById("stationName"),
|
|
usageStatus: document.getElementById("usageStatus"),
|
|
activeBy: document.getElementById("activeBy"),
|
|
startedAt: document.getElementById("startedAt"),
|
|
endsAt: document.getElementById("endsAt"),
|
|
remainingUsage: document.getElementById("remainingUsage"),
|
|
stationOnlinePill: document.getElementById("stationOnlinePill"),
|
|
activateBtn: document.getElementById("activateBtn"),
|
|
deactivateBtn: document.getElementById("deactivateBtn"),
|
|
refreshBtn: document.getElementById("refreshBtn"),
|
|
reservationPanel: document.getElementById("reservationPanel"),
|
|
reserveNextBtn: document.getElementById("reserveNextBtn"),
|
|
reservationList: document.getElementById("reservationList"),
|
|
reservationMessage: document.getElementById("reservationMessage"),
|
|
logoutBtn: document.getElementById("logoutBtn"),
|
|
settingsLogoutTopBtn: document.getElementById("settingsLogoutTopBtn"),
|
|
activationProgress: document.getElementById("activationProgress"),
|
|
progressText: document.getElementById("progressText"),
|
|
progressFill: document.getElementById("progressFill"),
|
|
progressEta: document.getElementById("progressEta"),
|
|
activationProgressSwr: document.getElementById("activationProgressSwr"),
|
|
progressTextSwr: document.getElementById("progressTextSwr"),
|
|
progressFillSwr: document.getElementById("progressFillSwr"),
|
|
progressEtaSwr: document.getElementById("progressEtaSwr"),
|
|
stationLinks: document.getElementById("stationLinks"),
|
|
swrLink: document.getElementById("swrLink"),
|
|
openwebrxLink: document.getElementById("openwebrxLink"),
|
|
websdrLink: document.getElementById("websdrLink"),
|
|
rotorLink: document.getElementById("rotorLink"),
|
|
openwebrxPanel: document.getElementById("openwebrxPanel"),
|
|
controlsPanel: document.getElementById("controlsPanel"),
|
|
openwebrxOpenBtn: document.getElementById("openwebrxOpenBtn"),
|
|
openwebrxBandSelect: document.getElementById("openwebrxBandSelect"),
|
|
openwebrxBandSetBtn: document.getElementById("openwebrxBandSetBtn"),
|
|
openwebrxEnableTxBtn: document.getElementById("openwebrxEnableTxBtn"),
|
|
openwebrxCloseBtn: document.getElementById("openwebrxCloseBtn"),
|
|
openwebrxMessage: document.getElementById("openwebrxMessage"),
|
|
openwebrxSessionAccess: document.getElementById("openwebrxSessionAccess"),
|
|
openwebrxSessionLink: document.getElementById("openwebrxSessionLink"),
|
|
openwebrxCopyLinkBtn: document.getElementById("openwebrxCopyLinkBtn"),
|
|
openwebrxSessionTicket: document.getElementById("openwebrxSessionTicket"),
|
|
openwebrxTxStatePill: document.getElementById("openwebrxTxStatePill"),
|
|
rotorCurrent: document.getElementById("rotorCurrent"),
|
|
rotorCompass: document.getElementById("rotorCompass"),
|
|
rotorCompassArrow: document.getElementById("rotorCompassArrow"),
|
|
rotorTarget: document.getElementById("rotorTarget"),
|
|
rotorSetBtn: document.getElementById("rotorSetBtn"),
|
|
rotorPresets: document.getElementById("rotorPresets"),
|
|
controlsMessage: document.getElementById("controlsMessage"),
|
|
statusMessage: document.getElementById("statusMessage"),
|
|
swrSummaryGeneratedAt: document.getElementById("swrSummaryGeneratedAt"),
|
|
swrSummaryOverall: document.getElementById("swrSummaryOverall"),
|
|
swrSummaryBands: document.getElementById("swrSummaryBands"),
|
|
swrSummaryMessage: document.getElementById("swrSummaryMessage"),
|
|
refreshSwrBtn: document.getElementById("refreshSwrBtn"),
|
|
runSwrCheckBtn: document.getElementById("runSwrCheckBtn"),
|
|
swrPageGeneratedAt: document.getElementById("swrPageGeneratedAt"),
|
|
swrPageOverall: document.getElementById("swrPageOverall"),
|
|
swrPageBands: document.getElementById("swrPageBands"),
|
|
swrPageMessage: document.getElementById("swrPageMessage"),
|
|
refreshSwrPageBtn: document.getElementById("refreshSwrPageBtn"),
|
|
runSwrCheckPageBtn: document.getElementById("runSwrCheckPageBtn"),
|
|
themeToggle: document.getElementById("themeToggle"),
|
|
authView: document.getElementById("authView"),
|
|
rmsView: document.getElementById("rmsView"),
|
|
pageRms: document.getElementById("pageRms"),
|
|
pageSwr: document.getElementById("pageSwr"),
|
|
pageUser: document.getElementById("pageUser"),
|
|
pageHelp: document.getElementById("pageHelp"),
|
|
pagePlugins: document.getElementById("pagePlugins"),
|
|
pagePluginConfig: document.getElementById("pagePluginConfig"),
|
|
pageProviders: document.getElementById("pageProviders"),
|
|
pageAdmin: document.getElementById("pageAdmin"),
|
|
pageUsers: document.getElementById("pageUsers"),
|
|
pageApprovals: document.getElementById("pageApprovals"),
|
|
pageActivity: document.getElementById("pageActivity"),
|
|
userMenuButton: document.getElementById("userMenuButton"),
|
|
userMenu: document.getElementById("userMenu"),
|
|
menuRms: document.getElementById("menuRms"),
|
|
menuSwr: document.getElementById("menuSwr"),
|
|
menuUser: document.getElementById("menuUser"),
|
|
menuHelp: document.getElementById("menuHelp"),
|
|
menuPlugins: document.getElementById("menuPlugins"),
|
|
menuPluginConfig: document.getElementById("menuPluginConfig"),
|
|
menuProviders: document.getElementById("menuProviders"),
|
|
menuUsers: document.getElementById("menuUsers"),
|
|
menuApprovals: document.getElementById("menuApprovals"),
|
|
menuActivity: document.getElementById("menuActivity"),
|
|
menuAdmin: document.getElementById("menuAdmin"),
|
|
languageMenuButton: document.getElementById("languageMenuButton"),
|
|
languageMenu: document.getElementById("languageMenu"),
|
|
menuLanguageSelect: document.getElementById("menuLanguageSelect"),
|
|
settingsEmail: document.getElementById("settingsEmail"),
|
|
settingsRole: document.getElementById("settingsRole"),
|
|
settingsAuthMethodSelect: document.getElementById("settingsAuthMethodSelect"),
|
|
settingsLanguageSelect: document.getElementById("settingsLanguageSelect"),
|
|
settingsSaveAuthMethodBtn: document.getElementById("settingsSaveAuthMethodBtn"),
|
|
settingsSaveLanguageBtn: document.getElementById("settingsSaveLanguageBtn"),
|
|
settingsThemeBtn: document.getElementById("settingsThemeBtn"),
|
|
settingsRefreshBtn: document.getElementById("settingsRefreshBtn"),
|
|
pageTitle: document.getElementById("pageTitle"),
|
|
pageHint: document.getElementById("pageHint"),
|
|
pageCrumb: document.getElementById("pageCrumb"),
|
|
currentUserLink: document.getElementById("currentUserLink"),
|
|
mobileNav: document.getElementById("mobileNav"),
|
|
mobileNavRms: document.getElementById("mobileNavRms"),
|
|
mobileNavSwr: document.getElementById("mobileNavSwr"),
|
|
mobileNavUser: document.getElementById("mobileNavUser"),
|
|
mobileNavHelp: document.getElementById("mobileNavHelp"),
|
|
mobileNavPlugins: document.getElementById("mobileNavPlugins"),
|
|
mobileNavPluginConfig: document.getElementById("mobileNavPluginConfig"),
|
|
mobileNavUsers: document.getElementById("mobileNavUsers"),
|
|
mobileNavApprovals: document.getElementById("mobileNavApprovals"),
|
|
mobileNavActivity: document.getElementById("mobileNavActivity"),
|
|
mobileNavAdmin: document.getElementById("mobileNavAdmin"),
|
|
adminCard: document.getElementById("adminCard"),
|
|
setOnlineBtn: document.getElementById("setOnlineBtn"),
|
|
setOfflineBtn: document.getElementById("setOfflineBtn"),
|
|
forceReleaseBtn: document.getElementById("forceReleaseBtn"),
|
|
refreshAuditBtn: document.getElementById("refreshAuditBtn"),
|
|
roleEmail: document.getElementById("roleEmail"),
|
|
setRoleAdminBtn: document.getElementById("setRoleAdminBtn"),
|
|
setRoleOperatorBtn: document.getElementById("setRoleOperatorBtn"),
|
|
adminMessage: document.getElementById("adminMessage"),
|
|
auditLog: document.getElementById("auditLog"),
|
|
pluginControls: document.getElementById("pluginControls"),
|
|
pluginMessage: document.getElementById("pluginMessage"),
|
|
pluginsConfigCard: document.getElementById("pluginsConfigCard"),
|
|
pluginsAdminConfig: document.getElementById("pluginsAdminConfig"),
|
|
refreshPluginsPageBtn: document.getElementById("refreshPluginsPageBtn"),
|
|
providersCapabilityMatrix: document.getElementById("providersCapabilityMatrix"),
|
|
providersAdminConfig: document.getElementById("providersAdminConfig"),
|
|
providersMessage: document.getElementById("providersMessage"),
|
|
refreshProvidersBtn: document.getElementById("refreshProvidersBtn"),
|
|
usersAdmin: document.getElementById("usersAdmin"),
|
|
usersMessage: document.getElementById("usersMessage"),
|
|
usersFilterQuery: document.getElementById("usersFilterQuery"),
|
|
usersFilterRole: document.getElementById("usersFilterRole"),
|
|
usersFilterStatus: document.getElementById("usersFilterStatus"),
|
|
refreshUsersBtn: document.getElementById("refreshUsersBtn"),
|
|
approvalsList: document.getElementById("approvalsList"),
|
|
approvalsMessage: document.getElementById("approvalsMessage"),
|
|
approvalsFilterQuery: document.getElementById("approvalsFilterQuery"),
|
|
approvalsFilterStatus: document.getElementById("approvalsFilterStatus"),
|
|
approvalsFilterOpenBtn: document.getElementById("approvalsFilterOpenBtn"),
|
|
approvalsFilterAllBtn: document.getElementById("approvalsFilterAllBtn"),
|
|
refreshApprovalsBtn: document.getElementById("refreshApprovalsBtn"),
|
|
activityLogList: document.getElementById("activityLogList"),
|
|
activityMessage: document.getElementById("activityMessage"),
|
|
activityFilterQuery: document.getElementById("activityFilterQuery"),
|
|
activityFilterType: document.getElementById("activityFilterType"),
|
|
refreshActivityBtn: document.getElementById("refreshActivityBtn"),
|
|
helpTitle: document.getElementById("helpTitle"),
|
|
helpQuickStartTitle: document.getElementById("helpQuickStartTitle"),
|
|
helpQuickStartSteps: document.getElementById("helpQuickStartSteps"),
|
|
helpSections: document.getElementById("helpSections"),
|
|
helpMessage: document.getElementById("helpMessage"),
|
|
refreshHelpBtn: document.getElementById("refreshHelpBtn"),
|
|
maintenanceStatePill: document.getElementById("maintenanceStatePill"),
|
|
maintenanceMessageInput: document.getElementById("maintenanceMessageInput"),
|
|
maintenanceEnableBtn: document.getElementById("maintenanceEnableBtn"),
|
|
maintenanceDisableBtn: document.getElementById("maintenanceDisableBtn"),
|
|
loginDomainsInput: document.getElementById("loginDomainsInput"),
|
|
saveLoginDomainsBtn: document.getElementById("saveLoginDomainsBtn"),
|
|
logoLightFile: document.getElementById("logoLightFile"),
|
|
logoDarkFile: document.getElementById("logoDarkFile"),
|
|
uploadLogoLightBtn: document.getElementById("uploadLogoLightBtn"),
|
|
uploadLogoDarkBtn: document.getElementById("uploadLogoDarkBtn"),
|
|
removeLogoLightBtn: document.getElementById("removeLogoLightBtn"),
|
|
removeLogoDarkBtn: document.getElementById("removeLogoDarkBtn")
|
|
};
|
|
|
|
init().catch((error) => {
|
|
renderMessage(els.authMessage, error.message || "Initialisierung fehlgeschlagen", true);
|
|
});
|
|
|
|
async function init() {
|
|
await initI18n();
|
|
loadTheme();
|
|
hydrateFilterStateFromUrl();
|
|
bindEvents();
|
|
await refreshPublicSystemStatus();
|
|
await refreshPublicAuthMethods();
|
|
await handleEmailTokenFromUrl();
|
|
await refreshCurrentUser();
|
|
await ensureLanguageFromUserPreference();
|
|
applyRoute(true);
|
|
if (state.user) {
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
await refreshControls();
|
|
await refreshPlugins();
|
|
await refreshUsers();
|
|
await refreshApprovals();
|
|
await refreshActivityLog();
|
|
await refreshHelpContent();
|
|
connectEvents();
|
|
}
|
|
|
|
setInterval(() => {
|
|
if (!state.user) {
|
|
refreshPublicSystemStatus().catch(() => {});
|
|
return;
|
|
}
|
|
const route = currentRoute();
|
|
if (route === "/rms/swr" || route === "/rms") {
|
|
refreshStatus().catch(() => {});
|
|
}
|
|
}, SWR_DETAIL_REFRESH_MS);
|
|
}
|
|
|
|
async function initI18n() {
|
|
const initial = normalizeLanguage(state.i18n.language);
|
|
state.i18n.language = initial;
|
|
await ensureLanguageDictionary("de");
|
|
if (initial !== "de") {
|
|
await ensureLanguageDictionary(initial);
|
|
}
|
|
applyI18n();
|
|
}
|
|
|
|
function normalizeLanguage(value) {
|
|
const next = String(value || "").trim().toLowerCase();
|
|
return SUPPORTED_UI_LANGUAGES.includes(next) ? next : DEFAULT_UI_LANGUAGE;
|
|
}
|
|
|
|
async function ensureLanguageDictionary(language) {
|
|
const normalized = normalizeLanguage(language);
|
|
if (state.i18n.dictionaries[normalized]) {
|
|
return state.i18n.dictionaries[normalized];
|
|
}
|
|
try {
|
|
const response = await fetch(`/i18n/${encodeURIComponent(normalized)}.json`, { cache: "no-store" });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load language ${normalized}`);
|
|
}
|
|
const parsed = await response.json();
|
|
state.i18n.dictionaries[normalized] = parsed && typeof parsed === "object" ? parsed : {};
|
|
} catch {
|
|
state.i18n.dictionaries[normalized] = {};
|
|
}
|
|
return state.i18n.dictionaries[normalized];
|
|
}
|
|
|
|
function languageDictionary(language) {
|
|
return state.i18n.dictionaries[normalizeLanguage(language)] || {};
|
|
}
|
|
|
|
function translateLiteral(text) {
|
|
const source = String(text || "");
|
|
if (!source) {
|
|
return source;
|
|
}
|
|
const current = languageDictionary(state.i18n.language);
|
|
const currentLiterals = current && current.literals && typeof current.literals === "object" ? current.literals : {};
|
|
if (Object.prototype.hasOwnProperty.call(currentLiterals, source)) {
|
|
return String(currentLiterals[source]);
|
|
}
|
|
const de = languageDictionary("de");
|
|
const deLiterals = de && de.literals && typeof de.literals === "object" ? de.literals : {};
|
|
if (Object.prototype.hasOwnProperty.call(deLiterals, source)) {
|
|
return String(deLiterals[source]);
|
|
}
|
|
return source;
|
|
}
|
|
|
|
const originalTextNodes = new WeakMap();
|
|
const originalElementAttributes = new WeakMap();
|
|
|
|
function applyI18n(root = document.body) {
|
|
if (!root) {
|
|
return;
|
|
}
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
acceptNode(node) {
|
|
if (!node || !node.nodeValue || !node.nodeValue.trim()) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
const parent = node.parentElement;
|
|
if (!parent) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
if (["SCRIPT", "STYLE", "CODE", "PRE"].includes(parent.tagName)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
});
|
|
const textNodes = [];
|
|
let node = walker.nextNode();
|
|
while (node) {
|
|
textNodes.push(node);
|
|
node = walker.nextNode();
|
|
}
|
|
for (const textNode of textNodes) {
|
|
const original = originalTextNodes.has(textNode)
|
|
? originalTextNodes.get(textNode)
|
|
: String(textNode.nodeValue);
|
|
if (!originalTextNodes.has(textNode)) {
|
|
originalTextNodes.set(textNode, original);
|
|
}
|
|
const leading = original.match(/^\s*/)?.[0] || "";
|
|
const trailing = original.match(/\s*$/)?.[0] || "";
|
|
const trimmed = original.trim();
|
|
textNode.nodeValue = trimmed ? `${leading}${translateLiteral(trimmed)}${trailing}` : original;
|
|
}
|
|
const attrNames = ["placeholder", "title", "aria-label"];
|
|
const elements = root.querySelectorAll("*");
|
|
for (const el of elements) {
|
|
if (!originalElementAttributes.has(el)) {
|
|
originalElementAttributes.set(el, {});
|
|
}
|
|
const originalAttrs = originalElementAttributes.get(el);
|
|
for (const attr of attrNames) {
|
|
const value = el.getAttribute(attr);
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(originalAttrs, attr)) {
|
|
originalAttrs[attr] = value;
|
|
}
|
|
el.setAttribute(attr, translateLiteral(originalAttrs[attr]));
|
|
}
|
|
}
|
|
renderLanguageSelectors();
|
|
}
|
|
|
|
function localeForDate() {
|
|
return state.i18n.language === "en" ? "en-GB" : "de-AT";
|
|
}
|
|
|
|
async function setLanguage(language, options = {}) {
|
|
const normalized = normalizeLanguage(language);
|
|
await ensureLanguageDictionary(normalized);
|
|
state.i18n.language = normalized;
|
|
if (options.persist !== false) {
|
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, normalized);
|
|
}
|
|
applyI18n();
|
|
renderStatus();
|
|
renderSwrPanels();
|
|
renderHelpContent();
|
|
if (options.saveUserDefault) {
|
|
await savePreferredLanguage();
|
|
}
|
|
}
|
|
|
|
async function ensureLanguageFromUserPreference() {
|
|
if (!state.user || !state.user.preferredLanguage) {
|
|
return;
|
|
}
|
|
const preferred = normalizeLanguage(state.user.preferredLanguage);
|
|
if (preferred === normalizeLanguage(state.i18n.language)) {
|
|
renderLanguageSelectors();
|
|
return;
|
|
}
|
|
await setLanguage(preferred, { persist: true, saveUserDefault: false });
|
|
}
|
|
|
|
function renderLanguageSelectors() {
|
|
const dictionary = languageDictionary(state.i18n.language);
|
|
const locales = dictionary && dictionary.locales && typeof dictionary.locales === "object"
|
|
? dictionary.locales
|
|
: { de: "Deutsch", en: "English" };
|
|
const options = SUPPORTED_UI_LANGUAGES.map((lang) => ({
|
|
value: lang,
|
|
label: locales[lang] || lang.toUpperCase()
|
|
}));
|
|
for (const select of [els.menuLanguageSelect, els.settingsLanguageSelect]) {
|
|
if (!select) {
|
|
continue;
|
|
}
|
|
const currentValue = select.value;
|
|
select.innerHTML = "";
|
|
for (const optionData of options) {
|
|
const option = document.createElement("option");
|
|
option.value = optionData.value;
|
|
option.textContent = optionData.label;
|
|
select.appendChild(option);
|
|
}
|
|
select.value = normalizeLanguage(state.i18n.language || currentValue || DEFAULT_UI_LANGUAGE);
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
const on = (el, event, handler) => {
|
|
if (el) {
|
|
el.addEventListener(event, handler);
|
|
}
|
|
};
|
|
|
|
els.authForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
await requestAccess();
|
|
});
|
|
|
|
if (els.emailDomainSelect) {
|
|
els.emailDomainSelect.addEventListener("change", () => {
|
|
renderLoginDomainNotice();
|
|
});
|
|
}
|
|
|
|
els.activateBtn.addEventListener("click", async () => {
|
|
await activateStation();
|
|
});
|
|
|
|
els.deactivateBtn.addEventListener("click", async () => {
|
|
await releaseStation();
|
|
});
|
|
|
|
els.refreshBtn.addEventListener("click", async () => {
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
await refreshControls();
|
|
});
|
|
|
|
if (els.reserveNextBtn) {
|
|
els.reserveNextBtn.addEventListener("click", async () => {
|
|
await reserveNextSlot();
|
|
});
|
|
}
|
|
|
|
els.logoutBtn.addEventListener("click", async () => {
|
|
await logout();
|
|
});
|
|
if (els.settingsLogoutTopBtn) {
|
|
els.settingsLogoutTopBtn.addEventListener("click", async () => {
|
|
await logout();
|
|
});
|
|
}
|
|
|
|
els.themeToggle.addEventListener("click", () => {
|
|
toggleTheme();
|
|
});
|
|
|
|
els.settingsThemeBtn.addEventListener("click", () => {
|
|
toggleTheme();
|
|
});
|
|
|
|
if (els.settingsSaveAuthMethodBtn) {
|
|
els.settingsSaveAuthMethodBtn.addEventListener("click", async () => {
|
|
await savePreferredAuthMethod();
|
|
});
|
|
}
|
|
|
|
if (els.settingsSaveLanguageBtn) {
|
|
els.settingsSaveLanguageBtn.addEventListener("click", async () => {
|
|
await savePreferredLanguage();
|
|
});
|
|
}
|
|
|
|
if (els.menuLanguageSelect) {
|
|
els.menuLanguageSelect.addEventListener("change", async () => {
|
|
await setLanguage(els.menuLanguageSelect.value, { persist: true, saveUserDefault: false });
|
|
setLanguageMenuOpen(false);
|
|
});
|
|
}
|
|
|
|
if (els.settingsLanguageSelect) {
|
|
els.settingsLanguageSelect.addEventListener("change", async () => {
|
|
await setLanguage(els.settingsLanguageSelect.value, { persist: true, saveUserDefault: false });
|
|
});
|
|
}
|
|
|
|
els.settingsRefreshBtn.addEventListener("click", async () => {
|
|
await refreshPublicSystemStatus();
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
await refreshControls();
|
|
if (isAdmin()) {
|
|
await refreshPlugins();
|
|
await refreshUsers();
|
|
}
|
|
if (canSeeApprovals()) {
|
|
await refreshApprovals();
|
|
}
|
|
if (canSeeActivityLog()) {
|
|
await refreshActivityLog();
|
|
}
|
|
await refreshHelpContent();
|
|
});
|
|
|
|
els.userMenuButton.addEventListener("click", (event) => {
|
|
if (!state.user) {
|
|
return;
|
|
}
|
|
event.stopPropagation();
|
|
setLanguageMenuOpen(false);
|
|
setUserMenuOpen(els.userMenu.hidden);
|
|
});
|
|
if (els.languageMenuButton) {
|
|
els.languageMenuButton.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(els.languageMenu ? els.languageMenu.hidden : false);
|
|
});
|
|
}
|
|
if (els.languageMenu) {
|
|
els.languageMenu.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
});
|
|
}
|
|
on(els.menuRms, "click", () => navigateRmsPage("rms"));
|
|
on(els.menuSwr, "click", () => navigateRmsPage("swr"));
|
|
on(els.menuUser, "click", () => navigateRmsPage("user"));
|
|
on(els.menuHelp, "click", () => navigateRmsPage("help"));
|
|
on(els.menuPlugins, "click", () => navigateRmsPage("plugins"));
|
|
on(els.menuPluginConfig, "click", () => navigateRmsPage("plugin-config"));
|
|
on(els.menuProviders, "click", () => navigateRmsPage("providers"));
|
|
on(els.menuUsers, "click", () => navigateRmsPage("users"));
|
|
on(els.menuApprovals, "click", () => navigateRmsPage("approvals"));
|
|
on(els.menuActivity, "click", () => navigateRmsPage("activity"));
|
|
on(els.menuAdmin, "click", () => navigateRmsPage("admin"));
|
|
on(els.mobileNavRms, "click", () => navigateRmsPage("rms"));
|
|
on(els.mobileNavSwr, "click", () => navigateRmsPage("swr"));
|
|
on(els.mobileNavUser, "click", () => navigateRmsPage("user"));
|
|
on(els.mobileNavHelp, "click", () => navigateRmsPage("help"));
|
|
on(els.mobileNavPlugins, "click", () => navigateRmsPage("plugins"));
|
|
on(els.mobileNavPluginConfig, "click", () => navigateRmsPage("plugin-config"));
|
|
on(els.mobileNavUsers, "click", () => navigateRmsPage("users"));
|
|
on(els.mobileNavApprovals, "click", () => navigateRmsPage("approvals"));
|
|
on(els.mobileNavActivity, "click", () => navigateRmsPage("activity"));
|
|
on(els.mobileNavAdmin, "click", () => navigateRmsPage("admin"));
|
|
if (els.currentUserLink) {
|
|
els.currentUserLink.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateRmsPage("user");
|
|
});
|
|
}
|
|
document.addEventListener("click", () => {
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(false);
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(false);
|
|
}
|
|
});
|
|
|
|
window.addEventListener("popstate", () => {
|
|
hydrateFilterStateFromUrl();
|
|
applyRoute(true);
|
|
});
|
|
|
|
window.addEventListener("pageshow", () => {
|
|
void refreshFrontendOnResume();
|
|
});
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (!document.hidden) {
|
|
void refreshFrontendOnResume();
|
|
}
|
|
});
|
|
|
|
if (els.refreshProvidersBtn) {
|
|
els.refreshProvidersBtn.addEventListener("click", async () => {
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
});
|
|
}
|
|
if (els.refreshSwrBtn) {
|
|
els.refreshSwrBtn.addEventListener("click", async () => {
|
|
await refreshSwrReport();
|
|
});
|
|
}
|
|
if (els.refreshSwrPageBtn) {
|
|
els.refreshSwrPageBtn.addEventListener("click", async () => {
|
|
await refreshSwrReport();
|
|
});
|
|
}
|
|
if (els.runSwrCheckBtn) {
|
|
els.runSwrCheckBtn.addEventListener("click", async () => {
|
|
await runManualSwrCheck();
|
|
});
|
|
}
|
|
if (els.runSwrCheckPageBtn) {
|
|
els.runSwrCheckPageBtn.addEventListener("click", async () => {
|
|
await runManualSwrCheck();
|
|
});
|
|
}
|
|
if (els.openwebrxOpenBtn) {
|
|
els.openwebrxOpenBtn.addEventListener("click", async () => {
|
|
await openOpenWebRxSession();
|
|
});
|
|
}
|
|
if (els.openwebrxLink) {
|
|
els.openwebrxLink.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
await openOpenWebRxExternal();
|
|
});
|
|
}
|
|
if (els.openwebrxCopyLinkBtn) {
|
|
els.openwebrxCopyLinkBtn.addEventListener("click", async () => {
|
|
await copyOpenWebRxLink();
|
|
});
|
|
}
|
|
if (els.openwebrxEnableTxBtn) {
|
|
els.openwebrxEnableTxBtn.addEventListener("click", async (event) => {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
await toggleOpenWebRxTxPower();
|
|
});
|
|
}
|
|
if (els.rotorSetBtn) {
|
|
els.rotorSetBtn.addEventListener("click", async () => {
|
|
await setOpenWebRxRotor();
|
|
});
|
|
}
|
|
if (els.rotorPresets) {
|
|
els.rotorPresets.addEventListener("click", async (event) => {
|
|
const target = event && event.target ? event.target.closest("button[data-azimuth]") : null;
|
|
if (!target || !els.rotorTarget) {
|
|
return;
|
|
}
|
|
const azimuth = Number(target.getAttribute("data-azimuth"));
|
|
if (!Number.isFinite(azimuth)) {
|
|
return;
|
|
}
|
|
els.rotorTarget.value = String(Math.round(azimuth));
|
|
await setOpenWebRxRotor();
|
|
});
|
|
}
|
|
if (els.rotorCompass) {
|
|
els.rotorCompass.addEventListener("pointerdown", (event) => {
|
|
if (!els.rotorTarget || els.rotorTarget.disabled) {
|
|
return;
|
|
}
|
|
if (event.pointerType === "mouse" && event.button !== 0) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
rotorCompassDragPointerId = event.pointerId;
|
|
if (typeof els.rotorCompass.setPointerCapture === "function") {
|
|
try {
|
|
els.rotorCompass.setPointerCapture(event.pointerId);
|
|
} catch {
|
|
// ignore pointer capture errors
|
|
}
|
|
}
|
|
els.rotorCompass.classList.add("is-dragging");
|
|
updateRotorTargetFromPointerEvent(event);
|
|
});
|
|
els.rotorCompass.addEventListener("pointermove", (event) => {
|
|
if (rotorCompassDragPointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
updateRotorTargetFromPointerEvent(event);
|
|
});
|
|
const stopRotorCompassDrag = (event) => {
|
|
if (rotorCompassDragPointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
if (typeof els.rotorCompass.releasePointerCapture === "function") {
|
|
try {
|
|
els.rotorCompass.releasePointerCapture(event.pointerId);
|
|
} catch {
|
|
// ignore pointer capture errors
|
|
}
|
|
}
|
|
rotorCompassDragPointerId = null;
|
|
els.rotorCompass.classList.remove("is-dragging");
|
|
};
|
|
els.rotorCompass.addEventListener("pointerup", stopRotorCompassDrag);
|
|
els.rotorCompass.addEventListener("pointercancel", stopRotorCompassDrag);
|
|
els.rotorCompass.addEventListener("lostpointercapture", stopRotorCompassDrag);
|
|
}
|
|
if (els.openwebrxBandSetBtn) {
|
|
els.openwebrxBandSetBtn.addEventListener("click", async () => {
|
|
await setOpenWebRxBand();
|
|
});
|
|
}
|
|
if (els.openwebrxCloseBtn) {
|
|
els.openwebrxCloseBtn.addEventListener("click", async () => {
|
|
await closeOpenWebRxSession();
|
|
await refreshStatus();
|
|
});
|
|
}
|
|
if (els.refreshPluginsPageBtn) {
|
|
els.refreshPluginsPageBtn.addEventListener("click", async () => {
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
});
|
|
}
|
|
|
|
if (els.refreshUsersBtn) {
|
|
els.refreshUsersBtn.addEventListener("click", async () => {
|
|
await refreshUsers();
|
|
});
|
|
}
|
|
if (els.usersFilterQuery) {
|
|
els.usersFilterQuery.addEventListener("input", () => {
|
|
state.usersFilter.query = els.usersFilterQuery.value.trim().toLowerCase();
|
|
renderUsersAdmin();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.usersFilterRole) {
|
|
els.usersFilterRole.addEventListener("change", () => {
|
|
state.usersFilter.role = els.usersFilterRole.value;
|
|
renderUsersAdmin();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.usersFilterStatus) {
|
|
els.usersFilterStatus.addEventListener("change", () => {
|
|
state.usersFilter.status = els.usersFilterStatus.value;
|
|
renderUsersAdmin();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
|
|
if (els.refreshApprovalsBtn) {
|
|
els.refreshApprovalsBtn.addEventListener("click", async () => {
|
|
await refreshApprovals();
|
|
});
|
|
}
|
|
if (els.refreshActivityBtn) {
|
|
els.refreshActivityBtn.addEventListener("click", async () => {
|
|
await refreshActivityLog();
|
|
});
|
|
}
|
|
if (els.refreshHelpBtn) {
|
|
els.refreshHelpBtn.addEventListener("click", async () => {
|
|
await refreshHelpContent();
|
|
});
|
|
}
|
|
if (els.activityFilterQuery) {
|
|
els.activityFilterQuery.addEventListener("input", () => {
|
|
state.activityFilter.query = els.activityFilterQuery.value.trim().toLowerCase();
|
|
renderActivityLog();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.activityFilterType) {
|
|
els.activityFilterType.addEventListener("change", () => {
|
|
state.activityFilter.type = els.activityFilterType.value;
|
|
renderActivityLog();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.approvalsFilterOpenBtn) {
|
|
els.approvalsFilterOpenBtn.addEventListener("click", () => {
|
|
state.approvalsFilter.mode = "open";
|
|
renderApprovals();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.approvalsFilterAllBtn) {
|
|
els.approvalsFilterAllBtn.addEventListener("click", () => {
|
|
state.approvalsFilter.mode = "all";
|
|
renderApprovals();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.approvalsFilterQuery) {
|
|
els.approvalsFilterQuery.addEventListener("input", () => {
|
|
state.approvalsFilter.query = els.approvalsFilterQuery.value.trim().toLowerCase();
|
|
renderApprovals();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.approvalsFilterStatus) {
|
|
els.approvalsFilterStatus.addEventListener("change", () => {
|
|
state.approvalsFilter.status = els.approvalsFilterStatus.value;
|
|
renderApprovals();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
|
|
if (els.maintenanceEnableBtn) {
|
|
els.maintenanceEnableBtn.addEventListener("click", async () => {
|
|
await setMaintenanceMode(true);
|
|
});
|
|
}
|
|
if (els.maintenanceDisableBtn) {
|
|
els.maintenanceDisableBtn.addEventListener("click", async () => {
|
|
await setMaintenanceMode(false);
|
|
});
|
|
}
|
|
if (els.saveLoginDomainsBtn) {
|
|
els.saveLoginDomainsBtn.addEventListener("click", async () => {
|
|
await saveLoginDomains();
|
|
});
|
|
}
|
|
if (els.uploadLogoLightBtn) {
|
|
els.uploadLogoLightBtn.addEventListener("click", async () => {
|
|
await uploadBrandLogo("light", els.logoLightFile);
|
|
});
|
|
}
|
|
if (els.uploadLogoDarkBtn) {
|
|
els.uploadLogoDarkBtn.addEventListener("click", async () => {
|
|
await uploadBrandLogo("dark", els.logoDarkFile);
|
|
});
|
|
}
|
|
if (els.removeLogoLightBtn) {
|
|
els.removeLogoLightBtn.addEventListener("click", async () => {
|
|
await removeBrandLogo("light");
|
|
});
|
|
}
|
|
if (els.removeLogoDarkBtn) {
|
|
els.removeLogoDarkBtn.addEventListener("click", async () => {
|
|
await removeBrandLogo("dark");
|
|
});
|
|
}
|
|
|
|
if (els.verifyOtpBtn) {
|
|
els.verifyOtpBtn.addEventListener("click", async () => {
|
|
await verifyOtpCode();
|
|
});
|
|
}
|
|
|
|
const adminUnsupported = [
|
|
els.setOnlineBtn,
|
|
els.setOfflineBtn,
|
|
els.forceReleaseBtn,
|
|
els.refreshAuditBtn,
|
|
els.setRoleAdminBtn,
|
|
els.setRoleOperatorBtn
|
|
];
|
|
for (const btn of adminUnsupported) {
|
|
on(btn, "click", () => {
|
|
renderMessage(els.adminMessage, "Dieser Admin-Bereich wird auf die neue Plugin-Admin-API migriert.", true);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function refreshFrontendOnResume() {
|
|
if (resumeRefreshInFlight) {
|
|
return;
|
|
}
|
|
resumeRefreshInFlight = true;
|
|
try {
|
|
await refreshPublicSystemStatus();
|
|
await refreshPublicAuthMethods();
|
|
await refreshCurrentUser();
|
|
applyRoute(true);
|
|
if (!state.user) {
|
|
return;
|
|
}
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
await refreshControls();
|
|
const route = currentRoute();
|
|
if (route === "/rms/plugins" || route === "/rms/plugin-konfig" || route === "/rms/providers") {
|
|
await refreshPlugins();
|
|
}
|
|
if (route === "/rms/users") {
|
|
await refreshUsers();
|
|
}
|
|
if (route === "/rms/freigaben") {
|
|
await refreshApprovals();
|
|
}
|
|
if (route === "/rms/aktivitaet") {
|
|
await refreshActivityLog();
|
|
}
|
|
if (route === "/rms/hilfe") {
|
|
await refreshHelpContent();
|
|
}
|
|
connectEvents();
|
|
} catch {
|
|
// best effort resume refresh
|
|
} finally {
|
|
resumeRefreshInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function requestAccess() {
|
|
clearMessages("auth");
|
|
const email = composeLoginEmail();
|
|
if (!isLoginEmailAllowed(email)) {
|
|
renderMessage(els.authMessage, `Nur Club-Mailadressen (${formatAllowedDomainsHint()}) sind zum Anmelden moeglich.`, true);
|
|
return;
|
|
}
|
|
const method = els.authMethodSelect.value;
|
|
try {
|
|
const result = await api("/v1/auth/request-access", {
|
|
method: "POST",
|
|
body: { email, method },
|
|
authRequired: false
|
|
});
|
|
if (result && result.challengeType === "oauth" && result.authorizeUrl) {
|
|
window.location.assign(String(result.authorizeUrl));
|
|
return;
|
|
}
|
|
els.otpWrap.hidden = result.challengeType !== "otp";
|
|
renderMessage(els.authMessage, result.message || "Bitte E-Mail pruefen.", false, true);
|
|
} catch (error) {
|
|
if (error && error.status === 404) {
|
|
try {
|
|
const fallback = await api("/v1/auth/login", {
|
|
method: "POST",
|
|
body: { email, method },
|
|
authRequired: false
|
|
});
|
|
els.otpWrap.hidden = fallback.challengeType !== "otp";
|
|
renderMessage(els.authMessage, fallback.message || "Bitte E-Mail pruefen.", false, true);
|
|
return;
|
|
} catch {
|
|
renderMessage(els.authMessage, "Login-Endpunkt nicht gefunden. Bitte Backend/Server neu starten.", true);
|
|
return;
|
|
}
|
|
}
|
|
renderMessage(els.authMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function verifyOtpCode() {
|
|
clearMessages("auth");
|
|
const email = composeLoginEmail();
|
|
try {
|
|
const result = await api("/v1/auth/verify-email", {
|
|
method: "POST",
|
|
body: {
|
|
email,
|
|
code: els.otpCode.value.trim()
|
|
},
|
|
authRequired: false
|
|
});
|
|
if (result.accessToken) {
|
|
setTokens(result.accessToken, result.refreshToken);
|
|
state.user = result.user;
|
|
await ensureLanguageFromUserPreference();
|
|
els.otpCode.value = "";
|
|
els.otpWrap.hidden = true;
|
|
applyRoute();
|
|
updateUserUi();
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
await refreshControls();
|
|
await refreshPlugins();
|
|
await refreshUsers();
|
|
await refreshApprovals();
|
|
await refreshActivityLog();
|
|
connectEvents();
|
|
renderMessage(els.authMessage, `Willkommen ${result.user.email}`, false, true);
|
|
return;
|
|
}
|
|
renderMessage(els.authMessage, result.message || "Code bestaetigt.", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.authMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
function isLoginEmailAllowed(email) {
|
|
const normalized = String(email || "").trim().toLowerCase();
|
|
if (!normalized || !normalized.includes("@")) {
|
|
return false;
|
|
}
|
|
const atIndex = normalized.lastIndexOf("@");
|
|
if (atIndex < 0) {
|
|
return false;
|
|
}
|
|
const domain = normalized.slice(atIndex + 1);
|
|
return getAllowedLoginDomains().includes(domain);
|
|
}
|
|
|
|
function composeLoginEmail() {
|
|
const callsign = String(els.callsignInput && els.callsignInput.value ? els.callsignInput.value : "")
|
|
.trim()
|
|
.toLowerCase();
|
|
const selectedDomain = String(
|
|
els.emailDomainSelect && els.emailDomainSelect.value
|
|
? els.emailDomainSelect.value
|
|
: getAllowedLoginDomains()[0] || ""
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!callsign || !selectedDomain) {
|
|
return "";
|
|
}
|
|
if (callsign.includes("@")) {
|
|
return callsign;
|
|
}
|
|
return `${callsign}@${selectedDomain}`;
|
|
}
|
|
|
|
function splitEmailParts(email) {
|
|
const normalized = String(email || "").trim().toLowerCase();
|
|
const atIndex = normalized.lastIndexOf("@");
|
|
if (atIndex < 0) {
|
|
return {
|
|
callsign: normalized,
|
|
domain: ""
|
|
};
|
|
}
|
|
return {
|
|
callsign: normalized.slice(0, atIndex),
|
|
domain: normalized.slice(atIndex + 1)
|
|
};
|
|
}
|
|
|
|
function getAllowedLoginDomains() {
|
|
const list = state && state.system && Array.isArray(state.system.allowedLoginDomains)
|
|
? state.system.allowedLoginDomains
|
|
: [];
|
|
return list.length > 0 ? list : DEFAULT_ALLOWED_LOGIN_DOMAINS.slice();
|
|
}
|
|
|
|
function formatAllowedDomainsHint() {
|
|
return getAllowedLoginDomains().map((domain) => `@${domain}`).join(" oder ");
|
|
}
|
|
|
|
function renderLoginDomainSelect() {
|
|
if (!els.emailDomainSelect) {
|
|
return;
|
|
}
|
|
const domains = getAllowedLoginDomains();
|
|
const previous = String(els.emailDomainSelect.value || "").toLowerCase();
|
|
els.emailDomainSelect.innerHTML = "";
|
|
for (const domain of domains) {
|
|
const option = document.createElement("option");
|
|
option.value = domain;
|
|
option.textContent = domain;
|
|
els.emailDomainSelect.appendChild(option);
|
|
}
|
|
if (previous && domains.includes(previous)) {
|
|
els.emailDomainSelect.value = previous;
|
|
} else if (domains.length > 0) {
|
|
els.emailDomainSelect.value = domains[0];
|
|
}
|
|
renderLoginDomainNotice();
|
|
}
|
|
|
|
function renderLoginDomainNotice() {
|
|
if (!els.loginDomainNotice || !els.emailDomainSelect) {
|
|
return;
|
|
}
|
|
const domains = getAllowedLoginDomains();
|
|
const defaultDomain = String(domains[0] || "").trim().toLowerCase();
|
|
const selectedDomain = String(els.emailDomainSelect.value || defaultDomain).trim().toLowerCase();
|
|
const needsApproval = Boolean(defaultDomain && selectedDomain && selectedDomain !== defaultDomain);
|
|
|
|
els.loginDomainNotice.hidden = !needsApproval;
|
|
if (!needsApproval) {
|
|
els.loginDomainNotice.textContent = "";
|
|
return;
|
|
}
|
|
|
|
els.loginDomainNotice.textContent = `Hinweis: Fuer @${selectedDomain} wird ein Freigabeprozess gestartet. Zugriff ist erst nach Freigabe moeglich.`;
|
|
}
|
|
|
|
async function handleEmailTokenFromUrl() {
|
|
const url = new URL(window.location.href);
|
|
if (url.searchParams.get("requestApproval") === "1") {
|
|
const email = (url.searchParams.get("email") || "").trim();
|
|
if (email) {
|
|
setLoginEmail(email);
|
|
try {
|
|
const result = await api("/v1/auth/request-approval", {
|
|
method: "POST",
|
|
body: { email },
|
|
authRequired: false
|
|
});
|
|
renderMessage(els.authMessage, result.message || "Freigabe angefordert.", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.authMessage, error.message, true);
|
|
}
|
|
}
|
|
url.searchParams.delete("requestApproval");
|
|
url.searchParams.delete("email");
|
|
window.history.replaceState({}, "", `${url.pathname}${url.search}`);
|
|
}
|
|
|
|
const token = url.searchParams.get("verifyToken") || url.searchParams.get("loginToken");
|
|
const authError = url.searchParams.get("authError");
|
|
if (authError) {
|
|
const authMessage = url.searchParams.get("authMessage") || "OAuth Anmeldung fehlgeschlagen.";
|
|
renderMessage(els.authMessage, authMessage, true);
|
|
url.searchParams.delete("authError");
|
|
url.searchParams.delete("authMessage");
|
|
window.history.replaceState({}, "", `${url.pathname}${url.search}`);
|
|
return;
|
|
}
|
|
if (!token) {
|
|
return;
|
|
}
|
|
clearMessages("auth");
|
|
try {
|
|
const result = await api("/v1/auth/verify-email", {
|
|
method: "POST",
|
|
body: { token },
|
|
authRequired: false
|
|
});
|
|
if (result.accessToken) {
|
|
setTokens(result.accessToken, result.refreshToken);
|
|
state.user = result.user;
|
|
await ensureLanguageFromUserPreference();
|
|
renderMessage(els.authMessage, `Willkommen ${result.user.email}`, false, true);
|
|
url.searchParams.delete("verifyToken");
|
|
url.searchParams.delete("loginToken");
|
|
window.history.replaceState({}, "", `${url.pathname}${url.search}`);
|
|
} else {
|
|
renderMessage(els.authMessage, result.message || "Link verarbeitet.", false, true);
|
|
}
|
|
} catch (error) {
|
|
renderMessage(els.authMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
function setLoginEmail(email) {
|
|
const parts = splitEmailParts(email);
|
|
if (els.callsignInput) {
|
|
els.callsignInput.value = parts.callsign || "";
|
|
}
|
|
if (els.emailDomainSelect) {
|
|
renderLoginDomainSelect();
|
|
if (parts.domain && getAllowedLoginDomains().includes(parts.domain)) {
|
|
els.emailDomainSelect.value = parts.domain;
|
|
}
|
|
renderLoginDomainNotice();
|
|
}
|
|
}
|
|
|
|
async function refreshPublicSystemStatus() {
|
|
try {
|
|
const result = await api("/v1/public/system", { authRequired: false });
|
|
state.system = {
|
|
maintenanceMode: Boolean(result.maintenanceMode),
|
|
maintenanceMessage: result.maintenanceMessage || "",
|
|
updatedAt: result.updatedAt || null,
|
|
branding: normalizeBranding(result.branding),
|
|
allowedLoginDomains: normalizeLoginDomainList(result.allowedLoginDomains)
|
|
};
|
|
} catch {
|
|
state.system = {
|
|
maintenanceMode: false,
|
|
maintenanceMessage: "",
|
|
updatedAt: null,
|
|
branding: normalizeBranding(null),
|
|
allowedLoginDomains: DEFAULT_ALLOWED_LOGIN_DOMAINS.slice()
|
|
};
|
|
}
|
|
renderLoginDomainSelect();
|
|
renderMaintenanceBanner();
|
|
renderBranding();
|
|
}
|
|
|
|
async function refreshPublicAuthMethods() {
|
|
try {
|
|
const result = await api("/v1/public/auth-methods", { authRequired: false });
|
|
state.authMethods = Array.isArray(result.methods) ? result.methods : [];
|
|
} catch {
|
|
state.authMethods = [];
|
|
}
|
|
renderAuthMethods();
|
|
}
|
|
|
|
function renderAuthMethods() {
|
|
if (!els.authMethodSelect) {
|
|
return;
|
|
}
|
|
els.authMethodSelect.innerHTML = "";
|
|
if (!state.authMethods.length) {
|
|
const option = document.createElement("option");
|
|
option.value = "smtp-link";
|
|
option.textContent = "per Mail";
|
|
els.authMethodSelect.appendChild(option);
|
|
els.authMethodSelect.value = "smtp-link";
|
|
renderUserSettingsAuthMethods();
|
|
return;
|
|
}
|
|
for (const method of state.authMethods) {
|
|
const option = document.createElement("option");
|
|
option.value = method.id;
|
|
option.textContent = method.label;
|
|
els.authMethodSelect.appendChild(option);
|
|
}
|
|
if (state.authMethods.some((method) => method.id === "smtp-link")) {
|
|
els.authMethodSelect.value = "smtp-link";
|
|
}
|
|
renderUserSettingsAuthMethods();
|
|
}
|
|
|
|
function renderUserSettingsAuthMethods() {
|
|
if (!els.settingsAuthMethodSelect || !els.settingsSaveAuthMethodBtn) {
|
|
return;
|
|
}
|
|
const select = els.settingsAuthMethodSelect;
|
|
select.innerHTML = "";
|
|
const userMethods = new Set(Array.isArray(state.user && state.user.enabledAuthMethods) ? state.user.enabledAuthMethods : []);
|
|
const methods = state.authMethods.filter((method) => userMethods.has(method.id));
|
|
if (!methods.length) {
|
|
const option = document.createElement("option");
|
|
option.value = "";
|
|
option.textContent = "Keine Methode verfuegbar";
|
|
select.appendChild(option);
|
|
select.disabled = true;
|
|
els.settingsSaveAuthMethodBtn.disabled = true;
|
|
return;
|
|
}
|
|
for (const method of methods) {
|
|
const option = document.createElement("option");
|
|
option.value = method.id;
|
|
option.textContent = method.label;
|
|
select.appendChild(option);
|
|
}
|
|
if (state.user && methods.some((method) => method.id === state.user.primaryAuthMethod)) {
|
|
select.value = state.user.primaryAuthMethod;
|
|
} else if (methods.some((method) => method.id === "smtp-link")) {
|
|
select.value = "smtp-link";
|
|
}
|
|
select.disabled = false;
|
|
els.settingsSaveAuthMethodBtn.disabled = false;
|
|
}
|
|
|
|
async function savePreferredAuthMethod() {
|
|
const primaryMethod = els.settingsAuthMethodSelect ? els.settingsAuthMethodSelect.value : "";
|
|
if (!primaryMethod) return;
|
|
try {
|
|
const result = await api("/v1/me/auth-method", {
|
|
method: "PUT",
|
|
body: { primaryMethod }
|
|
});
|
|
state.user = result.user;
|
|
renderUserSettingsAuthMethods();
|
|
renderMessage(els.authMessage, "Praeferierte Authentifizierung gespeichert.", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.authMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function savePreferredLanguage() {
|
|
if (!state.user) {
|
|
return;
|
|
}
|
|
const preferredLanguage = normalizeLanguage(els.settingsLanguageSelect ? els.settingsLanguageSelect.value : state.i18n.language);
|
|
try {
|
|
const result = await api("/v1/me/language", {
|
|
method: "PUT",
|
|
body: { preferredLanguage }
|
|
});
|
|
state.user = result.user;
|
|
await setLanguage(preferredLanguage, { persist: true, saveUserDefault: false });
|
|
renderMessage(els.authMessage, translateLiteral("Praeferierte Sprache gespeichert."), false, true);
|
|
} catch (error) {
|
|
renderMessage(els.authMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
function renderMaintenanceBanner() {
|
|
const active = Boolean(state.system.maintenanceMode);
|
|
els.maintenanceBanner.hidden = !active;
|
|
els.maintenanceBanner.textContent = active ? (state.system.maintenanceMessage || translateLiteral("Wartungsmodus aktiv")) : "";
|
|
els.maintenanceBanner.className = active ? "message error" : "message";
|
|
if (els.maintenanceStatePill) {
|
|
els.maintenanceStatePill.textContent = active ? translateLiteral("Aktiv") : translateLiteral("Inaktiv");
|
|
els.maintenanceStatePill.classList.toggle("offline", active);
|
|
els.maintenanceStatePill.classList.toggle("ok", !active);
|
|
}
|
|
if (els.maintenanceMessageInput && !els.maintenanceMessageInput.value) {
|
|
els.maintenanceMessageInput.value = state.system.maintenanceMessage || "";
|
|
}
|
|
if (els.loginDomainsInput && document.activeElement !== els.loginDomainsInput) {
|
|
els.loginDomainsInput.value = getAllowedLoginDomains().join("\n");
|
|
}
|
|
}
|
|
|
|
function normalizeLoginDomainList(value) {
|
|
const list = Array.isArray(value)
|
|
? value
|
|
: String(value || "").split(/[\n,;\s]+/);
|
|
const unique = [];
|
|
const seen = new Set();
|
|
for (const entry of list) {
|
|
const domain = String(entry || "").trim().toLowerCase();
|
|
if (!domain) continue;
|
|
if (!/^[a-z0-9.-]+$/.test(domain)) continue;
|
|
if (!domain.includes(".")) continue;
|
|
if (seen.has(domain)) continue;
|
|
seen.add(domain);
|
|
unique.push(domain);
|
|
}
|
|
return unique.length > 0 ? unique : DEFAULT_ALLOWED_LOGIN_DOMAINS.slice();
|
|
}
|
|
|
|
async function saveLoginDomains() {
|
|
clearMessages("admin");
|
|
const domains = normalizeLoginDomainList(els.loginDomainsInput ? els.loginDomainsInput.value : "");
|
|
try {
|
|
const result = await api("/v1/admin/login-domains", {
|
|
method: "PUT",
|
|
body: { domains }
|
|
});
|
|
state.system.allowedLoginDomains = normalizeLoginDomainList(result && result.domains ? result.domains : domains);
|
|
renderMaintenanceBanner();
|
|
renderMessage(els.adminMessage, `Login-Domains gespeichert (${formatAllowedDomainsHint()}).`, false, true);
|
|
} catch (error) {
|
|
renderMessage(els.adminMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
function renderBranding() {
|
|
if (!els.brandLogo || !els.brandFallback) {
|
|
return;
|
|
}
|
|
const theme = document.documentElement.dataset.theme === "light" ? "light" : "dark";
|
|
const branding = normalizeBranding(state.system && state.system.branding);
|
|
const logoUrl = theme === "light" ? branding.logoLightUrl : branding.logoDarkUrl;
|
|
if (logoUrl) {
|
|
const sep = logoUrl.includes("?") ? "&" : "?";
|
|
els.brandLogo.src = `${logoUrl}${sep}v=${encodeURIComponent(String(state.system.updatedAt || ""))}`;
|
|
els.brandLogo.hidden = false;
|
|
els.brandFallback.hidden = true;
|
|
} else {
|
|
els.brandLogo.hidden = true;
|
|
els.brandLogo.removeAttribute("src");
|
|
els.brandFallback.hidden = false;
|
|
}
|
|
}
|
|
|
|
function normalizeBranding(value) {
|
|
const branding = value && typeof value === "object" ? value : {};
|
|
return {
|
|
logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null,
|
|
logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null
|
|
};
|
|
}
|
|
|
|
async function uploadBrandLogo(theme, inputEl) {
|
|
clearMessages("admin");
|
|
try {
|
|
const file = inputEl && inputEl.files && inputEl.files[0];
|
|
if (!file) {
|
|
renderMessage(els.adminMessage, "Bitte zuerst ein Bild auswaehlen.", true);
|
|
return;
|
|
}
|
|
const dataUrl = await fileToDataUrl(file);
|
|
const result = await api("/v1/admin/branding/logo", {
|
|
method: "PUT",
|
|
body: {
|
|
theme,
|
|
dataUrl,
|
|
fileName: file.name
|
|
}
|
|
});
|
|
state.system.branding = normalizeBranding(result.branding);
|
|
state.system.updatedAt = result.updatedAt || new Date().toISOString();
|
|
renderBranding();
|
|
if (inputEl) {
|
|
inputEl.value = "";
|
|
}
|
|
renderMessage(els.adminMessage, `${theme === "light" ? "Light" : "Dark"}-Logo gespeichert.`, false, true);
|
|
} catch (error) {
|
|
renderMessage(els.adminMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function removeBrandLogo(theme) {
|
|
clearMessages("admin");
|
|
try {
|
|
const result = await api(`/v1/admin/branding/logo?theme=${encodeURIComponent(theme)}`, {
|
|
method: "DELETE"
|
|
});
|
|
state.system.branding = normalizeBranding(result.branding);
|
|
state.system.updatedAt = result.updatedAt || new Date().toISOString();
|
|
renderBranding();
|
|
renderMessage(els.adminMessage, `${theme === "light" ? "Light" : "Dark"}-Logo entfernt.`, false, true);
|
|
} catch (error) {
|
|
renderMessage(els.adminMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
function fileToDataUrl(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onerror = () => reject(new Error("Datei konnte nicht gelesen werden"));
|
|
reader.onload = () => resolve(String(reader.result || ""));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function setMaintenanceMode(enabled) {
|
|
clearMessages("admin");
|
|
try {
|
|
const result = await api("/v1/admin/maintenance", {
|
|
method: "PUT",
|
|
body: {
|
|
enabled,
|
|
message: els.maintenanceMessageInput.value.trim()
|
|
}
|
|
});
|
|
state.system.maintenanceMode = Boolean(result.maintenanceMode);
|
|
state.system.maintenanceMessage = result.maintenanceMessage || "";
|
|
renderMaintenanceBanner();
|
|
renderMessage(els.adminMessage, enabled ? "Wartungsmodus aktiviert" : "Wartungsmodus deaktiviert", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.adminMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function refreshCurrentUser() {
|
|
if (!state.accessToken) {
|
|
state.user = null;
|
|
updateUserUi();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await api("/v1/me", { authRequired: true });
|
|
state.user = result.user;
|
|
renderUserSettingsAuthMethods();
|
|
await ensureLanguageFromUserPreference();
|
|
} catch {
|
|
state.user = null;
|
|
clearTokens();
|
|
}
|
|
updateUserUi();
|
|
}
|
|
|
|
async function logout(maintenanceRedirect = false, skipServerLogout = false) {
|
|
stopOpenWebRxTxPolling();
|
|
stopActivationWatch();
|
|
stopRemainingUsageWatch();
|
|
activationPending = false;
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
if (!skipServerLogout) {
|
|
try {
|
|
await api("/v1/auth/logout", {
|
|
method: "POST",
|
|
body: { refreshToken: state.refreshToken },
|
|
authRequired: false
|
|
});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
clearTokens();
|
|
state.user = null;
|
|
state.status = null;
|
|
state.swrReport = null;
|
|
state.controls = [];
|
|
state.plugins = [];
|
|
state.providers = {};
|
|
state.capabilities = [];
|
|
state.users = [];
|
|
state.helpContent = null;
|
|
state.activityEntries = [];
|
|
state.approvals = [];
|
|
state.openWebRx = {
|
|
sessionUrl: "",
|
|
sessionTicket: "",
|
|
expiresAt: null,
|
|
bands: [],
|
|
selectedBand: "",
|
|
txActive: null,
|
|
powerCommandConfigured: true,
|
|
pttCommandConfigured: true,
|
|
rotor: {
|
|
azimuth: null,
|
|
moving: false,
|
|
min: 0,
|
|
max: 360
|
|
},
|
|
busy: false,
|
|
pollMs: OPENWEBRX_TX_POLL_MS_DEFAULT,
|
|
pttPressed: false
|
|
};
|
|
syncOpenWebRxTicketCookie("");
|
|
renderOpenWebRxSessionAccess();
|
|
if (els.openwebrxPanel) {
|
|
els.openwebrxPanel.hidden = true;
|
|
}
|
|
if (els.controlsPanel) {
|
|
els.controlsPanel.hidden = true;
|
|
}
|
|
renderOpenWebRxBandOptions();
|
|
renderOpenWebRxTxState();
|
|
renderRotorState();
|
|
setOpenWebRxBusy(false);
|
|
renderPluginControls();
|
|
renderPluginAdmin();
|
|
renderUsersAdmin();
|
|
renderApprovals();
|
|
renderActivityLog();
|
|
renderHelpContent();
|
|
renderStatus();
|
|
renderSwrPanels();
|
|
if (maintenanceRedirect) {
|
|
await refreshPublicSystemStatus();
|
|
}
|
|
applyRoute();
|
|
updateUserUi();
|
|
renderMessage(
|
|
els.authMessage,
|
|
maintenanceRedirect
|
|
? (state.system.maintenanceMessage || "Wartungsmodus aktiv. Bitte spaeter erneut versuchen.")
|
|
: "Abgemeldet.",
|
|
maintenanceRedirect,
|
|
!maintenanceRedirect
|
|
);
|
|
}
|
|
|
|
async function activateStation() {
|
|
clearMessages("status");
|
|
try {
|
|
const result = await api("/v1/station/activation-jobs", {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
if (result.pending) {
|
|
activationPending = true;
|
|
const text = "Aktivierung gestartet...";
|
|
renderMessage(els.swrSummaryMessage, text, false, true);
|
|
renderMessage(els.swrPageMessage, text, false, true);
|
|
startActivationWatch();
|
|
}
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
} catch (error) {
|
|
renderMessage(els.statusMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function releaseStation() {
|
|
clearMessages("status");
|
|
try {
|
|
await api("/v1/station/release", { method: "POST", body: {} });
|
|
state.openWebRx.sessionUrl = "";
|
|
state.openWebRx.sessionTicket = "";
|
|
state.openWebRx.expiresAt = null;
|
|
state.openWebRx.bands = [];
|
|
state.openWebRx.selectedBand = "";
|
|
state.openWebRx.txActive = false;
|
|
state.openWebRx.rotor = {
|
|
azimuth: null,
|
|
rawAzimuth: null,
|
|
moving: false,
|
|
stale: false,
|
|
updatedAt: null,
|
|
min: 0,
|
|
max: 360
|
|
};
|
|
state.openWebRx.busy = false;
|
|
state.openWebRx.pttPressed = false;
|
|
syncOpenWebRxTicketCookie("");
|
|
renderOpenWebRxBandOptions();
|
|
renderOpenWebRxTxState();
|
|
renderRotorState();
|
|
setOpenWebRxBusy(false);
|
|
renderOpenWebRxSessionAccess();
|
|
renderMessage(els.statusMessage, "Station freigegeben", false, true);
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
} catch (error) {
|
|
renderMessage(els.statusMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function reserveNextSlot() {
|
|
clearMessages("status");
|
|
try {
|
|
await api("/v1/station/reservations/next", { method: "POST", body: {} });
|
|
renderMessage(els.reservationMessage, "Reservierung gespeichert", false, true);
|
|
await refreshStatus();
|
|
} catch (error) {
|
|
renderMessage(els.reservationMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function cancelOwnReservation() {
|
|
clearMessages("status");
|
|
try {
|
|
await api("/v1/station/reservations/next", { method: "DELETE" });
|
|
renderMessage(els.reservationMessage, "Reservierung entfernt", false, true);
|
|
await refreshStatus();
|
|
} catch (error) {
|
|
renderMessage(els.reservationMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function cancelReservationByUserId(userId) {
|
|
const normalized = String(userId || "").trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
clearMessages("status");
|
|
try {
|
|
await api(`/v1/station/reservations/${encodeURIComponent(normalized)}`, { method: "DELETE" });
|
|
renderMessage(els.reservationMessage, "Reservierung geloescht", false, true);
|
|
await refreshStatus();
|
|
} catch (error) {
|
|
renderMessage(els.reservationMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function openOpenWebRxSession() {
|
|
clearMessages("status");
|
|
setOpenWebRxBusy(true);
|
|
try {
|
|
const result = await requestOpenWebRxSessionWithRetry();
|
|
const session = result && result.session ? result.session : null;
|
|
const sessionTicket = session && session.ticket ? String(session.ticket) : "";
|
|
state.openWebRx.sessionUrl = buildOpenWebRxSessionUrl(session && session.iframeUrl ? session.iframeUrl : "", sessionTicket);
|
|
state.openWebRx.sessionTicket = sessionTicket;
|
|
state.openWebRx.expiresAt = session && session.expiresAt ? session.expiresAt : null;
|
|
syncOpenWebRxTicketCookie(state.openWebRx.sessionTicket, state.openWebRx.expiresAt);
|
|
renderOpenWebRxSessionAccess();
|
|
await refreshOpenWebRxBands();
|
|
await refreshOpenWebRxTxStatus();
|
|
await refreshOpenWebRxRotorStatus();
|
|
renderMessage(els.openwebrxMessage, "", false);
|
|
} catch (error) {
|
|
renderMessage(els.openwebrxMessage, error.message, true);
|
|
} finally {
|
|
setOpenWebRxBusy(false);
|
|
}
|
|
}
|
|
|
|
async function openOpenWebRxExternal() {
|
|
if (!state.user) {
|
|
return;
|
|
}
|
|
clearMessages("status");
|
|
try {
|
|
const result = await requestOpenWebRxSessionWithRetry();
|
|
const session = result && result.session ? result.session : null;
|
|
const sessionTicket = session && session.ticket ? String(session.ticket) : "";
|
|
const sessionUrl = buildOpenWebRxSessionUrl(session && session.iframeUrl ? String(session.iframeUrl) : "", sessionTicket);
|
|
if (!sessionUrl) {
|
|
throw new Error("OpenWebRX Session konnte nicht erstellt werden");
|
|
}
|
|
state.openWebRx.sessionUrl = sessionUrl;
|
|
state.openWebRx.sessionTicket = sessionTicket;
|
|
state.openWebRx.expiresAt = session && session.expiresAt ? session.expiresAt : null;
|
|
syncOpenWebRxTicketCookie(state.openWebRx.sessionTicket, state.openWebRx.expiresAt);
|
|
renderOpenWebRxSessionAccess();
|
|
window.open(sessionUrl, "_blank", "noopener,noreferrer");
|
|
await refreshStatus();
|
|
renderMessage(els.openwebrxMessage, "OpenWebRX in neuem Tab geoeffnet. Session-Link und Ticket aktualisiert.", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.statusMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
function buildOpenWebRxSessionUrl(rawUrl, ticket) {
|
|
const baseUrl = String(rawUrl || "").trim();
|
|
const normalizedTicket = String(ticket || "").trim();
|
|
if (!baseUrl) {
|
|
return "";
|
|
}
|
|
if (!normalizedTicket) {
|
|
return baseUrl;
|
|
}
|
|
if (/[?&]ticket=/.test(baseUrl)) {
|
|
return baseUrl;
|
|
}
|
|
const separator = baseUrl.includes("?") ? "&" : "?";
|
|
return `${baseUrl}${separator}ticket=${encodeURIComponent(normalizedTicket)}`;
|
|
}
|
|
|
|
async function requestOpenWebRxSessionWithRetry() {
|
|
const attempts = [0, 700, 1400, 2200];
|
|
let lastError = null;
|
|
for (let i = 0; i < attempts.length; i += 1) {
|
|
const waitMs = attempts[i];
|
|
if (waitMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
}
|
|
try {
|
|
return await api("/v1/openwebrx/session", {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
} catch (error) {
|
|
lastError = error;
|
|
const status = Number(error && error.status);
|
|
const msg = String(error && error.message ? error.message : "").toLowerCase();
|
|
const retryable = status === 502 || status === 503 || status === 504
|
|
|| msg.includes("bad gateway")
|
|
|| msg.includes("upstream")
|
|
|| msg.includes("connection refused")
|
|
|| msg.includes("gateway");
|
|
if (!retryable || i >= attempts.length - 1) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
throw lastError || new Error("OpenWebRX Session konnte nicht erstellt werden");
|
|
}
|
|
|
|
async function refreshOpenWebRxBands() {
|
|
if (!state.user || !state.status || !state.status.isInUse || state.status.activeByUserId !== state.user.id) {
|
|
state.openWebRx.bands = [];
|
|
state.openWebRx.selectedBand = "";
|
|
renderOpenWebRxBandOptions();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/v1/openwebrx/bands");
|
|
state.openWebRx.bands = Array.isArray(result.bands) ? result.bands : [];
|
|
state.openWebRx.selectedBand = result.selectedBand ? String(result.selectedBand) : "";
|
|
renderOpenWebRxBandOptions();
|
|
} catch {
|
|
state.openWebRx.bands = [];
|
|
state.openWebRx.selectedBand = "";
|
|
renderOpenWebRxBandOptions();
|
|
}
|
|
}
|
|
|
|
async function refreshOpenWebRxTxStatus() {
|
|
if (!state.user || !state.status || !state.status.isInUse || state.status.activeByUserId !== state.user.id) {
|
|
state.openWebRx.txActive = null;
|
|
state.openWebRx.powerCommandConfigured = true;
|
|
state.openWebRx.pttCommandConfigured = true;
|
|
renderOpenWebRxTxState();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/v1/openwebrx/tx/status");
|
|
state.openWebRx.txActive = Boolean(result && result.txActive);
|
|
state.openWebRx.powerCommandConfigured = result && result.powerCommandConfigured !== false;
|
|
state.openWebRx.pttCommandConfigured = result && result.pttCommandConfigured !== false;
|
|
} catch {
|
|
state.openWebRx.txActive = null;
|
|
state.openWebRx.powerCommandConfigured = true;
|
|
state.openWebRx.pttCommandConfigured = true;
|
|
}
|
|
renderOpenWebRxTxState();
|
|
}
|
|
|
|
async function refreshOpenWebRxRotorStatus() {
|
|
if (!state.user || !state.status || !state.status.isInUse || state.status.activeByUserId !== state.user.id) {
|
|
state.openWebRx.rotor = {
|
|
azimuth: null,
|
|
rawAzimuth: null,
|
|
moving: false,
|
|
stale: false,
|
|
updatedAt: null,
|
|
min: 0,
|
|
max: 360
|
|
};
|
|
renderRotorState();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/v1/openwebrx/rotor/status");
|
|
const rotor = result && result.rotor && typeof result.rotor === "object" ? result.rotor : {};
|
|
const hasAzimuth = rotor.azimuth !== null && rotor.azimuth !== undefined && rotor.azimuth !== "";
|
|
const hasRawAzimuth = rotor.rawAzimuth !== null && rotor.rawAzimuth !== undefined && rotor.rawAzimuth !== "";
|
|
state.openWebRx.rotor = {
|
|
azimuth: hasAzimuth && Number.isFinite(Number(rotor.azimuth)) ? Number(rotor.azimuth) : null,
|
|
rawAzimuth: hasRawAzimuth && Number.isFinite(Number(rotor.rawAzimuth)) ? Number(rotor.rawAzimuth) : null,
|
|
moving: Boolean(rotor.moving),
|
|
stale: Boolean(rotor.stale),
|
|
updatedAt: rotor.updatedAt ? String(rotor.updatedAt) : null,
|
|
min: Number.isFinite(Number(rotor.min)) ? Number(rotor.min) : 0,
|
|
max: Number.isFinite(Number(rotor.max)) ? Number(rotor.max) : 360
|
|
};
|
|
} catch {
|
|
state.openWebRx.rotor = {
|
|
azimuth: null,
|
|
rawAzimuth: null,
|
|
moving: false,
|
|
stale: false,
|
|
updatedAt: null,
|
|
min: 0,
|
|
max: 360
|
|
};
|
|
}
|
|
renderRotorState();
|
|
}
|
|
|
|
function startOpenWebRxTxPolling() {
|
|
const pollMs = normalizeOpenWebRxPollMs(state.openWebRx.pollMs);
|
|
if (openWebRxTxPollTimer && openWebRxTxPollIntervalMs === pollMs) {
|
|
return;
|
|
}
|
|
stopOpenWebRxTxPolling();
|
|
openWebRxTxPollIntervalMs = pollMs;
|
|
openWebRxTxPollTimer = setInterval(async () => {
|
|
if (openWebRxTxPollInFlight) {
|
|
return;
|
|
}
|
|
if (!state.user || !state.status || !state.status.isInUse || state.status.activeByUserId !== state.user.id) {
|
|
return;
|
|
}
|
|
openWebRxTxPollInFlight = true;
|
|
try {
|
|
await refreshOpenWebRxTxStatus();
|
|
await refreshOpenWebRxRotorStatus();
|
|
} finally {
|
|
openWebRxTxPollInFlight = false;
|
|
}
|
|
}, pollMs);
|
|
}
|
|
|
|
function stopOpenWebRxTxPolling() {
|
|
if (openWebRxTxPollTimer) {
|
|
clearInterval(openWebRxTxPollTimer);
|
|
openWebRxTxPollTimer = null;
|
|
}
|
|
openWebRxTxPollInFlight = false;
|
|
openWebRxTxPollIntervalMs = null;
|
|
}
|
|
|
|
function applyOpenWebRxPollingConfig(status) {
|
|
const nextMs = normalizeOpenWebRxPollMs(status && status.openWebRxTxPollMs);
|
|
const changed = nextMs !== state.openWebRx.pollMs;
|
|
state.openWebRx.pollMs = nextMs;
|
|
if (changed && openWebRxTxPollTimer) {
|
|
startOpenWebRxTxPolling();
|
|
}
|
|
}
|
|
|
|
function normalizeOpenWebRxPollMs(value) {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n)) {
|
|
return OPENWEBRX_TX_POLL_MS_DEFAULT;
|
|
}
|
|
return Math.max(1000, Math.min(60000, Math.trunc(n)));
|
|
}
|
|
|
|
function renderOpenWebRxTxState() {
|
|
if (!els.openwebrxTxStatePill) {
|
|
return;
|
|
}
|
|
els.openwebrxTxStatePill.className = "pill";
|
|
if (els.openwebrxEnableTxBtn) {
|
|
els.openwebrxEnableTxBtn.textContent = "RIG einschalten";
|
|
els.openwebrxEnableTxBtn.disabled = state.openWebRx.busy || state.openWebRx.powerCommandConfigured === false;
|
|
}
|
|
if (state.openWebRx.powerCommandConfigured === false) {
|
|
els.openwebrxTxStatePill.textContent = "RIG: nicht konfiguriert";
|
|
return;
|
|
}
|
|
if (state.openWebRx.txActive === true) {
|
|
els.openwebrxTxStatePill.textContent = "RIG: an";
|
|
if (els.openwebrxEnableTxBtn) {
|
|
els.openwebrxEnableTxBtn.textContent = "RIG ausschalten";
|
|
}
|
|
return;
|
|
}
|
|
if (state.openWebRx.txActive === false) {
|
|
els.openwebrxTxStatePill.textContent = "RIG: aus";
|
|
if (els.openwebrxEnableTxBtn) {
|
|
els.openwebrxEnableTxBtn.textContent = "RIG einschalten";
|
|
}
|
|
return;
|
|
}
|
|
els.openwebrxTxStatePill.textContent = "RIG: unbekannt";
|
|
}
|
|
|
|
async function toggleOpenWebRxTxPower() {
|
|
if (state.openWebRx.powerCommandConfigured === false) {
|
|
renderMessage(els.controlsMessage, "RIG ON/OFF ist nicht konfiguriert (RFROUTE_CMD_ON/RFROUTE_CMD_OFF)", true);
|
|
return;
|
|
}
|
|
if (state.openWebRx.txActive === true) {
|
|
await disableOpenWebRxTx();
|
|
return;
|
|
}
|
|
await enableOpenWebRxTx();
|
|
}
|
|
|
|
function renderOpenWebRxBandOptions() {
|
|
if (!els.openwebrxBandSelect) {
|
|
return;
|
|
}
|
|
els.openwebrxBandSelect.innerHTML = "";
|
|
const bands = state.openWebRx.bands || [];
|
|
if (!bands.length) {
|
|
const option = document.createElement("option");
|
|
option.value = "";
|
|
option.textContent = "Keine Baender";
|
|
els.openwebrxBandSelect.appendChild(option);
|
|
els.openwebrxBandSelect.disabled = true;
|
|
if (els.openwebrxBandSetBtn) {
|
|
els.openwebrxBandSetBtn.disabled = true;
|
|
}
|
|
setOpenWebRxBusy(state.openWebRx.busy);
|
|
return;
|
|
}
|
|
for (const band of bands) {
|
|
const option = document.createElement("option");
|
|
option.value = String(band.band || "");
|
|
option.textContent = String(band.label || band.band || "Band");
|
|
if (state.openWebRx.selectedBand && option.value === state.openWebRx.selectedBand) {
|
|
option.selected = true;
|
|
}
|
|
els.openwebrxBandSelect.appendChild(option);
|
|
}
|
|
els.openwebrxBandSelect.disabled = false;
|
|
if (els.openwebrxBandSetBtn) {
|
|
els.openwebrxBandSetBtn.disabled = false;
|
|
}
|
|
setOpenWebRxBusy(state.openWebRx.busy);
|
|
}
|
|
|
|
async function setOpenWebRxBand() {
|
|
const band = els.openwebrxBandSelect ? String(els.openwebrxBandSelect.value || "").trim() : "";
|
|
if (!band) {
|
|
renderMessage(els.openwebrxMessage, "Bitte Band auswaehlen", true);
|
|
return;
|
|
}
|
|
setOpenWebRxBusy(true);
|
|
try {
|
|
const result = await api("/v1/openwebrx/bands/select", {
|
|
method: "POST",
|
|
body: { band }
|
|
});
|
|
state.openWebRx.selectedBand = band;
|
|
renderMessage(els.openwebrxMessage, result && result.result && result.result.message ? result.result.message : "Band gesetzt", false, true);
|
|
await refreshOpenWebRxBands();
|
|
} catch (error) {
|
|
renderMessage(els.openwebrxMessage, error.message, true);
|
|
} finally {
|
|
setOpenWebRxBusy(false);
|
|
}
|
|
}
|
|
|
|
async function enableOpenWebRxTx() {
|
|
setOpenWebRxBusy(true);
|
|
try {
|
|
await api("/v1/openwebrx/tx/enable", {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
state.openWebRx.txActive = true;
|
|
renderOpenWebRxTxState();
|
|
renderMessage(els.controlsMessage, "RIG eingeschaltet", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.controlsMessage, error.message, true);
|
|
} finally {
|
|
setOpenWebRxBusy(false);
|
|
}
|
|
}
|
|
|
|
async function disableOpenWebRxTx() {
|
|
setOpenWebRxBusy(true);
|
|
try {
|
|
await api("/v1/openwebrx/tx/disable", {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
state.openWebRx.txActive = false;
|
|
renderOpenWebRxTxState();
|
|
renderMessage(els.controlsMessage, "RIG ausgeschaltet", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.controlsMessage, error.message, true);
|
|
} finally {
|
|
setOpenWebRxBusy(false);
|
|
}
|
|
}
|
|
|
|
async function setOpenWebRxRotor() {
|
|
if (!els.rotorTarget) {
|
|
return;
|
|
}
|
|
const raw = String(els.rotorTarget.value || "").trim();
|
|
if (!raw) {
|
|
renderMessage(els.controlsMessage, "Bitte Rotor-Ziel eingeben", true);
|
|
return;
|
|
}
|
|
const target = Number(raw.replace(",", "."));
|
|
if (!Number.isFinite(target) || target < 0 || target > 360) {
|
|
renderMessage(els.controlsMessage, "Rotor-Ziel muss zwischen 0 und 360 liegen", true);
|
|
return;
|
|
}
|
|
setOpenWebRxBusy(true);
|
|
try {
|
|
const result = await api("/v1/openwebrx/rotor/set", {
|
|
method: "POST",
|
|
body: { target, trigger: "user" }
|
|
});
|
|
renderMessage(
|
|
els.controlsMessage,
|
|
result && result.result && result.result.message ? result.result.message : `Rotor auf ${Math.round(target)}° gesetzt`,
|
|
false,
|
|
true
|
|
);
|
|
await refreshOpenWebRxRotorStatus();
|
|
} catch (error) {
|
|
renderMessage(els.controlsMessage, error.message, true);
|
|
} finally {
|
|
setOpenWebRxBusy(false);
|
|
}
|
|
}
|
|
|
|
function normalizeCompassAzimuth(value) {
|
|
return ((Number(value) % 360) + 360) % 360;
|
|
}
|
|
|
|
function rotorTargetBounds() {
|
|
const rotor = state.openWebRx && state.openWebRx.rotor ? state.openWebRx.rotor : null;
|
|
const min = rotor && Number.isFinite(Number(rotor.min)) ? Number(rotor.min) : 0;
|
|
const max = rotor && Number.isFinite(Number(rotor.max)) ? Number(rotor.max) : 360;
|
|
return {
|
|
min: Math.min(min, max),
|
|
max: Math.max(min, max)
|
|
};
|
|
}
|
|
|
|
function updateRotorTargetFromPointerEvent(event) {
|
|
if (!els.rotorCompass || !els.rotorTarget) {
|
|
return;
|
|
}
|
|
const rect = els.rotorCompass.getBoundingClientRect();
|
|
const centerX = rect.left + rect.width / 2;
|
|
const centerY = rect.top + rect.height / 2;
|
|
const pointerX = Number(event.clientX);
|
|
const pointerY = Number(event.clientY);
|
|
if (!Number.isFinite(pointerX) || !Number.isFinite(pointerY)) {
|
|
return;
|
|
}
|
|
const dx = pointerX - centerX;
|
|
const dy = pointerY - centerY;
|
|
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
|
|
return;
|
|
}
|
|
const azimuth = normalizeCompassAzimuth((Math.atan2(dx, -dy) * 180) / Math.PI);
|
|
const bounds = rotorTargetBounds();
|
|
const clamped = Math.min(bounds.max, Math.max(bounds.min, Math.round(azimuth)));
|
|
els.rotorTarget.value = String(clamped);
|
|
}
|
|
|
|
function renderRotorState() {
|
|
if (!els.rotorCurrent) {
|
|
return;
|
|
}
|
|
const rotor = state.openWebRx && state.openWebRx.rotor ? state.openWebRx.rotor : null;
|
|
const hasAzimuth = rotor && rotor.azimuth !== null && rotor.azimuth !== undefined && rotor.azimuth !== "";
|
|
const azimuth = hasAzimuth && Number.isFinite(Number(rotor.azimuth)) ? Math.round(Number(rotor.azimuth)) : null;
|
|
if (azimuth === null) {
|
|
els.rotorCurrent.textContent = "Rotor: -";
|
|
if (els.rotorCompass) {
|
|
els.rotorCompass.style.opacity = "0.45";
|
|
}
|
|
if (els.rotorCompassArrow) {
|
|
els.rotorCompassArrow.style.transform = "translate(-50%, -100%) rotate(0deg)";
|
|
}
|
|
} else {
|
|
const stale = Boolean(rotor && rotor.stale);
|
|
const moving = Boolean(rotor && rotor.moving);
|
|
let suffix = "";
|
|
if (moving) {
|
|
suffix = " (dreht)";
|
|
} else if (stale) {
|
|
suffix = " (letzter Wert)";
|
|
}
|
|
els.rotorCurrent.textContent = `Rotor: ${azimuth}°${suffix}`;
|
|
if (els.rotorCompass) {
|
|
els.rotorCompass.style.opacity = "1";
|
|
}
|
|
if (els.rotorCompassArrow) {
|
|
const normalized = ((Number(azimuth) % 360) + 360) % 360;
|
|
els.rotorCompassArrow.style.transform = `translate(-50%, -100%) rotate(${normalized}deg)`;
|
|
}
|
|
}
|
|
|
|
if (els.rotorTarget) {
|
|
const min = rotor && Number.isFinite(Number(rotor.min)) ? Number(rotor.min) : 0;
|
|
const max = rotor && Number.isFinite(Number(rotor.max)) ? Number(rotor.max) : 360;
|
|
els.rotorTarget.min = String(min);
|
|
els.rotorTarget.max = String(max);
|
|
if (!String(els.rotorTarget.value || "").trim() && azimuth !== null) {
|
|
els.rotorTarget.value = String(azimuth);
|
|
}
|
|
}
|
|
|
|
if (els.rotorPresets) {
|
|
for (const button of els.rotorPresets.querySelectorAll("button[data-azimuth]")) {
|
|
const preset = Number(button.getAttribute("data-azimuth"));
|
|
const active = azimuth !== null && Number.isFinite(preset) && Math.abs(preset - azimuth) <= 5;
|
|
button.classList.toggle("primary-btn", active);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function startOpenWebRxPtt() {
|
|
if (state.openWebRx.pttPressed) {
|
|
return;
|
|
}
|
|
state.openWebRx.pttPressed = true;
|
|
setOpenWebRxBusy(true);
|
|
try {
|
|
await api("/v1/openwebrx/ptt/down", {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
renderMessage(els.openwebrxMessage, "PTT gedrueckt: Antenne auf TX", false, true);
|
|
} catch (error) {
|
|
state.openWebRx.pttPressed = false;
|
|
renderMessage(els.openwebrxMessage, error.message, true);
|
|
} finally {
|
|
setOpenWebRxBusy(false);
|
|
}
|
|
}
|
|
|
|
async function stopOpenWebRxPtt() {
|
|
if (!state.openWebRx.pttPressed) {
|
|
return;
|
|
}
|
|
state.openWebRx.pttPressed = false;
|
|
setOpenWebRxBusy(true);
|
|
try {
|
|
await api("/v1/openwebrx/ptt/up", {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
renderMessage(els.openwebrxMessage, "PTT losgelassen: Antenne auf RX", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.openwebrxMessage, error.message, true);
|
|
} finally {
|
|
setOpenWebRxBusy(false);
|
|
}
|
|
}
|
|
|
|
async function closeOpenWebRxSession() {
|
|
setOpenWebRxBusy(true);
|
|
try {
|
|
await api("/v1/openwebrx/session/close", {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
state.openWebRx.sessionUrl = "";
|
|
state.openWebRx.sessionTicket = "";
|
|
state.openWebRx.expiresAt = null;
|
|
state.openWebRx.bands = [];
|
|
state.openWebRx.selectedBand = "";
|
|
state.openWebRx.txActive = false;
|
|
state.openWebRx.rotor = {
|
|
azimuth: null,
|
|
rawAzimuth: null,
|
|
moving: false,
|
|
stale: false,
|
|
updatedAt: null,
|
|
min: 0,
|
|
max: 360
|
|
};
|
|
state.openWebRx.pttPressed = false;
|
|
syncOpenWebRxTicketCookie("");
|
|
renderOpenWebRxBandOptions();
|
|
renderOpenWebRxTxState();
|
|
renderRotorState();
|
|
renderOpenWebRxSessionAccess();
|
|
renderMessage(els.openwebrxMessage, "RIG ausgeschaltet, OpenWebRX Session geschlossen", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.openwebrxMessage, error.message, true);
|
|
} finally {
|
|
setOpenWebRxBusy(false);
|
|
}
|
|
}
|
|
|
|
function setOpenWebRxBusy(busy) {
|
|
state.openWebRx.busy = Boolean(busy);
|
|
const disabled = state.openWebRx.busy;
|
|
if (els.openwebrxOpenBtn) els.openwebrxOpenBtn.disabled = disabled;
|
|
if (els.openwebrxBandSetBtn) els.openwebrxBandSetBtn.disabled = disabled || (state.openWebRx.bands || []).length === 0;
|
|
if (els.openwebrxBandSelect) els.openwebrxBandSelect.disabled = disabled || (state.openWebRx.bands || []).length === 0;
|
|
if (els.openwebrxEnableTxBtn) {
|
|
els.openwebrxEnableTxBtn.disabled = disabled || state.openWebRx.powerCommandConfigured === false;
|
|
}
|
|
if (els.rotorSetBtn) els.rotorSetBtn.disabled = disabled;
|
|
if (els.rotorTarget) els.rotorTarget.disabled = disabled;
|
|
if (els.rotorPresets) {
|
|
for (const button of els.rotorPresets.querySelectorAll("button[data-azimuth]")) {
|
|
button.disabled = disabled;
|
|
}
|
|
}
|
|
if (els.openwebrxCloseBtn) els.openwebrxCloseBtn.disabled = disabled;
|
|
if (els.openwebrxCopyLinkBtn) {
|
|
els.openwebrxCopyLinkBtn.disabled = disabled || !String(state.openWebRx.sessionUrl || "").trim();
|
|
}
|
|
}
|
|
|
|
async function copyOpenWebRxLink() {
|
|
const sessionUrl = String(state.openWebRx.sessionUrl || "").trim();
|
|
if (!sessionUrl) {
|
|
renderMessage(els.openwebrxMessage, "Kein OpenWebRX Link vorhanden", true);
|
|
return;
|
|
}
|
|
try {
|
|
await copyTextToClipboard(sessionUrl);
|
|
renderMessage(els.openwebrxMessage, "OpenWebRX Link in Zwischenablage kopiert", false, true);
|
|
} catch {
|
|
renderMessage(els.openwebrxMessage, "Link konnte nicht kopiert werden", true);
|
|
}
|
|
}
|
|
|
|
async function copyTextToClipboard(value) {
|
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
|
await navigator.clipboard.writeText(value);
|
|
return;
|
|
}
|
|
const helper = document.createElement("textarea");
|
|
helper.value = value;
|
|
helper.setAttribute("readonly", "readonly");
|
|
helper.style.position = "absolute";
|
|
helper.style.left = "-9999px";
|
|
document.body.appendChild(helper);
|
|
helper.select();
|
|
const copied = document.execCommand("copy");
|
|
document.body.removeChild(helper);
|
|
if (!copied) {
|
|
throw new Error("copy-failed");
|
|
}
|
|
}
|
|
|
|
function renderOpenWebRxSessionAccess() {
|
|
if (!els.openwebrxSessionAccess || !els.openwebrxSessionLink || !els.openwebrxSessionTicket) {
|
|
return;
|
|
}
|
|
const sessionUrl = String(state.openWebRx.sessionUrl || "").trim();
|
|
const ticket = String(state.openWebRx.sessionTicket || "").trim();
|
|
const visible = Boolean(sessionUrl);
|
|
els.openwebrxSessionAccess.hidden = !visible;
|
|
els.openwebrxSessionLink.hidden = !visible;
|
|
if (!visible) {
|
|
els.openwebrxSessionLink.removeAttribute("href");
|
|
els.openwebrxSessionTicket.textContent = "-";
|
|
if (els.openwebrxCopyLinkBtn) {
|
|
els.openwebrxCopyLinkBtn.disabled = true;
|
|
}
|
|
return;
|
|
}
|
|
els.openwebrxSessionLink.href = sessionUrl;
|
|
els.openwebrxSessionTicket.textContent = ticket || "(nicht geliefert)";
|
|
if (els.openwebrxCopyLinkBtn) {
|
|
els.openwebrxCopyLinkBtn.disabled = state.openWebRx.busy;
|
|
}
|
|
}
|
|
|
|
function syncOpenWebRxTicketCookie(ticket, expiresAtIso) {
|
|
const value = String(ticket || "").trim();
|
|
const baseRoot = "path=/; SameSite=Lax";
|
|
const baseOpenWebRx = "path=/openwebrx/; SameSite=Lax";
|
|
const secure = window && window.location && window.location.protocol === "https:" ? "; Secure" : "";
|
|
if (!value) {
|
|
document.cookie = `rms_owrx_ticket=; Max-Age=0; ${baseRoot}${secure}`;
|
|
document.cookie = `rms_owrx_ticket=; Max-Age=0; ${baseOpenWebRx}${secure}`;
|
|
return;
|
|
}
|
|
let maxAge = 600;
|
|
if (expiresAtIso) {
|
|
const expiresMs = Date.parse(String(expiresAtIso));
|
|
if (Number.isFinite(expiresMs)) {
|
|
const remainingSec = Math.floor((expiresMs - Date.now()) / 1000);
|
|
if (remainingSec > 0) {
|
|
maxAge = Math.max(10, Math.min(remainingSec, 86400));
|
|
}
|
|
}
|
|
}
|
|
document.cookie = `rms_owrx_ticket=${encodeURIComponent(value)}; Max-Age=${maxAge}; ${baseRoot}${secure}`;
|
|
document.cookie = `rms_owrx_ticket=; Max-Age=0; ${baseOpenWebRx}${secure}`;
|
|
}
|
|
|
|
async function refreshStatus() {
|
|
try {
|
|
const status = await api("/v1/station/status");
|
|
state.status = status;
|
|
applyOpenWebRxPollingConfig(status);
|
|
renderStatus();
|
|
await refreshOpenWebRxBands();
|
|
await refreshOpenWebRxTxStatus();
|
|
await refreshOpenWebRxRotorStatus();
|
|
} catch (error) {
|
|
renderMessage(els.statusMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function refreshSwrReport() {
|
|
if (!canOperateStation()) {
|
|
state.swrReport = null;
|
|
renderSwrPanels();
|
|
return;
|
|
}
|
|
try {
|
|
const report = await api("/v1/swr/report");
|
|
state.swrReport = report;
|
|
renderSwrPanels();
|
|
} catch (error) {
|
|
renderMessage(els.swrSummaryMessage, error.message, true);
|
|
renderMessage(els.swrPageMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function runManualSwrCheck() {
|
|
if (state.status && state.status.swrRun && state.status.swrRun.running) {
|
|
const text = "SWR-Check laeuft bereits.";
|
|
renderMessage(els.swrSummaryMessage, text, true);
|
|
renderMessage(els.swrPageMessage, text, true);
|
|
return;
|
|
}
|
|
if (state.status && state.status.isInUse) {
|
|
const text = "SWR-Check gesperrt solange die Station aktiv ist.";
|
|
renderMessage(els.swrSummaryMessage, text, true);
|
|
renderMessage(els.swrPageMessage, text, true);
|
|
return;
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
const expectedSec = SWR_EXPECTED_DURATION_SEC_DEFAULT;
|
|
const previousReport = state.swrReport;
|
|
|
|
const runningBands = Array.isArray(previousReport && previousReport.bands)
|
|
? previousReport.bands.map((entry) => ({ ...entry, status: "RUNNING" }))
|
|
: [];
|
|
state.swrReport = {
|
|
source: previousReport && previousReport.source ? previousReport.source : "native-controller",
|
|
generatedAt: new Date(startedAt).toISOString(),
|
|
overallStatus: "RUNNING",
|
|
overviewUrl: previousReport && Object.prototype.hasOwnProperty.call(previousReport, "overviewUrl")
|
|
? previousReport.overviewUrl
|
|
: null,
|
|
bands: runningBands
|
|
};
|
|
renderSwrPanels();
|
|
|
|
setSwrRunButtonsBusy(true);
|
|
|
|
const updateProgressMessage = () => {
|
|
const elapsedSec = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
|
const remainingSec = Math.max(0, expectedSec - elapsedSec);
|
|
const text = `SWR-Messung laeuft... ${elapsedSec}s vergangen, ca. ${remainingSec}s verbleibend.`;
|
|
renderMessage(els.swrSummaryMessage, text, false, true);
|
|
renderMessage(els.swrPageMessage, text, false, true);
|
|
};
|
|
|
|
updateProgressMessage();
|
|
|
|
try {
|
|
const payload = await api("/v1/swr/run-check", {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
const reportFromRun = payload && payload.result && payload.result.report ? payload.result.report : null;
|
|
const reportFromView = payload && payload.report ? payload.report : null;
|
|
if (reportFromRun) {
|
|
state.swrReport = reportFromRun;
|
|
renderSwrPanels();
|
|
} else if (reportFromView) {
|
|
state.swrReport = reportFromView;
|
|
renderSwrPanels();
|
|
} else {
|
|
await refreshSwrReport();
|
|
}
|
|
const finishedSec = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
|
|
const overall = state.swrReport && state.swrReport.overallStatus ? state.swrReport.overallStatus : "UNKNOWN";
|
|
const doneText = `SWR-Check ERFOLGREICH abgeschlossen nach ${finishedSec}s. Ergebnis: ${overall}.`;
|
|
renderMessage(els.swrSummaryMessage, doneText, false, true);
|
|
renderMessage(els.swrPageMessage, doneText, false, true);
|
|
} catch (error) {
|
|
state.swrReport = previousReport;
|
|
renderSwrPanels();
|
|
renderMessage(els.swrSummaryMessage, error.message, true);
|
|
renderMessage(els.swrPageMessage, error.message, true);
|
|
} finally {
|
|
setSwrRunButtonsBusy(false);
|
|
}
|
|
}
|
|
|
|
function setSwrRunButtonsBusy(isBusy) {
|
|
const swrRunning = Boolean(state.status && state.status.swrRun && state.status.swrRun.running);
|
|
const disabled = Boolean(isBusy) || swrRunning;
|
|
if (els.runSwrCheckBtn) {
|
|
els.runSwrCheckBtn.disabled = disabled;
|
|
}
|
|
if (els.runSwrCheckPageBtn) {
|
|
els.runSwrCheckPageBtn.disabled = disabled;
|
|
}
|
|
}
|
|
|
|
function renderSwrPanels() {
|
|
const report = state.swrReport;
|
|
const generatedAtText = report && report.generatedAt
|
|
? `Stand: ${new Date(report.generatedAt).toLocaleString(localeForDate())}`
|
|
: "Stand: -";
|
|
const overallText = `Gesamt: ${report && report.overallStatus ? report.overallStatus : "UNKNOWN"}`;
|
|
|
|
if (els.swrSummaryGeneratedAt) {
|
|
els.swrSummaryGeneratedAt.textContent = generatedAtText;
|
|
}
|
|
if (els.swrSummaryOverall) {
|
|
els.swrSummaryOverall.textContent = overallText;
|
|
}
|
|
if (els.swrPageGeneratedAt) {
|
|
els.swrPageGeneratedAt.textContent = generatedAtText;
|
|
}
|
|
if (els.swrPageOverall) {
|
|
els.swrPageOverall.textContent = overallText;
|
|
}
|
|
|
|
renderSwrBandsInto(els.swrSummaryBands, report, { withImages: false, compact: true });
|
|
renderSwrBandsInto(els.swrPageBands, report, { withImages: true, compact: false });
|
|
}
|
|
|
|
function renderSwrBandsInto(container, report, options = {}) {
|
|
if (!container) return;
|
|
container.innerHTML = "";
|
|
const bands = report && Array.isArray(report.bands) ? report.bands : [];
|
|
if (!bands.length) {
|
|
container.textContent = "Keine SWR Daten vorhanden.";
|
|
return;
|
|
}
|
|
if (options.compact) {
|
|
container.classList.add("swr-summary-list");
|
|
for (const band of bands) {
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.className = "swr-summary-row swr-summary-link";
|
|
row.title = `${band.band} in SWR-Detailseite anzeigen`;
|
|
row.addEventListener("click", () => {
|
|
navigateRmsPage("swr");
|
|
setTimeout(() => {
|
|
scrollToSwrBand(band.band);
|
|
}, 0);
|
|
});
|
|
|
|
const bandText = document.createElement("strong");
|
|
bandText.textContent = band.band;
|
|
row.appendChild(bandText);
|
|
|
|
const status = document.createElement("span");
|
|
status.className = "pill";
|
|
status.textContent = band.status || "UNKNOWN";
|
|
row.appendChild(status);
|
|
|
|
container.appendChild(row);
|
|
}
|
|
return;
|
|
}
|
|
|
|
container.classList.remove("swr-summary-list");
|
|
for (const band of bands) {
|
|
const imageVersion = band.updatedAt || "";
|
|
const imageUrl = withCacheVersion(band.imageUrl, imageVersion);
|
|
const block = document.createElement("div");
|
|
block.className = "plugin-block";
|
|
block.id = swrBandAnchorId(band.band);
|
|
block.dataset.swrBand = String(band.band || "").toLowerCase();
|
|
const head = document.createElement("div");
|
|
head.className = "section-head";
|
|
const title = document.createElement("strong");
|
|
title.textContent = band.band;
|
|
head.appendChild(title);
|
|
const status = document.createElement("span");
|
|
status.className = "pill";
|
|
status.textContent = band.status || "UNKNOWN";
|
|
head.appendChild(status);
|
|
block.appendChild(head);
|
|
|
|
if (band.updatedAt) {
|
|
const updated = document.createElement("p");
|
|
updated.className = "muted";
|
|
updated.textContent = `Bildstand: ${new Date(band.updatedAt).toLocaleString(localeForDate())}`;
|
|
block.appendChild(updated);
|
|
}
|
|
|
|
if (options.withImages && imageUrl) {
|
|
const img = document.createElement("img");
|
|
img.src = imageUrl;
|
|
img.alt = `SWR ${band.band}`;
|
|
img.className = "swr-band-image";
|
|
block.appendChild(img);
|
|
}
|
|
|
|
if (!options.withImages && imageUrl) {
|
|
const link = document.createElement("a");
|
|
link.href = imageUrl;
|
|
link.target = "_blank";
|
|
link.rel = "noopener";
|
|
link.className = "ghost-btn";
|
|
link.textContent = `${band.band} Grafik`;
|
|
block.appendChild(link);
|
|
}
|
|
|
|
container.appendChild(block);
|
|
}
|
|
}
|
|
|
|
function swrBandAnchorId(band) {
|
|
const normalized = String(band || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
return `swr-band-${normalized || "unknown"}`;
|
|
}
|
|
|
|
function scrollToSwrBand(band) {
|
|
const id = swrBandAnchorId(band);
|
|
const target = document.getElementById(id);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
|
|
async function refreshControls() {
|
|
try {
|
|
const response = await api("/v1/ui/controls");
|
|
state.controls = response.controls || [];
|
|
renderPluginControls();
|
|
} catch (error) {
|
|
renderMessage(els.pluginMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function refreshPlugins() {
|
|
if (!isAdmin()) {
|
|
state.plugins = [];
|
|
state.providers = {};
|
|
state.capabilities = [];
|
|
renderPluginAdmin();
|
|
renderProviderAdmin();
|
|
renderCapabilityMatrix();
|
|
await refreshPublicAuthMethods();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/v1/plugins");
|
|
state.plugins = result.plugins || [];
|
|
state.providers = result.providers || {};
|
|
state.capabilities = result.capabilities || [];
|
|
renderPluginAdmin();
|
|
renderProviderAdmin();
|
|
renderCapabilityMatrix();
|
|
await refreshPublicAuthMethods();
|
|
} catch (error) {
|
|
renderMessage(els.adminMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function refreshUsers() {
|
|
if (!canSeeUsersList()) {
|
|
state.users = [];
|
|
renderUsersAdmin();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/v1/admin/users");
|
|
state.users = result.users || [];
|
|
renderUsersAdmin();
|
|
} catch (error) {
|
|
renderMessage(els.usersMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function refreshApprovals() {
|
|
if (!canSeeApprovals()) {
|
|
state.approvals = [];
|
|
renderApprovals();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/v1/approvals");
|
|
state.approvals = result.approvals || [];
|
|
renderApprovals();
|
|
} catch (error) {
|
|
renderMessage(els.approvalsMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function refreshActivityLog() {
|
|
if (!canSeeActivityLog()) {
|
|
state.activityEntries = [];
|
|
renderActivityLog();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/v1/activity-log?limit=300");
|
|
state.activityEntries = Array.isArray(result.entries) ? result.entries : [];
|
|
renderActivityLog();
|
|
} catch (error) {
|
|
renderMessage(els.activityMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function refreshHelpContent() {
|
|
if (!state.user) {
|
|
state.helpContent = null;
|
|
renderHelpContent();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/v1/help/content");
|
|
state.helpContent = result && result.content ? result.content : null;
|
|
renderHelpContent();
|
|
} catch (error) {
|
|
state.helpContent = null;
|
|
renderHelpContent();
|
|
if (els.helpMessage) {
|
|
renderMessage(els.helpMessage, error.message, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderHelpContent() {
|
|
if (!els.helpSections || !els.helpQuickStartSteps) {
|
|
return;
|
|
}
|
|
els.helpSections.innerHTML = "";
|
|
els.helpQuickStartSteps.innerHTML = "";
|
|
|
|
const content = state.helpContent;
|
|
if (!content) {
|
|
if (els.helpTitle) {
|
|
els.helpTitle.textContent = "Hilfe";
|
|
}
|
|
if (els.helpQuickStartTitle) {
|
|
els.helpQuickStartTitle.textContent = "Schnellstart";
|
|
}
|
|
const li = document.createElement("li");
|
|
li.className = "muted";
|
|
li.textContent = "Keine Hilfedaten geladen.";
|
|
els.helpQuickStartSteps.appendChild(li);
|
|
return;
|
|
}
|
|
|
|
if (els.helpTitle) {
|
|
els.helpTitle.textContent = content.title || "Hilfe";
|
|
}
|
|
if (els.helpQuickStartTitle) {
|
|
els.helpQuickStartTitle.textContent = content.quickStart && content.quickStart.title
|
|
? content.quickStart.title
|
|
: "Schnellstart";
|
|
}
|
|
|
|
const quickSteps = content.quickStart && Array.isArray(content.quickStart.steps)
|
|
? content.quickStart.steps
|
|
: [];
|
|
for (const step of quickSteps) {
|
|
const li = document.createElement("li");
|
|
li.textContent = String(step);
|
|
els.helpQuickStartSteps.appendChild(li);
|
|
}
|
|
if (!quickSteps.length) {
|
|
const li = document.createElement("li");
|
|
li.className = "muted";
|
|
li.textContent = "Keine Schnellstart-Schritte hinterlegt.";
|
|
els.helpQuickStartSteps.appendChild(li);
|
|
}
|
|
|
|
const sections = Array.isArray(content.sections) ? content.sections : [];
|
|
for (const section of sections) {
|
|
const block = document.createElement("section");
|
|
block.className = "plugin-block";
|
|
|
|
const title = document.createElement("h3");
|
|
title.textContent = String(section && section.title ? section.title : "Hinweis");
|
|
block.appendChild(title);
|
|
|
|
const lines = Array.isArray(section && section.body) ? section.body : [];
|
|
for (const line of lines) {
|
|
const p = document.createElement("p");
|
|
p.textContent = String(line);
|
|
block.appendChild(p);
|
|
}
|
|
|
|
els.helpSections.appendChild(block);
|
|
}
|
|
}
|
|
|
|
function renderActivityLog() {
|
|
if (!els.activityLogList) return;
|
|
els.activityLogList.innerHTML = "";
|
|
if (!canSeeActivityLog()) return;
|
|
const sourceEntries = state.activityEntries.filter((entry) => {
|
|
if (state.activityFilter.type !== "all" && entry.action !== state.activityFilter.type) {
|
|
return false;
|
|
}
|
|
if (state.activityFilter.query) {
|
|
const haystack = `${String(entry.email || "")} ${String(entry.message || "")} ${String(entry.action || "")}`.toLowerCase();
|
|
if (!haystack.includes(state.activityFilter.query)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (!sourceEntries.length) {
|
|
els.activityLogList.textContent = "Keine Aktivitaetsdaten vorhanden.";
|
|
return;
|
|
}
|
|
for (const entry of sourceEntries) {
|
|
const block = document.createElement("div");
|
|
block.className = "plugin-block";
|
|
const head = document.createElement("div");
|
|
head.className = "section-head";
|
|
const title = document.createElement("strong");
|
|
title.textContent = new Date(entry.at).toLocaleString(localeForDate());
|
|
head.appendChild(title);
|
|
const pill = document.createElement("span");
|
|
pill.className = "pill";
|
|
pill.textContent = entry.action;
|
|
head.appendChild(pill);
|
|
block.appendChild(head);
|
|
|
|
const text = document.createElement("p");
|
|
text.textContent = entry.message || entry.action;
|
|
block.appendChild(text);
|
|
|
|
if (entry.details) {
|
|
const details = document.createElement("pre");
|
|
details.className = "audit-log";
|
|
details.textContent = JSON.stringify(entry.details, null, 2);
|
|
block.appendChild(details);
|
|
}
|
|
if (isAdmin()) {
|
|
const actions = document.createElement("div");
|
|
actions.className = "actions";
|
|
const deleteBtn = document.createElement("button");
|
|
deleteBtn.type = "button";
|
|
deleteBtn.className = "danger";
|
|
deleteBtn.textContent = "Eintrag loeschen";
|
|
deleteBtn.addEventListener("click", async () => {
|
|
await deleteActivityEntry(entry.id);
|
|
});
|
|
actions.appendChild(deleteBtn);
|
|
block.appendChild(actions);
|
|
}
|
|
els.activityLogList.appendChild(block);
|
|
}
|
|
}
|
|
|
|
function renderUsersAdmin() {
|
|
if (!els.usersAdmin) return;
|
|
els.usersAdmin.innerHTML = "";
|
|
if (!canSeeUsersList()) return;
|
|
const readOnly = !isAdmin();
|
|
if (readOnly) {
|
|
const hint = document.createElement("p");
|
|
hint.className = "muted";
|
|
hint.textContent = "Read-only Ansicht: Rollen und Methoden koennen nur von Admin bearbeitet werden.";
|
|
els.usersAdmin.appendChild(hint);
|
|
}
|
|
if (!state.users.length) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = "Keine Benutzer gefunden.";
|
|
els.usersAdmin.appendChild(empty);
|
|
return;
|
|
}
|
|
const filteredUsers = state.users.filter((user) => {
|
|
const query = state.usersFilter.query;
|
|
const role = state.usersFilter.role;
|
|
const status = state.usersFilter.status;
|
|
if (query && !String(user.email || "").toLowerCase().includes(query)) {
|
|
return false;
|
|
}
|
|
if (role !== "all" && user.role !== role) {
|
|
return false;
|
|
}
|
|
if (status !== "all" && user.status !== status) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const sortedUsers = [...filteredUsers].sort((a, b) => {
|
|
const roleWeight = { admin: 0, approver: 1, operator: 2 };
|
|
const aWeight = roleWeight[a.role] ?? 9;
|
|
const bWeight = roleWeight[b.role] ?? 9;
|
|
if (aWeight !== bWeight) {
|
|
return aWeight - bWeight;
|
|
}
|
|
return String(a.email || "").localeCompare(String(b.email || ""));
|
|
});
|
|
|
|
if (!sortedUsers.length) {
|
|
const emptyFiltered = document.createElement("p");
|
|
emptyFiltered.className = "muted";
|
|
emptyFiltered.textContent = "Keine Benutzer fuer den aktuellen Filter gefunden.";
|
|
els.usersAdmin.appendChild(emptyFiltered);
|
|
return;
|
|
}
|
|
|
|
for (const user of sortedUsers) {
|
|
const block = document.createElement("div");
|
|
block.className = "plugin-block";
|
|
const head = document.createElement("div");
|
|
head.className = "section-head";
|
|
const title = document.createElement("h3");
|
|
title.textContent = user.email;
|
|
head.appendChild(title);
|
|
const status = document.createElement("span");
|
|
status.className = "pill";
|
|
status.textContent = `${user.role} | ${user.status}`;
|
|
head.appendChild(status);
|
|
block.appendChild(head);
|
|
|
|
if (!readOnly) {
|
|
const controls = document.createElement("div");
|
|
controls.className = "actions";
|
|
for (const role of ["operator", "approver", "admin"]) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = role === user.role ? "ghost-btn" : "";
|
|
btn.textContent = role;
|
|
btn.disabled = role === user.role;
|
|
btn.addEventListener("click", async () => {
|
|
await updateUserRole(user.id, role);
|
|
});
|
|
controls.appendChild(btn);
|
|
}
|
|
block.appendChild(controls);
|
|
}
|
|
|
|
if (state.authMethods.length) {
|
|
const methodsWrap = document.createElement("div");
|
|
methodsWrap.className = "stack";
|
|
const hint = document.createElement("span");
|
|
hint.className = "muted";
|
|
hint.textContent = "Bestaetigungsarten";
|
|
methodsWrap.appendChild(hint);
|
|
|
|
const enabledMethods = new Set(Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : []);
|
|
const methodChecks = [];
|
|
for (const method of state.authMethods) {
|
|
const label = document.createElement("label");
|
|
label.className = "field";
|
|
const caption = document.createElement("span");
|
|
caption.textContent = `${method.label} (${method.type})`;
|
|
const checkbox = document.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.checked = enabledMethods.has(method.id);
|
|
label.appendChild(caption);
|
|
label.appendChild(checkbox);
|
|
methodsWrap.appendChild(label);
|
|
methodChecks.push({ methodId: method.id, checkbox });
|
|
}
|
|
|
|
const primarySelect = document.createElement("select");
|
|
for (const method of state.authMethods) {
|
|
const option = document.createElement("option");
|
|
option.value = method.id;
|
|
option.textContent = `${method.label}`;
|
|
option.selected = method.id === user.primaryAuthMethod;
|
|
primarySelect.appendChild(option);
|
|
}
|
|
methodsWrap.appendChild(primarySelect);
|
|
|
|
if (readOnly) {
|
|
for (const entry of methodChecks) {
|
|
entry.checkbox.disabled = true;
|
|
}
|
|
primarySelect.disabled = true;
|
|
} else {
|
|
const saveBtn = document.createElement("button");
|
|
saveBtn.type = "button";
|
|
saveBtn.textContent = "Auth-Methoden speichern";
|
|
saveBtn.addEventListener("click", async () => {
|
|
const enabled = methodChecks.filter((entry) => entry.checkbox.checked).map((entry) => entry.methodId);
|
|
await updateUserAuthMethods(user.id, enabled, primarySelect.value);
|
|
});
|
|
methodsWrap.appendChild(saveBtn);
|
|
}
|
|
block.appendChild(methodsWrap);
|
|
}
|
|
|
|
els.usersAdmin.appendChild(block);
|
|
}
|
|
}
|
|
|
|
function renderApprovals() {
|
|
if (!els.approvalsList) return;
|
|
els.approvalsList.innerHTML = "";
|
|
if (!canSeeApprovals()) return;
|
|
const showOpenOnly = state.approvalsFilter.mode !== "all";
|
|
els.approvalsFilterOpenBtn.classList.toggle("active", showOpenOnly);
|
|
els.approvalsFilterAllBtn.classList.toggle("active", !showOpenOnly);
|
|
|
|
const sourceApprovals = state.approvals.filter((entry) => {
|
|
if (showOpenOnly && entry.status !== "pending") {
|
|
return false;
|
|
}
|
|
if (state.approvalsFilter.status !== "all" && entry.status !== state.approvalsFilter.status) {
|
|
return false;
|
|
}
|
|
if (state.approvalsFilter.query) {
|
|
const email = String(entry.email || "").toLowerCase();
|
|
if (!email.includes(state.approvalsFilter.query)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (!sourceApprovals.length) {
|
|
els.approvalsList.textContent = "Keine Freigabe-Anfragen vorhanden.";
|
|
return;
|
|
}
|
|
const statusWeight = { pending: 0, approved: 1, rejected: 2 };
|
|
const sortedApprovals = [...sourceApprovals].sort((a, b) => {
|
|
const aWeight = statusWeight[a.status] ?? 9;
|
|
const bWeight = statusWeight[b.status] ?? 9;
|
|
if (aWeight !== bWeight) {
|
|
return aWeight - bWeight;
|
|
}
|
|
return new Date(b.updatedAt || b.createdAt || 0).getTime() - new Date(a.updatedAt || a.createdAt || 0).getTime();
|
|
});
|
|
|
|
for (const entry of sortedApprovals) {
|
|
const block = document.createElement("div");
|
|
block.className = "plugin-block";
|
|
const head = document.createElement("div");
|
|
head.className = "section-head";
|
|
const title = document.createElement("h3");
|
|
title.textContent = entry.email;
|
|
head.appendChild(title);
|
|
const status = document.createElement("span");
|
|
status.className = "pill";
|
|
status.classList.add(`approval-${entry.status}`);
|
|
status.textContent = entry.status;
|
|
head.appendChild(status);
|
|
block.appendChild(head);
|
|
|
|
const meta = document.createElement("p");
|
|
meta.className = "muted";
|
|
meta.textContent = `Erstellt: ${new Date(entry.createdAt).toLocaleString(localeForDate())} | Account: ${entry.userStatus || "-"} (${entry.userRole || "-"}) | Zuletzt: ${entry.updatedBy || "-"}`;
|
|
block.appendChild(meta);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "actions";
|
|
const approveBtn = document.createElement("button");
|
|
approveBtn.type = "button";
|
|
approveBtn.textContent = entry.status === "approved" ? "Erneut freigeben" : "Freigeben";
|
|
approveBtn.addEventListener("click", async () => {
|
|
await decideApproval(entry.id, true);
|
|
});
|
|
actions.appendChild(approveBtn);
|
|
|
|
const rejectBtn = document.createElement("button");
|
|
rejectBtn.type = "button";
|
|
rejectBtn.className = "danger";
|
|
rejectBtn.textContent = entry.status === "rejected" ? "Erneut ablehnen" : "Ablehnen";
|
|
rejectBtn.addEventListener("click", async () => {
|
|
await decideApproval(entry.id, false);
|
|
});
|
|
actions.appendChild(rejectBtn);
|
|
|
|
if (isAdmin()) {
|
|
const deleteBtn = document.createElement("button");
|
|
deleteBtn.type = "button";
|
|
deleteBtn.className = "danger";
|
|
deleteBtn.textContent = "Eintrag loeschen";
|
|
deleteBtn.addEventListener("click", async () => {
|
|
await deleteApprovalEntry(entry.id);
|
|
});
|
|
actions.appendChild(deleteBtn);
|
|
}
|
|
block.appendChild(actions);
|
|
|
|
els.approvalsList.appendChild(block);
|
|
}
|
|
}
|
|
|
|
async function updateUserRole(userId, role) {
|
|
clearMessages("users");
|
|
try {
|
|
await api(`/v1/admin/users/${encodeURIComponent(userId)}/role`, {
|
|
method: "PUT",
|
|
body: { role }
|
|
});
|
|
await refreshUsers();
|
|
await refreshApprovals();
|
|
renderMessage(els.usersMessage, `Rolle auf ${role} gesetzt`, false, true);
|
|
} catch (error) {
|
|
renderMessage(els.usersMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function updateUserAuthMethods(userId, enabledMethods, primaryMethod) {
|
|
clearMessages("users");
|
|
try {
|
|
await api(`/v1/admin/users/${encodeURIComponent(userId)}/auth-methods`, {
|
|
method: "PUT",
|
|
body: { enabledMethods, primaryMethod }
|
|
});
|
|
await refreshUsers();
|
|
renderMessage(els.usersMessage, "Bestaetigungsarten gespeichert", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.usersMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function decideApproval(id, approve) {
|
|
clearMessages("approvals");
|
|
try {
|
|
await api(`/v1/approvals/${encodeURIComponent(id)}/${approve ? "approve" : "reject"}`, {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
await refreshApprovals();
|
|
await refreshUsers();
|
|
renderMessage(els.approvalsMessage, approve ? "Freigabe bestaetigt" : "Freigabe abgelehnt", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.approvalsMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function deleteApprovalEntry(id) {
|
|
clearMessages("approvals");
|
|
try {
|
|
await api(`/v1/approvals/${encodeURIComponent(id)}`, {
|
|
method: "DELETE"
|
|
});
|
|
await refreshApprovals();
|
|
renderMessage(els.approvalsMessage, "Freigabe-Eintrag geloescht", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.approvalsMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function deleteActivityEntry(id) {
|
|
clearMessages("activity");
|
|
try {
|
|
await api(`/v1/activity-log/${encodeURIComponent(id)}`, {
|
|
method: "DELETE"
|
|
});
|
|
await refreshActivityLog();
|
|
renderMessage(els.activityMessage, "Aktivitaets-Eintrag geloescht", false, true);
|
|
} catch (error) {
|
|
renderMessage(els.activityMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
function renderStatus() {
|
|
const status = state.status;
|
|
if (!status) {
|
|
stopRemainingUsageWatch();
|
|
if (els.remainingUsage) {
|
|
els.remainingUsage.textContent = "-";
|
|
}
|
|
return;
|
|
}
|
|
|
|
const activationRunning = Boolean(status.activation && status.activation.running);
|
|
|
|
els.stationName.textContent = status.stationName || "-";
|
|
els.usageStatus.textContent = status.isInUse
|
|
? translateLiteral("In Benutzung")
|
|
: (activationRunning ? translateLiteral("Aktivierung laeuft") : translateLiteral("Frei"));
|
|
els.activeBy.textContent = status.activeByEmail || "-";
|
|
els.startedAt.textContent = status.startedAt ? new Date(status.startedAt).toLocaleString(localeForDate()) : "-";
|
|
els.endsAt.textContent = status.endsAt ? new Date(status.endsAt).toLocaleString(localeForDate()) : "-";
|
|
renderRemainingUsage();
|
|
if (status.isInUse || activationRunning) {
|
|
startRemainingUsageWatch();
|
|
} else {
|
|
stopRemainingUsageWatch();
|
|
}
|
|
els.stationOnlinePill.textContent = status.stationOnline ? "Online" : "Offline";
|
|
els.stationOnlinePill.classList.toggle("ok", Boolean(status.stationOnline));
|
|
els.stationOnlinePill.classList.toggle("offline", !status.stationOnline);
|
|
|
|
if (status.maintenanceMode) {
|
|
renderMessage(els.statusMessage, status.maintenanceMessage || "Wartungsmodus aktiv", true);
|
|
}
|
|
|
|
const swrRun = status.swrRun || status.activation;
|
|
renderActivationProgress(swrRun);
|
|
renderReservationQueue(status);
|
|
renderStationLinks(status);
|
|
renderOpenWebRx(status);
|
|
|
|
const loggedIn = Boolean(state.user);
|
|
const swrRunning = Boolean(swrRun && swrRun.running);
|
|
const isOwner = loggedIn && state.user.email === status.activeByEmail;
|
|
const activeReservation = status.reservationQueue && status.reservationQueue.activeEntry
|
|
? status.reservationQueue.activeEntry
|
|
: null;
|
|
const slotLockActive = Boolean(status.reservationQueue && status.reservationQueue.slotLockActive && activeReservation);
|
|
const isSlotOwner = loggedIn
|
|
&& activeReservation
|
|
&& state.user
|
|
&& state.user.id
|
|
&& String(state.user.id) === String(activeReservation.userId || "");
|
|
const slotDenied = slotLockActive && !isSlotOwner && !isAdmin();
|
|
const canOperate = canOperateStation();
|
|
els.activateBtn.disabled = !loggedIn || !canOperate || activationRunning || swrRunning || !status.stationOnline || Boolean(status.isInUse) || Boolean(status.maintenanceMode) || slotDenied;
|
|
els.deactivateBtn.disabled = !loggedIn || !status.isInUse || (!isOwner && !isAdmin()) || slotDenied;
|
|
setSwrRunButtonsBusy(false);
|
|
}
|
|
|
|
function startRemainingUsageWatch() {
|
|
if (remainingUsageTimer) {
|
|
return;
|
|
}
|
|
remainingUsageTimer = setInterval(() => {
|
|
renderRemainingUsage();
|
|
}, 1000);
|
|
}
|
|
|
|
function stopRemainingUsageWatch() {
|
|
if (!remainingUsageTimer) {
|
|
return;
|
|
}
|
|
clearInterval(remainingUsageTimer);
|
|
remainingUsageTimer = null;
|
|
}
|
|
|
|
function renderRemainingUsage() {
|
|
if (!els.remainingUsage) {
|
|
return;
|
|
}
|
|
const status = state.status;
|
|
const activationRunning = Boolean(status && status.activation && status.activation.running);
|
|
if (!status || (!status.isInUse && !activationRunning)) {
|
|
els.remainingUsage.textContent = "-";
|
|
return;
|
|
}
|
|
|
|
const endsAtMs = Date.parse(String(status.endsAt || ""));
|
|
if (Number.isFinite(endsAtMs)) {
|
|
const remainingSec = Math.max(0, Math.ceil((endsAtMs - Date.now()) / 1000));
|
|
els.remainingUsage.textContent = formatRemainingUsage(remainingSec);
|
|
return;
|
|
}
|
|
|
|
els.remainingUsage.textContent = formatRemainingUsage(Math.max(0, Number(status.remainingUsageSec || 0)));
|
|
}
|
|
|
|
function renderReservationQueue(status) {
|
|
if (!els.reservationPanel || !els.reserveNextBtn || !els.reservationList) {
|
|
return;
|
|
}
|
|
const queue = status && status.reservationQueue && typeof status.reservationQueue === "object"
|
|
? status.reservationQueue
|
|
: { entries: [], canReserve: false };
|
|
const entries = Array.isArray(queue.entries) ? queue.entries : [];
|
|
const visible = Boolean(queue.visible);
|
|
const loggedIn = Boolean(state.user);
|
|
const canOperate = canOperateStation();
|
|
const isOwner = loggedIn && status && state.user && state.user.id && String(state.user.id) === String(status.activeByUserId || "");
|
|
const hasOwnReservation = loggedIn
|
|
&& state.user
|
|
&& entries.some((entry) => String(entry && entry.userId ? entry.userId : "") === String(state.user.id || ""));
|
|
|
|
els.reservationPanel.hidden = !visible;
|
|
els.reserveNextBtn.disabled = !loggedIn || !canOperate || !queue.canReserve || isOwner || hasOwnReservation;
|
|
|
|
els.reservationList.innerHTML = "";
|
|
if (!visible) {
|
|
return;
|
|
}
|
|
if (!entries.length) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = translateLiteral("Noch keine Reservierungen vorhanden.");
|
|
els.reservationList.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
const list = document.createElement("div");
|
|
list.className = "swr-summary-list";
|
|
let hasMineEntry = false;
|
|
entries.forEach((entry) => {
|
|
const row = document.createElement("div");
|
|
row.className = `swr-summary-row reservation-row${entry.active ? " reservation-row-active" : ""}`;
|
|
|
|
const left = document.createElement("div");
|
|
left.className = "stack";
|
|
|
|
const title = document.createElement("strong");
|
|
title.textContent = `#${Number(entry.position || 0)} ${entry.email || "-"}`;
|
|
|
|
const details = document.createElement("small");
|
|
details.className = "muted";
|
|
const fromText = entry.from
|
|
? new Date(entry.from).toLocaleString(localeForDate(), {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
})
|
|
: "-";
|
|
const toText = entry.to
|
|
? new Date(entry.to).toLocaleString(localeForDate(), {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
})
|
|
: "-";
|
|
details.textContent = `${fromText} - ${toText}`;
|
|
|
|
const pill = document.createElement("span");
|
|
pill.className = `pill${entry.active ? " ok" : ""}`;
|
|
if (entry.active) {
|
|
pill.textContent = translateLiteral("Aktiv");
|
|
} else {
|
|
const fromLabel = entry.from
|
|
? new Date(entry.from).toLocaleTimeString(localeForDate(), { hour: "2-digit", minute: "2-digit" })
|
|
: "-";
|
|
const toLabel = entry.to
|
|
? new Date(entry.to).toLocaleTimeString(localeForDate(), { hour: "2-digit", minute: "2-digit" })
|
|
: "-";
|
|
pill.textContent = `${fromLabel} - ${toLabel}`;
|
|
}
|
|
|
|
left.appendChild(title);
|
|
left.appendChild(details);
|
|
const right = document.createElement("div");
|
|
right.className = "actions";
|
|
right.appendChild(pill);
|
|
if (isAdmin() && entry && entry.userId) {
|
|
const adminDeleteBtn = document.createElement("button");
|
|
adminDeleteBtn.type = "button";
|
|
adminDeleteBtn.className = "ghost-btn danger";
|
|
adminDeleteBtn.textContent = translateLiteral("Reservierung loeschen");
|
|
adminDeleteBtn.title = translateLiteral("Reservierung dieses Benutzers loeschen");
|
|
adminDeleteBtn.addEventListener("click", async () => {
|
|
await cancelReservationByUserId(entry.userId);
|
|
});
|
|
right.appendChild(adminDeleteBtn);
|
|
}
|
|
|
|
row.appendChild(left);
|
|
row.appendChild(right);
|
|
list.appendChild(row);
|
|
|
|
const isMine = loggedIn
|
|
&& state.user
|
|
&& String(entry.userId || "") === String(state.user.id || "");
|
|
hasMineEntry = hasMineEntry || isMine;
|
|
});
|
|
els.reservationList.appendChild(list);
|
|
|
|
if (hasMineEntry) {
|
|
const removeWrap = document.createElement("div");
|
|
removeWrap.className = "actions";
|
|
const removeBtn = document.createElement("button");
|
|
removeBtn.type = "button";
|
|
removeBtn.className = "ghost-btn danger";
|
|
removeBtn.textContent = translateLiteral("Meine Reservierung loeschen");
|
|
removeBtn.addEventListener("click", async () => {
|
|
await cancelOwnReservation();
|
|
});
|
|
removeWrap.appendChild(removeBtn);
|
|
els.reservationList.appendChild(removeWrap);
|
|
}
|
|
}
|
|
|
|
function renderOpenWebRx(status) {
|
|
if (!els.openwebrxPanel) {
|
|
return;
|
|
}
|
|
const loggedIn = Boolean(state.user);
|
|
const isOwner = loggedIn && status && status.isInUse && state.user.id && state.user.id === status.activeByUserId;
|
|
els.openwebrxPanel.hidden = !isOwner;
|
|
if (els.controlsPanel) {
|
|
els.controlsPanel.hidden = !isOwner;
|
|
}
|
|
if (!isOwner) {
|
|
stopOpenWebRxTxPolling();
|
|
state.openWebRx.sessionUrl = "";
|
|
state.openWebRx.sessionTicket = "";
|
|
state.openWebRx.expiresAt = null;
|
|
state.openWebRx.bands = [];
|
|
state.openWebRx.selectedBand = "";
|
|
state.openWebRx.txActive = null;
|
|
state.openWebRx.rotor = {
|
|
azimuth: null,
|
|
moving: false,
|
|
min: 0,
|
|
max: 360
|
|
};
|
|
syncOpenWebRxTicketCookie("");
|
|
renderOpenWebRxBandOptions();
|
|
renderOpenWebRxTxState();
|
|
renderRotorState();
|
|
renderOpenWebRxSessionAccess();
|
|
return;
|
|
}
|
|
startOpenWebRxTxPolling();
|
|
if (els.openwebrxOpenBtn) {
|
|
els.openwebrxOpenBtn.disabled = Boolean(status.activation && status.activation.running) || state.openWebRx.busy;
|
|
}
|
|
setOpenWebRxBusy(state.openWebRx.busy);
|
|
}
|
|
|
|
function renderActivationProgress(activation) {
|
|
const running = Boolean(activation && activation.running);
|
|
renderSingleActivationProgress({
|
|
box: els.activationProgress,
|
|
fill: els.progressFill,
|
|
text: els.progressText,
|
|
eta: els.progressEta
|
|
}, activation, running);
|
|
renderSingleActivationProgress({
|
|
box: els.activationProgressSwr,
|
|
fill: els.progressFillSwr,
|
|
text: els.progressTextSwr,
|
|
eta: els.progressEtaSwr
|
|
}, activation, running);
|
|
if (!running) {
|
|
stopActivationWatch();
|
|
return;
|
|
}
|
|
startActivationWatch();
|
|
}
|
|
|
|
function renderSingleActivationProgress(target, activation, running) {
|
|
if (!target || !target.box || !target.fill || !target.text || !target.eta) {
|
|
return;
|
|
}
|
|
target.box.hidden = !running;
|
|
if (!running) {
|
|
target.fill.style.width = "0%";
|
|
target.text.textContent = "-";
|
|
target.eta.textContent = "Geschaetzte Restzeit: -";
|
|
return;
|
|
}
|
|
const percent = Number(activation.percent || 0);
|
|
const elapsedSec = Number(activation.elapsedSec || 0);
|
|
const remainingSec = Number(activation.remainingSec || 0);
|
|
const phase = String(activation.phase || "swr-check");
|
|
target.fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
|
target.text.textContent = `${elapsedSec}s`;
|
|
target.eta.textContent = `SWR-Status: ${phase}${remainingSec > 0 ? `, noch ca. ${remainingSec}s` : ""}`;
|
|
}
|
|
|
|
function startActivationWatch() {
|
|
if (activationWatchTimer) {
|
|
return;
|
|
}
|
|
activationWatchTimer = setInterval(async () => {
|
|
if (activationWatchInFlight || !state.user) {
|
|
return;
|
|
}
|
|
activationWatchInFlight = true;
|
|
try {
|
|
await refreshStatus();
|
|
const activation = state.status && state.status.activation ? state.status.activation : null;
|
|
const swrRun = state.status && state.status.swrRun ? state.status.swrRun : null;
|
|
if (activation && activation.running && String(activation.phase || "") !== "swr-check") {
|
|
clearActivationSwrWaitingMessages();
|
|
}
|
|
await refreshSwrReport();
|
|
if (!(swrRun && swrRun.running)) {
|
|
if (activationPending && !(state.status && state.status.isInUse)) {
|
|
const activation = state.status && state.status.activation ? state.status.activation : null;
|
|
const reason = activation && activation.lastStatus === "failed" && activation.lastError
|
|
? `: ${activation.lastError}`
|
|
: ". Bitte Logs pruefen.";
|
|
const failText = `SWR-Check fertig, Aktivierung fehlgeschlagen${reason}`;
|
|
renderMessage(els.swrSummaryMessage, failText, true);
|
|
renderMessage(els.swrPageMessage, failText, true);
|
|
}
|
|
activationPending = false;
|
|
stopActivationWatch();
|
|
await refreshControls();
|
|
}
|
|
} catch {
|
|
// ignore watch errors
|
|
} finally {
|
|
activationWatchInFlight = false;
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function stopActivationWatch() {
|
|
if (!activationWatchTimer) {
|
|
return;
|
|
}
|
|
clearInterval(activationWatchTimer);
|
|
activationWatchTimer = null;
|
|
}
|
|
|
|
function clearActivationSwrWaitingMessages() {
|
|
const waitingPrefix = "SWR-Messung laeuft";
|
|
const targets = [els.swrSummaryMessage, els.swrPageMessage];
|
|
for (const target of targets) {
|
|
if (!target || typeof target.textContent !== "string") {
|
|
continue;
|
|
}
|
|
if (target.textContent.trim().startsWith(waitingPrefix)) {
|
|
target.textContent = "";
|
|
target.className = "message";
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderStationLinks(status) {
|
|
if (!els.stationLinks) {
|
|
return;
|
|
}
|
|
const links = status && status.links ? status.links : {};
|
|
const ready = Boolean(status && status.linksReady);
|
|
const hasAny = Boolean(links.swrOverview || links.openWebRxPath || links.webSdr || links.rotorControl);
|
|
els.stationLinks.hidden = !(ready && hasAny);
|
|
setLink(els.swrLink, links.swrOverview);
|
|
setLink(els.openwebrxLink, links.openWebRxPath || null);
|
|
setLink(els.websdrLink, links.webSdr);
|
|
setLink(els.rotorLink, links.rotorControl);
|
|
}
|
|
|
|
function setLink(el, href) {
|
|
if (!el) {
|
|
return;
|
|
}
|
|
if (!href) {
|
|
el.hidden = true;
|
|
el.removeAttribute("href");
|
|
return;
|
|
}
|
|
el.hidden = false;
|
|
el.href = href;
|
|
}
|
|
|
|
function renderPluginControls() {
|
|
els.pluginControls.innerHTML = "";
|
|
if (!state.controls.length) {
|
|
els.pluginControls.textContent = "Keine dynamischen Controls verfuegbar.";
|
|
return;
|
|
}
|
|
|
|
for (const control of state.controls) {
|
|
if (control.controlId === "station-main") {
|
|
continue;
|
|
}
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "card";
|
|
wrapper.style.padding = "0.8rem";
|
|
|
|
const title = document.createElement("h3");
|
|
title.textContent = control.title;
|
|
wrapper.appendChild(title);
|
|
|
|
const status = document.createElement("p");
|
|
status.className = "muted";
|
|
status.textContent = JSON.stringify(control.status || {});
|
|
wrapper.appendChild(status);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "schema-form";
|
|
for (const action of control.actions || []) {
|
|
const form = document.createElement("form");
|
|
form.className = "schema-form";
|
|
const formFields = renderActionFields(form, action.inputSchema || {});
|
|
|
|
const submit = document.createElement("button");
|
|
submit.type = "submit";
|
|
submit.textContent = action.name;
|
|
form.appendChild(submit);
|
|
|
|
form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const input = readActionInput(formFields);
|
|
await executePluginAction(control, action, input);
|
|
});
|
|
|
|
actions.appendChild(form);
|
|
}
|
|
wrapper.appendChild(actions);
|
|
els.pluginControls.appendChild(wrapper);
|
|
}
|
|
}
|
|
|
|
function renderActionFields(form, schema) {
|
|
const fields = [];
|
|
const properties = schema && schema.properties ? schema.properties : {};
|
|
for (const [name, fieldSchema] of Object.entries(properties)) {
|
|
const label = document.createElement("label");
|
|
label.className = "field";
|
|
const caption = document.createElement("span");
|
|
caption.textContent = name;
|
|
label.appendChild(caption);
|
|
|
|
const required = Array.isArray(schema.required) && schema.required.includes(name);
|
|
|
|
if (Array.isArray(fieldSchema.enum)) {
|
|
const select = document.createElement("select");
|
|
select.name = name;
|
|
select.required = required;
|
|
for (const optionValue of fieldSchema.enum) {
|
|
const option = document.createElement("option");
|
|
option.value = String(optionValue);
|
|
option.textContent = String(optionValue);
|
|
select.appendChild(option);
|
|
}
|
|
label.appendChild(select);
|
|
fields.push({ name, type: fieldSchema.type || "string", element: select });
|
|
} else {
|
|
const input = document.createElement("input");
|
|
input.name = name;
|
|
input.required = required;
|
|
if (fieldSchema.type === "number" || fieldSchema.type === "integer") {
|
|
input.type = "number";
|
|
if (fieldSchema.type === "integer") input.step = "1";
|
|
if (fieldSchema.minimum !== undefined) input.min = String(fieldSchema.minimum);
|
|
if (fieldSchema.maximum !== undefined) input.max = String(fieldSchema.maximum);
|
|
} else if (fieldSchema.type === "boolean") {
|
|
input.type = "checkbox";
|
|
} else {
|
|
input.type = "text";
|
|
}
|
|
if (fieldSchema.default !== undefined) {
|
|
if (fieldSchema.type === "boolean") {
|
|
input.checked = Boolean(fieldSchema.default);
|
|
} else {
|
|
input.value = String(fieldSchema.default);
|
|
}
|
|
}
|
|
label.appendChild(input);
|
|
fields.push({ name, type: fieldSchema.type || "string", element: input });
|
|
}
|
|
|
|
form.appendChild(label);
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
function readActionInput(fields) {
|
|
const input = {};
|
|
for (const field of fields) {
|
|
if (!field || !field.element) {
|
|
continue;
|
|
}
|
|
if (field.type === "boolean" && !field.hasSavedValue && !field.dirty && !field.element.checked) {
|
|
continue;
|
|
}
|
|
const raw = field.type === "boolean" ? String(field.element.checked) : field.element.value;
|
|
if (raw === "") {
|
|
if (field.dirty && (field.type === "string" || !field.type)) {
|
|
input[field.name] = "";
|
|
}
|
|
continue;
|
|
}
|
|
if (field.type === "number" || field.type === "integer") {
|
|
const parsed = Number(raw);
|
|
input[field.name] = field.type === "integer" ? Math.trunc(parsed) : parsed;
|
|
} else if (field.type === "boolean") {
|
|
input[field.name] = field.element.checked;
|
|
} else {
|
|
input[field.name] = raw;
|
|
}
|
|
}
|
|
return input;
|
|
}
|
|
|
|
async function executePluginAction(control, action, input) {
|
|
clearMessages("plugin");
|
|
try {
|
|
await api(`/v1/ui/controls/${encodeURIComponent(control.controlId)}/actions/${encodeURIComponent(action.name)}`, {
|
|
method: "POST",
|
|
body: { input }
|
|
});
|
|
renderMessage(els.pluginMessage, `${control.title}: ${action.name} ausgefuehrt`, false, true);
|
|
await refreshStatus();
|
|
await refreshControls();
|
|
} catch (error) {
|
|
renderMessage(els.pluginMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
function renderPluginAdmin() {
|
|
renderPluginAdminInto(els.pluginsAdminConfig);
|
|
}
|
|
|
|
function renderPluginAdminInto(container) {
|
|
if (!container) {
|
|
return;
|
|
}
|
|
container.innerHTML = "";
|
|
if (!isAdmin()) {
|
|
return;
|
|
}
|
|
if (!state.plugins.length) {
|
|
container.textContent = "Keine Plugins gefunden.";
|
|
return;
|
|
}
|
|
|
|
for (const plugin of state.plugins) {
|
|
const block = document.createElement("div");
|
|
block.className = "plugin-block";
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "section-head";
|
|
const title = document.createElement("h3");
|
|
title.textContent = `${plugin.name} (${plugin.id})`;
|
|
head.appendChild(title);
|
|
|
|
const toggle = document.createElement("button");
|
|
toggle.type = "button";
|
|
toggle.className = plugin.enabled ? "danger" : "";
|
|
toggle.textContent = plugin.enabled ? "Deaktivieren" : "Aktivieren";
|
|
toggle.addEventListener("click", async () => {
|
|
await togglePlugin(plugin.id, plugin.enabled);
|
|
});
|
|
head.appendChild(toggle);
|
|
block.appendChild(head);
|
|
|
|
const caps = document.createElement("p");
|
|
caps.className = "muted";
|
|
caps.textContent = `Capabilities: ${(plugin.capabilities || []).join(", ") || "-"}`;
|
|
block.appendChild(caps);
|
|
|
|
const settingsSchema = plugin.settingsSchema || { type: "object", properties: {} };
|
|
const hasSettings = settingsSchema && settingsSchema.properties && Object.keys(settingsSchema.properties).length > 0;
|
|
if (hasSettings) {
|
|
const settingsTitle = document.createElement("span");
|
|
settingsTitle.className = "muted";
|
|
settingsTitle.textContent = "Plugin Settings";
|
|
block.appendChild(settingsTitle);
|
|
|
|
const settingsForm = document.createElement("form");
|
|
settingsForm.className = "schema-form";
|
|
const settingFields = renderSchemaForm(settingsForm, settingsSchema, plugin.settings || {});
|
|
maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin);
|
|
|
|
const save = document.createElement("button");
|
|
save.type = "submit";
|
|
save.textContent = "Settings speichern";
|
|
settingsForm.appendChild(save);
|
|
|
|
settingsForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
await savePluginSettings(plugin.id, settingFields);
|
|
});
|
|
|
|
block.appendChild(settingsForm);
|
|
}
|
|
|
|
container.appendChild(block);
|
|
}
|
|
}
|
|
|
|
function maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin) {
|
|
if (!settingsForm || !Array.isArray(settingFields) || !plugin || plugin.id !== "rms.microham") {
|
|
return;
|
|
}
|
|
const extraArgsField = settingFields.find((field) => field && field.name === "audioFfmpegExtraArgs" && field.element);
|
|
if (!extraArgsField || !extraArgsField.element) {
|
|
return;
|
|
}
|
|
|
|
const presets = {
|
|
flat: {
|
|
hp: 250,
|
|
lp: 2800,
|
|
mids: 0,
|
|
presence: 0,
|
|
deesserEnabled: false,
|
|
deesserFreq: 5200,
|
|
deesserCut: 0,
|
|
compressorEnabled: false,
|
|
compressorThreshold: -18,
|
|
compressorRatio: 2.5,
|
|
gateEnabled: false,
|
|
gateThreshold: -52,
|
|
gateRelease: 180,
|
|
limiter: true
|
|
},
|
|
dx: {
|
|
hp: 360,
|
|
lp: 2500,
|
|
mids: 1.0,
|
|
presence: 1.8,
|
|
deesserEnabled: true,
|
|
deesserFreq: 5200,
|
|
deesserCut: 2.0,
|
|
compressorEnabled: true,
|
|
compressorThreshold: -20,
|
|
compressorRatio: 3.0,
|
|
gateEnabled: false,
|
|
gateThreshold: -50,
|
|
gateRelease: 160,
|
|
limiter: true
|
|
},
|
|
ragchew: {
|
|
hp: 300,
|
|
lp: 2800,
|
|
mids: 0.5,
|
|
presence: 1.0,
|
|
deesserEnabled: true,
|
|
deesserFreq: 5000,
|
|
deesserCut: 1.2,
|
|
compressorEnabled: true,
|
|
compressorThreshold: -22,
|
|
compressorRatio: 2.2,
|
|
gateEnabled: true,
|
|
gateThreshold: -55,
|
|
gateRelease: 220,
|
|
limiter: true
|
|
}
|
|
};
|
|
|
|
const parsed = parseMicrohamEqArgs(extraArgsField.element.value);
|
|
const initial = parsed || presets.flat;
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "plugin-eq-builder";
|
|
|
|
const title = document.createElement("strong");
|
|
title.textContent = "TX Audio EQ";
|
|
wrap.appendChild(title);
|
|
|
|
const hint = document.createElement("small");
|
|
hint.className = "muted";
|
|
hint.textContent = parsed
|
|
? "EQ aus aktuellen Extra-Args geladen"
|
|
: "Preset/Regler schreiben den FFmpeg-EQ in audioFfmpegExtraArgs";
|
|
wrap.appendChild(hint);
|
|
|
|
const presetRow = document.createElement("div");
|
|
presetRow.className = "plugin-eq-row";
|
|
|
|
const presetLabel = document.createElement("span");
|
|
presetLabel.className = "muted";
|
|
presetLabel.textContent = "Preset";
|
|
presetRow.appendChild(presetLabel);
|
|
|
|
const presetSelect = document.createElement("select");
|
|
presetSelect.innerHTML = [
|
|
'<option value="flat">Flat - neutral</option>',
|
|
'<option value="dx">DX - durchsetzungsstark</option>',
|
|
'<option value="ragchew">Ragchew - angenehmer Klang</option>'
|
|
].join("");
|
|
presetRow.appendChild(presetSelect);
|
|
|
|
const presetBtn = document.createElement("button");
|
|
presetBtn.type = "button";
|
|
presetBtn.className = "ghost-btn";
|
|
presetBtn.textContent = "Preset anwenden";
|
|
presetRow.appendChild(presetBtn);
|
|
|
|
wrap.appendChild(presetRow);
|
|
|
|
const presetInfo = document.createElement("small");
|
|
presetInfo.className = "muted";
|
|
wrap.appendChild(presetInfo);
|
|
|
|
const hpControl = createEqSliderControl("Highpass (Hz)", 80, 600, 10, initial.hp);
|
|
const lpControl = createEqSliderControl("Lowpass (Hz)", 1800, 4000, 10, initial.lp);
|
|
const midsControl = createEqSliderControl("Mitten (dB)", -8, 12, 0.5, initial.mids);
|
|
const presenceControl = createEqSliderControl("Presence (dB)", -8, 12, 0.5, initial.presence);
|
|
const deesserFreqControl = createEqSliderControl("De-Esser (Hz)", 3500, 8000, 100, initial.deesserFreq);
|
|
const deesserCutControl = createEqSliderControl("De-Esser Cut (dB)", 0, 8, 0.5, initial.deesserCut);
|
|
const compThresholdControl = createEqSliderControl("Compressor Threshold (dB)", -40, -6, 1, initial.compressorThreshold);
|
|
const compRatioControl = createEqSliderControl("Compressor Ratio", 1.2, 6, 0.1, initial.compressorRatio);
|
|
const gateThresholdControl = createEqSliderControl("Gate Threshold (dB)", -70, -20, 1, initial.gateThreshold);
|
|
const gateReleaseControl = createEqSliderControl("Gate Release (ms)", 60, 500, 10, initial.gateRelease);
|
|
wrap.appendChild(hpControl.row);
|
|
wrap.appendChild(lpControl.row);
|
|
wrap.appendChild(midsControl.row);
|
|
wrap.appendChild(presenceControl.row);
|
|
const deesserEnabledRow = createEqCheckboxRow("De-Esser aktivieren", initial.deesserEnabled);
|
|
wrap.appendChild(deesserEnabledRow.row);
|
|
wrap.appendChild(deesserFreqControl.row);
|
|
wrap.appendChild(deesserCutControl.row);
|
|
const compressorEnabledRow = createEqCheckboxRow("Compressor aktivieren", initial.compressorEnabled);
|
|
wrap.appendChild(compressorEnabledRow.row);
|
|
wrap.appendChild(compThresholdControl.row);
|
|
wrap.appendChild(compRatioControl.row);
|
|
const gateEnabledRow = createEqCheckboxRow("Noise Gate aktivieren", initial.gateEnabled);
|
|
wrap.appendChild(gateEnabledRow.row);
|
|
wrap.appendChild(gateThresholdControl.row);
|
|
wrap.appendChild(gateReleaseControl.row);
|
|
|
|
const limiterRow = document.createElement("label");
|
|
limiterRow.className = "plugin-eq-checkbox";
|
|
const limiterInput = document.createElement("input");
|
|
limiterInput.type = "checkbox";
|
|
limiterInput.checked = Boolean(initial.limiter);
|
|
limiterRow.appendChild(limiterInput);
|
|
const limiterText = document.createElement("span");
|
|
limiterText.textContent = "Limiter aktivieren (alimiter=0.95)";
|
|
limiterRow.appendChild(limiterText);
|
|
wrap.appendChild(limiterRow);
|
|
|
|
const preview = document.createElement("small");
|
|
preview.className = "muted";
|
|
wrap.appendChild(preview);
|
|
|
|
const saveButton = settingsForm.querySelector('button[type="submit"]');
|
|
if (saveButton && saveButton.parentNode === settingsForm) {
|
|
settingsForm.insertBefore(wrap, saveButton);
|
|
} else {
|
|
settingsForm.appendChild(wrap);
|
|
}
|
|
|
|
const applyControlsToExtraArgs = () => {
|
|
const hp = Number(hpControl.number.value);
|
|
const lp = Number(lpControl.number.value);
|
|
const mids = Number(midsControl.number.value);
|
|
const presence = Number(presenceControl.number.value);
|
|
const deesserEnabled = Boolean(deesserEnabledRow.input.checked);
|
|
const deesserFreq = Number(deesserFreqControl.number.value);
|
|
const deesserCut = Number(deesserCutControl.number.value);
|
|
const compressorEnabled = Boolean(compressorEnabledRow.input.checked);
|
|
const compressorThreshold = Number(compThresholdControl.number.value);
|
|
const compressorRatio = Number(compRatioControl.number.value);
|
|
const gateEnabled = Boolean(gateEnabledRow.input.checked);
|
|
const gateThreshold = Number(gateThresholdControl.number.value);
|
|
const gateRelease = Number(gateReleaseControl.number.value);
|
|
const limiter = Boolean(limiterInput.checked);
|
|
const args = buildMicrohamEqExtraArgs({
|
|
hp,
|
|
lp,
|
|
mids,
|
|
presence,
|
|
deesserEnabled,
|
|
deesserFreq,
|
|
deesserCut,
|
|
compressorEnabled,
|
|
compressorThreshold,
|
|
compressorRatio,
|
|
gateEnabled,
|
|
gateThreshold,
|
|
gateRelease,
|
|
limiter
|
|
});
|
|
extraArgsField.element.value = args;
|
|
preview.textContent = `Generierte Extra-Args: ${args}`;
|
|
};
|
|
|
|
const updatePresetInfo = () => {
|
|
const selected = String(presetSelect.value || "flat");
|
|
if (selected === "dx") {
|
|
presetInfo.textContent = "DX: sprachfokussiert mit reduziertem Bass und moderater Praesenz, weniger kuenstlich abgestimmt.";
|
|
return;
|
|
}
|
|
if (selected === "ragchew") {
|
|
presetInfo.textContent = "Ragchew: natuerlicher Sprachklang mit leicht reduziertem Bass und sanfter Mitten/Praesenz-Anhebung.";
|
|
return;
|
|
}
|
|
presetInfo.textContent = "Flat: neutrale Basis mit wenig Klangfaerbung, gut fuer Vergleich und Feintuning.";
|
|
};
|
|
|
|
const bindControlUpdate = (control) => {
|
|
control.range.addEventListener("input", () => {
|
|
control.number.value = control.range.value;
|
|
applyControlsToExtraArgs();
|
|
});
|
|
control.number.addEventListener("input", () => {
|
|
control.range.value = control.number.value;
|
|
applyControlsToExtraArgs();
|
|
});
|
|
};
|
|
|
|
bindControlUpdate(hpControl);
|
|
bindControlUpdate(lpControl);
|
|
bindControlUpdate(midsControl);
|
|
bindControlUpdate(presenceControl);
|
|
bindControlUpdate(deesserFreqControl);
|
|
bindControlUpdate(deesserCutControl);
|
|
bindControlUpdate(compThresholdControl);
|
|
bindControlUpdate(compRatioControl);
|
|
bindControlUpdate(gateThresholdControl);
|
|
bindControlUpdate(gateReleaseControl);
|
|
limiterInput.addEventListener("change", applyControlsToExtraArgs);
|
|
deesserEnabledRow.input.addEventListener("change", applyControlsToExtraArgs);
|
|
compressorEnabledRow.input.addEventListener("change", applyControlsToExtraArgs);
|
|
gateEnabledRow.input.addEventListener("change", applyControlsToExtraArgs);
|
|
|
|
presetBtn.addEventListener("click", () => {
|
|
const selected = presets[presetSelect.value] || presets.flat;
|
|
hpControl.range.value = String(selected.hp);
|
|
hpControl.number.value = String(selected.hp);
|
|
lpControl.range.value = String(selected.lp);
|
|
lpControl.number.value = String(selected.lp);
|
|
midsControl.range.value = String(selected.mids);
|
|
midsControl.number.value = String(selected.mids);
|
|
presenceControl.range.value = String(selected.presence);
|
|
presenceControl.number.value = String(selected.presence);
|
|
deesserEnabledRow.input.checked = Boolean(selected.deesserEnabled);
|
|
deesserFreqControl.range.value = String(selected.deesserFreq);
|
|
deesserFreqControl.number.value = String(selected.deesserFreq);
|
|
deesserCutControl.range.value = String(selected.deesserCut);
|
|
deesserCutControl.number.value = String(selected.deesserCut);
|
|
compressorEnabledRow.input.checked = Boolean(selected.compressorEnabled);
|
|
compThresholdControl.range.value = String(selected.compressorThreshold);
|
|
compThresholdControl.number.value = String(selected.compressorThreshold);
|
|
compRatioControl.range.value = String(selected.compressorRatio);
|
|
compRatioControl.number.value = String(selected.compressorRatio);
|
|
gateEnabledRow.input.checked = Boolean(selected.gateEnabled);
|
|
gateThresholdControl.range.value = String(selected.gateThreshold);
|
|
gateThresholdControl.number.value = String(selected.gateThreshold);
|
|
gateReleaseControl.range.value = String(selected.gateRelease);
|
|
gateReleaseControl.number.value = String(selected.gateRelease);
|
|
limiterInput.checked = Boolean(selected.limiter);
|
|
applyControlsToExtraArgs();
|
|
});
|
|
|
|
presetSelect.addEventListener("change", updatePresetInfo);
|
|
|
|
preview.textContent = `Aktuelle Extra-Args: ${extraArgsField.element.value || "<leer>"}`;
|
|
updatePresetInfo();
|
|
}
|
|
|
|
function createEqSliderControl(labelText, min, max, step, value) {
|
|
const row = document.createElement("div");
|
|
row.className = "plugin-eq-row";
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "muted";
|
|
label.textContent = labelText;
|
|
row.appendChild(label);
|
|
|
|
const range = document.createElement("input");
|
|
range.type = "range";
|
|
range.min = String(min);
|
|
range.max = String(max);
|
|
range.step = String(step);
|
|
range.value = String(value);
|
|
row.appendChild(range);
|
|
|
|
const number = document.createElement("input");
|
|
number.type = "number";
|
|
number.min = String(min);
|
|
number.max = String(max);
|
|
number.step = String(step);
|
|
number.value = String(value);
|
|
row.appendChild(number);
|
|
|
|
return { row, range, number };
|
|
}
|
|
|
|
function createEqCheckboxRow(labelText, checked) {
|
|
const row = document.createElement("label");
|
|
row.className = "plugin-eq-checkbox";
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.checked = Boolean(checked);
|
|
row.appendChild(input);
|
|
const label = document.createElement("span");
|
|
label.textContent = labelText;
|
|
row.appendChild(label);
|
|
return { row, input };
|
|
}
|
|
|
|
function buildMicrohamEqExtraArgs({
|
|
hp,
|
|
lp,
|
|
mids,
|
|
presence,
|
|
deesserEnabled,
|
|
deesserFreq,
|
|
deesserCut,
|
|
compressorEnabled,
|
|
compressorThreshold,
|
|
compressorRatio,
|
|
gateEnabled,
|
|
gateThreshold,
|
|
gateRelease,
|
|
limiter
|
|
}) {
|
|
const filters = [
|
|
`highpass=f=${Math.round(Number(hp) || 0)}`,
|
|
`lowpass=f=${Math.round(Number(lp) || 0)}`,
|
|
`equalizer=f=1200:t=q:w=0.8:g=${Number(Number(mids || 0).toFixed(1))}`,
|
|
`equalizer=f=2100:t=q:w=0.9:g=${Number(Number(presence || 0).toFixed(1))}`
|
|
];
|
|
if (deesserEnabled && Number(deesserCut || 0) > 0) {
|
|
const freq = Math.round(Number(deesserFreq) || 5200);
|
|
const cut = Number(Number(-Math.abs(Number(deesserCut || 0))).toFixed(1));
|
|
filters.push(`equalizer=f=${freq}:t=q:w=1.1:g=${cut}`);
|
|
}
|
|
if (compressorEnabled) {
|
|
const thresholdDb = Number(Number(compressorThreshold || -20).toFixed(1));
|
|
const ratio = Number(Number(compressorRatio || 3).toFixed(2));
|
|
const thresholdLinear = Number(Math.pow(10, thresholdDb / 20).toFixed(6));
|
|
filters.push(`acompressor=threshold=${thresholdLinear}:ratio=${ratio}:attack=5:release=120:makeup=1.2`);
|
|
}
|
|
if (gateEnabled) {
|
|
const gateThresholdDb = Number(Number(gateThreshold || -55).toFixed(1));
|
|
const gateThresholdLinear = Number(Math.pow(10, gateThresholdDb / 20).toFixed(6));
|
|
const release = Math.round(Number(gateRelease || 180));
|
|
filters.push(`agate=threshold=${gateThresholdLinear}:release=${release}`);
|
|
}
|
|
if (limiter) {
|
|
filters.push("alimiter=limit=0.95");
|
|
}
|
|
return `-af "${filters.join(",")}"`;
|
|
}
|
|
|
|
function parseMicrohamEqArgs(value) {
|
|
const text = String(value || "").trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
const hpMatch = text.match(/highpass\s*=\s*f\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
|
|
const lpMatch = text.match(/lowpass\s*=\s*f\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
|
|
const midsMatch = text.match(/equalizer\s*=\s*f\s*=\s*1200\s*:\s*t\s*=\s*q\s*:\s*w\s*=\s*0\.8\s*:\s*g\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
|
|
const presenceMatch = text.match(/equalizer\s*=\s*f\s*=\s*2100\s*:\s*t\s*=\s*q\s*:\s*w\s*=\s*0\.9\s*:\s*g\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
|
|
if (!hpMatch || !lpMatch || !presenceMatch) {
|
|
return null;
|
|
}
|
|
const hp = Number(hpMatch[1]);
|
|
const lp = Number(lpMatch[1]);
|
|
const mids = midsMatch ? Number(midsMatch[1]) : 0;
|
|
const presence = Number(presenceMatch[1]);
|
|
if (!Number.isFinite(hp) || !Number.isFinite(lp) || !Number.isFinite(mids) || !Number.isFinite(presence)) {
|
|
return null;
|
|
}
|
|
const deesserMatch = text.match(/equalizer\s*=\s*f\s*=\s*([0-9]+)\s*:\s*t\s*=\s*q\s*:\s*w\s*=\s*1\.1\s*:\s*g\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
|
|
const deesserEnabled = Boolean(deesserMatch);
|
|
const deesserFreq = deesserMatch ? Number(deesserMatch[1]) : 5200;
|
|
const deesserCut = deesserMatch ? Math.abs(Number(deesserMatch[2])) : 0;
|
|
const compMatch = text.match(/acompressor\s*=\s*threshold\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*:\s*ratio\s*=\s*([0-9]+(?:\.[0-9]+)?)/i);
|
|
const compressorEnabled = Boolean(compMatch);
|
|
const compressorThreshold = compMatch ? Number((20 * Math.log10(Number(compMatch[1]))).toFixed(1)) : -20;
|
|
const compressorRatio = compMatch ? Number(compMatch[2]) : 3;
|
|
const gateMatch = text.match(/agate\s*=\s*threshold\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*:\s*release\s*=\s*([0-9]+(?:\.[0-9]+)?)/i);
|
|
const gateEnabled = Boolean(gateMatch);
|
|
const gateThreshold = gateMatch ? Number((20 * Math.log10(Number(gateMatch[1]))).toFixed(1)) : -55;
|
|
const gateRelease = gateMatch ? Number(gateMatch[2]) : 180;
|
|
const limiter = /alimiter\s*=\s*limit\s*=\s*0\.95/i.test(text);
|
|
return {
|
|
hp,
|
|
lp,
|
|
mids,
|
|
presence,
|
|
deesserEnabled,
|
|
deesserFreq,
|
|
deesserCut,
|
|
compressorEnabled,
|
|
compressorThreshold,
|
|
compressorRatio,
|
|
gateEnabled,
|
|
gateThreshold,
|
|
gateRelease,
|
|
limiter
|
|
};
|
|
}
|
|
|
|
function renderProviderAdmin() {
|
|
renderProviderAdminInto(els.providersAdminConfig);
|
|
}
|
|
|
|
function renderProviderAdminInto(container) {
|
|
if (!container) {
|
|
return;
|
|
}
|
|
container.innerHTML = "";
|
|
if (!isAdmin()) {
|
|
return;
|
|
}
|
|
if (!state.capabilities.length) {
|
|
container.textContent = "Keine Provider-Daten verfuegbar.";
|
|
return;
|
|
}
|
|
|
|
for (const entry of state.capabilities) {
|
|
const block = document.createElement("div");
|
|
block.className = "plugin-block";
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "section-head";
|
|
const title = document.createElement("h3");
|
|
title.textContent = entry.capability;
|
|
head.appendChild(title);
|
|
block.appendChild(head);
|
|
|
|
const row = document.createElement("label");
|
|
row.className = "field";
|
|
const label = document.createElement("span");
|
|
label.textContent = "Aktiver Provider";
|
|
row.appendChild(label);
|
|
|
|
const select = document.createElement("select");
|
|
for (const provider of entry.providers || []) {
|
|
const opt = document.createElement("option");
|
|
opt.value = provider.pluginId;
|
|
opt.textContent = provider.pluginId;
|
|
if (entry.activePluginId === provider.pluginId) {
|
|
opt.selected = true;
|
|
}
|
|
select.appendChild(opt);
|
|
}
|
|
select.addEventListener("change", async () => {
|
|
await switchProvider(entry.capability, select.value);
|
|
});
|
|
row.appendChild(select);
|
|
block.appendChild(row);
|
|
|
|
const providers = document.createElement("div");
|
|
providers.className = "actions";
|
|
for (const provider of entry.providers || []) {
|
|
const badge = document.createElement("span");
|
|
badge.className = `health-badge health-${provider.health}`;
|
|
badge.textContent = `${provider.pluginId} (${provider.enabled ? "on" : "off"}, ${provider.health})`;
|
|
providers.appendChild(badge);
|
|
}
|
|
block.appendChild(providers);
|
|
container.appendChild(block);
|
|
}
|
|
}
|
|
|
|
function renderCapabilityMatrix() {
|
|
renderCapabilityMatrixInto(els.providersCapabilityMatrix);
|
|
}
|
|
|
|
function renderCapabilityMatrixInto(container) {
|
|
if (!container) {
|
|
return;
|
|
}
|
|
container.innerHTML = "";
|
|
if (!isAdmin()) {
|
|
return;
|
|
}
|
|
if (!state.capabilities.length) {
|
|
container.textContent = "Keine Capability Daten verfuegbar.";
|
|
return;
|
|
}
|
|
|
|
for (const entry of state.capabilities) {
|
|
const row = document.createElement("div");
|
|
row.className = "matrix-row";
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "section-head";
|
|
const title = document.createElement("strong");
|
|
title.textContent = entry.capability;
|
|
head.appendChild(title);
|
|
|
|
const active = document.createElement("span");
|
|
active.className = "pill";
|
|
active.textContent = `Aktiv: ${entry.activePluginId || "-"}`;
|
|
head.appendChild(active);
|
|
row.appendChild(head);
|
|
|
|
const providers = document.createElement("div");
|
|
providers.className = "actions";
|
|
for (const provider of entry.providers || []) {
|
|
const badge = document.createElement("span");
|
|
badge.className = `health-badge health-${provider.health}`;
|
|
badge.textContent = `${provider.pluginId} (${provider.enabled ? "on" : "off"}, ${provider.health})`;
|
|
providers.appendChild(badge);
|
|
}
|
|
row.appendChild(providers);
|
|
|
|
container.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function renderSchemaForm(form, schema, values) {
|
|
const fields = [];
|
|
const properties = schema.properties || {};
|
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
|
|
for (const [name, fieldSchema] of Object.entries(properties)) {
|
|
const label = document.createElement("label");
|
|
label.className = "field";
|
|
const caption = document.createElement("span");
|
|
caption.textContent = name;
|
|
label.appendChild(caption);
|
|
|
|
const hasSavedValue = values && values[name] !== undefined;
|
|
const rawCurrentValue = hasSavedValue ? values[name] : fieldSchema.default;
|
|
const currentValue = coerceSchemaValue(fieldSchema, rawCurrentValue);
|
|
const sourceLabel = hasSavedValue ? "gespeichert" : (fieldSchema.default !== undefined ? "default" : "nicht gesetzt");
|
|
|
|
if (Array.isArray(fieldSchema.enum)) {
|
|
const select = document.createElement("select");
|
|
select.name = name;
|
|
select.required = required.includes(name);
|
|
for (const enumValue of fieldSchema.enum) {
|
|
const option = document.createElement("option");
|
|
option.value = String(enumValue);
|
|
option.textContent = String(enumValue);
|
|
if (currentValue === enumValue) {
|
|
option.selected = true;
|
|
}
|
|
select.appendChild(option);
|
|
}
|
|
label.appendChild(select);
|
|
const field = { name, type: fieldSchema.type || "string", element: select, hasSavedValue, dirty: false };
|
|
select.addEventListener("change", () => {
|
|
field.dirty = true;
|
|
});
|
|
fields.push(field);
|
|
} else {
|
|
const input = document.createElement("input");
|
|
input.name = name;
|
|
input.required = required.includes(name);
|
|
if (fieldSchema.type === "number" || fieldSchema.type === "integer") {
|
|
input.type = "number";
|
|
if (fieldSchema.type === "integer") input.step = "1";
|
|
if (fieldSchema.minimum !== undefined) input.min = String(fieldSchema.minimum);
|
|
if (fieldSchema.maximum !== undefined) input.max = String(fieldSchema.maximum);
|
|
} else if (fieldSchema.type === "boolean") {
|
|
input.type = "checkbox";
|
|
} else {
|
|
input.type = "text";
|
|
}
|
|
|
|
const value = currentValue;
|
|
if (value !== undefined) {
|
|
if (fieldSchema.type === "boolean") {
|
|
input.checked = Boolean(value);
|
|
} else {
|
|
input.value = String(value);
|
|
}
|
|
}
|
|
label.appendChild(input);
|
|
const field = { name, type: fieldSchema.type || "string", element: input, hasSavedValue, dirty: false };
|
|
input.addEventListener("change", () => {
|
|
field.dirty = true;
|
|
});
|
|
fields.push(field);
|
|
}
|
|
|
|
const currentInfo = document.createElement("small");
|
|
currentInfo.className = "muted";
|
|
currentInfo.textContent = `Aktuell in Verwendung: ${formatSettingValue(currentValue)} (${sourceLabel})`;
|
|
label.appendChild(currentInfo);
|
|
|
|
form.appendChild(label);
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
function formatSettingValue(value) {
|
|
if (value === undefined || value === null || value === "") {
|
|
return "<leer>";
|
|
}
|
|
if (typeof value === "boolean") {
|
|
return value ? "true" : "false";
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function coerceSchemaValue(fieldSchema, value) {
|
|
if (!fieldSchema || value === undefined || value === null) {
|
|
return value;
|
|
}
|
|
const type = String(fieldSchema.type || "").toLowerCase();
|
|
if (type === "boolean") {
|
|
if (typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
const normalized = String(value).trim().toLowerCase();
|
|
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
return true;
|
|
}
|
|
if (["0", "false", "no", "off", ""].includes(normalized)) {
|
|
return false;
|
|
}
|
|
return Boolean(value);
|
|
}
|
|
if (type === "number" || type === "integer") {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) {
|
|
return value;
|
|
}
|
|
return type === "integer" ? Math.trunc(numeric) : numeric;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function savePluginSettings(pluginId, fields) {
|
|
clearMessages("admin");
|
|
try {
|
|
const settings = readActionInput(fields);
|
|
await api(`/v1/plugins/${encodeURIComponent(pluginId)}/settings`, {
|
|
method: "PUT",
|
|
body: { settings }
|
|
});
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
renderMessage(els.adminMessage, `Settings fuer ${pluginId} gespeichert`, false, true);
|
|
} catch (error) {
|
|
renderMessage(els.adminMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function togglePlugin(pluginId, currentlyEnabled) {
|
|
clearMessages("admin");
|
|
try {
|
|
const route = currentlyEnabled ? "disable" : "enable";
|
|
await api(`/v1/plugins/${encodeURIComponent(pluginId)}/${route}`, {
|
|
method: "POST",
|
|
body: {}
|
|
});
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
renderMessage(els.adminMessage, `${pluginId} ${currentlyEnabled ? "deaktiviert" : "aktiviert"}`, false, true);
|
|
} catch (error) {
|
|
renderMessage(els.adminMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
async function switchProvider(capability, pluginId) {
|
|
clearMessages("provider");
|
|
try {
|
|
await api(`/v1/admin/capabilities/${encodeURIComponent(capability)}/provider`, {
|
|
method: "PUT",
|
|
body: { pluginId }
|
|
});
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
renderMessage(els.providersMessage, `Provider fuer ${capability} auf ${pluginId} gesetzt`, false, true);
|
|
} catch (error) {
|
|
renderMessage(els.providersMessage, error.message, true);
|
|
}
|
|
}
|
|
|
|
function updateUserUi() {
|
|
const setHidden = (el, value) => {
|
|
if (el) {
|
|
el.hidden = value;
|
|
}
|
|
};
|
|
const setDisabled = (el, value) => {
|
|
if (el) {
|
|
el.disabled = value;
|
|
}
|
|
};
|
|
const loggedIn = Boolean(state.user);
|
|
const rmsVisible = loggedIn && window.location.pathname.startsWith("/rms");
|
|
const rawPage = currentRmsPage();
|
|
const page = normalizedRmsPage(rawPage);
|
|
|
|
if (rmsVisible && page !== rawPage) {
|
|
window.history.replaceState({}, "", pageToPath(page));
|
|
}
|
|
|
|
setHidden(els.authView, rmsVisible);
|
|
setHidden(els.rmsView, !rmsVisible);
|
|
setHidden(els.pageRms, !rmsVisible || page !== "rms");
|
|
setHidden(els.pageSwr, !rmsVisible || page !== "swr");
|
|
setHidden(els.pageUser, !rmsVisible || page !== "user");
|
|
setHidden(els.pageHelp, !rmsVisible || page !== "help");
|
|
setHidden(els.pagePlugins, !rmsVisible || page !== "plugins" || !isAdmin());
|
|
setHidden(els.pagePluginConfig, !rmsVisible || page !== "plugin-config" || !isAdmin());
|
|
setHidden(els.pageProviders, !rmsVisible || page !== "providers" || !isAdmin());
|
|
setHidden(els.pageAdmin, !rmsVisible || page !== "admin");
|
|
setHidden(els.pageUsers, !rmsVisible || page !== "users");
|
|
setHidden(els.pageApprovals, !rmsVisible || page !== "approvals");
|
|
setHidden(els.pageActivity, !rmsVisible || page !== "activity");
|
|
setDisabled(els.loginBtn, loggedIn);
|
|
setDisabled(els.logoutBtn, !loggedIn);
|
|
setDisabled(els.settingsLogoutTopBtn, !loggedIn);
|
|
setDisabled(els.userMenuButton, !loggedIn);
|
|
setDisabled(els.callsignInput, loggedIn);
|
|
setDisabled(els.emailDomainSelect, loggedIn);
|
|
if (els.userMenuButton) {
|
|
els.userMenuButton.textContent = "☰";
|
|
els.userMenuButton.setAttribute("aria-label", loggedIn ? `Menue (${state.user.email})` : "Menue");
|
|
}
|
|
setHidden(els.currentUserLink, !loggedIn);
|
|
if (els.currentUserLink) {
|
|
els.currentUserLink.textContent = loggedIn ? `👤 ${state.user.email}` : "👤 -";
|
|
}
|
|
setHidden(els.adminCard, !isAdmin());
|
|
if (els.pluginsConfigCard) {
|
|
els.pluginsConfigCard.hidden = !isAdmin();
|
|
}
|
|
setHidden(els.menuAdmin, !isAdmin());
|
|
setHidden(els.menuSwr, !canOperateStation());
|
|
setHidden(els.menuHelp, !loggedIn);
|
|
setHidden(els.menuPlugins, !isAdmin());
|
|
setHidden(els.menuPluginConfig, !isAdmin());
|
|
setHidden(els.menuProviders, !isAdmin());
|
|
setHidden(els.menuUsers, !canSeeUsersList());
|
|
setHidden(els.menuApprovals, !canSeeApprovals());
|
|
setHidden(els.menuActivity, !canSeeActivityLog());
|
|
setHidden(els.mobileNavUsers, !canSeeUsersList());
|
|
setHidden(els.mobileNavPlugins, !isAdmin());
|
|
setHidden(els.mobileNavPluginConfig, !isAdmin());
|
|
setHidden(els.mobileNavSwr, !canOperateStation());
|
|
setHidden(els.mobileNavHelp, !loggedIn);
|
|
setHidden(els.mobileNavAdmin, !isAdmin());
|
|
setHidden(els.mobileNavApprovals, !canSeeApprovals());
|
|
setHidden(els.mobileNavActivity, !canSeeActivityLog());
|
|
if (els.mobileNav) {
|
|
els.mobileNav.classList.toggle("admin-visible", isAdmin());
|
|
els.mobileNav.classList.toggle("swr-visible", canOperateStation());
|
|
els.mobileNav.classList.toggle("users-visible", canSeeUsersList());
|
|
els.mobileNav.classList.toggle("approvals-visible", canSeeApprovals());
|
|
els.mobileNav.classList.toggle("activity-visible", canSeeActivityLog());
|
|
}
|
|
if (els.settingsEmail) {
|
|
els.settingsEmail.textContent = loggedIn ? state.user.email : "-";
|
|
}
|
|
if (els.settingsRole) {
|
|
els.settingsRole.textContent = loggedIn ? state.user.role : "-";
|
|
}
|
|
renderLanguageSelectors();
|
|
if (els.settingsLanguageSelect) {
|
|
const userPreferred = normalizeLanguage(loggedIn && state.user && state.user.preferredLanguage
|
|
? state.user.preferredLanguage
|
|
: state.i18n.language);
|
|
els.settingsLanguageSelect.value = userPreferred;
|
|
}
|
|
if (els.menuLanguageSelect) {
|
|
els.menuLanguageSelect.value = normalizeLanguage(state.i18n.language);
|
|
}
|
|
renderUserSettingsAuthMethods();
|
|
renderMaintenanceBanner();
|
|
updateMenuState(page, loggedIn);
|
|
updatePageMeta(loggedIn, page);
|
|
applyI18n();
|
|
setHidden(els.mobileNav, !rmsVisible);
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(false);
|
|
if (rmsVisible) {
|
|
animateCurrentPage(page);
|
|
}
|
|
}
|
|
|
|
function animateCurrentPage(page) {
|
|
const target = page === "user"
|
|
? els.pageUser
|
|
: page === "swr"
|
|
? els.pageSwr
|
|
: page === "help"
|
|
? els.pageHelp
|
|
: page === "plugins"
|
|
? els.pagePlugins
|
|
: page === "plugin-config"
|
|
? els.pagePluginConfig
|
|
: page === "providers"
|
|
? els.pageProviders
|
|
: page === "users"
|
|
? els.pageUsers
|
|
: page === "approvals"
|
|
? els.pageApprovals
|
|
: page === "activity"
|
|
? els.pageActivity
|
|
: page === "admin"
|
|
? els.pageAdmin
|
|
: els.pageRms;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
target.classList.remove("page-enter");
|
|
void target.offsetWidth;
|
|
target.classList.add("page-enter");
|
|
}
|
|
|
|
function updatePageMeta(loggedIn, page) {
|
|
if (!loggedIn) {
|
|
els.pageTitle.textContent = "RMS Status";
|
|
els.pageHint.textContent = "Bitte anmelden";
|
|
els.pageCrumb.textContent = "LOGIN";
|
|
return;
|
|
}
|
|
if (page === "user") {
|
|
els.pageTitle.textContent = "Einstellungen";
|
|
els.pageHint.textContent = "Persoenliche Einstellungen";
|
|
els.pageCrumb.textContent = "RMS / EINSTELLUNGEN";
|
|
return;
|
|
}
|
|
if (page === "help") {
|
|
els.pageTitle.textContent = "Hilfe";
|
|
els.pageHint.textContent = "Grundablaeufe fuer den Stationsbetrieb";
|
|
els.pageCrumb.textContent = "RMS / HILFE";
|
|
return;
|
|
}
|
|
if (page === "plugins") {
|
|
els.pageTitle.textContent = "Plugin Controls";
|
|
els.pageHint.textContent = "Dynamische Geraetesteuerung";
|
|
els.pageCrumb.textContent = "RMS / PLUGIN CONTROLS";
|
|
return;
|
|
}
|
|
if (page === "plugin-config") {
|
|
els.pageTitle.textContent = "Plugin Konfiguration";
|
|
els.pageHint.textContent = "Einstellungen und Aktivierung";
|
|
els.pageCrumb.textContent = "RMS / PLUGIN KONFIG";
|
|
return;
|
|
}
|
|
if (page === "providers") {
|
|
els.pageTitle.textContent = "Provider";
|
|
els.pageHint.textContent = "Capability-Zuordnung";
|
|
els.pageCrumb.textContent = "RMS / PROVIDER";
|
|
return;
|
|
}
|
|
if (page === "swr") {
|
|
els.pageTitle.textContent = "SWR Test-Daten";
|
|
els.pageHint.textContent = "Bandauswertung und Grafiken";
|
|
els.pageCrumb.textContent = "RMS / SWR";
|
|
return;
|
|
}
|
|
if (page === "admin") {
|
|
els.pageTitle.textContent = "Admin";
|
|
els.pageHint.textContent = "System- und Plugin-Verwaltung";
|
|
els.pageCrumb.textContent = "RMS / ADMIN";
|
|
return;
|
|
}
|
|
if (page === "users") {
|
|
els.pageTitle.textContent = "Benutzerverwaltung";
|
|
els.pageHint.textContent = "Rollen und Accountstatus";
|
|
els.pageCrumb.textContent = "RMS / USERS";
|
|
return;
|
|
}
|
|
if (page === "approvals") {
|
|
els.pageTitle.textContent = "Freigaben";
|
|
els.pageHint.textContent = "Externe Domain-Anfragen";
|
|
els.pageCrumb.textContent = "RMS / FREIGABEN";
|
|
return;
|
|
}
|
|
if (page === "activity") {
|
|
els.pageTitle.textContent = "Aktivitaetslog";
|
|
els.pageHint.textContent = "Bedienung und Stationsnutzung";
|
|
els.pageCrumb.textContent = "RMS / AKTIVITAET";
|
|
return;
|
|
}
|
|
els.pageTitle.textContent = "RMS Status";
|
|
els.pageHint.textContent = "Station steuern";
|
|
els.pageCrumb.textContent = "RMS / STATUS";
|
|
}
|
|
|
|
function updateMenuState(page, loggedIn) {
|
|
const map = {
|
|
rms: els.menuRms,
|
|
swr: els.menuSwr,
|
|
user: els.menuUser,
|
|
help: els.menuHelp,
|
|
plugins: els.menuPlugins,
|
|
"plugin-config": els.menuPluginConfig,
|
|
providers: els.menuProviders,
|
|
users: els.menuUsers,
|
|
approvals: els.menuApprovals,
|
|
activity: els.menuActivity,
|
|
admin: els.menuAdmin
|
|
};
|
|
for (const [name, el] of Object.entries(map)) {
|
|
if (el) {
|
|
el.classList.toggle("active", loggedIn && page === name);
|
|
}
|
|
}
|
|
const mobileMap = {
|
|
rms: els.mobileNavRms,
|
|
swr: els.mobileNavSwr,
|
|
user: els.mobileNavUser,
|
|
help: els.mobileNavHelp,
|
|
plugins: els.mobileNavPlugins,
|
|
"plugin-config": els.mobileNavPluginConfig,
|
|
users: els.mobileNavUsers,
|
|
approvals: els.mobileNavApprovals,
|
|
activity: els.mobileNavActivity,
|
|
admin: els.mobileNavAdmin
|
|
};
|
|
for (const [name, el] of Object.entries(mobileMap)) {
|
|
if (el) {
|
|
el.classList.toggle("active", loggedIn && page === name);
|
|
}
|
|
}
|
|
if (!loggedIn) {
|
|
const clearActive = [
|
|
els.menuRms,
|
|
els.menuSwr,
|
|
els.menuUser,
|
|
els.menuHelp,
|
|
els.menuPlugins,
|
|
els.menuPluginConfig,
|
|
els.menuProviders,
|
|
els.menuUsers,
|
|
els.menuApprovals,
|
|
els.menuActivity,
|
|
els.menuAdmin,
|
|
els.mobileNavRms,
|
|
els.mobileNavSwr,
|
|
els.mobileNavUser,
|
|
els.mobileNavHelp,
|
|
els.mobileNavPlugins,
|
|
els.mobileNavPluginConfig,
|
|
els.mobileNavUsers,
|
|
els.mobileNavApprovals,
|
|
els.mobileNavActivity,
|
|
els.mobileNavAdmin
|
|
];
|
|
for (const el of clearActive) {
|
|
if (el) {
|
|
el.classList.remove("active");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearMessages(scope = "all") {
|
|
if (scope === "all" || scope === "auth") {
|
|
els.authMessage.textContent = "";
|
|
els.authMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "status") {
|
|
els.statusMessage.textContent = "";
|
|
els.statusMessage.className = "message";
|
|
if (els.reservationMessage) {
|
|
els.reservationMessage.textContent = "";
|
|
els.reservationMessage.className = "message";
|
|
}
|
|
if (els.openwebrxMessage) {
|
|
els.openwebrxMessage.textContent = "";
|
|
els.openwebrxMessage.className = "message";
|
|
}
|
|
}
|
|
if (scope === "all" || scope === "swr") {
|
|
if (els.swrSummaryMessage) {
|
|
els.swrSummaryMessage.textContent = "";
|
|
els.swrSummaryMessage.className = "message";
|
|
}
|
|
if (els.swrPageMessage) {
|
|
els.swrPageMessage.textContent = "";
|
|
els.swrPageMessage.className = "message";
|
|
}
|
|
}
|
|
if (scope === "all" || scope === "admin") {
|
|
els.adminMessage.textContent = "";
|
|
els.adminMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "provider") {
|
|
if (els.providersMessage) {
|
|
els.providersMessage.textContent = "";
|
|
els.providersMessage.className = "message";
|
|
}
|
|
}
|
|
if (scope === "all" || scope === "plugin") {
|
|
els.pluginMessage.textContent = "";
|
|
els.pluginMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "users") {
|
|
els.usersMessage.textContent = "";
|
|
els.usersMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "approvals") {
|
|
els.approvalsMessage.textContent = "";
|
|
els.approvalsMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "activity") {
|
|
els.activityMessage.textContent = "";
|
|
els.activityMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "help") {
|
|
if (els.helpMessage) {
|
|
els.helpMessage.textContent = "";
|
|
els.helpMessage.className = "message";
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderMessage(el, text, isError = false, isOk = false) {
|
|
if (!el) return;
|
|
el.textContent = translateLiteral(String(text || ""));
|
|
el.className = "message";
|
|
if (isError) {
|
|
el.classList.add("error");
|
|
}
|
|
if (isOk) {
|
|
el.classList.add("ok");
|
|
}
|
|
}
|
|
|
|
function currentRoute() {
|
|
const path = window.location.pathname;
|
|
if (
|
|
path === "/rms" ||
|
|
path === "/rms/swr" ||
|
|
path === "/rms/user" ||
|
|
path === "/rms/hilfe" ||
|
|
path === "/rms/plugins" ||
|
|
path === "/rms/plugin-konfig" ||
|
|
path === "/rms/providers" ||
|
|
path === "/rms/users" ||
|
|
path === "/rms/freigaben" ||
|
|
path === "/rms/aktivitaet" ||
|
|
path === "/rms/admin"
|
|
) {
|
|
return path;
|
|
}
|
|
return "/login";
|
|
}
|
|
|
|
function applyRoute(replace = false) {
|
|
const desired = state.user ? pageToPath(normalizedRmsPage(currentRmsPage())) : "/login";
|
|
const desiredUrl = state.user
|
|
? `${desired}${routeQueryForPage(normalizedRmsPage(currentRmsPage()))}`
|
|
: "/login";
|
|
const currentUrl = `${window.location.pathname}${window.location.search}`;
|
|
if (currentUrl !== desiredUrl) {
|
|
window.history[replace ? "replaceState" : "pushState"]({}, "", desiredUrl);
|
|
}
|
|
updateUserUi();
|
|
}
|
|
|
|
function currentRmsPage() {
|
|
const path = window.location.pathname;
|
|
if (path === "/rms/user") return "user";
|
|
if (path === "/rms/swr") return "swr";
|
|
if (path === "/rms/hilfe") return "help";
|
|
if (path === "/rms/plugins") return "plugins";
|
|
if (path === "/rms/plugin-konfig") return "plugin-config";
|
|
if (path === "/rms/providers") return "providers";
|
|
if (path === "/rms/users") return "users";
|
|
if (path === "/rms/freigaben") return "approvals";
|
|
if (path === "/rms/aktivitaet") return "activity";
|
|
if (path === "/rms/admin") return "admin";
|
|
return "rms";
|
|
}
|
|
|
|
function normalizedRmsPage(page) {
|
|
if (page === "admin" && !isAdmin()) {
|
|
return "rms";
|
|
}
|
|
if (page === "swr" && !canOperateStation()) {
|
|
return "rms";
|
|
}
|
|
if (page === "users" && !canSeeUsersList()) {
|
|
return "rms";
|
|
}
|
|
if (page === "plugins" && !isAdmin()) {
|
|
return "rms";
|
|
}
|
|
if (page === "plugin-config" && !isAdmin()) {
|
|
return "rms";
|
|
}
|
|
if (page === "providers" && !isAdmin()) {
|
|
return "rms";
|
|
}
|
|
if (page === "approvals" && !canSeeApprovals()) {
|
|
return "rms";
|
|
}
|
|
if (page === "activity" && !canSeeActivityLog()) {
|
|
return "rms";
|
|
}
|
|
return page;
|
|
}
|
|
|
|
function navigateRmsPage(page) {
|
|
if (!state.user) {
|
|
return;
|
|
}
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(false);
|
|
const safePage = normalizedRmsPage(["rms", "swr", "user", "help", "plugins", "plugin-config", "providers", "users", "approvals", "activity", "admin"].includes(page) ? page : "rms");
|
|
window.history.pushState({}, "", `${pageToPath(safePage)}${routeQueryForPage(safePage)}`);
|
|
updateUserUi();
|
|
}
|
|
|
|
function pageToPath(page) {
|
|
if (page === "swr") return "/rms/swr";
|
|
if (page === "user") return "/rms/user";
|
|
if (page === "help") return "/rms/hilfe";
|
|
if (page === "plugins") return "/rms/plugins";
|
|
if (page === "plugin-config") return "/rms/plugin-konfig";
|
|
if (page === "providers") return "/rms/providers";
|
|
if (page === "users") return "/rms/users";
|
|
if (page === "approvals") return "/rms/freigaben";
|
|
if (page === "activity") return "/rms/aktivitaet";
|
|
if (page === "admin") return "/rms/admin";
|
|
return "/rms";
|
|
}
|
|
|
|
function routeQueryForPage(page) {
|
|
const params = new URLSearchParams();
|
|
if (page === "users") {
|
|
if (state.usersFilter.query) params.set("uq", state.usersFilter.query);
|
|
if (state.usersFilter.role !== "all") params.set("ur", state.usersFilter.role);
|
|
if (state.usersFilter.status !== "all") params.set("us", state.usersFilter.status);
|
|
}
|
|
if (page === "approvals") {
|
|
if (state.approvalsFilter.mode !== "open") params.set("am", state.approvalsFilter.mode);
|
|
if (state.approvalsFilter.query) params.set("aq", state.approvalsFilter.query);
|
|
if (state.approvalsFilter.status !== "all") params.set("as", state.approvalsFilter.status);
|
|
}
|
|
if (page === "activity") {
|
|
if (state.activityFilter.query) params.set("lq", state.activityFilter.query);
|
|
if (state.activityFilter.type !== "all") params.set("lt", state.activityFilter.type);
|
|
}
|
|
const query = params.toString();
|
|
return query ? `?${query}` : "";
|
|
}
|
|
|
|
function updateRouteQueryForCurrentPage() {
|
|
if (!state.user) {
|
|
return;
|
|
}
|
|
const page = normalizedRmsPage(currentRmsPage());
|
|
const next = `${pageToPath(page)}${routeQueryForPage(page)}`;
|
|
const current = `${window.location.pathname}${window.location.search}`;
|
|
if (current !== next) {
|
|
window.history.replaceState({}, "", next);
|
|
}
|
|
}
|
|
|
|
function hydrateFilterStateFromUrl() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
state.usersFilter.query = String(params.get("uq") || "").trim().toLowerCase();
|
|
state.usersFilter.role = normalizeFilterValue(params.get("ur"), ["all", "admin", "approver", "operator"], "all");
|
|
state.usersFilter.status = normalizeFilterValue(
|
|
params.get("us"),
|
|
["all", "active", "pending_approval", "pending_verification", "denied"],
|
|
"all"
|
|
);
|
|
|
|
state.approvalsFilter.mode = normalizeFilterValue(params.get("am"), ["open", "all"], "open");
|
|
state.approvalsFilter.query = String(params.get("aq") || "").trim().toLowerCase();
|
|
state.approvalsFilter.status = normalizeFilterValue(params.get("as"), ["all", "pending", "approved", "rejected"], "all");
|
|
|
|
state.activityFilter.query = String(params.get("lq") || "").trim().toLowerCase();
|
|
state.activityFilter.type = normalizeFilterValue(
|
|
params.get("lt"),
|
|
[
|
|
"all",
|
|
"auth.request_access",
|
|
"station.activate.start",
|
|
"station.activate.done",
|
|
"station.activate.failed",
|
|
"station.deactivate",
|
|
"station.deactivate.timeout"
|
|
],
|
|
"all"
|
|
);
|
|
|
|
syncFilterInputsFromState();
|
|
}
|
|
|
|
function syncFilterInputsFromState() {
|
|
if (els.usersFilterQuery) els.usersFilterQuery.value = state.usersFilter.query;
|
|
if (els.usersFilterRole) els.usersFilterRole.value = state.usersFilter.role;
|
|
if (els.usersFilterStatus) els.usersFilterStatus.value = state.usersFilter.status;
|
|
if (els.approvalsFilterQuery) els.approvalsFilterQuery.value = state.approvalsFilter.query;
|
|
if (els.approvalsFilterStatus) els.approvalsFilterStatus.value = state.approvalsFilter.status;
|
|
if (els.activityFilterQuery) els.activityFilterQuery.value = state.activityFilter.query;
|
|
if (els.activityFilterType) els.activityFilterType.value = state.activityFilter.type;
|
|
}
|
|
|
|
function normalizeFilterValue(value, allowed, fallback) {
|
|
return allowed.includes(value) ? value : fallback;
|
|
}
|
|
|
|
function formatRemainingUsage(totalSec) {
|
|
const sec = Math.max(0, Math.floor(Number(totalSec || 0)));
|
|
const hours = Math.floor(sec / 3600);
|
|
const minutes = Math.floor((sec % 3600) / 60);
|
|
const seconds = sec % 60;
|
|
if (hours > 0) {
|
|
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
}
|
|
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
}
|
|
|
|
function withCacheVersion(url, version) {
|
|
const rawUrl = String(url || "").trim();
|
|
if (!rawUrl) {
|
|
return "";
|
|
}
|
|
const rawVersion = String(version || "").trim();
|
|
if (!rawVersion) {
|
|
return rawUrl;
|
|
}
|
|
const separator = rawUrl.includes("?") ? "&" : "?";
|
|
return `${rawUrl}${separator}v=${encodeURIComponent(rawVersion)}`;
|
|
}
|
|
|
|
function setUserMenuOpen(open) {
|
|
const effective = Boolean(open && state.user);
|
|
els.userMenu.hidden = !effective;
|
|
els.userMenuButton.setAttribute("aria-expanded", effective ? "true" : "false");
|
|
els.userMenuButton.textContent = "☰";
|
|
if (state.user) {
|
|
els.userMenuButton.setAttribute("aria-label", effective ? `Menue schliessen (${state.user.email})` : `Menue oeffnen (${state.user.email})`);
|
|
} else {
|
|
els.userMenuButton.setAttribute("aria-label", effective ? "Menue schliessen" : "Menue oeffnen");
|
|
}
|
|
}
|
|
|
|
function setLanguageMenuOpen(open) {
|
|
if (!els.languageMenu || !els.languageMenuButton) {
|
|
return;
|
|
}
|
|
const effective = Boolean(open);
|
|
els.languageMenu.hidden = !effective;
|
|
els.languageMenuButton.setAttribute("aria-expanded", effective ? "true" : "false");
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const current = document.documentElement.dataset.theme || "dark";
|
|
const next = current === "dark" ? "light" : "dark";
|
|
document.documentElement.dataset.theme = next;
|
|
localStorage.setItem("arcg-theme", next);
|
|
updateThemeToggleIcon();
|
|
renderBranding();
|
|
}
|
|
|
|
function loadTheme() {
|
|
const saved = localStorage.getItem("arcg-theme");
|
|
document.documentElement.dataset.theme = saved === "light" ? "light" : "dark";
|
|
updateThemeToggleIcon();
|
|
renderBranding();
|
|
}
|
|
|
|
function updateThemeToggleIcon() {
|
|
if (!els.themeToggle) {
|
|
return;
|
|
}
|
|
const theme = document.documentElement.dataset.theme || "dark";
|
|
if (theme === "dark") {
|
|
els.themeToggle.textContent = "☀";
|
|
els.themeToggle.setAttribute("aria-label", "Zu Light Mode wechseln");
|
|
return;
|
|
}
|
|
els.themeToggle.textContent = "☾";
|
|
els.themeToggle.setAttribute("aria-label", "Zu Dark Mode wechseln");
|
|
}
|
|
|
|
function isAdmin() {
|
|
return Boolean(state.user && state.user.role === "admin");
|
|
}
|
|
|
|
function canOperateStation() {
|
|
return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver" || state.user.role === "operator"));
|
|
}
|
|
|
|
function canSeeApprovals() {
|
|
return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver"));
|
|
}
|
|
|
|
function canSeeUsersList() {
|
|
return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver"));
|
|
}
|
|
|
|
function canSeeActivityLog() {
|
|
return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver"));
|
|
}
|
|
|
|
function setTokens(accessToken, refreshToken) {
|
|
state.accessToken = accessToken || "";
|
|
state.refreshToken = refreshToken || "";
|
|
if (state.accessToken) {
|
|
localStorage.setItem("rms-access-token", state.accessToken);
|
|
}
|
|
if (state.refreshToken) {
|
|
localStorage.setItem("rms-refresh-token", state.refreshToken);
|
|
}
|
|
}
|
|
|
|
function clearTokens() {
|
|
state.accessToken = "";
|
|
state.refreshToken = "";
|
|
localStorage.removeItem("rms-access-token");
|
|
localStorage.removeItem("rms-refresh-token");
|
|
}
|
|
|
|
let eventSource = null;
|
|
let eventReconnectTimer = null;
|
|
let openWebRxTxPollTimer = null;
|
|
let openWebRxTxPollInFlight = false;
|
|
let openWebRxTxPollIntervalMs = null;
|
|
|
|
function connectEvents() {
|
|
if (!state.accessToken) {
|
|
return;
|
|
}
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
if (eventReconnectTimer) {
|
|
clearTimeout(eventReconnectTimer);
|
|
eventReconnectTimer = null;
|
|
}
|
|
eventSource = new EventSource(`/v1/events/stream?accessToken=${encodeURIComponent(state.accessToken)}`);
|
|
eventSource.onmessage = () => {};
|
|
eventSource.onerror = async () => {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
const refreshed = await tryRefreshToken({ reason: "sse" });
|
|
if (!state.user || (!state.accessToken && !refreshed)) {
|
|
return;
|
|
}
|
|
if (eventReconnectTimer) {
|
|
clearTimeout(eventReconnectTimer);
|
|
}
|
|
eventReconnectTimer = setTimeout(() => {
|
|
connectEvents();
|
|
}, 2000);
|
|
};
|
|
eventSource.addEventListener("station.status.changed", async () => {
|
|
await refreshStatus();
|
|
if (canSeeActivityLog()) {
|
|
await refreshActivityLog();
|
|
}
|
|
});
|
|
eventSource.addEventListener("station.activation.progress", async () => {
|
|
await refreshStatus();
|
|
});
|
|
eventSource.addEventListener("station.activation.completed", async (event) => {
|
|
activationPending = false;
|
|
stopActivationWatch();
|
|
let reportFromEvent = null;
|
|
try {
|
|
const payload = JSON.parse(event.data || "{}");
|
|
if (payload && payload.swrReport && typeof payload.swrReport === "object") {
|
|
reportFromEvent = payload.swrReport;
|
|
}
|
|
} catch {
|
|
// ignore malformed payload
|
|
}
|
|
if (reportFromEvent) {
|
|
state.swrReport = reportFromEvent;
|
|
renderSwrPanels();
|
|
}
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
const overall = state.swrReport && state.swrReport.overallStatus ? state.swrReport.overallStatus : "UNKNOWN";
|
|
const doneText = `SWR-Check ERFOLGREICH abgeschlossen. Ergebnis: ${overall}.`;
|
|
renderMessage(els.swrSummaryMessage, doneText, false, true);
|
|
renderMessage(els.swrPageMessage, doneText, false, true);
|
|
await refreshControls();
|
|
if (canSeeActivityLog()) {
|
|
await refreshActivityLog();
|
|
}
|
|
});
|
|
eventSource.addEventListener("station.activation.failed", async (event) => {
|
|
activationPending = false;
|
|
stopActivationWatch();
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
let errorText = "Unbekannter Fehler";
|
|
try {
|
|
const payload = JSON.parse(event.data || "{}");
|
|
if (payload && payload.error) {
|
|
errorText = String(payload.error);
|
|
}
|
|
} catch {
|
|
// ignore parse issues
|
|
}
|
|
const failText = `SWR-Check fertig, Aktivierung fehlgeschlagen: ${errorText}`;
|
|
renderMessage(els.swrSummaryMessage, failText, true);
|
|
renderMessage(els.swrPageMessage, failText, true);
|
|
await refreshControls();
|
|
});
|
|
eventSource.addEventListener("swr.run.started", async () => {
|
|
await refreshStatus();
|
|
});
|
|
eventSource.addEventListener("swr.run.finished", async () => {
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
});
|
|
eventSource.addEventListener("swr.report.changed", async () => {
|
|
await refreshSwrReport();
|
|
});
|
|
eventSource.addEventListener("plugin.provider.changed", async () => {
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
});
|
|
eventSource.addEventListener("plugin.enabled.changed", async () => {
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
});
|
|
eventSource.addEventListener("plugin.health.changed", async () => {
|
|
await refreshPlugins();
|
|
});
|
|
eventSource.addEventListener("approval.status.changed", async () => {
|
|
await refreshApprovals();
|
|
await refreshUsers();
|
|
if (canSeeActivityLog()) {
|
|
await refreshActivityLog();
|
|
}
|
|
});
|
|
eventSource.addEventListener("system.maintenance.enabled", async (event) => {
|
|
const payload = JSON.parse(event.data || "{}");
|
|
state.system.maintenanceMode = true;
|
|
state.system.maintenanceMessage = payload.message || state.system.maintenanceMessage;
|
|
if (!isAdmin()) {
|
|
await logout(true, true);
|
|
return;
|
|
}
|
|
renderMaintenanceBanner();
|
|
});
|
|
eventSource.addEventListener("system.maintenance.disabled", async () => {
|
|
state.system.maintenanceMode = false;
|
|
await refreshPublicSystemStatus();
|
|
});
|
|
eventSource.addEventListener("branding.updated", async () => {
|
|
await refreshPublicSystemStatus();
|
|
});
|
|
}
|
|
|
|
async function api(path, options = {}, triedRefresh = false) {
|
|
const headers = {
|
|
"Content-Type": "application/json"
|
|
};
|
|
const authRequired = options.authRequired !== false;
|
|
if (authRequired && state.accessToken) {
|
|
headers.Authorization = `Bearer ${state.accessToken}`;
|
|
}
|
|
|
|
const response = await fetch(path, {
|
|
method: options.method || "GET",
|
|
headers,
|
|
body: options.body ? JSON.stringify(options.body) : undefined
|
|
});
|
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (response.status === 401 && authRequired && !triedRefresh && state.refreshToken) {
|
|
const refreshed = await tryRefreshToken();
|
|
if (refreshed) {
|
|
return api(path, options, true);
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = new Error((payload.error && payload.error.message) || `Request failed: ${response.status}`);
|
|
err.status = response.status;
|
|
err.code = payload && payload.error ? payload.error.code : undefined;
|
|
throw err;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function tryRefreshToken(options = {}) {
|
|
const reason = String(options.reason || "api");
|
|
if (!state.refreshToken) {
|
|
return false;
|
|
}
|
|
try {
|
|
const result = await api("/v1/auth/refresh", {
|
|
method: "POST",
|
|
body: { refreshToken: state.refreshToken },
|
|
authRequired: false
|
|
}, true);
|
|
setTokens(result.accessToken, result.refreshToken);
|
|
state.user = result.user;
|
|
updateUserUi();
|
|
return true;
|
|
} catch {
|
|
if (reason !== "sse") {
|
|
clearTokens();
|
|
state.user = null;
|
|
updateUserUi();
|
|
}
|
|
return false;
|
|
}
|
|
}
|