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; 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"), email: document.getElementById("email"), loginBtn: document.getElementById("loginBtn"), authMethodSelect: document.getElementById("authMethodSelect"), otpWrap: document.getElementById("otpWrap"), otpCode: document.getElementById("otpCode"), verifyOtpBtn: document.getElementById("verifyOtpBtn"), authMessage: document.getElementById("authMessage"), 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(); }); 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.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 = els.email.value.trim(); 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"); try { const result = await api("/v1/auth/verify-email", { method: "POST", body: { email: els.email.value.trim(), 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 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 "); } 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) { els.email.value = 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); } } 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() }; } 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; state.openWebRx.sessionUrl = session && session.iframeUrl ? session.iframeUrl : ""; state.openWebRx.sessionTicket = session && session.ticket ? String(session.ticket) : ""; 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 sessionUrl = session && session.iframeUrl ? String(session.iframeUrl) : ""; if (!sessionUrl) { throw new Error("OpenWebRX Session konnte nicht erstellt werden"); } state.openWebRx.sessionUrl = sessionUrl; state.openWebRx.sessionTicket = session && session.ticket ? String(session.ticket) : ""; 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); } } 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 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 = [ '', '', '' ].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 || ""}`; 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 ""; } 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.email, 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; } }