Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { copyText } from '../../utils/clientControls.js';
|
||||
import { instructionBlocks } from './instructionBlocks.js';
|
||||
|
||||
interface InstructionLinkStep {
|
||||
before?: string;
|
||||
link: [string, string];
|
||||
after?: string;
|
||||
}
|
||||
|
||||
interface InstructionCopyAction {
|
||||
id: string;
|
||||
label: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface InstructionBlockData {
|
||||
id: string;
|
||||
label: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
paragraphs?: string[];
|
||||
steps?: Array<string | InstructionLinkStep>;
|
||||
code?: string;
|
||||
multilineCode?: boolean;
|
||||
copies?: InstructionCopyAction[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface InstructionsFeatureOptions {
|
||||
isGateway: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
controlHost: string;
|
||||
}
|
||||
|
||||
function InstructionStep({ step }: { step: string | InstructionLinkStep }) {
|
||||
if (typeof step === 'string') return step;
|
||||
return (
|
||||
<>
|
||||
{step.before}
|
||||
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
|
||||
{step.after}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InstructionBlock({
|
||||
block,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
block: InstructionBlockData;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
}, []);
|
||||
|
||||
async function copyInstruction(action: InstructionCopyAction) {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
try {
|
||||
await copyText(action.text);
|
||||
setCopyFeedback({ id: action.id, failed: false });
|
||||
} catch {
|
||||
setCopyFeedback({ id: action.id, failed: true });
|
||||
}
|
||||
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`client-instruction-block${open ? ' is-open' : ''}`}
|
||||
style={{ viewTransitionName: `instruction-${block.id}` }}
|
||||
>
|
||||
<button
|
||||
className="client-instruction-summary"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span>{block.label}</span>
|
||||
<strong>{block.title}</strong>
|
||||
<small>{block.summary}</small>
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
|
||||
<div className="client-instruction-body">
|
||||
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
{block.steps && (
|
||||
<ol>
|
||||
{block.steps.map((step) => (
|
||||
<li key={typeof step === 'string' ? step : step.link[1]}>
|
||||
<InstructionStep step={step} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{block.code && (block.multilineCode
|
||||
? <pre className="client-instruction-code"><code>{block.code}</code></pre>
|
||||
: <code>{block.code}</code>)}
|
||||
{block.copies && <div className="client-instruction-copies">
|
||||
{block.copies.map((action) => {
|
||||
const feedback = copyFeedback?.id === action.id ? copyFeedback : null;
|
||||
return <div className="client-instruction-copy" key={action.id}>
|
||||
<span>{action.label}</span>
|
||||
<button
|
||||
className={`client-copy-button client-instruction-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
type="button"
|
||||
onClick={() => copyInstruction(action)}
|
||||
>
|
||||
<span className="client-copy-label">Скопировать</span>
|
||||
{feedback && <span className="client-copy-feedback" aria-hidden="true">
|
||||
{feedback.failed ? 'Ошибка' : 'Скопировано'}
|
||||
</span>}
|
||||
</button>
|
||||
</div>;
|
||||
})}
|
||||
<span className="client-live-region" role="status" aria-live="polite">
|
||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||
</span>
|
||||
</div>}
|
||||
{block.note && <p className="client-instruction-note">{block.note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function useInstructionsFeature({
|
||||
isGateway,
|
||||
host,
|
||||
port,
|
||||
controlHost,
|
||||
}: InstructionsFeatureOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [openInstructionId, setOpenInstructionId] = useState('');
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const [intro, ...guides] = instructionBlocks({ isGateway, host, port, controlHost }) as InstructionBlockData[];
|
||||
const openInstruction = guides.find((block) => block.id === openInstructionId);
|
||||
const orderedGuides = openInstruction
|
||||
? [openInstruction, ...guides.filter((block) => block.id !== openInstructionId)]
|
||||
: guides;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
requestAnimationFrame(() => {
|
||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
if (panelRef.current?.contains(event.target as Node)) return;
|
||||
if (toggleRef.current?.contains(event.target as Node)) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', closeOutside);
|
||||
return () => document.removeEventListener('pointerdown', closeOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
function toggleInstruction(id: string) {
|
||||
const update = () => flushSync(() => {
|
||||
setOpenInstructionId((current) => current === id ? '' : id);
|
||||
});
|
||||
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
document.startViewTransition(update);
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
openInstructionId,
|
||||
intro,
|
||||
guides: orderedGuides,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
close: () => setIsOpen(false),
|
||||
toggle: () => setIsOpen((open) => !open),
|
||||
toggleInstruction,
|
||||
};
|
||||
}
|
||||
|
||||
export type InstructionsFeature = ReturnType<typeof useInstructionsFeature>;
|
||||
|
||||
export function InstructionsToggle({
|
||||
feature,
|
||||
onToggle,
|
||||
}: {
|
||||
feature: InstructionsFeature;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-instructions"
|
||||
aria-label={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" />
|
||||
<path d="M12 11v5M12 8h.01" />
|
||||
</svg>
|
||||
<span>Как использовать</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
export function InstructionsPanel({
|
||||
feature,
|
||||
isGateway,
|
||||
}: {
|
||||
feature: InstructionsFeature;
|
||||
isGateway: boolean;
|
||||
}) {
|
||||
return <aside
|
||||
ref={feature.panelRef}
|
||||
id="client-instructions"
|
||||
className={`client-drawer client-instructions${feature.isOpen ? ' is-open' : ''}`}
|
||||
aria-labelledby="instructions-title"
|
||||
aria-hidden={!feature.isOpen}
|
||||
inert={!feature.isOpen ? true : undefined}
|
||||
>
|
||||
<div className="client-drawer-sheet client-instructions-sheet">
|
||||
<button
|
||||
ref={feature.closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть инструкции"
|
||||
onClick={feature.close}
|
||||
>×</button>
|
||||
<header className="client-instructions-header">
|
||||
<span>Подключение</span>
|
||||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||
<div className="client-instructions-intro">
|
||||
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="client-instruction-list">
|
||||
{feature.guides.map((block) => (
|
||||
<InstructionBlock
|
||||
block={block}
|
||||
key={block.id}
|
||||
open={block.id === feature.openInstructionId}
|
||||
onToggle={() => feature.toggleInstruction(block.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
InstructionsPanel,
|
||||
InstructionsToggle,
|
||||
useInstructionsFeature,
|
||||
type InstructionsFeature,
|
||||
} from './InstructionsFeature.js';
|
||||
@@ -0,0 +1,116 @@
|
||||
import { grafanaDashboardJson, prometheusScrapeConfig } from './prometheus.js';
|
||||
|
||||
export function instructionBlocks({ isGateway, host, port, controlHost }: {
|
||||
isGateway: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
controlHost: string;
|
||||
}) {
|
||||
const httpProxy = `http://${host}:${port}`;
|
||||
const socksProxy = `socks5://${host}:${port}`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'about',
|
||||
label: 'Основы',
|
||||
title: isGateway ? 'Gateway и прокси' : 'Что такое прокси',
|
||||
summary: isGateway
|
||||
? 'Два способа направить трафик через это устройство.'
|
||||
: 'Способ направить трафик выбранного приложения через VPN.',
|
||||
paragraphs: isGateway
|
||||
? [
|
||||
`Gateway (${host}) заменяет основной шлюз устройства и проводит через VPN весь его интернет-трафик.`,
|
||||
`Gateway Proxy (${host}:${port}) работает точечно: его указывают в браузере, редакторе или другом приложении. Если приложение не умеет работать с прокси, можно использовать ProxyBridge.`,
|
||||
]
|
||||
: [
|
||||
`Локальный прокси (${host}:${port}) не перенаправляет приложения автоматически. Каждое приложение должно использовать этот адрес само — напрямую или через ProxyBridge.`,
|
||||
'HTTP обычно проще для браузеров и редакторов. SOCKS5 подходит приложениям и инструментам, которым нужен более универсальный транспорт.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'proxybridge',
|
||||
label: 'Приложения',
|
||||
title: 'ProxyBridge',
|
||||
summary: 'Направляет через прокси отдельные приложения, даже если у них нет своей настройки.',
|
||||
steps: [
|
||||
{
|
||||
link: ['Установите ProxyBridge', 'https://interceptsuite.com/download/proxybridge'],
|
||||
after: ' с официальной страницы проекта.',
|
||||
},
|
||||
`Добавьте прокси типа SOCKS5: сервер ${host}, порт ${port}.`,
|
||||
'Создайте правило, выберите нужное приложение и действие Proxy.',
|
||||
'Включите ProxyBridge и запустите приложение заново.',
|
||||
],
|
||||
note: 'Не добавляйте в правило сам ProxyBridge и VPN-клиент: это может создать прокси-цикл.',
|
||||
},
|
||||
{
|
||||
id: 'switchyomega',
|
||||
label: 'Браузер',
|
||||
title: 'SwitchyOmega',
|
||||
summary: 'Переключает прокси-профили только для браузера.',
|
||||
steps: [
|
||||
{
|
||||
link: ['Установите расширение', 'https://chromewebstore.google.com/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif'],
|
||||
after: ' и откройте его настройки.',
|
||||
},
|
||||
'Создайте профиль Proxy Profile.',
|
||||
`Выберите HTTP, укажите сервер ${host} и порт ${port}.`,
|
||||
'Создайте профиль Auto Switch, выберите созданный прокси для нужных сайтов, а для остальных оставьте Direct.',
|
||||
'Если сайт не загрузился, откройте SwitchyOmega: расширение покажет проблемные ресурсы. Добавьте домен текущего сайта в Auto Switch и назначьте ему прокси-профиль.',
|
||||
],
|
||||
note: 'Проект больше не поддерживается. Используйте его только если расширение уже подходит вашему браузеру.',
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
label: 'Редактор',
|
||||
title: 'Visual Studio Code',
|
||||
summary: 'VS Code использует системный прокси или адрес, переданный при запуске.',
|
||||
steps: [
|
||||
'Если прокси уже настроен в системе, полностью перезапустите VS Code — обычно он подхватит настройку автоматически.',
|
||||
'Для отдельного запуска через SOCKS5 используйте команду ниже.',
|
||||
{
|
||||
link: ['Документация VS Code', 'https://code.visualstudio.com/docs/setup/network'],
|
||||
after: ' описывает также системный прокси, HTTP и параметры исключений.',
|
||||
},
|
||||
],
|
||||
code: `code --proxy-server="${socksProxy}"`,
|
||||
note: `VS Code не поддерживает логин и пароль для SOCKS5. Здесь прокси ${host}:${port} локальный и без авторизации, поэтому этот вариант подходит. HTTP-адрес ${httpProxy} остаётся альтернативой.`,
|
||||
},
|
||||
...(isGateway ? [{
|
||||
id: 'router',
|
||||
label: 'Вся сеть',
|
||||
title: 'Заменить Gateway в роутере',
|
||||
summary: 'Роутер будет выдавать этот Gateway устройствам как основной шлюз.',
|
||||
steps: [
|
||||
`Закрепите за Gateway постоянный адрес ${host} в настройках DHCP роутера.`,
|
||||
'Откройте настройки локальной сети или DHCP. Не меняйте шлюз WAN/интернет-подключения.',
|
||||
`В поле Default Gateway, Router или Основной шлюз укажите ${host}.`,
|
||||
'Сохраните настройки и переподключите устройства к сети, чтобы они получили новый маршрут.',
|
||||
`Для отката верните в это поле локальный адрес самого роутера вместо ${host}.`,
|
||||
],
|
||||
note: 'Gateway и устройства должны находиться в одной локальной сети. Сначала проверьте настройку на одном устройстве вручную.',
|
||||
}, {
|
||||
id: 'prometheus',
|
||||
label: 'Мониторинг',
|
||||
title: 'Prometheus и Grafana',
|
||||
summary: 'Готовые traffic и domain metrics для Gateway и отдельных устройств.',
|
||||
paragraphs: [
|
||||
`Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`,
|
||||
'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.',
|
||||
'Единый фильтр «Устройства» управляет скоростью, накопленным трафиком, сервисами и доменами для всех или одного устройства. Отдельный график показывает текущую скорость каждого активного устройства; нулевые series скрыты.',
|
||||
],
|
||||
steps: [
|
||||
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
|
||||
'В Grafana добавьте этот Prometheus как data source.',
|
||||
'Скопируйте dashboard JSON, откройте Dashboards → New → Import и вставьте его.',
|
||||
],
|
||||
code: prometheusScrapeConfig(controlHost),
|
||||
multilineCode: true,
|
||||
copies: [
|
||||
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
|
||||
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
|
||||
],
|
||||
note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками, устройства с policy Direct и трафик без распознанного домена в domain series не входят.',
|
||||
}] : []),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import dashboard from '../../../../monitoring/grafana/harbor-gateway.json' with { type: 'json' };
|
||||
|
||||
export const grafanaDashboardJson = JSON.stringify(dashboard, null, 2);
|
||||
|
||||
export function prometheusScrapeConfig(controlHost: string) {
|
||||
return `scrape_configs:
|
||||
- job_name: harbor_gateway
|
||||
scrape_interval: 30s
|
||||
scrape_timeout: 3s
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["${controlHost}"]`;
|
||||
}
|
||||
Reference in New Issue
Block a user