Persist configurable connectivity diagnostics
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-19 13:38:19 +03:00
parent df865fbe3d
commit 416b2b294a
22 changed files with 407 additions and 131 deletions
@@ -1,4 +1,11 @@
import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js';
import { isDeepStrictEqual } from 'node:util';
import {
normalizeDiagnosticSettings,
type DiagnosticSettings,
} from '../../../shared/connectivityDiagnostics.js';
import type { HarborServer, StoredProfile, StoredState } from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
interface DiagnosticState {
desiredProfileId?: unknown;
@@ -6,6 +13,8 @@ interface DiagnosticState {
appliedServerId?: unknown;
appliedServerSnapshot?: HarborServer | null;
profiles?: StoredProfile[];
revision?: number;
diagnostics?: DiagnosticSettings;
}
interface DiagnosticsResult extends Record<string, unknown> {
@@ -13,7 +22,10 @@ interface DiagnosticsResult extends Record<string, unknown> {
}
interface ConnectivityDiagnosticsDependencies {
readState(): DiagnosticState;
state: {
read(): DiagnosticState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
}
@@ -40,9 +52,11 @@ export function createConnectivityDiagnosticsUseCase(
dependencies: ConnectivityDiagnosticsDependencies,
) {
return {
async run(services: unknown, target: unknown) {
const selected = selectedServer(dependencies.readState());
async run(target: unknown) {
const state = dependencies.state.read();
const selected = selectedServer(state);
const server = selected ? { id: selected.id, label: selected.label } : null;
const services = normalizeDiagnosticSettings(state.diagnostics).customServices;
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
return {
...result,
@@ -52,6 +66,24 @@ export function createConnectivityDiagnosticsUseCase(
},
};
},
updateSettings(settings: unknown, expectedRevision: unknown) {
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
throw new HarborError('REQUEST_INVALID');
}
let diagnostics: DiagnosticSettings;
try {
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
? settings as Record<string, unknown>
: {};
diagnostics = normalizeDiagnosticSettings({ ...requested, configured: true }, { strict: true });
} catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause });
}
const current = dependencies.state.read();
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
if (isDeepStrictEqual(current.diagnostics, diagnostics)) return;
dependencies.state.update((state) => ({ ...state, diagnostics }));
},
};
}
@@ -4,8 +4,9 @@ import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/
import { sendJson } from '../response.js';
interface ConnectivityDiagnosticsRouteDependencies {
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run'>;
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'updateSettings'>;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
sendState(res: ServerResponse): Promise<void>;
}
export function createConnectivityDiagnosticsRoute(
@@ -13,11 +14,19 @@ export function createConnectivityDiagnosticsRoute(
) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'POST' || req.url !== '/api/diagnostics/connectivity') return false;
const { services = [], target = null } = await dependencies.readBody(req);
const result = await dependencies.diagnostics.run(services, target);
sendJson(res, 200, result);
return true;
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
const { target = null } = await dependencies.readBody(req);
const result = await dependencies.diagnostics.run(target);
sendJson(res, 200, result);
return true;
}
if (req.url === '/api/diagnostics/settings' && req.method === 'PUT') {
const { settings, expectedRevision } = await dependencies.readBody(req);
dependencies.diagnostics.updateSettings(settings, expectedRevision);
await dependencies.sendState(res);
return true;
}
return false;
},
};
}
+5 -1
View File
@@ -381,7 +381,10 @@ const deviceInventoryRoute = createDeviceInventoryRoute({
});
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
readState: () => normalizeStoredState(stateStore.read()),
state: {
read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState,
},
runDiagnostics: async (services, target) => remoteDataplane
? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
: requireLocalConnectivityDiagnostics().run({
@@ -393,6 +396,7 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
diagnostics: connectivityDiagnostics,
readBody,
sendState: (res) => stateRoute.send(res),
});
const gatewayPresenceRoute = createGatewayPresenceRoute({
appMode: settings.appMode,
+1 -1
View File
@@ -13,7 +13,7 @@ import {
type NormalizedServer,
} from '../../shared/serverIdentity.js';
export const STATE_SCHEMA_VERSION = 6;
export const STATE_SCHEMA_VERSION = 7;
export interface AtomicWriteOptions {
beforeRename?: (temporaryPath: string, filePath: string) => void;
+67
View File
@@ -25,6 +25,73 @@ export const CONNECTIVITY_SITES = Object.freeze([
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
export interface DiagnosticService {
id: string;
label: string;
url: string;
}
export interface DiagnosticSettings {
configured: boolean;
customServices: DiagnosticService[];
hiddenServiceIds: string[];
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function diagnosticService(value: unknown): DiagnosticService | null {
const candidate = record(value);
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
if (!/^custom-[a-z0-9-]{1,80}$/i.test(id) || !label || label.length > 40) return null;
try {
const url = new URL(typeof candidate.url === 'string' ? candidate.url.trim() : '');
if (
url.protocol !== 'https:'
|| url.username
|| url.password
|| (url.port && url.port !== '443')
) return null;
return { id, label, url: url.href };
} catch {
return null;
}
}
export function normalizeDiagnosticSettings(
value: unknown,
{ strict = false }: { strict?: boolean } = {},
): DiagnosticSettings {
const candidate = record(value);
const requestedServices = Array.isArray(candidate.customServices) ? candidate.customServices : [];
const customServices = requestedServices
.map(diagnosticService)
.filter((service): service is DiagnosticService => Boolean(service))
.filter((service, index, services) => services.findIndex(({ id }) => id === service.id) === index)
.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES);
const builtInIds = new Set(CONNECTIVITY_SITES.map(({ id }) => id));
const requestedHiddenIds = Array.isArray(candidate.hiddenServiceIds) ? candidate.hiddenServiceIds : [];
const hiddenServiceIds = requestedHiddenIds
.filter((id): id is string => typeof id === 'string' && builtInIds.has(id))
.filter((id, index, ids) => ids.indexOf(id) === index);
if (strict && (
typeof candidate.configured !== 'boolean'
|| !Array.isArray(candidate.customServices)
|| !Array.isArray(candidate.hiddenServiceIds)
|| customServices.length !== requestedServices.length
|| hiddenServiceIds.length !== requestedHiddenIds.length
)) throw new TypeError('Invalid diagnostic settings');
return {
configured: candidate.configured === true,
customServices,
hiddenServiceIds,
};
}
export interface ConnectivitySiteResult {
id: string;
label: string;
+28 -8
View File
@@ -4,6 +4,10 @@ import {
type RouteRuleOutbound,
} from '../routingRules.js';
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
import {
normalizeDiagnosticSettings,
type DiagnosticSettings,
} from '../connectivityDiagnostics.js';
export type HarborMode = 'client' | 'gateway';
export type ConnectionState = 'running' | 'stopped';
@@ -78,6 +82,7 @@ export interface StateSnapshot {
startedAt: string | null;
lastError: string | null;
};
diagnostics: DiagnosticSettings;
route: {
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
mode: string;
@@ -116,6 +121,7 @@ export interface PersistedState extends Record<string, unknown> {
routeRulesRevision: number;
connectionDesired?: ConnectionState;
gatewayAutoEnabled?: boolean;
diagnostics: DiagnosticSettings;
}
// Legacy fields are derived in memory for bounded callers during the v5 cutover.
@@ -286,6 +292,7 @@ export function normalizeStoredState(value: unknown): StoredState {
&& Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
? state.routeRulesRevision
: 0,
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
subscriptionUrl: selectedProfile?.subscriptionUrl || '',
selectedServerId,
selectedTag: selectedServer?.label || '',
@@ -383,6 +390,7 @@ export function createStateSnapshot({
startedAt: dateOrNull(runtime?.startedAt),
lastError: null,
},
diagnostics: stored.diagnostics,
route: {
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
mode: routeMode,
@@ -418,18 +426,21 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
? { ...rule, outbound: 'direct' }
: rule
);
const candidate = rawCandidate?.route?.rulesContractVersion === undefined
&& Array.isArray(rawCandidate?.route?.localRules)
&& Array.isArray(rawCandidate?.route?.activeLocalRules)
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
: rawCandidate;
const candidate = candidateWithDiagnostics?.route?.rulesContractVersion === undefined
&& Array.isArray(candidateWithDiagnostics?.route?.localRules)
&& Array.isArray(candidateWithDiagnostics?.route?.activeLocalRules)
? {
...rawCandidate,
...candidateWithDiagnostics,
route: {
...rawCandidate.route,
localRules: rawCandidate.route.localRules.map(legacyRule),
activeLocalRules: rawCandidate.route.activeLocalRules.map(legacyRule),
...candidateWithDiagnostics.route,
localRules: candidateWithDiagnostics.route.localRules.map(legacyRule),
activeLocalRules: candidateWithDiagnostics.route.activeLocalRules.map(legacyRule),
},
}
: rawCandidate;
: candidateWithDiagnostics;
const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
const nullableDate = (value: unknown) => value === null || validDate(value);
const nullableString = (value: unknown) => value === null || typeof value === 'string';
@@ -467,6 +478,14 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
typeof profile.desiredServerId === 'string' &&
Array.isArray(profile.servers) && profile.servers.every(validServer)
);
const validDiagnostics = (diagnostics: DiagnosticSettings) => {
try {
normalizeDiagnosticSettings(diagnostics, { strict: true });
return true;
} catch {
return false;
}
};
if (
!snapshot ||
@@ -496,6 +515,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
!CONNECTION_STATES.has(candidate.connection.process) ||
!nullableDate(candidate.connection.startedAt) ||
!nullableString(candidate.connection.lastError) ||
!validDiagnostics(candidate.diagnostics) ||
!candidate.route ||
![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) ||
typeof candidate.route.mode !== 'string' ||
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.26.7',
gatewayClient: '0.27.7',
gatewayBackend: '0.27.0',
macClient: '0.27.0',
gatewayClient: '0.28.0',
gatewayBackend: '0.28.0',
});
export interface ParsedVersion {
+5
View File
@@ -299,6 +299,11 @@ export function App() {
() => api.routeRules.update(rules, expectedRevision),
'routing',
)}
onUpdateDiagnosticsSettings={(settings: unknown) => run(
'diagnosticsSettings',
() => api.diagnostics.updateSettings(settings, revisionRef.current),
'diagnostics',
)}
onDismissError={() => {
setError(null);
setDismissedCanonicalError(canonicalErrorId);
+10 -2
View File
@@ -177,11 +177,18 @@ export const api = {
),
},
diagnostics: {
connectivity: (services: unknown[] = [], target: unknown = null) => request(
connectivity: (target: unknown = null) => request(
'/api/diagnostics/connectivity',
{
method: 'POST',
body: JSON.stringify({ services, target }),
body: JSON.stringify({ target }),
},
),
updateSettings: (settings: unknown, expectedRevision: number) => request(
'/api/diagnostics/settings',
{
method: 'PUT',
body: JSON.stringify({ settings, expectedRevision }),
},
),
},
@@ -229,6 +236,7 @@ export function parseHarborState(value: unknown): HarborClientState {
subscription: snapshot.subscription,
selection: snapshot.selection,
connection: snapshot.connection,
diagnostics: snapshot.diagnostics,
route: snapshot.route,
operation: snapshot.operation,
servers: snapshot.servers,
+5 -1
View File
@@ -92,7 +92,7 @@ interface ComponentActions {
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise<unknown>;
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
}
interface ClientViewState extends StateSnapshot {
@@ -118,6 +118,7 @@ interface ClientOverviewPageProps {
onStop: () => Promise<unknown>;
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
onDismissError: () => void;
}
@@ -423,6 +424,7 @@ export function ClientOverviewPage({
onStop,
onSetGatewayAuto,
onSaveRouteRules,
onUpdateDiagnosticsSettings,
onDismissError,
}: ClientOverviewPageProps) {
const isGateway = state?.mode === 'gateway';
@@ -843,6 +845,8 @@ export function ClientOverviewPage({
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature}
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
settings={state.diagnostics}
updateSettings={onUpdateDiagnosticsSettings}
isGateway={isGateway}
/>}
@@ -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>}
+2 -1
View File
@@ -1,6 +1,6 @@
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
| 'gatewayAuto' | 'routeRules';
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings';
export interface OperationState {
status: 'running';
@@ -21,6 +21,7 @@ const OPERATION_KEYS: readonly OperationKey[] = [
'profileDelete',
'gatewayAuto',
'routeRules',
'diagnosticsSettings',
];
// The backend has one canonical revision and one mutation queue, so the UI mirrors that lock.
+6 -2
View File
@@ -81,10 +81,10 @@
.client-diagnostics-row-name {
min-width: 0;
display: inline-flex;
display: flex;
align-items: center;
gap: 4px;
max-width: 100%;
width: 100%;
}
.client-diagnostics-row-name > span:first-child {
@@ -99,6 +99,10 @@
flex: 0 0 24px;
}
.client-diagnostics-row-refresh-wrap {
margin-left: auto;
}
.client-diagnostics-row-refresh svg {
width: 13px;
height: 13px;