Add section-level dashboard server filtering

Replace per-group dashboard hiding with a global section filter, including live URLTest and Priority group selectors. Keep server selectors usable before choices exist and preserve shared rule-set values across conditional LuCI fields.
This commit is contained in:
ushan0v
2026-07-14 00:33:11 +03:00
parent bc2b1faf91
commit f8928fdf24
21 changed files with 2549 additions and 1539 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+65 -11
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: FORKOP\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-13 03:22+0300\n"
"PO-Revision-Date: 2026-07-13 03:22+0300\n"
"POT-Creation-Date: 2026-07-13 00:06+0300\n"
"PO-Revision-Date: 2026-07-13 00:06+0300\n"
"Last-Translator: ushan0v\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -77,6 +77,9 @@ msgstr "Добавьте URL или локальные пути к списка
msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported."
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила."
msgid "Add URLTest / Priority group"
msgstr "Добавить URLTest / Приоритет группу"
msgid "Additional marking rules found"
msgstr "Найдены дополнительные правила маркировки"
@@ -86,6 +89,12 @@ msgstr "Дополнительно исключать серверы по про
msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно отбирать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам."
msgid "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters."
msgstr "Дополнительно скрывать серверы по протоколу, транспорту и защите. Скрывать серверы, совпавшие по указанным параметрам."
msgid "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно показывать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам."
msgid "Address"
msgstr "Адрес"
@@ -647,6 +656,9 @@ msgstr "Исключить из этого уровня серверы с одн
msgid "Exclude servers with one of the selected transports from this level."
msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов."
msgid "Exclude URLTest / Priority group"
msgstr "Исключить URLTest / Приоритет группу"
msgid "Expecting: %s"
msgstr "Ожидается: %s"
@@ -698,6 +710,9 @@ msgstr "Интервал поиска более быстрого сервера
msgid "Fastest"
msgstr "Самый быстрый"
msgid "Filter the servers that will be displayed on the dashboard."
msgstr "Отфильтровать серверы, которые будут отображаться в дашборде."
msgid "Firewall WAN port"
msgstr "Порт в firewall для WAN"
@@ -794,24 +809,36 @@ msgstr "gRPC-сервис"
msgid "Handshake timeout"
msgstr "Таймаут рукопожатия"
msgid "Hide added servers"
msgstr "Скрывать добавленные серверы"
msgid "Hide cascade connection nodes"
msgstr "Скрывать узлы каскадных подключений"
msgid "Hide dashboard servers added to Priority"
msgstr "Скрывать серверы, добавленные в Priority, из дашборда"
msgid "Hide dashboard servers added to URLTest"
msgstr "Скрывать серверы из дашборда, которые добавлены в URLTest"
msgid "Hide individual nodes that are already included in imported subscription URLTest groups"
msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки"
msgid "Hide intermediate nodes used as detours by other subscription nodes"
msgstr "Скрывать промежуточные узлы, которые используются как detour для других узлов подписки"
msgid "Hide selected servers."
msgstr "Скрывать выбранные серверы."
msgid "Hide servers from selected groups without repeating their filtering criteria."
msgstr "Скрывать серверы из выбранных групп без повторения их параметров фильтрации."
msgid "Hide servers from the specified countries."
msgstr "Скрывать серверы из указанных стран."
msgid "Hide servers whose names match the expression."
msgstr "Скрывать серверы, имена которых совпадают с выражением."
msgid "Hide servers with one of the selected protocols."
msgstr "Скрывать серверы с одним из выбранных протоколов."
msgid "Hide servers with one of the selected security types."
msgstr "Скрывать серверы с одним из выбранных типов защиты."
msgid "Hide servers with one of the selected transports."
msgstr "Скрывать серверы с одним из выбранных транспортов."
msgid "Hide URLTest group nodes"
msgstr "Скрывать узлы URLTest групп"
@@ -1646,6 +1673,9 @@ msgstr "Выберите транзитную секцию"
msgid "Select an existing network interface"
msgstr "Выберите существующий сетевой интерфейс"
msgid "Select an existing URLTest or Priority group"
msgstr "Выберите существующую URLTest или Приоритет группу"
msgid "Select an intermediate section"
msgstr "Выберите промежуточную секцию"
@@ -1721,6 +1751,9 @@ msgstr "server_port должен быть числом от 1 до 65535"
msgid "Servers"
msgstr "Серверы"
msgid "Servers on dashboard"
msgstr "Серверы в дашборде"
msgid "Service action failed"
msgstr "Действие сервиса не выполнено"
@@ -1736,6 +1769,27 @@ msgstr "Настройки"
msgid "Show metadata on dashboard"
msgstr "Отображать метаданные в дашборде"
msgid "Show only selected servers."
msgstr "Показывать только выбранные серверы."
msgid "Show only servers with one of the selected protocols."
msgstr "Показывать только серверы с одним из выбранных протоколов."
msgid "Show only servers with one of the selected security types."
msgstr "Показывать только серверы с одним из выбранных типов защиты."
msgid "Show only servers with one of the selected transports."
msgstr "Показывать только серверы с одним из выбранных транспортов."
msgid "Show servers from selected groups without repeating their filtering criteria."
msgstr "Показывать серверы из выбранных групп без повторения их параметров фильтрации."
msgid "Show servers only from the specified countries."
msgstr "Показывать серверы только из указанных стран."
msgid "Show servers whose names match the expression."
msgstr "Показывать серверы, имена которых совпадают с выражением."
msgid "Show sing-box config"
msgstr "Показать sing-box конфигурацию"
@@ -70,7 +70,6 @@ type PriorityCacheGroup = {
fastest_check_interval?: string;
interrupt_exist_connections?: boolean;
pin_dashboard?: boolean;
hide_added_outbounds?: boolean;
outbounds?: string[];
levels?: PriorityCacheLevel[];
};
@@ -100,7 +99,6 @@ type UrlTestConfig = {
displayName: string;
settings: ItemSettings;
pinDashboard: boolean;
hideAddedOutbounds: boolean;
showDetectedCountries: boolean;
};
@@ -110,7 +108,6 @@ type PriorityConfig = {
displayName: string;
settings: ItemSettings;
pinDashboard: boolean;
hideAddedOutbounds: boolean;
healthUrl: string;
activeCheckInterval: string;
checkTimeout: string;
@@ -286,7 +283,6 @@ function hydrateConfigSections(configSections: Forkop.ConfigSection[]) {
idle_timeout: item.idle_timeout,
interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
urltest_filter_mode: item.filter_mode,
detect_server_country: item.detect_server_country,
urltest_include_countries: item.include_countries,
@@ -339,7 +335,6 @@ function hydrateConfigSections(configSections: Forkop.ConfigSection[]) {
fastest_check_interval: item.fastest_check_interval,
interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
levels,
};
});
@@ -661,11 +656,6 @@ function getUrlTestConfigs(section: Forkop.ConfigSection): UrlTestConfig[] {
displayName: getUrlTestDisplayName(section, id, settings),
settings,
pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true),
hideAddedOutbounds: itemSettingBoolean(
settings,
'hide_added_outbounds',
false,
),
showDetectedCountries:
filteringEnabled &&
itemSettingString(settings, 'detect_server_country', 'flag_emoji') ===
@@ -748,11 +738,6 @@ function getPriorityConfigs(section: Forkop.ConfigSection): PriorityConfig[] {
displayName: itemSettingString(settings, 'name', id),
settings,
pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true),
hideAddedOutbounds: itemSettingBoolean(
settings,
'hide_added_outbounds',
false,
),
healthUrl: itemSettingString(
settings,
'health_url',
@@ -1108,31 +1093,6 @@ function buildProxyGroupOutbounds(
const selectorCodes = selector?.value?.all ?? [];
const urlTestCodes = urlTestConfigs.map((config) => config.code);
const priorityCodes = priorityConfigs.map((config) => config.code);
const urlTestCodeSet = new Set(urlTestCodes);
const priorityCodeSet = new Set(priorityCodes);
const hideAddedCodeSet = new Set<string>();
urlTestEntries.forEach(({ config, entry }) => {
if (!config.hideAddedOutbounds) {
return;
}
const childCodes = urltestGroups[config.code]?.outbounds?.length
? urltestGroups[config.code].outbounds || []
: entry?.value.all || [];
childCodes.forEach((code) => hideAddedCodeSet.add(code));
});
priorityEntries.forEach(({ config, entry }) => {
if (!config.hideAddedOutbounds) {
return;
}
const childCodes = priorityGroups[config.code]?.outbounds?.length
? priorityGroups[config.code].outbounds || []
: entry?.value.all || [];
childCodes.forEach((code) => hideAddedCodeSet.add(code));
});
const showDetectedCountries =
urlTestConfigs.some((config) => config.showDetectedCountries) ||
priorityConfigs.some((config) => config.showDetectedCountries);
@@ -1147,17 +1107,7 @@ function buildProxyGroupOutbounds(
...(selectorCodes.length ? selectorCodes : fallbackCodes),
...urlTestCodes,
...priorityCodes,
]).filter((code) => {
if (!hideAddedCodeSet.has(code)) {
return true;
}
return (
urlTestCodeSet.has(code) ||
priorityCodeSet.has(code) ||
isUrlTestProxyEntry(proxyByCode.get(code))
);
});
]);
const outbounds = uniqueCodes(groupCodes).flatMap((code) => {
const item = proxyByCode.get(code);
@@ -87,7 +87,6 @@ function priorityGroup(
fastest_check_interval: '3m',
interrupt_exist_connections: '1',
pin_dashboard: '1',
hide_added_outbounds: '0',
...options,
};
}
@@ -423,59 +422,6 @@ describe('getDashboardSections', () => {
]);
});
it('hides servers already added to the Priority group when enabled', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({ urltests: [], urltest_settings: undefined }),
priorityGroup('pg_main', { hide_added_outbounds: '1' }),
priorityLevel('pl_de'),
]);
mocks.getClashApiProxies.mockResolvedValue({
success: true,
data: {
proxies: {
...clashProxies,
'main-out': proxy('Selector', {
name: 'main-out',
now: 'main-priority-pg_main-out',
all: ['main-1-out', 'main-2-out', 'main-priority-pg_main-out'],
}),
'main-priority-pg_main-out': proxy('Selector', {
name: 'main-priority-pg_main-out',
now: 'main-1-out',
all: ['main-1-out', 'main-2-out'],
}),
},
},
});
mocks.fsRead.mockResolvedValue(
JSON.stringify({
priorityGroups: {
'main-priority-pg_main-out': {
displayName: 'Codex Priority',
outbounds: ['main-1-out', 'main-2-out'],
levels: [
{
id: 'pl_de',
displayName: 'Germany',
order: 0,
outbounds: ['main-1-out', 'main-2-out'],
},
],
},
},
}),
);
const result = await getDashboardSections();
const [section] = result.data;
expect(result.success).toBe(true);
expect(section.outbounds.map((item) => item.code)).toEqual([
'main-priority-pg_main-out',
]);
expect(section.latencyTestCodes).toBeUndefined();
});
it('keeps an empty Priority group visible when sing-box skipped the selector', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
@@ -522,31 +468,6 @@ describe('getDashboardSections', () => {
expect(priority?.priorityInfo?.outbounds).toEqual([]);
});
it('hides servers already added to the URLTest group when enabled', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
urltest_settings: urlTestSettings('urltest', {
display_name: 'Fastest',
hide_added_outbounds: '1',
}),
}),
]);
const result = await getDashboardSections();
const [section] = result.data;
expect(result.success).toBe(true);
expect(section.latencyTestCode).toBe('main-out');
expect(section.latencyTestCodes).toEqual([
'main-urltest-out',
'main-2-out',
]);
expect(section.outbounds.map((item) => item.code)).toEqual([
'main-urltest-out',
'main-2-out',
]);
});
it('keeps legacy URLTest sections compatible before migration', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
@@ -636,31 +557,6 @@ describe('getDashboardSections', () => {
]);
});
it.each(['exclude', 'include', 'mixed', 'disabled'] as const)(
'hides added URLTest members independently from %s filter mode',
async (urltest_filter_mode) => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
urltest_settings: urlTestSettings('urltest', {
display_name: 'Fastest',
urltest_filter_mode,
hide_added_outbounds: '1',
}),
}),
]);
const result = await getDashboardSections();
const [section] = result.data;
expect(result.success).toBe(true);
expect(section.latencyTestCode).toBe('main-out');
expect(section.latencyTestCodes).toEqual([
'main-urltest-out',
'main-2-out',
]);
},
);
it('keeps non-built-in URLTest groups in selector order when latency sorting is disabled', async () => {
mocks.getConfigSections.mockResolvedValue([proxySection()]);
mocks.getClashApiProxies.mockResolvedValue({
@@ -822,72 +718,6 @@ describe('getDashboardSections', () => {
]);
});
it('keeps visible URLTest groups when added leaf servers are hidden', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
urltest_settings: urlTestSettings('urltest', {
display_name: 'Fastest',
hide_added_outbounds: '1',
}),
}),
]);
mocks.getClashApiProxies.mockResolvedValue({
success: true,
data: {
proxies: {
'main-out': proxy('Selector', {
name: 'main-out',
now: 'main-1-out',
all: [
'main-1-out',
'main-provider-urltest-out',
'main-2-out',
'main-3-out',
'main-urltest-out',
],
}),
'main-urltest-out': proxy('URLTest', {
name: 'main-urltest-out',
history: [{ time: '2026-06-11T00:00:00Z', delay: 10 }],
all: ['main-1-out', 'main-3-out'],
}),
'main-provider-urltest-out': proxy('URLTest', {
name: 'Provider URLTest',
history: [{ time: '2026-06-11T00:00:00Z', delay: 150 }],
all: ['provider-hidden-1-out'],
}),
'main-1-out': proxy('VLESS', {
name: 'Included 1',
history: [{ time: '2026-06-11T00:00:00Z', delay: 100 }],
}),
'main-2-out': proxy('VLESS', {
name: 'Filtered 2',
history: [{ time: '2026-06-11T00:00:00Z', delay: 200 }],
}),
'main-3-out': proxy('VLESS', {
name: 'Included 3',
history: [{ time: '2026-06-11T00:00:00Z', delay: 300 }],
}),
},
},
});
const result = await getDashboardSections();
const [section] = result.data;
expect(result.success).toBe(true);
expect(section.latencyTestCodes).toEqual([
'main-urltest-out',
'main-provider-urltest-out',
'main-2-out',
]);
expect(section.outbounds.map((item) => item.code)).toEqual([
'main-urltest-out',
'main-provider-urltest-out',
'main-2-out',
]);
});
it('does not expose flag-emoji detected countries on dashboard outbounds', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({
+18 -1
View File
@@ -267,6 +267,24 @@ export namespace Forkop {
urltest_settings?: string;
priority_groups?: string[];
priority_group_settings?: string;
dashboard_filter_mode?: 'disabled' | 'exclude' | 'include' | 'mixed';
dashboard_detect_server_country?: 'flag_emoji' | 'country_is';
dashboard_include_countries?: string[];
dashboard_include_outbounds?: string[];
dashboard_include_regex?: string[];
dashboard_include_proxy_parameters?: '0' | '1';
dashboard_include_protocols?: string[];
dashboard_include_transports?: string[];
dashboard_include_securities?: string[];
dashboard_include_groups?: string[];
dashboard_exclude_countries?: string[];
dashboard_exclude_outbounds?: string[];
dashboard_exclude_regex?: string[];
dashboard_exclude_proxy_parameters?: '0' | '1';
dashboard_exclude_protocols?: string[];
dashboard_exclude_transports?: string[];
dashboard_exclude_securities?: string[];
dashboard_exclude_groups?: string[];
urltest_proxy_links?: string[];
subscription_url?: string;
subscription_user_agent?: string;
@@ -298,7 +316,6 @@ export namespace Forkop {
idle_timeout?: string;
interrupt_exist_connections?: '0' | '1';
pin_dashboard?: '0' | '1';
hide_added_outbounds?: '0' | '1';
filter_mode?: 'disabled' | 'exclude' | 'include' | 'mixed';
include_countries?: string[];
include_outbounds?: string[];
+6 -1
View File
@@ -44,6 +44,12 @@ config settings 'settings'
# list community_lists 'russia_inside'
# list rule_set 'https://example.com/rules.srs'
# list rule_set_with_subnets 'https://example.com/rules-with-subnets.json'
# option dashboard_filter_mode 'disabled'
# option dashboard_detect_server_country 'flag_emoji'
# #list dashboard_include_outbounds 'Example server name'
# #list dashboard_include_groups 'Fastest'
# #list dashboard_exclude_outbounds 'Backup server name'
# #list dashboard_exclude_groups 'Backup'
# option outbound_detour_enabled '0'
# #option outbound_detour_section 'main'
# option sort_by_latency '0'
@@ -81,7 +87,6 @@ config settings 'settings'
# option idle_timeout '30m'
# option interrupt_exist_connections '1'
# option pin_dashboard '1'
# option hide_added_outbounds '0'
# option filter_mode 'disabled'
# option detect_server_country 'flag_emoji'
# list exclude_countries 'US'
+90 -14
View File
@@ -363,6 +363,78 @@ function priority_groups(section) {
return result;
}
function dashboard_filter_mode(section) {
return option(section, "dashboard_filter_mode", "disabled") || "disabled";
}
function dashboard_detect_server_country(section) {
return option(section, "dashboard_detect_server_country", "flag_emoji") || "flag_emoji";
}
function dashboard_include_countries(section) {
return list_value(section, "dashboard_include_countries");
}
function dashboard_include_outbounds(section) {
return list_value(section, "dashboard_include_outbounds");
}
function dashboard_include_regex(section) {
return list_value(section, "dashboard_include_regex");
}
function dashboard_include_proxy_parameters(section) {
return bool_option(section, "dashboard_include_proxy_parameters", false);
}
function dashboard_include_protocols(section) {
return list_value(section, "dashboard_include_protocols");
}
function dashboard_include_transports(section) {
return list_value(section, "dashboard_include_transports");
}
function dashboard_include_securities(section) {
return list_value(section, "dashboard_include_securities");
}
function dashboard_include_groups(section) {
return list_value(section, "dashboard_include_groups");
}
function dashboard_exclude_countries(section) {
return list_value(section, "dashboard_exclude_countries");
}
function dashboard_exclude_outbounds(section) {
return list_value(section, "dashboard_exclude_outbounds");
}
function dashboard_exclude_regex(section) {
return list_value(section, "dashboard_exclude_regex");
}
function dashboard_exclude_proxy_parameters(section) {
return bool_option(section, "dashboard_exclude_proxy_parameters", false);
}
function dashboard_exclude_protocols(section) {
return list_value(section, "dashboard_exclude_protocols");
}
function dashboard_exclude_transports(section) {
return list_value(section, "dashboard_exclude_transports");
}
function dashboard_exclude_securities(section) {
return list_value(section, "dashboard_exclude_securities");
}
function dashboard_exclude_groups(section) {
return list_value(section, "dashboard_exclude_groups");
}
function priority_group_child(section, value) {
return owned_child_section(section, "priority_group", value);
}
@@ -677,13 +749,6 @@ function urltest_detect_server_country(section, value) {
option(section, "detect_server_country", "flag_emoji") || "flag_emoji");
}
function urltest_hide_added_outbounds(section, value) {
let child = urltest_child(section, value);
if (child != null)
return child_bool(child, "hide_added_outbounds", false);
return item_bool(section, "urltest_settings", value, "hide_added_outbounds", false);
}
function urltest_pin_dashboard(section, value) {
let child = urltest_child(section, value);
if (child != null)
@@ -824,11 +889,6 @@ function priority_group_interrupt_exist_connections(section, value) {
return child != null ? child_bool(child, "interrupt_exist_connections", true) : true;
}
function priority_group_hide_added_outbounds(section, value) {
let child = priority_group_child(section, value);
return child != null ? child_bool(child, "hide_added_outbounds", false) : false;
}
function priority_group_pin_dashboard(section, value) {
let child = priority_group_child(section, value);
return child != null ? child_bool(child, "pin_dashboard", true) : true;
@@ -999,6 +1059,24 @@ return {
interfaces,
outbound_jsons,
urltests,
dashboard_filter_mode,
dashboard_detect_server_country,
dashboard_include_countries,
dashboard_include_outbounds,
dashboard_include_regex,
dashboard_include_proxy_parameters,
dashboard_include_protocols,
dashboard_include_transports,
dashboard_include_securities,
dashboard_include_groups,
dashboard_exclude_countries,
dashboard_exclude_outbounds,
dashboard_exclude_regex,
dashboard_exclude_proxy_parameters,
dashboard_exclude_protocols,
dashboard_exclude_transports,
dashboard_exclude_securities,
dashboard_exclude_groups,
community_lists,
rule_sets,
rule_sets_with_subnets,
@@ -1033,7 +1111,6 @@ return {
urltest_interrupt_exist_connections,
urltest_filter_mode,
urltest_detect_server_country,
urltest_hide_added_outbounds,
urltest_pin_dashboard,
urltest_include_countries,
urltest_include_outbounds,
@@ -1061,7 +1138,6 @@ return {
priority_group_switch_to_faster_same_priority,
priority_group_fastest_check_interval,
priority_group_interrupt_exist_connections,
priority_group_hide_added_outbounds,
priority_group_pin_dashboard,
priority_level_display_name,
priority_level_order,
+59
View File
@@ -1033,6 +1033,13 @@ function validate_urltest_filter_mode_value(value, section) {
fail_validation("Invalid URLTest filter mode '" + value + "' in rule '" + section + "'. Aborted.");
}
function validate_dashboard_filter_mode_value(value, section) {
if (as_string(value) == "" || contains([ "disabled", "exclude", "include", "mixed" ], value))
return;
fail_validation("Invalid dashboard filter mode '" + value + "' in rule '" + section + "'. Aborted.");
}
function validate_priority_filter_mode_value(value, section, group_id, level_id) {
if (contains([ "disabled", "exclude", "include", "mixed" ], value))
return;
@@ -1166,6 +1173,56 @@ function validate_priority_group(section, group_id) {
}
}
function validate_dashboard_group_references(section, values) {
let available = {};
for (let group_id in connections.urltests(section))
available[connections.urltest_display_name(section, group_id)] = true;
for (let group_id in connections.priority_groups(section))
available[connections.priority_group_display_name(section, group_id)] = true;
for (let group_name in values)
if (!available[group_name])
fail_validation("Unknown dashboard URLTest/Priority group '" + group_name + "' in rule '" + section_name(section) + "'. Aborted.");
}
function validate_dashboard_filter(section) {
let name = section_name(section);
let filter_mode = connections.dashboard_filter_mode(section);
validate_dashboard_filter_mode_value(filter_mode, name);
if (filter_mode == "disabled")
return;
validate_detect_server_country_value(connections.dashboard_detect_server_country(section), name);
if (filter_mode == "include" || filter_mode == "mixed") {
for (let value in connections.dashboard_include_countries(section))
validate_country_code_value(value, name);
for (let value in connections.dashboard_include_regex(section))
validate_urltest_regex_value(value, name);
validate_dashboard_group_references(section, connections.dashboard_include_groups(section));
if (connections.dashboard_include_proxy_parameters(section))
validate_proxy_parameter_filters(
connections.dashboard_include_protocols(section),
connections.dashboard_include_transports(section),
connections.dashboard_include_securities(section),
name
);
}
if (filter_mode == "exclude" || filter_mode == "mixed") {
for (let value in connections.dashboard_exclude_countries(section))
validate_country_code_value(value, name);
for (let value in connections.dashboard_exclude_regex(section))
validate_urltest_regex_value(value, name);
validate_dashboard_group_references(section, connections.dashboard_exclude_groups(section));
if (connections.dashboard_exclude_proxy_parameters(section))
validate_proxy_parameter_filters(
connections.dashboard_exclude_protocols(section),
connections.dashboard_exclude_transports(section),
connections.dashboard_exclude_securities(section),
name
);
}
}
function validate_combined_domain_value(value, section) {
if (as_string(value) == "" || combined_domain_valid(value))
return;
@@ -1397,6 +1454,8 @@ function validate_rule(section, sections, context) {
}
if (connections.is_connections_action(action)) {
validate_dashboard_filter(section);
for (let urltest_id in connections.urltests(section)) {
validate_urltest_identifier_value(urltest_id, name);
+24 -2
View File
@@ -959,6 +959,29 @@ function interfaces_signature(section) {
return sprintf("%J", result);
}
function dashboard_filter_signature(section) {
return sprintf("%J", {
filter_mode: connections.dashboard_filter_mode(section),
detect_server_country: connections.dashboard_detect_server_country(section),
include_countries: connections.dashboard_include_countries(section),
include_outbounds: connections.dashboard_include_outbounds(section),
include_regex: connections.dashboard_include_regex(section),
include_proxy_parameters: connections.dashboard_include_proxy_parameters(section) ? "1" : "0",
include_protocols: connections.dashboard_include_protocols(section),
include_transports: connections.dashboard_include_transports(section),
include_securities: connections.dashboard_include_securities(section),
include_groups: connections.dashboard_include_groups(section),
exclude_countries: connections.dashboard_exclude_countries(section),
exclude_outbounds: connections.dashboard_exclude_outbounds(section),
exclude_regex: connections.dashboard_exclude_regex(section),
exclude_proxy_parameters: connections.dashboard_exclude_proxy_parameters(section) ? "1" : "0",
exclude_protocols: connections.dashboard_exclude_protocols(section),
exclude_transports: connections.dashboard_exclude_transports(section),
exclude_securities: connections.dashboard_exclude_securities(section),
exclude_groups: connections.dashboard_exclude_groups(section)
});
}
function urltests_signature(section) {
let result = [];
for (let entry in connections.urltests(section)) {
@@ -971,7 +994,6 @@ function urltests_signature(section) {
idle_timeout: connections.urltest_idle_timeout(section, entry),
interrupt_exist_connections: connections.urltest_interrupt_exist_connections(section, entry) ? "1" : "0",
pin_dashboard: connections.urltest_pin_dashboard(section, entry) ? "1" : "0",
hide_added_outbounds: connections.urltest_hide_added_outbounds(section, entry) ? "1" : "0",
filter_mode: connections.urltest_filter_mode(section, entry),
detect_server_country: connections.urltest_detect_server_country(section, entry),
include_countries: connections.urltest_include_countries(section, entry),
@@ -1018,7 +1040,6 @@ function priority_groups_signature(section) {
fastest_check_interval: connections.priority_group_fastest_check_interval(section, group_id),
interrupt_exist_connections: connections.priority_group_interrupt_exist_connections(section, group_id) ? "1" : "0",
pin_dashboard: connections.priority_group_pin_dashboard(section, group_id) ? "1" : "0",
hide_added_outbounds: connections.priority_group_hide_added_outbounds(section, group_id) ? "1" : "0",
levels
});
}
@@ -1205,6 +1226,7 @@ function append_sing_box_rule_signature_body(body, section, sections) {
body = signature_add_value(body, prefix + ".legacy_outbound_json", option(section, "outbound_json", ""));
body = signature_add_value(body, prefix + ".urltests", urltests_signature(section));
body = signature_add_value(body, prefix + ".priority_groups", priority_groups_signature(section));
body = signature_add_value(body, prefix + ".dashboard_filter", dashboard_filter_signature(section));
body = signature_add_value(body, prefix + ".urltest_enabled", bool_option_value(section, "urltest_enabled", false));
body = signature_add_value(body, prefix + ".detect_server_country", normalize_detect_server_country_method(option(section, "detect_server_country", "flag_emoji")));
body = signature_add_value(body, prefix + ".urltest_check_interval", section_urltest_check_interval(section));
+92 -26
View File
@@ -895,6 +895,15 @@ function configured_country_filter(mode, include_countries, exclude_countries) {
}
function section_needs_country_is(section) {
let dashboard_mode = connections.dashboard_filter_mode(section);
if (connections.dashboard_detect_server_country(section) == "country_is" &&
configured_country_filter(
dashboard_mode,
connections.dashboard_include_countries(section),
connections.dashboard_exclude_countries(section)
))
return true;
for (let urltest_id in connections.urltests(section)) {
let mode = connections.urltest_filter_mode(section, urltest_id);
if (connections.urltest_detect_server_country(section, urltest_id) == "country_is" &&
@@ -1028,30 +1037,32 @@ function urltest_all_candidate_outbounds(urltest_candidate_tags) {
}
function urltest_matching_candidate_outbounds(urltest_candidate_tags, names, countries, name_filter, regexes, country_filter,
metadata, proxy_parameters_enabled, proxy_parameters_operator, protocols, transports, securities) {
metadata, proxy_parameters_enabled, proxy_parameters_operator, protocols, transports, securities, additional_matches) {
names = object_or_empty(names);
countries = object_or_empty(countries);
country_filter = runtime_urltest.normalized_country_list(country_filter);
let regex_set = regex_match_set(urltest_candidate_tags, names, regexes);
let base_filter_configured = name_or_country_filter_configured(name_filter, regexes, country_filter);
let additional_set = object_keys_set(additional_matches);
let result = [];
for (let tag in array_or_empty(urltest_candidate_tags)) {
let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set) ||
tag_country_filter_matches(tag, countries, country_filter);
let matches = base_matches;
let matches = additional_set[tag] || base_matches;
if (proxy_parameters_enabled && proxy_parameters_operator == "or") {
matches = base_matches || proxy_parameter_filter_matches_any(
matches = additional_set[tag] || base_matches || proxy_parameter_filter_matches_any(
tag, metadata, protocols, transports, securities
);
}
else if (proxy_parameters_enabled) {
if (!base_filter_configured)
base_matches = true;
matches = base_matches && proxy_parameter_filter_matches_all(
tag, metadata, protocols, transports, securities
);
matches = additional_set[tag] ||
(base_matches && proxy_parameter_filter_matches_all(
tag, metadata, protocols, transports, securities
));
}
if (matches)
@@ -1075,7 +1086,8 @@ function filter_candidate_outbounds(filter_mode, urltest_candidate_tags, names,
include_names, include_regex, include_countries,
include_proxy_parameters, include_protocols, include_transports, include_securities,
exclude_names, exclude_regex, exclude_countries,
exclude_proxy_parameters, exclude_protocols, exclude_transports, exclude_securities) {
exclude_proxy_parameters, exclude_protocols, exclude_transports, exclude_securities,
include_additional_matches, exclude_additional_matches) {
let all_outbounds = urltest_all_candidate_outbounds(urltest_candidate_tags);
if (filter_mode == "" || filter_mode == "disabled")
return all_outbounds;
@@ -1094,7 +1106,8 @@ function filter_candidate_outbounds(filter_mode, urltest_candidate_tags, names,
"and",
include_protocols,
include_transports,
include_securities
include_securities,
include_additional_matches
);
let exclude_outbounds = urltest_matching_candidate_outbounds(
urltest_candidate_tags,
@@ -1108,7 +1121,8 @@ function filter_candidate_outbounds(filter_mode, urltest_candidate_tags, names,
"or",
exclude_protocols,
exclude_transports,
exclude_securities
exclude_securities,
exclude_additional_matches
);
if (filter_mode == "include")
@@ -1179,6 +1193,59 @@ function priority_level_filtered_outbounds(group_id, level_id, urltest_candidate
);
}
function dashboard_country_metadata(section, state) {
let metadata = object_or_empty(object_or_empty(state.outboundMetadata).countries);
if (connections.dashboard_detect_server_country(section) == "flag_emoji")
return runtime_urltest.countries_from_flag_names(object_or_empty(object_or_empty(state.outboundMetadata).names));
return metadata;
}
function selected_group_outbounds(group_names, group_outbounds) {
group_outbounds = object_or_empty(group_outbounds);
let result = [];
for (let group_name in array_or_empty(group_names))
for (let tag_name in array_or_empty(group_outbounds[group_name]))
push(result, tag_name);
return unique_string_array(result);
}
function remember_dashboard_group_outbounds(group_outbounds, group_name, outbounds) {
group_name = as_string(group_name);
if (group_name == "")
return;
let combined = array_or_empty(group_outbounds[group_name]);
for (let tag_name in array_or_empty(outbounds))
push(combined, tag_name);
group_outbounds[group_name] = unique_string_array(combined);
}
function dashboard_filtered_outbounds(section, selector_tags, state, group_outbounds) {
return filter_candidate_outbounds(
connections.dashboard_filter_mode(section),
selector_tags,
object_or_empty(object_or_empty(state.outboundMetadata).names),
dashboard_country_metadata(section, state),
object_or_empty(state.outboundMetadata),
connections.dashboard_include_outbounds(section),
connections.dashboard_include_regex(section),
connections.dashboard_include_countries(section),
connections.dashboard_include_proxy_parameters(section),
connections.dashboard_include_protocols(section),
connections.dashboard_include_transports(section),
connections.dashboard_include_securities(section),
connections.dashboard_exclude_outbounds(section),
connections.dashboard_exclude_regex(section),
connections.dashboard_exclude_countries(section),
connections.dashboard_exclude_proxy_parameters(section),
connections.dashboard_exclude_protocols(section),
connections.dashboard_exclude_transports(section),
connections.dashboard_exclude_securities(section),
selected_group_outbounds(connections.dashboard_include_groups(section), group_outbounds),
selected_group_outbounds(connections.dashboard_exclude_groups(section), group_outbounds)
);
}
function priority_levels_with_outbounds(group_id, urltest_candidate_tags, state) {
let result = [];
let assigned = {};
@@ -1303,7 +1370,6 @@ function add_priority_group_outbound(config, section, group_id, urltest_candidat
fastest_check_interval: connections.priority_group_fastest_check_interval(section, group_id),
interrupt_exist_connections: connections.priority_group_interrupt_exist_connections(section, group_id),
pin_dashboard: connections.priority_group_pin_dashboard(section, group_id),
hide_added_outbounds: connections.priority_group_hide_added_outbounds(section, group_id),
outbounds,
levels
});
@@ -1328,40 +1394,37 @@ function add_proxy_selector(config, section, selector_tags, urltest_candidate_ta
let selector_default = selector_tags[0];
let urltest_tags = [];
let priority_tags = [];
let hidden_selector_tags = {};
let group_outbounds = {};
for (let urltest_id in connections.urltests(section)) {
let urltest = add_urltest_outbound(config, section, urltest_id, urltest_candidate_tags, state);
remember_dashboard_group_outbounds(
group_outbounds,
connections.urltest_display_name(section, urltest_id),
urltest.outbounds
);
if (urltest.tag == "")
continue;
push(urltest_tags, urltest.tag);
if (connections.urltest_hide_added_outbounds(section, urltest_id)) {
for (let tag in array_or_empty(urltest.outbounds))
hidden_selector_tags[tag] = true;
}
}
for (let group_id in connections.priority_groups(section)) {
let priority = add_priority_group_outbound(config, section, group_id, urltest_candidate_tags, state);
remember_dashboard_group_outbounds(
group_outbounds,
connections.priority_group_display_name(section, group_id),
priority.outbounds
);
if (priority.tag == "")
continue;
push(priority_tags, priority.tag);
if (connections.priority_group_hide_added_outbounds(section, group_id)) {
for (let tag in array_or_empty(priority.outbounds))
hidden_selector_tags[tag] = true;
}
}
selector_outbounds = dashboard_filtered_outbounds(section, selector_tags, state, group_outbounds);
selector_default = selector_outbounds[0];
if (length(urltest_tags) > 0 || length(priority_tags) > 0) {
selector_outbounds = [];
for (let tag in selector_tags) {
if (!hidden_selector_tags[tag])
push(selector_outbounds, tag);
}
for (let tag in urltest_tags)
push(selector_outbounds, tag);
for (let tag in priority_tags)
@@ -1369,6 +1432,9 @@ function add_proxy_selector(config, section, selector_tags, urltest_candidate_ta
selector_default = length(urltest_tags) > 0 ? urltest_tags[0] : priority_tags[0];
}
if (length(selector_outbounds) == 0)
runtime_generate_unsupported("dashboard server filtering produced no usable outbounds");
push(config.outbounds, {
type: "selector",
tag: selector_tag,
@@ -3309,7 +3309,6 @@ function hydrateConfigSections(configSections) {
idle_timeout: item.idle_timeout,
interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
urltest_filter_mode: item.filter_mode,
detect_server_country: item.detect_server_country,
urltest_include_countries: item.include_countries,
@@ -3356,7 +3355,6 @@ function hydrateConfigSections(configSections) {
fastest_check_interval: item.fastest_check_interval,
interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
levels
};
});
@@ -3572,11 +3570,6 @@ function getUrlTestConfigs(section) {
displayName: getUrlTestDisplayName(section, id, settings),
settings,
pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true),
hideAddedOutbounds: itemSettingBoolean(
settings,
"hide_added_outbounds",
false
),
showDetectedCountries: filteringEnabled && itemSettingString(settings, "detect_server_country", "flag_emoji") === "country_is"
};
});
@@ -3635,11 +3628,6 @@ function getPriorityConfigs(section) {
displayName: itemSettingString(settings, "name", id),
settings,
pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true),
hideAddedOutbounds: itemSettingBoolean(
settings,
"hide_added_outbounds",
false
),
healthUrl: itemSettingString(
settings,
"health_url",
@@ -3884,23 +3872,6 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
const selectorCodes = selector?.value?.all ?? [];
const urlTestCodes = urlTestConfigs.map((config) => config.code);
const priorityCodes = priorityConfigs.map((config) => config.code);
const urlTestCodeSet = new Set(urlTestCodes);
const priorityCodeSet = new Set(priorityCodes);
const hideAddedCodeSet = /* @__PURE__ */ new Set();
urlTestEntries.forEach(({ config, entry }) => {
if (!config.hideAddedOutbounds) {
return;
}
const childCodes = urltestGroups[config.code]?.outbounds?.length ? urltestGroups[config.code].outbounds || [] : entry?.value.all || [];
childCodes.forEach((code) => hideAddedCodeSet.add(code));
});
priorityEntries.forEach(({ config, entry }) => {
if (!config.hideAddedOutbounds) {
return;
}
const childCodes = priorityGroups[config.code]?.outbounds?.length ? priorityGroups[config.code].outbounds || [] : entry?.value.all || [];
childCodes.forEach((code) => hideAddedCodeSet.add(code));
});
const showDetectedCountries = urlTestConfigs.some((config) => config.showDetectedCountries) || priorityConfigs.some((config) => config.showDetectedCountries);
const builtInUrltestCode = urlTestCodes[0] || "";
const fallbackCodes = uniqueCodes([
@@ -3913,12 +3884,7 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
...selectorCodes.length ? selectorCodes : fallbackCodes,
...urlTestCodes,
...priorityCodes
]).filter((code) => {
if (!hideAddedCodeSet.has(code)) {
return true;
}
return urlTestCodeSet.has(code) || priorityCodeSet.has(code) || isUrlTestProxyEntry(proxyByCode.get(code));
});
]);
const outbounds = uniqueCodes(groupCodes).flatMap((code) => {
const item = proxyByCode.get(code);
const urlTestConfig = urlTestConfigByCode.get(code);
@@ -431,6 +431,8 @@ let actionProvidersAvailabilityLoader = null;
const outboundNameChoicesCache = new Map();
const outboundNameChoicesInflight = new Map();
const outboundNameSourceOptions = new Map();
const sectionGroupSourceOptions = new Map();
const dashboardFilterChoiceRefreshers = new Map();
const SECTION_CACHE_DIR = "/var/run/forkop/section-cache";
const COUNTRY_CODES =
"AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW XK".split(
@@ -1323,7 +1325,14 @@ const SettingsDynamicList = form.DynamicList.extend({
},
});
return widget.render();
const node = widget.render();
if (typeof this.onListChange === "function") {
node.addEventListener("cbi-dynlist-change", () => {
this.onListChange(section_id);
});
}
return node;
},
parse(section_id) {
@@ -1415,6 +1424,78 @@ const ButtonAddSettingsDynamicList = SettingsDynamicList.extend({
buttonAdd: true,
});
function configureLiveDynamicListChoices(option, getChoices) {
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]),
);
// form.DynamicList passes null when there are no choices, which permanently
// renders a plain input. An empty object keeps the stock combobox alive so
// later live choices can be added without reopening the modal.
const widget = new ui.DynamicList(values, labels, {
id: this.cbid(section_id),
sort: this.keylist,
allowduplicates: this.allowduplicates,
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,
);
return true;
};
const refreshBeforeOpening = (event) => {
if (event.target && event.target.closest(".add-item")) {
refreshChoices();
}
};
node.addEventListener("mousedown", refreshBeforeOpening, true);
node.addEventListener("focusin", refreshBeforeOpening, true);
if (!dashboardFilterChoiceRefreshers.has(section_id)) {
dashboardFilterChoiceRefreshers.set(section_id, new Set());
}
dashboardFilterChoiceRefreshers.get(section_id).add(refreshChoices);
return node;
};
}
function countryChoices() {
return COUNTRY_CODES.map((code) => ({
value: code,
@@ -1529,6 +1610,94 @@ function currentDraftOutboundNames(section_id) {
return names;
}
function currentSectionGroupValues(section_id, typeName) {
const liveValues = currentLiveDynamicListValues(section_id, typeName);
if (liveValues != null) {
return liveValues;
}
const option = sectionGroupSourceOptions.get(typeName);
if (option && typeof option.formvalue === "function") {
try {
const value = option.formvalue(section_id);
if (value != null) {
return normalizeDynamicListItems(value);
}
} catch (_error) {
// Fall back to the child sections when the widget is not mounted.
}
}
return getChildItemIds(section_id, typeName);
}
function sectionGroupDisplayName(section_id, typeName, value) {
const itemId = `${value || ""}`.trim();
if (isExistingChildItem(section_id, itemId, typeName)) {
return `${uci.get(UCI_PACKAGE, itemId, "name") || itemId}`.trim();
}
const option = sectionGroupSourceOptions.get(typeName);
const pending =
option && option.pendingChildSettings
? option.pendingChildSettings[section_id]
: null;
const settings = pending && pending[itemId];
return `${(settings && settings.name) || itemId}`.trim();
}
function currentSectionGroupChoices(section_id, selectedValues = []) {
const seen = new Set();
const result = [];
const append = (typeName, value) => {
value = `${value || ""}`.trim();
if (!value) {
return;
}
const name = sectionGroupDisplayName(section_id, typeName, value) || value;
if (seen.has(name)) {
return;
}
seen.add(name);
result.push({ value: name, label: name });
};
currentSectionGroupValues(section_id, "urltest").forEach((value) =>
append("urltest", value),
);
currentSectionGroupValues(section_id, "priority_group").forEach((value) =>
append("priority_group", value),
);
normalizeDynamicListItems(selectedValues).forEach((value) => {
value = `${value || ""}`.trim();
if (value && !seen.has(value)) {
seen.add(value);
result.push({ value, label: value });
}
});
return result.sort((left, right) => left.label.localeCompare(right.label));
}
function refreshDashboardFilterChoiceWidgets(section_id) {
const refreshers = dashboardFilterChoiceRefreshers.get(section_id);
if (!refreshers) {
return;
}
refreshers.forEach((refresh) => {
if (refresh() === false) {
refreshers.delete(refresh);
}
});
if (refreshers.size === 0) {
dashboardFilterChoiceRefreshers.delete(section_id);
}
}
function isDownloadThroughTargetSection(section, currentSectionId) {
const sectionName = getUciSectionName(section);
const action = (section && section.action) || "";
@@ -1916,7 +2085,6 @@ function urlTestSettingsKeys() {
"idle_timeout",
"interrupt_exist_connections",
"pin_dashboard",
"hide_added_outbounds",
"filter_mode",
"detect_server_country",
"include_countries",
@@ -1945,7 +2113,6 @@ function defaultUrlTestSettings(name) {
idle_timeout: "30m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
hide_added_outbounds: "0",
filter_mode: "disabled",
detect_server_country: "flag_emoji",
};
@@ -1959,7 +2126,6 @@ function urlTestChildDefaults() {
idle_timeout: "30m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
hide_added_outbounds: "0",
filter_mode: "disabled",
detect_server_country: "flag_emoji",
};
@@ -1977,7 +2143,6 @@ function priorityGroupSettingsKeys() {
"fastest_check_interval",
"interrupt_exist_connections",
"pin_dashboard",
"hide_added_outbounds",
];
}
@@ -1993,7 +2158,6 @@ function defaultPriorityGroupSettings() {
fastest_check_interval: "3m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
hide_added_outbounds: "0",
};
}
@@ -2008,7 +2172,6 @@ function priorityGroupChildDefaults() {
fastest_check_interval: "3m",
interrupt_exist_connections: "1",
pin_dashboard: "1",
hide_added_outbounds: "0",
};
}
@@ -2368,15 +2531,6 @@ function addUrlTestItemOptions(itemSection, options = {}) {
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"hide_added_outbounds",
_("Hide added servers"),
_("Hide dashboard servers added to URLTest"),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(
form.ListValue,
"filter_mode",
@@ -2507,6 +2661,9 @@ function addUrlTestItemOptions(itemSection, options = {}) {
});
};
list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, (itemId, values) =>
currentOutboundNameChoices(parentSectionForItem(itemId), values),
);
}
if (validator) {
list.validate = function (_itemId, value) {
@@ -2717,6 +2874,9 @@ function addPriorityLevelItemOptions(itemSection, options = {}) {
});
};
list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, (itemId, values) =>
currentOutboundNameChoices(parentSectionForItem(itemId), values),
);
}
if (validator) {
list.validate = function (_itemId, value) {
@@ -2857,15 +3017,6 @@ function addPriorityGroupItemOptions(itemSection, options = {}) {
o.default = "1";
o.rmempty = false;
o = itemSection.option(
form.Flag,
"hide_added_outbounds",
_("Hide added servers"),
_("Hide dashboard servers added to Priority"),
);
o.default = "0";
o.rmempty = false;
o = itemSection.option(
ButtonAddSettingsDynamicList,
"priority_level",
@@ -2937,6 +3088,229 @@ function addPriorityGroupItemOptions(itemSection, options = {}) {
};
}
function addDashboardGroupFilterOption(
optionSection,
key,
label,
description,
modes,
) {
const list = optionSection.option(form.DynamicList, key, label, description);
modes.forEach((mode) => {
list.depends({
action: "connection",
dashboard_filter_mode: mode,
urltest: /.+/,
});
list.depends({
action: "connection",
dashboard_filter_mode: mode,
priority_group: /.+/,
});
});
list.rmempty = true;
list.placeholder = _("-- Select --");
list.load = function (section_id) {
const values = getConfigListValues(section_id, key);
const choices = currentSectionGroupChoices(section_id, values);
refreshOptionChoices(this, choices);
return values;
};
list.validate = function (section_id, value) {
const selected = new Set(
currentSectionGroupChoices(section_id).map((choice) => choice.value),
);
return normalizeDynamicListItems(value).every((item) => selected.has(item))
? true
: _("Select an existing URLTest or Priority group");
};
configureLiveDynamicListChoices(list, currentSectionGroupChoices);
}
function addDashboardServerFilterOptions(section) {
const optionSection = {
option: (optionType, ...args) => {
const option = section.taboption("settings", optionType, ...args);
option.modalonly = true;
return option;
},
};
let o = optionSection.option(
form.ListValue,
"dashboard_filter_mode",
_("Servers on dashboard"),
_("Filter the servers that will be displayed on the dashboard."),
);
urlTestFilterModeChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "disabled";
o.depends("action", "connection");
o = optionSection.option(
form.ListValue,
"dashboard_detect_server_country",
_("Detect server country"),
);
["exclude", "include", "mixed"].forEach((mode) =>
o.depends({ action: "connection", dashboard_filter_mode: mode }),
);
serverCountryDetectionChoices().forEach((choice) =>
o.value(choice.value, choice.label),
);
o.default = "flag_emoji";
const includeProxyParameterOptions = {
prefix: "dashboard_include",
toggleLabel: _("Include by proxy parameters"),
toggleDescription: _(
"Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters.",
),
dependencies: [
{ action: "connection", dashboard_filter_mode: "include" },
{ action: "connection", dashboard_filter_mode: "mixed" },
],
protocolDescription: _(
"Show only servers with one of the selected protocols.",
),
transportDescription: _(
"Show only servers with one of the selected transports.",
),
securityDescription: _(
"Show only servers with one of the selected security types.",
),
};
const excludeProxyParameterOptions = {
prefix: "dashboard_exclude",
toggleLabel: _("Exclude by proxy parameters"),
toggleDescription: _(
"Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters.",
),
dependencies: [
{ action: "connection", dashboard_filter_mode: "exclude" },
{ action: "connection", dashboard_filter_mode: "mixed" },
],
protocolDescription: _("Hide servers with one of the selected protocols."),
transportDescription: _(
"Hide servers with one of the selected transports.",
),
securityDescription: _(
"Hide servers with one of the selected security types.",
),
};
[
[
"dashboard_include_countries",
_("Include countries"),
_("Show servers only from the specified countries."),
countryChoices(),
validateCountryCode,
["include", "mixed"],
],
[
"dashboard_include_outbounds",
_("Include servers"),
_("Show only selected servers."),
null,
null,
["include", "mixed"],
],
[
"dashboard_include_regex",
_("Include by regular expression"),
_("Show servers whose names match the expression."),
null,
validateRegex,
["include", "mixed"],
],
[
"dashboard_exclude_countries",
_("Exclude countries"),
_("Hide servers from the specified countries."),
countryChoices(),
validateCountryCode,
["exclude", "mixed"],
],
[
"dashboard_exclude_outbounds",
_("Exclude servers"),
_("Hide selected servers."),
null,
null,
["exclude", "mixed"],
],
[
"dashboard_exclude_regex",
_("Exclude by regular expression"),
_("Hide servers whose names match the expression."),
null,
validateRegex,
["exclude", "mixed"],
],
].forEach(([key, label, description, choices, validator, modes]) => {
const list = optionSection.option(
form.DynamicList,
key,
label,
description,
);
modes.forEach((mode) =>
list.depends({ action: "connection", dashboard_filter_mode: mode }),
);
list.rmempty = true;
if (choices) {
choices.forEach((choice) => list.value(choice.value, choice.label));
list.placeholder = _("-- Select --");
}
if (key.endsWith("_outbounds")) {
list.load = function (section_id) {
const values = getConfigListValues(section_id, key);
loadOutboundNameChoices(section_id).then(() => {
refreshDashboardFilterChoiceWidgets(section_id);
});
return values;
};
list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, currentOutboundNameChoices);
}
if (validator) {
list.validate = function (_section_id, value) {
return validator(null, value);
};
}
if (key === "dashboard_include_regex") {
addDashboardGroupFilterOption(
optionSection,
"dashboard_include_groups",
_("Add URLTest / Priority group"),
_(
"Show servers from selected groups without repeating their filtering criteria.",
),
["include", "mixed"],
);
addProxyParameterFilterOptions(
optionSection,
includeProxyParameterOptions,
);
} else if (key === "dashboard_exclude_regex") {
addDashboardGroupFilterOption(
optionSection,
"dashboard_exclude_groups",
_("Exclude URLTest / Priority group"),
_(
"Hide servers from selected groups without repeating their filtering criteria.",
),
["exclude", "mixed"],
);
addProxyParameterFilterOptions(
optionSection,
excludeProxyParameterOptions,
);
}
});
}
function settingValueEquals(left, right) {
const normalize = (value) => {
if (Array.isArray(value)) {
@@ -6855,6 +7229,9 @@ function createSectionContent(section) {
const validation = main.validateProxyUrl(value);
return validation.valid ? true : validation.message;
};
o.onchange = function (_event, section_id) {
refreshDashboardFilterChoiceWidgets(section_id);
};
outboundNameSourceOptions.set("selector_proxy_links", o);
o = section.taboption(
@@ -6943,6 +7320,7 @@ function createSectionContent(section) {
delete this.pendingChildSettings[section_id];
}
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
outboundNameSourceOptions.set("interfaces", o);
o = section.taboption(
@@ -6969,6 +7347,7 @@ function createSectionContent(section) {
const validation = main.validateOutboundJson(value);
return validation.valid ? true : validation.message;
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
outboundNameSourceOptions.set("outbound_jsons", o);
o = section.taboption(
@@ -7027,6 +7406,8 @@ function createSectionContent(section) {
this.inputValueForItem(section_id, itemId),
);
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
sectionGroupSourceOptions.set("urltest", o);
o = section.taboption(
"settings",
@@ -7064,6 +7445,8 @@ function createSectionContent(section) {
this.inputValueForItem(section_id, itemId),
);
};
o.onListChange = refreshDashboardFilterChoiceWidgets;
sectionGroupSourceOptions.set("priority_group", o);
o = section.taboption(
"settings",
@@ -7444,6 +7827,8 @@ function createSectionContent(section) {
),
);
ruleSetOption.modalonly = true;
// Both widgets map to rule_set, so neither inactive view may erase shared storage.
ruleSetOption.retain = true;
dependsOnRoutingAction(ruleSetOption);
ruleSetOption.renderItemSettingsModal = showRuleSetSettingsModal;
ruleSetOption.load = function (section_id) {
@@ -7472,6 +7857,7 @@ function createSectionContent(section) {
);
dnsRuleSetOption.depends("action", "dns");
dnsRuleSetOption.modalonly = true;
dnsRuleSetOption.retain = true;
dnsRuleSetOption.load = function (section_id) {
return getCustomRulesetReferences(section_id);
};
@@ -7527,6 +7913,8 @@ function createSectionContent(section) {
dynamicValidate: validatePortCondition,
});
dependsOnRoutingAction(portsOption);
addDashboardServerFilterOptions(section);
}
function loadSectionTableOptions(sectionRef) {
+65 -11
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: FORKOP\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-13 03:22+0300\n"
"PO-Revision-Date: 2026-07-13 03:22+0300\n"
"POT-Creation-Date: 2026-07-13 00:06+0300\n"
"PO-Revision-Date: 2026-07-13 00:06+0300\n"
"Last-Translator: ushan0v\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -77,6 +77,9 @@ msgstr "Добавьте URL или локальные пути к списка
msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported."
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила."
msgid "Add URLTest / Priority group"
msgstr "Добавить URLTest / Приоритет группу"
msgid "Additional marking rules found"
msgstr "Найдены дополнительные правила маркировки"
@@ -86,6 +89,12 @@ msgstr "Дополнительно исключать серверы по про
msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно отбирать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам."
msgid "Additionally hide servers by protocol, transport, and security. Hide servers matching the specified parameters."
msgstr "Дополнительно скрывать серверы по протоколу, транспорту и защите. Скрывать серверы, совпавшие по указанным параметрам."
msgid "Additionally show servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно показывать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам."
msgid "Address"
msgstr "Адрес"
@@ -647,6 +656,9 @@ msgstr "Исключить из этого уровня серверы с одн
msgid "Exclude servers with one of the selected transports from this level."
msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов."
msgid "Exclude URLTest / Priority group"
msgstr "Исключить URLTest / Приоритет группу"
msgid "Expecting: %s"
msgstr "Ожидается: %s"
@@ -698,6 +710,9 @@ msgstr "Интервал поиска более быстрого сервера
msgid "Fastest"
msgstr "Самый быстрый"
msgid "Filter the servers that will be displayed on the dashboard."
msgstr "Отфильтровать серверы, которые будут отображаться в дашборде."
msgid "Firewall WAN port"
msgstr "Порт в firewall для WAN"
@@ -794,24 +809,36 @@ msgstr "gRPC-сервис"
msgid "Handshake timeout"
msgstr "Таймаут рукопожатия"
msgid "Hide added servers"
msgstr "Скрывать добавленные серверы"
msgid "Hide cascade connection nodes"
msgstr "Скрывать узлы каскадных подключений"
msgid "Hide dashboard servers added to Priority"
msgstr "Скрывать серверы, добавленные в Priority, из дашборда"
msgid "Hide dashboard servers added to URLTest"
msgstr "Скрывать серверы из дашборда, которые добавлены в URLTest"
msgid "Hide individual nodes that are already included in imported subscription URLTest groups"
msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки"
msgid "Hide intermediate nodes used as detours by other subscription nodes"
msgstr "Скрывать промежуточные узлы, которые используются как detour для других узлов подписки"
msgid "Hide selected servers."
msgstr "Скрывать выбранные серверы."
msgid "Hide servers from selected groups without repeating their filtering criteria."
msgstr "Скрывать серверы из выбранных групп без повторения их параметров фильтрации."
msgid "Hide servers from the specified countries."
msgstr "Скрывать серверы из указанных стран."
msgid "Hide servers whose names match the expression."
msgstr "Скрывать серверы, имена которых совпадают с выражением."
msgid "Hide servers with one of the selected protocols."
msgstr "Скрывать серверы с одним из выбранных протоколов."
msgid "Hide servers with one of the selected security types."
msgstr "Скрывать серверы с одним из выбранных типов защиты."
msgid "Hide servers with one of the selected transports."
msgstr "Скрывать серверы с одним из выбранных транспортов."
msgid "Hide URLTest group nodes"
msgstr "Скрывать узлы URLTest групп"
@@ -1646,6 +1673,9 @@ msgstr "Выберите транзитную секцию"
msgid "Select an existing network interface"
msgstr "Выберите существующий сетевой интерфейс"
msgid "Select an existing URLTest or Priority group"
msgstr "Выберите существующую URLTest или Приоритет группу"
msgid "Select an intermediate section"
msgstr "Выберите промежуточную секцию"
@@ -1721,6 +1751,9 @@ msgstr "server_port должен быть числом от 1 до 65535"
msgid "Servers"
msgstr "Серверы"
msgid "Servers on dashboard"
msgstr "Серверы в дашборде"
msgid "Service action failed"
msgstr "Действие сервиса не выполнено"
@@ -1736,6 +1769,27 @@ msgstr "Настройки"
msgid "Show metadata on dashboard"
msgstr "Отображать метаданные в дашборде"
msgid "Show only selected servers."
msgstr "Показывать только выбранные серверы."
msgid "Show only servers with one of the selected protocols."
msgstr "Показывать только серверы с одним из выбранных протоколов."
msgid "Show only servers with one of the selected security types."
msgstr "Показывать только серверы с одним из выбранных типов защиты."
msgid "Show only servers with one of the selected transports."
msgstr "Показывать только серверы с одним из выбранных транспортов."
msgid "Show servers from selected groups without repeating their filtering criteria."
msgstr "Показывать серверы из выбранных групп без повторения их параметров фильтрации."
msgid "Show servers only from the specified countries."
msgstr "Показывать серверы только из указанных стран."
msgid "Show servers whose names match the expression."
msgstr "Показывать серверы, имена которых совпадают с выражением."
msgid "Show sing-box config"
msgstr "Показать sing-box конфигурацию"
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FORKOP_LIB="$ROOT_DIR/forkop/files/usr/lib"
GENERATOR_UC="$FORKOP_LIB/singbox/generator.uc"
VALIDATOR_UC="$FORKOP_LIB/config/validator.uc"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
generate_config() {
local fixture="$1"
local output="$2"
mkdir -p "${output}.section-cache"
ucode -L "$FORKOP_LIB" "$GENERATOR_UC" generate-config-fixture \
"$fixture" "$output" "127.0.0.1"
}
validate_fixture() {
local fixture="$1"
FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR_UC" \
validate-runtime-fixture "$fixture" "{}"
}
cat >"$WORK_DIR/mixed.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"dns_server": [ "77.88.8.8" ],
"bootstrap_dns_server": [ "77.88.8.8" ]
},
"section": [
{
".name": "proxy",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [
"{\"type\":\"vless\",\"tag\":\"Alpha\",\"server\":\"alpha.example\",\"server_port\":443,\"uuid\":\"00000000-0000-4000-8000-000000000001\",\"tls\":{\"enabled\":true}}",
"{\"type\":\"vless\",\"tag\":\"Beta\",\"server\":\"beta.example\",\"server_port\":443,\"uuid\":\"00000000-0000-4000-8000-000000000002\",\"transport\":{\"type\":\"ws\"},\"tls\":{\"enabled\":true,\"reality\":{\"enabled\":true}}}",
"{\"type\":\"vmess\",\"tag\":\"Gamma\",\"server\":\"gamma.example\",\"server_port\":443,\"uuid\":\"00000000-0000-4000-8000-000000000003\",\"transport\":{\"type\":\"grpc\"}}",
"{\"type\":\"trojan\",\"tag\":\"Delta\",\"server\":\"delta.example\",\"server_port\":443,\"password\":\"secret\",\"transport\":{\"type\":\"xhttp\"},\"tls\":{\"enabled\":true}}",
"{\"type\":\"http\",\"tag\":\"Echo\",\"server\":\"echo.example\",\"server_port\":8080}"
],
"dashboard_filter_mode": "mixed",
"dashboard_include_proxy_parameters": "1",
"dashboard_include_protocols": [ "http" ],
"dashboard_include_groups": [ "Shared group" ],
"dashboard_exclude_groups": [ "Blocked group" ]
}
],
"urltest": [
{
".name": "ut_main",
".type": "urltest",
"section": "proxy",
"name": "Shared group",
"filter_mode": "include",
"include_outbounds": [ "Alpha", "Beta" ]
}
],
"priority_group": [
{
".name": "pg_shared",
".type": "priority_group",
"section": "proxy",
"name": "Shared group"
},
{
".name": "pg_main",
".type": "priority_group",
"section": "proxy",
"name": "Blocked group"
}
],
"priority_level": [
{
".name": "pl_shared",
".type": "priority_level",
"group": "pg_shared",
"name": "Shared members",
"order": "0",
"filter_mode": "include",
"include_outbounds": [ "Gamma" ]
},
{
".name": "pl_main",
".type": "priority_level",
"group": "pg_main",
"name": "Blocked members",
"order": "0",
"filter_mode": "include",
"include_outbounds": [ "Beta", "Gamma" ]
}
]
}
JSON
node - "$WORK_DIR/mixed.json" "$WORK_DIR" <<'JS'
const fs = require('fs');
const path = require('path');
const source = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const outputDir = process.argv[3];
for (const mode of ['disabled', 'include', 'exclude']) {
const fixture = structuredClone(source);
const section = fixture.section[0];
section.dashboard_filter_mode = mode;
if (mode === 'disabled') {
delete section.dashboard_include_proxy_parameters;
delete section.dashboard_include_protocols;
delete section.dashboard_include_groups;
delete section.dashboard_exclude_groups;
} else if (mode === 'include') {
delete section.dashboard_include_proxy_parameters;
delete section.dashboard_include_protocols;
delete section.dashboard_exclude_groups;
} else {
delete section.dashboard_include_proxy_parameters;
delete section.dashboard_include_protocols;
delete section.dashboard_include_groups;
}
fs.writeFileSync(path.join(outputDir, `${mode}.json`), JSON.stringify(fixture));
}
const invalid = structuredClone(source);
invalid.section[0].dashboard_include_groups = ['missing_group'];
fs.writeFileSync(path.join(outputDir, 'invalid-group.json'), JSON.stringify(invalid));
const invalidMode = structuredClone(source);
invalidMode.section[0].dashboard_filter_mode = 'unknown';
fs.writeFileSync(path.join(outputDir, 'invalid-mode.json'), JSON.stringify(invalidMode));
JS
for mode in disabled include exclude mixed; do
validate_fixture "$WORK_DIR/$mode.json" >/dev/null || fail "$mode dashboard filter validation"
generate_config "$WORK_DIR/$mode.json" "$WORK_DIR/$mode-config.json"
done
if validate_fixture "$WORK_DIR/invalid-group.json" >/dev/null 2>&1; then
fail "unknown dashboard group should be rejected"
fi
if validate_fixture "$WORK_DIR/invalid-mode.json" >/dev/null 2>&1; then
fail "unknown dashboard filter mode should be rejected"
fi
ucode -e '
let fs = require("fs");
function fail(message) { die(message + "\n"); }
function outbound_by_tag(config, tag) {
for (let outbound in config.outbounds || [])
if (outbound && outbound.tag == tag)
return outbound;
return null;
}
function assert_array(value, expected, label) {
value = value || [];
if (length(value) != length(expected))
fail(label + " length mismatch: " + sprintf("%J", value));
for (let i = 0; i < length(expected); i++)
if (value[i] != expected[i])
fail(label + " mismatch: " + sprintf("%J", value));
}
let disabled = outbound_by_tag(json(fs.readfile(ARGV[0])), "proxy-out");
let include = outbound_by_tag(json(fs.readfile(ARGV[1])), "proxy-out");
let exclude = outbound_by_tag(json(fs.readfile(ARGV[2])), "proxy-out");
let mixed = outbound_by_tag(json(fs.readfile(ARGV[3])), "proxy-out");
assert_array(disabled.outbounds, [ "Alpha", "Beta", "Gamma", "Delta", "Echo", "proxy-urltest-ut_main-out", "proxy-priority-pg_shared-out", "proxy-priority-pg_main-out" ], "all dashboard servers");
assert_array(include.outbounds, [ "Alpha", "Beta", "Gamma", "proxy-urltest-ut_main-out", "proxy-priority-pg_shared-out", "proxy-priority-pg_main-out" ], "same-name URLTest and Priority groups");
assert_array(exclude.outbounds, [ "Alpha", "Delta", "Echo", "proxy-urltest-ut_main-out", "proxy-priority-pg_shared-out", "proxy-priority-pg_main-out" ], "excluded Priority group");
assert_array(mixed.outbounds, [ "Alpha", "Echo", "proxy-urltest-ut_main-out", "proxy-priority-pg_shared-out", "proxy-priority-pg_main-out" ], "mixed group and proxy filters");
if (mixed.default != "proxy-urltest-ut_main-out")
fail("section selector should still default to the first URLTest group");
' "$WORK_DIR/disabled-config.json" "$WORK_DIR/include-config.json" \
"$WORK_DIR/exclude-config.json" "$WORK_DIR/mixed-config.json" ||
fail "dashboard server filter regression"
printf 'dashboard server filter checks passed\n'
+5 -3
View File
@@ -52,14 +52,16 @@ const ruleSetOption = requiredIndex("const ruleSetOption = section.taboption(");
const dnsRuleSetOption = requiredIndex("const dnsRuleSetOption = section.taboption(", ruleSetOption);
const domainLists = requiredIndex("const domainIpListsOption", dnsRuleSetOption);
if (!source.slice(ruleSetOption, dnsRuleSetOption).includes("SettingsDynamicList") ||
!source.slice(ruleSetOption, dnsRuleSetOption).includes("dependsOnRoutingAction(ruleSetOption)")) {
fail("routing rule sets must keep their settings DynamicList");
!source.slice(ruleSetOption, dnsRuleSetOption).includes("dependsOnRoutingAction(ruleSetOption)") ||
!source.slice(ruleSetOption, dnsRuleSetOption).includes("ruleSetOption.retain = true")) {
fail("routing rule sets must keep their retained settings DynamicList");
}
if (!source.slice(dnsRuleSetOption, domainLists).includes("form.DynamicList") ||
!source.slice(dnsRuleSetOption, domainLists).includes('"_dns_rule_set"') ||
!source.slice(dnsRuleSetOption, domainLists).includes('dnsRuleSetOption.depends("action", "dns")') ||
!source.slice(dnsRuleSetOption, domainLists).includes("dnsRuleSetOption.retain = true") ||
!source.slice(dnsRuleSetOption, domainLists).includes("writeDnsRulesetReferences")) {
fail("DNS rule sets must use a dedicated classic DynamicList");
fail("DNS rule sets must use a retained classic DynamicList");
}
NODE
+78
View File
@@ -13,6 +13,9 @@ wrapper_styles="$(sed -n '/^\.fkp-button-add-dynlist > \.add-item {$/,/^}$/p' "$
button_styles="$(sed -n '/^\.fkp-button-add-dynlist > \.add-item > \.cbi-button-add {$/,/^}$/p' "$SECTION_JS")"
urltest_options="$(sed -n '/^function addUrlTestItemOptions(/,/^function priorityLevelSettingsForValidation(/p' "$SECTION_JS")"
priority_options="$(sed -n '/^function addPriorityLevelItemOptions(/,/^function addPriorityGroupItemOptions(/p' "$SECTION_JS")"
dashboard_options="$(sed -n '/^function addDashboardServerFilterOptions(/,/^function settingValueEquals(/p' "$SECTION_JS")"
create_section="$(sed -n '/^function createSectionContent(/,/^function loadSectionTableOptions(/p' "$SECTION_JS")"
live_choices="$(sed -n '/^function configureLiveDynamicListChoices(/,/^function countryChoices(/p' "$SECTION_JS")"
grep -Fq 'display: flex;' <<<"$wrapper_styles" ||
fail "button-only DynamicList add rows must use a content-sized flex wrapper"
@@ -36,10 +39,14 @@ grep -Fq 'if (key === "include_regex") {' <<<"$urltest_options" ||
fail "URLTest include proxy parameters must follow the include regex option"
grep -Fq 'else if (key === "exclude_regex") {' <<<"$urltest_options" ||
fail "URLTest exclude proxy parameters must follow the exclude regex option"
grep -Fq 'configureLiveDynamicListChoices(list, (itemId, values) =>' <<<"$urltest_options" ||
fail "URLTest server filters must stay comboboxes when no choices exist"
grep -Fq 'if (key === "regex") {' <<<"$priority_options" ||
fail "Priority include proxy parameters must follow the include regex option"
grep -Fq 'else if (key === "exclude_regex") {' <<<"$priority_options" ||
fail "Priority exclude proxy parameters must follow the exclude regex option"
grep -Fq 'configureLiveDynamicListChoices(list, (itemId, values) =>' <<<"$priority_options" ||
fail "Priority server filters must stay comboboxes when no choices exist"
grep -Fq '["direct", "Direct"]' "$SECTION_JS" ||
fail "proxy protocol choices must keep Direct untranslated"
grep -Fq '["none", "None"]' "$SECTION_JS" ||
@@ -49,5 +56,76 @@ grep -Fq '_("Security")' "$SECTION_JS" ||
if grep -Fq 'Connection security' "$SECTION_JS"; then
fail "proxy parameter filter copy must not mention connection security"
fi
if grep -Fq 'hide_added_outbounds' "$SECTION_JS"; then
fail "per-group dashboard hiding must be removed from LuCI"
fi
grep -Fq '"dashboard_filter_mode"' <<<"$dashboard_options" ||
fail "section settings must expose the dashboard server filter"
grep -Fq '_("Servers on dashboard")' <<<"$dashboard_options" ||
fail "dashboard server filter must use the requested section-level label"
grep -Fq '_("Filter the servers that will be displayed on the dashboard.")' <<<"$dashboard_options" ||
fail "dashboard server filter must use the requested description"
grep -Fq 'option.modalonly = true;' <<<"$dashboard_options" ||
fail "dashboard server filter options must stay inside the section modal"
grep -Fq '"dashboard_include_groups"' <<<"$dashboard_options" ||
fail "dashboard include filters must support URLTest and Priority groups"
grep -Fq '"dashboard_exclude_groups"' <<<"$dashboard_options" ||
fail "dashboard exclude filters must support URLTest and Priority groups"
include_group_line="$(grep -n '"dashboard_include_groups"' <<<"$dashboard_options" | head -n1 | cut -d: -f1)"
include_proxy_line="$(grep -n 'includeProxyParameterOptions' <<<"$dashboard_options" | tail -n1 | cut -d: -f1)"
[[ "$include_group_line" -lt "$include_proxy_line" ]] ||
fail "dashboard include group selector must precede the proxy-parameter toggle"
exclude_group_line="$(grep -n '"dashboard_exclude_groups"' <<<"$dashboard_options" | head -n1 | cut -d: -f1)"
exclude_proxy_line="$(grep -n 'excludeProxyParameterOptions' <<<"$dashboard_options" | tail -n1 | cut -d: -f1)"
[[ "$exclude_group_line" -lt "$exclude_proxy_line" ]] ||
fail "dashboard exclude group selector must precede the proxy-parameter toggle"
grep -Fq 'const liveValues = currentLiveDynamicListValues(section_id, typeName);' "$SECTION_JS" ||
fail "dashboard group choices must read live uncommitted DynamicList values"
grep -Fq 'o.onListChange = refreshDashboardFilterChoiceWidgets;' "$SECTION_JS" ||
fail "new servers and groups must refresh dashboard selectors immediately"
grep -Fq 'form.DynamicList,' <<<"$(sed -n '/^function addDashboardGroupFilterOption(/,/^function addDashboardServerFilterOptions(/p' "$SECTION_JS")" ||
fail "dashboard group filters must use the standard DynamicList"
if grep -Fq 'SelectOnlyUIDynamicList' "$SECTION_JS"; then
fail "dashboard group filters must not replace the standard DynamicList widget"
fi
grep -Fq 'configureLiveDynamicListChoices(list, currentOutboundNameChoices);' <<<"$dashboard_options" ||
fail "dashboard server selectors must refresh live uncommitted outbound choices"
grep -Fq 'configureLiveDynamicListChoices(list, currentSectionGroupChoices);' "$SECTION_JS" ||
fail "dashboard group selectors must refresh live uncommitted group choices"
grep -Fq 'const widget = new ui.DynamicList(values, labels, {' <<<"$live_choices" ||
fail "dashboard selectors must always render the stock DynamicList combobox"
grep -Fq 'validate: L.bind(this.validate, this, section_id),' <<<"$live_choices" ||
fail "dashboard selectors must use the LuCI 24.10-compatible validator binding"
if grep -Fq 'this.getValidator(section_id)' <<<"$live_choices"; then
fail "dashboard selectors must not require the newer LuCI getValidator API"
fi
grep -Fq 'const node = widget.render();' <<<"$live_choices" ||
fail "dashboard selectors must refresh their rendered DynamicList instance directly"
if grep -Fq 'form.DynamicList.prototype.renderWidget' <<<"$live_choices"; then
fail "empty dashboard choices must not fall back to a plain DynamicList input"
fi
grep -Fq 'dashboardFilterChoiceRefreshers.get(section_id).add(refreshChoices);' <<<"$live_choices" ||
fail "dashboard selectors must register the rendered modal widget for source changes"
grep -Fq 'node.addEventListener("mousedown", refreshBeforeOpening, true);' <<<"$live_choices" ||
fail "dashboard selectors must refresh choices before opening"
grep -Fq 'if (currentSignature === choiceSignature)' "$SECTION_JS" ||
fail "dashboard selectors must not rebuild unchanged choices while selecting an item"
grep -Fq 'loadOutboundNameChoices(section_id).then(() => {' <<<"$dashboard_options" ||
fail "dashboard server choices must refresh after metadata loads"
if grep -Fq 'return loadOutboundNameChoices(section_id)' <<<"$dashboard_options"; then
fail "dashboard metadata loading must not block the section modal"
fi
grep -Fq 'result.push({ value: name, label: name });' "$SECTION_JS" ||
fail "dashboard group selector values must use display names"
if grep -Fq 'label: `${typeLabel}: ${name}`' "$SECTION_JS"; then
fail "dashboard group labels must not include URLTest/Priority prefixes"
fi
ports_line="$(grep -n 'dependsOnRoutingAction(portsOption);' <<<"$create_section" | cut -d: -f1)"
dashboard_line="$(grep -n 'addDashboardServerFilterOptions(section);' <<<"$create_section" | cut -d: -f1)"
[[ "$dashboard_line" -gt "$ports_line" ]] ||
fail "dashboard server filter must be the last section option block"
if grep -Fq '__forkop_no_group__' "$SECTION_JS"; then
fail "dashboard group selectors must not duplicate the placeholder with a fake choice"
fi
printf 'LuCI DynamicList layout checks passed\n'
+4 -5
View File
@@ -89,8 +89,7 @@ cat >"$WORK_DIR/fixture.json" <<'JSON'
"pick_fastest": "1",
"switch_to_faster_same_priority": "1",
"fastest_check_interval": "3m",
"pin_dashboard": "1",
"hide_added_outbounds": "1"
"pin_dashboard": "1"
},
{
".name": "pg_backup",
@@ -189,8 +188,8 @@ if (main.default != "proxy-2-out")
fail("main priority selector should default to the first upper-level outbound");
if (!selector || selector.default != "proxy-priority-pg_main-out")
fail("section selector should default to the first priority group when URLTest is absent");
if (contains(selector.outbounds, "proxy-2-out") || contains(selector.outbounds, "proxy-3-out"))
fail("priority-added outbounds should be hidden from selector when hide_added_outbounds=1");
if (!contains(selector.outbounds, "proxy-2-out") || !contains(selector.outbounds, "proxy-3-out"))
fail("section selector should keep individual priority members by default");
if (selector.outbounds[length(selector.outbounds || []) - 2] != "proxy-priority-pg_main-out" ||
selector.outbounds[length(selector.outbounds || []) - 1] != "proxy-priority-pg_backup-out")
fail("section selector should include priority groups in order");
@@ -205,7 +204,7 @@ if (cached.check_timeout != "2s" || cached.fastest_check_interval != "3m")
fail("priority group timing metadata was not cached");
if (cached.pick_fastest !== true || cached.switch_to_faster_same_priority !== true)
fail("priority boolean metadata was not cached");
if (cached.interrupt_exist_connections !== true || cached.pin_dashboard !== true || cached.hide_added_outbounds !== true)
if (cached.interrupt_exist_connections !== true || cached.pin_dashboard !== true)
fail("priority dashboard/default metadata was not cached");
assert_array(cached.outbounds, [ "proxy-2-out", "proxy-3-out" ], "cached priority outbounds");
if (length(cached.levels || []) != 2 || cached.levels[0].id != "pl_upper" || cached.levels[1].id != "pl_lower")
+25 -1
View File
@@ -408,6 +408,24 @@ cat >"$WORK_DIR/sing-box-signature.json" <<'JSON'
"enabled": "1",
"action": "proxy",
"selector_proxy_links": [ "vless://one", "vless://two" ],
"dashboard_filter_mode": "mixed",
"dashboard_detect_server_country": "country_is",
"dashboard_include_countries": [ "NL" ],
"dashboard_include_outbounds": [ "node-a" ],
"dashboard_include_regex": [ "^A" ],
"dashboard_include_proxy_parameters": "1",
"dashboard_include_protocols": [ "vless" ],
"dashboard_include_transports": [ "ws" ],
"dashboard_include_securities": [ "reality" ],
"dashboard_include_groups": [ "urltest" ],
"dashboard_exclude_countries": [ "RU" ],
"dashboard_exclude_outbounds": [ "node-b" ],
"dashboard_exclude_regex": [ "backup" ],
"dashboard_exclude_proxy_parameters": "1",
"dashboard_exclude_protocols": [ "http" ],
"dashboard_exclude_transports": [ "tcp" ],
"dashboard_exclude_securities": [ "none" ],
"dashboard_exclude_groups": [ "urltest" ],
"subscription_urls": [ "https://example.com/sub.txt" ],
"subscription_url_settings": "{\"https://example.com/sub.txt\":{\"prefix_nodes\":\"1\",\"node_prefix\":\"Example\"}}",
"urltest_enabled": "1",
@@ -559,9 +577,11 @@ proxy
[rule.proxy1.legacy_outbound_json]
[rule.proxy1.urltests]
[ { "id": "urltest", "display_name": "Fastest", "check_interval": "3m", "tolerance": "75", "testing_url": "https://www.gstatic.com/generate_204", "idle_timeout": "", "interrupt_exist_connections": "1", "pin_dashboard": "1", "hide_added_outbounds": "0", "filter_mode": "exclude", "detect_server_country": "1", "include_countries": [ ], "include_outbounds": [ "node-a" ], "include_regex": [ ], "exclude_countries": [ "RU" ], "exclude_outbounds": [ ], "exclude_regex": [ ] } ]
[ { "id": "urltest", "display_name": "Fastest", "check_interval": "3m", "tolerance": "75", "testing_url": "https://www.gstatic.com/generate_204", "idle_timeout": "", "interrupt_exist_connections": "1", "pin_dashboard": "1", "filter_mode": "exclude", "detect_server_country": "1", "include_countries": [ ], "include_outbounds": [ "node-a" ], "include_regex": [ ], "exclude_countries": [ "RU" ], "exclude_outbounds": [ ], "exclude_regex": [ ] } ]
[rule.proxy1.priority_groups]
[ ]
[rule.proxy1.dashboard_filter]
{ "filter_mode": "mixed", "detect_server_country": "country_is", "include_countries": [ "NL" ], "include_outbounds": [ "node-a" ], "include_regex": [ "^A" ], "include_proxy_parameters": "1", "include_protocols": [ "vless" ], "include_transports": [ "ws" ], "include_securities": [ "reality" ], "include_groups": [ "urltest" ], "exclude_countries": [ "RU" ], "exclude_outbounds": [ "node-b" ], "exclude_regex": [ "backup" ], "exclude_proxy_parameters": "1", "exclude_protocols": [ "http" ], "exclude_transports": [ "tcp" ], "exclude_securities": [ "none" ], "exclude_groups": [ "urltest" ] }
[rule.proxy1.urltest_enabled]
1
[rule.proxy1.detect_server_country]
@@ -646,6 +666,8 @@ outbound
[ ]
[rule.out1.priority_groups]
[ ]
[rule.out1.dashboard_filter]
{ "filter_mode": "disabled", "detect_server_country": "flag_emoji", "include_countries": [ ], "include_outbounds": [ ], "include_regex": [ ], "include_proxy_parameters": "0", "include_protocols": [ ], "include_transports": [ ], "include_securities": [ ], "include_groups": [ ], "exclude_countries": [ ], "exclude_outbounds": [ ], "exclude_regex": [ ], "exclude_proxy_parameters": "0", "exclude_protocols": [ ], "exclude_transports": [ ], "exclude_securities": [ ], "exclude_groups": [ ] }
[rule.out1.urltest_enabled]
0
[rule.out1.detect_server_country]
@@ -752,6 +774,8 @@ wg0
[ ]
[rule.vpn1.priority_groups]
[ ]
[rule.vpn1.dashboard_filter]
{ "filter_mode": "disabled", "detect_server_country": "flag_emoji", "include_countries": [ ], "include_outbounds": [ ], "include_regex": [ ], "include_proxy_parameters": "0", "include_protocols": [ ], "include_transports": [ ], "include_securities": [ ], "include_groups": [ ], "exclude_countries": [ ], "exclude_outbounds": [ ], "exclude_regex": [ ], "exclude_proxy_parameters": "0", "exclude_protocols": [ ], "exclude_transports": [ ], "exclude_securities": [ ], "exclude_groups": [ ] }
[rule.vpn1.urltest_enabled]
0
[rule.vpn1.detect_server_country]
-72
View File
@@ -185,76 +185,4 @@ if (cache.urltestGroups["proxy-urltest-cfg010002-out"].displayName != "Stable UR
die("stable URLTest display name was not cached\n");
' "$multi_output" "$multi_output.section-cache/proxy.json" || fail "multi URLTest settings regression"
cat >"$WORK_DIR/hide-added-fixture.json" <<'JSON'
{
"settings": {
"interface": "lan",
"routing_mode": "proxy"
},
"section": [
{
".name": "proxy",
".type": "section",
"enabled": "1",
"action": "proxy",
"selector_proxy_links": [
"vless://00000000-0000-4000-8000-000000000001@example.com:443?encryption=none&security=tls&sni=example.com#first",
"vless://00000000-0000-4000-8000-000000000002@example.org:443?encryption=none&security=tls&sni=example.org#second"
]
}
],
"urltest": [
{
".name": "cfg020001",
".type": "urltest",
"section": "proxy",
"name": "Fast URLTest",
"check_interval": "30s",
"tolerance": "50",
"testing_url": "https://fast.example/204",
"filter_mode": "include",
"include_regex": [ "first" ],
"hide_added_outbounds": "1"
}
]
}
JSON
hide_added_output="$WORK_DIR/hide-added-config.json"
mkdir -p "$hide_added_output.section-cache"
ucode -L "$FORKOP_LIB" "$FORKOP_LIB/singbox/generator.uc" generate-config-fixture \
"$WORK_DIR/hide-added-fixture.json" "$hide_added_output" "127.0.0.1"
ucode -e '
let fs = require("fs");
let cfg = json(fs.readfile(ARGV[0]));
let urltest = null;
let selector = null;
function contains(values, needle) {
for (let value in values || [])
if (value == needle)
return true;
return false;
}
for (let outbound in cfg.outbounds || []) {
if (outbound && outbound.tag == "proxy-urltest-cfg020001-out")
urltest = outbound;
if (outbound && outbound.tag == "proxy-out")
selector = outbound;
}
if (!urltest || !contains(urltest.outbounds, "proxy-1-out") || contains(urltest.outbounds, "proxy-2-out"))
die("URLTest should contain only the filtered first outbound\n");
if (!selector)
die("selector was not generated\n");
if (contains(selector.outbounds, "proxy-1-out"))
die("URLTest-added outbound should be hidden from selector\n");
if (!contains(selector.outbounds, "proxy-2-out"))
die("non-URLTest outbound should remain in selector\n");
if (!contains(selector.outbounds, "proxy-urltest-cfg020001-out"))
die("URLTest outbound should remain in selector\n");
' "$hide_added_output" || fail "URLTest hide added selector regression"
printf 'URLTest interrupt checks passed\n'