feat: асинхронное обновление списков в LuCI (fix XHR timeout) и форматированный config.json

- backend: добавлены команды list_update_async и list_update_status с отслеживанием шагов в /var/run/tachyon/list-update.status
- ui: кнопка ручного обновления списков теперь запускает операцию в фоне, показывает модалку с индикатором и опрашивает статус (устранен таймаут LuCI XHR в 30 сек)
- json: sing-box config.json теперь форматируется с отступами в 2 пробела (sprintf '%.2J') для удобства чтения
- tests: обновлены тесты и добавлены проверки для list-update-status (122/122 passed)
This commit is contained in:
Dushnilin
2026-09-06 17:55:40 +03:00
parent 2fd8c2dca3
commit 625715fed0
10 changed files with 256 additions and 17 deletions
+22
View File
@@ -5935,3 +5935,25 @@ msgstr "Обратите внимание: серверы Cloudflare WARP под
msgid "new build" msgid "new build"
msgstr "новая сборка" msgstr "новая сборка"
msgid "This may take 1-2 minutes. The process runs in the background on the router."
msgstr "Это может занять 1–2 минуты. Процесс выполняется в фоновом режиме на роутере."
msgid "Lists update is continuing in the background."
msgstr "Обновление списков продолжается в фоновом режиме."
msgid "Lists update is still running in background. You can check system logs."
msgstr "Обновление списков всё ещё выполняется в фоне. Вы можете проверить системный журнал."
msgid "Error starting lists update: "
msgstr "Ошибка запуска обновления списков: "
msgid "Starting lists update"
msgstr "Запуск обновления списков"
msgid "Downloading and processing lists"
msgstr "Скачивание и обработка списков"
msgid "Applying rules and subnet lists"
msgstr "Применение правил и списков подсетей"
@@ -1985,22 +1985,120 @@ function createSettingsContent(section, capabilities) {
updateListsBtn.inputstyle = "action"; updateListsBtn.inputstyle = "action";
updateListsBtn.depends("list_update_enabled", "1"); updateListsBtn.depends("list_update_enabled", "1");
updateListsBtn.onclick = function () { 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..."), [ ui.showModal(_("Updating lists..."), [
statusText,
helpText,
E( E(
"p", "div",
{ class: "spinning" }, { class: "button-row", style: "margin-top: 16px; text-align: right;" },
_("Downloading and applying rule sets and lists..."), [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 return fs
.exec("/usr/bin/tachyon", ["list_update"]) .exec("/usr/bin/tachyon", ["list_update_async"])
.then(function () { .then(function () {
ui.hideModal(); pollTimer = window.setInterval(checkStatus, 2000);
ui.addNotification(
null,
E("p", _("Lists and rule sets successfully updated!")),
"info",
);
}) })
.catch(function (err) { .catch(function (err) {
ui.hideModal(); ui.hideModal();
+22
View File
@@ -5935,3 +5935,25 @@ msgstr "Обратите внимание: серверы Cloudflare WARP под
msgid "new build" msgid "new build"
msgstr "новая сборка" msgstr "новая сборка"
msgid "This may take 1-2 minutes. The process runs in the background on the router."
msgstr "Это может занять 1–2 минуты. Процесс выполняется в фоновом режиме на роутере."
msgid "Lists update is continuing in the background."
msgstr "Обновление списков продолжается в фоновом режиме."
msgid "Lists update is still running in background. You can check system logs."
msgstr "Обновление списков всё ещё выполняется в фоне. Вы можете проверить системный журнал."
msgid "Error starting lists update: "
msgstr "Ошибка запуска обновления списков: "
msgid "Starting lists update"
msgstr "Запуск обновления списков"
msgid "Downloading and processing lists"
msgstr "Скачивание и обработка списков"
msgid "Applying rules and subnet lists"
msgstr "Применение правил и списков подсетей"
+4
View File
@@ -70,6 +70,8 @@ function show_help() {
print(" uninstall Remove Tachyon files installed outside opkg/apk\n"); print(" uninstall Remove Tachyon files installed outside opkg/apk\n");
print(" main Run main Tachyon process\n"); print(" main Run main Tachyon process\n");
print(" list_update Update remote lists\n"); print(" list_update Update remote lists\n");
print(" list_update_async Update remote lists in background\n");
print(" list_update_status Get background lists update status\n");
print(" list_update_if_due Update remote lists only when the configured interval has elapsed\n"); print(" list_update_if_due Update remote lists only when the configured interval has elapsed\n");
print(" subscription_update [rule] [source_index]\n"); print(" subscription_update [rule] [source_index]\n");
print(" Update all subscription proxies, one rule, or one source in a rule\n"); print(" Update all subscription proxies, one rule, or one source in a rule\n");
@@ -183,6 +185,8 @@ function command_spec(command) {
restore_dnsmasq: [ "service/lifecycle.uc", "dnsmasq-restore", 0 ], restore_dnsmasq: [ "service/lifecycle.uc", "dnsmasq-restore", 0 ],
main: [ "service/lifecycle.uc", "main", 0 ], main: [ "service/lifecycle.uc", "main", 0 ],
list_update: [ "components/updates.uc", "list-update", 0 ], list_update: [ "components/updates.uc", "list-update", 0 ],
list_update_async: [ "components/updates.uc", "list-update-async", 0 ],
list_update_status: [ "components/updates.uc", "list-update-status", 0 ],
list_update_if_due: [ "components/updates.uc", "list-update-if-due", 0 ], list_update_if_due: [ "components/updates.uc", "list-update-if-due", 0 ],
subscription_update: [ "components/updates.uc", "subscription-update", 2 ], subscription_update: [ "components/updates.uc", "subscription-update", 2 ],
subscription_update_async: [ "components/updates.uc", "subscription-update-async", 2 ], subscription_update_async: [ "components/updates.uc", "subscription-update-async", 2 ],
@@ -22,6 +22,7 @@ const TMP_SUBSCRIPTION_FOLDER = getenv("TMP_SUBSCRIPTION_FOLDER") || TMP_SING_BO
const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon"; const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon";
const LIST_UPDATE_STATE_FILE = getenv("TACHYON_LIST_UPDATE_STATE_FILE") || RUNTIME_STATE_DIR + "/list-update.timestamp"; const LIST_UPDATE_STATE_FILE = getenv("TACHYON_LIST_UPDATE_STATE_FILE") || RUNTIME_STATE_DIR + "/list-update.timestamp";
const LIST_UPDATE_PID_FILE = getenv("TACHYON_LIST_UPDATE_PID_FILE") || "/var/run/tachyon_list_update.pid"; const LIST_UPDATE_PID_FILE = getenv("TACHYON_LIST_UPDATE_PID_FILE") || "/var/run/tachyon_list_update.pid";
const LIST_UPDATE_STATUS_FILE = getenv("TACHYON_LIST_UPDATE_STATUS_FILE") || RUNTIME_STATE_DIR + "/list-update.status";
const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update"; const SUBSCRIPTION_UPDATE_STATE_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_STATE_DIR") || RUNTIME_STATE_DIR + "/subscription-update";
const SUBSCRIPTION_JOB_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_JOB_DIR") || "/var/run/tachyon/subscription-update-jobs"; const SUBSCRIPTION_JOB_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_JOB_DIR") || "/var/run/tachyon/subscription-update-jobs";
const SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR") || RUNTIME_STATE_DIR + "/subscription-update.lock"; const SUBSCRIPTION_UPDATE_LOCK_DIR = getenv("TACHYON_SUBSCRIPTION_UPDATE_LOCK_DIR") || RUNTIME_STATE_DIR + "/subscription-update.lock";
@@ -103,6 +104,7 @@ let command_success = common.command_success;
let command_success_from_args = common.command_success_from_args; let command_success_from_args = common.command_success_from_args;
let read_json_file = common.read_json_file; let read_json_file = common.read_json_file;
let write_json = common.write_json; let write_json = common.write_json;
let write_json_file = common.write_json_file;
let write_file = common.write_file; let write_file = common.write_file;
let remove_file = common.remove_file; let remove_file = common.remove_file;
let object_or_empty = common.object_or_empty; let object_or_empty = common.object_or_empty;
@@ -2905,20 +2907,35 @@ function write_list_update_timestamp(timestamp) {
write_file(LIST_UPDATE_STATE_FILE, as_string(timestamp) + "\n"); write_file(LIST_UPDATE_STATE_FILE, as_string(timestamp) + "\n");
} }
function update_list_status(running, success, progress, message) {
ensure_dir(RUNTIME_STATE_DIR);
write_json_file(LIST_UPDATE_STATUS_FILE, {
running: running,
success: success,
progress: progress,
message: message,
timestamp: now_seconds()
});
}
function list_update() { function list_update() {
log_message("Starting lists update", "info"); log_message("Starting lists update", "info");
if (!list_update_pid_begin()) if (!list_update_pid_begin())
exit(0); exit(0);
update_list_status(true, null, "starting", "Starting lists update");
let settings = uci_settings(); let settings = uci_settings();
let proxy_address = service_proxy_address(settings, "lists"); let proxy_address = service_proxy_address(settings, "lists");
if (!dns_probe_passed(proxy_address)) { if (!dns_probe_passed(proxy_address)) {
update_list_status(false, false, "error", "DNS probe failed");
list_update_pid_end(); list_update_pid_end();
exit(0); exit(0);
} }
github_probe(proxy_address); github_probe(proxy_address);
log_message("Downloading and processing lists", "info"); log_message("Downloading and processing lists", "info");
update_list_status(true, null, "downloading", "Downloading and processing lists");
let sections = uci_sections("section"); let sections = uci_sections("section");
let ok = true; let ok = true;
@@ -2937,6 +2954,7 @@ function list_update() {
// Фаза 2: применение к nft (захватываем лок reload, чтобы не конфликтовать // Фаза 2: применение к nft (захватываем лок reload, чтобы не конфликтовать
// с reload-firewall, который пересоздаёт таблицы и сеты nftables) // с reload-firewall, который пересоздаёт таблицы и сеты nftables)
log_message("Waiting for reload lock to apply nft rules", "debug"); log_message("Waiting for reload lock to apply nft rules", "debug");
update_list_status(true, null, "applying", "Applying rules and subnet lists");
if (!acquire_runtime_lock(RELOAD_LOCK_DIR, true)) { if (!acquire_runtime_lock(RELOAD_LOCK_DIR, true)) {
log_message("Could not acquire reload lock for nft apply; saving pending marker for next attempt", "warn"); log_message("Could not acquire reload lock for nft apply; saving pending marker for next attempt", "warn");
// Without this marker the deferred apply is lost for good — nothing else // Without this marker the deferred apply is lost for good — nothing else
@@ -2952,6 +2970,7 @@ function list_update() {
} }
if (!marker_written) if (!marker_written)
log_message("Failed to save pending nft marker, this list update will not be re-applied", "err"); log_message("Failed to save pending nft marker, this list update will not be re-applied", "err");
update_list_status(false, false, "error", "Could not acquire reload lock for nft apply");
list_update_pid_end(); list_update_pid_end();
exit(1); exit(1);
} }
@@ -2976,15 +2995,69 @@ function list_update() {
write_list_update_timestamp(now_seconds()); write_list_update_timestamp(now_seconds());
log_message("Lists update completed successfully", "info"); log_message("Lists update completed successfully", "info");
reload_singbox_after_list_update(); reload_singbox_after_list_update();
update_list_status(false, true, "done", "Lists and rule sets successfully updated");
} }
else { else {
log_message("Lists update failed", "info"); log_message("Lists update failed", "info");
update_list_status(false, false, "failed", "Lists update failed");
} }
list_update_pid_end(); list_update_pid_end();
exit(ok ? 0 : 1); exit(ok ? 0 : 1);
} }
function list_update_async() {
let existing_pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || ""));
if (existing_pid != "" && runtime_pid_running(existing_pid)) {
write_json({
success: true,
running: true,
message: "Lists update is already running"
});
return;
}
update_list_status(true, null, "starting", "Starting lists update");
let cmd = command_env({
TACHYON_CONFIG_NAME: CONFIG_NAME,
TACHYON_LIB: LIB_DIR,
TACHYON_BIN: BIN_PATH
}) + " " + BIN_PATH + " list_update";
system(common.background_command(cmd));
write_json({
success: true,
running: true,
message: "Lists update started"
});
}
function list_update_status() {
let pid = trim(as_string(fs.readfile(LIST_UPDATE_PID_FILE) || ""));
let running = (pid != "" && runtime_pid_running(pid));
let status_data = object_or_empty(read_json_file(LIST_UPDATE_STATUS_FILE));
if (running) {
write_json({
success: true,
running: true,
progress: status_data.progress || "updating",
message: status_data.message || "Updating lists...",
timestamp: status_data.timestamp
});
} else {
write_json({
success: status_data.success != null ? status_data.success : true,
running: false,
progress: status_data.progress || "idle",
message: status_data.message || "Idle",
timestamp: status_data.timestamp || file_first_line_value(LIST_UPDATE_STATE_FILE)
});
}
}
function list_update_if_due() { function list_update_if_due() {
let interval = settings_update_interval(uci_settings()); let interval = settings_update_interval(uci_settings());
if (interval == "") if (interval == "")
@@ -3384,6 +3457,10 @@ else if (mode == "remove-cron-jobs")
remove_cron_jobs(ARGV[1], ARGV[2], ARGV[3]); remove_cron_jobs(ARGV[1], ARGV[2], ARGV[3]);
else if (mode == "list-update") else if (mode == "list-update")
list_update(); list_update();
else if (mode == "list-update-async")
list_update_async();
else if (mode == "list-update-status")
list_update_status();
else if (mode == "list-update-if-due") else if (mode == "list-update-if-due")
list_update_if_due(); list_update_if_due();
else if (mode == "stop-list-update") else if (mode == "stop-list-update")
+3 -2
View File
@@ -67,11 +67,12 @@ function csv_to_json_array(value) {
// rename. Both failure paths already report to the caller through `false`, and // rename. Both failure paths already report to the caller through `false`, and
// an unlink that throws means the temp file was never created — nothing left // an unlink that throws means the temp file was never created — nothing left
// to clean up. // to clean up.
function write_json_file(path, value) { function write_json_file(path, value, indent) {
path = as_string(path); path = as_string(path);
let stamp = clock(); let stamp = clock();
let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]); let tmp_path = sprintf("%s.%d.%d.tmp", path, stamp[0], stamp[1]);
let result = fs.writefile(tmp_path, sprintf("%J\n", value)); let fmt = (indent != null && indent > 0) ? sprintf("%%.%dJ\n", indent) : "%J\n";
let result = fs.writefile(tmp_path, sprintf(fmt, value));
if (result == null || (type(result) == "boolean" && !result)) { if (result == null || (type(result) == "boolean" && !result)) {
try { fs.unlink(tmp_path); } catch(e) {} try { fs.unlink(tmp_path); } catch(e) {}
return false; return false;
+2 -2
View File
@@ -117,10 +117,10 @@ function ensure_parent_dir(path) {
return ensure_dir(parent_dir(path)); return ensure_dir(parent_dir(path));
} }
function atomic_write_json_file(path, value) { function atomic_write_json_file(path, value, indent) {
if (!ensure_parent_dir(path)) if (!ensure_parent_dir(path))
return false; return false;
return write_json_file(path, value); return write_json_file(path, value, indent != null ? indent : 2);
} }
function fixture_section_list(type_name) { function fixture_section_list(type_name) {
+1 -1
View File
@@ -787,7 +787,7 @@ function patch_dns_config(state_path) {
let temp_config = temp_path(); let temp_config = temp_path();
if (backup_path == "" || temp_config == "" || if (backup_path == "" || temp_config == "" ||
fs.writefile(backup_path, fs.readfile(config_path)) == null || fs.writefile(backup_path, fs.readfile(config_path)) == null ||
!common.write_json_file(temp_config, config)) { !common.write_json_file(temp_config, config, 2)) {
remove_files([ backup_path, temp_config ]); remove_files([ backup_path, temp_config ]);
exit(1); exit(1);
} }
+2 -2
View File
@@ -83,7 +83,7 @@ assert_contains "$OUT" '"tag": "dns-block-in"' "always-on: dns-block-in inbound"
assert_contains "$OUT" '"listen_port": 1053' "always-on: block inbound port 1053" assert_contains "$OUT" '"listen_port": 1053' "always-on: block inbound port 1053"
# DNS reject rules with source_ip_cidr for IP devices # DNS reject rules with source_ip_cidr for IP devices
grep -o '"source_ip_cidr": \[ [^]]*\]' "$OUT" | grep -q '192.168.1.150/32' || grep -q '192.168.1.150/32' "$OUT" ||
fail "always-on: DNS rule must carry source_ip_cidr 192.168.1.150/32" fail "always-on: DNS rule must carry source_ip_cidr 192.168.1.150/32"
assert_contains "$OUT" '"action": "reject"' "always-on: DNS reject action" assert_contains "$OUT" '"action": "reject"' "always-on: DNS reject action"
@@ -131,7 +131,7 @@ generate_config "$WORK_DIR/fixture_allow.json" "$OUT"
# Whitelist mode must invert the DNS rule # Whitelist mode must invert the DNS rule
grep -o '"invert": true' "$OUT" | head -1 >/dev/null || grep -o '"invert": true' "$OUT" | head -1 >/dev/null ||
fail "whitelist: DNS rule must be inverted" fail "whitelist: DNS rule must be inverted"
assert_contains "$OUT" '"source_ip_cidr": [ "192.168.1.151/32" ]' "whitelist: source_ip_cidr scoped" assert_contains "$OUT" '192.168.1.151/32' "whitelist: source_ip_cidr scoped"
# ─── Fixture: MAC-only device (no IP in DNS rules, nft redirect only) ──────── # ─── Fixture: MAC-only device (no IP in DNS rules, nft redirect only) ────────
cat >"$WORK_DIR/fixture_mac.json" <<'JSON' cat >"$WORK_DIR/fixture_mac.json" <<'JSON'
+15
View File
@@ -139,4 +139,19 @@ if (value.message !== "Subscription update completed by fake worker") {
} }
NODE NODE
# list-update-status checks
idle_status="$(updates_ucode list-update-status)"
JSON_VALUE="$idle_status" node -e '
const v = JSON.parse(process.env.JSON_VALUE);
if (v.success !== true || v.running !== false) process.exit(1);
' || fail "list-update-status idle state mismatch"
printf '%s\n' "$$" >"$WORK_DIR/tachyon_list_update.pid"
running_status="$(TACHYON_LIST_UPDATE_PID_FILE="$WORK_DIR/tachyon_list_update.pid" updates_ucode list-update-status)"
JSON_VALUE="$running_status" node -e '
const v = JSON.parse(process.env.JSON_VALUE);
if (v.success !== true || v.running !== true) process.exit(1);
' || fail "list-update-status running state mismatch"
rm -f "$WORK_DIR/tachyon_list_update.pid"
printf 'subscription update job checks passed\n' printf 'subscription update job checks passed\n'