Files
ARCG-Remote-Station-Software/public/app.js
OE6DXD 7465c63b97 add OAuth defaults and admin reservation deletion support
Seed rms.auth.oauth plugin settings with a Google OIDC example while keeping the plugin disabled by default, add admin API/UI support to delete individual reservation entries, and extend auth flow handling for OAuth callback redirects and errors.
2026-03-16 13:11:17 +01:00

4888 lines
162 KiB
JavaScript

const OPENWEBRX_TX_POLL_MS_DEFAULT = 3000;
const SWR_EXPECTED_DURATION_SEC_DEFAULT = 40;
const SWR_DETAIL_REFRESH_MS = 20000;
const DEFAULT_UI_LANGUAGE = "de";
const SUPPORTED_UI_LANGUAGES = ["de", "en"];
const LANGUAGE_STORAGE_KEY = "rms-lang";
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
}
},
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"),
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"),
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();
});
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.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();
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);
}
}
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)
};
} catch {
state.system = {
maintenanceMode: false,
maintenanceMessage: "",
updatedAt: null,
branding: normalizeBranding(null)
};
}
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 || "";
}
}
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);
}
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);
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);
}
}
function renderStatus() {
const status = state.status;
if (!status) {
stopRemainingUsageWatch();
if (els.remainingUsage) {
els.remainingUsage.textContent = "-";
}
return;
}
const activationRunning = Boolean(status.activation && status.activation.running);
els.stationName.textContent = status.stationName || "-";
els.usageStatus.textContent = status.isInUse
? translateLiteral("In Benutzung")
: (activationRunning ? translateLiteral("Aktivierung laeuft") : translateLiteral("Frei"));
els.activeBy.textContent = status.activeByEmail || "-";
els.startedAt.textContent = status.startedAt ? new Date(status.startedAt).toLocaleString(localeForDate()) : "-";
els.endsAt.textContent = status.endsAt ? new Date(status.endsAt).toLocaleString(localeForDate()) : "-";
renderRemainingUsage();
if (status.isInUse || activationRunning) {
startRemainingUsageWatch();
} else {
stopRemainingUsageWatch();
}
els.stationOnlinePill.textContent = status.stationOnline ? "Online" : "Offline";
els.stationOnlinePill.classList.toggle("ok", Boolean(status.stationOnline));
els.stationOnlinePill.classList.toggle("offline", !status.stationOnline);
if (status.maintenanceMode) {
renderMessage(els.statusMessage, status.maintenanceMessage || "Wartungsmodus aktiv", true);
}
const swrRun = status.swrRun || status.activation;
renderActivationProgress(swrRun);
renderReservationQueue(status);
renderStationLinks(status);
renderOpenWebRx(status);
const loggedIn = Boolean(state.user);
const swrRunning = Boolean(swrRun && swrRun.running);
const isOwner = loggedIn && state.user.email === status.activeByEmail;
const activeReservation = status.reservationQueue && status.reservationQueue.activeEntry
? status.reservationQueue.activeEntry
: null;
const slotLockActive = Boolean(status.reservationQueue && status.reservationQueue.slotLockActive && activeReservation);
const isSlotOwner = loggedIn
&& activeReservation
&& state.user
&& state.user.id
&& String(state.user.id) === String(activeReservation.userId || "");
const slotDenied = slotLockActive && !isSlotOwner && !isAdmin();
const canOperate = canOperateStation();
els.activateBtn.disabled = !loggedIn || !canOperate || activationRunning || swrRunning || !status.stationOnline || Boolean(status.isInUse) || Boolean(status.maintenanceMode) || slotDenied;
els.deactivateBtn.disabled = !loggedIn || !status.isInUse || (!isOwner && !isAdmin()) || slotDenied;
setSwrRunButtonsBusy(false);
}
function startRemainingUsageWatch() {
if (remainingUsageTimer) {
return;
}
remainingUsageTimer = setInterval(() => {
renderRemainingUsage();
}, 1000);
}
function stopRemainingUsageWatch() {
if (!remainingUsageTimer) {
return;
}
clearInterval(remainingUsageTimer);
remainingUsageTimer = null;
}
function renderRemainingUsage() {
if (!els.remainingUsage) {
return;
}
const status = state.status;
const activationRunning = Boolean(status && status.activation && status.activation.running);
if (!status || (!status.isInUse && !activationRunning)) {
els.remainingUsage.textContent = "-";
return;
}
const endsAtMs = Date.parse(String(status.endsAt || ""));
if (Number.isFinite(endsAtMs)) {
const remainingSec = Math.max(0, Math.ceil((endsAtMs - Date.now()) / 1000));
els.remainingUsage.textContent = formatRemainingUsage(remainingSec);
return;
}
els.remainingUsage.textContent = formatRemainingUsage(Math.max(0, Number(status.remainingUsageSec || 0)));
}
function renderReservationQueue(status) {
if (!els.reservationPanel || !els.reserveNextBtn || !els.reservationList) {
return;
}
const queue = status && status.reservationQueue && typeof status.reservationQueue === "object"
? status.reservationQueue
: { entries: [], canReserve: false };
const entries = Array.isArray(queue.entries) ? queue.entries : [];
const visible = Boolean(queue.visible);
const loggedIn = Boolean(state.user);
const canOperate = canOperateStation();
const isOwner = loggedIn && status && state.user && state.user.id && String(state.user.id) === String(status.activeByUserId || "");
const hasOwnReservation = loggedIn
&& state.user
&& entries.some((entry) => String(entry && entry.userId ? entry.userId : "") === String(state.user.id || ""));
els.reservationPanel.hidden = !visible;
els.reserveNextBtn.disabled = !loggedIn || !canOperate || !queue.canReserve || isOwner || hasOwnReservation;
els.reservationList.innerHTML = "";
if (!visible) {
return;
}
if (!entries.length) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = translateLiteral("Noch keine Reservierungen vorhanden.");
els.reservationList.appendChild(empty);
return;
}
const list = document.createElement("div");
list.className = "swr-summary-list";
let hasMineEntry = false;
entries.forEach((entry) => {
const row = document.createElement("div");
row.className = `swr-summary-row reservation-row${entry.active ? " reservation-row-active" : ""}`;
const left = document.createElement("div");
left.className = "stack";
const title = document.createElement("strong");
title.textContent = `#${Number(entry.position || 0)} ${entry.email || "-"}`;
const details = document.createElement("small");
details.className = "muted";
const fromText = entry.from
? new Date(entry.from).toLocaleString(localeForDate(), {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
: "-";
const toText = entry.to
? new Date(entry.to).toLocaleString(localeForDate(), {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
: "-";
details.textContent = `${fromText} - ${toText}`;
const pill = document.createElement("span");
pill.className = `pill${entry.active ? " ok" : ""}`;
if (entry.active) {
pill.textContent = translateLiteral("Aktiv");
} else {
const fromLabel = entry.from
? new Date(entry.from).toLocaleTimeString(localeForDate(), { hour: "2-digit", minute: "2-digit" })
: "-";
const toLabel = entry.to
? new Date(entry.to).toLocaleTimeString(localeForDate(), { hour: "2-digit", minute: "2-digit" })
: "-";
pill.textContent = `${fromLabel} - ${toLabel}`;
}
left.appendChild(title);
left.appendChild(details);
const right = document.createElement("div");
right.className = "actions";
right.appendChild(pill);
if (isAdmin() && entry && entry.userId) {
const adminDeleteBtn = document.createElement("button");
adminDeleteBtn.type = "button";
adminDeleteBtn.className = "ghost-btn danger";
adminDeleteBtn.textContent = translateLiteral("Reservierung loeschen");
adminDeleteBtn.title = translateLiteral("Reservierung dieses Benutzers loeschen");
adminDeleteBtn.addEventListener("click", async () => {
await cancelReservationByUserId(entry.userId);
});
right.appendChild(adminDeleteBtn);
}
row.appendChild(left);
row.appendChild(right);
list.appendChild(row);
const isMine = loggedIn
&& state.user
&& String(entry.userId || "") === String(state.user.id || "");
hasMineEntry = hasMineEntry || isMine;
});
els.reservationList.appendChild(list);
if (hasMineEntry) {
const removeWrap = document.createElement("div");
removeWrap.className = "actions";
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "ghost-btn danger";
removeBtn.textContent = translateLiteral("Meine Reservierung loeschen");
removeBtn.addEventListener("click", async () => {
await cancelOwnReservation();
});
removeWrap.appendChild(removeBtn);
els.reservationList.appendChild(removeWrap);
}
}
function renderOpenWebRx(status) {
if (!els.openwebrxPanel) {
return;
}
const loggedIn = Boolean(state.user);
const isOwner = loggedIn && status && status.isInUse && state.user.id && state.user.id === status.activeByUserId;
els.openwebrxPanel.hidden = !isOwner;
if (els.controlsPanel) {
els.controlsPanel.hidden = !isOwner;
}
if (!isOwner) {
stopOpenWebRxTxPolling();
state.openWebRx.sessionUrl = "";
state.openWebRx.sessionTicket = "";
state.openWebRx.expiresAt = null;
state.openWebRx.bands = [];
state.openWebRx.selectedBand = "";
state.openWebRx.txActive = null;
state.openWebRx.rotor = {
azimuth: null,
moving: false,
min: 0,
max: 360
};
syncOpenWebRxTicketCookie("");
renderOpenWebRxBandOptions();
renderOpenWebRxTxState();
renderRotorState();
renderOpenWebRxSessionAccess();
return;
}
startOpenWebRxTxPolling();
if (els.openwebrxOpenBtn) {
els.openwebrxOpenBtn.disabled = Boolean(status.activation && status.activation.running) || state.openWebRx.busy;
}
setOpenWebRxBusy(state.openWebRx.busy);
}
function renderActivationProgress(activation) {
const running = Boolean(activation && activation.running);
renderSingleActivationProgress({
box: els.activationProgress,
fill: els.progressFill,
text: els.progressText,
eta: els.progressEta
}, activation, running);
renderSingleActivationProgress({
box: els.activationProgressSwr,
fill: els.progressFillSwr,
text: els.progressTextSwr,
eta: els.progressEtaSwr
}, activation, running);
if (!running) {
stopActivationWatch();
return;
}
startActivationWatch();
}
function renderSingleActivationProgress(target, activation, running) {
if (!target || !target.box || !target.fill || !target.text || !target.eta) {
return;
}
target.box.hidden = !running;
if (!running) {
target.fill.style.width = "0%";
target.text.textContent = "-";
target.eta.textContent = "Geschaetzte Restzeit: -";
return;
}
const percent = Number(activation.percent || 0);
const elapsedSec = Number(activation.elapsedSec || 0);
const remainingSec = Number(activation.remainingSec || 0);
const phase = String(activation.phase || "swr-check");
target.fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
target.text.textContent = `${elapsedSec}s`;
target.eta.textContent = `SWR-Status: ${phase}${remainingSec > 0 ? `, noch ca. ${remainingSec}s` : ""}`;
}
function startActivationWatch() {
if (activationWatchTimer) {
return;
}
activationWatchTimer = setInterval(async () => {
if (activationWatchInFlight || !state.user) {
return;
}
activationWatchInFlight = true;
try {
await refreshStatus();
const activation = state.status && state.status.activation ? state.status.activation : null;
const swrRun = state.status && state.status.swrRun ? state.status.swrRun : null;
if (activation && activation.running && String(activation.phase || "") !== "swr-check") {
clearActivationSwrWaitingMessages();
}
await refreshSwrReport();
if (!(swrRun && swrRun.running)) {
if (activationPending && !(state.status && state.status.isInUse)) {
const activation = state.status && state.status.activation ? state.status.activation : null;
const reason = activation && activation.lastStatus === "failed" && activation.lastError
? `: ${activation.lastError}`
: ". Bitte Logs pruefen.";
const failText = `SWR-Check fertig, Aktivierung fehlgeschlagen${reason}`;
renderMessage(els.swrSummaryMessage, failText, true);
renderMessage(els.swrPageMessage, failText, true);
}
activationPending = false;
stopActivationWatch();
await refreshControls();
}
} catch {
// ignore watch errors
} finally {
activationWatchInFlight = false;
}
}, 1000);
}
function stopActivationWatch() {
if (!activationWatchTimer) {
return;
}
clearInterval(activationWatchTimer);
activationWatchTimer = null;
}
function clearActivationSwrWaitingMessages() {
const waitingPrefix = "SWR-Messung laeuft";
const targets = [els.swrSummaryMessage, els.swrPageMessage];
for (const target of targets) {
if (!target || typeof target.textContent !== "string") {
continue;
}
if (target.textContent.trim().startsWith(waitingPrefix)) {
target.textContent = "";
target.className = "message";
}
}
}
function renderStationLinks(status) {
if (!els.stationLinks) {
return;
}
const links = status && status.links ? status.links : {};
const ready = Boolean(status && status.linksReady);
const hasAny = Boolean(links.swrOverview || links.openWebRxPath || links.webSdr || links.rotorControl);
els.stationLinks.hidden = !(ready && hasAny);
setLink(els.swrLink, links.swrOverview);
setLink(els.openwebrxLink, links.openWebRxPath || null);
setLink(els.websdrLink, links.webSdr);
setLink(els.rotorLink, links.rotorControl);
}
function setLink(el, href) {
if (!el) {
return;
}
if (!href) {
el.hidden = true;
el.removeAttribute("href");
return;
}
el.hidden = false;
el.href = href;
}
function renderPluginControls() {
els.pluginControls.innerHTML = "";
if (!state.controls.length) {
els.pluginControls.textContent = "Keine dynamischen Controls verfuegbar.";
return;
}
for (const control of state.controls) {
if (control.controlId === "station-main") {
continue;
}
const wrapper = document.createElement("div");
wrapper.className = "card";
wrapper.style.padding = "0.8rem";
const title = document.createElement("h3");
title.textContent = control.title;
wrapper.appendChild(title);
const status = document.createElement("p");
status.className = "muted";
status.textContent = JSON.stringify(control.status || {});
wrapper.appendChild(status);
const actions = document.createElement("div");
actions.className = "schema-form";
for (const action of control.actions || []) {
const form = document.createElement("form");
form.className = "schema-form";
const formFields = renderActionFields(form, action.inputSchema || {});
const submit = document.createElement("button");
submit.type = "submit";
submit.textContent = action.name;
form.appendChild(submit);
form.addEventListener("submit", async (event) => {
event.preventDefault();
const input = readActionInput(formFields);
await executePluginAction(control, action, input);
});
actions.appendChild(form);
}
wrapper.appendChild(actions);
els.pluginControls.appendChild(wrapper);
}
}
function renderActionFields(form, schema) {
const fields = [];
const properties = schema && schema.properties ? schema.properties : {};
for (const [name, fieldSchema] of Object.entries(properties)) {
const label = document.createElement("label");
label.className = "field";
const caption = document.createElement("span");
caption.textContent = name;
label.appendChild(caption);
const required = Array.isArray(schema.required) && schema.required.includes(name);
if (Array.isArray(fieldSchema.enum)) {
const select = document.createElement("select");
select.name = name;
select.required = required;
for (const optionValue of fieldSchema.enum) {
const option = document.createElement("option");
option.value = String(optionValue);
option.textContent = String(optionValue);
select.appendChild(option);
}
label.appendChild(select);
fields.push({ name, type: fieldSchema.type || "string", element: select });
} else {
const input = document.createElement("input");
input.name = name;
input.required = required;
if (fieldSchema.type === "number" || fieldSchema.type === "integer") {
input.type = "number";
if (fieldSchema.type === "integer") input.step = "1";
if (fieldSchema.minimum !== undefined) input.min = String(fieldSchema.minimum);
if (fieldSchema.maximum !== undefined) input.max = String(fieldSchema.maximum);
} else if (fieldSchema.type === "boolean") {
input.type = "checkbox";
} else {
input.type = "text";
}
if (fieldSchema.default !== undefined) {
if (fieldSchema.type === "boolean") {
input.checked = Boolean(fieldSchema.default);
} else {
input.value = String(fieldSchema.default);
}
}
label.appendChild(input);
fields.push({ name, type: fieldSchema.type || "string", element: input });
}
form.appendChild(label);
}
return fields;
}
function readActionInput(fields) {
const input = {};
for (const field of fields) {
if (!field || !field.element) {
continue;
}
if (field.type === "boolean" && !field.hasSavedValue && !field.dirty && !field.element.checked) {
continue;
}
const raw = field.type === "boolean" ? String(field.element.checked) : field.element.value;
if (raw === "") {
if (field.dirty && (field.type === "string" || !field.type)) {
input[field.name] = "";
}
continue;
}
if (field.type === "number" || field.type === "integer") {
const parsed = Number(raw);
input[field.name] = field.type === "integer" ? Math.trunc(parsed) : parsed;
} else if (field.type === "boolean") {
input[field.name] = field.element.checked;
} else {
input[field.name] = raw;
}
}
return input;
}
async function executePluginAction(control, action, input) {
clearMessages("plugin");
try {
await api(`/v1/ui/controls/${encodeURIComponent(control.controlId)}/actions/${encodeURIComponent(action.name)}`, {
method: "POST",
body: { input }
});
renderMessage(els.pluginMessage, `${control.title}: ${action.name} ausgefuehrt`, false, true);
await refreshStatus();
await refreshControls();
} catch (error) {
renderMessage(els.pluginMessage, error.message, true);
}
}
function renderPluginAdmin() {
renderPluginAdminInto(els.pluginsAdminConfig);
}
function renderPluginAdminInto(container) {
if (!container) {
return;
}
container.innerHTML = "";
if (!isAdmin()) {
return;
}
if (!state.plugins.length) {
container.textContent = "Keine Plugins gefunden.";
return;
}
for (const plugin of state.plugins) {
const block = document.createElement("div");
block.className = "plugin-block";
const head = document.createElement("div");
head.className = "section-head";
const title = document.createElement("h3");
title.textContent = `${plugin.name} (${plugin.id})`;
head.appendChild(title);
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = plugin.enabled ? "danger" : "";
toggle.textContent = plugin.enabled ? "Deaktivieren" : "Aktivieren";
toggle.addEventListener("click", async () => {
await togglePlugin(plugin.id, plugin.enabled);
});
head.appendChild(toggle);
block.appendChild(head);
const caps = document.createElement("p");
caps.className = "muted";
caps.textContent = `Capabilities: ${(plugin.capabilities || []).join(", ") || "-"}`;
block.appendChild(caps);
const settingsSchema = plugin.settingsSchema || { type: "object", properties: {} };
const hasSettings = settingsSchema && settingsSchema.properties && Object.keys(settingsSchema.properties).length > 0;
if (hasSettings) {
const settingsTitle = document.createElement("span");
settingsTitle.className = "muted";
settingsTitle.textContent = "Plugin Settings";
block.appendChild(settingsTitle);
const settingsForm = document.createElement("form");
settingsForm.className = "schema-form";
const settingFields = renderSchemaForm(settingsForm, settingsSchema, plugin.settings || {});
maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin);
const save = document.createElement("button");
save.type = "submit";
save.textContent = "Settings speichern";
settingsForm.appendChild(save);
settingsForm.addEventListener("submit", async (event) => {
event.preventDefault();
await savePluginSettings(plugin.id, settingFields);
});
block.appendChild(settingsForm);
}
container.appendChild(block);
}
}
function maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin) {
if (!settingsForm || !Array.isArray(settingFields) || !plugin || plugin.id !== "rms.microham") {
return;
}
const extraArgsField = settingFields.find((field) => field && field.name === "audioFfmpegExtraArgs" && field.element);
if (!extraArgsField || !extraArgsField.element) {
return;
}
const presets = {
flat: {
hp: 250,
lp: 2800,
mids: 0,
presence: 0,
deesserEnabled: false,
deesserFreq: 5200,
deesserCut: 0,
compressorEnabled: false,
compressorThreshold: -18,
compressorRatio: 2.5,
gateEnabled: false,
gateThreshold: -52,
gateRelease: 180,
limiter: true
},
dx: {
hp: 360,
lp: 2500,
mids: 1.0,
presence: 1.8,
deesserEnabled: true,
deesserFreq: 5200,
deesserCut: 2.0,
compressorEnabled: true,
compressorThreshold: -20,
compressorRatio: 3.0,
gateEnabled: false,
gateThreshold: -50,
gateRelease: 160,
limiter: true
},
ragchew: {
hp: 300,
lp: 2800,
mids: 0.5,
presence: 1.0,
deesserEnabled: true,
deesserFreq: 5000,
deesserCut: 1.2,
compressorEnabled: true,
compressorThreshold: -22,
compressorRatio: 2.2,
gateEnabled: true,
gateThreshold: -55,
gateRelease: 220,
limiter: true
}
};
const parsed = parseMicrohamEqArgs(extraArgsField.element.value);
const initial = parsed || presets.flat;
const wrap = document.createElement("div");
wrap.className = "plugin-eq-builder";
const title = document.createElement("strong");
title.textContent = "TX Audio EQ";
wrap.appendChild(title);
const hint = document.createElement("small");
hint.className = "muted";
hint.textContent = parsed
? "EQ aus aktuellen Extra-Args geladen"
: "Preset/Regler schreiben den FFmpeg-EQ in audioFfmpegExtraArgs";
wrap.appendChild(hint);
const presetRow = document.createElement("div");
presetRow.className = "plugin-eq-row";
const presetLabel = document.createElement("span");
presetLabel.className = "muted";
presetLabel.textContent = "Preset";
presetRow.appendChild(presetLabel);
const presetSelect = document.createElement("select");
presetSelect.innerHTML = [
'<option value="flat">Flat - neutral</option>',
'<option value="dx">DX - durchsetzungsstark</option>',
'<option value="ragchew">Ragchew - angenehmer Klang</option>'
].join("");
presetRow.appendChild(presetSelect);
const presetBtn = document.createElement("button");
presetBtn.type = "button";
presetBtn.className = "ghost-btn";
presetBtn.textContent = "Preset anwenden";
presetRow.appendChild(presetBtn);
wrap.appendChild(presetRow);
const presetInfo = document.createElement("small");
presetInfo.className = "muted";
wrap.appendChild(presetInfo);
const hpControl = createEqSliderControl("Highpass (Hz)", 80, 600, 10, initial.hp);
const lpControl = createEqSliderControl("Lowpass (Hz)", 1800, 4000, 10, initial.lp);
const midsControl = createEqSliderControl("Mitten (dB)", -8, 12, 0.5, initial.mids);
const presenceControl = createEqSliderControl("Presence (dB)", -8, 12, 0.5, initial.presence);
const deesserFreqControl = createEqSliderControl("De-Esser (Hz)", 3500, 8000, 100, initial.deesserFreq);
const deesserCutControl = createEqSliderControl("De-Esser Cut (dB)", 0, 8, 0.5, initial.deesserCut);
const compThresholdControl = createEqSliderControl("Compressor Threshold (dB)", -40, -6, 1, initial.compressorThreshold);
const compRatioControl = createEqSliderControl("Compressor Ratio", 1.2, 6, 0.1, initial.compressorRatio);
const gateThresholdControl = createEqSliderControl("Gate Threshold (dB)", -70, -20, 1, initial.gateThreshold);
const gateReleaseControl = createEqSliderControl("Gate Release (ms)", 60, 500, 10, initial.gateRelease);
wrap.appendChild(hpControl.row);
wrap.appendChild(lpControl.row);
wrap.appendChild(midsControl.row);
wrap.appendChild(presenceControl.row);
const deesserEnabledRow = createEqCheckboxRow("De-Esser aktivieren", initial.deesserEnabled);
wrap.appendChild(deesserEnabledRow.row);
wrap.appendChild(deesserFreqControl.row);
wrap.appendChild(deesserCutControl.row);
const compressorEnabledRow = createEqCheckboxRow("Compressor aktivieren", initial.compressorEnabled);
wrap.appendChild(compressorEnabledRow.row);
wrap.appendChild(compThresholdControl.row);
wrap.appendChild(compRatioControl.row);
const gateEnabledRow = createEqCheckboxRow("Noise Gate aktivieren", initial.gateEnabled);
wrap.appendChild(gateEnabledRow.row);
wrap.appendChild(gateThresholdControl.row);
wrap.appendChild(gateReleaseControl.row);
const limiterRow = document.createElement("label");
limiterRow.className = "plugin-eq-checkbox";
const limiterInput = document.createElement("input");
limiterInput.type = "checkbox";
limiterInput.checked = Boolean(initial.limiter);
limiterRow.appendChild(limiterInput);
const limiterText = document.createElement("span");
limiterText.textContent = "Limiter aktivieren (alimiter=0.95)";
limiterRow.appendChild(limiterText);
wrap.appendChild(limiterRow);
const preview = document.createElement("small");
preview.className = "muted";
wrap.appendChild(preview);
const saveButton = settingsForm.querySelector('button[type="submit"]');
if (saveButton && saveButton.parentNode === settingsForm) {
settingsForm.insertBefore(wrap, saveButton);
} else {
settingsForm.appendChild(wrap);
}
const applyControlsToExtraArgs = () => {
const hp = Number(hpControl.number.value);
const lp = Number(lpControl.number.value);
const mids = Number(midsControl.number.value);
const presence = Number(presenceControl.number.value);
const deesserEnabled = Boolean(deesserEnabledRow.input.checked);
const deesserFreq = Number(deesserFreqControl.number.value);
const deesserCut = Number(deesserCutControl.number.value);
const compressorEnabled = Boolean(compressorEnabledRow.input.checked);
const compressorThreshold = Number(compThresholdControl.number.value);
const compressorRatio = Number(compRatioControl.number.value);
const gateEnabled = Boolean(gateEnabledRow.input.checked);
const gateThreshold = Number(gateThresholdControl.number.value);
const gateRelease = Number(gateReleaseControl.number.value);
const limiter = Boolean(limiterInput.checked);
const args = buildMicrohamEqExtraArgs({
hp,
lp,
mids,
presence,
deesserEnabled,
deesserFreq,
deesserCut,
compressorEnabled,
compressorThreshold,
compressorRatio,
gateEnabled,
gateThreshold,
gateRelease,
limiter
});
extraArgsField.element.value = args;
preview.textContent = `Generierte Extra-Args: ${args}`;
};
const updatePresetInfo = () => {
const selected = String(presetSelect.value || "flat");
if (selected === "dx") {
presetInfo.textContent = "DX: sprachfokussiert mit reduziertem Bass und moderater Praesenz, weniger kuenstlich abgestimmt.";
return;
}
if (selected === "ragchew") {
presetInfo.textContent = "Ragchew: natuerlicher Sprachklang mit leicht reduziertem Bass und sanfter Mitten/Praesenz-Anhebung.";
return;
}
presetInfo.textContent = "Flat: neutrale Basis mit wenig Klangfaerbung, gut fuer Vergleich und Feintuning.";
};
const bindControlUpdate = (control) => {
control.range.addEventListener("input", () => {
control.number.value = control.range.value;
applyControlsToExtraArgs();
});
control.number.addEventListener("input", () => {
control.range.value = control.number.value;
applyControlsToExtraArgs();
});
};
bindControlUpdate(hpControl);
bindControlUpdate(lpControl);
bindControlUpdate(midsControl);
bindControlUpdate(presenceControl);
bindControlUpdate(deesserFreqControl);
bindControlUpdate(deesserCutControl);
bindControlUpdate(compThresholdControl);
bindControlUpdate(compRatioControl);
bindControlUpdate(gateThresholdControl);
bindControlUpdate(gateReleaseControl);
limiterInput.addEventListener("change", applyControlsToExtraArgs);
deesserEnabledRow.input.addEventListener("change", applyControlsToExtraArgs);
compressorEnabledRow.input.addEventListener("change", applyControlsToExtraArgs);
gateEnabledRow.input.addEventListener("change", applyControlsToExtraArgs);
presetBtn.addEventListener("click", () => {
const selected = presets[presetSelect.value] || presets.flat;
hpControl.range.value = String(selected.hp);
hpControl.number.value = String(selected.hp);
lpControl.range.value = String(selected.lp);
lpControl.number.value = String(selected.lp);
midsControl.range.value = String(selected.mids);
midsControl.number.value = String(selected.mids);
presenceControl.range.value = String(selected.presence);
presenceControl.number.value = String(selected.presence);
deesserEnabledRow.input.checked = Boolean(selected.deesserEnabled);
deesserFreqControl.range.value = String(selected.deesserFreq);
deesserFreqControl.number.value = String(selected.deesserFreq);
deesserCutControl.range.value = String(selected.deesserCut);
deesserCutControl.number.value = String(selected.deesserCut);
compressorEnabledRow.input.checked = Boolean(selected.compressorEnabled);
compThresholdControl.range.value = String(selected.compressorThreshold);
compThresholdControl.number.value = String(selected.compressorThreshold);
compRatioControl.range.value = String(selected.compressorRatio);
compRatioControl.number.value = String(selected.compressorRatio);
gateEnabledRow.input.checked = Boolean(selected.gateEnabled);
gateThresholdControl.range.value = String(selected.gateThreshold);
gateThresholdControl.number.value = String(selected.gateThreshold);
gateReleaseControl.range.value = String(selected.gateRelease);
gateReleaseControl.number.value = String(selected.gateRelease);
limiterInput.checked = Boolean(selected.limiter);
applyControlsToExtraArgs();
});
presetSelect.addEventListener("change", updatePresetInfo);
preview.textContent = `Aktuelle Extra-Args: ${extraArgsField.element.value || "<leer>"}`;
updatePresetInfo();
}
function createEqSliderControl(labelText, min, max, step, value) {
const row = document.createElement("div");
row.className = "plugin-eq-row";
const label = document.createElement("span");
label.className = "muted";
label.textContent = labelText;
row.appendChild(label);
const range = document.createElement("input");
range.type = "range";
range.min = String(min);
range.max = String(max);
range.step = String(step);
range.value = String(value);
row.appendChild(range);
const number = document.createElement("input");
number.type = "number";
number.min = String(min);
number.max = String(max);
number.step = String(step);
number.value = String(value);
row.appendChild(number);
return { row, range, number };
}
function createEqCheckboxRow(labelText, checked) {
const row = document.createElement("label");
row.className = "plugin-eq-checkbox";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = Boolean(checked);
row.appendChild(input);
const label = document.createElement("span");
label.textContent = labelText;
row.appendChild(label);
return { row, input };
}
function buildMicrohamEqExtraArgs({
hp,
lp,
mids,
presence,
deesserEnabled,
deesserFreq,
deesserCut,
compressorEnabled,
compressorThreshold,
compressorRatio,
gateEnabled,
gateThreshold,
gateRelease,
limiter
}) {
const filters = [
`highpass=f=${Math.round(Number(hp) || 0)}`,
`lowpass=f=${Math.round(Number(lp) || 0)}`,
`equalizer=f=1200:t=q:w=0.8:g=${Number(Number(mids || 0).toFixed(1))}`,
`equalizer=f=2100:t=q:w=0.9:g=${Number(Number(presence || 0).toFixed(1))}`
];
if (deesserEnabled && Number(deesserCut || 0) > 0) {
const freq = Math.round(Number(deesserFreq) || 5200);
const cut = Number(Number(-Math.abs(Number(deesserCut || 0))).toFixed(1));
filters.push(`equalizer=f=${freq}:t=q:w=1.1:g=${cut}`);
}
if (compressorEnabled) {
const thresholdDb = Number(Number(compressorThreshold || -20).toFixed(1));
const ratio = Number(Number(compressorRatio || 3).toFixed(2));
const thresholdLinear = Number(Math.pow(10, thresholdDb / 20).toFixed(6));
filters.push(`acompressor=threshold=${thresholdLinear}:ratio=${ratio}:attack=5:release=120:makeup=1.2`);
}
if (gateEnabled) {
const gateThresholdDb = Number(Number(gateThreshold || -55).toFixed(1));
const gateThresholdLinear = Number(Math.pow(10, gateThresholdDb / 20).toFixed(6));
const release = Math.round(Number(gateRelease || 180));
filters.push(`agate=threshold=${gateThresholdLinear}:release=${release}`);
}
if (limiter) {
filters.push("alimiter=limit=0.95");
}
return `-af "${filters.join(",")}"`;
}
function parseMicrohamEqArgs(value) {
const text = String(value || "").trim();
if (!text) {
return null;
}
const hpMatch = text.match(/highpass\s*=\s*f\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
const lpMatch = text.match(/lowpass\s*=\s*f\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
const midsMatch = text.match(/equalizer\s*=\s*f\s*=\s*1200\s*:\s*t\s*=\s*q\s*:\s*w\s*=\s*0\.8\s*:\s*g\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
const presenceMatch = text.match(/equalizer\s*=\s*f\s*=\s*2100\s*:\s*t\s*=\s*q\s*:\s*w\s*=\s*0\.9\s*:\s*g\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
if (!hpMatch || !lpMatch || !presenceMatch) {
return null;
}
const hp = Number(hpMatch[1]);
const lp = Number(lpMatch[1]);
const mids = midsMatch ? Number(midsMatch[1]) : 0;
const presence = Number(presenceMatch[1]);
if (!Number.isFinite(hp) || !Number.isFinite(lp) || !Number.isFinite(mids) || !Number.isFinite(presence)) {
return null;
}
const deesserMatch = text.match(/equalizer\s*=\s*f\s*=\s*([0-9]+)\s*:\s*t\s*=\s*q\s*:\s*w\s*=\s*1\.1\s*:\s*g\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
const deesserEnabled = Boolean(deesserMatch);
const deesserFreq = deesserMatch ? Number(deesserMatch[1]) : 5200;
const deesserCut = deesserMatch ? Math.abs(Number(deesserMatch[2])) : 0;
const compMatch = text.match(/acompressor\s*=\s*threshold\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*:\s*ratio\s*=\s*([0-9]+(?:\.[0-9]+)?)/i);
const compressorEnabled = Boolean(compMatch);
const compressorThreshold = compMatch ? Number((20 * Math.log10(Number(compMatch[1]))).toFixed(1)) : -20;
const compressorRatio = compMatch ? Number(compMatch[2]) : 3;
const gateMatch = text.match(/agate\s*=\s*threshold\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*:\s*release\s*=\s*([0-9]+(?:\.[0-9]+)?)/i);
const gateEnabled = Boolean(gateMatch);
const gateThreshold = gateMatch ? Number((20 * Math.log10(Number(gateMatch[1]))).toFixed(1)) : -55;
const gateRelease = gateMatch ? Number(gateMatch[2]) : 180;
const limiter = /alimiter\s*=\s*limit\s*=\s*0\.95/i.test(text);
return {
hp,
lp,
mids,
presence,
deesserEnabled,
deesserFreq,
deesserCut,
compressorEnabled,
compressorThreshold,
compressorRatio,
gateEnabled,
gateThreshold,
gateRelease,
limiter
};
}
function renderProviderAdmin() {
renderProviderAdminInto(els.providersAdminConfig);
}
function renderProviderAdminInto(container) {
if (!container) {
return;
}
container.innerHTML = "";
if (!isAdmin()) {
return;
}
if (!state.capabilities.length) {
container.textContent = "Keine Provider-Daten verfuegbar.";
return;
}
for (const entry of state.capabilities) {
const block = document.createElement("div");
block.className = "plugin-block";
const head = document.createElement("div");
head.className = "section-head";
const title = document.createElement("h3");
title.textContent = entry.capability;
head.appendChild(title);
block.appendChild(head);
const row = document.createElement("label");
row.className = "field";
const label = document.createElement("span");
label.textContent = "Aktiver Provider";
row.appendChild(label);
const select = document.createElement("select");
for (const provider of entry.providers || []) {
const opt = document.createElement("option");
opt.value = provider.pluginId;
opt.textContent = provider.pluginId;
if (entry.activePluginId === provider.pluginId) {
opt.selected = true;
}
select.appendChild(opt);
}
select.addEventListener("change", async () => {
await switchProvider(entry.capability, select.value);
});
row.appendChild(select);
block.appendChild(row);
const providers = document.createElement("div");
providers.className = "actions";
for (const provider of entry.providers || []) {
const badge = document.createElement("span");
badge.className = `health-badge health-${provider.health}`;
badge.textContent = `${provider.pluginId} (${provider.enabled ? "on" : "off"}, ${provider.health})`;
providers.appendChild(badge);
}
block.appendChild(providers);
container.appendChild(block);
}
}
function renderCapabilityMatrix() {
renderCapabilityMatrixInto(els.providersCapabilityMatrix);
}
function renderCapabilityMatrixInto(container) {
if (!container) {
return;
}
container.innerHTML = "";
if (!isAdmin()) {
return;
}
if (!state.capabilities.length) {
container.textContent = "Keine Capability Daten verfuegbar.";
return;
}
for (const entry of state.capabilities) {
const row = document.createElement("div");
row.className = "matrix-row";
const head = document.createElement("div");
head.className = "section-head";
const title = document.createElement("strong");
title.textContent = entry.capability;
head.appendChild(title);
const active = document.createElement("span");
active.className = "pill";
active.textContent = `Aktiv: ${entry.activePluginId || "-"}`;
head.appendChild(active);
row.appendChild(head);
const providers = document.createElement("div");
providers.className = "actions";
for (const provider of entry.providers || []) {
const badge = document.createElement("span");
badge.className = `health-badge health-${provider.health}`;
badge.textContent = `${provider.pluginId} (${provider.enabled ? "on" : "off"}, ${provider.health})`;
providers.appendChild(badge);
}
row.appendChild(providers);
container.appendChild(row);
}
}
function renderSchemaForm(form, schema, values) {
const fields = [];
const properties = schema.properties || {};
const required = Array.isArray(schema.required) ? schema.required : [];
for (const [name, fieldSchema] of Object.entries(properties)) {
const label = document.createElement("label");
label.className = "field";
const caption = document.createElement("span");
caption.textContent = name;
label.appendChild(caption);
const hasSavedValue = values && values[name] !== undefined;
const rawCurrentValue = hasSavedValue ? values[name] : fieldSchema.default;
const currentValue = coerceSchemaValue(fieldSchema, rawCurrentValue);
const sourceLabel = hasSavedValue ? "gespeichert" : (fieldSchema.default !== undefined ? "default" : "nicht gesetzt");
if (Array.isArray(fieldSchema.enum)) {
const select = document.createElement("select");
select.name = name;
select.required = required.includes(name);
for (const enumValue of fieldSchema.enum) {
const option = document.createElement("option");
option.value = String(enumValue);
option.textContent = String(enumValue);
if (currentValue === enumValue) {
option.selected = true;
}
select.appendChild(option);
}
label.appendChild(select);
const field = { name, type: fieldSchema.type || "string", element: select, hasSavedValue, dirty: false };
select.addEventListener("change", () => {
field.dirty = true;
});
fields.push(field);
} else {
const input = document.createElement("input");
input.name = name;
input.required = required.includes(name);
if (fieldSchema.type === "number" || fieldSchema.type === "integer") {
input.type = "number";
if (fieldSchema.type === "integer") input.step = "1";
if (fieldSchema.minimum !== undefined) input.min = String(fieldSchema.minimum);
if (fieldSchema.maximum !== undefined) input.max = String(fieldSchema.maximum);
} else if (fieldSchema.type === "boolean") {
input.type = "checkbox";
} else {
input.type = "text";
}
const value = currentValue;
if (value !== undefined) {
if (fieldSchema.type === "boolean") {
input.checked = Boolean(value);
} else {
input.value = String(value);
}
}
label.appendChild(input);
const field = { name, type: fieldSchema.type || "string", element: input, hasSavedValue, dirty: false };
input.addEventListener("change", () => {
field.dirty = true;
});
fields.push(field);
}
const currentInfo = document.createElement("small");
currentInfo.className = "muted";
currentInfo.textContent = `Aktuell in Verwendung: ${formatSettingValue(currentValue)} (${sourceLabel})`;
label.appendChild(currentInfo);
form.appendChild(label);
}
return fields;
}
function formatSettingValue(value) {
if (value === undefined || value === null || value === "") {
return "<leer>";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
return String(value);
}
function coerceSchemaValue(fieldSchema, value) {
if (!fieldSchema || value === undefined || value === null) {
return value;
}
const type = String(fieldSchema.type || "").toLowerCase();
if (type === "boolean") {
if (typeof value === "boolean") {
return value;
}
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off", ""].includes(normalized)) {
return false;
}
return Boolean(value);
}
if (type === "number" || type === "integer") {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return value;
}
return type === "integer" ? Math.trunc(numeric) : numeric;
}
return value;
}
async function savePluginSettings(pluginId, fields) {
clearMessages("admin");
try {
const settings = readActionInput(fields);
await api(`/v1/plugins/${encodeURIComponent(pluginId)}/settings`, {
method: "PUT",
body: { settings }
});
await refreshPlugins();
await refreshControls();
renderMessage(els.adminMessage, `Settings fuer ${pluginId} gespeichert`, false, true);
} catch (error) {
renderMessage(els.adminMessage, error.message, true);
}
}
async function togglePlugin(pluginId, currentlyEnabled) {
clearMessages("admin");
try {
const route = currentlyEnabled ? "disable" : "enable";
await api(`/v1/plugins/${encodeURIComponent(pluginId)}/${route}`, {
method: "POST",
body: {}
});
await refreshPlugins();
await refreshControls();
renderMessage(els.adminMessage, `${pluginId} ${currentlyEnabled ? "deaktiviert" : "aktiviert"}`, false, true);
} catch (error) {
renderMessage(els.adminMessage, error.message, true);
}
}
async function switchProvider(capability, pluginId) {
clearMessages("provider");
try {
await api(`/v1/admin/capabilities/${encodeURIComponent(capability)}/provider`, {
method: "PUT",
body: { pluginId }
});
await refreshPlugins();
await refreshControls();
renderMessage(els.providersMessage, `Provider fuer ${capability} auf ${pluginId} gesetzt`, false, true);
} catch (error) {
renderMessage(els.providersMessage, error.message, true);
}
}
function updateUserUi() {
const setHidden = (el, value) => {
if (el) {
el.hidden = value;
}
};
const setDisabled = (el, value) => {
if (el) {
el.disabled = value;
}
};
const loggedIn = Boolean(state.user);
const rmsVisible = loggedIn && window.location.pathname.startsWith("/rms");
const rawPage = currentRmsPage();
const page = normalizedRmsPage(rawPage);
if (rmsVisible && page !== rawPage) {
window.history.replaceState({}, "", pageToPath(page));
}
setHidden(els.authView, rmsVisible);
setHidden(els.rmsView, !rmsVisible);
setHidden(els.pageRms, !rmsVisible || page !== "rms");
setHidden(els.pageSwr, !rmsVisible || page !== "swr");
setHidden(els.pageUser, !rmsVisible || page !== "user");
setHidden(els.pageHelp, !rmsVisible || page !== "help");
setHidden(els.pagePlugins, !rmsVisible || page !== "plugins" || !isAdmin());
setHidden(els.pagePluginConfig, !rmsVisible || page !== "plugin-config" || !isAdmin());
setHidden(els.pageProviders, !rmsVisible || page !== "providers" || !isAdmin());
setHidden(els.pageAdmin, !rmsVisible || page !== "admin");
setHidden(els.pageUsers, !rmsVisible || page !== "users");
setHidden(els.pageApprovals, !rmsVisible || page !== "approvals");
setHidden(els.pageActivity, !rmsVisible || page !== "activity");
setDisabled(els.loginBtn, loggedIn);
setDisabled(els.logoutBtn, !loggedIn);
setDisabled(els.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;
}
}