190 lines
6.9 KiB
React
190 lines
6.9 KiB
React
import React, { useEffect, useReducer, useRef, useState } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import './styles.css';
|
|
import { api, HarborApiError } from './api.js';
|
|
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
|
import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx';
|
|
import {
|
|
compatibleSnapshot,
|
|
harborReducer,
|
|
initialHarborState,
|
|
} from './state/harborReducer.js';
|
|
import { createOperationRegistry } from './state/operations.js';
|
|
|
|
function App() {
|
|
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
|
|
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
|
|
harborReducer,
|
|
initialHarborState,
|
|
);
|
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
|
const [operations, setOperations] = useState({});
|
|
const [error, setError] = useState(null);
|
|
const [versionInfo, setVersionInfo] = useState(null);
|
|
const pollGeneration = useRef(0);
|
|
const operationRegistry = useRef(null);
|
|
if (!operationRegistry.current) {
|
|
operationRegistry.current = createOperationRegistry(setOperations);
|
|
}
|
|
|
|
function setPendingServerId(serverId) {
|
|
dispatch({ type: 'select-server', serverId });
|
|
}
|
|
|
|
async function loadState({ retry = false } = {}) {
|
|
if (retry) dispatch({ type: 'retry-sync' });
|
|
const generation = pollGeneration.current;
|
|
try {
|
|
const snapshot = await api.state();
|
|
if (!compatibleSnapshot(snapshot)) {
|
|
const incompatible = new Error('Ожидался Harbor state apiVersion 1');
|
|
incompatible.code = 'INCOMPATIBLE_API';
|
|
throw incompatible;
|
|
}
|
|
if (generation === pollGeneration.current) {
|
|
dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
|
|
}
|
|
} catch (requestError) {
|
|
if (generation === pollGeneration.current) {
|
|
dispatch({ type: 'sync-failed', error: requestError });
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadState();
|
|
const timer = setInterval(loadState, 5000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
api.version().then((info) => {
|
|
if (!cancelled) setVersionInfo(info);
|
|
}).catch((requestError) => {
|
|
console.warn(`[version] Не удалось получить runtime-версию: ${requestError.message}`);
|
|
if (!cancelled) setVersionInfo(null);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!state?.mode) return;
|
|
const isGateway = state.mode === 'gateway';
|
|
document.title = isGateway ? 'Harbor Gateway' : 'Harbor Connect';
|
|
document.getElementById('harbor-favicon').href = isGateway
|
|
? '/harbor-gateway.svg?v=2'
|
|
: '/harbor-connect.svg?v=2';
|
|
}, [state?.mode]);
|
|
|
|
function run(key, action, context) {
|
|
setError(null);
|
|
return operationRegistry.current.run(key, async () => {
|
|
try {
|
|
return await applyMutation(action);
|
|
} catch (err) {
|
|
const safeError = err instanceof HarborApiError
|
|
? err
|
|
: new HarborApiError({ code: err?.code }, err?.status);
|
|
setError({
|
|
context,
|
|
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
|
|
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
|
|
: safeError.message,
|
|
code: safeError.code,
|
|
correlationId: safeError.correlationId,
|
|
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
|
|
? () => run(key, action, context)
|
|
: null,
|
|
});
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function applyMutation(action) {
|
|
pollGeneration.current += 1;
|
|
const result = await action();
|
|
if (!result?.state) throw new Error('Harbor API не вернул state snapshot');
|
|
if (!compatibleSnapshot(result.state)) throw new Error('Harbor API не вернул state snapshot v1');
|
|
dispatch({
|
|
type: 'sync-succeeded',
|
|
snapshot: result.state,
|
|
receivedAt: new Date().toISOString(),
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function fetchSubscription() {
|
|
return run('subscriptionImport', async () => {
|
|
const data = await api.subscription.fetch(subscriptionUrl);
|
|
dispatch({ type: 'clear-pending-server' });
|
|
return data;
|
|
}, 'subscription');
|
|
}
|
|
|
|
async function refreshSubscription() {
|
|
return run('subscriptionRefresh', api.subscription.refresh, 'subscription');
|
|
}
|
|
|
|
async function forgetSubscription() {
|
|
return run('subscriptionDelete', async () => {
|
|
const data = await api.subscription.forget();
|
|
setSubscriptionUrl('');
|
|
dispatch({ type: 'clear-pending-server' });
|
|
return data;
|
|
}, 'subscription');
|
|
}
|
|
|
|
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
|
|
|
|
return (
|
|
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
|
|
<StaleBanner transport={transport} onRetry={() => loadState({ retry: true })} />
|
|
<div className="app-body client-mode">
|
|
<main className="app-main">
|
|
<ClientOverviewPage
|
|
state={previewReady ? {
|
|
...state,
|
|
mode: 'client',
|
|
hasSubscription: true,
|
|
subscriptionHost: 'harbor.example',
|
|
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
|
|
proxyPort: 8082,
|
|
} : state}
|
|
versionInfo={versionInfo}
|
|
operations={operations}
|
|
error={error}
|
|
subscriptionUrl={subscriptionUrl}
|
|
setSubscriptionUrl={setSubscriptionUrl}
|
|
servers={previewReady ? [{
|
|
id: 'preview-amsterdam',
|
|
label: 'Amsterdam',
|
|
host: '127.0.0.1',
|
|
port: 443,
|
|
protocol: 'vless',
|
|
}] : state.servers || []}
|
|
pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId}
|
|
setPendingServerId={setPendingServerId}
|
|
onFetchSubscription={fetchSubscription}
|
|
onRefreshSubscription={refreshSubscription}
|
|
onForgetSubscription={forgetSubscription}
|
|
onApply={(serverId) => run('serverApply', () => api.apply(serverId), 'connection')}
|
|
onRestart={() => run('connection', api.singbox.restart, 'connection')}
|
|
onStop={() => run('connection', api.singbox.stop, 'connection')}
|
|
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
|
|
onSaveRouteRules={(rules, expectedRevision) => run(
|
|
'routeRules',
|
|
() => api.routeRules.update(rules, expectedRevision),
|
|
'routing',
|
|
)}
|
|
onDismissError={() => setError(null)}
|
|
/>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
createRoot(document.getElementById('root')).render(<App />);
|