Persist configurable connectivity diagnostics
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
CONNECTIVITY_NETWORK_SOURCE,
|
||||
CONNECTIVITY_SITES,
|
||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||
type DiagnosticService,
|
||||
type DiagnosticSettings,
|
||||
} from '../../../shared/connectivityDiagnostics.js';
|
||||
import {
|
||||
parseConnectivityResult,
|
||||
@@ -25,12 +27,6 @@ import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
|
||||
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
||||
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
||||
|
||||
interface DiagnosticService extends Record<string, unknown> {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface IpSourceDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -38,10 +34,8 @@ interface IpSourceDefinition {
|
||||
}
|
||||
|
||||
type StatusValue = [className: string, label: string];
|
||||
type RunConnectivityDiagnostics = (
|
||||
services: DiagnosticService[],
|
||||
target: string,
|
||||
) => Promise<unknown>;
|
||||
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
|
||||
type UpdateSettings = (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -88,6 +82,15 @@ function readHiddenServices(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegacyServices() {
|
||||
try {
|
||||
localStorage.removeItem(CUSTOM_SERVICES_KEY);
|
||||
localStorage.removeItem(HIDDEN_SERVICES_KEY);
|
||||
} catch {
|
||||
// Browser storage is migration-only; canonical settings already live on the backend.
|
||||
}
|
||||
}
|
||||
|
||||
function resultStatus(
|
||||
site: DiagnosticSiteResult | undefined,
|
||||
pending: boolean,
|
||||
@@ -159,6 +162,7 @@ function IpCell({
|
||||
if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />;
|
||||
if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />;
|
||||
if (!path?.available) return <Status value={['is-muted', '—']} route={route} />;
|
||||
if (!value) return <Status value={['is-muted', '—']} route={route} />;
|
||||
if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
|
||||
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
|
||||
}
|
||||
@@ -176,6 +180,7 @@ function NetworkCell({
|
||||
if (path?.available === false) status = ['is-muted', '—'];
|
||||
else if (pending) status = ['is-running', 'Тестируем'];
|
||||
else if (!path?.available) status = ['is-muted', '—'];
|
||||
else if (path.network == null) status = ['is-muted', '—'];
|
||||
const identity = [path?.network?.asn, path?.network?.provider].filter(Boolean).join(' · ');
|
||||
const location = [path?.network?.city, path?.network?.country].filter(Boolean).join(', ');
|
||||
if (!status && !identity && !location) status = ['is-error', 'Нет ответа'];
|
||||
@@ -230,20 +235,23 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity
|
||||
export function ConnectivityDiagnosticsPanel({
|
||||
feature,
|
||||
runConnectivityDiagnostics,
|
||||
settings,
|
||||
updateSettings,
|
||||
isGateway,
|
||||
}: {
|
||||
feature: DiagnosticsFeature;
|
||||
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
||||
settings: DiagnosticSettings;
|
||||
updateSettings: UpdateSettings;
|
||||
isGateway: boolean;
|
||||
}) {
|
||||
const [result, setResult] = useState<ConnectivityResult | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'running' | 'ready' | 'error'>('idle');
|
||||
const [activeTarget, setActiveTarget] = useState<string | null>(null);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [customServices, setCustomServices] = useState(readCustomServices);
|
||||
const [hiddenServiceIds, setHiddenServiceIds] = useState(readHiddenServices);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removingServiceId, setRemovingServiceId] = useState('');
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [serviceName, setServiceName] = useState('');
|
||||
const [serviceUrl, setServiceUrl] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
@@ -251,23 +259,25 @@ export function ConnectivityDiagnosticsPanel({
|
||||
const runnerRef = useRef<HTMLSpanElement>(null);
|
||||
const previousTargetRef = useRef<string | null>(null);
|
||||
const retryTargetRef = useRef<string | undefined>(undefined);
|
||||
const migrationAttemptedRef = useRef(false);
|
||||
const requestError = requestDetails(error);
|
||||
const { customServices, hiddenServiceIds } = settings;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(CUSTOM_SERVICES_KEY, JSON.stringify(customServices));
|
||||
} catch {
|
||||
// The service still works for this session when browser storage is unavailable.
|
||||
if (settings.configured) {
|
||||
clearLegacyServices();
|
||||
return;
|
||||
}
|
||||
}, [customServices]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_SERVICES_KEY, JSON.stringify(hiddenServiceIds));
|
||||
} catch {
|
||||
// The service list still works for this session when browser storage is unavailable.
|
||||
}
|
||||
}, [hiddenServiceIds]);
|
||||
if (migrationAttemptedRef.current) return;
|
||||
migrationAttemptedRef.current = true;
|
||||
void updateSettings({
|
||||
customServices: readCustomServices(),
|
||||
hiddenServiceIds: readHiddenServices(),
|
||||
}).then((saved) => {
|
||||
if (saved === false) return;
|
||||
clearLegacyServices();
|
||||
});
|
||||
}, [settings.configured, updateSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sheet = sheetRef.current;
|
||||
@@ -306,7 +316,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
];
|
||||
for (const target of targets) {
|
||||
setActiveTarget(target);
|
||||
const partial = parseConnectivityResult(await runConnectivityDiagnostics(customServices, target));
|
||||
const partial = parseConnectivityResult(await runConnectivityDiagnostics(target));
|
||||
const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1;
|
||||
next = legacyFullResult ? partial : mergeResult(next, partial);
|
||||
setResult(next);
|
||||
@@ -321,17 +331,22 @@ export function ConnectivityDiagnosticsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function addService(event: FormEvent<HTMLFormElement>) {
|
||||
async function addService(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
|
||||
const parsed = new URL(serviceUrl.trim());
|
||||
if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.');
|
||||
setCustomServices((services) => [...services, {
|
||||
setSettingsSaving(true);
|
||||
const saved = await updateSettings({
|
||||
customServices: [...customServices, {
|
||||
id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
|
||||
label: serviceName.trim() || parsed.hostname,
|
||||
url: parsed.href,
|
||||
}]);
|
||||
}],
|
||||
hiddenServiceIds,
|
||||
});
|
||||
if (saved === false) throw new Error('Не удалось сохранить сервис.');
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setFormError('');
|
||||
@@ -342,37 +357,52 @@ export function ConnectivityDiagnosticsPanel({
|
||||
? Reflect.get(validationError, 'message')
|
||||
: undefined;
|
||||
setFormError(typeof message === 'string' ? message : 'Проверьте адрес.');
|
||||
} finally {
|
||||
setSettingsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function removeService(serviceId: string) {
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
finishRemoveService(serviceId);
|
||||
void finishRemoveService(serviceId);
|
||||
return;
|
||||
}
|
||||
setRemovingServiceId(serviceId);
|
||||
}
|
||||
|
||||
function finishRemoveService(serviceId: string) {
|
||||
const update = () => flushSync(() => {
|
||||
async function finishRemoveService(serviceId: string) {
|
||||
const update = async () => {
|
||||
if (serviceId === 'draft') {
|
||||
setAdding(false);
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setFormError('');
|
||||
} else if (serviceId.startsWith('custom-')) {
|
||||
setCustomServices((services) => services.filter((service) => service.id !== serviceId));
|
||||
} else {
|
||||
setHiddenServiceIds((ids) => [...new Set([...ids, serviceId])]);
|
||||
flushSync(() => {
|
||||
setAdding(false);
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setFormError('');
|
||||
setRemovingServiceId('');
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRemovingServiceId('');
|
||||
setResult(null);
|
||||
});
|
||||
setSettingsSaving(true);
|
||||
const saved = await updateSettings({
|
||||
customServices: serviceId.startsWith('custom-')
|
||||
? customServices.filter((service) => service.id !== serviceId)
|
||||
: customServices,
|
||||
hiddenServiceIds: serviceId.startsWith('custom-')
|
||||
? hiddenServiceIds
|
||||
: [...new Set([...hiddenServiceIds, serviceId])],
|
||||
});
|
||||
flushSync(() => {
|
||||
setRemovingServiceId('');
|
||||
setSettingsSaving(false);
|
||||
if (saved === false) setFormError('Не удалось сохранить список сервисов.');
|
||||
else setResult(null);
|
||||
});
|
||||
};
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
update();
|
||||
await update();
|
||||
return;
|
||||
}
|
||||
document.startViewTransition(update);
|
||||
await document.startViewTransition(update).finished;
|
||||
}
|
||||
|
||||
const pending = status === 'running';
|
||||
@@ -380,7 +410,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)),
|
||||
...customServices,
|
||||
];
|
||||
const serviceEditorBlocked = pending || Boolean(removingServiceId);
|
||||
const serviceEditorBlocked = pending || settingsSaving || Boolean(removingServiceId);
|
||||
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
|
||||
const { isOpen: open, panelRef, closeRef, close: onClose } = feature;
|
||||
|
||||
@@ -527,7 +557,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
<span
|
||||
className="client-delete-strike"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => finishRemoveService(site.id)}
|
||||
onAnimationEnd={() => void finishRemoveService(site.id)}
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
@@ -572,7 +602,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
<span
|
||||
className="client-delete-strike"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => finishRemoveService('draft')}
|
||||
onAnimationEnd={() => void finishRemoveService('draft')}
|
||||
/>
|
||||
</form>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user