fix: исправлен race condition nft + управление Telegram ботом в LuCI

- backend: исправлена гонка данных (race condition) при старте роутера,
  когда list_update пытался добавить элементы в nft-сеты, пока reload-firewall
  ещё пересоздавал таблицу TachyonTable. Фаза применения nft теперь защищена
  RELOAD_LOCK_DIR, что устраняет ошибку 'nft add element ... No such file or directory'

- backend: добавлены CLI-команды telegram_status, telegram_start, telegram_stop
  в /usr/bin/tachyon для управления ботом из LuCI

- luci: добавлен виджет статуса Telegram-бота на вкладке настроек —
  показывает состояние (запущен/остановлен с PID), кнопки Запустить/Остановить,
  автообновление каждые 10 секунд без перезагрузки страницы

- frontend: пересобран main.js (yarn ci — 507 тестов прошли успешно)
- версия npm-пакета: 1.2.0
This commit is contained in:
Dushnilin
2026-07-16 16:55:13 +03:00
parent b7ccf19993
commit becc129a49
8 changed files with 2478 additions and 3335 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "fe-app-tachyon",
"version": "1.1.5",
"version": "1.2.0",
"license": "MIT",
"type": "module",
"scripts": {
@@ -241,10 +241,9 @@ export const TachyonShellMethods = {
[section],
),
getSubscriptionMetadata: async (section: string) =>
callBaseMethod<Tachyon.SubscriptionMetadata | Tachyon.SubscriptionMetadata[]>(
Tachyon.AvailableMethods.GET_SUBSCRIPTION_METADATA,
[section],
),
callBaseMethod<
Tachyon.SubscriptionMetadata | Tachyon.SubscriptionMetadata[]
>(Tachyon.AvailableMethods.GET_SUBSCRIPTION_METADATA, [section]),
checkSingBox: async () =>
callBaseMethod<Tachyon.SingBoxCheckResult>(
Tachyon.AvailableMethods.CHECK_SING_BOX,
@@ -537,7 +536,11 @@ export const TachyonShellMethods = {
) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [Tachyon.AvailableMethods.COMPONENT_ACTION_ASYNC, component, action],
args: [
Tachyon.AvailableMethods.COMPONENT_ACTION_ASYNC,
component,
action,
],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
});
const parsedResponse = parseComponentActionStartResult(response);
@@ -745,7 +748,8 @@ export const TachyonShellMethods = {
while (true) {
await sleep(SUBSCRIPTION_UPDATE_POLL_INTERVAL_MS);
const response = await TachyonShellMethods.subscriptionUpdateStatus(jobId);
const response =
await TachyonShellMethods.subscriptionUpdateStatus(jobId);
if (!response.success) {
if (transientRpc.shouldContinue(response.error)) {
@@ -764,7 +764,8 @@ async function handleShowSingBoxConfig() {
setDiagnosticActionLoading('showSingBoxConfig', true);
try {
const showSingBoxConfig = await TachyonShellMethods.showSingBoxConfig(false);
const showSingBoxConfig =
await TachyonShellMethods.showSingBoxConfig(false);
if (showSingBoxConfig.success) {
const rawSingBoxConfigText = stringifySingBoxConfig(
@@ -532,7 +532,9 @@ async function completeComponentActionJob(
});
}
async function followComponentActionState(state: Tachyon.ComponentActionResult) {
async function followComponentActionState(
state: Tachyon.ComponentActionResult,
) {
const jobId = state.job_id;
const key = getComponentActionKey(state.component, state.action);
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@
"require form";
"require uci";
"require baseclass";
"require fs";
"require tools.widgets as widgets";
"require view.tachyon.main as main";
@@ -700,7 +701,143 @@ function createSettingsContent(section, capabilities) {
o.rmempty = false;
}
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",
+6
View File
@@ -122,6 +122,9 @@ function show_help() {
print(" doctor Run doctor diagnostics checks\n");
print(" ai_doctor Run AI doctor diagnostics completions\n");
print(" watchdog Manage watchdog daemon (start-runtime|stop-runtime|status)\n");
print(" telegram_status Get Telegram bot running status\n");
print(" telegram_start Start Telegram bot\n");
print(" telegram_stop Stop Telegram bot\n");
print(" validate_nfqws_strategy_json\n");
print(" Validate an NFQWS strategy and print JSON result\n");
print(" validate_nfqws2_strategy_json\n");
@@ -197,6 +200,9 @@ function command_spec(command) {
ai_doctor: [ "diagnostics/runtime.uc", "ai-doctor", 0 ],
watchdog: [ "service/watchdog.uc", "", 1 ],
telegram: [ "service/telegram.uc", "", 2 ],
telegram_status: [ "service/telegram.uc", "status", 0 ],
telegram_start: [ "service/telegram.uc", "start-runtime", 0 ],
telegram_stop: [ "service/telegram.uc", "stop-runtime", 0 ],
validate_nfqws_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws-strategy-json", 1 ],
validate_nfqws2_strategy_json: [ "diagnostics/runtime.uc", "validate-nfqws2-strategy-json", 1 ],
validate_byedpi_strategy_json: [ "providers/byedpi/validator.uc", "validate-json", 1 ],
+16 -3
View File
@@ -2497,15 +2497,26 @@ function list_update() {
let sections = uci_sections("section");
let ok = true;
// Фаза 1: скачивание файлов (без блокировки, не трогает nft)
for (let section in sections)
if (!rebuild_domain_ip_lists_from_rule(section, settings))
ok = false;
for (let section in sections)
if (!import_builtin_subnets_from_rule(section, settings))
ok = false;
for (let section in sections)
if (!import_domains_from_remote_domain_lists(section, settings))
ok = false;
// Фаза 2: применение к nft (захватываем лок reload, чтобы не конфликтовать
// с reload-firewall, который пересоздаёт таблицы и сеты nftables)
log_message("Waiting for reload lock to apply nft rules", "debug");
if (!acquire_runtime_lock(RELOAD_LOCK_DIR, true)) {
log_message("Could not acquire reload lock for nft apply; skipping nft update", "warn");
list_update_pid_end();
exit(1);
}
for (let section in sections)
if (!import_builtin_subnets_from_rule(section, settings))
ok = false;
for (let section in sections)
if (!import_subnets_from_remote_subnet_lists(section, settings))
ok = false;
@@ -2513,6 +2524,8 @@ function list_update() {
if (!import_rule_sets_with_subnets_from_rule(section, settings))
ok = false;
release_runtime_lock(RELOAD_LOCK_DIR);
if (ok) {
write_list_update_timestamp(now_seconds());
log_message("Lists update completed successfully", "info");