Unify Harbor error handling across server and client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 12s

This commit is contained in:
2026-07-11 20:38:41 +03:00
parent 457dd912d1
commit 9da4fef1f0
16 changed files with 493 additions and 100 deletions

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useReducer, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import { api } from './api.js';
import { api, HarborApiError } from './api.js';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx';
import {
@@ -18,7 +18,7 @@ function App() {
);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState(null);
const pollGeneration = useRef(0);
function setPendingTag(serverId) {
@@ -60,14 +60,21 @@ function App() {
: '/harbor-connect.svg?v=2';
}, [state?.mode]);
async function run(action) {
async function run(action, context) {
setBusy(true);
setError('');
setError(null);
try {
return await applyMutation(action);
} catch (err) {
setError(err.message);
throw err;
const safeError = err instanceof HarborApiError ? err : new HarborApiError();
setError({
context,
message: safeError.message,
code: safeError.code,
correlationId: safeError.correlationId,
retry: safeError.retryable ? () => run(action, context) : null,
});
return false;
} finally {
setBusy(false);
}
@@ -91,11 +98,11 @@ function App() {
const data = await api.subscription.fetch(subscriptionUrl);
dispatch({ type: 'clear-pending-server' });
return data;
});
}, 'subscription');
}
async function refreshSubscription() {
return applyMutation(api.subscription.refresh);
return run(api.subscription.refresh, 'subscription');
}
async function forgetSubscription() {
@@ -104,7 +111,7 @@ function App() {
setSubscriptionUrl('');
dispatch({ type: 'clear-pending-server' });
return data;
});
}, 'subscription');
}
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
@@ -126,10 +133,10 @@ function App() {
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onApply={(tag) => run(() => api.apply(tag))}
onRestart={() => run(api.singbox.restart)}
onStop={() => run(api.singbox.stop)}
onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled))}
onApply={(tag) => run(() => api.apply(tag), 'connection')}
onRestart={() => run(api.singbox.restart, 'connection')}
onStop={() => run(api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled), 'connection')}
/>
</main>
</div>

View File

