Handle stale sync state in client UI
Some checks failed
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Failing after 1m21s

This commit is contained in:
2026-07-11 19:31:35 +03:00
parent a775d8456a
commit 198669694c
7 changed files with 410 additions and 26 deletions

View File

@@ -1,33 +1,53 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useReducer, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import { api } from './api.js';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx';
import {
compatibleSnapshot,
harborReducer,
initialHarborState,
} from './state/harborReducer.js';
function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [state, setState] = useState(null);
const [{ snapshot: state, pendingServerId: pendingTag, transport }, dispatch] = useReducer(
harborReducer,
initialHarborState,
);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const pollGeneration = useRef(0);
function syncState(data) {
setState(data);
setPendingTag((current) => (
(data.servers || []).some((server) => server.tag === current)
? current
: data.selectedTag || ''
));
function setPendingTag(serverId) {
dispatch({ type: 'select-server', serverId });
}
async function loadState() {
syncState(await api.state());
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().catch((err) => setError(err.message));
const timer = setInterval(() => loadState().catch(() => {}), 5000);
loadState();
const timer = setInterval(loadState, 5000);
return () => clearInterval(timer);
}, []);
@@ -44,10 +64,7 @@ function App() {
setBusy(true);
setError('');
try {
const result = await action();
if (result?.state) syncState(result.state);
else await loadState();
return result;
return await applyMutation(action);
} catch (err) {
setError(err.message);
throw err;
@@ -56,34 +73,50 @@ function App() {
}
}
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(async () => {
const data = await api.subscription.fetch(subscriptionUrl);
setPendingTag('');
dispatch({ type: 'clear-pending-server' });
return data;
});
}
async function refreshSubscription() {
const data = await api.subscription.refresh();
syncState(data.state || data);
setPendingTag(data.selectedTag || '');
return data;
try {
return await applyMutation(api.subscription.refresh);
} catch (refreshError) {
setError(refreshError.message);
throw refreshError;
}
}
async function forgetSubscription() {
return run(async () => {
const data = await api.subscription.forget();
setSubscriptionUrl('');
setPendingTag('');
dispatch({ type: 'clear-pending-server' });
return data;
});
}
if (!state) return <div className="app-loading">Harbor</div>;
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