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

@@ -48,6 +48,10 @@ The backend owns subscription metadata, servers, desired/applied selection, desi
A consumer must eventually apply only snapshots whose revision is at least its current revision. The frontend comparison and stale/offline transport envelope are intentionally handled by TASK-002 and TASK-003.
The frontend keeps the accepted snapshot in one reducer and replaces it only when `incoming.revision` is greater. Equal revisions preserve object identity so background polling does not replay decorative transitions. Mutation responses are applied directly; polling requests started before a mutation are logically invalidated and cannot overwrite its result. A locally pending server choice remains local until a newer snapshot acknowledges it or removes that server.
Browser transport state lives beside, not inside, the domain snapshot. It records boot status, last successful sync time and consecutive failures. Three failed polls mark the retained snapshot stale; the next successful GET or mutation clears that marker. An initial failure shows `control-unreachable`, `incompatible-api` or `fatal` without inventing domain state.
## Desired and applied state
`selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded.

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

View File

@@ -8,7 +8,9 @@ async function request(url, options = {}) {
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data?.success === false) {
throw new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`);
const error = new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`);
error.status = response.status;
throw error;
}
return data;
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
const bootCopy = {
'control-unreachable': {
title: 'Harbor недоступен',
message: 'Control plane не ответил. Проверьте, что контейнер запущен, и повторите запрос.',
},
'incompatible-api': {
title: 'Версия Harbor несовместима',
message: 'Интерфейс получил state неизвестной версии. Обновите frontend и control plane вместе.',
},
fatal: {
title: 'Harbor не удалось запустить',
message: 'Произошла непредвиденная ошибка. Технические детали помогут найти причину.',
},
};
export function BootStatePage({ transport, onRetry }) {
if (transport.bootStatus === 'loading') return <div className="app-loading">Harbor</div>;
const copy = bootCopy[transport.bootStatus] || bootCopy.fatal;
return (
<main className="app-boot">
<span>Harbor</span>
<h1>{copy.title}</h1>
<p>{copy.message}</p>
<button type="button" onClick={onRetry}>Повторить</button>
<details>
<summary>Технические детали</summary>
<code>{transport.error?.message}</code>
<pre>{`curl -i ${window.location.origin}/api/state\ndocker compose logs --tail=100`}</pre>
</details>
</main>
);
}
export function StaleBanner({ transport, onRetry }) {
if (!transport.stale) return null;
const lastSync = transport.lastSuccessfulSyncAt
? new Date(transport.lastSuccessfulSyncAt).toLocaleTimeString('ru-RU')
: 'неизвестно';
const incompatible = transport.error?.kind === 'incompatible-api';
return (
<aside className="client-stale-banner" role="status">
<strong>{incompatible ? 'API несовместим' : 'Показано последнее известное состояние'}</strong>
<span>Последняя синхронизация: {lastSync}</span>
<button type="button" onClick={onRetry}>Повторить</button>
</aside>
);
}

View File

@@ -0,0 +1,92 @@
export const initialHarborState = {
snapshot: null,
pendingServerId: '',
transport: {
bootStatus: 'loading',
lastSuccessfulSyncAt: null,
consecutiveFailures: 0,
stale: false,
error: null,
},
};
export const STALE_FAILURE_THRESHOLD = 3;
export function compatibleSnapshot(snapshot) {
return snapshot?.apiVersion === 1 &&
Number.isSafeInteger(snapshot.revision) &&
typeof snapshot.selection?.desiredServerId === 'string' &&
Array.isArray(snapshot.servers);
}
export function classifySyncError(error) {
const status = Number(error?.status) || 0;
if (error?.code === 'INCOMPATIBLE_API' || status === 404) return 'incompatible-api';
if (error?.name === 'TypeError' || status >= 500) return 'control-unreachable';
return 'fatal';
}
function reconcilePendingServer(pendingServerId, snapshot) {
if (!pendingServerId || snapshot.selection.desiredServerId === pendingServerId) return '';
return snapshot.servers.some((server) => server.id === pendingServerId)
? pendingServerId
: '';
}
export function harborReducer(current, action) {
if (action.type === 'select-server') {
return action.serverId === current.pendingServerId
? current
: { ...current, pendingServerId: action.serverId };
}
if (action.type === 'clear-pending-server') {
return current.pendingServerId ? { ...current, pendingServerId: '' } : current;
}
if (action.type === 'retry-sync') {
return current.snapshot ? current : {
...current,
transport: { ...current.transport, bootStatus: 'loading', error: null },
};
}
if (action.type === 'sync-failed') {
const consecutiveFailures = current.transport.consecutiveFailures + 1;
const bootStatus = classifySyncError(action.error);
return {
...current,
transport: {
...current.transport,
bootStatus: current.snapshot ? 'ready' : bootStatus,
consecutiveFailures,
stale: Boolean(current.snapshot) && (
bootStatus === 'incompatible-api' || consecutiveFailures >= STALE_FAILURE_THRESHOLD
),
error: {
kind: bootStatus,
message: action.error?.message || 'Неизвестная ошибка',
},
},
};
}
if (action.type !== 'sync-succeeded') return current;
const snapshot = action.snapshot;
const newer = !current.snapshot || snapshot.revision > current.snapshot.revision;
return {
snapshot: newer ? snapshot : current.snapshot,
pendingServerId: newer
? reconcilePendingServer(current.pendingServerId, snapshot)
: current.pendingServerId,
transport: {
bootStatus: 'ready',
lastSuccessfulSyncAt: action.receivedAt,
consecutiveFailures: 0,
stale: false,
error: null,
},
};
}

View File

@@ -49,6 +49,100 @@ p {
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
}
.app-boot {
min-height: 100vh;
display: grid;
place-content: center;
justify-items: start;
gap: 14px;
padding: 32px;
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
color: light-dark(oklch(0.24 0.014 145), oklch(0.93 0.008 145));
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
}
.app-boot > span,
.app-boot summary {
color: light-dark(oklch(0.53 0.014 145), oklch(0.68 0.012 145));
font-size: 11px;
}
.app-boot h1 {
max-width: 22ch;
margin: 0;
font-size: clamp(22px, 5vw, 34px);
letter-spacing: -0.05em;
}
.app-boot p {
max-width: 58ch;
color: light-dark(oklch(0.53 0.014 145), oklch(0.68 0.012 145));
font-size: 12px;
line-height: 1.7;
}
.app-boot button,
.client-stale-banner button {
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: light-dark(oklch(0.91 0.02 185), oklch(0.28 0.03 185));
cursor: pointer;
}
.app-boot button:focus-visible,
.client-stale-banner button:focus-visible {
outline: 2px solid oklch(0.65 0.11 185);
outline-offset: 3px;
}
.app-boot details {
max-width: min(640px, calc(100vw - 64px));
margin-top: 8px;
}
.app-boot summary {
cursor: pointer;
}
.app-boot code,
.app-boot pre {
display: block;
overflow-x: auto;
margin: 12px 0 0;
font-size: 11px;
line-height: 1.6;
white-space: pre-wrap;
}
.client-stale-banner {
position: fixed;
top: 16px;
left: 50%;
z-index: 60;
display: grid;
grid-template-columns: auto auto auto;
align-items: center;
gap: 12px;
padding: 9px 10px 9px 14px;
border-radius: 12px;
background: color-mix(in oklch, var(--client-panel) 90%, transparent);
box-shadow: 0 10px 32px oklch(0.08 0.015 145 / 0.14);
backdrop-filter: blur(12px);
color: var(--client-muted);
font: 600 10px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
transform: translateX(-50%);
}
.client-stale-banner strong {
color: var(--client-text);
}
.client-stale-banner button {
color: var(--client-text);
font-size: 10px;
}
/* ============ Client overview ============ */
.app.client-app {
@@ -1897,6 +1991,16 @@ p {
padding: 20px 14px;
}
.client-stale-banner {
width: calc(100% - 28px);
grid-template-columns: 1fr auto;
}
.client-stale-banner span {
grid-column: 1 / -1;
grid-row: 2;
}
.client-panel {
position: static;
grid-template-columns: 1fr;

View File

@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
classifySyncError,
harborReducer,
initialHarborState,
} from '../../src/web/state/harborReducer.js';
const snapshot = (revision, desiredServerId = '', serverIds = ['one', 'two']) => ({
apiVersion: 1,
revision,
selection: { desiredServerId },
servers: serverIds.map((id) => ({ id })),
});
const receive = (state, value) => harborReducer(state, {
type: 'sync-succeeded',
snapshot: value,
receivedAt: `2026-07-11T12:00:0${value.revision}.000Z`,
});
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
test('an older polling promise cannot replace a newer mutation snapshot', async () => {
let state = receive(initialHarborState, snapshot(1, 'one'));
const poll = deferred();
const mutation = deferred();
const apply = (promise) => promise.then((value) => { state = receive(state, value); });
const pollApplied = apply(poll.promise);
const mutationApplied = apply(mutation.promise);
mutation.resolve(snapshot(3, 'two'));
await mutationApplied;
poll.resolve(snapshot(2, 'one'));
await pollApplied;
assert.equal(state.snapshot.revision, 3);
assert.equal(state.snapshot.selection.desiredServerId, 'two');
});
test('an equal revision keeps the current snapshot identity', () => {
const state = receive(initialHarborState, snapshot(4, 'one'));
const next = receive(state, snapshot(4, 'two'));
assert.equal(next.snapshot, state.snapshot);
assert.equal(next.snapshot.selection.desiredServerId, 'one');
});
test('pending selection survives polling until canonical state acknowledges it', () => {
let state = receive(initialHarborState, snapshot(1, 'one'));
state = harborReducer(state, { type: 'select-server', serverId: 'two' });
state = receive(state, snapshot(2, 'one'));
assert.equal(state.pendingServerId, 'two');
state = receive(state, snapshot(3, 'two'));
assert.equal(state.pendingServerId, '');
});
test('pending selection is cleared when its server disappears', () => {
let state = receive(initialHarborState, snapshot(1, 'one'));
state = harborReducer(state, { type: 'select-server', serverId: 'two' });
assert.equal(receive(state, snapshot(2, 'one', ['one'])).pendingServerId, '');
});
test('initial 500 and connection refusal become retryable boot failures', () => {
const serverError = Object.assign(new Error('Internal Server Error'), { status: 500 });
const refused = new TypeError('fetch failed');
assert.equal(classifySyncError(serverError), 'control-unreachable');
assert.equal(classifySyncError(refused), 'control-unreachable');
const failed = harborReducer(initialHarborState, { type: 'sync-failed', error: serverError });
assert.equal(failed.transport.bootStatus, 'control-unreachable');
assert.equal(failed.snapshot, null);
});
test('ready state becomes stale after three failures and recovers on success', () => {
const known = snapshot(1, 'one');
let state = receive(initialHarborState, known);
const error = new TypeError('fetch failed');
for (let attempt = 0; attempt < 3; attempt += 1) {
state = harborReducer(state, { type: 'sync-failed', error });
}
assert.equal(state.snapshot, known);
assert.equal(state.transport.stale, true);
assert.equal(state.transport.consecutiveFailures, 3);
state = receive(state, snapshot(2, 'two'));
assert.equal(state.transport.stale, false);
assert.equal(state.transport.consecutiveFailures, 0);
assert.equal(state.snapshot.selection.desiredServerId, 'two');
});