Keep both: upstream RAM-usage calculator for dns_cache_size and the fork AdGuard Home DNS mode options (dns_main_mode / dns_adguard_failover_enabled).
3819 lines
106 KiB
JavaScript
3819 lines
106 KiB
JavaScript
"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 <button> toggle instead of <input type="checkbox"> to avoid LuCI
|
||
// theme pointer-events overrides that swallow checkbox click events.
|
||
const toggleBtn = E("button", {
|
||
type: "button",
|
||
title: isFixed
|
||
? _("Always visible")
|
||
: isVisible
|
||
? _("Click to hide")
|
||
: _("Click to show"),
|
||
style: [
|
||
"width:20px;height:20px;padding:0;border-radius:3px;border:2px solid;",
|
||
"display:flex;align-items:center;justify-content:center;",
|
||
"font-size:13px;font-weight:bold;line-height:1;cursor:",
|
||
isFixed ? "default;" : "pointer;",
|
||
"flex-shrink:0;",
|
||
isFixed || isVisible
|
||
? "border-color:var(--primary-color,#2196f3);background:var(--primary-color,#2196f3);color:#fff;"
|
||
: "border-color:var(--border-color,#aaa);background:transparent;color:transparent;",
|
||
].join(""),
|
||
});
|
||
toggleBtn.textContent = "✓";
|
||
if (isFixed) toggleBtn.disabled = true;
|
||
|
||
if (!isFixed) {
|
||
toggleBtn.addEventListener("click", function (ev) {
|
||
ev.preventDefault();
|
||
visibility[id] = !visibility[id];
|
||
syncToUci();
|
||
renderList();
|
||
});
|
||
}
|
||
|
||
const textCol = E(
|
||
"div",
|
||
{
|
||
style: "flex:1;display:flex;flex-direction:column;",
|
||
},
|
||
[
|
||
E("span", { style: "font-weight:600;font-size:0.95rem;" }, [
|
||
tabMeta.label,
|
||
isFixed
|
||
? E(
|
||
"em",
|
||
{
|
||
style:
|
||
"font-size:0.75rem;font-weight:normal;margin-left:6px;color:var(--text-color-medium,#888);",
|
||
},
|
||
" (" + _("Always visible") + ")",
|
||
)
|
||
: "",
|
||
]),
|
||
tabMeta.desc
|
||
? E(
|
||
"span",
|
||
{
|
||
style: "font-size:0.75rem;color:var(--text-color-medium,#888);",
|
||
},
|
||
tabMeta.desc,
|
||
)
|
||
: "",
|
||
],
|
||
);
|
||
|
||
const upBtn = E(
|
||
"button",
|
||
{
|
||
class: "btn cbi-button",
|
||
type: "button",
|
||
title: _("Move Up"),
|
||
style:
|
||
"padding:2px 8px;font-size:0.8rem;line-height:1.2;cursor:pointer;",
|
||
},
|
||
"▲",
|
||
);
|
||
upBtn.disabled = idx === 0;
|
||
upBtn.addEventListener("click", function (ev) {
|
||
ev.preventDefault();
|
||
if (idx > 0) {
|
||
const tmp = ordered[idx - 1];
|
||
ordered[idx - 1] = ordered[idx];
|
||
ordered[idx] = tmp;
|
||
syncToUci();
|
||
renderList();
|
||
}
|
||
});
|
||
|
||
const downBtn = E(
|
||
"button",
|
||
{
|
||
class: "btn cbi-button",
|
||
type: "button",
|
||
title: _("Move Down"),
|
||
style:
|
||
"padding:2px 8px;font-size:0.8rem;line-height:1.2;cursor:pointer;",
|
||
},
|
||
"▼",
|
||
);
|
||
downBtn.disabled = idx === totalLen - 1;
|
||
downBtn.addEventListener("click", function (ev) {
|
||
ev.preventDefault();
|
||
if (idx < ordered.length - 1) {
|
||
const tmp = ordered[idx + 1];
|
||
ordered[idx + 1] = ordered[idx];
|
||
ordered[idx] = tmp;
|
||
syncToUci();
|
||
renderList();
|
||
}
|
||
});
|
||
|
||
row.appendChild(badge);
|
||
row.appendChild(toggleBtn);
|
||
row.appendChild(textCol);
|
||
row.appendChild(upBtn);
|
||
row.appendChild(downBtn);
|
||
return row;
|
||
}
|
||
|
||
function renderList() {
|
||
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
|
||
ordered.forEach(function (id, idx) {
|
||
listEl.appendChild(renderTabRow(id, idx, ordered.length));
|
||
});
|
||
}
|
||
|
||
renderList();
|
||
syncToUci();
|
||
|
||
wrapper.appendChild(listEl);
|
||
return wrapper;
|
||
}
|
||
|
||
function createComponentsVisibilityWidget(option, section_id) {
|
||
const ALL_COMPONENTS = [
|
||
{
|
||
id: "tachyon",
|
||
label: "Tachyon",
|
||
desc: _("Core orchestration service and LuCI application"),
|
||
defaultVisible: true,
|
||
fixed: true,
|
||
},
|
||
{
|
||
id: "sing_box",
|
||
label: "Sing-box",
|
||
desc: _("Universal proxy platform routing engine"),
|
||
defaultVisible: true,
|
||
fixed: true,
|
||
},
|
||
{
|
||
id: "zapret",
|
||
label: "Zapret",
|
||
desc: _("DPI bypass engine (nfqws v1)"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
{
|
||
id: "zapret2",
|
||
label: "Zapret2",
|
||
desc: _("Advanced DPI bypass engine with Lua scripts (nfqws2)"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
{
|
||
id: "byedpi",
|
||
label: "ByeDPI",
|
||
desc: _("Local SOCKS proxy desynchronizer (ciadpi)"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
{
|
||
id: "wdtt",
|
||
label: "WDTT",
|
||
desc: _("Western Digital Tunnel Transport"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
{
|
||
id: "olcrtc",
|
||
label: "OlcRTC",
|
||
desc: _("Real-time WebRTC anti-censorship tunnel"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
{
|
||
id: "fptn",
|
||
label: "FPTN",
|
||
desc: _("Fast packet transport network client"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
{
|
||
id: "tailscale",
|
||
label: "Tailscale",
|
||
desc: _("Zero config mesh VPN daemon"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
{
|
||
id: "direct_bypass",
|
||
label: _("Direct Proxy"),
|
||
desc: _("Dedicated direct HTTP/SOCKS5 outbound proxy"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
{
|
||
id: "adguard",
|
||
label: _("AdGuard Home"),
|
||
desc: _("Caching DNS with ad blocking and failover"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
{
|
||
id: "torrserver_direct",
|
||
label: _("TorrServer Direct"),
|
||
desc: _("Direct cgroup bypass for TorrServer"),
|
||
defaultVisible: true,
|
||
fixed: false,
|
||
},
|
||
];
|
||
|
||
const visibility = {};
|
||
ALL_COMPONENTS.forEach(function (c) {
|
||
if (c.fixed) {
|
||
visibility[c.id] = true;
|
||
} else {
|
||
const optKey = "show_component_" + c.id;
|
||
const val = uci.get(UCI_PACKAGE, section_id, optKey);
|
||
if (val === undefined || val === null || val === "") {
|
||
visibility[c.id] = c.defaultVisible;
|
||
} else {
|
||
visibility[c.id] = val === "1" || val === true;
|
||
}
|
||
}
|
||
});
|
||
|
||
const wrapper = E("div", {
|
||
id: "components-visibility-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() {
|
||
wrapper._vis = Object.assign({}, visibility);
|
||
ALL_COMPONENTS.forEach(function (c) {
|
||
if (!c.fixed) {
|
||
const optKey = "show_component_" + c.id;
|
||
uci.set(UCI_PACKAGE, section_id, optKey, visibility[c.id] ? "1" : "0");
|
||
}
|
||
});
|
||
}
|
||
wrapper._syncToUci = syncToUci;
|
||
|
||
function renderList() {
|
||
listEl.innerHTML = "";
|
||
ALL_COMPONENTS.forEach(function (c, idx) {
|
||
const isVisible = Boolean(visibility[c.id]);
|
||
const isFixed = Boolean(c.fixed);
|
||
|
||
const row = E("div", {
|
||
style: [
|
||
"display:flex;align-items:center;gap:12px;padding:8px 12px;",
|
||
idx < ALL_COMPONENTS.length - 1
|
||
? "border-bottom:1px solid var(--border-color,#dee2e6);"
|
||
: "",
|
||
isVisible ? "" : "opacity:0.5;background:rgba(0,0,0,0.02);",
|
||
].join(""),
|
||
});
|
||
|
||
const toggleBtn = E("button", {
|
||
type: "button",
|
||
title: isFixed
|
||
? _("Always visible")
|
||
: isVisible
|
||
? _("Click to hide")
|
||
: _("Click to show"),
|
||
style: [
|
||
"width:20px;height:20px;padding:0;border-radius:3px;border:2px solid;",
|
||
"display:flex;align-items:center;justify-content:center;",
|
||
"font-size:13px;font-weight:bold;line-height:1;cursor:",
|
||
isFixed ? "default;" : "pointer;",
|
||
"flex-shrink:0;",
|
||
isFixed || isVisible
|
||
? "border-color:var(--primary-color,#2196f3);background:var(--primary-color,#2196f3);color:#fff;"
|
||
: "border-color:var(--border-color,#aaa);background:transparent;color:transparent;",
|
||
].join(""),
|
||
});
|
||
toggleBtn.textContent = "✓";
|
||
if (isFixed) toggleBtn.disabled = true;
|
||
|
||
if (!isFixed) {
|
||
toggleBtn.addEventListener("click", function (ev) {
|
||
ev.preventDefault();
|
||
visibility[c.id] = !visibility[c.id];
|
||
syncToUci();
|
||
renderList();
|
||
});
|
||
}
|
||
|
||
const textWrap = E(
|
||
"div",
|
||
{ style: "flex:1;min-width:0;line-height:1.3;" },
|
||
[
|
||
E(
|
||
"div",
|
||
{
|
||
style:
|
||
"font-weight:600;font-size:0.875rem;color:inherit;",
|
||
},
|
||
c.label,
|
||
),
|
||
E(
|
||
"div",
|
||
{
|
||
style:
|
||
"font-size:0.75rem;opacity:0.75;",
|
||
},
|
||
c.desc,
|
||
),
|
||
],
|
||
);
|
||
|
||
row.appendChild(toggleBtn);
|
||
row.appendChild(textWrap);
|
||
listEl.appendChild(row);
|
||
});
|
||
}
|
||
|
||
renderList();
|
||
syncToUci();
|
||
|
||
wrapper.appendChild(listEl);
|
||
return wrapper;
|
||
}
|
||
|
||
function createSettingsContent(section, capabilities) {
|
||
section.tab("navigation", _("Menu & Navigation"));
|
||
section.tab("dns", _("DNS Settings"));
|
||
section.tab("network", _("Network & Interfaces"));
|
||
section.tab("services", _("Services & Access"));
|
||
section.tab("updates", _("Updates & Downloads"));
|
||
section.tab("ai", _("AI & Watchdog"));
|
||
section.tab("advanced", _("Advanced System"));
|
||
|
||
// ── Navigation & Menu Settings ──────────────────────────────────────────
|
||
|
||
let o = section.taboption(
|
||
"navigation",
|
||
form.ListValue,
|
||
"default_tab",
|
||
_("Default Tab on Startup"),
|
||
_("Select which tab opens automatically when entering Tachyon."),
|
||
);
|
||
o.value("dashboard", _("Dashboard"));
|
||
o.value("section", _("Sections"));
|
||
o.value("server", _("Servers"));
|
||
o.value("profile", _("Family Profiles"));
|
||
o.value("schedule", _("Parental Control"));
|
||
o.value("monitoring", _("Monitoring"));
|
||
o.value("diagnostic", _("Diagnostics"));
|
||
o.value("updates", _("Components"));
|
||
o.value("settings", _("Settings"));
|
||
o.value("telegram", _("Telegram Bot"));
|
||
o.default = "dashboard";
|
||
o.rmempty = false;
|
||
|
||
const tabsOrderOpt = section.taboption(
|
||
"navigation",
|
||
form.Value,
|
||
"_tabs_order_widget",
|
||
_("Tab Order & Visibility"),
|
||
_(
|
||
"Customize the order and visibility of tabs in the main navigation menu. Use the arrows to reorder and checkboxes to show or hide tabs.",
|
||
),
|
||
);
|
||
tabsOrderOpt.renderWidget = function (section_id) {
|
||
return createMenuTabsOrderWidget(this, section_id);
|
||
};
|
||
tabsOrderOpt.formvalue = function (section_id) {
|
||
const el = document.getElementById("menu-tabs-order-widget-" + section_id);
|
||
// Return serialized state so LuCI knows something changed
|
||
return el ? JSON.stringify({ order: el._order, vis: el._vis }) : "";
|
||
};
|
||
tabsOrderOpt.write = function (_section_id) {
|
||
// UCI is written directly by syncToUci() inside the widget on every interaction.
|
||
// Nothing extra to do here.
|
||
};
|
||
tabsOrderOpt.remove = function () {};
|
||
|
||
const compVisOpt = section.taboption(
|
||
"navigation",
|
||
form.Value,
|
||
"_components_visibility_widget",
|
||
_("Component Cards Visibility"),
|
||
_(
|
||
"Customize which component cards are displayed in the Components tab. Checkboxes show or hide components.",
|
||
),
|
||
);
|
||
compVisOpt.renderWidget = function (section_id) {
|
||
return createComponentsVisibilityWidget(this, section_id);
|
||
};
|
||
compVisOpt.formvalue = function (section_id) {
|
||
const el = document.getElementById(
|
||
"components-visibility-widget-" + section_id,
|
||
);
|
||
return el ? JSON.stringify(el._vis) : "";
|
||
};
|
||
compVisOpt.write = function (section_id) {
|
||
const el = document.getElementById(
|
||
"components-visibility-widget-" + section_id,
|
||
);
|
||
if (el && typeof el._syncToUci === "function") {
|
||
el._syncToUci();
|
||
}
|
||
};
|
||
compVisOpt.remove = function () {};
|
||
|
||
// ── DNS Settings ────────────────────────────────────────────────────────
|
||
|
||
const dnsBenchmarkBtn = section.taboption(
|
||
"dns",
|
||
form.Button,
|
||
"_dns_benchmark_btn",
|
||
_("DNS Auto-Tuner & Benchmark"),
|
||
_(
|
||
"Automatically benchmark latency, packet loss, and anti-censorship reliability of major DNS servers (UDP/DoH) from this router to find and apply the optimal configuration.",
|
||
),
|
||
);
|
||
dnsBenchmarkBtn.inputtitle = "⚡ " + _("Auto-Tune & Benchmark DNS");
|
||
dnsBenchmarkBtn.inputstyle = "action important";
|
||
dnsBenchmarkBtn.onclick = function () {
|
||
if (main && typeof main.renderDnsBenchmarkModal === "function") {
|
||
main.renderDnsBenchmarkModal();
|
||
}
|
||
};
|
||
|
||
settingsDnsDynamicState.dnsType =
|
||
uci.get(UCI_PACKAGE, "settings", "dns_type") || "udp";
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.ListValue,
|
||
"dns_type",
|
||
_("DNS Protocol Type"),
|
||
_("Select DNS protocol to use"),
|
||
);
|
||
o.value("doh", _("DNS over HTTPS (DoH)"));
|
||
o.value("dot", _("DNS over TLS (DoT)"));
|
||
o.value("doq", _("DNS over QUIC (DoQ)"));
|
||
o.value("udp", _("UDP (Unprotected DNS)"));
|
||
o.default = "udp";
|
||
o.rmempty = false;
|
||
|
||
const dnsTypeOption = o;
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.ListValue,
|
||
"dns_upstream_mode",
|
||
_("Upstream Mode"),
|
||
_(
|
||
"Sequential: failover to the next server after N failures. Parallel: aggressive failover — switches in seconds (1 failure = switch), best if your ISP blocks DoH/DoT.",
|
||
),
|
||
);
|
||
o.value("sequential", _("Sequential (one active at a time)"));
|
||
o.value("parallel", _("Parallel (fast failover, ≤3 s)"));
|
||
o.default = "sequential";
|
||
o.rmempty = false;
|
||
|
||
const dnsModeOption = o;
|
||
|
||
const dnsOption = section.taboption(
|
||
"dns",
|
||
form.DynamicList,
|
||
"dns_server",
|
||
_("DNS Servers"),
|
||
_(
|
||
"Main DNS server. If multiple servers are selected, a timeout switches to a backup.",
|
||
),
|
||
);
|
||
configureDnsDynamicList(
|
||
dnsOption,
|
||
(_section_id) => {
|
||
const dnsType = settingsDnsDynamicState.dnsType || "udp";
|
||
return getDnsServerChoices(dnsType);
|
||
},
|
||
"77.88.8.8",
|
||
);
|
||
|
||
const bootstrapOption = section.taboption(
|
||
"dns",
|
||
form.DynamicList,
|
||
"bootstrap_dns_server",
|
||
_("Bootstrap DNS Servers"),
|
||
_(
|
||
"DNS server used to obtain IP addresses for upstream DNS and proxies. If multiple servers are selected, a timeout switches to a backup.",
|
||
),
|
||
);
|
||
configureDnsList(
|
||
bootstrapOption,
|
||
main.BOOTSTRAP_DNS_SERVER_OPTIONS,
|
||
"77.88.8.8",
|
||
);
|
||
|
||
const fallbackDnsOption = section.taboption(
|
||
"dns",
|
||
form.DynamicList,
|
||
"dns_fallback_server",
|
||
_("Fallback DNS Servers"),
|
||
_(
|
||
"Plain-UDP DNS servers used as a last resort when all primary DNS fail. These are always unencrypted (no DoH/DoT). Example: 1.1.1.1, 8.8.8.8. Unlike the ISP fallback toggle, these are your own chosen servers.",
|
||
),
|
||
);
|
||
configureDnsList(fallbackDnsOption, main.BOOTSTRAP_DNS_SERVER_OPTIONS, "");
|
||
fallbackDnsOption.rmempty = true;
|
||
fallbackDnsOption.default = [];
|
||
fallbackDnsOption.validate = function (_section_id, value) {
|
||
const normalized = `${value || ""}`.trim();
|
||
if (!normalized) return true; // allow empty list
|
||
// Fallback servers are plain-UDP only — reject DoH/DoT/DoQ URLs and
|
||
// path-style DoH (e.g. "cloudflare-dns.com/dns-query"). This mirrors
|
||
// the backend validator.uc check so the user gets immediate feedback.
|
||
if (normalized.includes("://"))
|
||
return _(
|
||
"Fallback DNS servers must be plain UDP (IP or hostname). DoH/DoT URLs are not allowed here.",
|
||
);
|
||
if (normalized.includes("/"))
|
||
return _(
|
||
"Fallback DNS servers must be plain UDP (IP or hostname). Path-style DoH (e.g. domain/path) is not allowed here.",
|
||
);
|
||
const validation = main.validateDNS(normalized);
|
||
return validation.valid ? true : validation.message;
|
||
};
|
||
|
||
dnsTypeOption.onchange = function (_ev, section_id, value) {
|
||
const newType = value || "udp";
|
||
if (newType === settingsDnsDynamicState.dnsType) return;
|
||
settingsDnsDynamicState.dnsType = newType;
|
||
|
||
const widget = settingsDnsDynamicState.widget;
|
||
if (widget) {
|
||
const servers =
|
||
main.DNS_SERVERS_BY_PROTOCOL[newType] ||
|
||
main.DNS_SERVERS_BY_PROTOCOL.udp;
|
||
const defaultLabels = {};
|
||
Object.entries(servers).forEach(([v, l]) => {
|
||
defaultLabels[v] = _(l);
|
||
});
|
||
const defaultServers = getDefaultDnsServers(newType);
|
||
widget.choices = defaultLabels;
|
||
widget.setValue(defaultServers);
|
||
}
|
||
|
||
const refreshers = settingsDnsDynamicState.refreshers.get(section_id);
|
||
if (refreshers) {
|
||
refreshers.forEach((fn) => fn());
|
||
}
|
||
};
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Flag,
|
||
"fallback_wan_main",
|
||
_("Enable WAN DNS Fallback for Main DNS"),
|
||
_(
|
||
"⚠️ If all Main DNS fail 3 times, queries will be sent to your ISP's DNS in plaintext. Only use as a last resort to prevent complete internet loss.",
|
||
),
|
||
);
|
||
o.default = o.disabled;
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Flag,
|
||
"fallback_wan_bootstrap",
|
||
_("Enable WAN DNS Fallback for Bootstrap DNS"),
|
||
_(
|
||
"⚠️ If all Bootstrap DNS fail 3 times, queries will be sent to your ISP's DNS in plaintext. Only use as a last resort to prevent complete internet loss.",
|
||
),
|
||
);
|
||
o.default = o.disabled;
|
||
|
||
// Timing controls — hidden in parallel mode (auto-configured) and when only one server
|
||
function configureTimingVisibility(opt) {
|
||
opt.retain = true;
|
||
opt.checkDepends = function (section_id) {
|
||
// hide when parallel (auto-tuned) unless there are multiple servers
|
||
const mode =
|
||
uci.get(UCI_PACKAGE, "settings", "dns_upstream_mode") || "sequential";
|
||
if (mode === "parallel") return false;
|
||
return (
|
||
optionListValues(dnsOption, section_id).length > 1 ||
|
||
optionListValues(bootstrapOption, section_id).length > 1 ||
|
||
optionListValues(fallbackDnsOption, section_id).length > 0
|
||
);
|
||
};
|
||
}
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Value,
|
||
"dns_check_interval",
|
||
_("DNS Check Interval"),
|
||
_("How often to check the active DNS servers."),
|
||
);
|
||
configureDnsDuration(o, "10s", dnsOption, bootstrapOption, fallbackDnsOption);
|
||
configureTimingVisibility(o);
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Value,
|
||
"dns_recovery_check_interval",
|
||
_("Higher-priority DNS Check"),
|
||
_("How often to check whether a higher-priority DNS server has recovered."),
|
||
);
|
||
configureDnsDuration(o, "60s", dnsOption, bootstrapOption, fallbackDnsOption);
|
||
configureTimingVisibility(o);
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Value,
|
||
"dns_check_timeout",
|
||
_("DNS Unavailability Timeout"),
|
||
_(
|
||
"Maximum time to wait for example.com to resolve during a DNS health check.",
|
||
),
|
||
);
|
||
configureDnsDuration(o, "2s", dnsOption, bootstrapOption, fallbackDnsOption);
|
||
configureTimingVisibility(o);
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Value,
|
||
"dns_failure_threshold",
|
||
_("DNS Failures Before Switching"),
|
||
_(
|
||
"Number of consecutive failed checks required before switching DNS servers.",
|
||
),
|
||
);
|
||
o.default = "3";
|
||
o.rmempty = false;
|
||
o.datatype = "range(1, 10)";
|
||
configureDnsFailoverVisibility(
|
||
o,
|
||
dnsOption,
|
||
bootstrapOption,
|
||
fallbackDnsOption,
|
||
);
|
||
configureTimingVisibility(o);
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Value,
|
||
"dns_recovery_threshold",
|
||
_("DNS Successful Checks Before Recovery"),
|
||
_(
|
||
"Number of consecutive successful checks required before returning to a higher-priority DNS server.",
|
||
),
|
||
);
|
||
o.default = "3";
|
||
o.rmempty = false;
|
||
o.datatype = "range(1, 10)";
|
||
configureDnsFailoverVisibility(
|
||
o,
|
||
dnsOption,
|
||
bootstrapOption,
|
||
fallbackDnsOption,
|
||
);
|
||
configureTimingVisibility(o);
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Value,
|
||
"dns_rewrite_ttl",
|
||
_("DNS Rewrite TTL"),
|
||
_("Time in seconds for DNS record caching (default: 60)"),
|
||
);
|
||
o.default = "60";
|
||
o.rmempty = false;
|
||
o.validate = function (section_id, value) {
|
||
if (!value) {
|
||
return _("TTL value cannot be empty");
|
||
}
|
||
|
||
const ttl = parseInt(value);
|
||
if (isNaN(ttl) || ttl < 0) {
|
||
return _("TTL must be a positive number");
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Flag,
|
||
"dns_turbo_cache",
|
||
_("DNS Turbo Cache"),
|
||
_(
|
||
"Keeps FakeIP cache persistent across reboots and pre-resolves popular blocked domains on startup so first-visit latency is 0\u00a0ms.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Flag,
|
||
"dns_local_cache",
|
||
_("Local DNS Cache"),
|
||
_(
|
||
"Enables high-capacity local DNS caching in dnsmasq to accelerate DNS queries and absorb transient latency during service restarts.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Value,
|
||
"dns_cache_size",
|
||
_("DNS Cache Size (entries)"),
|
||
_(
|
||
"Maximum number of cached domain entries in dnsmasq (default: 10000 ≈ 1.2 MB RAM). Memory is allocated dynamically per request (~120 B per domain), not pre-allocated upfront. Recommended: 10000 for routers with ≥128 MB RAM, 2500–5000 for 64 MB devices.",
|
||
),
|
||
);
|
||
o.depends("dns_local_cache", "1");
|
||
o.default = "10000";
|
||
o.datatype = "uinteger";
|
||
o.placeholder = "10000";
|
||
o.rmempty = true;
|
||
|
||
const originalDnsCacheRenderWidget = o.renderWidget;
|
||
o.renderWidget = function (section_id, option_index, cfgvalue) {
|
||
const node = originalDnsCacheRenderWidget.call(
|
||
this,
|
||
section_id,
|
||
option_index,
|
||
cfgvalue,
|
||
);
|
||
const input =
|
||
node && typeof node.querySelector === "function"
|
||
? node.querySelector("input")
|
||
: node;
|
||
|
||
const calcNode = document.createElement("div");
|
||
calcNode.className = "cbi-value-description";
|
||
calcNode.style.marginTop = "4px";
|
||
calcNode.style.fontSize = "12px";
|
||
|
||
const updateCalc = () => {
|
||
const rawVal =
|
||
input && input.value !== undefined && input.value !== ""
|
||
? input.value
|
||
: (cfgvalue || 10000);
|
||
const val = parseInt(rawVal, 10);
|
||
if (isNaN(val) || val <= 0) {
|
||
calcNode.textContent =
|
||
"⚡ " + _("Local DNS caching disabled (0 entries)");
|
||
return;
|
||
}
|
||
const bytes = val * 120;
|
||
const sizeText =
|
||
bytes < 1024 * 1024
|
||
? (bytes / 1024).toFixed(0) + " KB"
|
||
: (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||
|
||
calcNode.innerHTML =
|
||
"📊 " +
|
||
_("Estimated RAM usage (max):") +
|
||
" <strong>~" +
|
||
sizeText +
|
||
"</strong> " +
|
||
"<span style='opacity: 0.8;'>(" +
|
||
_("allocated dynamically on demand, not upfront") +
|
||
")</span>";
|
||
};
|
||
|
||
if (input && typeof input.addEventListener === "function") {
|
||
input.addEventListener("input", updateCalc);
|
||
input.addEventListener("change", updateCalc);
|
||
}
|
||
updateCalc();
|
||
|
||
if (node && typeof node.appendChild === "function") {
|
||
node.appendChild(calcNode);
|
||
}
|
||
return node;
|
||
};
|
||
|
||
// ─── AdGuard Home main DNS mode ──────────────────────────────────────────
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.ListValue,
|
||
"dns_main_mode",
|
||
_("Main DNS"),
|
||
_(
|
||
"Choose which resolver serves DNS to the LAN. When AdGuard Home is installed and enabled, 'dnsmasq' keeps the dnsmasq→AdGuard→sing-box chain, and 'AdGuard Home' makes AdGuard the main DNS listener on port 53 (dnsmasq serves DHCP only). Without AdGuard Home this setting has no effect.",
|
||
),
|
||
);
|
||
o.value("dnsmasq", _("dnsmasq (via AdGuard → sing-box)"));
|
||
o.value("adguard", _("AdGuard Home (port 53, dnsmasq = DHCP only)"));
|
||
o.default = "dnsmasq";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Flag,
|
||
"dns_adguard_failover_enabled",
|
||
_("DNS Failover to AdGuard"),
|
||
_(
|
||
"When Tachyon (sing-box DNS) is unreachable, AdGuard Home automatically answers from the fallback DNS servers you configure in its own settings (upstream list). DNS keeps resolving through AdGuard until Tachyon recovers.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
// ─── DNS Strategy ────────────────────────────────────────────────────────
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.ListValue,
|
||
"dns_strategy",
|
||
_("DNS Strategy"),
|
||
);
|
||
o.value("prefer_ipv4", _("Prefer IPv4"));
|
||
o.value("ipv4_only", _("IPv4 only"));
|
||
o.value("prefer_ipv6", _("Prefer IPv6"));
|
||
o.value("ipv6_only", _("IPv6 only"));
|
||
o.default = "prefer_ipv4";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.Flag,
|
||
"dns_detour_enabled",
|
||
_("DNS through proxy"),
|
||
_("Route main DNS requests through the selected section."),
|
||
);
|
||
configureDownloadViaProxyFlag(o, "dns_detour_section");
|
||
|
||
o = section.taboption(
|
||
"dns",
|
||
form.ListValue,
|
||
"dns_detour_section",
|
||
_("DNS requests through section"),
|
||
);
|
||
o.depends("dns_detour_enabled", "1");
|
||
configureDownloadSectionOption(o, "dns_detour_section", capabilities);
|
||
|
||
o = section.taboption(
|
||
"network",
|
||
widgets.DeviceSelect,
|
||
"source_network_interfaces",
|
||
_("Source Network Interface"),
|
||
_("Select the network interface from which the traffic will originate"),
|
||
);
|
||
o.default = "br-lan";
|
||
o.noaliases = true;
|
||
o.nobridges = false;
|
||
o.noinactive = false;
|
||
o.multiple = true;
|
||
o.filter = function (section_id, value) {
|
||
// Block specific interface names from being selectable
|
||
const blocked = ["wan", "phy0-ap0", "phy1-ap0", "pppoe-wan"];
|
||
if (blocked.includes(value)) {
|
||
return false;
|
||
}
|
||
|
||
// Try to find the device object by its name
|
||
const device = this.devices.find((dev) => dev.getName() === value);
|
||
|
||
// If no device is found, allow the value
|
||
if (!device) {
|
||
return true;
|
||
}
|
||
|
||
// Check the type of the device
|
||
const type = device.getType();
|
||
|
||
// Consider any Wi-Fi / wireless / wlan device as invalid
|
||
const isWireless =
|
||
type === "wifi" || type === "wireless" || type.includes("wlan");
|
||
|
||
// Allow only non-wireless devices
|
||
return !isWireless;
|
||
};
|
||
|
||
o = section.taboption(
|
||
"network",
|
||
form.Flag,
|
||
"enable_output_network_interface",
|
||
_("Enable Output Network Interface"),
|
||
_("You can select Output Network Interface, by default autodetect"),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"network",
|
||
widgets.DeviceSelect,
|
||
"output_network_interface",
|
||
_("Output Network Interface"),
|
||
_("Select the network interface to which the traffic will originate"),
|
||
);
|
||
o.noaliases = true;
|
||
o.multiple = false;
|
||
o.depends("enable_output_network_interface", "1");
|
||
o.filter = function (section_id, value) {
|
||
// Blocked interface names that should never be selectable
|
||
const blockedInterfaces = ["br-lan"];
|
||
|
||
// Reject immediately if the value matches any blocked interface
|
||
if (blockedInterfaces.includes(value)) {
|
||
return false;
|
||
}
|
||
|
||
// Reject lan*
|
||
if (value.startsWith("lan")) {
|
||
return false;
|
||
}
|
||
|
||
// Reject tun*, wg*, vpn*, awg*, oc*
|
||
if (
|
||
value.startsWith("tun") ||
|
||
value.startsWith("wg") ||
|
||
value.startsWith("vpn") ||
|
||
value.startsWith("awg") ||
|
||
value.startsWith("oc")
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
// Try to find the device object with the given name
|
||
const device = this.devices.find((dev) => dev.getName() === value);
|
||
|
||
// If no device is found, allow the value
|
||
if (!device) {
|
||
return true;
|
||
}
|
||
|
||
// Get the device type (e.g., "wifi", "ethernet", etc.)
|
||
const type = device.getType();
|
||
|
||
// Reject wireless-related devices
|
||
const isWireless =
|
||
type === "wifi" || type === "wireless" || type.includes("wlan");
|
||
|
||
return !isWireless;
|
||
};
|
||
|
||
o = section.taboption(
|
||
"network",
|
||
form.Flag,
|
||
"enable_badwan_interface_monitoring",
|
||
_("Interface Monitoring"),
|
||
_("Interface monitoring for Bad WAN"),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"network",
|
||
widgets.NetworkSelect,
|
||
"badwan_monitored_interfaces",
|
||
_("Monitored Interfaces"),
|
||
_("Select the WAN interfaces to be monitored"),
|
||
);
|
||
o.depends("enable_badwan_interface_monitoring", "1");
|
||
o.multiple = true;
|
||
o.default = "wan";
|
||
o.filter = function (section_id, value) {
|
||
// Reject if the value is in the blocked list ['lan', 'loopback']
|
||
if (["lan", "loopback"].includes(value)) {
|
||
return false;
|
||
}
|
||
|
||
// Reject if the value starts with '@' (means it's an alias/reference)
|
||
if (value.startsWith("@")) {
|
||
return false;
|
||
}
|
||
|
||
// Otherwise allow it
|
||
return true;
|
||
};
|
||
|
||
o = section.taboption(
|
||
"network",
|
||
form.Value,
|
||
"badwan_reload_delay",
|
||
_("Interface Monitoring Delay"),
|
||
_("Delay in milliseconds before reloading Tachyon after interface UP"),
|
||
);
|
||
o.depends("enable_badwan_interface_monitoring", "1");
|
||
o.default = "2000";
|
||
o.rmempty = false;
|
||
o.validate = function (section_id, value) {
|
||
if (!value) {
|
||
return _("Delay value cannot be empty");
|
||
}
|
||
return true;
|
||
};
|
||
|
||
o = section.taboption(
|
||
"network",
|
||
form.Flag,
|
||
"route_router_traffic",
|
||
_("Route Router's Own Traffic"),
|
||
_(
|
||
"Route local traffic originating directly from the router (e.g. wget, curl, opkg, torrent clients on the router) through Tachyon.",
|
||
),
|
||
);
|
||
configureDownloadViaProxyFlag(o, "route_router_traffic_section");
|
||
|
||
o = section.taboption(
|
||
"network",
|
||
form.ListValue,
|
||
"route_router_traffic_section",
|
||
_("Router Traffic Target Section"),
|
||
_("Select the section to route router's own traffic through."),
|
||
);
|
||
o.depends("route_router_traffic", "1");
|
||
configureDownloadSectionOption(
|
||
o,
|
||
"route_router_traffic_section",
|
||
capabilities,
|
||
);
|
||
|
||
o = section.taboption(
|
||
"services",
|
||
form.Flag,
|
||
"enable_yacd",
|
||
_("Enable YACD"),
|
||
`<a href="${main.getClashUIUrl()}" target="_blank">${main.getClashUIUrl()}</a>`,
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"services",
|
||
form.Flag,
|
||
"enable_yacd_wan_access",
|
||
_("Enable YACD WAN Access"),
|
||
_(
|
||
"Allows access to YACD from the WAN. Make sure to open the appropriate port in your firewall.",
|
||
),
|
||
);
|
||
o.depends("enable_yacd", "1");
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"services",
|
||
form.Value,
|
||
"yacd_secret_key",
|
||
_("YACD Secret Key"),
|
||
_(
|
||
"Secret key for authenticating remote access to YACD when WAN access is enabled.",
|
||
),
|
||
);
|
||
o.depends("enable_yacd_wan_access", "1");
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"services",
|
||
form.Flag,
|
||
"disable_quic",
|
||
_("Disable QUIC"),
|
||
_(
|
||
"Disable the QUIC protocol to improve compatibility or fix issues with video streaming",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"services",
|
||
form.Flag,
|
||
"block_doh",
|
||
_("Block DoH"),
|
||
_(
|
||
"Block well-known DNS-over-HTTPS resolver IPs so LAN clients cannot bypass the router DNS",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"services",
|
||
form.Flag,
|
||
"isolate_p2p",
|
||
_("P2P Leak Protection"),
|
||
_("Isolate BitTorrent traffic and force it direct to prevent VPN bans"),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"services",
|
||
form.Value,
|
||
"p2p_ports",
|
||
_("P2P Client Ports"),
|
||
_(
|
||
'Pin P2P isolation to the torrent client\'s own ports, comma separated. Each entry is "proto", "proto:port" or "proto:port-port" (tcp/udp). Without a port the whole protocol is routed direct.',
|
||
),
|
||
);
|
||
o.depends("isolate_p2p", "1");
|
||
o.placeholder = "tcp:6881,udp:6881-6889";
|
||
o.validate = function (_section_id, value) {
|
||
if (!value || !value.trim()) {
|
||
return true;
|
||
}
|
||
|
||
const entries = value.split(",");
|
||
for (const entry of entries) {
|
||
const trimmed = entry.trim();
|
||
if (!/^((tcp|udp):)?[0-9]{1,5}(-[0-9]{1,5})?$|^(tcp|udp)$/.test(trimmed)) {
|
||
return _(
|
||
'Each entry must be "proto", "proto:port" or "proto:port-port" (tcp/udp), e.g. tcp:6881,udp:6881-6889',
|
||
);
|
||
}
|
||
|
||
const portSpec = trimmed.includes(":")
|
||
? trimmed.split(":")[1]
|
||
: (/^[0-9]/.test(trimmed) ? trimmed : null);
|
||
if (!portSpec) {
|
||
continue;
|
||
}
|
||
|
||
const bounds = portSpec.split("-").map(Number);
|
||
if (
|
||
bounds.some(
|
||
(port) => !Number.isInteger(port) || port < 1 || port > 65535,
|
||
)
|
||
) {
|
||
return _("Ports must be between 1 and 65535");
|
||
}
|
||
|
||
if (bounds.length === 2 && bounds[0] > bounds[1]) {
|
||
return _("Port range start must not exceed its end");
|
||
}
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
o = section.taboption(
|
||
"services",
|
||
form.Flag,
|
||
"game_console_optimizer",
|
||
_("Game Console Optimizer (NAT Type 1)"),
|
||
_(
|
||
"Bypasses UDP traffic for selected game consoles (PS5/Xbox) to achieve NAT Type 1 / 2 (Full Cone NAT) for P2P matchmaking, while keeping TCP (PSN/Auth) routed through the proxy.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
const gameConsoleIpsOpt = section.taboption(
|
||
"services",
|
||
form.DynamicList,
|
||
"game_console_ips",
|
||
_("Game Console IPs"),
|
||
_("Select or enter the IP addresses of your game consoles."),
|
||
);
|
||
gameConsoleIpsOpt.depends("game_console_optimizer", "1");
|
||
gameConsoleIpsOpt.datatype = "ipaddr";
|
||
gameConsoleIpsOpt.renderWidget = function (
|
||
section_id,
|
||
option_index,
|
||
cfgvalue,
|
||
) {
|
||
return local_devices.createLocalDeviceDynamicListWidget(
|
||
this,
|
||
section_id,
|
||
cfgvalue,
|
||
);
|
||
};
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Flag,
|
||
"list_update_enabled",
|
||
_("Enable list updates"),
|
||
_("Enable automatic updates for remote lists and rule sets"),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Value,
|
||
"update_interval",
|
||
_("List Update Frequency"),
|
||
_("Use sing-box duration format like 1d, 12h or 30m"),
|
||
);
|
||
o.depends("list_update_enabled", "1");
|
||
o.placeholder = "1d";
|
||
o.default = "1d";
|
||
o.rmempty = false;
|
||
o.cfgvalue = function (section_id) {
|
||
return uci.get(UCI_PACKAGE, section_id, "update_interval") || "1d";
|
||
};
|
||
o.write = function (section_id, value) {
|
||
const normalized = value ? `${value}`.trim() : "";
|
||
|
||
if (normalized.length) {
|
||
uci.set(UCI_PACKAGE, section_id, "update_interval", normalized);
|
||
} else {
|
||
uci.set(UCI_PACKAGE, section_id, "update_interval", "1d");
|
||
}
|
||
};
|
||
o.validate = function (_section_id, value) {
|
||
const normalized = value ? `${value}`.trim() : "";
|
||
|
||
if (!normalized.length) {
|
||
return _("Use sing-box duration format like 1d, 12h or 30m");
|
||
}
|
||
|
||
if (isSingBoxDuration(normalized)) {
|
||
return true;
|
||
}
|
||
|
||
return _("Use sing-box duration format like 1d, 12h or 30m");
|
||
};
|
||
|
||
const updateListsBtn = section.taboption(
|
||
"updates",
|
||
form.Button,
|
||
"_update_lists_now",
|
||
_("Manual list update"),
|
||
_("Force immediate download and refresh of all remote lists and rule sets"),
|
||
);
|
||
updateListsBtn.inputtitle = _("Update lists now");
|
||
updateListsBtn.inputstyle = "action";
|
||
updateListsBtn.depends("list_update_enabled", "1");
|
||
updateListsBtn.onclick = function () {
|
||
const statusText = E(
|
||
"p",
|
||
{ class: "spinning" },
|
||
_("Downloading and applying rule sets and lists..."),
|
||
);
|
||
const helpText = E(
|
||
"p",
|
||
{ style: "margin-top: 8px; font-size: 90%; opacity: 0.7;" },
|
||
_(
|
||
"This may take 1-2 minutes. The process runs in the background on the router.",
|
||
),
|
||
);
|
||
|
||
let pollTimer = null;
|
||
let pollAttempts = 0;
|
||
const maxPollAttempts = 120; // 120 * 2s = 240s = 4 minutes
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) {
|
||
window.clearInterval(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
}
|
||
|
||
const closeBtn = E(
|
||
"button",
|
||
{
|
||
class: "btn cbi-button cbi-button-neutral",
|
||
type: "button",
|
||
click: function () {
|
||
stopPolling();
|
||
ui.hideModal();
|
||
ui.addNotification(
|
||
null,
|
||
E("p", _("Lists update is continuing in the background.")),
|
||
"info",
|
||
);
|
||
},
|
||
},
|
||
_("Close"),
|
||
);
|
||
|
||
ui.showModal(_("Updating lists..."), [
|
||
statusText,
|
||
helpText,
|
||
E(
|
||
"div",
|
||
{ class: "button-row", style: "margin-top: 16px; text-align: right;" },
|
||
[closeBtn],
|
||
),
|
||
]);
|
||
|
||
function checkStatus() {
|
||
pollAttempts++;
|
||
return fs
|
||
.exec("/usr/bin/tachyon", ["list_update_status"])
|
||
.then(function (res) {
|
||
let data = {};
|
||
try {
|
||
data = JSON.parse((res.stdout || "").trim() || "{}");
|
||
} catch (e) {}
|
||
|
||
if (data.running) {
|
||
if (data.message) {
|
||
statusText.textContent = _(data.message) || data.message;
|
||
}
|
||
if (pollAttempts >= maxPollAttempts) {
|
||
stopPolling();
|
||
ui.hideModal();
|
||
ui.addNotification(
|
||
null,
|
||
E(
|
||
"p",
|
||
_(
|
||
"Lists update is still running in background. You can check system logs.",
|
||
),
|
||
),
|
||
"info",
|
||
);
|
||
}
|
||
} else {
|
||
stopPolling();
|
||
ui.hideModal();
|
||
if (data.success !== false) {
|
||
ui.addNotification(
|
||
null,
|
||
E("p", _("Lists and rule sets successfully updated!")),
|
||
"info",
|
||
);
|
||
} else {
|
||
ui.addNotification(
|
||
null,
|
||
E(
|
||
"p",
|
||
_("Error updating lists: ") +
|
||
(data.message || _("Update failed")),
|
||
),
|
||
"error",
|
||
);
|
||
}
|
||
}
|
||
})
|
||
.catch(function () {
|
||
if (pollAttempts >= maxPollAttempts) {
|
||
stopPolling();
|
||
ui.hideModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
return fs
|
||
.exec("/usr/bin/tachyon", ["list_update_async"])
|
||
.then(function () {
|
||
pollTimer = window.setInterval(checkStatus, 2000);
|
||
})
|
||
.catch(function (err) {
|
||
ui.hideModal();
|
||
ui.addNotification(
|
||
null,
|
||
E("p", _("Error updating lists: ") + (err.message || err)),
|
||
"error",
|
||
);
|
||
});
|
||
};
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Flag,
|
||
"download_all_presets",
|
||
_("Pre-download all preset lists"),
|
||
_(
|
||
"Cache all built-in lists and databases even if they are not currently selected in any section. Recommended if you have plenty of storage space or USB drive.",
|
||
),
|
||
);
|
||
o.depends("list_update_enabled", "1");
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Flag,
|
||
"component_update_check_enabled",
|
||
_("Automatic component update checks"),
|
||
_("Automatically check installed components for new versions"),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Value,
|
||
"component_update_check_interval",
|
||
_("Component update check interval"),
|
||
_("Use sing-box duration format like 1d, 12h or 30m"),
|
||
);
|
||
o.depends("component_update_check_enabled", "1");
|
||
o.placeholder = "1d";
|
||
o.default = "1d";
|
||
o.rmempty = false;
|
||
o.cfgvalue = function (section_id) {
|
||
return (
|
||
uci.get(UCI_PACKAGE, section_id, "component_update_check_interval") ||
|
||
"1d"
|
||
);
|
||
};
|
||
o.write = function (section_id, value) {
|
||
const normalized = value ? `${value}`.trim() : "";
|
||
uci.set(
|
||
UCI_PACKAGE,
|
||
section_id,
|
||
"component_update_check_interval",
|
||
normalized.length ? normalized : "1d",
|
||
);
|
||
};
|
||
o.validate = function (_section_id, value) {
|
||
const normalized = value ? `${value}`.trim() : "";
|
||
|
||
if (normalized.length && isSingBoxDuration(normalized)) {
|
||
return true;
|
||
}
|
||
|
||
return _("Use sing-box duration format like 1d, 12h or 30m");
|
||
};
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Flag,
|
||
"component_auto_update_enabled",
|
||
_("Automatic component updates"),
|
||
_("Automatically download and install new versions of components"),
|
||
);
|
||
o.depends("component_update_check_enabled", "1");
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.ListValue,
|
||
"component_auto_update_mode",
|
||
_("Auto-update mode"),
|
||
_(
|
||
"Choose whether to install updates immediately when detected or at a scheduled time (e.g. maintenance window)",
|
||
),
|
||
);
|
||
o.depends("component_auto_update_enabled", "1");
|
||
o.value("immediate", _("Immediately upon release"));
|
||
o.value("schedule", _("By schedule"));
|
||
o.default = "immediate";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Value,
|
||
"component_auto_update_time",
|
||
_("Scheduled auto-update time"),
|
||
_(
|
||
"Time of day to run scheduled component updates in HH:MM (24h) format, e.g. 04:00",
|
||
),
|
||
);
|
||
o.depends({
|
||
component_auto_update_enabled: "1",
|
||
component_auto_update_mode: "schedule",
|
||
});
|
||
o.placeholder = "04:00";
|
||
o.default = "04:00";
|
||
o.rmempty = false;
|
||
o.validate = function (_section_id, value) {
|
||
const normalized = value ? `${value}`.trim() : "";
|
||
if (/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/.test(normalized)) {
|
||
return true;
|
||
}
|
||
return _("Enter a valid time in HH:MM format (00:00 to 23:59)");
|
||
};
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.MultiValue,
|
||
"component_auto_update_targets",
|
||
_("Eligible components for auto-update"),
|
||
_("Select which components are permitted to update automatically"),
|
||
);
|
||
o.depends("component_auto_update_enabled", "1");
|
||
o.value("all", _("All components"));
|
||
o.value("tachyon", _("Tachyon Core"));
|
||
o.value("luci_app_tachyon", _("LuCI Web UI"));
|
||
o.value("sing_box", _("sing-box"));
|
||
o.value("zapret", _("Zapret"));
|
||
o.value("zapret2", _("Zapret2"));
|
||
o.value("byedpi", _("ByeDPI"));
|
||
o.value("tailscale", _("Tailscale"));
|
||
o.default = ["all"];
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Flag,
|
||
"component_backup_enabled",
|
||
_("Save component backup before update"),
|
||
_(
|
||
"Keep the previous working binary/package on the router flash for instant offline rollback if needed. Consumes extra storage.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Value,
|
||
"latency_test_url",
|
||
_("Latency test URL"),
|
||
_(
|
||
"Default address for checking server availability and latency. URLTest uses its own address.",
|
||
),
|
||
);
|
||
latencyTestUrlChoices().forEach((value) => o.value(value));
|
||
o.default =
|
||
main.DEFAULT_LATENCY_TEST_URL || "https://www.gstatic.com/generate_204";
|
||
o.rmempty = false;
|
||
o.validate = function (_section_id, value) {
|
||
return validateLatencyTestUrl(value);
|
||
};
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Flag,
|
||
"download_lists_via_proxy",
|
||
_("Download lists through a section"),
|
||
_("Download remote lists and rule sets via the selected section"),
|
||
);
|
||
configureDownloadViaProxyFlag(o, "download_lists_via_proxy_section");
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.ListValue,
|
||
"download_lists_via_proxy_section",
|
||
_("Download lists through"),
|
||
);
|
||
o.depends("download_lists_via_proxy", "1");
|
||
configureDownloadSectionOption(
|
||
o,
|
||
"download_lists_via_proxy_section",
|
||
capabilities,
|
||
);
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.Flag,
|
||
"download_components_via_proxy",
|
||
_("Download components through a section"),
|
||
_("Download component packages via the selected section"),
|
||
);
|
||
configureDownloadViaProxyFlag(o, "download_components_via_proxy_section");
|
||
|
||
o = section.taboption(
|
||
"updates",
|
||
form.ListValue,
|
||
"download_components_via_proxy_section",
|
||
_("Download components through"),
|
||
);
|
||
o.depends("download_components_via_proxy", "1");
|
||
configureDownloadSectionOption(
|
||
o,
|
||
"download_components_via_proxy_section",
|
||
capabilities,
|
||
);
|
||
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.Flag,
|
||
"dont_touch_dhcp",
|
||
_("Dont Touch My DHCP!"),
|
||
_("Tachyon will not modify your DHCP configuration"),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.Flag,
|
||
"section_failover_enabled",
|
||
_("Auto-failover of proxy sections"),
|
||
_(
|
||
"Watchdog probes every Connection section; when the active one fails several checks in a row, traffic switches to a healthy backup and Telegram is notified. Requires a config reload to take effect.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.Value,
|
||
"section_failover_threshold",
|
||
_("Failover failure threshold"),
|
||
_("Number of consecutive failed probes before switching sections"),
|
||
);
|
||
o.depends("section_failover_enabled", "1");
|
||
o.default = "3";
|
||
o.placeholder = "3";
|
||
o.rmempty = false;
|
||
o.datatype = "range(1,30)";
|
||
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.ListValue,
|
||
"config_path",
|
||
_("Config File Path"),
|
||
_(
|
||
"Select path for sing-box config file. Change this ONLY if you know what you are doing",
|
||
),
|
||
);
|
||
o.value("/etc/sing-box/config.json", "Flash (/etc/sing-box/config.json)");
|
||
o.value("/tmp/sing-box/config.json", "RAM (/tmp/sing-box/config.json)");
|
||
o.default = "/etc/sing-box/config.json";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.Value,
|
||
"cache_path",
|
||
_("Cache File Path"),
|
||
_(
|
||
"Select or enter path for sing-box cache file. Change this ONLY if you know what you are doing",
|
||
),
|
||
);
|
||
o.value("/tmp/sing-box/cache.db", "RAM (/tmp/sing-box/cache.db)");
|
||
o.value(
|
||
"/usr/share/sing-box/cache.db",
|
||
"Flash (/usr/share/sing-box/cache.db)",
|
||
);
|
||
o.default = "/tmp/sing-box/cache.db";
|
||
o.rmempty = false;
|
||
o.validate = function (section_id, value) {
|
||
if (!value) {
|
||
return _("Cache file path cannot be empty");
|
||
}
|
||
|
||
if (!value.startsWith("/")) {
|
||
return _("Path must be absolute (start with /)");
|
||
}
|
||
|
||
if (!value.endsWith("cache.db")) {
|
||
return _("Path must end with cache.db");
|
||
}
|
||
|
||
const parts = value.split("/").filter(Boolean);
|
||
if (parts.length < 2) {
|
||
return _("Path must contain at least one directory (like /tmp/cache.db)");
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.ListValue,
|
||
"log_level",
|
||
_("Log Level"),
|
||
_("Select the log level for sing-box"),
|
||
);
|
||
o.value("trace", "Trace");
|
||
o.value("debug", "Debug");
|
||
o.value("info", "Info");
|
||
o.value("warn", "Warn");
|
||
o.value("error", "Error");
|
||
o.value("fatal", "Fatal");
|
||
o.value("panic", "Panic");
|
||
o.default = "warn";
|
||
o.rmempty = false;
|
||
|
||
// Anonymization and Security
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.Flag,
|
||
"webrtc_leak_protect",
|
||
_("WebRTC Leak Protection"),
|
||
_(
|
||
"Detects and drops STUN/TURN packets in nftables to prevent real IP leaks via WebRTC.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.Flag,
|
||
"dns_doq_ech",
|
||
_("Strict ECH + DoQ DNS Enforcer"),
|
||
_(
|
||
"Forces sing-box to tunnel DNS requests via DoQ with Encrypted Client Hello (ECH) for SNI hiding.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.ListValue,
|
||
"qos_priority_engine",
|
||
_("QoS Priority Engine (DSCP)"),
|
||
_(
|
||
"Prioritizes interactive DNS, VoIP/WebRTC and gaming via DSCP marking in nftables. Works cooperatively with OpenWrt SQM (CAKE/FQ_CoDel) without bufferbloat degradation.",
|
||
),
|
||
);
|
||
o.value("", _("Auto (cooperative with SQM/CAKE)"));
|
||
o.value("1", _("Enabled (always prioritize)"));
|
||
o.value("0", _("Disabled"));
|
||
o.default = "";
|
||
o.rmempty = true;
|
||
|
||
// WARP Generator Proxy Section
|
||
o = section.taboption(
|
||
"advanced",
|
||
form.ListValue,
|
||
"warp_proxy_section",
|
||
_("WARP Generator Proxy Section"),
|
||
_(
|
||
"Section used for Cloudflare WARP API registration. If your ISP blocks Cloudflare API on WAN, select a proxy section (with Mixed proxy enabled) or a tunnel interface section (WireGuard / AmneziaWG / VPN). Leave empty to use direct WAN / default mixed proxy.",
|
||
),
|
||
);
|
||
o.value("", _("Default (Direct WAN / Default mixed proxy)"));
|
||
o.cfgvalue = function (section_id) {
|
||
return (
|
||
uci.get(UCI_PACKAGE, section_id, "warp_proxy_section") ||
|
||
uci.get(UCI_PACKAGE, "settings", "warp_proxy_section") ||
|
||
""
|
||
);
|
||
};
|
||
o.load = function (section_id) {
|
||
const sections = this.map?.data?.state?.values?.[UCI_PACKAGE] ?? {};
|
||
this.keylist = [];
|
||
this.vallist = [];
|
||
this.value("", _("Default (Direct WAN / Default mixed proxy)"));
|
||
for (const secName in sections) {
|
||
const sec = sections[secName];
|
||
if (sec[".type"] === "section" && sec.enabled !== "0") {
|
||
const action = sec.action || "section";
|
||
const iface = sec.section_interface || sec.interface || "";
|
||
const desc = iface ? ` (${action}: ${iface})` : ` (${action})`;
|
||
this.value(secName, `${_("Section")}: ${sec.label || secName}${desc}`);
|
||
}
|
||
}
|
||
|
||
if (
|
||
typeof network !== "undefined" &&
|
||
typeof network.getDevices === "function"
|
||
) {
|
||
return network.getDevices().then(
|
||
L.bind(function (devices) {
|
||
(devices || []).forEach((dev) => {
|
||
const name =
|
||
typeof dev.getName === "function" ? dev.getName() : dev.name;
|
||
if (name && name !== "lo" && !this.keylist.includes(name)) {
|
||
const type =
|
||
typeof dev.getTypeI18n === "function"
|
||
? dev.getTypeI18n()
|
||
: typeof dev.getType === "function"
|
||
? dev.getType()
|
||
: "";
|
||
const desc = type ? ` (${type})` : "";
|
||
this.value(name, `${_("Interface")}: ${name}${desc}`);
|
||
}
|
||
});
|
||
return this.cfgvalue(section_id);
|
||
}, this),
|
||
);
|
||
}
|
||
|
||
return this.cfgvalue(section_id);
|
||
};
|
||
o.write = function (section_id, value) {
|
||
const normalized = value ? `${value}`.trim() : "";
|
||
if (normalized) {
|
||
uci.set(UCI_PACKAGE, section_id, "warp_proxy_section", normalized);
|
||
if (section_id !== "settings" && uci.get(UCI_PACKAGE, "settings")) {
|
||
uci.set(UCI_PACKAGE, "settings", "warp_proxy_section", normalized);
|
||
}
|
||
} else {
|
||
uci.unset(UCI_PACKAGE, section_id, "warp_proxy_section");
|
||
if (section_id !== "settings" && uci.get(UCI_PACKAGE, "settings")) {
|
||
uci.unset(UCI_PACKAGE, "settings", "warp_proxy_section");
|
||
}
|
||
}
|
||
};
|
||
o.remove = function (section_id) {
|
||
uci.unset(UCI_PACKAGE, section_id, "warp_proxy_section");
|
||
if (section_id !== "settings" && uci.get(UCI_PACKAGE, "settings")) {
|
||
uci.unset(UCI_PACKAGE, "settings", "warp_proxy_section");
|
||
}
|
||
};
|
||
|
||
const resetOpt = section.taboption(
|
||
"advanced",
|
||
form.DummyValue,
|
||
"_reset_settings",
|
||
_("Reset Settings"),
|
||
_(
|
||
"Restores the Tachyon factory defaults: all rules, subscriptions, custom DNS servers and other options will be lost.",
|
||
),
|
||
);
|
||
resetOpt.rawhtml = true;
|
||
resetOpt.cfgvalue = function () {
|
||
return createResetSettingsWidget();
|
||
};
|
||
|
||
const snapshotsOpt = section.taboption(
|
||
"advanced",
|
||
form.DummyValue,
|
||
"_snapshots",
|
||
_("Config Snapshots"),
|
||
_(
|
||
"Save a snapshot of the working config and restore it later if something breaks.",
|
||
),
|
||
);
|
||
snapshotsOpt.rawhtml = true;
|
||
snapshotsOpt.cfgvalue = function () {
|
||
return createSnapshotsWidget();
|
||
};
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"enable_watchdog",
|
||
_("Enable Watchdog"),
|
||
_(
|
||
"Enables the background watchdog process to monitor services and auto-recover on failures.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
|
||
// AI Agent API Settings
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"agent_api_token",
|
||
_("AI Agent REST API Bearer Token"),
|
||
_(
|
||
"Secret token for authorizing WRITE operations (POST/PUT) on /cgi-bin/tachyon-agent/. Leave empty if auth is not required.",
|
||
),
|
||
);
|
||
o.password = true;
|
||
o.rmempty = true;
|
||
|
||
// AI Doctor (ChatGPT / LLM Integration)
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"enable_ai_doctor",
|
||
_("Enable AI Doctor (ChatGPT / LLM Analysis)"),
|
||
_(
|
||
"Allows Tachyon to send diagnostic snapshots to ChatGPT or DeepSeek API for intelligent root-cause analysis and auto-repair recommendations.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.ListValue,
|
||
"ai_doctor_provider",
|
||
_("AI Provider"),
|
||
_("Select AI LLM provider for diagnostics"),
|
||
);
|
||
o.depends("enable_ai_doctor", "1");
|
||
o.value("openai", "OpenAI (ChatGPT)");
|
||
o.value("anthropic", "Anthropic (Claude API)");
|
||
o.value("deepseek", "DeepSeek API");
|
||
o.value("openrouter", "OpenRouter (openrouter.ai)");
|
||
o.value("ollama", _("Ollama (Local PC / Server)"));
|
||
o.value("lmstudio", _("LM Studio (Local PC / Server)"));
|
||
o.value("custom", _("Custom OpenAI-Compatible API"));
|
||
o.default = "openai";
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"ai_doctor_model",
|
||
_("AI Model"),
|
||
_(
|
||
"Model name to use. Leave empty to use default (gpt-4o-mini / claude-3-5-haiku / deepseek-chat / llama3:latest).",
|
||
),
|
||
);
|
||
o.depends("enable_ai_doctor", "1");
|
||
o.rmempty = true;
|
||
o.placeholder = _("(provider default)");
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.ListValue,
|
||
"ai_doctor_lang",
|
||
_("AI Doctor Language"),
|
||
_("Language for AI Doctor diagnosis responses"),
|
||
);
|
||
o.depends("enable_ai_doctor", "1");
|
||
o.value("ru", "Русский (Russian)");
|
||
o.value("en", "English");
|
||
o.default = "ru";
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"ai_doctor_api_key",
|
||
_("AI API Key"),
|
||
_(
|
||
"API Key for OpenAI (sk-...) or DeepSeek (optional for Ollama / LM Studio)",
|
||
),
|
||
);
|
||
o.depends("enable_ai_doctor", "1");
|
||
o.password = true;
|
||
o.rmempty = true;
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"ai_doctor_custom_url",
|
||
_("Custom API Endpoint URL"),
|
||
_(
|
||
"Custom OpenAI-compatible API endpoint URL (e.g. http://192.168.1.100:11434/v1/chat/completions)",
|
||
),
|
||
);
|
||
o.depends("ai_doctor_provider", "custom");
|
||
o.depends("ai_doctor_provider", "ollama");
|
||
o.depends("ai_doctor_provider", "lmstudio");
|
||
o.rmempty = true;
|
||
o.placeholder = "http://192.168.1.100:11434/v1/chat/completions";
|
||
|
||
// RAG (Knowledge Base Retrieval)
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"enable_rag",
|
||
_("Enable RAG (Knowledge Base Retrieval)"),
|
||
_(
|
||
"Pre-built index of Tachyon documentation chunks. When enabled, AI Doctor searches relevant docs before generating a diagnosis.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
// Watchdog runtime status & controls
|
||
const wdStatusOpt = section.taboption(
|
||
"ai",
|
||
form.DummyValue,
|
||
"_watchdog_status",
|
||
_("Watchdog Status"),
|
||
);
|
||
wdStatusOpt.rawhtml = true;
|
||
wdStatusOpt.cfgvalue = function () {
|
||
return createWatchdogStatusWidget();
|
||
};
|
||
wdStatusOpt.depends("enable_watchdog", "1");
|
||
|
||
// ─── AI Watchdog Settings ──────────────────────────────────────────────────
|
||
|
||
// Proxy Health Monitor
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_proxy_health_enabled",
|
||
_("Enable Proxy Health Monitor"),
|
||
_(
|
||
"Periodically checks if the proxy is responding. Restarts Tachyon after consecutive failures.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"ai_proxy_health_interval",
|
||
_("Proxy Health Check Interval (s)"),
|
||
_("How often to check proxy health in seconds (fast tier, default 30)."),
|
||
);
|
||
o.default = "30";
|
||
o.datatype = "min(15)";
|
||
o.depends("ai_proxy_health_enabled", "1");
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"ai_proxy_health_fail_threshold",
|
||
_("Proxy Fail Threshold"),
|
||
_("Number of consecutive failures before restarting (default 3)."),
|
||
);
|
||
o.default = "3";
|
||
o.datatype = "min(1)";
|
||
o.depends("ai_proxy_health_enabled", "1");
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"ai_proxy_health_url",
|
||
_("Proxy Health Check URL"),
|
||
_("URL to test through the proxy (default: Cloudflare 204)."),
|
||
);
|
||
o.default = "https://cp.cloudflare.com/generate_204";
|
||
o.depends("ai_proxy_health_enabled", "1");
|
||
|
||
// DNS Continuous Check
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_dns_continuous_enabled",
|
||
_("Enable DNS Continuous Check"),
|
||
_(
|
||
"Faster DNS health monitoring than the default cycle. Switches bootstrap DNS on failure.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"ai_dns_interval",
|
||
_("DNS Check Interval (s)"),
|
||
_("How often to check DNS health in seconds (default 60)."),
|
||
);
|
||
o.default = "60";
|
||
o.datatype = "min(30)";
|
||
o.depends("ai_dns_continuous_enabled", "1");
|
||
|
||
// Reload Dedup
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_reload_dedup_enabled",
|
||
_("Enable Firewall Reload Dedup"),
|
||
_(
|
||
"Prevents multiple reload_firewall calls within 120 seconds to avoid connection drops.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
// Metrics
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_metrics_enabled",
|
||
_("Enable Health Metrics"),
|
||
_(
|
||
"Records proxy/DNS latency and health status in hourly buckets for analysis.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"ai_metrics_retention_hours",
|
||
_("Metrics Retention (hours)"),
|
||
_("How many hours of metrics to keep (default 24)."),
|
||
);
|
||
o.default = "24";
|
||
o.datatype = "min(1)";
|
||
o.depends("ai_metrics_enabled", "1");
|
||
|
||
// Smart Cooldowns
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_smart_cooldowns_enabled",
|
||
_("Enable Smart Cooldowns"),
|
||
_(
|
||
"Use 3-tier check intervals: fast (15s), normal (120s), slow (300s) instead of a single interval.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
// Config Validation
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_config_validation_enabled",
|
||
_("Enable Config Validation"),
|
||
_(
|
||
"Validates sing-box config before restart to prevent boot loops on broken configs.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
// Graceful Degradation
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_graceful_degradation_enabled",
|
||
_("Enable Graceful Degradation"),
|
||
_(
|
||
"If one health check fails with an error, continue running other checks instead of stopping.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
// Persistent Smart Detect
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_persistent_smart_detect",
|
||
_("Persistent Smart Detect"),
|
||
_(
|
||
"Store auto-detected domains in /etc instead of /tmp to survive reboots.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
// Adaptive Intervals
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_adaptive_intervals_enabled",
|
||
_("Enable Adaptive Intervals"),
|
||
_(
|
||
"Automatically increase check intervals when healthy (to 5 min) and decrease on problems (to 120s).",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
// Anomaly Detection
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Flag,
|
||
"ai_anomaly_detection_enabled",
|
||
_("Enable Anomaly Detection"),
|
||
_(
|
||
"Monitors sing-box reconnect frequency. Alerts if too many reconnects per hour.",
|
||
),
|
||
);
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
o.depends("enable_watchdog", "1");
|
||
|
||
o = section.taboption(
|
||
"ai",
|
||
form.Value,
|
||
"ai_anomaly_reconnect_threshold",
|
||
_("Reconnect Threshold"),
|
||
_("Max reconnects per hour before alert (default 10)."),
|
||
);
|
||
o.default = "10";
|
||
o.datatype = "min(1)";
|
||
o.depends("ai_anomaly_detection_enabled", "1");
|
||
|
||
// ─── End AI Watchdog Settings ──────────────────────────────────────────────
|
||
|
||
// Smart Detect
|
||
o = section.taboption(
|
||
"services",
|
||
form.Flag,
|
||
"smart_detect",
|
||
_("Enable Smart Detect"),
|
||
_(
|
||
"Auto-detects blocked domains from logs and adds them to the first section where they work via proxy.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
// Smart Detect sections (domain test order)
|
||
const sdSectionsOpt = section.taboption(
|
||
"services",
|
||
form.Value,
|
||
"_smart_detect_sections",
|
||
_("Domain test sections"),
|
||
_(
|
||
"Select and order the sections through which blocked domains are tested. Checked sections are tried top-to-bottom.",
|
||
),
|
||
);
|
||
sdSectionsOpt.rawhtml = true;
|
||
sdSectionsOpt.depends("smart_detect", "1");
|
||
sdSectionsOpt.renderWidget = function (section_id, option_index, cfgvalue) {
|
||
return createSmartDetectSectionsWidget(section_id);
|
||
};
|
||
sdSectionsOpt.formvalue = function (section_id) {
|
||
const el = document.getElementById(
|
||
"smart-detect-sections-widget-" + section_id,
|
||
);
|
||
return el ? el.value : [];
|
||
};
|
||
sdSectionsOpt.write = function (section_id, formvalue) {
|
||
if (Array.isArray(formvalue) && formvalue.length > 0) {
|
||
uci.set(UCI_PACKAGE, section_id, "smart_detect_sections", formvalue);
|
||
} else {
|
||
uci.unset(UCI_PACKAGE, section_id, "smart_detect_sections");
|
||
}
|
||
};
|
||
sdSectionsOpt.remove = function (section_id) {
|
||
uci.unset(UCI_PACKAGE, section_id, "smart_detect_sections");
|
||
};
|
||
|
||
const sdDomainsOpt = section.option(
|
||
form.DummyValue,
|
||
"_smart_detect_domains",
|
||
_("Auto-detected domains"),
|
||
_(
|
||
"List of domains automatically detected and the section they were added to. To edit or remove them, open the routing rules of the respective section.",
|
||
),
|
||
);
|
||
sdDomainsOpt.depends("smart_detect", "1");
|
||
sdDomainsOpt.rawhtml = true;
|
||
sdDomainsOpt.cfgvalue = function (section_id) {
|
||
const allDomains = [];
|
||
const allSections = uci.sections(UCI_PACKAGE, "section") || [];
|
||
allSections.forEach(function (s) {
|
||
const ud = L.toArray(s.user_domains || []);
|
||
ud.forEach(function (d) {
|
||
allDomains.push({ domain: d, section: s[".name"] });
|
||
});
|
||
});
|
||
|
||
if (allDomains.length === 0) {
|
||
return "<em>" + _("No domains detected yet") + "</em>";
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s || "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
let html = '<ul style="margin:0;padding-left:1.5rem;">';
|
||
allDomains.forEach(function (item) {
|
||
html +=
|
||
"<li><code>" +
|
||
esc(item.domain) +
|
||
"</code> → <b>" +
|
||
esc(item.section) +
|
||
"</b></li>";
|
||
});
|
||
html += "</ul>";
|
||
return html;
|
||
};
|
||
}
|
||
|
||
function createTelegramStatusWidget() {
|
||
const wrapper = E("div", {
|
||
id: "tachyon-telegram-status-widget",
|
||
style:
|
||
"display:flex;align-items:center;gap:12px;padding:10px 0;flex-wrap:wrap;",
|
||
});
|
||
|
||
const indicator = E("span", {
|
||
id: "tachyon-telegram-status-indicator",
|
||
style:
|
||
"display:inline-flex;align-items:center;gap:6px;font-weight:bold;font-size:14px;",
|
||
});
|
||
|
||
const dot = E("span", {
|
||
id: "tachyon-telegram-status-dot",
|
||
style:
|
||
"display:inline-block;width:10px;height:10px;border-radius:50%;background:#aaa;",
|
||
});
|
||
|
||
const statusText = E("span", {
|
||
id: "tachyon-telegram-status-text",
|
||
});
|
||
statusText.textContent = _("Checking…");
|
||
|
||
indicator.appendChild(dot);
|
||
indicator.appendChild(statusText);
|
||
|
||
const btnStart = E("button", {
|
||
id: "tachyon-telegram-btn-start",
|
||
class: "btn cbi-button cbi-button-action",
|
||
style: "display:none;",
|
||
});
|
||
btnStart.textContent = _("Start bot");
|
||
|
||
const btnStop = E("button", {
|
||
id: "tachyon-telegram-btn-stop",
|
||
class: "btn cbi-button cbi-button-negative",
|
||
style: "display:none;",
|
||
});
|
||
btnStop.textContent = _("Stop bot");
|
||
|
||
const statusMsg = E("span", {
|
||
id: "tachyon-telegram-status-msg",
|
||
style: "font-size:12px;color:var(--text-color-medium,#888);",
|
||
});
|
||
|
||
function applyStatus(running, pid) {
|
||
if (running) {
|
||
dot.style.background = "#4caf50";
|
||
statusText.textContent = pid
|
||
? _("Running") + " (PID " + pid + ")"
|
||
: _("Running");
|
||
btnStart.style.display = "none";
|
||
btnStop.style.display = "";
|
||
} else {
|
||
dot.style.background = "#f44336";
|
||
statusText.textContent = _("Stopped");
|
||
btnStart.style.display = "";
|
||
btnStop.style.display = "none";
|
||
}
|
||
}
|
||
|
||
function refreshStatus() {
|
||
return fs
|
||
.exec("/usr/bin/tachyon", ["telegram_status"])
|
||
.then(function (res) {
|
||
const out = (res.stdout || "").trim();
|
||
const pidMatch = out.match(/\(pid\s+(\d+)\)/);
|
||
const pid = pidMatch ? pidMatch[1] : null;
|
||
applyStatus(out.indexOf("running") === 0, pid);
|
||
})
|
||
.catch(function () {
|
||
dot.style.background = "#aaa";
|
||
statusText.textContent = _("Unknown");
|
||
});
|
||
}
|
||
|
||
btnStart.addEventListener("click", function () {
|
||
btnStart.disabled = true;
|
||
statusMsg.textContent = _("Starting…");
|
||
fs.exec("/usr/bin/tachyon", ["telegram_start"])
|
||
.then(function () {
|
||
statusMsg.textContent = "";
|
||
return refreshStatus();
|
||
})
|
||
.catch(function () {
|
||
statusMsg.textContent = _("Failed to start bot");
|
||
btnStart.disabled = false;
|
||
});
|
||
});
|
||
|
||
btnStop.addEventListener("click", function () {
|
||
btnStop.disabled = true;
|
||
statusMsg.textContent = _("Stopping…");
|
||
fs.exec("/usr/bin/tachyon", ["telegram_stop"])
|
||
.then(function () {
|
||
statusMsg.textContent = "";
|
||
return refreshStatus();
|
||
})
|
||
.catch(function () {
|
||
statusMsg.textContent = _("Failed to stop bot");
|
||
btnStop.disabled = false;
|
||
});
|
||
});
|
||
|
||
wrapper.appendChild(indicator);
|
||
wrapper.appendChild(btnStart);
|
||
wrapper.appendChild(btnStop);
|
||
wrapper.appendChild(statusMsg);
|
||
|
||
// Initial status fetch
|
||
refreshStatus();
|
||
// Refresh every 10s while visible
|
||
const timer = setInterval(refreshStatus, 10000);
|
||
const observer = new MutationObserver(function () {
|
||
if (!document.body.contains(wrapper)) {
|
||
clearInterval(timer);
|
||
observer.disconnect();
|
||
}
|
||
});
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
|
||
return wrapper;
|
||
}
|
||
|
||
function createTelegramContent(section) {
|
||
// Виджет статуса бота: показывает состояние и кнопки управления
|
||
const statusOpt = section.option(
|
||
form.DummyValue,
|
||
"_telegram_status",
|
||
_("Bot Status"),
|
||
);
|
||
statusOpt.rawhtml = true;
|
||
statusOpt.cfgvalue = function () {
|
||
return createTelegramStatusWidget();
|
||
};
|
||
|
||
let o = section.option(
|
||
form.Flag,
|
||
"enabled",
|
||
_("Enable Telegram Bot"),
|
||
_(
|
||
"Enables the background daemon that polls Telegram for commands and sends notifications.",
|
||
),
|
||
);
|
||
o.default = "0";
|
||
o.rmempty = false;
|
||
|
||
o = section.option(
|
||
form.Value,
|
||
"bot_token",
|
||
_("Telegram Bot Token"),
|
||
_("Enter the API token obtained from @BotFather."),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.password = true;
|
||
o.rmempty = false;
|
||
o.validate = function (section_id, value) {
|
||
if (this.section.formvalue(section_id, "enabled") === "1" && !value) {
|
||
return _("Token is required when the bot is enabled");
|
||
}
|
||
return true;
|
||
};
|
||
|
||
o = section.option(
|
||
form.Value,
|
||
"admin_ids",
|
||
_("Administrator Chat IDs"),
|
||
_(
|
||
"Comma-separated list of Telegram user IDs authorized to control the bot.",
|
||
),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.rmempty = false;
|
||
o.validate = function (section_id, value) {
|
||
if (this.section.formvalue(section_id, "enabled") === "1") {
|
||
if (!value) {
|
||
return _("At least one Admin Chat ID is required");
|
||
}
|
||
if (!/^-?[0-9]+(,-?[0-9]+)*$/.test(value)) {
|
||
return _("Must be a comma-separated list of numeric IDs");
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
o = section.option(
|
||
form.Value,
|
||
"poll_interval",
|
||
_("Polling Interval (seconds)"),
|
||
_("How often to check Telegram for new messages (default: 5)."),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.default = "5";
|
||
o.rmempty = false;
|
||
o.validate = function (section_id, value) {
|
||
if (this.section.formvalue(section_id, "enabled") !== "1") {
|
||
return true;
|
||
}
|
||
const val = parseInt(value);
|
||
if (isNaN(val) || val < 1) {
|
||
return _("Polling interval must be at least 1 second");
|
||
}
|
||
return true;
|
||
};
|
||
|
||
o = section.option(
|
||
form.Flag,
|
||
"notify_crash",
|
||
_("Notify on Core Crashes"),
|
||
_(
|
||
"Send a Telegram notification if sing-box or nftables rules crash and get auto-restored.",
|
||
),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
|
||
o = section.option(
|
||
form.Flag,
|
||
"notify_restart",
|
||
_("Notify on Service Restarts"),
|
||
_(
|
||
"Send a Telegram notification if the Tachyon service is restarted by the watchdog.",
|
||
),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
|
||
o = section.option(
|
||
form.Flag,
|
||
"notify_server_switch",
|
||
_("Notify on Server Switches"),
|
||
_(
|
||
"Send a Telegram notification when a URLTest group switches to a new server.",
|
||
),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
|
||
o = section.option(
|
||
form.Flag,
|
||
"notify_subscription",
|
||
_("Notify on Subscription Updates"),
|
||
_(
|
||
"Send a Telegram notification when proxy subscriptions are successfully updated.",
|
||
),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
|
||
o = section.option(
|
||
form.Flag,
|
||
"notify_cert",
|
||
_("Notify on Certificate Warnings"),
|
||
_(
|
||
"Send a Telegram notification if SSL/TLS certificates are close to expiration.",
|
||
),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
|
||
o = section.option(
|
||
form.Flag,
|
||
"notify_dns_leak",
|
||
_("Notify on DNS Leaks"),
|
||
_("Send a Telegram notification if a potential DNS leak is detected."),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.default = "1";
|
||
o.rmempty = false;
|
||
|
||
o = section.option(
|
||
form.ListValue,
|
||
"bot_proxy_section",
|
||
_("Proxy Section for Bot"),
|
||
_(
|
||
"Route bot API requests through this section. Leave empty to use the default mixed proxy.",
|
||
),
|
||
);
|
||
o.depends("enabled", "1");
|
||
o.retain = true;
|
||
o.default = "";
|
||
o.rmempty = true;
|
||
o.value("", _("Default (auto)"));
|
||
o.load = function (section_id) {
|
||
const sections = this.map?.data?.state?.values?.[UCI_PACKAGE] ?? {};
|
||
for (const secName in sections) {
|
||
const sec = sections[secName];
|
||
if (
|
||
sec[".type"] === "section" &&
|
||
sec.enabled !== "0" &&
|
||
isDownloadSectionAction(sec.action, null)
|
||
) {
|
||
this.value(secName, sec.label || secName);
|
||
}
|
||
}
|
||
return uci.get(UCI_PACKAGE, "telegram", "bot_proxy_section") || "";
|
||
};
|
||
o.write = function (_section_id, value) {
|
||
const normalized = value ? `${value}`.trim() : "";
|
||
if (normalized) {
|
||
uci.set(UCI_PACKAGE, "telegram", "bot_proxy_section", normalized);
|
||
} else {
|
||
uci.unset(UCI_PACKAGE, "telegram", "bot_proxy_section");
|
||
}
|
||
};
|
||
o.remove = function () {
|
||
uci.unset(UCI_PACKAGE, "telegram", "bot_proxy_section");
|
||
};
|
||
|
||
// ── Telegram API Connection Test ──────────────────────────────────────────
|
||
|
||
const testOpt = section.option(
|
||
form.DummyValue,
|
||
"_test_telegram_connection",
|
||
_("Test Telegram Connection"),
|
||
);
|
||
testOpt.rawhtml = true;
|
||
testOpt.depends("enabled", "1");
|
||
testOpt.cfgvalue = function () {
|
||
const wrapper = E("div", { style: "margin-top: 8px;" });
|
||
const btn = E(
|
||
"button",
|
||
{
|
||
class: "btn cbi-button cbi-button-action",
|
||
type: "button",
|
||
id: "tachyon-telegram-test-btn",
|
||
},
|
||
"\uD83D\uDD0C " + _("Test Connection"),
|
||
);
|
||
const result = E("div", {
|
||
id: "tachyon-telegram-test-result",
|
||
style:
|
||
"margin-top: 8px; white-space: pre-wrap; font-family: monospace; font-size: 12px; display: none;",
|
||
});
|
||
wrapper.appendChild(btn);
|
||
wrapper.appendChild(result);
|
||
|
||
btn.addEventListener("click", function () {
|
||
btn.disabled = true;
|
||
btn.textContent = "\u23F3 " + _("Testing…");
|
||
result.style.display = "block";
|
||
result.style.color = "";
|
||
result.textContent = _("Running diagnostics…");
|
||
|
||
fs.exec("/usr/bin/tachyon", ["telegram_diagnose"])
|
||
.then(function (res) {
|
||
var data;
|
||
try {
|
||
data = JSON.parse(res.stdout || "{}");
|
||
} catch (e) {
|
||
data = null;
|
||
}
|
||
|
||
if (!data || !data.checks) {
|
||
result.textContent =
|
||
"\u274C " +
|
||
_("Failed to run diagnostics — is the tachyon binary installed?");
|
||
btn.disabled = false;
|
||
btn.textContent = "\uD83D\uDD0C " + _("Test Connection");
|
||
return;
|
||
}
|
||
|
||
var lines = [];
|
||
for (var i = 0; i < data.checks.length; i++) {
|
||
var c = data.checks[i];
|
||
lines.push((c.ok ? "\u2705" : "\u274C") + " " + c.message);
|
||
}
|
||
if (data.ok) {
|
||
lines.push("");
|
||
lines.push(
|
||
"\u2705 " +
|
||
_("All checks passed — Telegram bot should work correctly"),
|
||
);
|
||
}
|
||
result.textContent = lines.join("\n");
|
||
result.style.color = data.ok ? "" : "#e53935";
|
||
btn.disabled = false;
|
||
btn.textContent = "\uD83D\uDD0C " + _("Test Connection");
|
||
})
|
||
.catch(function () {
|
||
result.textContent =
|
||
"\u274C " +
|
||
_("Failed to run diagnostics — is the tachyon binary installed?");
|
||
btn.disabled = false;
|
||
btn.textContent = "\uD83D\uDD0C " + _("Test Connection");
|
||
});
|
||
});
|
||
|
||
return wrapper;
|
||
};
|
||
}
|
||
|
||
const EntryPoint = {
|
||
createSettingsContent,
|
||
createTelegramContent,
|
||
};
|
||
|
||
return baseclass.extend(EntryPoint);
|