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

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;