diff --git a/docs/product/application-state.md b/docs/product/application-state.md
index 0c07e69..a539daa 100644
--- a/docs/product/application-state.md
+++ b/docs/product/application-state.md
@@ -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.
diff --git a/src/web/App.jsx b/src/web/App.jsx
index aa8de67..b2afdb7 100644
--- a/src/web/App.jsx
+++ b/src/web/App.jsx
@@ -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
Harbor
;
+ if (!state) return loadState({ retry: true })} />;
return (
+
loadState({ retry: true })} />
({}));
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;
}
diff --git a/src/web/components/SyncStatus.jsx b/src/web/components/SyncStatus.jsx
new file mode 100644
index 0000000..5c9bbdf
--- /dev/null
+++ b/src/web/components/SyncStatus.jsx
@@ -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 Harbor
;
+
+ const copy = bootCopy[transport.bootStatus] || bootCopy.fatal;
+ return (
+
+ Harbor
+ {copy.title}
+ {copy.message}
+
+
+ Технические детали
+ {transport.error?.message}
+ {`curl -i ${window.location.origin}/api/state\ndocker compose logs --tail=100`}
+
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/src/web/state/harborReducer.js b/src/web/state/harborReducer.js
new file mode 100644
index 0000000..b8d2bc3
--- /dev/null
+++ b/src/web/state/harborReducer.js
@@ -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,
+ },
+ };
+}
diff --git a/src/web/styles.css b/src/web/styles.css
index 774525b..13ac727 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -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;
diff --git a/test/web/harbor-state.test.js b/test/web/harbor-state.test.js
new file mode 100644
index 0000000..e193775
--- /dev/null
+++ b/test/web/harbor-state.test.js
@@ -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');
+});