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 "" msgstr ""
"Project-Id-Version: FORKOP\n" "Project-Id-Version: FORKOP\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-13 03:22+0300\n" "POT-Creation-Date: 2026-07-13 00:06+0300\n"
"PO-Revision-Date: 2026-07-13 03:22+0300\n" "PO-Revision-Date: 2026-07-13 00:06+0300\n"
"Last-Translator: ushan0v\n" "Last-Translator: ushan0v\n"
"Language-Team: none\n" "Language-Team: none\n"
"Language: ru\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." msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported."
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила." msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила."
msgid "Add URLTest / Priority group"
msgstr "Добавить URLTest / Приоритет группу"
msgid "Additional marking rules found" msgid "Additional marking rules found"
msgstr "Найдены дополнительные правила маркировки" msgstr "Найдены дополнительные правила маркировки"
@@ -86,6 +89,12 @@ msgstr "Дополнительно исключать серверы по про
msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters." msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно отбирать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам." 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" msgid "Address"
msgstr "Адрес" msgstr "Адрес"
@@ -647,6 +656,9 @@ msgstr "Исключить из этого уровня серверы с одн
msgid "Exclude servers with one of the selected transports from this level." msgid "Exclude servers with one of the selected transports from this level."
msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов." msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов."
msgid "Exclude URLTest / Priority group"
msgstr "Исключить URLTest / Приоритет группу"
msgid "Expecting: %s" msgid "Expecting: %s"
msgstr "Ожидается: %s" msgstr "Ожидается: %s"
@@ -698,6 +710,9 @@ msgstr "Интервал поиска более быстрого сервера
msgid "Fastest" msgid "Fastest"
msgstr "Самый быстрый" msgstr "Самый быстрый"
msgid "Filter the servers that will be displayed on the dashboard."
msgstr "Отфильтровать серверы, которые будут отображаться в дашборде."
msgid "Firewall WAN port" msgid "Firewall WAN port"
msgstr "Порт в firewall для WAN" msgstr "Порт в firewall для WAN"
@@ -794,24 +809,36 @@ msgstr "gRPC-сервис"
msgid "Handshake timeout" msgid "Handshake timeout"
msgstr "Таймаут рукопожатия" msgstr "Таймаут рукопожатия"
msgid "Hide added servers"
msgstr "Скрывать добавленные серверы"
msgid "Hide cascade connection nodes" msgid "Hide cascade connection nodes"
msgstr "Скрывать узлы каскадных подключений" 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" msgid "Hide individual nodes that are already included in imported subscription URLTest groups"
msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки" msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки"
msgid "Hide intermediate nodes used as detours by other subscription nodes" msgid "Hide intermediate nodes used as detours by other subscription nodes"
msgstr "Скрывать промежуточные узлы, которые используются как detour для других узлов подписки" 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" msgid "Hide URLTest group nodes"
msgstr "Скрывать узлы URLTest групп" msgstr "Скрывать узлы URLTest групп"
@@ -1646,6 +1673,9 @@ msgstr "Выберите транзитную секцию"
msgid "Select an existing network interface" msgid "Select an existing network interface"
msgstr "Выберите существующий сетевой интерфейс" msgstr "Выберите существующий сетевой интерфейс"
msgid "Select an existing URLTest or Priority group"
msgstr "Выберите существующую URLTest или Приоритет группу"
msgid "Select an intermediate section" msgid "Select an intermediate section"
msgstr "Выберите промежуточную секцию" msgstr "Выберите промежуточную секцию"
@@ -1721,6 +1751,9 @@ msgstr "server_port должен быть числом от 1 до 65535"
msgid "Servers" msgid "Servers"
msgstr "Серверы" msgstr "Серверы"
msgid "Servers on dashboard"
msgstr "Серверы в дашборде"
msgid "Service action failed" msgid "Service action failed"
msgstr "Действие сервиса не выполнено" msgstr "Действие сервиса не выполнено"
@@ -1736,6 +1769,27 @@ msgstr "Настройки"
msgid "Show metadata on dashboard" msgid "Show metadata on dashboard"
msgstr "Отображать метаданные в дашборде" 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" msgid "Show sing-box config"
msgstr "Показать sing-box конфигурацию" msgstr "Показать sing-box конфигурацию"
@@ -70,7 +70,6 @@ type PriorityCacheGroup = {
fastest_check_interval?: string; fastest_check_interval?: string;
interrupt_exist_connections?: boolean; interrupt_exist_connections?: boolean;
pin_dashboard?: boolean; pin_dashboard?: boolean;
hide_added_outbounds?: boolean;
outbounds?: string[]; outbounds?: string[];
levels?: PriorityCacheLevel[]; levels?: PriorityCacheLevel[];
}; };
@@ -100,7 +99,6 @@ type UrlTestConfig = {
displayName: string; displayName: string;
settings: ItemSettings; settings: ItemSettings;
pinDashboard: boolean; pinDashboard: boolean;
hideAddedOutbounds: boolean;
showDetectedCountries: boolean; showDetectedCountries: boolean;
}; };
@@ -110,7 +108,6 @@ type PriorityConfig = {
displayName: string; displayName: string;
settings: ItemSettings; settings: ItemSettings;
pinDashboard: boolean; pinDashboard: boolean;
hideAddedOutbounds: boolean;
healthUrl: string; healthUrl: string;
activeCheckInterval: string; activeCheckInterval: string;
checkTimeout: string; checkTimeout: string;
@@ -286,7 +283,6 @@ function hydrateConfigSections(configSections: Forkop.ConfigSection[]) {
idle_timeout: item.idle_timeout, idle_timeout: item.idle_timeout,
interrupt_exist_connections: item.interrupt_exist_connections, interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard, pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
urltest_filter_mode: item.filter_mode, urltest_filter_mode: item.filter_mode,
detect_server_country: item.detect_server_country, detect_server_country: item.detect_server_country,
urltest_include_countries: item.include_countries, urltest_include_countries: item.include_countries,
@@ -339,7 +335,6 @@ function hydrateConfigSections(configSections: Forkop.ConfigSection[]) {
fastest_check_interval: item.fastest_check_interval, fastest_check_interval: item.fastest_check_interval,
interrupt_exist_connections: item.interrupt_exist_connections, interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard, pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
levels, levels,
}; };
}); });
@@ -661,11 +656,6 @@ function getUrlTestConfigs(section: Forkop.ConfigSection): UrlTestConfig[] {
displayName: getUrlTestDisplayName(section, id, settings), displayName: getUrlTestDisplayName(section, id, settings),
settings, settings,
pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true), pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true),
hideAddedOutbounds: itemSettingBoolean(
settings,
'hide_added_outbounds',
false,
),
showDetectedCountries: showDetectedCountries:
filteringEnabled && filteringEnabled &&
itemSettingString(settings, 'detect_server_country', 'flag_emoji') === itemSettingString(settings, 'detect_server_country', 'flag_emoji') ===
@@ -748,11 +738,6 @@ function getPriorityConfigs(section: Forkop.ConfigSection): PriorityConfig[] {
displayName: itemSettingString(settings, 'name', id), displayName: itemSettingString(settings, 'name', id),
settings, settings,
pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true), pinDashboard: itemSettingBoolean(settings, 'pin_dashboard', true),
hideAddedOutbounds: itemSettingBoolean(
settings,
'hide_added_outbounds',
false,
),
healthUrl: itemSettingString( healthUrl: itemSettingString(
settings, settings,
'health_url', 'health_url',
@@ -1108,31 +1093,6 @@ function buildProxyGroupOutbounds(
const selectorCodes = selector?.value?.all ?? []; const selectorCodes = selector?.value?.all ?? [];
const urlTestCodes = urlTestConfigs.map((config) => config.code); const urlTestCodes = urlTestConfigs.map((config) => config.code);
const priorityCodes = priorityConfigs.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 = const showDetectedCountries =
urlTestConfigs.some((config) => config.showDetectedCountries) || urlTestConfigs.some((config) => config.showDetectedCountries) ||
priorityConfigs.some((config) => config.showDetectedCountries); priorityConfigs.some((config) => config.showDetectedCountries);
@@ -1147,17 +1107,7 @@ function buildProxyGroupOutbounds(
...(selectorCodes.length ? selectorCodes : fallbackCodes), ...(selectorCodes.length ? selectorCodes : fallbackCodes),
...urlTestCodes, ...urlTestCodes,
...priorityCodes, ...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 outbounds = uniqueCodes(groupCodes).flatMap((code) => {
const item = proxyByCode.get(code); const item = proxyByCode.get(code);
@@ -87,7 +87,6 @@ function priorityGroup(
fastest_check_interval: '3m', fastest_check_interval: '3m',
interrupt_exist_connections: '1', interrupt_exist_connections: '1',
pin_dashboard: '1', pin_dashboard: '1',
hide_added_outbounds: '0',
...options, ...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 () => { it('keeps an empty Priority group visible when sing-box skipped the selector', async () => {
mocks.getConfigSections.mockResolvedValue([ mocks.getConfigSections.mockResolvedValue([
proxySection({ proxySection({
@@ -522,31 +468,6 @@ describe('getDashboardSections', () => {
expect(priority?.priorityInfo?.outbounds).toEqual([]); 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 () => { it('keeps legacy URLTest sections compatible before migration', async () => {
mocks.getConfigSections.mockResolvedValue([ mocks.getConfigSections.mockResolvedValue([
proxySection({ 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 () => { it('keeps non-built-in URLTest groups in selector order when latency sorting is disabled', async () => {
mocks.getConfigSections.mockResolvedValue([proxySection()]); mocks.getConfigSections.mockResolvedValue([proxySection()]);
mocks.getClashApiProxies.mockResolvedValue({ 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 () => { it('does not expose flag-emoji detected countries on dashboard outbounds', async () => {
mocks.getConfigSections.mockResolvedValue([ mocks.getConfigSections.mockResolvedValue([
proxySection({ proxySection({
+18 -1
View File
@@ -267,6 +267,24 @@ export namespace Forkop {
urltest_settings?: string; urltest_settings?: string;
priority_groups?: string[]; priority_groups?: string[];
priority_group_settings?: 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[]; urltest_proxy_links?: string[];
subscription_url?: string; subscription_url?: string;
subscription_user_agent?: string; subscription_user_agent?: string;
@@ -298,7 +316,6 @@ export namespace Forkop {
idle_timeout?: string; idle_timeout?: string;
interrupt_exist_connections?: '0' | '1'; interrupt_exist_connections?: '0' | '1';
pin_dashboard?: '0' | '1'; pin_dashboard?: '0' | '1';
hide_added_outbounds?: '0' | '1';
filter_mode?: 'disabled' | 'exclude' | 'include' | 'mixed'; filter_mode?: 'disabled' | 'exclude' | 'include' | 'mixed';
include_countries?: string[]; include_countries?: string[];
include_outbounds?: string[]; include_outbounds?: string[];
+6 -1
View File
@@ -44,6 +44,12 @@ config settings 'settings'
# list community_lists 'russia_inside' # list community_lists 'russia_inside'
# list rule_set 'https://example.com/rules.srs' # list rule_set 'https://example.com/rules.srs'
# list rule_set_with_subnets 'https://example.com/rules-with-subnets.json' # 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_enabled '0'
# #option outbound_detour_section 'main' # #option outbound_detour_section 'main'
# option sort_by_latency '0' # option sort_by_latency '0'
@@ -81,7 +87,6 @@ config settings 'settings'
# option idle_timeout '30m' # option idle_timeout '30m'
# option interrupt_exist_connections '1' # option interrupt_exist_connections '1'
# option pin_dashboard '1' # option pin_dashboard '1'
# option hide_added_outbounds '0'
# option filter_mode 'disabled' # option filter_mode 'disabled'
# option detect_server_country 'flag_emoji' # option detect_server_country 'flag_emoji'
# list exclude_countries 'US' # list exclude_countries 'US'
+90 -14
View File
@@ -363,6 +363,78 @@ function priority_groups(section) {
return result; 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) { function priority_group_child(section, value) {
return owned_child_section(section, "priority_group", 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"); 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) { function urltest_pin_dashboard(section, value) {
let child = urltest_child(section, value); let child = urltest_child(section, value);
if (child != null) 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; 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) { function priority_group_pin_dashboard(section, value) {
let child = priority_group_child(section, value); let child = priority_group_child(section, value);
return child != null ? child_bool(child, "pin_dashboard", true) : true; return child != null ? child_bool(child, "pin_dashboard", true) : true;
@@ -999,6 +1059,24 @@ return {
interfaces, interfaces,
outbound_jsons, outbound_jsons,
urltests, 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, community_lists,
rule_sets, rule_sets,
rule_sets_with_subnets, rule_sets_with_subnets,
@@ -1033,7 +1111,6 @@ return {
urltest_interrupt_exist_connections, urltest_interrupt_exist_connections,
urltest_filter_mode, urltest_filter_mode,
urltest_detect_server_country, urltest_detect_server_country,
urltest_hide_added_outbounds,
urltest_pin_dashboard, urltest_pin_dashboard,
urltest_include_countries, urltest_include_countries,
urltest_include_outbounds, urltest_include_outbounds,
@@ -1061,7 +1138,6 @@ return {
priority_group_switch_to_faster_same_priority, priority_group_switch_to_faster_same_priority,
priority_group_fastest_check_interval, priority_group_fastest_check_interval,
priority_group_interrupt_exist_connections, priority_group_interrupt_exist_connections,
priority_group_hide_added_outbounds,
priority_group_pin_dashboard, priority_group_pin_dashboard,
priority_level_display_name, priority_level_display_name,
priority_level_order, 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."); 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) { function validate_priority_filter_mode_value(value, section, group_id, level_id) {
if (contains([ "disabled", "exclude", "include", "mixed" ], value)) if (contains([ "disabled", "exclude", "include", "mixed" ], value))
return; 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) { function validate_combined_domain_value(value, section) {
if (as_string(value) == "" || combined_domain_valid(value)) if (as_string(value) == "" || combined_domain_valid(value))
return; return;
@@ -1397,6 +1454,8 @@ function validate_rule(section, sections, context) {
} }
if (connections.is_connections_action(action)) { if (connections.is_connections_action(action)) {
validate_dashboard_filter(section);
for (let urltest_id in connections.urltests(section)) { for (let urltest_id in connections.urltests(section)) {
validate_urltest_identifier_value(urltest_id, name); validate_urltest_identifier_value(urltest_id, name);
+24 -2
View File
@@ -959,6 +959,29 @@ function interfaces_signature(section) {
return sprintf("%J", result); 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) { function urltests_signature(section) {
let result = []; let result = [];
for (let entry in connections.urltests(section)) { for (let entry in connections.urltests(section)) {
@@ -971,7 +994,6 @@ function urltests_signature(section) {
idle_timeout: connections.urltest_idle_timeout(section, entry), idle_timeout: connections.urltest_idle_timeout(section, entry),
interrupt_exist_connections: connections.urltest_interrupt_exist_connections(section, entry) ? "1" : "0", interrupt_exist_connections: connections.urltest_interrupt_exist_connections(section, entry) ? "1" : "0",
pin_dashboard: connections.urltest_pin_dashboard(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), filter_mode: connections.urltest_filter_mode(section, entry),
detect_server_country: connections.urltest_detect_server_country(section, entry), detect_server_country: connections.urltest_detect_server_country(section, entry),
include_countries: connections.urltest_include_countries(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), 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", 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", 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 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 + ".legacy_outbound_json", option(section, "outbound_json", ""));
body = signature_add_value(body, prefix + ".urltests", urltests_signature(section)); 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 + ".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 + ".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 + ".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)); 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) { 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)) { for (let urltest_id in connections.urltests(section)) {
let mode = connections.urltest_filter_mode(section, urltest_id); let mode = connections.urltest_filter_mode(section, urltest_id);
if (connections.urltest_detect_server_country(section, urltest_id) == "country_is" && 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, 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); names = object_or_empty(names);
countries = object_or_empty(countries); countries = object_or_empty(countries);
country_filter = runtime_urltest.normalized_country_list(country_filter); country_filter = runtime_urltest.normalized_country_list(country_filter);
let regex_set = regex_match_set(urltest_candidate_tags, names, regexes); 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 base_filter_configured = name_or_country_filter_configured(name_filter, regexes, country_filter);
let additional_set = object_keys_set(additional_matches);
let result = []; let result = [];
for (let tag in array_or_empty(urltest_candidate_tags)) { for (let tag in array_or_empty(urltest_candidate_tags)) {
let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set) || let base_matches = tag_name_filter_matches(tag, names, name_filter, regex_set) ||
tag_country_filter_matches(tag, countries, country_filter); 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") { 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 tag, metadata, protocols, transports, securities
); );
} }
else if (proxy_parameters_enabled) { else if (proxy_parameters_enabled) {
if (!base_filter_configured) if (!base_filter_configured)
base_matches = true; base_matches = true;
matches = base_matches && proxy_parameter_filter_matches_all( matches = additional_set[tag] ||
tag, metadata, protocols, transports, securities (base_matches && proxy_parameter_filter_matches_all(
); tag, metadata, protocols, transports, securities
));
} }
if (matches) if (matches)
@@ -1075,7 +1086,8 @@ function filter_candidate_outbounds(filter_mode, urltest_candidate_tags, names,
include_names, include_regex, include_countries, include_names, include_regex, include_countries,
include_proxy_parameters, include_protocols, include_transports, include_securities, include_proxy_parameters, include_protocols, include_transports, include_securities,
exclude_names, exclude_regex, exclude_countries, 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); let all_outbounds = urltest_all_candidate_outbounds(urltest_candidate_tags);
if (filter_mode == "" || filter_mode == "disabled") if (filter_mode == "" || filter_mode == "disabled")
return all_outbounds; return all_outbounds;
@@ -1094,7 +1106,8 @@ function filter_candidate_outbounds(filter_mode, urltest_candidate_tags, names,
"and", "and",
include_protocols, include_protocols,
include_transports, include_transports,
include_securities include_securities,
include_additional_matches
); );
let exclude_outbounds = urltest_matching_candidate_outbounds( let exclude_outbounds = urltest_matching_candidate_outbounds(
urltest_candidate_tags, urltest_candidate_tags,
@@ -1108,7 +1121,8 @@ function filter_candidate_outbounds(filter_mode, urltest_candidate_tags, names,
"or", "or",
exclude_protocols, exclude_protocols,
exclude_transports, exclude_transports,
exclude_securities exclude_securities,
exclude_additional_matches
); );
if (filter_mode == "include") 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) { function priority_levels_with_outbounds(group_id, urltest_candidate_tags, state) {
let result = []; let result = [];
let assigned = {}; 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), fastest_check_interval: connections.priority_group_fastest_check_interval(section, group_id),
interrupt_exist_connections: connections.priority_group_interrupt_exist_connections(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), pin_dashboard: connections.priority_group_pin_dashboard(section, group_id),
hide_added_outbounds: connections.priority_group_hide_added_outbounds(section, group_id),
outbounds, outbounds,
levels levels
}); });
@@ -1328,40 +1394,37 @@ function add_proxy_selector(config, section, selector_tags, urltest_candidate_ta
let selector_default = selector_tags[0]; let selector_default = selector_tags[0];
let urltest_tags = []; let urltest_tags = [];
let priority_tags = []; let priority_tags = [];
let hidden_selector_tags = {}; let group_outbounds = {};
for (let urltest_id in connections.urltests(section)) { for (let urltest_id in connections.urltests(section)) {
let urltest = add_urltest_outbound(config, section, urltest_id, urltest_candidate_tags, state); 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 == "") if (urltest.tag == "")
continue; continue;
push(urltest_tags, urltest.tag); 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)) { for (let group_id in connections.priority_groups(section)) {
let priority = add_priority_group_outbound(config, section, group_id, urltest_candidate_tags, state); 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 == "") if (priority.tag == "")
continue; continue;
push(priority_tags, priority.tag); 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) { 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) for (let tag in urltest_tags)
push(selector_outbounds, tag); push(selector_outbounds, tag);
for (let tag in priority_tags) 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]; 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, { push(config.outbounds, {
type: "selector", type: "selector",
tag: selector_tag, tag: selector_tag,
@@ -3309,7 +3309,6 @@ function hydrateConfigSections(configSections) {
idle_timeout: item.idle_timeout, idle_timeout: item.idle_timeout,
interrupt_exist_connections: item.interrupt_exist_connections, interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard, pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
urltest_filter_mode: item.filter_mode, urltest_filter_mode: item.filter_mode,
detect_server_country: item.detect_server_country, detect_server_country: item.detect_server_country,
urltest_include_countries: item.include_countries, urltest_include_countries: item.include_countries,
@@ -3356,7 +3355,6 @@ function hydrateConfigSections(configSections) {
fastest_check_interval: item.fastest_check_interval, fastest_check_interval: item.fastest_check_interval,
interrupt_exist_connections: item.interrupt_exist_connections, interrupt_exist_connections: item.interrupt_exist_connections,
pin_dashboard: item.pin_dashboard, pin_dashboard: item.pin_dashboard,
hide_added_outbounds: item.hide_added_outbounds,
levels levels
}; };
}); });
@@ -3572,11 +3570,6 @@ function getUrlTestConfigs(section) {
displayName: getUrlTestDisplayName(section, id, settings), displayName: getUrlTestDisplayName(section, id, settings),
settings, settings,
pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true), pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true),
hideAddedOutbounds: itemSettingBoolean(
settings,
"hide_added_outbounds",
false
),
showDetectedCountries: filteringEnabled && itemSettingString(settings, "detect_server_country", "flag_emoji") === "country_is" showDetectedCountries: filteringEnabled && itemSettingString(settings, "detect_server_country", "flag_emoji") === "country_is"
}; };
}); });
@@ -3635,11 +3628,6 @@ function getPriorityConfigs(section) {
displayName: itemSettingString(settings, "name", id), displayName: itemSettingString(settings, "name", id),
settings, settings,
pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true), pinDashboard: itemSettingBoolean(settings, "pin_dashboard", true),
hideAddedOutbounds: itemSettingBoolean(
settings,
"hide_added_outbounds",
false
),
healthUrl: itemSettingString( healthUrl: itemSettingString(
settings, settings,
"health_url", "health_url",
@@ -3884,23 +3872,6 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
const selectorCodes = selector?.value?.all ?? []; const selectorCodes = selector?.value?.all ?? [];
const urlTestCodes = urlTestConfigs.map((config) => config.code); const urlTestCodes = urlTestConfigs.map((config) => config.code);
const priorityCodes = priorityConfigs.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 showDetectedCountries = urlTestConfigs.some((config) => config.showDetectedCountries) || priorityConfigs.some((config) => config.showDetectedCountries);
const builtInUrltestCode = urlTestCodes[0] || ""; const builtInUrltestCode = urlTestCodes[0] || "";
const fallbackCodes = uniqueCodes([ const fallbackCodes = uniqueCodes([
@@ -3913,12 +3884,7 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
...selectorCodes.length ? selectorCodes : fallbackCodes, ...selectorCodes.length ? selectorCodes : fallbackCodes,
...urlTestCodes, ...urlTestCodes,
...priorityCodes ...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 outbounds = uniqueCodes(groupCodes).flatMap((code) => {
const item = proxyByCode.get(code); const item = proxyByCode.get(code);
const urlTestConfig = urlTestConfigByCode.get(code); const urlTestConfig = urlTestConfigByCode.get(code);
@@ -431,6 +431,8 @@ let actionProvidersAvailabilityLoader = null;
const outboundNameChoicesCache = new Map(); const outboundNameChoicesCache = new Map();
const outboundNameChoicesInflight = new Map(); const outboundNameChoicesInflight = new Map();
const outboundNameSourceOptions = new Map(); const outboundNameSourceOptions = new Map();
const sectionGroupSourceOptions = new Map();
const dashboardFilterChoiceRefreshers = new Map();
const SECTION_CACHE_DIR = "/var/run/forkop/section-cache"; const SECTION_CACHE_DIR = "/var/run/forkop/section-cache";
const COUNTRY_CODES = 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( "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) { parse(section_id) {
@@ -1415,6 +1424,78 @@ const ButtonAddSettingsDynamicList = SettingsDynamicList.extend({
buttonAdd: true, 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() { function countryChoices() {
return COUNTRY_CODES.map((code) => ({ return COUNTRY_CODES.map((code) => ({
value: code, value: code,
@@ -1529,6 +1610,94 @@ function currentDraftOutboundNames(section_id) {
return names; 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) { function isDownloadThroughTargetSection(section, currentSectionId) {
const sectionName = getUciSectionName(section); const sectionName = getUciSectionName(section);
const action = (section && section.action) || ""; const action = (section && section.action) || "";
@@ -1916,7 +2085,6 @@ function urlTestSettingsKeys() {
"idle_timeout", "idle_timeout",
"interrupt_exist_connections", "interrupt_exist_connections",
"pin_dashboard", "pin_dashboard",
"hide_added_outbounds",
"filter_mode", "filter_mode",
"detect_server_country", "detect_server_country",
"include_countries", "include_countries",
@@ -1945,7 +2113,6 @@ function defaultUrlTestSettings(name) {
idle_timeout: "30m", idle_timeout: "30m",
interrupt_exist_connections: "1", interrupt_exist_connections: "1",
pin_dashboard: "1", pin_dashboard: "1",
hide_added_outbounds: "0",
filter_mode: "disabled", filter_mode: "disabled",
detect_server_country: "flag_emoji", detect_server_country: "flag_emoji",
}; };
@@ -1959,7 +2126,6 @@ function urlTestChildDefaults() {
idle_timeout: "30m", idle_timeout: "30m",
interrupt_exist_connections: "1", interrupt_exist_connections: "1",
pin_dashboard: "1", pin_dashboard: "1",
hide_added_outbounds: "0",
filter_mode: "disabled", filter_mode: "disabled",
detect_server_country: "flag_emoji", detect_server_country: "flag_emoji",
}; };
@@ -1977,7 +2143,6 @@ function priorityGroupSettingsKeys() {
"fastest_check_interval", "fastest_check_interval",
"interrupt_exist_connections", "interrupt_exist_connections",
"pin_dashboard", "pin_dashboard",
"hide_added_outbounds",
]; ];
} }
@@ -1993,7 +2158,6 @@ function defaultPriorityGroupSettings() {
fastest_check_interval: "3m", fastest_check_interval: "3m",
interrupt_exist_connections: "1", interrupt_exist_connections: "1",
pin_dashboard: "1", pin_dashboard: "1",
hide_added_outbounds: "0",
}; };
} }
@@ -2008,7 +2172,6 @@ function priorityGroupChildDefaults() {
fastest_check_interval: "3m", fastest_check_interval: "3m",
interrupt_exist_connections: "1", interrupt_exist_connections: "1",
pin_dashboard: "1", pin_dashboard: "1",
hide_added_outbounds: "0",
}; };
} }
@@ -2368,15 +2531,6 @@ function addUrlTestItemOptions(itemSection, options = {}) {
o.default = "1"; o.default = "1";
o.rmempty = false; 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( o = itemSection.option(
form.ListValue, form.ListValue,
"filter_mode", "filter_mode",
@@ -2507,6 +2661,9 @@ function addUrlTestItemOptions(itemSection, options = {}) {
}); });
}; };
list.placeholder = _("-- Select --"); list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, (itemId, values) =>
currentOutboundNameChoices(parentSectionForItem(itemId), values),
);
} }
if (validator) { if (validator) {
list.validate = function (_itemId, value) { list.validate = function (_itemId, value) {
@@ -2717,6 +2874,9 @@ function addPriorityLevelItemOptions(itemSection, options = {}) {
}); });
}; };
list.placeholder = _("-- Select --"); list.placeholder = _("-- Select --");
configureLiveDynamicListChoices(list, (itemId, values) =>
currentOutboundNameChoices(parentSectionForItem(itemId), values),
);
} }
if (validator) { if (validator) {
list.validate = function (_itemId, value) { list.validate = function (_itemId, value) {
@@ -2857,15 +3017,6 @@ function addPriorityGroupItemOptions(itemSection, options = {}) {
o.default = "1"; o.default = "1";
o.rmempty = false; 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( o = itemSection.option(
ButtonAddSettingsDynamicList, ButtonAddSettingsDynamicList,
"priority_level", "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) { function settingValueEquals(left, right) {
const normalize = (value) => { const normalize = (value) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -6855,6 +7229,9 @@ function createSectionContent(section) {
const validation = main.validateProxyUrl(value); const validation = main.validateProxyUrl(value);
return validation.valid ? true : validation.message; return validation.valid ? true : validation.message;
}; };
o.onchange = function (_event, section_id) {
refreshDashboardFilterChoiceWidgets(section_id);
};
outboundNameSourceOptions.set("selector_proxy_links", o); outboundNameSourceOptions.set("selector_proxy_links", o);
o = section.taboption( o = section.taboption(
@@ -6943,6 +7320,7 @@ function createSectionContent(section) {
delete this.pendingChildSettings[section_id]; delete this.pendingChildSettings[section_id];
} }
}; };
o.onListChange = refreshDashboardFilterChoiceWidgets;
outboundNameSourceOptions.set("interfaces", o); outboundNameSourceOptions.set("interfaces", o);
o = section.taboption( o = section.taboption(
@@ -6969,6 +7347,7 @@ function createSectionContent(section) {
const validation = main.validateOutboundJson(value); const validation = main.validateOutboundJson(value);
return validation.valid ? true : validation.message; return validation.valid ? true : validation.message;
}; };
o.onListChange = refreshDashboardFilterChoiceWidgets;
outboundNameSourceOptions.set("outbound_jsons", o); outboundNameSourceOptions.set("outbound_jsons", o);
o = section.taboption( o = section.taboption(
@@ -7027,6 +7406,8 @@ function createSectionContent(section) {
this.inputValueForItem(section_id, itemId), this.inputValueForItem(section_id, itemId),
); );
}; };
o.onListChange = refreshDashboardFilterChoiceWidgets;
sectionGroupSourceOptions.set("urltest", o);
o = section.taboption( o = section.taboption(
"settings", "settings",
@@ -7064,6 +7445,8 @@ function createSectionContent(section) {
this.inputValueForItem(section_id, itemId), this.inputValueForItem(section_id, itemId),
); );
}; };
o.onListChange = refreshDashboardFilterChoiceWidgets;
sectionGroupSourceOptions.set("priority_group", o);
o = section.taboption( o = section.taboption(
"settings", "settings",
@@ -7444,6 +7827,8 @@ function createSectionContent(section) {
), ),
); );
ruleSetOption.modalonly = true; ruleSetOption.modalonly = true;
// Both widgets map to rule_set, so neither inactive view may erase shared storage.
ruleSetOption.retain = true;
dependsOnRoutingAction(ruleSetOption); dependsOnRoutingAction(ruleSetOption);
ruleSetOption.renderItemSettingsModal = showRuleSetSettingsModal; ruleSetOption.renderItemSettingsModal = showRuleSetSettingsModal;
ruleSetOption.load = function (section_id) { ruleSetOption.load = function (section_id) {
@@ -7472,6 +7857,7 @@ function createSectionContent(section) {
); );
dnsRuleSetOption.depends("action", "dns"); dnsRuleSetOption.depends("action", "dns");
dnsRuleSetOption.modalonly = true; dnsRuleSetOption.modalonly = true;
dnsRuleSetOption.retain = true;
dnsRuleSetOption.load = function (section_id) { dnsRuleSetOption.load = function (section_id) {
return getCustomRulesetReferences(section_id); return getCustomRulesetReferences(section_id);
}; };
@@ -7527,6 +7913,8 @@ function createSectionContent(section) {
dynamicValidate: validatePortCondition, dynamicValidate: validatePortCondition,
}); });
dependsOnRoutingAction(portsOption); dependsOnRoutingAction(portsOption);
addDashboardServerFilterOptions(section);
} }
function loadSectionTableOptions(sectionRef) { function loadSectionTableOptions(sectionRef) {
+65 -11
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: FORKOP\n" "Project-Id-Version: FORKOP\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-13 03:22+0300\n" "POT-Creation-Date: 2026-07-13 00:06+0300\n"
"PO-Revision-Date: 2026-07-13 03:22+0300\n" "PO-Revision-Date: 2026-07-13 00:06+0300\n"
"Last-Translator: ushan0v\n" "Last-Translator: ushan0v\n"
"Language-Team: none\n" "Language-Team: none\n"
"Language: ru\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." msgid "Add URLs or local paths to .srs / .json lists. Only domain rules are supported."
msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила." msgstr "Добавьте URL или локальные пути к спискам .srs / .json. Поддерживаются только доменные правила."
msgid "Add URLTest / Priority group"
msgstr "Добавить URLTest / Приоритет группу"
msgid "Additional marking rules found" msgid "Additional marking rules found"
msgstr "Найдены дополнительные правила маркировки" msgstr "Найдены дополнительные правила маркировки"
@@ -86,6 +89,12 @@ msgstr "Дополнительно исключать серверы по про
msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters." msgid "Additionally filter servers by protocol, transport, and security. Add only servers matching the specified parameters."
msgstr "Дополнительно отбирать серверы по протоколу, транспорту и защите. Добавлять только серверы, совпавшие по указанным параметрам." 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" msgid "Address"
msgstr "Адрес" msgstr "Адрес"
@@ -647,6 +656,9 @@ msgstr "Исключить из этого уровня серверы с одн
msgid "Exclude servers with one of the selected transports from this level." msgid "Exclude servers with one of the selected transports from this level."
msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов." msgstr "Исключить из этого уровня серверы с одним из выбранных транспортов."
msgid "Exclude URLTest / Priority group"
msgstr "Исключить URLTest / Приоритет группу"
msgid "Expecting: %s" msgid "Expecting: %s"
msgstr "Ожидается: %s" msgstr "Ожидается: %s"
@@ -698,6 +710,9 @@ msgstr "Интервал поиска более быстрого сервера
msgid "Fastest" msgid "Fastest"
msgstr "Самый быстрый" msgstr "Самый быстрый"
msgid "Filter the servers that will be displayed on the dashboard."
msgstr "Отфильтровать серверы, которые будут отображаться в дашборде."
msgid "Firewall WAN port" msgid "Firewall WAN port"
msgstr "Порт в firewall для WAN" msgstr "Порт в firewall для WAN"
@@ -794,24 +809,36 @@ msgstr "gRPC-сервис"
msgid "Handshake timeout" msgid "Handshake timeout"
msgstr "Таймаут рукопожатия" msgstr "Таймаут рукопожатия"
msgid "Hide added servers"
msgstr "Скрывать добавленные серверы"
msgid "Hide cascade connection nodes" msgid "Hide cascade connection nodes"
msgstr "Скрывать узлы каскадных подключений" 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" msgid "Hide individual nodes that are already included in imported subscription URLTest groups"
msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки" msgstr "Скрывать отдельные узлы, которые уже входят в импортированные URLTest группы подписки"
msgid "Hide intermediate nodes used as detours by other subscription nodes" msgid "Hide intermediate nodes used as detours by other subscription nodes"
msgstr "Скрывать промежуточные узлы, которые используются как detour для других узлов подписки" 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" msgid "Hide URLTest group nodes"
msgstr "Скрывать узлы URLTest групп" msgstr "Скрывать узлы URLTest групп"
@@ -1646,6 +1673,9 @@ msgstr "Выберите транзитную секцию"
msgid "Select an existing network interface" msgid "Select an existing network interface"
msgstr "Выберите существующий сетевой интерфейс" msgstr "Выберите существующий сетевой интерфейс"
msgid "Select an existing URLTest or Priority group"
msgstr "Выберите существующую URLTest или Приоритет группу"
msgid "Select an intermediate section" msgid "Select an intermediate section"
msgstr "Выберите промежуточную секцию" msgstr "Выберите промежуточную секцию"
@@ -1721,6 +1751,9 @@ msgstr "server_port должен быть числом от 1 до 65535"
msgid "Servers" msgid "Servers"
msgstr "Серверы" msgstr "Серверы"
msgid "Servers on dashboard"
msgstr "Серверы в дашборде"
msgid "Service action failed" msgid "Service action failed"
msgstr "Действие сервиса не выполнено" msgstr "Действие сервиса не выполнено"
@@ -1736,6 +1769,27 @@ msgstr "Настройки"
msgid "Show metadata on dashboard" msgid "Show metadata on dashboard"
msgstr "Отображать метаданные в дашборде" 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" msgid "Show sing-box config"
msgstr "Показать sing-box конфигурацию" 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