diff --git a/AGENTS.md b/AGENTS.md index 738ffff..0fddaa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ ProxyWarden - standalone Windows desktop client в корне репозитор - В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`. - Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально. - Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов. +- Не оставлять dev-серверы (`npm run dev`, `npm run tauri -- dev`, preview-серверы) запущенными после проверки. Если сервер был поднят агентом, остановить его перед финальным ответом. ## Проверка diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2b44b96..fee977b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,5 +18,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-dialog = "2.7.1" base64 = "0.22" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] } url = "2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 43b5dd3..2a313cf 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -58,6 +58,44 @@ const NDISAPI_RELEASE_API_URL: &str = "https://api.github.com/repos/wiresock/ndisapi/releases/latest"; const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe"; const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe"; +const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4); +const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2); +const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check"; + +const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[ + ProxyProbeEndpoint { + id: "cloudflare-trace", + name: "Cloudflare Trace", + url: "https://www.cloudflare.com/cdn-cgi/trace", + ip_source: ProbeIpSource::CloudflareTrace, + }, + ProxyProbeEndpoint { + id: "cloudflare-speed", + name: "Cloudflare Speed", + url: "https://speed.cloudflare.com/meta", + ip_source: ProbeIpSource::JsonField("clientIp"), + }, + ProxyProbeEndpoint { + id: "ipify", + name: "ipify", + url: "https://api.ipify.org?format=json", + ip_source: ProbeIpSource::JsonField("ip"), + }, +]; + +#[derive(Debug, Clone, Copy)] +pub struct ProxyProbeEndpoint { + id: &'static str, + name: &'static str, + url: &'static str, + ip_source: ProbeIpSource, +} + +#[derive(Debug, Clone, Copy)] +enum ProbeIpSource { + CloudflareTrace, + JsonField(&'static str), +} #[derive(Debug, Clone)] pub struct CommandState { @@ -239,6 +277,31 @@ pub struct PingServerResponse { pub error: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyProbeResponse { + pub id: String, + pub name: String, + pub url: String, + pub ok: bool, + pub status: Option, + pub latency: Option, + pub ip: Option, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyTargetCheckResponse { + pub tag: String, + pub server: String, + pub server_port: u16, + pub ok: bool, + pub latency: Option, + pub error: Option, + pub probes: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GenerateSingBoxConfigResponse { @@ -610,7 +673,7 @@ pub fn ping_all_singbox_servers( #[tauri::command] pub fn ping_proxy_target( input: PingProxyTargetInputDto, -) -> Result { +) -> Result { ping_proxy_target_endpoint(input) } @@ -1328,7 +1391,14 @@ pub fn ping_all_singbox_servers_in_storage( pub fn ping_proxy_target_endpoint( input: PingProxyTargetInputDto, -) -> Result { +) -> Result { + ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES) +} + +pub fn ping_proxy_target_endpoint_with_probes( + input: PingProxyTargetInputDto, + probes: &[ProxyProbeEndpoint], +) -> Result { let host = input.host.trim(); if host.is_empty() { return Err(CommandError::new( @@ -1337,7 +1407,40 @@ pub fn ping_proxy_target_endpoint( )); } - Ok(ping_endpoint("external-proxy", host, input.port)) + let tcp = ping_endpoint("route-proxy", host, input.port); + if !tcp.ok { + return Ok(ProxyTargetCheckResponse { + tag: "route-proxy".to_string(), + server: host.to_string(), + server_port: input.port, + ok: false, + latency: tcp.latency, + error: tcp.error, + probes: Vec::new(), + }); + } + + let probe_results = run_proxy_probes(host, input.port, probes); + let has_probe_success = probe_results.iter().any(|probe| probe.ok); + let ok = probe_results.is_empty() || has_probe_success; + let error = if ok { + None + } else { + Some( + "SOCKS5 порт доступен, но тестовые HTTP endpoints не ответили через прокси." + .to_string(), + ) + }; + + Ok(ProxyTargetCheckResponse { + tag: "route-proxy".to_string(), + server: host.to_string(), + server_port: input.port, + ok, + latency: tcp.latency, + error, + probes: probe_results, + }) } pub fn generate_singbox_config_with_services( @@ -1479,6 +1582,167 @@ fn ping_endpoint(tag: &str, server: &str, server_port: u16) -> PingServerRespons } } +fn run_proxy_probes( + proxy_host: &str, + proxy_port: u16, + probes: &[ProxyProbeEndpoint], +) -> Vec { + if probes.is_empty() { + return Vec::new(); + } + + let proxy_url = socks5h_proxy_url(proxy_host, proxy_port); + let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| { + reqwest::blocking::Client::builder() + .timeout(PROXY_CHECK_TIMEOUT) + .connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT) + .proxy(proxy) + .build() + }) { + Ok(client) => client, + Err(error) => { + return probes + .iter() + .map(|probe| { + failed_probe( + *probe, + format!("Не удалось подготовить SOCKS5 проверку: {error}"), + ) + }) + .collect(); + } + }; + + let handles = probes + .iter() + .copied() + .map(|probe| { + let client = client.clone(); + std::thread::spawn(move || run_proxy_probe(&client, probe)) + }) + .collect::>(); + + handles + .into_iter() + .zip(probes.iter().copied()) + .map(|(handle, probe)| { + handle + .join() + .unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string())) + }) + .collect() +} + +fn run_proxy_probe( + client: &reqwest::blocking::Client, + probe: ProxyProbeEndpoint, +) -> ProxyProbeResponse { + let started = Instant::now(); + let response = match client + .get(probe.url) + .header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT) + .send() + { + Ok(response) => response, + Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")), + }; + + let status = response.status(); + let status_code = status.as_u16(); + let body = match response.text() { + Ok(body) => body, + Err(error) => { + return failed_probe_with_status( + probe, + status_code, + format!("Ответ не прочитан: {error}"), + ) + } + }; + let latency = started.elapsed().as_millis(); + + if !status.is_success() { + return ProxyProbeResponse { + id: probe.id.to_string(), + name: probe.name.to_string(), + url: probe.url.to_string(), + ok: false, + status: Some(status_code), + latency: Some(latency), + ip: None, + error: Some(format!("HTTP {status_code}")), + }; + } + + let ip = extract_probe_ip(probe, &body); + + ProxyProbeResponse { + id: probe.id.to_string(), + name: probe.name.to_string(), + url: probe.url.to_string(), + ok: true, + status: Some(status_code), + latency: Some(latency), + ip, + error: None, + } +} + +fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse { + failed_probe_with_status(probe, 0, error) +} + +fn failed_probe_with_status( + probe: ProxyProbeEndpoint, + status: u16, + error: String, +) -> ProxyProbeResponse { + ProxyProbeResponse { + id: probe.id.to_string(), + name: probe.name.to_string(), + url: probe.url.to_string(), + ok: false, + status: (status > 0).then_some(status), + latency: None, + ip: None, + error: Some(error), + } +} + +fn socks5h_proxy_url(host: &str, port: u16) -> String { + let host = host.trim().trim_start_matches('[').trim_end_matches(']'); + if host.contains(':') { + format!("socks5h://[{host}]:{port}") + } else { + format!("socks5h://{host}:{port}") + } +} + +fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option { + match probe.ip_source { + ProbeIpSource::CloudflareTrace => body + .lines() + .find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)), + ProbeIpSource::JsonField(field) => serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get(field) + .and_then(|field| field.as_str()) + .and_then(normalize_ip) + }), + } +} + +fn normalize_ip(value: &str) -> Option { + let candidate = value.trim().trim_matches('"'); + if candidate.parse::().is_ok() { + Some(candidate.to_string()) + } else { + None + } +} + fn local_lan_ipv4() -> Option { let socket = UdpSocket::bind("0.0.0.0:0").ok()?; socket.connect("8.8.8.8:80").ok()?; diff --git a/src-tauri/tests/command_tests.rs b/src-tauri/tests/command_tests.rs index 506d79a..97594e8 100644 --- a/src-tauri/tests/command_tests.rs +++ b/src-tauri/tests/command_tests.rs @@ -222,17 +222,21 @@ fn ping_proxy_target_reports_open_tcp_endpoint() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener"); let port = listener.local_addr().expect("read local addr").port(); - let result = commands::ping_proxy_target_endpoint(commands::PingProxyTargetInputDto { - host: "127.0.0.1".to_string(), - port, - }) + let result = commands::ping_proxy_target_endpoint_with_probes( + commands::PingProxyTargetInputDto { + host: "127.0.0.1".to_string(), + port, + }, + &[], + ) .expect("ping should return response"); - assert_eq!(result.tag, "external-proxy"); + assert_eq!(result.tag, "route-proxy"); assert_eq!(result.server, "127.0.0.1"); assert_eq!(result.server_port, port); assert!(result.ok); assert!(result.latency.is_some()); + assert!(result.probes.is_empty()); } #[test] diff --git a/src-tauri/tests/singbox_service_tests.rs b/src-tauri/tests/singbox_service_tests.rs index ca033a0..48596ee 100644 --- a/src-tauri/tests/singbox_service_tests.rs +++ b/src-tauri/tests/singbox_service_tests.rs @@ -55,7 +55,8 @@ noise #[test] fn safe_install_dir_allows_only_proxywarden_singbox_folder() { assert!( - ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box")).is_ok() + ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box")) + .is_ok() ); assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err()); assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err()); @@ -63,7 +64,12 @@ fn safe_install_dir_allows_only_proxywarden_singbox_folder() { #[test] fn service_control_script_targets_named_service_and_action() { - let script = service_control_script(SingBoxServiceAction::Start, "ProxyWardenSingBox", None, None); + let script = service_control_script( + SingBoxServiceAction::Start, + "ProxyWardenSingBox", + None, + None, + ); assert!(script.contains("$serviceName = 'ProxyWardenSingBox'")); assert!(script.contains("$action = 'start'")); @@ -84,9 +90,9 @@ fn service_control_script_syncs_generated_config_before_start() { assert!(script.contains( "$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'" )); - assert!(script.contains( - "$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'" - )); + assert!( + script.contains("$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'") + ); assert!(script.contains("Copy-Item -LiteralPath $configSource")); assert!(script.contains("'config_sync_failed'")); } @@ -119,7 +125,9 @@ fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox { install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"), executable_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe"), - wrapper_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe"), + wrapper_path: PathBuf::from( + r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe", + ), binary_exists, wrapper_exists, running, diff --git a/src/api/tauriCommands.ts b/src/api/tauriCommands.ts index ab2bf7b..ae49da7 100644 --- a/src/api/tauriCommands.ts +++ b/src/api/tauriCommands.ts @@ -75,6 +75,27 @@ export interface PingServerResponse { error?: string; } +export interface ProxyProbeResponse { + id: string; + name: string; + url: string; + ok: boolean; + status?: number; + latency?: number; + ip?: string; + error?: string; +} + +export interface ProxyTargetCheckResponse { + tag: string; + server: string; + serverPort: number; + ok: boolean; + latency?: number; + error?: string; + probes: ProxyProbeResponse[]; +} + export interface GenerateSingBoxConfigResponse { success: boolean; message: string; @@ -184,8 +205,8 @@ export function pingAllSingBoxServers(): Promise { return invoke('ping_all_singbox_servers'); } -export function pingProxyTarget(host: string, port: number): Promise { - return invoke('ping_proxy_target', { +export function pingProxyTarget(host: string, port: number): Promise { + return invoke('ping_proxy_target', { input: { host, port }, }); } diff --git a/src/app/App.tsx b/src/app/App.tsx index 74b93a4..744cac4 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; -import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Trash2, Wand2 } from 'lucide-react'; +import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Power, Trash2, Wand2 } from 'lucide-react'; import { applyProfiles, fetchSingBoxSubscription, @@ -13,7 +13,6 @@ import { getSingBoxStatus, installProxiFyre, installSingBox, - openConfigLocation, pingAllSingBoxServers, pingProxyTarget, pingSingBoxServer, @@ -30,6 +29,8 @@ import { type ApplyProfilesResponse, type LocalSingBoxStatusResponse, type PingServerResponse, + type ProxyProbeResponse, + type ProxyTargetCheckResponse, type ProxiFyreSetupStatus, type SingBoxSetupStatus, } from '../api/tauriCommands'; @@ -39,7 +40,7 @@ import { getApplyReadiness } from './readiness'; import { serviceControlState } from './viewModel'; type DraftItemType = Extract; -type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall'; +type ProxiFyreAction = 'start' | 'stop' | 'restart' | 'install' | 'uninstall'; type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'generate' | 'ping'; type RouteMode = 'external' | 'local-singbox'; type ServiceVisualState = 'active' | 'settling' | null; @@ -82,10 +83,18 @@ interface ConnectionCheckView { text: string; endpoint: string; details: string[]; + probes: ConnectionProbeView[]; disabledReason?: string; loading: boolean; } +interface ConnectionProbeView { + id: string; + label: string; + value: string; + tone: StatusTone; +} + interface RouteChainSegment { id: string; label: string; @@ -94,17 +103,23 @@ interface RouteChainSegment { details: string[]; } +interface PendingChangeRow { + id: string; + label: string; + before?: string; + after: string; + tone?: 'added' | 'removed' | 'changed'; +} + interface ConnectionCheckInput { routeMode: RouteMode; proxyInput: string; - proxyPing: PingServerResponse | null; + proxyCheck: ProxyTargetCheckResponse | null; singbox: ComponentStatus | undefined; singBoxStatus: LocalSingBoxStatusResponse | null; selectedServer: SubscriptionServer | null; - selectedServerPing: PingServerResponse | undefined; isDetectingComponents: boolean; isProxyChecking: boolean; - serverPingTag: string | null; } interface RouteChainInput { @@ -164,7 +179,7 @@ export function App() { const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); const [subscriptionInput, setSubscriptionInput] = useState(''); const [serverPings, setServerPings] = useState>({}); - const [proxyPing, setProxyPing] = useState(null); + const [proxyCheck, setProxyCheck] = useState(null); const [generatedConfigPath, setGeneratedConfigPath] = useState(''); const [logEntries, setLogEntries] = useState([]); const [activeLogId, setActiveLogId] = useState(null); @@ -172,7 +187,6 @@ export function App() { const [isLoading, setIsLoading] = useState(true); const [isDetectingComponents, setIsDetectingComponents] = useState(true); const [isApplying, setIsApplying] = useState(false); - const [isOpeningConfig, setIsOpeningConfig] = useState(false); const [isProxyChecking, setIsProxyChecking] = useState(false); const [serverPingTag, setServerPingTag] = useState(null); const [serviceAction, setServiceAction] = useState(null); @@ -181,7 +195,6 @@ export function App() { const [isSingBoxMenuOpen, setIsSingBoxMenuOpen] = useState(false); const [serviceVisualState, setServiceVisualState] = useState(null); const serviceVisualTimerRef = useRef(null); - const appsSectionRef = useRef(null); const proxyfier = useMemo( () => components.find((component) => component.id === 'proxyfier'), @@ -201,23 +214,26 @@ export function App() { () => singBoxStatus?.cache?.servers.find((server) => server.tag === selectedServerTag) ?? null, [selectedServerTag, singBoxStatus], ); - const selectedServerPing = selectedServerTag ? serverPings[selectedServerTag] : undefined; const currentSnapshot = useMemo( () => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag), [items, proxyInput, routeMode, selectedServerTag], ); - const hasUnappliedChanges = appliedSnapshot - ? !sameConfigSnapshot(currentSnapshot, appliedSnapshot) - : false; + const pendingChanges = useMemo( + () => appliedSnapshot ? configChangeRows(appliedSnapshot, currentSnapshot) : [], + [appliedSnapshot, currentSnapshot], + ); + const hasUnappliedChanges = pendingChanges.length > 0; + const shellStyle = hasUnappliedChanges + ? ({ '--change-row-count': String(pendingChanges.length) } as CSSProperties) + : undefined; const systemSummary = systemSummaryState({ isLoading, isDetectingComponents, proxyfier, singbox, - routeMode, + routeMode: appliedSnapshot?.routeMode ?? routeMode, singBoxStatus, - proxyPing, - hasUnappliedChanges, + proxyCheck, }); useEffect(() => { @@ -311,7 +327,7 @@ export function App() { : 'external'; setProxyInput(savedProxyInput); - setProxyPing(null); + setProxyCheck(null); setItems(savedItems); setLoadedProfiles(profiles); setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID); @@ -370,18 +386,12 @@ export function App() { function changeRouteMode(nextMode: RouteMode) { setRouteMode(nextMode); + setProxyCheck(null); } function changeProxyInput(nextValue: string) { setProxyInput(nextValue); - setProxyPing(null); - } - - function openAppsPanel() { - switchPanel('proxifyre'); - window.setTimeout(() => { - appsSectionRef.current?.scrollIntoView({ block: 'start' }); - }, 0); + setProxyCheck(null); } function switchPanel(nextPanel: PanelId) { @@ -465,6 +475,7 @@ export function App() { ); const result = await applyProfiles(); + await restartProxiFyreAfterApply(); const [saved, detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([ getSavedState(), getComponents(), @@ -514,26 +525,6 @@ export function App() { } } - async function openConfig() { - setIsOpeningConfig(true); - try { - const openedPath = await openConfigLocation(); - showNotice({ - kind: 'info', - title: 'Конфиг открыт', - text: openedPath, - }); - } catch (error) { - showNotice({ - kind: 'error', - title: 'Не удалось открыть конфиг', - text: errorMessage(error), - }); - } finally { - setIsOpeningConfig(false); - } - } - async function setProxiFyreServiceRunning(shouldRun: boolean) { const action = shouldRun ? 'start' : 'stop'; setServiceAction(action); @@ -643,6 +634,7 @@ export function App() { const component = shouldRun ? await startSingBoxService() : await stopSingBoxService(); setComponents((current) => upsertComponent(current, component)); await refreshSingBoxState(); + setProxyCheck(null); showNotice({ kind: 'success', title: shouldRun ? 'sing-box запущен' : 'sing-box остановлен', @@ -667,6 +659,7 @@ export function App() { const component = await installSingBox(); setComponents((current) => upsertComponent(current, component)); await refreshSingBoxState(); + setProxyCheck(null); showNotice({ kind: 'success', title: 'Local sing-box установлен', @@ -696,6 +689,7 @@ export function App() { const component = await uninstallSingBox(); setComponents((current) => upsertComponent(current, component)); await refreshSingBoxState(); + setProxyCheck(null); showNotice({ kind: 'success', title: 'Local sing-box удален', @@ -733,6 +727,7 @@ export function App() { setComponents((current) => upsertComponent(current, status.component)); setSubscriptionInput(''); setServerPings({}); + setProxyCheck(null); showNotice({ kind: 'success', title: 'Подписка обновлена', @@ -757,6 +752,7 @@ export function App() { setSingBoxStatus(status); setComponents((current) => upsertComponent(current, status.component)); setServerPings({}); + setProxyCheck(null); showNotice({ kind: 'info', title: 'Подписка очищена', @@ -778,6 +774,7 @@ export function App() { const status = await selectSingBoxServer(server); setSingBoxStatus(status); setComponents((current) => upsertComponent(current, status.component)); + setProxyCheck(null); } catch (error) { showNotice({ kind: 'error', @@ -832,14 +829,14 @@ export function App() { } } - async function pingExternalProxy() { - let parsed: ParsedProxy; + async function checkRouteProxy() { + let target: ParsedProxy; try { - parsed = parseProxy(proxyInput); + target = routeProxyCheckTarget(routeMode, proxyInput, singBoxStatus); } catch (error) { showNotice({ kind: 'error', - title: 'Прокси не проверен', + title: 'Маршрут не проверен', text: errorMessage(error), }); return; @@ -847,17 +844,17 @@ export function App() { setIsProxyChecking(true); try { - const result = await pingProxyTarget(parsed.host, parsed.port); - setProxyPing(result); + const result = await pingProxyTarget(target.host, target.port); + setProxyCheck(result); showNotice({ - kind: result.ok ? 'success' : 'error', - title: result.ok ? 'Внешний прокси отвечает' : 'Внешний прокси не ответил', - text: pingResultText(result), + kind: proxyCheckNoticeKind(result), + title: proxyCheckNoticeTitle(result), + text: proxyCheckText(result), }); } catch (error) { showNotice({ kind: 'error', - title: 'Прокси не проверен', + title: 'Маршрут не проверен', text: errorMessage(error), }); } finally { @@ -865,17 +862,22 @@ export function App() { } } - async function pingSelectedSingBoxServer() { - if (!selectedServer) { - showNotice({ - kind: 'error', - title: 'Сервер не выбран', - text: 'Выбери сервер Local sing-box перед проверкой.', - }); - return; - } + async function restartProxiFyreAfterApply() { + if (!proxyfier?.installed || !proxyfier.running) return; - await pingSingleSingBoxServer(selectedServer); + setServiceAction('restart'); + setIsServiceMenuOpen(false); + startServiceVisual(); + try { + await nextFrame(); + const stopped = await stopProxiFyreService(); + setComponents((current) => upsertComponent(current, stopped)); + const started = await startProxiFyreService(); + setComponents((current) => upsertComponent(current, started)); + } finally { + setServiceAction(null); + settleServiceVisual(); + } } async function generateSingBoxNow() { @@ -943,7 +945,6 @@ export function App() { function renderSummaryPanel() { const appliedRouteMode = appliedSnapshot?.routeMode ?? routeMode; - const draftHasLocalServer = currentSnapshot.routeMode === 'local-singbox' && currentSnapshot.selectedServerTag; return (
-
-
+ {renderSummaryStatusControl()}
@@ -967,9 +961,6 @@ export function App() { Применено сейчас {appliedSnapshot ? routeModeLabel(appliedRouteMode) : 'Состояние загружается'}
- - {hasUnappliedChanges ? 'Есть черновик' : 'Совпадает'} -
@@ -995,42 +986,6 @@ export function App() {
-
-
- Черновик интерфейса - {hasUnappliedChanges ? 'Есть реальные изменения' : 'Изменений нет'} -

- {hasUnappliedChanges - ? 'Текущие настройки отличаются от примененного состояния. Если вернуть значения назад, предупреждение исчезнет.' - : 'То, что выбрано в интерфейсе, совпадает с примененной конфигурацией.'} -

-
- {hasUnappliedChanges ? ( -
-
-
Будет маршрут
-
{snapshotRouteLine(currentSnapshot, singBoxStatus)}
-
-
-
VPN сервер
-
{draftHasLocalServer ? displayServerTag(currentSnapshot.selectedServerTag) : snapshotServerText(currentSnapshot)}
-
-
-
Приложения
-
{snapshotItemsText(currentSnapshot)}
-
-
- ) : null} -
- -
- - -
); } @@ -1043,8 +998,10 @@ export function App() { label: proxyfier.running ? 'Остановить' : 'Запустить', onClick: () => void setProxiFyreServiceRunning(!proxyfier.running), variant: proxyfier.running ? 'danger' as const : 'neutral' as const, - loading: serviceAction === 'start' || serviceAction === 'stop', - loadingLabel: serviceAction === 'start' ? 'Запускаю' : 'Останавливаю', + loading: serviceAction === 'start' || serviceAction === 'stop' || serviceAction === 'restart', + loadingLabel: serviceAction === 'restart' + ? 'Перезапускаю' + : serviceAction === 'start' ? 'Запускаю' : 'Останавливаю', disabled: isDetectingComponents || Boolean(serviceAction), } : { @@ -1101,15 +1058,12 @@ export function App() { function renderAppsSection() { return ( -
+

Приложения

{items.length}
-
- {renderApplyActions('proxifyre', { showConfigPath: false, showState: false })} -
{isProcessInputOpen ? ( @@ -1214,12 +1168,40 @@ export function App() { ); } - function renderApplyActions( - context: 'proxifyre' | 'proxy', - options: { showConfigPath?: boolean; showState?: boolean } = {}, - ) { - const showConfigPath = options.showConfigPath ?? true; - const showState = options.showState ?? true; + function renderSummaryStatusControl() { + const installed = Boolean(proxyfier?.installed); + const running = Boolean(proxyfier?.running); + const working = serviceAction === 'start' || serviceAction === 'stop' || serviceAction === 'restart'; + const buttonLabel = !installed ? 'Не установлен' : running ? 'Отключить' : 'Включить'; + + return ( +
+
+ ); + } + + function renderChangesDock() { + if (!hasUnappliedChanges || !appliedSnapshot) return null; + const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null; const readiness = getApplyReadiness({ routeMode, @@ -1231,50 +1213,46 @@ export function App() { externalProxyError, busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction), }); - const blockerAlreadyVisible = routeBlockerIsVisibleInProxyPanel(context, routeMode, readiness.title); - const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents && !blockerAlreadyVisible; - const showPending = readiness.ready && hasUnappliedChanges; + const applyButtonText = isLoading || isDetectingComponents + ? 'Проверяю готовность' + : readiness.ready + ? changesApplyButtonLabel(isApplying, singBoxAction, serviceAction) + : readiness.title ?? 'Применение недоступно'; return ( - <> - {showState && showBlocker ? ( -
- {readiness.title} - {readiness.text} -
- ) : showState && showPending ? ( -
- Изменения еще не применены в ProxiFyre - {applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))} -
- ) : null} - -
+ ); } @@ -1317,32 +1295,35 @@ export function App() { return (
-
- Проверка endpoint +
+ Проверка маршрута {check.title}

{check.text}

+ {check.probes.length ? ( +
+ {check.probes.map((probe) => ( + + {probe.label} + {probe.value} + + ))} +
+ ) : null} - Endpoint {check.endpoint} - + loading={singBoxAction === 'generate'} + label="Создать конфиг Local sing-box" + tooltip="Создать конфиг" + icon={} + />
@@ -1516,25 +1494,16 @@ export function App() { const check = connectionCheckView({ routeMode, proxyInput, - proxyPing, + proxyCheck, singbox, singBoxStatus, selectedServer, - selectedServerPing, isDetectingComponents, isProxyChecking, - serverPingTag, }); return ( -
-
-
- Настройки VPN / Прокси -

Маршрут и проверка соединения

-
- {routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'} -
+
{renderConnectionCheck(check)}
); @@ -1574,8 +1543,6 @@ export function App() {
{renderRouteChain()} - - {renderApplyActions('proxy')}
); } @@ -1620,7 +1587,7 @@ export function App() { } return ( -
+
@@ -1645,6 +1612,8 @@ export function App() {
+ {renderChangesDock()} + >) .sort((left, right) => `${left.type}:${left.value}`.localeCompare(`${right.type}:${right.value}`)); } +function configChangeRows(applied: ConfigSnapshot, current: ConfigSnapshot): PendingChangeRow[] { + if (sameConfigSnapshot(applied, current)) return []; + + const rows: PendingChangeRow[] = []; + + if (applied.routeMode !== current.routeMode) { + rows.push({ + id: 'route-mode', + label: 'Маршрут', + before: routeModeLabel(applied.routeMode), + after: routeModeLabel(current.routeMode), + }); + } + + if ( + applied.proxy !== current.proxy + && (applied.routeMode === 'external' || current.routeMode === 'external') + ) { + rows.push({ + id: 'external-proxy', + label: 'SOCKS5', + before: snapshotProxyChangeText(applied), + after: snapshotProxyChangeText(current), + }); + } + + if ( + applied.selectedServerTag !== current.selectedServerTag + && (applied.routeMode === 'local-singbox' || current.routeMode === 'local-singbox') + ) { + rows.push({ + id: 'vpn-server', + label: 'VPN сервер', + before: snapshotServerChangeText(applied), + after: snapshotServerChangeText(current), + }); + } + + const itemRows = snapshotItemChangeRows(applied.items, current.items); + if (itemRows.length) { + rows.push(...itemRows); + } + + return rows; +} + function sameConfigSnapshot(left: ConfigSnapshot, right: ConfigSnapshot) { if (left.routeMode !== right.routeMode) return false; if (left.proxy !== right.proxy) return false; @@ -1758,6 +1772,48 @@ function displaySnapshotProxy(proxy: string) { return proxy.replace(/^socks5:\/\//, '') || 'не указан'; } +function snapshotProxyChangeText(snapshot: ConfigSnapshot) { + return snapshot.routeMode === 'external' ? displaySnapshotProxy(snapshot.proxy) : 'не используется'; +} + +function snapshotServerChangeText(snapshot: ConfigSnapshot) { + if (snapshot.routeMode !== 'local-singbox') return 'не используется'; + return snapshot.selectedServerTag ? displayServerTag(snapshot.selectedServerTag) : 'сервер не выбран'; +} + +function snapshotItemChangeRows( + appliedItems: ConfigSnapshotItem[], + currentItems: ConfigSnapshotItem[], +): PendingChangeRow[] { + const appliedKeys = new Set(appliedItems.map(snapshotItemKey)); + const currentKeys = new Set(currentItems.map(snapshotItemKey)); + const added = currentItems.filter((item) => !appliedKeys.has(snapshotItemKey(item))); + const removed = appliedItems.filter((item) => !currentKeys.has(snapshotItemKey(item))); + + return [ + ...added.map((item) => ({ + id: `app-add-${snapshotItemKey(item)}`, + label: 'Добавлено', + after: `+ ${formatSnapshotItem(item)}`, + tone: 'added' as const, + })), + ...removed.map((item) => ({ + id: `app-remove-${snapshotItemKey(item)}`, + label: 'Удалено', + after: `- ${formatSnapshotItem(item)}`, + tone: 'removed' as const, + })), + ]; +} + +function snapshotItemKey(item: ConfigSnapshotItem) { + return `${item.type}:${item.value}`; +} + +function formatSnapshotItem(item: ConfigSnapshotItem) { + return `${itemTypeLabel(item.type)} ${item.value}`; +} + function systemSummaryState(input: SummaryStateInput): SummaryState { if (input.isLoading || input.isDetectingComponents) { return { @@ -1807,19 +1863,11 @@ function systemSummaryState(input: SummaryStateInput): SummaryState { } } - if (input.routeMode === 'external' && input.proxyPing && !input.proxyPing.ok) { + if (input.proxyCheck && !input.proxyCheck.ok) { return { tone: 'warning', - title: 'Прокси не ответил', - text: pingResultText(input.proxyPing), - }; - } - - if (input.hasUnappliedChanges) { - return { - tone: 'warning', - title: 'Есть непримененные изменения', - text: 'Настройки изменены, но ProxiFyre еще не обновлен.', + title: 'Маршрут не прошел проверку', + text: proxyCheckText(input.proxyCheck), }; } @@ -1830,6 +1878,19 @@ function systemSummaryState(input: SummaryStateInput): SummaryState { }; } +function summaryControlText( + snapshot: ConfigSnapshot | null, + proxyfier: ComponentStatus | undefined, + checking: boolean, +) { + if (checking) return 'Проверка компонентов.'; + if (!proxyfier?.installed) return 'ProxiFyre не найден.'; + if (!proxyfier.running) return 'Отключено: трафик идет напрямую.'; + if (!snapshot) return 'Маршрут загружается.'; + + return `${routeModeLabel(snapshot.routeMode)} · ${appCountCompact(snapshot.items.length)}`; +} + function routeEndpointLabel( routeMode: RouteMode, proxyInput: string, @@ -1850,44 +1911,47 @@ function routeEndpointLabel( function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView { if (input.routeMode === 'external') { - const proxyValidation = input.proxyInput.trim() ? safeProxyError(input.proxyInput) : 'Введи адрес SOCKS5 прокси.'; + const proxyValidation = input.proxyInput.trim() ? safeProxyError(input.proxyInput) : 'Введи SOCKS5 endpoint.'; const endpoint = routeEndpointLabel('external', input.proxyInput, input.singBoxStatus); const details = [ - 'Режим: внешний SOCKS5', `Endpoint: ${endpoint}`, - 'Проверка: TCP connect до proxy host:port.', - 'Local sing-box для внешнего маршрута не требуется.', + 'Проверка: TCP доступность SOCKS5 и HTTP-запросы через него.', + 'Контрольные точки: Cloudflare Trace, Cloudflare Speed, ipify.', + 'Local sing-box для этого маршрута не требуется.', ]; if (proxyValidation) { return { tone: 'warning', - title: 'Endpoint не готов', + title: 'Нужен endpoint', text: proxyValidation, endpoint, details, + probes: [], disabledReason: proxyValidation, loading: input.isProxyChecking, }; } - if (input.proxyPing) { + if (input.proxyCheck) { return { - tone: pingTone(input.proxyPing), - title: input.proxyPing.ok ? 'Endpoint отвечает' : 'Endpoint не отвечает', - text: pingResultText(input.proxyPing), + tone: proxyCheckTone(input.proxyCheck), + title: proxyCheckTitle(input.proxyCheck), + text: proxyCheckText(input.proxyCheck), endpoint, - details, + details: [...details, ...proxyCheckDetails(input.proxyCheck)], + probes: proxyCheckProbes(input.proxyCheck), loading: input.isProxyChecking, }; } return { tone: 'muted', - title: 'Проверка не запускалась', - text: 'Проверит доступность SOCKS5 endpoint без изменения маршрута.', + title: 'Готово к проверке', + text: 'Проверит TCP, внешний IP и задержку через тестовые endpoints.', endpoint, details, + probes: [], loading: input.isProxyChecking, }; } @@ -1897,32 +1961,34 @@ function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView { ? `${displayServerTag(input.selectedServer.tag)} · ${formatHostPort(input.selectedServer.server, input.selectedServer.serverPort)}` : 'сервер не выбран'; const details = [ - 'Режим: локальный sing-box', - `Local listener: ${localAddress}`, + `Endpoint: ${localAddress}`, `LAN: ${lanSingBoxAddress(input.singBoxStatus) ?? 'недоступен'}`, `Сервер: ${selectedEndpoint}`, - 'Проверка: TCP connect до выбранного сервера подписки.', + 'Проверка: HTTP-запросы через local SOCKS listener.', + 'Контрольные точки: Cloudflare Trace, Cloudflare Speed, ipify.', ]; if (input.isDetectingComponents) { return { tone: 'checking', title: 'Проверяю компоненты', - text: 'Обновляю состояние Local sing-box перед проверкой.', + text: 'Обновляю состояние.', endpoint: localAddress, details, + probes: [], disabledReason: 'Дождись завершения проверки компонентов.', - loading: Boolean(input.serverPingTag), + loading: input.isProxyChecking, }; } if (!input.singbox?.installed) { return { tone: 'warning', - title: 'Локальный runtime не готов', - text: 'Установи Local sing-box, затем загрузи подписку и выбери сервер.', + title: 'sing-box не готов', + text: 'Установи sing-box и выбери сервер.', endpoint: localAddress, details, + probes: [], disabledReason: 'Local sing-box не установлен.', loading: false, }; @@ -1932,9 +1998,10 @@ function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView { return { tone: 'warning', title: 'Служба остановлена', - text: 'Запусти Local sing-box перед проверкой локального маршрута.', + text: 'Запусти sing-box.', endpoint: localAddress, details, + probes: [], disabledReason: 'Local sing-box остановлен.', loading: false, }; @@ -1944,32 +2011,35 @@ function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView { return { tone: 'warning', title: 'Сервер не выбран', - text: 'Выбери сервер подписки для проверки.', + text: 'Выбери сервер подписки.', endpoint: selectedEndpoint, details, + probes: [], disabledReason: 'Сервер Local sing-box не выбран.', loading: false, }; } - if (input.selectedServerPing) { + if (input.proxyCheck) { return { - tone: pingTone(input.selectedServerPing), - title: input.selectedServerPing.ok ? 'Сервер отвечает' : 'Сервер не отвечает', - text: pingResultText(input.selectedServerPing), - endpoint: selectedEndpoint, - details, - loading: input.serverPingTag === input.selectedServer.tag, + tone: proxyCheckTone(input.proxyCheck), + title: proxyCheckTitle(input.proxyCheck), + text: proxyCheckText(input.proxyCheck), + endpoint: localAddress, + details: [...details, ...proxyCheckDetails(input.proxyCheck)], + probes: proxyCheckProbes(input.proxyCheck), + loading: input.isProxyChecking, }; } return { tone: 'muted', - title: 'Проверка не запускалась', - text: 'Проверит выбранный сервер подписки без применения маршрута.', - endpoint: selectedEndpoint, + title: 'Готово к проверке', + text: 'Проверит внешний IP и задержку через local SOCKS listener.', + endpoint: localAddress, details, - loading: input.serverPingTag === input.selectedServer.tag, + probes: [], + loading: input.isProxyChecking, }; } @@ -2042,20 +2112,6 @@ function routeChainSegments(input: RouteChainInput): RouteChainSegment[] { ]; } -function routeBlockerIsVisibleInProxyPanel( - context: 'proxifyre' | 'proxy', - routeMode: RouteMode, - title: string | undefined, -) { - if (context !== 'proxy' || !title) return false; - - if (routeMode === 'external') { - return title === 'Прокси не указан' || title === 'Проверь формат прокси'; - } - - return title === 'Local sing-box не установлен' || title === 'Сервер не выбран'; -} - function componentChainTone(component: ComponentStatus | undefined, checking: boolean): StatusTone { if (checking) return 'checking'; if (!component?.installed) return 'warning'; @@ -2097,19 +2153,121 @@ function pingResultText(ping: PingServerResponse) { return `${ping.server}:${ping.serverPort}: ${ping.error ?? 'нет ответа'}`; } -function applyButtonLabel( - context: 'proxifyre' | 'proxy', +function proxyCheckTone(check: ProxyTargetCheckResponse): StatusTone { + if (!check.ok) return 'error'; + if (check.probes.some((probe) => !probe.ok)) return 'warning'; + return 'ok'; +} + +function proxyCheckNoticeKind(check: ProxyTargetCheckResponse): Notice['kind'] { + if (!check.ok) return 'error'; + return check.probes.some((probe) => !probe.ok) ? 'info' : 'success'; +} + +function proxyCheckTitle(check: ProxyTargetCheckResponse) { + if (!check.ok) return 'Маршрут не прошел'; + const okCount = check.probes.filter((probe) => probe.ok).length; + if (check.probes.length && okCount < check.probes.length) return 'Маршрут частично отвечает'; + return 'Маршрут отвечает'; +} + +function proxyCheckNoticeTitle(check: ProxyTargetCheckResponse) { + if (!check.ok) return 'Проверка маршрута не прошла'; + const okCount = check.probes.filter((probe) => probe.ok).length; + if (check.probes.length && okCount < check.probes.length) return 'Проверка частично прошла'; + return 'Проверка маршрута прошла'; +} + +function proxyCheckText(check: ProxyTargetCheckResponse) { + const okProbes = check.probes.filter((probe) => probe.ok); + if (!check.ok) return check.error ?? proxyProbeErrorText(check); + if (!check.probes.length) return `${formatHostPort(check.server, check.serverPort)} доступен за ${check.latency ?? 0} ms.`; + + const fastest = okProbes.reduce((current, probe) => { + if (!current) return probe; + if ((probe.latency ?? Number.MAX_SAFE_INTEGER) < (current.latency ?? Number.MAX_SAFE_INTEGER)) return probe; + return current; + }, null); + const ips = uniqueProbeIps(okProbes); + const ipText = ips.length ? `IP ${ips.join(', ')}` : 'IP не распознан'; + const speedText = fastest ? `быстрее ${fastest.name}: ${fastest.latency ?? 0} ms` : `TCP ${check.latency ?? 0} ms`; + + return `Ответили ${okProbes.length}/${check.probes.length}; ${ipText}; ${speedText}.`; +} + +function proxyProbeErrorText(check: ProxyTargetCheckResponse) { + const failed = check.probes.find((probe) => !probe.ok); + return failed?.error ?? `${formatHostPort(check.server, check.serverPort)}: нет ответа`; +} + +function proxyCheckDetails(check: ProxyTargetCheckResponse) { + const details = [ + `SOCKS5: ${formatHostPort(check.server, check.serverPort)}${check.latency != null ? ` · ${check.latency} ms` : ''}`, + ]; + + for (const probe of check.probes) { + if (probe.ok) { + const ip = probe.ip ? ` · IP ${probe.ip}` : ''; + const status = probe.status ? ` · HTTP ${probe.status}` : ''; + const latency = probe.latency != null ? ` · ${probe.latency} ms` : ''; + details.push(`${probe.name}:${ip}${status}${latency}`); + } else { + details.push(`${probe.name}: ${probe.error ?? 'нет ответа'}`); + } + } + + return details; +} + +function proxyCheckProbes(check: ProxyTargetCheckResponse): ConnectionProbeView[] { + return [ + { + id: 'tcp', + label: 'SOCKS', + value: check.latency != null ? `${check.latency} ms` : check.ok ? 'ok' : 'fail', + tone: check.error && !check.ok ? 'error' : 'ok', + }, + ...check.probes.map((probe) => ({ + id: probe.id, + label: shortProbeName(probe.name), + value: probe.ok ? probeValue(probe) : 'fail', + tone: probe.ok ? 'ok' as const : 'error' as const, + })), + ]; +} + +function probeValue(probe: ProxyProbeResponse) { + const latency = probe.latency != null ? `${probe.latency} ms` : 'ok'; + return probe.ip ? `${compactIp(probe.ip)} · ${latency}` : latency; +} + +function uniqueProbeIps(probes: ProxyProbeResponse[]) { + return [...new Set(probes.map((probe) => probe.ip).filter((ip): ip is string => Boolean(ip)))]; +} + +function compactIp(ip: string) { + return ip.length > 20 ? `${ip.slice(0, 10)}...${ip.slice(-6)}` : ip; +} + +function shortProbeName(name: string) { + return name + .replace('Cloudflare ', 'CF ') + .replace('Trace', 'trace') + .replace('Speed', 'speed'); +} + +function changesApplyButtonLabel( isApplying: boolean, - hasUnappliedChanges: boolean, singBoxAction: SingBoxAction | null, + serviceAction: ProxiFyreAction | null, ) { if (isApplying) { if (singBoxAction === 'start') return 'Запускаю sing-box...'; if (singBoxAction === 'stop') return 'Перезапускаю sing-box...'; + if (serviceAction === 'restart') return 'Перезапускаю ProxiFyre...'; return 'Применяю...'; } - if (hasUnappliedChanges) return context === 'proxy' ? 'Применить маршрут' : 'Применить в ProxiFyre'; - return context === 'proxy' ? 'Обновить маршрут' : 'Обновить конфиг'; + return 'Применить изменения'; } function parseProxy(rawValue: string): ParsedProxy { @@ -2141,6 +2299,21 @@ function parseProxy(rawValue: string): ParsedProxy { return { protocol: 'socks5', host, port }; } +function routeProxyCheckTarget( + routeMode: RouteMode, + proxyInput: string, + status: LocalSingBoxStatusResponse | null, +): ParsedProxy { + if (routeMode === 'external') return parseProxy(proxyInput); + if (!status) throw new Error('Состояние Local sing-box не загружено.'); + + return { + protocol: 'socks5', + host: localSingBoxProbeHost(status), + port: status.config.listenPort, + }; +} + function targetForUi(targets: Target[], profile: Profile | undefined) { if (profile) return targets.find((target) => target.id === profile.targetId); return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external'); @@ -2360,16 +2533,6 @@ function noticeFromLocalApply(result: ApplyProfilesResponse, singBoxGeneratedPat }; } -function applyStateText(routeMode: RouteMode, isSingBoxInstalled: boolean, isSingBoxRunning: boolean) { - if (routeMode === 'local-singbox' && isSingBoxInstalled && !isSingBoxRunning) { - return 'Local sing-box сейчас остановлен. При применении клиент сначала запустит службу, затем обновит ProxiFyre.'; - } - if (routeMode === 'local-singbox' && isSingBoxInstalled && isSingBoxRunning) { - return 'При применении клиент обновит конфиг, перезапустит Local sing-box и затем обновит ProxiFyre.'; - } - return 'Нажми «Применить в ProxiFyre». Если служба уже запущена и маршрут не обновился, перезапусти ProxiFyre.'; -} - function upsertComponent(components: ComponentStatus[], component: ComponentStatus) { const index = components.findIndex((current) => current.id === component.id); if (index === -1) return [...components, component]; @@ -2428,9 +2591,12 @@ function pingSummary(results: PingServerResponse[]) { function localSingBoxAddress(status: LocalSingBoxStatusResponse | null) { if (!status) return 'не загружен'; + return formatHostPort(localSingBoxProbeHost(status), status.config.listenPort); +} + +function localSingBoxProbeHost(status: LocalSingBoxStatusResponse) { const host = status.config.listenHost.trim(); - const displayHost = host === '0.0.0.0' || host === '::' || !host ? '127.0.0.1' : host; - return formatHostPort(displayHost, status.config.listenPort); + return host === '0.0.0.0' || host === '::' || !host ? '127.0.0.1' : host; } function lanSingBoxAddress(status: LocalSingBoxStatusResponse | null) { diff --git a/src/styles/app.css b/src/styles/app.css index cf8e19a..35a7ae2 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1,5 +1,7 @@ :root { --app-footer-height: 54px; + --app-change-dock-height: 0px; + --change-row-count: 1; --app-header-row-height: 50px; --app-tab-height: 46px; --app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height)); @@ -630,7 +632,7 @@ button:disabled { min-width: 0; } -.ui-service-text > strong, +.ui-service-title-line > strong, .ui-service-text > span { display: block; overflow-wrap: anywhere; @@ -640,12 +642,22 @@ button:disabled { color: #8d99ae; } +.ui-service-title-line { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.ui-service-title-line > strong { + min-width: 0; +} + .ui-service-inline-actions { display: flex; align-items: center; - flex-wrap: wrap; + flex: 0 0 auto; gap: 6px; - margin-top: 6px; } .ui-service-actions { @@ -680,14 +692,18 @@ button:disabled { height: 100vh; overflow: hidden; background: #101216; - padding: var(--app-header-height) 0 var(--app-footer-height); + padding: var(--app-header-height) 0 calc(var(--app-footer-height) + var(--app-change-dock-height)); +} + +.simple-shell.has-change-dock { + --app-change-dock-height: clamp(60px, calc(20px + (var(--change-row-count) * 26px)), 220px); } .simple-panel { display: grid; align-content: start; min-height: 0; - height: calc(100vh - var(--app-header-height) - var(--app-footer-height)); + height: calc(100vh - var(--app-header-height) - var(--app-footer-height) - var(--app-change-dock-height)); width: 100%; border: 0; border-radius: 0; @@ -871,12 +887,11 @@ button:disabled { } .proxy-overview { - display: grid; - gap: 10px; + display: block; border: 1px solid #2b3342; border-radius: 4px; background: #111720; - padding: 10px; + padding: 8px 10px; } .proxy-overview.ok { @@ -895,63 +910,34 @@ button:disabled { border-color: rgba(59, 130, 246, 0.42); } -.proxy-overview-head { - display: flex; - align-items: end; - justify-content: space-between; - gap: 12px; - min-width: 0; -} - -.proxy-overview-head span { - display: block; - color: #8d99ae; - font-size: 12px; -} - -.proxy-overview-head h2 { - margin: 2px 0 0; - color: #eef2ff; - font-size: 17px; - letter-spacing: 0; -} - -.proxy-overview-head > strong { - min-width: 0; - color: #bfdbfe; - font-size: 13px; - overflow-wrap: anywhere; - text-align: right; -} - .summary-panel { gap: 12px; } -.summary-hero { +.summary-status-control { display: grid; - grid-template-columns: auto minmax(0, 1fr); + grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; - align-items: start; + align-items: center; border: 1px solid #2b3342; border-radius: 4px; background: #151923; - padding: 14px; + padding: 13px 14px; } -.summary-hero.ok { +.summary-status-control.ok { border-color: rgba(34, 197, 94, 0.44); } -.summary-hero.warning { +.summary-status-control.warning { border-color: rgba(245, 158, 11, 0.48); } -.summary-hero.error { +.summary-status-control.error { border-color: rgba(239, 68, 68, 0.52); } -.summary-hero.checking { +.summary-status-control.checking { border-color: rgba(59, 130, 246, 0.54); } @@ -985,14 +971,18 @@ button:disabled { animation: spin 0.75s linear infinite; } -.summary-hero span, +.summary-status-copy span, .summary-card-label { color: #8d99ae; font-size: 12px; font-weight: 700; } -.summary-hero strong { +.summary-status-copy { + min-width: 0; +} + +.summary-status-copy strong { display: block; color: #f8fafc; font-size: 22px; @@ -1000,14 +990,17 @@ button:disabled { margin-top: 2px; } -.summary-hero p { +.summary-status-copy p { color: #b6c2d4; - margin: 5px 0 0; + margin: 4px 0 0; overflow-wrap: anywhere; } -.summary-readout, -.summary-draft { +.summary-status-button { + min-width: 148px; +} + +.summary-readout { border: 1px solid #2b3342; border-radius: 4px; background: #131720; @@ -1021,16 +1014,14 @@ button:disabled { gap: 12px; } -.summary-readout-head span, -.summary-draft span { +.summary-readout-head span { display: block; color: #8d99ae; font-size: 12px; font-weight: 700; } -.summary-readout-head strong, -.summary-draft strong { +.summary-readout-head strong { display: block; color: #f8fafc; font-size: 18px; @@ -1038,24 +1029,6 @@ button:disabled { margin-top: 2px; } -.summary-pill { - flex: 0 0 auto; - border: 1px solid #2b3342; - border-radius: 999px; - background: #1a202b; - padding: 5px 9px; -} - -.summary-pill.ok { - border-color: rgba(34, 197, 94, 0.38); - color: #86efac; -} - -.summary-pill.warning { - border-color: rgba(245, 158, 11, 0.46); - color: #fcd34d; -} - .summary-lines { display: grid; gap: 0; @@ -1090,46 +1063,6 @@ button:disabled { overflow-wrap: anywhere; } -.summary-draft { - display: grid; - gap: 8px; -} - -.summary-draft.ok { - border-color: rgba(34, 197, 94, 0.34); -} - -.summary-draft.warning { - border-color: rgba(245, 158, 11, 0.48); - background: rgba(120, 53, 15, 0.16); -} - -.summary-draft p { - color: #b6c2d4; - margin: 5px 0 0; - overflow-wrap: anywhere; -} - -.summary-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.summary-actions button { - min-height: 34px; - border: 1px solid #343b49; - border-radius: 4px; - background: #242a35; - color: #eef2ff; - padding: 7px 10px; - cursor: pointer; -} - -.summary-actions button:hover { - background: #2d3543; -} - .summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1526,41 +1459,19 @@ button.summary-card:hover { .connection-check { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(180px, 260px) 132px; - gap: 8px; - align-items: stretch; -} - -.connection-check-main, -.connection-endpoint { - display: grid; - align-content: center; - gap: 3px; + grid-template-columns: minmax(190px, 1fr) minmax(220px, auto) minmax(132px, auto) auto; + gap: 10px; + align-items: center; min-width: 0; - min-height: 46px; - border: 1px solid #263040; - border-radius: 4px; - background: #0d1016; - padding: 8px 10px; } -.connection-endpoint { - cursor: pointer; +.connection-check-status { + display: grid; + gap: 1px; + min-width: 0; } -.connection-endpoint:hover { - border-color: #3b82f6; - background: #101827; -} - -.connection-check-main > span, -.connection-endpoint > span { - color: #8d99ae; - font-size: 12px; - font-weight: 700; -} - -.connection-check-main strong, +.connection-check-status strong, .connection-endpoint strong { min-width: 0; color: #eef2ff; @@ -1569,14 +1480,101 @@ button.summary-card:hover { white-space: nowrap; } -.connection-check-main p { +.connection-check-status > span { + color: #8d99ae; + font-size: 11px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +.connection-check-status p { color: #9aa8bd; margin: 0; - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connection-probes { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + min-width: 0; +} + +.connection-probe { + display: inline-grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 6px; + min-height: 24px; + max-width: 210px; + border: 1px solid #263040; + border-radius: 4px; + background: #0d1016; + padding: 3px 7px; + color: #b6c2d4; + font-size: 12px; +} + +.connection-probe.ok { + border-color: rgba(34, 197, 94, 0.28); +} + +.connection-probe.warning { + border-color: rgba(245, 158, 11, 0.34); +} + +.connection-probe.error { + border-color: rgba(239, 68, 68, 0.34); +} + +.connection-probe strong, +.connection-probe span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connection-probe strong { + color: #eef2ff; + font-weight: 800; +} + +.connection-probe.ok strong { + color: #86efac; +} + +.connection-probe.error strong { + color: #fca5a5; +} + +.connection-endpoint { + display: inline-flex; + align-items: center; + justify-content: flex-end; + min-width: 132px; + max-width: 260px; + min-height: 34px; + border: 1px solid #263040; + border-radius: 4px; + background: #0d1016; + cursor: pointer; + padding: 0 10px; +} + +.connection-endpoint:hover { + border-color: #3b82f6; + background: #101827; } .connection-check .ui-button { - width: 100%; + width: auto; + min-width: 104px; + white-space: nowrap; } .external-proxy-card { @@ -1631,18 +1629,19 @@ button.summary-card:hover { .setup-summary { display: inline-grid; - grid-template-columns: auto auto; - gap: 6px; - align-items: center; - width: fit-content; + place-items: center; + width: 22px; + min-width: 22px; + height: 22px; border: 1px solid #263040; - border-radius: 4px; + border-radius: 999px; background: #111720; color: #bfdbfe; cursor: pointer; - padding: 4px 7px; - font-size: 12px; - font-weight: 700; + padding: 0; + font-size: 13px; + font-weight: 900; + line-height: 1; } .setup-summary:hover { @@ -1650,9 +1649,9 @@ button.summary-card:hover { background: #162033; } -.setup-summary strong { +.setup-summary-glyph { color: #dbeafe; - font-size: 11px; + transform: translateY(-0.5px); } .route-switch { @@ -1731,17 +1730,20 @@ button.summary-card:hover { .singbox-workspace-actions { justify-content: flex-end; - gap: 7px; + gap: 6px; } -.singbox-workspace-actions .ui-button { +.singbox-workspace-actions .ui-icon-button { + width: 36px; + min-width: 36px; min-height: 32px; + padding: 0; color: #dbeafe; } .server-list { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 7px; } @@ -1758,6 +1760,13 @@ button.summary-card:hover { padding: 7px 9px; } +.server-row .ui-icon-button { + width: 34px; + min-width: 34px; + min-height: 30px; + padding: 0; +} + .server-row.selected { border-color: #2563eb; background: #17213a; @@ -1771,8 +1780,7 @@ button.summary-card:hover { border-color: rgba(239, 68, 68, 0.42); } -.server-select-button, -.server-ping-button { +.server-select-button { border: 0; background: transparent; color: inherit; @@ -1789,20 +1797,6 @@ button.summary-card:hover { text-align: left; } -.server-ping-button { - display: grid; - place-items: center; - width: 30px; - height: 28px; - border: 1px solid #263040; - border-radius: 4px; - color: #bfdbfe; -} - -.server-ping-button:hover { - background: #1d2735; -} - .server-select-dot { width: 10px; height: 10px; @@ -1922,22 +1916,6 @@ button.summary-card:hover { animation: spin 0.75s linear infinite; } -.apply-state { - display: grid; - gap: 3px; - margin-top: 14px; - border: 1px solid rgba(245, 158, 11, 0.48); - border-radius: 4px; - background: rgba(120, 53, 15, 0.2); - color: #fcd34d; - padding: 10px 12px; -} - -.apply-state span { - color: #d6b875; - overflow-wrap: anywhere; -} - .apps-section { display: grid; gap: 9px; @@ -1950,7 +1928,7 @@ button.summary-card:hover { .apps-header { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(340px, 520px); + grid-template-columns: minmax(0, 1fr); gap: 12px; align-items: center; min-height: 48px; @@ -1962,30 +1940,112 @@ button.summary-card:hover { justify-content: flex-start; } -.apps-config-actions { +.changes-dock { + position: fixed; + right: 0; + bottom: var(--app-footer-height); + left: 0; + z-index: 38; display: grid; - gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; align-items: center; + height: var(--app-change-dock-height); + overflow: hidden; + border-top: 1px solid #2b3342; + background: #141820; + box-shadow: 0 -18px 36px rgba(0, 0, 0, 0.22); + padding: 8px 18px; } -.apps-config-actions .apply-state { - margin: 0; +.changes-dock-main { + display: grid; + align-items: center; + min-width: 0; + min-height: 0; } -.apps-config-actions .command-row { - grid-template-columns: minmax(0, 1fr) 118px; - margin-top: 0; -} - -.apps-config-actions .apply-button, -.apps-config-actions .open-config-button { - height: 48px; - min-height: 48px; -} - -.apps-config-actions .config-path { - margin: 0; +.change-label { color: #8d99ae; + font-size: 12px; + font-weight: 750; +} + +.changes-list { + display: grid; + align-content: start; + gap: 2px; + min-width: 0; + min-height: 0; + overflow: auto; + list-style: none; + margin: 0; + padding: 0; +} + +.change-row { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + gap: 9px; + align-items: center; + min-height: 21px; + padding: 1px 2px; +} + +.change-values { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + color: #a9b4c5; +} + +.change-values span:not(.change-arrow), +.change-values strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.change-values strong { + color: #eef2ff; +} + +.change-row.added .change-values strong { + color: #bbf7d0; +} + +.change-row.removed .change-values strong { + color: #fecaca; +} + +.change-arrow { + flex: 0 0 auto; + color: #64748b; + font-weight: 800; +} + +.changes-actions { + display: grid; + grid-template-columns: 1fr; + align-content: center; + justify-items: end; +} + +.changes-actions .ui-button { + width: auto; + min-width: 178px; + max-width: 220px; + min-height: 36px; + padding-inline: 14px; + white-space: nowrap; +} + +.changes-actions .ui-button-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .section-head span { @@ -2277,38 +2337,10 @@ button.summary-card:hover { animation: shimmer 1.15s linear infinite; } -.command-row { - display: grid; - grid-template-columns: minmax(0, 1fr) 132px; - gap: 8px; - margin-top: 14px; -} - -.command-row .ui-button, .subscription-line .ui-button { width: 100%; } -.apps-config-actions .ui-button { - height: 48px; - min-height: 48px; -} - -.apply-button { - min-height: 46px; - width: 100%; - border: 1px solid #16a34a; - border-radius: 4px; - background: #22c55e; - color: #04130a; - font-weight: 800; - cursor: pointer; -} - -.apply-button:hover { - background: #4ade80; -} - .open-config-button { min-height: 46px; width: 100%; @@ -2618,11 +2650,15 @@ button.summary-card:hover { @media (max-width: 680px) { .simple-shell { - padding: var(--app-header-height) 0 var(--app-footer-height); + padding: var(--app-header-height) 0 calc(var(--app-footer-height) + var(--app-change-dock-height)); + } + + .simple-shell.has-change-dock { + --app-change-dock-height: clamp(104px, calc(82px + (var(--change-row-count) * 25px)), 260px); } .simple-panel { - height: calc(100vh - var(--app-header-height) - var(--app-footer-height)); + height: calc(100vh - var(--app-header-height) - var(--app-footer-height) - var(--app-change-dock-height)); padding: 12px 12px 16px; } @@ -2678,13 +2714,8 @@ button.summary-card:hover { flex-direction: column; } - .proxy-overview-head { - align-items: stretch; - flex-direction: column; - } - - .panel-section-head > strong, - .proxy-overview-head > strong { + .proxy-overview, + .panel-section-head > strong { text-align: left; } @@ -2697,18 +2728,22 @@ button.summary-card:hover { flex-direction: column; } - .summary-pill { - width: fit-content; - } - .summary-lines div { grid-template-columns: 1fr; gap: 4px; } - .summary-actions { - display: grid; + .summary-status-control { grid-template-columns: 1fr; + align-items: stretch; + } + + .summary-status-dot { + display: none; + } + + .summary-status-button { + width: 100%; } .setup-strip { @@ -2725,16 +2760,35 @@ button.summary-card:hover { min-height: 34px; } - .apps-config-actions .command-row { - grid-template-columns: 1fr; - } - .app-add-skeleton { grid-template-columns: 1fr; } .connection-check { grid-template-columns: 1fr; + align-items: stretch; + } + + .connection-probes { + justify-content: stretch; + } + + .connection-probe { + max-width: none; + } + + .connection-check-status p { + white-space: normal; + } + + .connection-endpoint { + width: 100%; + max-width: none; + justify-content: center; + } + + .connection-check .ui-button { + width: 100%; } .route-chain { @@ -2814,19 +2868,41 @@ button.summary-card:hover { } .singbox-workspace-actions { + justify-content: flex-end; width: 100%; } - .singbox-workspace-actions .ui-button { - flex: 1; + .singbox-workspace-actions .ui-icon-button { + width: 36px; + min-width: 36px; } .server-list { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } - .command-row { + .changes-dock { grid-template-columns: 1fr; + grid-template-rows: minmax(0, 1fr) auto; + gap: 8px; + padding: 9px 10px; + } + + .changes-dock-main { + grid-template-columns: 1fr; + } + + .changes-list { + max-height: none; + } + + .change-row { + grid-template-columns: 1fr; + gap: 3px; + } + + .change-values { + min-width: 0; } .log-dock { diff --git a/src/ui/ServiceControlRow.tsx b/src/ui/ServiceControlRow.tsx index b2c3bc8..a071f76 100644 --- a/src/ui/ServiceControlRow.tsx +++ b/src/ui/ServiceControlRow.tsx @@ -59,9 +59,11 @@ export function ServiceControlRow({