@@ -1,16 +1,51 @@
async function request(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
import { ERROR_DEFINITIONS, errorDefinition } from '../shared/errors.js';
export class HarborApiError extends Error {
constructor(payload = {}, status = 0) {
const code = ERROR_DEFINITIONS[payload.code] ? payload.code : 'UNKNOWN';
const definition = errorDefinition(code);
super(definition.message);
this.name = 'HarborApiError';
this.code = code;
this.status = status >= 400 ? status : definition.status;
this.retryable = definition.retryable;
this.details = payload.details;
this.correlationId = payload.correlationId
|| globalThis.crypto?.randomUUID?.()
|| new Date().toISOString();
}
}
export function validationStatusForError(error) {
return error?.code === 'SUBSCRIPTION_INVALID' ? 'invalid' : 'unavailable';
}
export async function request(url, options = {}, fetchImpl = fetch) {
let response;
try {
response = await fetchImpl(url, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers || {}),
},
});
} catch (error) {
if (error?.name === 'AbortError') throw error;
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
}
let data = {};
try {
data = await response.json();
} catch {
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
}
if (!response.ok || data?.success === false) {
const error = new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`);
error.status = response.status;
throw error;
const payload = data?.error && typeof data.error === 'object'
? data.error
: { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' };
throw new HarborApiError(payload, response.status);
}
return data;
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import { api, validationStatusForError } from '../api.js';
import {
connectionAction,
connectionDurationParts,
@@ -20,6 +20,19 @@ function CloudTooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
}
function InlineError({ error, context }) {
if (!error || error.context !== context) return null;
return (
<div className={`client-inline-error is-${context}`} role="alert">
<span>{error.message}</span>
{error.correlationId && (
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
)}
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
</div>
);
}
function InstructionStep({ step }) {
if (typeof step === 'string') return step;
return (
@@ -196,6 +209,7 @@ export function ClientOverviewPage({
});
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
const [validationAttempt, setValidationAttempt] = useState(0);
const [showIntro, setShowIntro] = useState(!hasSubscription);
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [pings, setPings] = useState({});
@@ -240,6 +254,9 @@ export function ClientOverviewPage({
const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation.status
: normalizedSubscriptionUrl ? 'checking' : 'idle';
const subscriptionError = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation.error
: null;
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
useEffect(() => {
@@ -316,10 +333,32 @@ export function ClientOverviewPage({
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
const timer = setTimeout(() => {
api.subscription.validate(normalizedSubscriptionUrl, controller.signal)
.then(() => setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid' }))
.catch(() => {
.then(() => setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: 'valid',
error: null,
}))
.catch((validationError) => {
if (!controller.signal.aborted) {
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'invalid' });
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: validationStatusForError(validationError),
error: {
context: 'subscription',
message: validationError.message,
correlationId: validationError.correlationId,
retry: validationError.retryable
? () => {
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: 'checking',
error: null,
});
setValidationAttempt((attempt) => attempt + 1);
}
: null,
},
});
}
});
}, 350);
@@ -328,7 +367,7 @@ export function ClientOverviewPage({
clearTimeout(timer);
controller.abort();
};
}, [normalizedSubscriptionUrl]);
}, [normalizedSubscriptionUrl, validationAttempt]);
useEffect(() => {
if (!hasSubscription) {
@@ -356,7 +395,7 @@ export function ClientOverviewPage({
useEffect(() => {
if (!state?.hasSubscription) return undefined;
onRefreshSubscription().catch(() => {});
onRefreshSubscription();
return undefined;
}, [state?.hasSubscription]);
@@ -413,7 +452,7 @@ export function ClientOverviewPage({
async function submitSubscription(event) {
event.preventDefault();
if (subscriptionValidationStatus !== 'valid') return;
await onFetchSubscription();
if (!await onFetchSubscription()) return;
setSubscriptionUrl('');
setEditingSubscription(false);
}
@@ -434,7 +473,7 @@ export function ClientOverviewPage({
const startedAt = performance.now();
setRefreshingInfo(true);
try {
await onRefreshSubscription();
if (!await onRefreshSubscription()) return;
setUsageUpdated(false);
requestAnimationFrame(() => setUsageUpdated(true));
setTimeout(() => setUsageUpdated(false), 900);
@@ -451,7 +490,7 @@ export function ClientOverviewPage({
}
async function forgetSubscription() {
await onForgetSubscription();
if (!await onForgetSubscription()) return;
setConfirmingDelete(false);
}
@@ -641,11 +680,10 @@ export function ClientOverviewPage({
</div>
)}
</section>
<InlineError error={error} context="connection" />
</section>
)}
{error && <p className="client-error" role="alert">{error}</p>}
<div
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}${confirmingDelete ? ' is-confirming-delete' : ''}`}
aria-hidden={subscriptionWaiting}
@@ -759,15 +797,20 @@ export function ClientOverviewPage({
? 'Сохранить подписку'
: subscriptionValidationStatus === 'invalid'
? 'Ссылка подписки не распознана'
: 'Проверяем ссылку подписки'}
: subscriptionValidationStatus === 'unavailable'
? 'Проверка подписки временно недоступна'
: 'Проверяем ссылку подписки'}
disabled={busy || subscriptionValidationStatus !== 'valid'}
>
{subscriptionValidationStatus === 'valid'
? '✓'
: subscriptionValidationStatus === 'invalid' ? '×' : '…'}
: subscriptionValidationStatus === 'invalid'
? '×'
: subscriptionValidationStatus === 'unavailable' ? '!' : '…'}
</button>
)}
</form>
<InlineError error={subscriptionError || error} context="subscription" />
</div>
{hasSubscription && subscriptionContentReady && hasUsage && (

View File

@@ -22,7 +22,7 @@ export function compatibleSnapshot(snapshot) {
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';
if (error?.code === 'CONTROL_UNREACHABLE' || error?.name === 'TypeError' || status >= 500) return 'control-unreachable';
return 'fatal';
}

View File

@@ -876,6 +876,7 @@ p {
}
.client-power-section {
position: relative;
display: grid;
justify-items: center;
gap: 18px;
@@ -1896,17 +1897,53 @@ p {
margin-top: 7px;
}
.client-error {
.client-inline-error {
position: absolute;
bottom: 0;
left: 50%;
width: min(420px, 90vw);
z-index: 3;
width: min(360px, 90vw);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
color: oklch(0.62 0.2 28);
font: 600 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: center;
transform: translateX(-50%);
}
.client-inline-error.is-connection {
top: calc(100% + 12px);
}
.client-inline-error.is-subscription {
top: calc(100% + 2px);
}
.client-inline-error small {
color: var(--client-muted);
font-size: 9px;
white-space: nowrap;
}
.client-inline-error button {
padding: 4px 7px;
border: 0;
border-radius: 6px;
background: color-mix(in oklch, var(--client-control) 72%, transparent);
color: var(--client-text);
font-size: 9px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.client-inline-error button:focus-visible {
outline: 2px solid oklch(0.68 0.15 28);
outline-offset: 2px;
}
.client-copy-button {
position: relative;
width: 86px;
@@ -2009,6 +2046,19 @@ p {
padding: 20px 6px;
}
.client-power-section {
padding-bottom: 40px;
}
.client-inline-error.is-connection {
top: auto;
bottom: 0;
}
.client-form-content {
gap: 48px;
}
.harbor-brand {
transform: translate(-50%, calc(-50% - 28vh)) scale(2.15);
}