Unify Harbor error handling across server and client
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import http from 'node:http';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
|
||||
function request(socketPath, pathname, method = 'GET') {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -27,8 +28,15 @@ function request(socketPath, pathname, method = 'GET') {
|
||||
export function createDataplaneClient(socketPath, send = request) {
|
||||
let current = { running: false, startedAt: null };
|
||||
const update = async (pathname, method) => {
|
||||
current = await send(socketPath, pathname, method);
|
||||
return current;
|
||||
try {
|
||||
current = await send(socketPath, pathname, method);
|
||||
return current;
|
||||
} catch (cause) {
|
||||
if (pathname === '/apply' || pathname === '/restart') {
|
||||
throw new HarborError('PROCESS_START_FAILED', { cause });
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
|
||||
const NONCE_RE = /^[a-f0-9]{32}$/;
|
||||
const PROOF_RE = /^[a-f0-9]{64}$/;
|
||||
@@ -51,9 +52,7 @@ function presenceProof(subscriptionUrl, nonce, gatewayId) {
|
||||
|
||||
export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }) {
|
||||
if (!NONCE_RE.test(String(nonce || ''))) {
|
||||
const error = new Error('Некорректный nonce');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
|
||||
const subscription = String(subscriptionUrl || '').trim();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
normalizeStoredState,
|
||||
withStateV0Compatibility,
|
||||
} from '../shared/contracts/state.js';
|
||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
@@ -91,10 +93,11 @@ async function withOperation(kind, operation) {
|
||||
updateStoredState((state) => state);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const harborError = normalizeHarborError(error);
|
||||
operationState = {
|
||||
...operationState,
|
||||
status: 'failed',
|
||||
error: error?.message || String(error),
|
||||
error: harborError.message,
|
||||
};
|
||||
updateStoredState((state) => state);
|
||||
throw error;
|
||||
@@ -103,7 +106,8 @@ async function withOperation(kind, operation) {
|
||||
|
||||
function serializeControl(operation) {
|
||||
const result = controlOperation.then(operation, operation);
|
||||
controlOperation = result.catch(() => {});
|
||||
// The caller observes result; this settled tail only keeps the next operation runnable.
|
||||
controlOperation = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -112,6 +116,29 @@ function sendJson(res, statusCode, payload) {
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function redactLogDetails(value) {
|
||||
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
|
||||
}
|
||||
|
||||
function sendError(res, error) {
|
||||
const harborError = normalizeHarborError(error);
|
||||
const correlationId = crypto.randomUUID();
|
||||
const technical = harborError.cause?.message || harborError.details || error;
|
||||
console.error(
|
||||
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technical?.message || technical)}`,
|
||||
);
|
||||
return sendJson(res, harborError.status, {
|
||||
success: false,
|
||||
error: {
|
||||
code: harborError.code,
|
||||
message: harborError.message,
|
||||
retryable: harborError.retryable,
|
||||
correlationId,
|
||||
...(harborError.details ? { details: harborError.details } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
@@ -122,9 +149,7 @@ function readBody(req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
tooLarge = true;
|
||||
const error = new Error('Тело запроса слишком большое');
|
||||
error.statusCode = 413;
|
||||
reject(error);
|
||||
reject(new HarborError('REQUEST_INVALID'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
@@ -134,10 +159,8 @@ function readBody(req) {
|
||||
if (!chunks.length) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch {
|
||||
const error = new Error('Невалидный JSON в теле запроса');
|
||||
error.statusCode = 400;
|
||||
reject(error);
|
||||
} catch (cause) {
|
||||
reject(new HarborError('REQUEST_INVALID', { cause }));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
@@ -312,7 +335,8 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
|
||||
async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!cached?.config) throw new Error('Сначала загрузите подписку');
|
||||
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
|
||||
const nextConfig = buildActiveConfig(cached.config, selectedTag);
|
||||
|
||||
if (persist) {
|
||||
updateStoredState((state) => ({
|
||||
@@ -325,7 +349,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, selectedTag));
|
||||
writeSingboxConfig(nextConfig);
|
||||
try {
|
||||
await startSingbox();
|
||||
} catch (error) {
|
||||
@@ -348,9 +372,7 @@ function refreshSavedSubscription() {
|
||||
subscriptionRefreshPromise = (async () => {
|
||||
const initialState = readJson(settings.statePath, {});
|
||||
if (!initialState.subscriptionUrl) {
|
||||
const error = new Error('Подписка не настроена');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
const subscriptionUrl = initialState.subscriptionUrl;
|
||||
@@ -358,7 +380,7 @@ function refreshSavedSubscription() {
|
||||
return serializeControl(async () => {
|
||||
const currentState = readJson(settings.statePath, {});
|
||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new Error('Подписка была изменена во время обновления');
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
|
||||
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
|
||||
@@ -498,11 +520,11 @@ async function handleApi(req, res) {
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
|
||||
if (settings.appMode !== 'client') {
|
||||
return sendJson(res, 400, { success: false, error: 'Режим доступен только в Harbor Connect' });
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const { enabled } = await readBody(req);
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' });
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
await withOperation('gateway-auto', () => serializeControl(async () => {
|
||||
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
|
||||
@@ -529,7 +551,7 @@ async function handleApi(req, res) {
|
||||
if (req.method === 'POST' && req.url === '/api/apply') {
|
||||
const { selectedTag = '' } = await readBody(req);
|
||||
const tag = String(selectedTag).trim();
|
||||
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' });
|
||||
if (!tag) throw new HarborError('REQUEST_INVALID');
|
||||
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag)));
|
||||
return sendState(res, { selectedTag: tag });
|
||||
}
|
||||
@@ -545,9 +567,7 @@ async function handleApi(req, res) {
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||
await withOperation('start', () => serializeControl(async () => {
|
||||
if (!fs.existsSync(settings.configPath)) {
|
||||
const error = new Error('Сначала выберите сервер');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
throw new HarborError('CONFIG_INVALID');
|
||||
}
|
||||
await singboxRuntime.restart();
|
||||
updateStoredState((state) => ({
|
||||
@@ -559,7 +579,7 @@ async function handleApi(req, res) {
|
||||
return sendState(res, { singboxRunning: true });
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { success: false, error: 'Не найдено' });
|
||||
return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
|
||||
}
|
||||
|
||||
const mime = {
|
||||
@@ -592,11 +612,7 @@ const server = http.createServer(async (req, res) => {
|
||||
? await handleApi(req, res)
|
||||
: serveStatic(req, res);
|
||||
} catch (error) {
|
||||
console.error('[control] request failed', error);
|
||||
return sendJson(res, error.statusCode || 500, {
|
||||
success: false,
|
||||
error: error.message || String(error),
|
||||
});
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { settings } from './config.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
const MIXED_INBOUND = 'mixed-in';
|
||||
@@ -22,7 +23,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDire
|
||||
const vpnOutbound = directClient
|
||||
? null
|
||||
: structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||
if (!directClient && !vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||
if (!directClient && !vpnOutbound) throw new HarborError('SERVER_NOT_FOUND');
|
||||
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
||||
if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) {
|
||||
vpnOutbound.packet_encoding = 'xudp';
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { setGatewayInterception } from './gatewayRouting.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
|
||||
export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) {
|
||||
let child = null;
|
||||
@@ -44,16 +45,27 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain
|
||||
|
||||
const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' });
|
||||
if (check.status !== 0) {
|
||||
throw new Error((check.stderr || check.stdout || 'sing-box check failed').trim());
|
||||
throw new HarborError('CONFIG_INVALID', {
|
||||
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
|
||||
});
|
||||
}
|
||||
|
||||
const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex');
|
||||
if (!force && child && nextHash === configHash) return state();
|
||||
|
||||
await stop();
|
||||
const current = spawn('sing-box', ['run', '-c', configPath], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
let current;
|
||||
try {
|
||||
current = spawn('sing-box', ['run', '-c', configPath], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
current.once('spawn', resolve);
|
||||
current.once('error', reject);
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new HarborError('PROCESS_START_FAILED', { cause });
|
||||
}
|
||||
child = current;
|
||||
configHash = nextHash;
|
||||
startedAt = new Date().toISOString();
|
||||
@@ -64,7 +76,7 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain
|
||||
child = null;
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
throw error;
|
||||
throw new HarborError('PROCESS_START_FAILED', { cause: error });
|
||||
}
|
||||
current.once('exit', () => {
|
||||
if (child !== current) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { settings } from './config.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
|
||||
@@ -40,10 +41,15 @@ export function parseUserInfo(headerValue) {
|
||||
|
||||
export function parseVlessUrl(rawUrl) {
|
||||
if (!rawUrl.startsWith('vless://')) {
|
||||
throw new Error('VLESS URL must start with vless://');
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
const parsed = new URL(rawUrl);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch (cause) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID', { cause });
|
||||
}
|
||||
const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out');
|
||||
const uuid = decodeURIComponent(parsed.username || '');
|
||||
const server = parsed.hostname;
|
||||
@@ -55,11 +61,11 @@ export function parseVlessUrl(rawUrl) {
|
||||
const flow = parsed.searchParams.get('flow') || '';
|
||||
|
||||
if (!uuid || !server || !serverPort) {
|
||||
throw new Error('VLESS URL misses uuid, host or port');
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
if (!publicKey || !shortId) {
|
||||
throw new Error('VLESS REALITY parameters pbk and sid are required');
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -111,7 +117,7 @@ export function parseSubscriptionBody(body) {
|
||||
.filter((line) => line.startsWith('vless://'));
|
||||
|
||||
if (!links.length) {
|
||||
throw new Error('Subscription does not contain JSON config or VLESS links');
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
parsedConfig = {
|
||||
@@ -130,7 +136,7 @@ export function parseSubscriptionBody(body) {
|
||||
}));
|
||||
|
||||
if (!servers.length) {
|
||||
throw new Error('No supported proxy outbounds found in subscription');
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
return { config: parsedConfig, servers };
|
||||
@@ -140,21 +146,26 @@ async function requestSubscription(url) {
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error('Invalid subscription URL');
|
||||
} catch (cause) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID', { cause });
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error('Subscription URL must use http or https');
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
const response = await fetch(parsedUrl, {
|
||||
headers: subscriptionHeaders(),
|
||||
redirect: 'follow',
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(parsedUrl, {
|
||||
headers: subscriptionHeaders(),
|
||||
redirect: 'follow',
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new HarborError('PROVIDER_UNAVAILABLE', { cause });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
||||
throw new HarborError('PROVIDER_UNAVAILABLE', { details: `HTTP ${response.status}` });
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
33
src/shared/errors.js
Normal file
33
src/shared/errors.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export const ERROR_DEFINITIONS = Object.freeze({
|
||||
CONTROL_UNREACHABLE: { status: 503, message: 'Harbor сейчас недоступен.', retryable: true },
|
||||
REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false },
|
||||
ENDPOINT_NOT_FOUND: { status: 404, message: 'Запрошенный API-метод не найден.', retryable: false },
|
||||
SUBSCRIPTION_INVALID: { status: 400, message: 'Ссылка подписки недействительна.', retryable: false },
|
||||
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true },
|
||||
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
|
||||
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
|
||||
CONFIG_INVALID: { status: 422, message: 'Конфигурация VPN недействительна.', retryable: false },
|
||||
PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true },
|
||||
OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true },
|
||||
UNKNOWN: { status: 500, message: 'Не удалось выполнить действие.', retryable: false },
|
||||
});
|
||||
|
||||
export function errorDefinition(code) {
|
||||
return ERROR_DEFINITIONS[code] || ERROR_DEFINITIONS.UNKNOWN;
|
||||
}
|
||||
|
||||
export class HarborError extends Error {
|
||||
constructor(code, { cause, details } = {}) {
|
||||
const definition = errorDefinition(code);
|
||||
super(definition.message, { cause });
|
||||
this.name = 'HarborError';
|
||||
this.code = ERROR_DEFINITIONS[code] ? code : 'UNKNOWN';
|
||||
this.status = definition.status;
|
||||
this.retryable = definition.retryable;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHarborError(error) {
|
||||
return error instanceof HarborError ? error : new HarborError('UNKNOWN', { cause: error });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user