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'); +});