Files
harbor-net/src/web/App.jsx
Dmitriy Petrov 74660d915f
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s
Track client operations and show inline progress
2026-07-11 20:55:20 +03:00

154 lines
5.6 KiB
JavaScript

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: pendingTag, transport }, dispatch] = useReducer(
harborReducer,
initialHarborState,
);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [operations, setOperations] = useState({});
const [error, setError] = useState(null);
const pollGeneration = useRef(0);
const operationRegistry = useRef(null);
if (!operationRegistry.current) {
operationRegistry.current = createOperationRegistry(setOperations);
}
function setPendingTag(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(() => {
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: safeError.message,
code: safeError.code,
correlationId: safeError.correlationId,
retry: safeError.retryable ? () => 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', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
operations={operations}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : state.servers || []}
pendingTag={previewReady ? 'Amsterdam' : pendingTag}
setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onApply={(tag) => run('serverApply', () => api.apply(tag), 'connection')}
onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
/>
</main>
</div>
</div>
);
}
createRoot(document.getElementById('root')).render(<App />);