Place a dedicated Logout button at the top of the Benutzer-Einstellungen page and wire it to the existing logout flow. Keep button state in sync with login status like the header menu logout action.
4895 lines
162 KiB
JavaScript
4895 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"),
|
|
settingsLogoutTopBtn: document.getElementById("settingsLogoutTopBtn"),
|
|
activationProgress: document.getElementById("activationProgress"),
|
|
progressText: document.getElementById("progressText"),
|
|
progressFill: document.getElementById("progressFill"),
|
|
progressEta: document.getElementById("progressEta"),
|
|
activationProgressSwr: document.getElementById("activationProgressSwr"),
|
|
progressTextSwr: document.getElementById("progressTextSwr"),
|
|
progressFillSwr: document.getElementById("progressFillSwr"),
|
|
progressEtaSwr: document.getElementById("progressEtaSwr"),
|
|
stationLinks: document.getElementById("stationLinks"),
|
|
swrLink: document.getElementById("swrLink"),
|
|
openwebrxLink: document.getElementById("openwebrxLink"),
|
|
websdrLink: document.getElementById("websdrLink"),
|
|
rotorLink: document.getElementById("rotorLink"),
|
|
openwebrxPanel: document.getElementById("openwebrxPanel"),
|
|
controlsPanel: document.getElementById("controlsPanel"),
|
|
openwebrxOpenBtn: document.getElementById("openwebrxOpenBtn"),
|
|
openwebrxBandSelect: document.getElementById("openwebrxBandSelect"),
|
|
openwebrxBandSetBtn: document.getElementById("openwebrxBandSetBtn"),
|
|
openwebrxEnableTxBtn: document.getElementById("openwebrxEnableTxBtn"),
|
|
openwebrxCloseBtn: document.getElementById("openwebrxCloseBtn"),
|
|
openwebrxMessage: document.getElementById("openwebrxMessage"),
|
|
openwebrxSessionAccess: document.getElementById("openwebrxSessionAccess"),
|
|
openwebrxSessionLink: document.getElementById("openwebrxSessionLink"),
|
|
openwebrxCopyLinkBtn: document.getElementById("openwebrxCopyLinkBtn"),
|
|
openwebrxSessionTicket: document.getElementById("openwebrxSessionTicket"),
|
|
openwebrxTxStatePill: document.getElementById("openwebrxTxStatePill"),
|
|
rotorCurrent: document.getElementById("rotorCurrent"),
|
|
rotorCompass: document.getElementById("rotorCompass"),
|
|
rotorCompassArrow: document.getElementById("rotorCompassArrow"),
|
|
rotorTarget: document.getElementById("rotorTarget"),
|
|
rotorSetBtn: document.getElementById("rotorSetBtn"),
|
|
rotorPresets: document.getElementById("rotorPresets"),
|
|
controlsMessage: document.getElementById("controlsMessage"),
|
|
statusMessage: document.getElementById("statusMessage"),
|
|
swrSummaryGeneratedAt: document.getElementById("swrSummaryGeneratedAt"),
|
|
swrSummaryOverall: document.getElementById("swrSummaryOverall"),
|
|
swrSummaryBands: document.getElementById("swrSummaryBands"),
|
|
swrSummaryMessage: document.getElementById("swrSummaryMessage"),
|
|
refreshSwrBtn: document.getElementById("refreshSwrBtn"),
|
|
runSwrCheckBtn: document.getElementById("runSwrCheckBtn"),
|
|
swrPageGeneratedAt: document.getElementById("swrPageGeneratedAt"),
|
|
swrPageOverall: document.getElementById("swrPageOverall"),
|
|
swrPageBands: document.getElementById("swrPageBands"),
|
|
swrPageMessage: document.getElementById("swrPageMessage"),
|
|
refreshSwrPageBtn: document.getElementById("refreshSwrPageBtn"),
|
|
runSwrCheckPageBtn: document.getElementById("runSwrCheckPageBtn"),
|
|
themeToggle: document.getElementById("themeToggle"),
|
|
authView: document.getElementById("authView"),
|
|
rmsView: document.getElementById("rmsView"),
|
|
pageRms: document.getElementById("pageRms"),
|
|
pageSwr: document.getElementById("pageSwr"),
|
|
pageUser: document.getElementById("pageUser"),
|
|
pageHelp: document.getElementById("pageHelp"),
|
|
pagePlugins: document.getElementById("pagePlugins"),
|
|
pagePluginConfig: document.getElementById("pagePluginConfig"),
|
|
pageProviders: document.getElementById("pageProviders"),
|
|
pageAdmin: document.getElementById("pageAdmin"),
|
|
pageUsers: document.getElementById("pageUsers"),
|
|
pageApprovals: document.getElementById("pageApprovals"),
|
|
pageActivity: document.getElementById("pageActivity"),
|
|
userMenuButton: document.getElementById("userMenuButton"),
|
|
userMenu: document.getElementById("userMenu"),
|
|
menuRms: document.getElementById("menuRms"),
|
|
menuSwr: document.getElementById("menuSwr"),
|
|
menuUser: document.getElementById("menuUser"),
|
|
menuHelp: document.getElementById("menuHelp"),
|
|
menuPlugins: document.getElementById("menuPlugins"),
|
|
menuPluginConfig: document.getElementById("menuPluginConfig"),
|
|
menuProviders: document.getElementById("menuProviders"),
|
|
menuUsers: document.getElementById("menuUsers"),
|
|
menuApprovals: document.getElementById("menuApprovals"),
|
|
menuActivity: document.getElementById("menuActivity"),
|
|
menuAdmin: document.getElementById("menuAdmin"),
|
|
languageMenuButton: document.getElementById("languageMenuButton"),
|
|
languageMenu: document.getElementById("languageMenu"),
|
|
menuLanguageSelect: document.getElementById("menuLanguageSelect"),
|
|
settingsEmail: document.getElementById("settingsEmail"),
|
|
settingsRole: document.getElementById("settingsRole"),
|
|
settingsAuthMethodSelect: document.getElementById("settingsAuthMethodSelect"),
|
|
settingsLanguageSelect: document.getElementById("settingsLanguageSelect"),
|
|
settingsSaveAuthMethodBtn: document.getElementById("settingsSaveAuthMethodBtn"),
|
|
settingsSaveLanguageBtn: document.getElementById("settingsSaveLanguageBtn"),
|
|
settingsThemeBtn: document.getElementById("settingsThemeBtn"),
|
|
settingsRefreshBtn: document.getElementById("settingsRefreshBtn"),
|
|
pageTitle: document.getElementById("pageTitle"),
|
|
pageHint: document.getElementById("pageHint"),
|
|
pageCrumb: document.getElementById("pageCrumb"),
|
|
currentUserLink: document.getElementById("currentUserLink"),
|
|
mobileNav: document.getElementById("mobileNav"),
|
|
mobileNavRms: document.getElementById("mobileNavRms"),
|
|
mobileNavSwr: document.getElementById("mobileNavSwr"),
|
|
mobileNavUser: document.getElementById("mobileNavUser"),
|
|
mobileNavHelp: document.getElementById("mobileNavHelp"),
|
|
mobileNavPlugins: document.getElementById("mobileNavPlugins"),
|
|
mobileNavPluginConfig: document.getElementById("mobileNavPluginConfig"),
|
|
mobileNavUsers: document.getElementById("mobileNavUsers"),
|
|
mobileNavApprovals: document.getElementById("mobileNavApprovals"),
|
|
mobileNavActivity: document.getElementById("mobileNavActivity"),
|
|
mobileNavAdmin: document.getElementById("mobileNavAdmin"),
|
|
adminCard: document.getElementById("adminCard"),
|
|
setOnlineBtn: document.getElementById("setOnlineBtn"),
|
|
setOfflineBtn: document.getElementById("setOfflineBtn"),
|
|
forceReleaseBtn: document.getElementById("forceReleaseBtn"),
|
|
refreshAuditBtn: document.getElementById("refreshAuditBtn"),
|
|
roleEmail: document.getElementById("roleEmail"),
|
|
setRoleAdminBtn: document.getElementById("setRoleAdminBtn"),
|
|
setRoleOperatorBtn: document.getElementById("setRoleOperatorBtn"),
|
|
adminMessage: document.getElementById("adminMessage"),
|
|
auditLog: document.getElementById("auditLog"),
|
|
pluginControls: document.getElementById("pluginControls"),
|
|
pluginMessage: document.getElementById("pluginMessage"),
|
|
pluginsConfigCard: document.getElementById("pluginsConfigCard"),
|
|
pluginsAdminConfig: document.getElementById("pluginsAdminConfig"),
|
|
refreshPluginsPageBtn: document.getElementById("refreshPluginsPageBtn"),
|
|
providersCapabilityMatrix: document.getElementById("providersCapabilityMatrix"),
|
|
providersAdminConfig: document.getElementById("providersAdminConfig"),
|
|
providersMessage: document.getElementById("providersMessage"),
|
|
refreshProvidersBtn: document.getElementById("refreshProvidersBtn"),
|
|
usersAdmin: document.getElementById("usersAdmin"),
|
|
usersMessage: document.getElementById("usersMessage"),
|
|
usersFilterQuery: document.getElementById("usersFilterQuery"),
|
|
usersFilterRole: document.getElementById("usersFilterRole"),
|
|
usersFilterStatus: document.getElementById("usersFilterStatus"),
|
|
refreshUsersBtn: document.getElementById("refreshUsersBtn"),
|
|
approvalsList: document.getElementById("approvalsList"),
|
|
approvalsMessage: document.getElementById("approvalsMessage"),
|
|
approvalsFilterQuery: document.getElementById("approvalsFilterQuery"),
|
|
approvalsFilterStatus: document.getElementById("approvalsFilterStatus"),
|
|
approvalsFilterOpenBtn: document.getElementById("approvalsFilterOpenBtn"),
|
|
approvalsFilterAllBtn: document.getElementById("approvalsFilterAllBtn"),
|
|
refreshApprovalsBtn: document.getElementById("refreshApprovalsBtn"),
|
|
activityLogList: document.getElementById("activityLogList"),
|
|
activityMessage: document.getElementById("activityMessage"),
|
|
activityFilterQuery: document.getElementById("activityFilterQuery"),
|
|
activityFilterType: document.getElementById("activityFilterType"),
|
|
refreshActivityBtn: document.getElementById("refreshActivityBtn"),
|
|
helpTitle: document.getElementById("helpTitle"),
|
|
helpQuickStartTitle: document.getElementById("helpQuickStartTitle"),
|
|
helpQuickStartSteps: document.getElementById("helpQuickStartSteps"),
|
|
helpSections: document.getElementById("helpSections"),
|
|
helpMessage: document.getElementById("helpMessage"),
|
|
refreshHelpBtn: document.getElementById("refreshHelpBtn"),
|
|
maintenanceStatePill: document.getElementById("maintenanceStatePill"),
|
|
maintenanceMessageInput: document.getElementById("maintenanceMessageInput"),
|
|
maintenanceEnableBtn: document.getElementById("maintenanceEnableBtn"),
|
|
maintenanceDisableBtn: document.getElementById("maintenanceDisableBtn"),
|
|
logoLightFile: document.getElementById("logoLightFile"),
|
|
logoDarkFile: document.getElementById("logoDarkFile"),
|
|
uploadLogoLightBtn: document.getElementById("uploadLogoLightBtn"),
|
|
uploadLogoDarkBtn: document.getElementById("uploadLogoDarkBtn"),
|
|
removeLogoLightBtn: document.getElementById("removeLogoLightBtn"),
|
|
removeLogoDarkBtn: document.getElementById("removeLogoDarkBtn")
|
|
};
|
|
|
|
init().catch((error) => {
|
|
renderMessage(els.authMessage, error.message || "Initialisierung fehlgeschlagen", true);
|
|
});
|
|
|
|
async function init() {
|
|
await initI18n();
|
|
loadTheme();
|
|
hydrateFilterStateFromUrl();
|
|
bindEvents();
|
|
await refreshPublicSystemStatus();
|
|
await refreshPublicAuthMethods();
|
|
await handleEmailTokenFromUrl();
|
|
await refreshCurrentUser();
|
|
await ensureLanguageFromUserPreference();
|
|
applyRoute(true);
|
|
if (state.user) {
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
await refreshControls();
|
|
await refreshPlugins();
|
|
await refreshUsers();
|
|
await refreshApprovals();
|
|
await refreshActivityLog();
|
|
await refreshHelpContent();
|
|
connectEvents();
|
|
}
|
|
|
|
setInterval(() => {
|
|
if (!state.user) {
|
|
refreshPublicSystemStatus().catch(() => {});
|
|
return;
|
|
}
|
|
const route = currentRoute();
|
|
if (route === "/rms/swr" || route === "/rms") {
|
|
refreshStatus().catch(() => {});
|
|
}
|
|
}, SWR_DETAIL_REFRESH_MS);
|
|
}
|
|
|
|
async function initI18n() {
|
|
const initial = normalizeLanguage(state.i18n.language);
|
|
state.i18n.language = initial;
|
|
await ensureLanguageDictionary("de");
|
|
if (initial !== "de") {
|
|
await ensureLanguageDictionary(initial);
|
|
}
|
|
applyI18n();
|
|
}
|
|
|
|
function normalizeLanguage(value) {
|
|
const next = String(value || "").trim().toLowerCase();
|
|
return SUPPORTED_UI_LANGUAGES.includes(next) ? next : DEFAULT_UI_LANGUAGE;
|
|
}
|
|
|
|
async function ensureLanguageDictionary(language) {
|
|
const normalized = normalizeLanguage(language);
|
|
if (state.i18n.dictionaries[normalized]) {
|
|
return state.i18n.dictionaries[normalized];
|
|
}
|
|
try {
|
|
const response = await fetch(`/i18n/${encodeURIComponent(normalized)}.json`, { cache: "no-store" });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load language ${normalized}`);
|
|
}
|
|
const parsed = await response.json();
|
|
state.i18n.dictionaries[normalized] = parsed && typeof parsed === "object" ? parsed : {};
|
|
} catch {
|
|
state.i18n.dictionaries[normalized] = {};
|
|
}
|
|
return state.i18n.dictionaries[normalized];
|
|
}
|
|
|
|
function languageDictionary(language) {
|
|
return state.i18n.dictionaries[normalizeLanguage(language)] || {};
|
|
}
|
|
|
|
function translateLiteral(text) {
|
|
const source = String(text || "");
|
|
if (!source) {
|
|
return source;
|
|
}
|
|
const current = languageDictionary(state.i18n.language);
|
|
const currentLiterals = current && current.literals && typeof current.literals === "object" ? current.literals : {};
|
|
if (Object.prototype.hasOwnProperty.call(currentLiterals, source)) {
|
|
return String(currentLiterals[source]);
|
|
}
|
|
const de = languageDictionary("de");
|
|
const deLiterals = de && de.literals && typeof de.literals === "object" ? de.literals : {};
|
|
if (Object.prototype.hasOwnProperty.call(deLiterals, source)) {
|
|
return String(deLiterals[source]);
|
|
}
|
|
return source;
|
|
}
|
|
|
|
const originalTextNodes = new WeakMap();
|
|
const originalElementAttributes = new WeakMap();
|
|
|
|
function applyI18n(root = document.body) {
|
|
if (!root) {
|
|
return;
|
|
}
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
acceptNode(node) {
|
|
if (!node || !node.nodeValue || !node.nodeValue.trim()) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
const parent = node.parentElement;
|
|
if (!parent) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
if (["SCRIPT", "STYLE", "CODE", "PRE"].includes(parent.tagName)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
});
|
|
const textNodes = [];
|
|
let node = walker.nextNode();
|
|
while (node) {
|
|
textNodes.push(node);
|
|
node = walker.nextNode();
|
|
}
|
|
for (const textNode of textNodes) {
|
|
const original = originalTextNodes.has(textNode)
|
|
? originalTextNodes.get(textNode)
|
|
: String(textNode.nodeValue);
|
|
if (!originalTextNodes.has(textNode)) {
|
|
originalTextNodes.set(textNode, original);
|
|
}
|
|
const leading = original.match(/^\s*/)?.[0] || "";
|
|
const trailing = original.match(/\s*$/)?.[0] || "";
|
|
const trimmed = original.trim();
|
|
textNode.nodeValue = trimmed ? `${leading}${translateLiteral(trimmed)}${trailing}` : original;
|
|
}
|
|
const attrNames = ["placeholder", "title", "aria-label"];
|
|
const elements = root.querySelectorAll("*");
|
|
for (const el of elements) {
|
|
if (!originalElementAttributes.has(el)) {
|
|
originalElementAttributes.set(el, {});
|
|
}
|
|
const originalAttrs = originalElementAttributes.get(el);
|
|
for (const attr of attrNames) {
|
|
const value = el.getAttribute(attr);
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(originalAttrs, attr)) {
|
|
originalAttrs[attr] = value;
|
|
}
|
|
el.setAttribute(attr, translateLiteral(originalAttrs[attr]));
|
|
}
|
|
}
|
|
renderLanguageSelectors();
|
|
}
|
|
|
|
function localeForDate() {
|
|
return state.i18n.language === "en" ? "en-GB" : "de-AT";
|
|
}
|
|
|
|
async function setLanguage(language, options = {}) {
|
|
const normalized = normalizeLanguage(language);
|
|
await ensureLanguageDictionary(normalized);
|
|
state.i18n.language = normalized;
|
|
if (options.persist !== false) {
|
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, normalized);
|
|
}
|
|
applyI18n();
|
|
renderStatus();
|
|
renderSwrPanels();
|
|
renderHelpContent();
|
|
if (options.saveUserDefault) {
|
|
await savePreferredLanguage();
|
|
}
|
|
}
|
|
|
|
async function ensureLanguageFromUserPreference() {
|
|
if (!state.user || !state.user.preferredLanguage) {
|
|
return;
|
|
}
|
|
const preferred = normalizeLanguage(state.user.preferredLanguage);
|
|
if (preferred === normalizeLanguage(state.i18n.language)) {
|
|
renderLanguageSelectors();
|
|
return;
|
|
}
|
|
await setLanguage(preferred, { persist: true, saveUserDefault: false });
|
|
}
|
|
|
|
function renderLanguageSelectors() {
|
|
const dictionary = languageDictionary(state.i18n.language);
|
|
const locales = dictionary && dictionary.locales && typeof dictionary.locales === "object"
|
|
? dictionary.locales
|
|
: { de: "Deutsch", en: "English" };
|
|
const options = SUPPORTED_UI_LANGUAGES.map((lang) => ({
|
|
value: lang,
|
|
label: locales[lang] || lang.toUpperCase()
|
|
}));
|
|
for (const select of [els.menuLanguageSelect, els.settingsLanguageSelect]) {
|
|
if (!select) {
|
|
continue;
|
|
}
|
|
const currentValue = select.value;
|
|
select.innerHTML = "";
|
|
for (const optionData of options) {
|
|
const option = document.createElement("option");
|
|
option.value = optionData.value;
|
|
option.textContent = optionData.label;
|
|
select.appendChild(option);
|
|
}
|
|
select.value = normalizeLanguage(state.i18n.language || currentValue || DEFAULT_UI_LANGUAGE);
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
const on = (el, event, handler) => {
|
|
if (el) {
|
|
el.addEventListener(event, handler);
|
|
}
|
|
};
|
|
|
|
els.authForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
await requestAccess();
|
|
});
|
|
|
|
els.activateBtn.addEventListener("click", async () => {
|
|
await activateStation();
|
|
});
|
|
|
|
els.deactivateBtn.addEventListener("click", async () => {
|
|
await releaseStation();
|
|
});
|
|
|
|
els.refreshBtn.addEventListener("click", async () => {
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
await refreshControls();
|
|
});
|
|
|
|
if (els.reserveNextBtn) {
|
|
els.reserveNextBtn.addEventListener("click", async () => {
|
|
await reserveNextSlot();
|
|
});
|
|
}
|
|
|
|
els.logoutBtn.addEventListener("click", async () => {
|
|
await logout();
|
|
});
|
|
if (els.settingsLogoutTopBtn) {
|
|
els.settingsLogoutTopBtn.addEventListener("click", async () => {
|
|
await logout();
|
|
});
|
|
}
|
|
|
|
els.themeToggle.addEventListener("click", () => {
|
|
toggleTheme();
|
|
});
|
|
|
|
els.settingsThemeBtn.addEventListener("click", () => {
|
|
toggleTheme();
|
|
});
|
|
|
|
if (els.settingsSaveAuthMethodBtn) {
|
|
els.settingsSaveAuthMethodBtn.addEventListener("click", async () => {
|
|
await savePreferredAuthMethod();
|
|
});
|
|
}
|
|
|
|
if (els.settingsSaveLanguageBtn) {
|
|
els.settingsSaveLanguageBtn.addEventListener("click", async () => {
|
|
await savePreferredLanguage();
|
|
});
|
|
}
|
|
|
|
if (els.menuLanguageSelect) {
|
|
els.menuLanguageSelect.addEventListener("change", async () => {
|
|
await setLanguage(els.menuLanguageSelect.value, { persist: true, saveUserDefault: false });
|
|
setLanguageMenuOpen(false);
|
|
});
|
|
}
|
|
|
|
if (els.settingsLanguageSelect) {
|
|
els.settingsLanguageSelect.addEventListener("change", async () => {
|
|
await setLanguage(els.settingsLanguageSelect.value, { persist: true, saveUserDefault: false });
|
|
});
|
|
}
|
|
|
|
els.settingsRefreshBtn.addEventListener("click", async () => {
|
|
await refreshPublicSystemStatus();
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
await refreshControls();
|
|
if (isAdmin()) {
|
|
await refreshPlugins();
|
|
await refreshUsers();
|
|
}
|
|
if (canSeeApprovals()) {
|
|
await refreshApprovals();
|
|
}
|
|
if (canSeeActivityLog()) {
|
|
await refreshActivityLog();
|
|
}
|
|
await refreshHelpContent();
|
|
});
|
|
|
|
els.userMenuButton.addEventListener("click", (event) => {
|
|
if (!state.user) {
|
|
return;
|
|
}
|
|
event.stopPropagation();
|
|
setLanguageMenuOpen(false);
|
|
setUserMenuOpen(els.userMenu.hidden);
|
|
});
|
|
if (els.languageMenuButton) {
|
|
els.languageMenuButton.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(els.languageMenu ? els.languageMenu.hidden : false);
|
|
});
|
|
}
|
|
if (els.languageMenu) {
|
|
els.languageMenu.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
});
|
|
}
|
|
on(els.menuRms, "click", () => navigateRmsPage("rms"));
|
|
on(els.menuSwr, "click", () => navigateRmsPage("swr"));
|
|
on(els.menuUser, "click", () => navigateRmsPage("user"));
|
|
on(els.menuHelp, "click", () => navigateRmsPage("help"));
|
|
on(els.menuPlugins, "click", () => navigateRmsPage("plugins"));
|
|
on(els.menuPluginConfig, "click", () => navigateRmsPage("plugin-config"));
|
|
on(els.menuProviders, "click", () => navigateRmsPage("providers"));
|
|
on(els.menuUsers, "click", () => navigateRmsPage("users"));
|
|
on(els.menuApprovals, "click", () => navigateRmsPage("approvals"));
|
|
on(els.menuActivity, "click", () => navigateRmsPage("activity"));
|
|
on(els.menuAdmin, "click", () => navigateRmsPage("admin"));
|
|
on(els.mobileNavRms, "click", () => navigateRmsPage("rms"));
|
|
on(els.mobileNavSwr, "click", () => navigateRmsPage("swr"));
|
|
on(els.mobileNavUser, "click", () => navigateRmsPage("user"));
|
|
on(els.mobileNavHelp, "click", () => navigateRmsPage("help"));
|
|
on(els.mobileNavPlugins, "click", () => navigateRmsPage("plugins"));
|
|
on(els.mobileNavPluginConfig, "click", () => navigateRmsPage("plugin-config"));
|
|
on(els.mobileNavUsers, "click", () => navigateRmsPage("users"));
|
|
on(els.mobileNavApprovals, "click", () => navigateRmsPage("approvals"));
|
|
on(els.mobileNavActivity, "click", () => navigateRmsPage("activity"));
|
|
on(els.mobileNavAdmin, "click", () => navigateRmsPage("admin"));
|
|
if (els.currentUserLink) {
|
|
els.currentUserLink.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateRmsPage("user");
|
|
});
|
|
}
|
|
document.addEventListener("click", () => {
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(false);
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(false);
|
|
}
|
|
});
|
|
|
|
window.addEventListener("popstate", () => {
|
|
hydrateFilterStateFromUrl();
|
|
applyRoute(true);
|
|
});
|
|
|
|
window.addEventListener("pageshow", () => {
|
|
void refreshFrontendOnResume();
|
|
});
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (!document.hidden) {
|
|
void refreshFrontendOnResume();
|
|
}
|
|
});
|
|
|
|
if (els.refreshProvidersBtn) {
|
|
els.refreshProvidersBtn.addEventListener("click", async () => {
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
});
|
|
}
|
|
if (els.refreshSwrBtn) {
|
|
els.refreshSwrBtn.addEventListener("click", async () => {
|
|
await refreshSwrReport();
|
|
});
|
|
}
|
|
if (els.refreshSwrPageBtn) {
|
|
els.refreshSwrPageBtn.addEventListener("click", async () => {
|
|
await refreshSwrReport();
|
|
});
|
|
}
|
|
if (els.runSwrCheckBtn) {
|
|
els.runSwrCheckBtn.addEventListener("click", async () => {
|
|
await runManualSwrCheck();
|
|
});
|
|
}
|
|
if (els.runSwrCheckPageBtn) {
|
|
els.runSwrCheckPageBtn.addEventListener("click", async () => {
|
|
await runManualSwrCheck();
|
|
});
|
|
}
|
|
if (els.openwebrxOpenBtn) {
|
|
els.openwebrxOpenBtn.addEventListener("click", async () => {
|
|
await openOpenWebRxSession();
|
|
});
|
|
}
|
|
if (els.openwebrxLink) {
|
|
els.openwebrxLink.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
await openOpenWebRxExternal();
|
|
});
|
|
}
|
|
if (els.openwebrxCopyLinkBtn) {
|
|
els.openwebrxCopyLinkBtn.addEventListener("click", async () => {
|
|
await copyOpenWebRxLink();
|
|
});
|
|
}
|
|
if (els.openwebrxEnableTxBtn) {
|
|
els.openwebrxEnableTxBtn.addEventListener("click", async (event) => {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
await toggleOpenWebRxTxPower();
|
|
});
|
|
}
|
|
if (els.rotorSetBtn) {
|
|
els.rotorSetBtn.addEventListener("click", async () => {
|
|
await setOpenWebRxRotor();
|
|
});
|
|
}
|
|
if (els.rotorPresets) {
|
|
els.rotorPresets.addEventListener("click", async (event) => {
|
|
const target = event && event.target ? event.target.closest("button[data-azimuth]") : null;
|
|
if (!target || !els.rotorTarget) {
|
|
return;
|
|
}
|
|
const azimuth = Number(target.getAttribute("data-azimuth"));
|
|
if (!Number.isFinite(azimuth)) {
|
|
return;
|
|
}
|
|
els.rotorTarget.value = String(Math.round(azimuth));
|
|
await setOpenWebRxRotor();
|
|
});
|
|
}
|
|
if (els.openwebrxBandSetBtn) {
|
|
els.openwebrxBandSetBtn.addEventListener("click", async () => {
|
|
await setOpenWebRxBand();
|
|
});
|
|
}
|
|
if (els.openwebrxCloseBtn) {
|
|
els.openwebrxCloseBtn.addEventListener("click", async () => {
|
|
await closeOpenWebRxSession();
|
|
await refreshStatus();
|
|
});
|
|
}
|
|
if (els.refreshPluginsPageBtn) {
|
|
els.refreshPluginsPageBtn.addEventListener("click", async () => {
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
});
|
|
}
|
|
|
|
if (els.refreshUsersBtn) {
|
|
els.refreshUsersBtn.addEventListener("click", async () => {
|
|
await refreshUsers();
|
|
});
|
|
}
|
|
if (els.usersFilterQuery) {
|
|
els.usersFilterQuery.addEventListener("input", () => {
|
|
state.usersFilter.query = els.usersFilterQuery.value.trim().toLowerCase();
|
|
renderUsersAdmin();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.usersFilterRole) {
|
|
els.usersFilterRole.addEventListener("change", () => {
|
|
state.usersFilter.role = els.usersFilterRole.value;
|
|
renderUsersAdmin();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.usersFilterStatus) {
|
|
els.usersFilterStatus.addEventListener("change", () => {
|
|
state.usersFilter.status = els.usersFilterStatus.value;
|
|
renderUsersAdmin();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
|
|
if (els.refreshApprovalsBtn) {
|
|
els.refreshApprovalsBtn.addEventListener("click", async () => {
|
|
await refreshApprovals();
|
|
});
|
|
}
|
|
if (els.refreshActivityBtn) {
|
|
els.refreshActivityBtn.addEventListener("click", async () => {
|
|
await refreshActivityLog();
|
|
});
|
|
}
|
|
if (els.refreshHelpBtn) {
|
|
els.refreshHelpBtn.addEventListener("click", async () => {
|
|
await refreshHelpContent();
|
|
});
|
|
}
|
|
if (els.activityFilterQuery) {
|
|
els.activityFilterQuery.addEventListener("input", () => {
|
|
state.activityFilter.query = els.activityFilterQuery.value.trim().toLowerCase();
|
|
renderActivityLog();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.activityFilterType) {
|
|
els.activityFilterType.addEventListener("change", () => {
|
|
state.activityFilter.type = els.activityFilterType.value;
|
|
renderActivityLog();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.approvalsFilterOpenBtn) {
|
|
els.approvalsFilterOpenBtn.addEventListener("click", () => {
|
|
state.approvalsFilter.mode = "open";
|
|
renderApprovals();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.approvalsFilterAllBtn) {
|
|
els.approvalsFilterAllBtn.addEventListener("click", () => {
|
|
state.approvalsFilter.mode = "all";
|
|
renderApprovals();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.approvalsFilterQuery) {
|
|
els.approvalsFilterQuery.addEventListener("input", () => {
|
|
state.approvalsFilter.query = els.approvalsFilterQuery.value.trim().toLowerCase();
|
|
renderApprovals();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
if (els.approvalsFilterStatus) {
|
|
els.approvalsFilterStatus.addEventListener("change", () => {
|
|
state.approvalsFilter.status = els.approvalsFilterStatus.value;
|
|
renderApprovals();
|
|
updateRouteQueryForCurrentPage();
|
|
});
|
|
}
|
|
|
|
if (els.maintenanceEnableBtn) {
|
|
els.maintenanceEnableBtn.addEventListener("click", async () => {
|
|
await setMaintenanceMode(true);
|
|
});
|
|
}
|
|
if (els.maintenanceDisableBtn) {
|
|
els.maintenanceDisableBtn.addEventListener("click", async () => {
|
|
await setMaintenanceMode(false);
|
|
});
|
|
}
|
|
if (els.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.settingsLogoutTopBtn, !loggedIn);
|
|
setDisabled(els.userMenuButton, !loggedIn);
|
|
setDisabled(els.email, loggedIn);
|
|
if (els.userMenuButton) {
|
|
els.userMenuButton.textContent = "☰";
|
|
els.userMenuButton.setAttribute("aria-label", loggedIn ? `Menue (${state.user.email})` : "Menue");
|
|
}
|
|
setHidden(els.currentUserLink, !loggedIn);
|
|
if (els.currentUserLink) {
|
|
els.currentUserLink.textContent = loggedIn ? `👤 ${state.user.email}` : "👤 -";
|
|
}
|
|
setHidden(els.adminCard, !isAdmin());
|
|
if (els.pluginsConfigCard) {
|
|
els.pluginsConfigCard.hidden = !isAdmin();
|
|
}
|
|
setHidden(els.menuAdmin, !isAdmin());
|
|
setHidden(els.menuSwr, !canOperateStation());
|
|
setHidden(els.menuHelp, !loggedIn);
|
|
setHidden(els.menuPlugins, !isAdmin());
|
|
setHidden(els.menuPluginConfig, !isAdmin());
|
|
setHidden(els.menuProviders, !isAdmin());
|
|
setHidden(els.menuUsers, !canSeeUsersList());
|
|
setHidden(els.menuApprovals, !canSeeApprovals());
|
|
setHidden(els.menuActivity, !canSeeActivityLog());
|
|
setHidden(els.mobileNavUsers, !canSeeUsersList());
|
|
setHidden(els.mobileNavPlugins, !isAdmin());
|
|
setHidden(els.mobileNavPluginConfig, !isAdmin());
|
|
setHidden(els.mobileNavSwr, !canOperateStation());
|
|
setHidden(els.mobileNavHelp, !loggedIn);
|
|
setHidden(els.mobileNavAdmin, !isAdmin());
|
|
setHidden(els.mobileNavApprovals, !canSeeApprovals());
|
|
setHidden(els.mobileNavActivity, !canSeeActivityLog());
|
|
if (els.mobileNav) {
|
|
els.mobileNav.classList.toggle("admin-visible", isAdmin());
|
|
els.mobileNav.classList.toggle("swr-visible", canOperateStation());
|
|
els.mobileNav.classList.toggle("users-visible", canSeeUsersList());
|
|
els.mobileNav.classList.toggle("approvals-visible", canSeeApprovals());
|
|
els.mobileNav.classList.toggle("activity-visible", canSeeActivityLog());
|
|
}
|
|
if (els.settingsEmail) {
|
|
els.settingsEmail.textContent = loggedIn ? state.user.email : "-";
|
|
}
|
|
if (els.settingsRole) {
|
|
els.settingsRole.textContent = loggedIn ? state.user.role : "-";
|
|
}
|
|
renderLanguageSelectors();
|
|
if (els.settingsLanguageSelect) {
|
|
const userPreferred = normalizeLanguage(loggedIn && state.user && state.user.preferredLanguage
|
|
? state.user.preferredLanguage
|
|
: state.i18n.language);
|
|
els.settingsLanguageSelect.value = userPreferred;
|
|
}
|
|
if (els.menuLanguageSelect) {
|
|
els.menuLanguageSelect.value = normalizeLanguage(state.i18n.language);
|
|
}
|
|
renderUserSettingsAuthMethods();
|
|
renderMaintenanceBanner();
|
|
updateMenuState(page, loggedIn);
|
|
updatePageMeta(loggedIn, page);
|
|
applyI18n();
|
|
setHidden(els.mobileNav, !rmsVisible);
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(false);
|
|
if (rmsVisible) {
|
|
animateCurrentPage(page);
|
|
}
|
|
}
|
|
|
|
function animateCurrentPage(page) {
|
|
const target = page === "user"
|
|
? els.pageUser
|
|
: page === "swr"
|
|
? els.pageSwr
|
|
: page === "help"
|
|
? els.pageHelp
|
|
: page === "plugins"
|
|
? els.pagePlugins
|
|
: page === "plugin-config"
|
|
? els.pagePluginConfig
|
|
: page === "providers"
|
|
? els.pageProviders
|
|
: page === "users"
|
|
? els.pageUsers
|
|
: page === "approvals"
|
|
? els.pageApprovals
|
|
: page === "activity"
|
|
? els.pageActivity
|
|
: page === "admin"
|
|
? els.pageAdmin
|
|
: els.pageRms;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
target.classList.remove("page-enter");
|
|
void target.offsetWidth;
|
|
target.classList.add("page-enter");
|
|
}
|
|
|
|
function updatePageMeta(loggedIn, page) {
|
|
if (!loggedIn) {
|
|
els.pageTitle.textContent = "RMS Status";
|
|
els.pageHint.textContent = "Bitte anmelden";
|
|
els.pageCrumb.textContent = "LOGIN";
|
|
return;
|
|
}
|
|
if (page === "user") {
|
|
els.pageTitle.textContent = "Einstellungen";
|
|
els.pageHint.textContent = "Persoenliche Einstellungen";
|
|
els.pageCrumb.textContent = "RMS / EINSTELLUNGEN";
|
|
return;
|
|
}
|
|
if (page === "help") {
|
|
els.pageTitle.textContent = "Hilfe";
|
|
els.pageHint.textContent = "Grundablaeufe fuer den Stationsbetrieb";
|
|
els.pageCrumb.textContent = "RMS / HILFE";
|
|
return;
|
|
}
|
|
if (page === "plugins") {
|
|
els.pageTitle.textContent = "Plugin Controls";
|
|
els.pageHint.textContent = "Dynamische Geraetesteuerung";
|
|
els.pageCrumb.textContent = "RMS / PLUGIN CONTROLS";
|
|
return;
|
|
}
|
|
if (page === "plugin-config") {
|
|
els.pageTitle.textContent = "Plugin Konfiguration";
|
|
els.pageHint.textContent = "Einstellungen und Aktivierung";
|
|
els.pageCrumb.textContent = "RMS / PLUGIN KONFIG";
|
|
return;
|
|
}
|
|
if (page === "providers") {
|
|
els.pageTitle.textContent = "Provider";
|
|
els.pageHint.textContent = "Capability-Zuordnung";
|
|
els.pageCrumb.textContent = "RMS / PROVIDER";
|
|
return;
|
|
}
|
|
if (page === "swr") {
|
|
els.pageTitle.textContent = "SWR Test-Daten";
|
|
els.pageHint.textContent = "Bandauswertung und Grafiken";
|
|
els.pageCrumb.textContent = "RMS / SWR";
|
|
return;
|
|
}
|
|
if (page === "admin") {
|
|
els.pageTitle.textContent = "Admin";
|
|
els.pageHint.textContent = "System- und Plugin-Verwaltung";
|
|
els.pageCrumb.textContent = "RMS / ADMIN";
|
|
return;
|
|
}
|
|
if (page === "users") {
|
|
els.pageTitle.textContent = "Benutzerverwaltung";
|
|
els.pageHint.textContent = "Rollen und Accountstatus";
|
|
els.pageCrumb.textContent = "RMS / USERS";
|
|
return;
|
|
}
|
|
if (page === "approvals") {
|
|
els.pageTitle.textContent = "Freigaben";
|
|
els.pageHint.textContent = "Externe Domain-Anfragen";
|
|
els.pageCrumb.textContent = "RMS / FREIGABEN";
|
|
return;
|
|
}
|
|
if (page === "activity") {
|
|
els.pageTitle.textContent = "Aktivitaetslog";
|
|
els.pageHint.textContent = "Bedienung und Stationsnutzung";
|
|
els.pageCrumb.textContent = "RMS / AKTIVITAET";
|
|
return;
|
|
}
|
|
els.pageTitle.textContent = "RMS Status";
|
|
els.pageHint.textContent = "Station steuern";
|
|
els.pageCrumb.textContent = "RMS / STATUS";
|
|
}
|
|
|
|
function updateMenuState(page, loggedIn) {
|
|
const map = {
|
|
rms: els.menuRms,
|
|
swr: els.menuSwr,
|
|
user: els.menuUser,
|
|
help: els.menuHelp,
|
|
plugins: els.menuPlugins,
|
|
"plugin-config": els.menuPluginConfig,
|
|
providers: els.menuProviders,
|
|
users: els.menuUsers,
|
|
approvals: els.menuApprovals,
|
|
activity: els.menuActivity,
|
|
admin: els.menuAdmin
|
|
};
|
|
for (const [name, el] of Object.entries(map)) {
|
|
if (el) {
|
|
el.classList.toggle("active", loggedIn && page === name);
|
|
}
|
|
}
|
|
const mobileMap = {
|
|
rms: els.mobileNavRms,
|
|
swr: els.mobileNavSwr,
|
|
user: els.mobileNavUser,
|
|
help: els.mobileNavHelp,
|
|
plugins: els.mobileNavPlugins,
|
|
"plugin-config": els.mobileNavPluginConfig,
|
|
users: els.mobileNavUsers,
|
|
approvals: els.mobileNavApprovals,
|
|
activity: els.mobileNavActivity,
|
|
admin: els.mobileNavAdmin
|
|
};
|
|
for (const [name, el] of Object.entries(mobileMap)) {
|
|
if (el) {
|
|
el.classList.toggle("active", loggedIn && page === name);
|
|
}
|
|
}
|
|
if (!loggedIn) {
|
|
const clearActive = [
|
|
els.menuRms,
|
|
els.menuSwr,
|
|
els.menuUser,
|
|
els.menuHelp,
|
|
els.menuPlugins,
|
|
els.menuPluginConfig,
|
|
els.menuProviders,
|
|
els.menuUsers,
|
|
els.menuApprovals,
|
|
els.menuActivity,
|
|
els.menuAdmin,
|
|
els.mobileNavRms,
|
|
els.mobileNavSwr,
|
|
els.mobileNavUser,
|
|
els.mobileNavHelp,
|
|
els.mobileNavPlugins,
|
|
els.mobileNavPluginConfig,
|
|
els.mobileNavUsers,
|
|
els.mobileNavApprovals,
|
|
els.mobileNavActivity,
|
|
els.mobileNavAdmin
|
|
];
|
|
for (const el of clearActive) {
|
|
if (el) {
|
|
el.classList.remove("active");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearMessages(scope = "all") {
|
|
if (scope === "all" || scope === "auth") {
|
|
els.authMessage.textContent = "";
|
|
els.authMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "status") {
|
|
els.statusMessage.textContent = "";
|
|
els.statusMessage.className = "message";
|
|
if (els.reservationMessage) {
|
|
els.reservationMessage.textContent = "";
|
|
els.reservationMessage.className = "message";
|
|
}
|
|
if (els.openwebrxMessage) {
|
|
els.openwebrxMessage.textContent = "";
|
|
els.openwebrxMessage.className = "message";
|
|
}
|
|
}
|
|
if (scope === "all" || scope === "swr") {
|
|
if (els.swrSummaryMessage) {
|
|
els.swrSummaryMessage.textContent = "";
|
|
els.swrSummaryMessage.className = "message";
|
|
}
|
|
if (els.swrPageMessage) {
|
|
els.swrPageMessage.textContent = "";
|
|
els.swrPageMessage.className = "message";
|
|
}
|
|
}
|
|
if (scope === "all" || scope === "admin") {
|
|
els.adminMessage.textContent = "";
|
|
els.adminMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "provider") {
|
|
if (els.providersMessage) {
|
|
els.providersMessage.textContent = "";
|
|
els.providersMessage.className = "message";
|
|
}
|
|
}
|
|
if (scope === "all" || scope === "plugin") {
|
|
els.pluginMessage.textContent = "";
|
|
els.pluginMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "users") {
|
|
els.usersMessage.textContent = "";
|
|
els.usersMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "approvals") {
|
|
els.approvalsMessage.textContent = "";
|
|
els.approvalsMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "activity") {
|
|
els.activityMessage.textContent = "";
|
|
els.activityMessage.className = "message";
|
|
}
|
|
if (scope === "all" || scope === "help") {
|
|
if (els.helpMessage) {
|
|
els.helpMessage.textContent = "";
|
|
els.helpMessage.className = "message";
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderMessage(el, text, isError = false, isOk = false) {
|
|
if (!el) return;
|
|
el.textContent = translateLiteral(String(text || ""));
|
|
el.className = "message";
|
|
if (isError) {
|
|
el.classList.add("error");
|
|
}
|
|
if (isOk) {
|
|
el.classList.add("ok");
|
|
}
|
|
}
|
|
|
|
function currentRoute() {
|
|
const path = window.location.pathname;
|
|
if (
|
|
path === "/rms" ||
|
|
path === "/rms/swr" ||
|
|
path === "/rms/user" ||
|
|
path === "/rms/hilfe" ||
|
|
path === "/rms/plugins" ||
|
|
path === "/rms/plugin-konfig" ||
|
|
path === "/rms/providers" ||
|
|
path === "/rms/users" ||
|
|
path === "/rms/freigaben" ||
|
|
path === "/rms/aktivitaet" ||
|
|
path === "/rms/admin"
|
|
) {
|
|
return path;
|
|
}
|
|
return "/login";
|
|
}
|
|
|
|
function applyRoute(replace = false) {
|
|
const desired = state.user ? pageToPath(normalizedRmsPage(currentRmsPage())) : "/login";
|
|
const desiredUrl = state.user
|
|
? `${desired}${routeQueryForPage(normalizedRmsPage(currentRmsPage()))}`
|
|
: "/login";
|
|
const currentUrl = `${window.location.pathname}${window.location.search}`;
|
|
if (currentUrl !== desiredUrl) {
|
|
window.history[replace ? "replaceState" : "pushState"]({}, "", desiredUrl);
|
|
}
|
|
updateUserUi();
|
|
}
|
|
|
|
function currentRmsPage() {
|
|
const path = window.location.pathname;
|
|
if (path === "/rms/user") return "user";
|
|
if (path === "/rms/swr") return "swr";
|
|
if (path === "/rms/hilfe") return "help";
|
|
if (path === "/rms/plugins") return "plugins";
|
|
if (path === "/rms/plugin-konfig") return "plugin-config";
|
|
if (path === "/rms/providers") return "providers";
|
|
if (path === "/rms/users") return "users";
|
|
if (path === "/rms/freigaben") return "approvals";
|
|
if (path === "/rms/aktivitaet") return "activity";
|
|
if (path === "/rms/admin") return "admin";
|
|
return "rms";
|
|
}
|
|
|
|
function normalizedRmsPage(page) {
|
|
if (page === "admin" && !isAdmin()) {
|
|
return "rms";
|
|
}
|
|
if (page === "swr" && !canOperateStation()) {
|
|
return "rms";
|
|
}
|
|
if (page === "users" && !canSeeUsersList()) {
|
|
return "rms";
|
|
}
|
|
if (page === "plugins" && !isAdmin()) {
|
|
return "rms";
|
|
}
|
|
if (page === "plugin-config" && !isAdmin()) {
|
|
return "rms";
|
|
}
|
|
if (page === "providers" && !isAdmin()) {
|
|
return "rms";
|
|
}
|
|
if (page === "approvals" && !canSeeApprovals()) {
|
|
return "rms";
|
|
}
|
|
if (page === "activity" && !canSeeActivityLog()) {
|
|
return "rms";
|
|
}
|
|
return page;
|
|
}
|
|
|
|
function navigateRmsPage(page) {
|
|
if (!state.user) {
|
|
return;
|
|
}
|
|
setUserMenuOpen(false);
|
|
setLanguageMenuOpen(false);
|
|
const safePage = normalizedRmsPage(["rms", "swr", "user", "help", "plugins", "plugin-config", "providers", "users", "approvals", "activity", "admin"].includes(page) ? page : "rms");
|
|
window.history.pushState({}, "", `${pageToPath(safePage)}${routeQueryForPage(safePage)}`);
|
|
updateUserUi();
|
|
}
|
|
|
|
function pageToPath(page) {
|
|
if (page === "swr") return "/rms/swr";
|
|
if (page === "user") return "/rms/user";
|
|
if (page === "help") return "/rms/hilfe";
|
|
if (page === "plugins") return "/rms/plugins";
|
|
if (page === "plugin-config") return "/rms/plugin-konfig";
|
|
if (page === "providers") return "/rms/providers";
|
|
if (page === "users") return "/rms/users";
|
|
if (page === "approvals") return "/rms/freigaben";
|
|
if (page === "activity") return "/rms/aktivitaet";
|
|
if (page === "admin") return "/rms/admin";
|
|
return "/rms";
|
|
}
|
|
|
|
function routeQueryForPage(page) {
|
|
const params = new URLSearchParams();
|
|
if (page === "users") {
|
|
if (state.usersFilter.query) params.set("uq", state.usersFilter.query);
|
|
if (state.usersFilter.role !== "all") params.set("ur", state.usersFilter.role);
|
|
if (state.usersFilter.status !== "all") params.set("us", state.usersFilter.status);
|
|
}
|
|
if (page === "approvals") {
|
|
if (state.approvalsFilter.mode !== "open") params.set("am", state.approvalsFilter.mode);
|
|
if (state.approvalsFilter.query) params.set("aq", state.approvalsFilter.query);
|
|
if (state.approvalsFilter.status !== "all") params.set("as", state.approvalsFilter.status);
|
|
}
|
|
if (page === "activity") {
|
|
if (state.activityFilter.query) params.set("lq", state.activityFilter.query);
|
|
if (state.activityFilter.type !== "all") params.set("lt", state.activityFilter.type);
|
|
}
|
|
const query = params.toString();
|
|
return query ? `?${query}` : "";
|
|
}
|
|
|
|
function updateRouteQueryForCurrentPage() {
|
|
if (!state.user) {
|
|
return;
|
|
}
|
|
const page = normalizedRmsPage(currentRmsPage());
|
|
const next = `${pageToPath(page)}${routeQueryForPage(page)}`;
|
|
const current = `${window.location.pathname}${window.location.search}`;
|
|
if (current !== next) {
|
|
window.history.replaceState({}, "", next);
|
|
}
|
|
}
|
|
|
|
function hydrateFilterStateFromUrl() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
state.usersFilter.query = String(params.get("uq") || "").trim().toLowerCase();
|
|
state.usersFilter.role = normalizeFilterValue(params.get("ur"), ["all", "admin", "approver", "operator"], "all");
|
|
state.usersFilter.status = normalizeFilterValue(
|
|
params.get("us"),
|
|
["all", "active", "pending_approval", "pending_verification", "denied"],
|
|
"all"
|
|
);
|
|
|
|
state.approvalsFilter.mode = normalizeFilterValue(params.get("am"), ["open", "all"], "open");
|
|
state.approvalsFilter.query = String(params.get("aq") || "").trim().toLowerCase();
|
|
state.approvalsFilter.status = normalizeFilterValue(params.get("as"), ["all", "pending", "approved", "rejected"], "all");
|
|
|
|
state.activityFilter.query = String(params.get("lq") || "").trim().toLowerCase();
|
|
state.activityFilter.type = normalizeFilterValue(
|
|
params.get("lt"),
|
|
[
|
|
"all",
|
|
"auth.request_access",
|
|
"station.activate.start",
|
|
"station.activate.done",
|
|
"station.activate.failed",
|
|
"station.deactivate",
|
|
"station.deactivate.timeout"
|
|
],
|
|
"all"
|
|
);
|
|
|
|
syncFilterInputsFromState();
|
|
}
|
|
|
|
function syncFilterInputsFromState() {
|
|
if (els.usersFilterQuery) els.usersFilterQuery.value = state.usersFilter.query;
|
|
if (els.usersFilterRole) els.usersFilterRole.value = state.usersFilter.role;
|
|
if (els.usersFilterStatus) els.usersFilterStatus.value = state.usersFilter.status;
|
|
if (els.approvalsFilterQuery) els.approvalsFilterQuery.value = state.approvalsFilter.query;
|
|
if (els.approvalsFilterStatus) els.approvalsFilterStatus.value = state.approvalsFilter.status;
|
|
if (els.activityFilterQuery) els.activityFilterQuery.value = state.activityFilter.query;
|
|
if (els.activityFilterType) els.activityFilterType.value = state.activityFilter.type;
|
|
}
|
|
|
|
function normalizeFilterValue(value, allowed, fallback) {
|
|
return allowed.includes(value) ? value : fallback;
|
|
}
|
|
|
|
function formatRemainingUsage(totalSec) {
|
|
const sec = Math.max(0, Math.floor(Number(totalSec || 0)));
|
|
const hours = Math.floor(sec / 3600);
|
|
const minutes = Math.floor((sec % 3600) / 60);
|
|
const seconds = sec % 60;
|
|
if (hours > 0) {
|
|
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
}
|
|
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
}
|
|
|
|
function withCacheVersion(url, version) {
|
|
const rawUrl = String(url || "").trim();
|
|
if (!rawUrl) {
|
|
return "";
|
|
}
|
|
const rawVersion = String(version || "").trim();
|
|
if (!rawVersion) {
|
|
return rawUrl;
|
|
}
|
|
const separator = rawUrl.includes("?") ? "&" : "?";
|
|
return `${rawUrl}${separator}v=${encodeURIComponent(rawVersion)}`;
|
|
}
|
|
|
|
function setUserMenuOpen(open) {
|
|
const effective = Boolean(open && state.user);
|
|
els.userMenu.hidden = !effective;
|
|
els.userMenuButton.setAttribute("aria-expanded", effective ? "true" : "false");
|
|
els.userMenuButton.textContent = "☰";
|
|
if (state.user) {
|
|
els.userMenuButton.setAttribute("aria-label", effective ? `Menue schliessen (${state.user.email})` : `Menue oeffnen (${state.user.email})`);
|
|
} else {
|
|
els.userMenuButton.setAttribute("aria-label", effective ? "Menue schliessen" : "Menue oeffnen");
|
|
}
|
|
}
|
|
|
|
function setLanguageMenuOpen(open) {
|
|
if (!els.languageMenu || !els.languageMenuButton) {
|
|
return;
|
|
}
|
|
const effective = Boolean(open);
|
|
els.languageMenu.hidden = !effective;
|
|
els.languageMenuButton.setAttribute("aria-expanded", effective ? "true" : "false");
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const current = document.documentElement.dataset.theme || "dark";
|
|
const next = current === "dark" ? "light" : "dark";
|
|
document.documentElement.dataset.theme = next;
|
|
localStorage.setItem("arcg-theme", next);
|
|
updateThemeToggleIcon();
|
|
renderBranding();
|
|
}
|
|
|
|
function loadTheme() {
|
|
const saved = localStorage.getItem("arcg-theme");
|
|
document.documentElement.dataset.theme = saved === "light" ? "light" : "dark";
|
|
updateThemeToggleIcon();
|
|
renderBranding();
|
|
}
|
|
|
|
function updateThemeToggleIcon() {
|
|
if (!els.themeToggle) {
|
|
return;
|
|
}
|
|
const theme = document.documentElement.dataset.theme || "dark";
|
|
if (theme === "dark") {
|
|
els.themeToggle.textContent = "☀";
|
|
els.themeToggle.setAttribute("aria-label", "Zu Light Mode wechseln");
|
|
return;
|
|
}
|
|
els.themeToggle.textContent = "☾";
|
|
els.themeToggle.setAttribute("aria-label", "Zu Dark Mode wechseln");
|
|
}
|
|
|
|
function isAdmin() {
|
|
return Boolean(state.user && state.user.role === "admin");
|
|
}
|
|
|
|
function canOperateStation() {
|
|
return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver" || state.user.role === "operator"));
|
|
}
|
|
|
|
function canSeeApprovals() {
|
|
return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver"));
|
|
}
|
|
|
|
function canSeeUsersList() {
|
|
return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver"));
|
|
}
|
|
|
|
function canSeeActivityLog() {
|
|
return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver"));
|
|
}
|
|
|
|
function setTokens(accessToken, refreshToken) {
|
|
state.accessToken = accessToken || "";
|
|
state.refreshToken = refreshToken || "";
|
|
if (state.accessToken) {
|
|
localStorage.setItem("rms-access-token", state.accessToken);
|
|
}
|
|
if (state.refreshToken) {
|
|
localStorage.setItem("rms-refresh-token", state.refreshToken);
|
|
}
|
|
}
|
|
|
|
function clearTokens() {
|
|
state.accessToken = "";
|
|
state.refreshToken = "";
|
|
localStorage.removeItem("rms-access-token");
|
|
localStorage.removeItem("rms-refresh-token");
|
|
}
|
|
|
|
let eventSource = null;
|
|
let eventReconnectTimer = null;
|
|
let openWebRxTxPollTimer = null;
|
|
let openWebRxTxPollInFlight = false;
|
|
let openWebRxTxPollIntervalMs = null;
|
|
|
|
function connectEvents() {
|
|
if (!state.accessToken) {
|
|
return;
|
|
}
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
if (eventReconnectTimer) {
|
|
clearTimeout(eventReconnectTimer);
|
|
eventReconnectTimer = null;
|
|
}
|
|
eventSource = new EventSource(`/v1/events/stream?accessToken=${encodeURIComponent(state.accessToken)}`);
|
|
eventSource.onmessage = () => {};
|
|
eventSource.onerror = async () => {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
const refreshed = await tryRefreshToken({ reason: "sse" });
|
|
if (!state.user || (!state.accessToken && !refreshed)) {
|
|
return;
|
|
}
|
|
if (eventReconnectTimer) {
|
|
clearTimeout(eventReconnectTimer);
|
|
}
|
|
eventReconnectTimer = setTimeout(() => {
|
|
connectEvents();
|
|
}, 2000);
|
|
};
|
|
eventSource.addEventListener("station.status.changed", async () => {
|
|
await refreshStatus();
|
|
if (canSeeActivityLog()) {
|
|
await refreshActivityLog();
|
|
}
|
|
});
|
|
eventSource.addEventListener("station.activation.progress", async () => {
|
|
await refreshStatus();
|
|
});
|
|
eventSource.addEventListener("station.activation.completed", async (event) => {
|
|
activationPending = false;
|
|
stopActivationWatch();
|
|
let reportFromEvent = null;
|
|
try {
|
|
const payload = JSON.parse(event.data || "{}");
|
|
if (payload && payload.swrReport && typeof payload.swrReport === "object") {
|
|
reportFromEvent = payload.swrReport;
|
|
}
|
|
} catch {
|
|
// ignore malformed payload
|
|
}
|
|
if (reportFromEvent) {
|
|
state.swrReport = reportFromEvent;
|
|
renderSwrPanels();
|
|
}
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
const overall = state.swrReport && state.swrReport.overallStatus ? state.swrReport.overallStatus : "UNKNOWN";
|
|
const doneText = `SWR-Check ERFOLGREICH abgeschlossen. Ergebnis: ${overall}.`;
|
|
renderMessage(els.swrSummaryMessage, doneText, false, true);
|
|
renderMessage(els.swrPageMessage, doneText, false, true);
|
|
await refreshControls();
|
|
if (canSeeActivityLog()) {
|
|
await refreshActivityLog();
|
|
}
|
|
});
|
|
eventSource.addEventListener("station.activation.failed", async (event) => {
|
|
activationPending = false;
|
|
stopActivationWatch();
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
let errorText = "Unbekannter Fehler";
|
|
try {
|
|
const payload = JSON.parse(event.data || "{}");
|
|
if (payload && payload.error) {
|
|
errorText = String(payload.error);
|
|
}
|
|
} catch {
|
|
// ignore parse issues
|
|
}
|
|
const failText = `SWR-Check fertig, Aktivierung fehlgeschlagen: ${errorText}`;
|
|
renderMessage(els.swrSummaryMessage, failText, true);
|
|
renderMessage(els.swrPageMessage, failText, true);
|
|
await refreshControls();
|
|
});
|
|
eventSource.addEventListener("swr.run.started", async () => {
|
|
await refreshStatus();
|
|
});
|
|
eventSource.addEventListener("swr.run.finished", async () => {
|
|
await refreshStatus();
|
|
await refreshSwrReport();
|
|
});
|
|
eventSource.addEventListener("swr.report.changed", async () => {
|
|
await refreshSwrReport();
|
|
});
|
|
eventSource.addEventListener("plugin.provider.changed", async () => {
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
});
|
|
eventSource.addEventListener("plugin.enabled.changed", async () => {
|
|
await refreshPlugins();
|
|
await refreshControls();
|
|
});
|
|
eventSource.addEventListener("plugin.health.changed", async () => {
|
|
await refreshPlugins();
|
|
});
|
|
eventSource.addEventListener("approval.status.changed", async () => {
|
|
await refreshApprovals();
|
|
await refreshUsers();
|
|
if (canSeeActivityLog()) {
|
|
await refreshActivityLog();
|
|
}
|
|
});
|
|
eventSource.addEventListener("system.maintenance.enabled", async (event) => {
|
|
const payload = JSON.parse(event.data || "{}");
|
|
state.system.maintenanceMode = true;
|
|
state.system.maintenanceMessage = payload.message || state.system.maintenanceMessage;
|
|
if (!isAdmin()) {
|
|
await logout(true, true);
|
|
return;
|
|
}
|
|
renderMaintenanceBanner();
|
|
});
|
|
eventSource.addEventListener("system.maintenance.disabled", async () => {
|
|
state.system.maintenanceMode = false;
|
|
await refreshPublicSystemStatus();
|
|
});
|
|
eventSource.addEventListener("branding.updated", async () => {
|
|
await refreshPublicSystemStatus();
|
|
});
|
|
}
|
|
|
|
async function api(path, options = {}, triedRefresh = false) {
|
|
const headers = {
|
|
"Content-Type": "application/json"
|
|
};
|
|
const authRequired = options.authRequired !== false;
|
|
if (authRequired && state.accessToken) {
|
|
headers.Authorization = `Bearer ${state.accessToken}`;
|
|
}
|
|
|
|
const response = await fetch(path, {
|
|
method: options.method || "GET",
|
|
headers,
|
|
body: options.body ? JSON.stringify(options.body) : undefined
|
|
});
|
|
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (response.status === 401 && authRequired && !triedRefresh && state.refreshToken) {
|
|
const refreshed = await tryRefreshToken();
|
|
if (refreshed) {
|
|
return api(path, options, true);
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = new Error((payload.error && payload.error.message) || `Request failed: ${response.status}`);
|
|
err.status = response.status;
|
|
err.code = payload && payload.error ? payload.error.code : undefined;
|
|
throw err;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function tryRefreshToken(options = {}) {
|
|
const reason = String(options.reason || "api");
|
|
if (!state.refreshToken) {
|
|
return false;
|
|
}
|
|
try {
|
|
const result = await api("/v1/auth/refresh", {
|
|
method: "POST",
|
|
body: { refreshToken: state.refreshToken },
|
|
authRequired: false
|
|
}, true);
|
|
setTokens(result.accessToken, result.refreshToken);
|
|
state.user = result.user;
|
|
updateUserUi();
|
|
return true;
|
|
} catch {
|
|
if (reason !== "sse") {
|
|
clearTokens();
|
|
state.user = null;
|
|
updateUserUi();
|
|
}
|
|
return false;
|
|
}
|
|
}
|