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 [versionInfo, setVersionInfo] = 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(() => { 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 loadState({ retry: true })} />; return (
loadState({ retry: true })} />
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')} onSaveRouteRules={(rules, expectedRevision) => run( 'routeRules', () => api.routeRules.update(rules, expectedRevision), 'routing', )} onDismissError={() => setError(null)} />
); } createRoot(document.getElementById('root')).render();