Handle stale sync state in client UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
51
src/web/components/SyncStatus.jsx
Normal file
51
src/web/components/SyncStatus.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/web/state/harborReducer.js
Normal file
92
src/web/state/harborReducer.js
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user