"use strict"; "require form"; "require uci"; "require baseclass"; "require fs"; "require network"; "require ui"; "require tools.widgets as widgets"; "require view.tachyon.main as main"; "require view.tachyon.local_devices as local_devices"; const UCI_PACKAGE = main.TACHYON_UCI_PACKAGE; function isSingBoxDuration(value) { return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value); } function latencyTestUrlChoices() { return Array.isArray(main.LATENCY_TEST_URL_OPTIONS) ? main.LATENCY_TEST_URL_OPTIONS : [main.DEFAULT_LATENCY_TEST_URL || "https://www.gstatic.com/generate_204"]; } function validateLatencyTestUrl(value) { const validation = main.validateUrl(`${value || ""}`.trim()); return validation.valid ? true : validation.message; } function isDownloadSectionAction(action, capabilities) { switch (action) { case "connection": case "proxy": case "outbound": case "vpn": case "awg": return true; case "zapret": return !capabilities?.loaded || Boolean(capabilities.zapretInstalled); case "zapret2": return !capabilities?.loaded || Boolean(capabilities.zapret2Installed); case "byedpi": return !capabilities?.loaded || Boolean(capabilities.byedpiInstalled); default: return false; } } function refreshDownloadSectionChoices(option, capabilities) { const sections = option.map?.data?.state?.values?.[UCI_PACKAGE] ?? {}; option.keylist = []; option.vallist = []; for (const secName in sections) { const sec = sections[secName]; if ( sec[".type"] === "section" && sec.enabled !== "0" && isDownloadSectionAction(sec.action, capabilities) ) { option.value(secName, sec.label || secName); } } } function configureDownloadSectionOption(option, sectionOption, capabilities) { option.default = ""; option.rmempty = false; option.cfgvalue = function (section_id) { return uci.get(UCI_PACKAGE, section_id, sectionOption) || ""; }; option.load = function (section_id) { refreshDownloadSectionChoices(this, capabilities); return this.cfgvalue(section_id); }; option.write = function (section_id, value) { const normalized = value ? `${value}`.trim() : ""; if (normalized) { uci.set(UCI_PACKAGE, section_id, sectionOption, normalized); } else { uci.unset(UCI_PACKAGE, section_id, sectionOption); } }; option.remove = function (section_id) { uci.unset(UCI_PACKAGE, section_id, sectionOption); }; option.validate = function (_section_id, value) { return value ? true : _("Select a section"); }; } function configureDownloadViaProxyFlag(option, sectionOption) { option.default = "0"; option.rmempty = false; option.write = function (section_id, value) { const enabled = value === "1" || value === true; uci.set(UCI_PACKAGE, section_id, this.option, enabled ? "1" : "0"); if (!enabled) { uci.unset(UCI_PACKAGE, section_id, sectionOption); } }; } function optionListValues(option, section_id) { const formValue = option.formvalue(section_id); const value = formValue != null ? formValue : option.cfgvalue(section_id); return L.toArray(value) .map((item) => `${item || ""}`.trim()) .filter(Boolean); } function configureDnsList(option, choices, defaultValue) { Object.entries(choices).forEach(([key, label]) => { option.value(key, _(label)); }); option.default = [defaultValue]; option.rmempty = false; option.validate = function (_section_id, value) { const normalized = `${value || ""}`.trim(); if (!normalized) { return optionListValues(option, _section_id).length > 0 ? true : _("Add at least one DNS server"); } const validation = main.validateDNS(normalized); return validation.valid ? true : validation.message; }; } function configureDnsFailoverVisibility( option, dnsOption, bootstrapOption, fallbackOption, ) { option.depends("dns_server", "__tachyon_multiple_dns__"); option.depends("bootstrap_dns_server", "__tachyon_multiple_dns__"); option.retain = true; option.checkDepends = function (section_id) { if ( optionListValues(dnsOption, section_id).length > 1 || optionListValues(bootstrapOption, section_id).length > 1 ) return true; // Also show when explicit fallback servers are configured if (fallbackOption) { return optionListValues(fallbackOption, section_id).length > 0; } return false; }; } function refreshOptionChoices(option, choices) { delete option.keylist; delete option.vallist; (choices || []).forEach((choice) => { if (typeof choice === "object") { option.value(choice.value, choice.label); } else { option.value(choice); } }); } function getDnsServerChoices(dnsType) { const servers = main.DNS_SERVERS_BY_PROTOCOL[dnsType] || main.DNS_SERVERS_BY_PROTOCOL.udp; return Object.entries(servers).map(([value, label]) => ({ value, label: _(label), })); } function configureDnsDynamicList(option, getChoices, defaultValue) { option.default = [defaultValue]; option.rmempty = false; option.validate = function (_section_id, value) { const normalized = `${value || ""}`.trim(); if (!normalized) { return optionListValues(option, _section_id).length > 0 ? true : _("Add at least one DNS server"); } const validation = main.validateDNS(normalized); return validation.valid ? true : validation.message; }; option.renderWidget = function (section_id, _option_index, cfgvalue) { const values = L.toArray(cfgvalue != null ? cfgvalue : this.default); const choices = getChoices(section_id, values); const labels = {}; choices.forEach((choice) => { labels[choice.value] = choice.label; }); refreshOptionChoices(this, choices); let choiceSignature = JSON.stringify( choices.map((choice) => [choice.value, choice.label]), ); const widget = new ui.DynamicList(values, labels, { id: this.cbid(section_id), sort: this.keylist, optional: this.optional || this.rmempty, datatype: this.datatype, placeholder: this.placeholder, validate: L.bind(this.validate, this, section_id), disabled: this.readonly != null ? this.readonly : this.map.readonly, }); const node = widget.render(); const refreshChoices = () => { if (!node.isConnected) return false; const currentValues = widget.getValue(); const currentChoices = getChoices(section_id, currentValues); const currentLabels = {}; currentChoices.forEach((choice) => { currentLabels[choice.value] = choice.label; }); const currentSignature = JSON.stringify( currentChoices.map((choice) => [choice.value, choice.label]), ); if (currentSignature === choiceSignature) return; choiceSignature = currentSignature; refreshOptionChoices(this, currentChoices); widget.choices = currentLabels; widget.clearChoices(); widget.addChoices( currentChoices.map((choice) => choice.value), currentLabels, ); }; const refreshBeforeOpening = (event) => { if (event.target && event.target.closest(".add-item")) refreshChoices(); }; node.addEventListener("mousedown", refreshBeforeOpening, true); node.addEventListener("focusin", refreshBeforeOpening, true); if (!settingsDnsDynamicState.refreshers.has(section_id)) { settingsDnsDynamicState.refreshers.set(section_id, new Set()); } settingsDnsDynamicState.refreshers.get(section_id).add(refreshChoices); settingsDnsDynamicState.widget = widget; settingsDnsDynamicState.option = this; return node; }; } const settingsDnsDynamicState = { dnsType: null, widget: null, option: null, refreshers: new Map(), }; function getDefaultDnsServers(dnsType) { const servers = main.DNS_SERVERS_BY_PROTOCOL[dnsType] || main.DNS_SERVERS_BY_PROTOCOL.udp; const keys = Object.keys(servers); return keys.length > 0 ? [keys[0]] : ["77.88.8.8"]; } function configureDnsDuration( option, defaultValue, dnsOption, bootstrapOption, fallbackOption, ) { option.default = defaultValue; option.rmempty = false; option.validate = function (_section_id, value) { const normalized = `${value || ""}`.trim(); if (!normalized || !isSingBoxDuration(normalized)) { return _("Use sing-box duration format like 10s, 1m or 2m30s"); } return true; }; configureDnsFailoverVisibility( option, dnsOption, bootstrapOption, fallbackOption, ); } function createWatchdogStatusWidget() { const wrapper = E("div", { id: "tachyon-watchdog-status-widget", style: "display:flex;align-items:center;gap:12px;padding:4px 0;flex-wrap:wrap;", }); const indicator = E("span", { style: "display:inline-flex;align-items:center;gap:6px;", }); const dot = E("span", { id: "tachyon-watchdog-status-dot", style: "display:inline-block;width:10px;height:10px;border-radius:50%;background:#aaa;flex-shrink:0;", }); const statusText = E("span", { id: "tachyon-watchdog-status-text" }); statusText.textContent = _("Checking\u2026"); indicator.appendChild(dot); indicator.appendChild(statusText); const btnStart = E("button", { class: "btn cbi-button cbi-button-action", type: "button", style: "display:none;", }); btnStart.textContent = _("Start"); const btnStop = E("button", { class: "btn cbi-button cbi-button-negative", type: "button", style: "display:none;", }); btnStop.textContent = _("Stop"); const msgEl = E("span", { style: "font-size:12px;color:var(--text-color-medium,#888);", }); function applyWdStatus(running) { if (running) { dot.style.background = "#4caf50"; statusText.textContent = _("Running"); btnStart.style.display = "none"; btnStop.style.display = ""; btnStop.disabled = false; } else { dot.style.background = "#f44336"; statusText.textContent = _("Stopped"); btnStart.style.display = ""; btnStart.disabled = false; btnStop.style.display = "none"; } } function refreshWdStatus() { return fs .exec("/usr/bin/tachyon", ["watchdog", "status"]) .then(function (res) { const out = ((res && res.stdout) || "").trim(); try { const data = JSON.parse(out); applyWdStatus(Boolean(data.running)); } catch (e) { applyWdStatus(out.indexOf("running") === 0); } }) .catch(function () { dot.style.background = "#aaa"; statusText.textContent = _("Unknown"); }); } btnStart.addEventListener("click", function () { btnStart.disabled = true; msgEl.textContent = _("Starting\u2026"); fs.exec("/usr/bin/tachyon", ["watchdog_start"]) .then(function () { msgEl.textContent = ""; return refreshWdStatus(); }) .catch(function () { msgEl.textContent = _("Failed to start watchdog"); btnStart.disabled = false; }); }); btnStop.addEventListener("click", function () { btnStop.disabled = true; msgEl.textContent = _("Stopping\u2026"); fs.exec("/usr/bin/tachyon", ["watchdog_stop"]) .then(function () { msgEl.textContent = ""; return refreshWdStatus(); }) .catch(function () { msgEl.textContent = _("Failed to stop watchdog"); btnStop.disabled = false; }); }); wrapper.appendChild(indicator); wrapper.appendChild(btnStart); wrapper.appendChild(btnStop); wrapper.appendChild(msgEl); refreshWdStatus(); const wdTimer = setInterval(refreshWdStatus, 10000); const wdObserver = new MutationObserver(function () { if (!document.body.contains(wrapper)) { clearInterval(wdTimer); wdObserver.disconnect(); } }); wdObserver.observe(document.body, { childList: true, subtree: true }); return wrapper; } function createResetSettingsWidget() { const wrapper = E("div", { id: "tachyon-reset-settings-widget", style: "display:flex;align-items:center;gap:12px;padding:4px 0;flex-wrap:wrap;", }); const btnReset = E("button", { class: "btn cbi-button cbi-button-negative", type: "button", }); btnReset.textContent = _("Reset Settings"); const msgEl = E("span", { style: "font-size:12px;color:var(--text-color-medium,#888);", }); function performReset() { if (btnReset.disabled) return; btnReset.disabled = true; msgEl.textContent = _("Resetting\u2026"); fs.exec("/usr/bin/tachyon", ["reset_settings"]) .then(function (res) { let success = false; try { success = JSON.parse((res && res.stdout) || "").success !== false; } catch (e) { success = false; } if (!success) throw new Error("reset failed"); msgEl.textContent = ""; ui.addNotification( null, E( "p", {}, _("Settings have been reset to defaults. The page will reload."), ), "info", ); setTimeout(function () { location.reload(); }, 1500); }) .catch(function () { msgEl.textContent = _("Failed to reset settings"); btnReset.disabled = false; }); } btnReset.addEventListener("click", function () { ui.showModal( _("Reset Settings"), [ E( "p", {}, _( "This will erase all Tachyon settings and restore the factory defaults. The service will restart. This action cannot be undone.", ), ), E("div", { class: "button-row" }, [ E( "button", { class: "btn cbi-button cbi-button-neutral", type: "button", click: function () { ui.hideModal(); }, }, _("Cancel"), ), E( "button", { class: "btn cbi-button cbi-button-negative", type: "button", click: function () { ui.hideModal(); performReset(); }, }, _("Reset"), ), ]), ], "cbi-modal", ); }); wrapper.appendChild(btnReset); wrapper.appendChild(msgEl); return wrapper; } function createSnapshotsWidget() { const wrapper = E("div", { id: "tachyon-snapshots-widget", style: "display:flex;flex-direction:column;gap:8px;padding:4px 0;", }); const btnSave = E("button", { class: "btn cbi-button cbi-button-positive", type: "button", }); btnSave.textContent = _("Save Snapshot"); const listEl = E("div", { style: "display:flex;flex-direction:column;gap:6px;", }); function formatStamp(stamp) { if (!stamp) return ""; return stamp.replace( /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/, "$1-$2-$3 $4:$5", ); } function formatSize(size) { if (size == null) return ""; if (size >= 1048576) return (size / 1048576).toFixed(1) + " MB"; if (size >= 1024) return Math.round(size / 1024) + " KB"; return size + " B"; } function execSnapshot(args) { return fs.exec("/usr/bin/tachyon", args).then(function (res) { let ok = false; try { let stdout = ((res && res.stdout) || "").trim(); let match = stdout.match(/\{[\s\S]*\}/); ok = JSON.parse(match ? match[0] : stdout).success !== false; } catch (e) { ok = false; } if (!ok) throw new Error("command failed"); }); } function performRestore(file) { execSnapshot(["snapshot_restore", file]) .then(function () { ui.addNotification( null, E( "p", {}, _( "Snapshot restored. The service restarts and the page will reload.", ), ), "info", ); setTimeout(function () { location.reload(); }, 2500); }) .catch(function () { ui.addNotification( null, E("p", {}, _("Failed to restore snapshot")), "error", ); loadSnapshots(); }); } function performDelete(file) { execSnapshot(["snapshot_delete", file]) .then(function () { loadSnapshots(); }) .catch(function () { ui.addNotification( null, E("p", {}, _("Failed to delete snapshot")), "error", ); }); } function loadSnapshots() { listEl.innerHTML = ""; fs.exec("/usr/bin/tachyon", ["snapshot_list"]) .then(function (res) { let snapshots = []; try { snapshots = ( JSON.parse((res && res.stdout) || "").snapshots || [] ).slice(0, 10); } catch (e) { snapshots = []; } if (snapshots.length === 0) { listEl.appendChild( E( "span", { style: "font-size:12px;color:var(--text-color-medium,#888);", }, _("No snapshots yet"), ), ); return; } snapshots.forEach(function (snap) { const row = E("div", { style: "display:flex;align-items:center;gap:10px;padding:2px 0;flex-wrap:wrap;", }); row.appendChild( E("span", { style: "font-weight:bold;" }, snap.name || ""), ); row.appendChild( E( "span", { style: "font-size:12px;color:var(--text-color-medium,#888);", }, formatStamp(snap.stamp) + (snap.size != null ? " \u00b7 " + formatSize(snap.size) : ""), ), ); row.appendChild( E( "button", { class: "btn cbi-button cbi-button-action", type: "button", click: function () { ui.showModal( _("Restore Snapshot"), [ E( "p", {}, _( "This will replace the current Tachyon settings with the snapshot", ) + ": \u201c" + (snap.name || "") + "\u201d. The service will restart.", ), E("div", { class: "button-row" }, [ E( "button", { class: "btn cbi-button cbi-button-neutral", type: "button", click: function () { ui.hideModal(); }, }, _("Cancel"), ), E( "button", { class: "btn cbi-button cbi-button-negative", type: "button", click: function () { ui.hideModal(); performRestore(snap.file); }, }, _("Restore"), ), ]), ], "cbi-modal", ); }, }, _("Restore"), ), ); row.appendChild( E( "button", { class: "btn cbi-button cbi-button-negative", type: "button", click: function () { ui.showModal( _("Delete Snapshot"), [ E( "p", {}, _("Delete the snapshot") + ": \u201c" + (snap.name || "") + "\u201d?", ), E("div", { class: "button-row" }, [ E( "button", { class: "btn cbi-button cbi-button-neutral", type: "button", click: function () { ui.hideModal(); }, }, _("Cancel"), ), E( "button", { class: "btn cbi-button cbi-button-negative", type: "button", click: function () { ui.hideModal(); performDelete(snap.file); }, }, _("Delete"), ), ]), ], "cbi-modal", ); }, }, _("Delete"), ), ); listEl.appendChild(row); }); }) .catch(function () { listEl.appendChild( E( "span", { style: "font-size:12px;color:var(--text-color-medium,#888);", }, _("Failed to load snapshots"), ), ); }); } btnSave.addEventListener("click", function () { const input = E("input", { type: "text", class: "cbi-input-text", style: "width:100%;", maxlength: 64, }); input.placeholder = _("Snapshot name"); ui.showModal( _("Save Snapshot"), [ E( "p", {}, _( "Give the snapshot a name. It is saved with the current date and can be restored from this list later.", ), ), input, E("div", { class: "button-row" }, [ E( "button", { class: "btn cbi-button cbi-button-neutral", type: "button", click: function () { ui.hideModal(); }, }, _("Cancel"), ), E( "button", { class: "btn cbi-button cbi-button-positive", type: "button", click: function () { const name = (input.value || "").trim(); if (!name) return; ui.hideModal(); execSnapshot(["snapshot_save", name]) .then(function () { loadSnapshots(); }) .catch(function () { ui.addNotification( null, E("p", {}, _("Failed to save snapshot")), "error", ); }); }, }, _("Save"), ), ]), ], "cbi-modal", ); }); wrapper.appendChild( E( "div", { style: "display:flex;align-items:center;gap:12px;flex-wrap:wrap;", }, btnSave, E( "span", { style: "font-size:12px;color:var(--text-color-medium,#888);", }, _( "Up to 10 snapshots are kept; the oldest ones are removed automatically.", ), ), ), ); wrapper.appendChild(listEl); loadSnapshots(); return wrapper; } function createSmartDetectSectionsWidget(section_id) { const TESTABLE_ACTIONS = [ "connection", "proxy", "outbound", "vpn", "awg", "warp", "anytls", "snell", "mieru", "sudoku", "masque", "openvpn", "wdtt", "olcrtc", "fptn", ]; const allSections = (uci.sections(UCI_PACKAGE, "section") || []) .filter(function (s) { return s.enabled !== "0" && TESTABLE_ACTIONS.indexOf(s.action) >= 0; }) .map(function (s) { return s[".name"]; }); if (allSections.length === 0) { const empty = E("em", { style: "color:var(--text-color-medium,#888);font-size:0.9rem;", }); empty.textContent = _("No active routing sections found."); return empty; } const rawVal = uci.get(UCI_PACKAGE, section_id, "smart_detect_sections"); const savedSections = L.toArray(rawVal || []); // Build ordered list: saved sections first (preserving order), then any not yet included const ordered = []; savedSections.forEach(function (name) { if (allSections.indexOf(name) >= 0 && ordered.indexOf(name) < 0) { ordered.push(name); } }); allSections.forEach(function (name) { if (ordered.indexOf(name) < 0) ordered.push(name); }); // enabledSet: which sections are checked const enabledSet = {}; if (savedSections.length > 0) { savedSections.forEach(function (name) { enabledSet[name] = true; }); } else if (ordered.length > 0) { enabledSet[ordered[0]] = true; } const wrapper = E("div", { id: "smart-detect-sections-widget-" + section_id, }); const listEl = E("div", { style: "border:1px solid var(--border-color,#dee2e6);border-radius:4px;overflow:hidden;margin-bottom:8px;max-width:480px;", }); function updateValue() { wrapper.value = ordered.filter(function (name) { return Boolean(enabledSet[name]); }); } function renderSdRow(name, idx, totalLen) { const isEnabled = Boolean(enabledSet[name]); const row = E("div", { style: [ "display:flex;align-items:center;gap:10px;padding:7px 10px;", idx < totalLen - 1 ? "border-bottom:1px solid var(--border-color,#dee2e6);" : "", isEnabled ? "" : "opacity:0.5;", ].join(""), }); const cb = E("input", { type: "checkbox" }); cb.checked = isEnabled; cb.addEventListener("change", function (ev) { enabledSet[name] = ev.target.checked; updateValue(); renderSdList(); }); const label = E("span", { style: "flex:1;font-family:monospace;font-size:0.9rem;user-select:none;", }); label.textContent = name; const upBtn = E("button", { class: "btn", type: "button", style: "padding:1px 8px;font-size:0.75rem;line-height:1.4;border:1px solid var(--border-color,#ccc);border-radius:3px;background:transparent;cursor:pointer;", }); upBtn.disabled = idx === 0; upBtn.textContent = "\u25b3"; upBtn.addEventListener("click", function () { if (idx > 0) { const tmp = ordered[idx - 1]; ordered[idx - 1] = ordered[idx]; ordered[idx] = tmp; updateValue(); renderSdList(); } }); const downBtn = E("button", { class: "btn", type: "button", style: "padding:1px 8px;font-size:0.75rem;line-height:1.4;border:1px solid var(--border-color,#ccc);border-radius:3px;background:transparent;cursor:pointer;", }); downBtn.disabled = idx === totalLen - 1; downBtn.textContent = "\u25bd"; downBtn.addEventListener("click", function () { if (idx < ordered.length - 1) { const tmp = ordered[idx + 1]; ordered[idx + 1] = ordered[idx]; ordered[idx] = tmp; updateValue(); renderSdList(); } }); row.appendChild(cb); row.appendChild(label); row.appendChild(upBtn); row.appendChild(downBtn); return row; } function renderSdList() { while (listEl.firstChild) listEl.removeChild(listEl.firstChild); ordered.forEach(function (name, idx) { listEl.appendChild(renderSdRow(name, idx, ordered.length)); }); } renderSdList(); updateValue(); wrapper.appendChild(listEl); return wrapper; } function createMenuTabsOrderWidget(option, section_id) { const ALL_MENU_TABS = [ { id: "dashboard", label: _("Dashboard"), desc: _("Main overview and status widgets"), defaultVisible: true, fixed: false, }, { id: "section", label: _("Sections"), desc: _("Routing sections, rules and strategies"), defaultVisible: true, fixed: false, }, { id: "server", label: _("Servers"), desc: _("External inbound proxy servers"), defaultVisible: false, fixed: false, }, { id: "profile", label: _("Family Profiles"), desc: _("Family profiles and device grouping"), defaultVisible: false, fixed: false, }, { id: "schedule", label: _("Parental Control"), desc: _("Access schedule and screen time limits"), defaultVisible: false, fixed: false, }, { id: "guest_mode", label: _("Guest Mode"), desc: _("Guest network isolation and device quotas"), defaultVisible: true, fixed: false, }, { id: "monitoring", label: _("Monitoring"), desc: _("Traffic statistics and active connections"), defaultVisible: true, fixed: false, }, { id: "diagnostic", label: _("Diagnostics"), desc: _("System diagnostics, AI Doctor and logs"), defaultVisible: true, fixed: false, }, { id: "updates", label: _("Components"), desc: _("Component versions and update manager"), defaultVisible: true, fixed: false, }, { id: "settings", label: _("Settings"), desc: _("Core configuration and preferences"), defaultVisible: true, fixed: true, }, { id: "telegram", label: _("Telegram Bot"), desc: _("Remote management Telegram daemon"), defaultVisible: true, fixed: false, }, ]; const rawTabOrder = uci.get(UCI_PACKAGE, section_id, "tab_order"); const savedOrder = Array.isArray(rawTabOrder) ? rawTabOrder : typeof rawTabOrder === "string" && rawTabOrder.trim() ? rawTabOrder.trim().split(/\s+/) : []; const tabMap = {}; ALL_MENU_TABS.forEach(function (t) { tabMap[t.id] = t; }); const ordered = []; savedOrder.forEach(function (id) { if (tabMap[id] && ordered.indexOf(id) < 0) { ordered.push(id); } }); ALL_MENU_TABS.forEach(function (t) { if (ordered.indexOf(t.id) < 0) { ordered.push(t.id); } }); const visibility = {}; ALL_MENU_TABS.forEach(function (t) { if (t.fixed) { visibility[t.id] = true; } else { let optKey = "show_tab_" + t.id; if (t.id === "server") optKey = "show_tab_servers"; else if (t.id === "profile") optKey = "show_tab_profiles"; else if (t.id === "schedule") optKey = "show_tab_parental"; const val = uci.get(UCI_PACKAGE, section_id, optKey); if (val === undefined || val === null || val === "") { visibility[t.id] = t.defaultVisible; } else { visibility[t.id] = val === "1" || val === true; } } }); const wrapper = E("div", { id: "menu-tabs-order-widget-" + section_id }); const listEl = E("div", { style: "border:1px solid var(--border-color,#dee2e6);border-radius:6px;overflow:hidden;margin-bottom:8px;max-width:560px;background:var(--card-bg,transparent);", }); function syncToUci() { uci.set(UCI_PACKAGE, section_id, "tab_order", ordered); wrapper._order = ordered.slice(); wrapper._vis = Object.assign({}, visibility); ALL_MENU_TABS.forEach(function (t) { if (!t.fixed) { let optKey = "show_tab_" + t.id; if (t.id === "server") optKey = "show_tab_servers"; else if (t.id === "profile") optKey = "show_tab_profiles"; else if (t.id === "schedule") optKey = "show_tab_parental"; uci.set(UCI_PACKAGE, section_id, optKey, visibility[t.id] ? "1" : "0"); } }); } function renderTabRow(id, idx, totalLen) { const tabMeta = tabMap[id] || { id: id, label: id, desc: "", fixed: false }; const isVisible = Boolean(visibility[id]); const isFixed = Boolean(tabMeta.fixed); const row = E("div", { style: [ "display:flex;align-items:center;gap:12px;padding:8px 12px;", idx < totalLen - 1 ? "border-bottom:1px solid var(--border-color,#dee2e6);" : "", isVisible ? "" : "opacity:0.5;background:rgba(0,0,0,0.02);", ].join(""), }); const badge = E( "span", { style: "font-size:0.75rem;font-weight:bold;color:var(--text-color-medium,#888);min-width:18px;text-align:center;", }, String(idx + 1), ); // Use a