Persist configurable connectivity diagnostics
This commit is contained in:
@@ -20,6 +20,7 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt
|
|||||||
- Reserve identical height for mutually exclusive content such as timer versus connection hint.
|
- Reserve identical height for mutually exclusive content such as timer versus connection hint.
|
||||||
- Give copy buttons fixed width. Overlay temporary feedback instead of replacing text in normal flow.
|
- Give copy buttons fixed width. Overlay temporary feedback instead of replacing text in normal flow.
|
||||||
- Align icons and labels in the same flex row. Do not position an icon by guessed absolute offsets.
|
- Align icons and labels in the same flex row. Do not position an icon by guessed absolute offsets.
|
||||||
|
- Give repeated row actions one fixed-width trailing slot aligned to the same edge. Reserve that slot when labels wrap or statuses change; never place the action at the end of intrinsic label text.
|
||||||
- Preserve a generous invisible hit area around icon-only controls.
|
- Preserve a generous invisible hit area around icon-only controls.
|
||||||
- Center proxy address and protocol actions with the power column.
|
- Center proxy address and protocol actions with the power column.
|
||||||
- Treat one-pixel optical misalignment as a defect when controls sit beside uppercase labels.
|
- Treat one-pixel optical misalignment as a defect when controls sit beside uppercase labels.
|
||||||
@@ -60,3 +61,4 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt
|
|||||||
- Show expiry as both date and remaining days, with correct Russian forms.
|
- Show expiry as both date and remaining days, with correct Russian forms.
|
||||||
- If there is no total, say `без лимита` and omit the progress bar.
|
- If there is no total, say `без лимита` and omit the progress bar.
|
||||||
- Hide unavailable rows instead of showing empty placeholders or zeros that imply real measurements.
|
- Hide unavailable rows instead of showing empty placeholders or zeros that imply real measurements.
|
||||||
|
- Keep `not tested`, `running`, `success`, and `failed` as separate row states. A result from one row must not turn untouched sibling rows into failures.
|
||||||
|
|||||||
@@ -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 {
|
interface DiagnosticState {
|
||||||
desiredProfileId?: unknown;
|
desiredProfileId?: unknown;
|
||||||
@@ -6,6 +13,8 @@ interface DiagnosticState {
|
|||||||
appliedServerId?: unknown;
|
appliedServerId?: unknown;
|
||||||
appliedServerSnapshot?: HarborServer | null;
|
appliedServerSnapshot?: HarborServer | null;
|
||||||
profiles?: StoredProfile[];
|
profiles?: StoredProfile[];
|
||||||
|
revision?: number;
|
||||||
|
diagnostics?: DiagnosticSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiagnosticsResult extends Record<string, unknown> {
|
interface DiagnosticsResult extends Record<string, unknown> {
|
||||||
@@ -13,7 +22,10 @@ interface DiagnosticsResult extends Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectivityDiagnosticsDependencies {
|
interface ConnectivityDiagnosticsDependencies {
|
||||||
readState(): DiagnosticState;
|
state: {
|
||||||
|
read(): DiagnosticState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +52,11 @@ export function createConnectivityDiagnosticsUseCase(
|
|||||||
dependencies: ConnectivityDiagnosticsDependencies,
|
dependencies: ConnectivityDiagnosticsDependencies,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
async run(services: unknown, target: unknown) {
|
async run(target: unknown) {
|
||||||
const selected = selectedServer(dependencies.readState());
|
const state = dependencies.state.read();
|
||||||
|
const selected = selectedServer(state);
|
||||||
const server = selected ? { id: selected.id, label: selected.label } : null;
|
const server = selected ? { id: selected.id, label: selected.label } : null;
|
||||||
|
const services = normalizeDiagnosticSettings(state.diagnostics).customServices;
|
||||||
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
|
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
|
||||||
return {
|
return {
|
||||||
...result,
|
...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';
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
interface ConnectivityDiagnosticsRouteDependencies {
|
interface ConnectivityDiagnosticsRouteDependencies {
|
||||||
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run'>;
|
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'updateSettings'>;
|
||||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
sendState(res: ServerResponse): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createConnectivityDiagnosticsRoute(
|
export function createConnectivityDiagnosticsRoute(
|
||||||
@@ -13,11 +14,19 @@ export function createConnectivityDiagnosticsRoute(
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
if (req.method !== 'POST' || req.url !== '/api/diagnostics/connectivity') return false;
|
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
|
||||||
const { services = [], target = null } = await dependencies.readBody(req);
|
const { target = null } = await dependencies.readBody(req);
|
||||||
const result = await dependencies.diagnostics.run(services, target);
|
const result = await dependencies.diagnostics.run(target);
|
||||||
sendJson(res, 200, result);
|
sendJson(res, 200, result);
|
||||||
return true;
|
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
@@ -381,7 +381,10 @@ const deviceInventoryRoute = createDeviceInventoryRoute({
|
|||||||
});
|
});
|
||||||
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
|
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
|
||||||
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
||||||
readState: () => normalizeStoredState(stateStore.read()),
|
state: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
runDiagnostics: async (services, target) => remoteDataplane
|
runDiagnostics: async (services, target) => remoteDataplane
|
||||||
? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
|
? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
|
||||||
: requireLocalConnectivityDiagnostics().run({
|
: requireLocalConnectivityDiagnostics().run({
|
||||||
@@ -393,6 +396,7 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
|||||||
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
||||||
diagnostics: connectivityDiagnostics,
|
diagnostics: connectivityDiagnostics,
|
||||||
readBody,
|
readBody,
|
||||||
|
sendState: (res) => stateRoute.send(res),
|
||||||
});
|
});
|
||||||
const gatewayPresenceRoute = createGatewayPresenceRoute({
|
const gatewayPresenceRoute = createGatewayPresenceRoute({
|
||||||
appMode: settings.appMode,
|
appMode: settings.appMode,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type NormalizedServer,
|
type NormalizedServer,
|
||||||
} from '../../shared/serverIdentity.js';
|
} from '../../shared/serverIdentity.js';
|
||||||
|
|
||||||
export const STATE_SCHEMA_VERSION = 6;
|
export const STATE_SCHEMA_VERSION = 7;
|
||||||
|
|
||||||
export interface AtomicWriteOptions {
|
export interface AtomicWriteOptions {
|
||||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||||
|
|||||||
@@ -25,6 +25,73 @@ export const CONNECTIVITY_SITES = Object.freeze([
|
|||||||
|
|
||||||
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
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 {
|
export interface ConnectivitySiteResult {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
type RouteRuleOutbound,
|
type RouteRuleOutbound,
|
||||||
} from '../routingRules.js';
|
} from '../routingRules.js';
|
||||||
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
||||||
|
import {
|
||||||
|
normalizeDiagnosticSettings,
|
||||||
|
type DiagnosticSettings,
|
||||||
|
} from '../connectivityDiagnostics.js';
|
||||||
|
|
||||||
export type HarborMode = 'client' | 'gateway';
|
export type HarborMode = 'client' | 'gateway';
|
||||||
export type ConnectionState = 'running' | 'stopped';
|
export type ConnectionState = 'running' | 'stopped';
|
||||||
@@ -78,6 +82,7 @@ export interface StateSnapshot {
|
|||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
};
|
};
|
||||||
|
diagnostics: DiagnosticSettings;
|
||||||
route: {
|
route: {
|
||||||
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
||||||
mode: string;
|
mode: string;
|
||||||
@@ -116,6 +121,7 @@ export interface PersistedState extends Record<string, unknown> {
|
|||||||
routeRulesRevision: number;
|
routeRulesRevision: number;
|
||||||
connectionDesired?: ConnectionState;
|
connectionDesired?: ConnectionState;
|
||||||
gatewayAutoEnabled?: boolean;
|
gatewayAutoEnabled?: boolean;
|
||||||
|
diagnostics: DiagnosticSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fields are derived in memory for bounded callers during the v5 cutover.
|
// 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
|
&& Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
|
||||||
? state.routeRulesRevision
|
? state.routeRulesRevision
|
||||||
: 0,
|
: 0,
|
||||||
|
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
|
||||||
subscriptionUrl: selectedProfile?.subscriptionUrl || '',
|
subscriptionUrl: selectedProfile?.subscriptionUrl || '',
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
selectedTag: selectedServer?.label || '',
|
selectedTag: selectedServer?.label || '',
|
||||||
@@ -383,6 +390,7 @@ export function createStateSnapshot({
|
|||||||
startedAt: dateOrNull(runtime?.startedAt),
|
startedAt: dateOrNull(runtime?.startedAt),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
|
diagnostics: stored.diagnostics,
|
||||||
route: {
|
route: {
|
||||||
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
||||||
mode: routeMode,
|
mode: routeMode,
|
||||||
@@ -418,18 +426,21 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
? { ...rule, outbound: 'direct' }
|
? { ...rule, outbound: 'direct' }
|
||||||
: rule
|
: rule
|
||||||
);
|
);
|
||||||
const candidate = rawCandidate?.route?.rulesContractVersion === undefined
|
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
|
||||||
&& Array.isArray(rawCandidate?.route?.localRules)
|
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
|
||||||
&& Array.isArray(rawCandidate?.route?.activeLocalRules)
|
: rawCandidate;
|
||||||
|
const candidate = candidateWithDiagnostics?.route?.rulesContractVersion === undefined
|
||||||
|
&& Array.isArray(candidateWithDiagnostics?.route?.localRules)
|
||||||
|
&& Array.isArray(candidateWithDiagnostics?.route?.activeLocalRules)
|
||||||
? {
|
? {
|
||||||
...rawCandidate,
|
...candidateWithDiagnostics,
|
||||||
route: {
|
route: {
|
||||||
...rawCandidate.route,
|
...candidateWithDiagnostics.route,
|
||||||
localRules: rawCandidate.route.localRules.map(legacyRule),
|
localRules: candidateWithDiagnostics.route.localRules.map(legacyRule),
|
||||||
activeLocalRules: rawCandidate.route.activeLocalRules.map(legacyRule),
|
activeLocalRules: candidateWithDiagnostics.route.activeLocalRules.map(legacyRule),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: rawCandidate;
|
: candidateWithDiagnostics;
|
||||||
const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||||
const nullableDate = (value: unknown) => value === null || validDate(value);
|
const nullableDate = (value: unknown) => value === null || validDate(value);
|
||||||
const nullableString = (value: unknown) => value === null || typeof value === 'string';
|
const nullableString = (value: unknown) => value === null || typeof value === 'string';
|
||||||
@@ -467,6 +478,14 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
typeof profile.desiredServerId === 'string' &&
|
typeof profile.desiredServerId === 'string' &&
|
||||||
Array.isArray(profile.servers) && profile.servers.every(validServer)
|
Array.isArray(profile.servers) && profile.servers.every(validServer)
|
||||||
);
|
);
|
||||||
|
const validDiagnostics = (diagnostics: DiagnosticSettings) => {
|
||||||
|
try {
|
||||||
|
normalizeDiagnosticSettings(diagnostics, { strict: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!snapshot ||
|
!snapshot ||
|
||||||
@@ -496,6 +515,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
!CONNECTION_STATES.has(candidate.connection.process) ||
|
!CONNECTION_STATES.has(candidate.connection.process) ||
|
||||||
!nullableDate(candidate.connection.startedAt) ||
|
!nullableDate(candidate.connection.startedAt) ||
|
||||||
!nullableString(candidate.connection.lastError) ||
|
!nullableString(candidate.connection.lastError) ||
|
||||||
|
!validDiagnostics(candidate.diagnostics) ||
|
||||||
!candidate.route ||
|
!candidate.route ||
|
||||||
![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) ||
|
![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) ||
|
||||||
typeof candidate.route.mode !== 'string' ||
|
typeof candidate.route.mode !== 'string' ||
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.26.7',
|
macClient: '0.27.0',
|
||||||
gatewayClient: '0.27.7',
|
gatewayClient: '0.28.0',
|
||||||
gatewayBackend: '0.27.0',
|
gatewayBackend: '0.28.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
@@ -299,6 +299,11 @@ export function App() {
|
|||||||
() => api.routeRules.update(rules, expectedRevision),
|
() => api.routeRules.update(rules, expectedRevision),
|
||||||
'routing',
|
'routing',
|
||||||
)}
|
)}
|
||||||
|
onUpdateDiagnosticsSettings={(settings: unknown) => run(
|
||||||
|
'diagnosticsSettings',
|
||||||
|
() => api.diagnostics.updateSettings(settings, revisionRef.current),
|
||||||
|
'diagnostics',
|
||||||
|
)}
|
||||||
onDismissError={() => {
|
onDismissError={() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setDismissedCanonicalError(canonicalErrorId);
|
setDismissedCanonicalError(canonicalErrorId);
|
||||||
|
|||||||
@@ -177,11 +177,18 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
connectivity: (services: unknown[] = [], target: unknown = null) => request(
|
connectivity: (target: unknown = null) => request(
|
||||||
'/api/diagnostics/connectivity',
|
'/api/diagnostics/connectivity',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
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,
|
subscription: snapshot.subscription,
|
||||||
selection: snapshot.selection,
|
selection: snapshot.selection,
|
||||||
connection: snapshot.connection,
|
connection: snapshot.connection,
|
||||||
|
diagnostics: snapshot.diagnostics,
|
||||||
route: snapshot.route,
|
route: snapshot.route,
|
||||||
operation: snapshot.operation,
|
operation: snapshot.operation,
|
||||||
servers: snapshot.servers,
|
servers: snapshot.servers,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ interface ComponentActions {
|
|||||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||||
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||||||
pingServers: (profileId: string, ids: string[]) => 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 {
|
interface ClientViewState extends StateSnapshot {
|
||||||
@@ -118,6 +118,7 @@ interface ClientOverviewPageProps {
|
|||||||
onStop: () => Promise<unknown>;
|
onStop: () => Promise<unknown>;
|
||||||
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
||||||
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
||||||
|
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
|
||||||
onDismissError: () => void;
|
onDismissError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +424,7 @@ export function ClientOverviewPage({
|
|||||||
onStop,
|
onStop,
|
||||||
onSetGatewayAuto,
|
onSetGatewayAuto,
|
||||||
onSaveRouteRules,
|
onSaveRouteRules,
|
||||||
|
onUpdateDiagnosticsSettings,
|
||||||
onDismissError,
|
onDismissError,
|
||||||
}: ClientOverviewPageProps) {
|
}: ClientOverviewPageProps) {
|
||||||
const isGateway = state?.mode === 'gateway';
|
const isGateway = state?.mode === 'gateway';
|
||||||
@@ -843,6 +845,8 @@ export function ClientOverviewPage({
|
|||||||
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||||||
feature={diagnosticsFeature}
|
feature={diagnosticsFeature}
|
||||||
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
||||||
|
settings={state.diagnostics}
|
||||||
|
updateSettings={onUpdateDiagnosticsSettings}
|
||||||
isGateway={isGateway}
|
isGateway={isGateway}
|
||||||
/>}
|
/>}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
CONNECTIVITY_NETWORK_SOURCE,
|
CONNECTIVITY_NETWORK_SOURCE,
|
||||||
CONNECTIVITY_SITES,
|
CONNECTIVITY_SITES,
|
||||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
|
type DiagnosticService,
|
||||||
|
type DiagnosticSettings,
|
||||||
} from '../../../shared/connectivityDiagnostics.js';
|
} from '../../../shared/connectivityDiagnostics.js';
|
||||||
import {
|
import {
|
||||||
parseConnectivityResult,
|
parseConnectivityResult,
|
||||||
@@ -25,12 +27,6 @@ import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
|
|||||||
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
||||||
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
||||||
|
|
||||||
interface DiagnosticService extends Record<string, unknown> {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IpSourceDefinition {
|
interface IpSourceDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -38,10 +34,8 @@ interface IpSourceDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StatusValue = [className: string, label: string];
|
type StatusValue = [className: string, label: string];
|
||||||
type RunConnectivityDiagnostics = (
|
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
|
||||||
services: DiagnosticService[],
|
type UpdateSettings = (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
|
||||||
target: string,
|
|
||||||
) => Promise<unknown>;
|
|
||||||
|
|
||||||
function record(value: unknown): value is Record<string, unknown> {
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
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(
|
function resultStatus(
|
||||||
site: DiagnosticSiteResult | undefined,
|
site: DiagnosticSiteResult | undefined,
|
||||||
pending: boolean,
|
pending: boolean,
|
||||||
@@ -159,6 +162,7 @@ function IpCell({
|
|||||||
if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />;
|
if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />;
|
||||||
if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />;
|
if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />;
|
||||||
if (!path?.available) return <Status value={['is-muted', '—']} 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} />;
|
if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
|
||||||
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
|
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
|
||||||
}
|
}
|
||||||
@@ -176,6 +180,7 @@ function NetworkCell({
|
|||||||
if (path?.available === false) status = ['is-muted', '—'];
|
if (path?.available === false) status = ['is-muted', '—'];
|
||||||
else if (pending) status = ['is-running', 'Тестируем'];
|
else if (pending) status = ['is-running', 'Тестируем'];
|
||||||
else if (!path?.available) status = ['is-muted', '—'];
|
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 identity = [path?.network?.asn, path?.network?.provider].filter(Boolean).join(' · ');
|
||||||
const location = [path?.network?.city, path?.network?.country].filter(Boolean).join(', ');
|
const location = [path?.network?.city, path?.network?.country].filter(Boolean).join(', ');
|
||||||
if (!status && !identity && !location) status = ['is-error', 'Нет ответа'];
|
if (!status && !identity && !location) status = ['is-error', 'Нет ответа'];
|
||||||
@@ -230,20 +235,23 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity
|
|||||||
export function ConnectivityDiagnosticsPanel({
|
export function ConnectivityDiagnosticsPanel({
|
||||||
feature,
|
feature,
|
||||||
runConnectivityDiagnostics,
|
runConnectivityDiagnostics,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
isGateway,
|
isGateway,
|
||||||
}: {
|
}: {
|
||||||
feature: DiagnosticsFeature;
|
feature: DiagnosticsFeature;
|
||||||
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
||||||
|
settings: DiagnosticSettings;
|
||||||
|
updateSettings: UpdateSettings;
|
||||||
isGateway: boolean;
|
isGateway: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [result, setResult] = useState<ConnectivityResult | null>(null);
|
const [result, setResult] = useState<ConnectivityResult | null>(null);
|
||||||
const [status, setStatus] = useState<'idle' | 'running' | 'ready' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'running' | 'ready' | 'error'>('idle');
|
||||||
const [activeTarget, setActiveTarget] = useState<string | null>(null);
|
const [activeTarget, setActiveTarget] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [customServices, setCustomServices] = useState(readCustomServices);
|
|
||||||
const [hiddenServiceIds, setHiddenServiceIds] = useState(readHiddenServices);
|
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [removingServiceId, setRemovingServiceId] = useState('');
|
const [removingServiceId, setRemovingServiceId] = useState('');
|
||||||
|
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||||
const [serviceName, setServiceName] = useState('');
|
const [serviceName, setServiceName] = useState('');
|
||||||
const [serviceUrl, setServiceUrl] = useState('');
|
const [serviceUrl, setServiceUrl] = useState('');
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
@@ -251,23 +259,25 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
const runnerRef = useRef<HTMLSpanElement>(null);
|
const runnerRef = useRef<HTMLSpanElement>(null);
|
||||||
const previousTargetRef = useRef<string | null>(null);
|
const previousTargetRef = useRef<string | null>(null);
|
||||||
const retryTargetRef = useRef<string | undefined>(undefined);
|
const retryTargetRef = useRef<string | undefined>(undefined);
|
||||||
|
const migrationAttemptedRef = useRef(false);
|
||||||
const requestError = requestDetails(error);
|
const requestError = requestDetails(error);
|
||||||
|
const { customServices, hiddenServiceIds } = settings;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
if (settings.configured) {
|
||||||
localStorage.setItem(CUSTOM_SERVICES_KEY, JSON.stringify(customServices));
|
clearLegacyServices();
|
||||||
} catch {
|
return;
|
||||||
// The service still works for this session when browser storage is unavailable.
|
|
||||||
}
|
}
|
||||||
}, [customServices]);
|
if (migrationAttemptedRef.current) return;
|
||||||
|
migrationAttemptedRef.current = true;
|
||||||
useEffect(() => {
|
void updateSettings({
|
||||||
try {
|
customServices: readCustomServices(),
|
||||||
localStorage.setItem(HIDDEN_SERVICES_KEY, JSON.stringify(hiddenServiceIds));
|
hiddenServiceIds: readHiddenServices(),
|
||||||
} catch {
|
}).then((saved) => {
|
||||||
// The service list still works for this session when browser storage is unavailable.
|
if (saved === false) return;
|
||||||
}
|
clearLegacyServices();
|
||||||
}, [hiddenServiceIds]);
|
});
|
||||||
|
}, [settings.configured, updateSettings]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const sheet = sheetRef.current;
|
const sheet = sheetRef.current;
|
||||||
@@ -306,7 +316,7 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
];
|
];
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
setActiveTarget(target);
|
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;
|
const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1;
|
||||||
next = legacyFullResult ? partial : mergeResult(next, partial);
|
next = legacyFullResult ? partial : mergeResult(next, partial);
|
||||||
setResult(next);
|
setResult(next);
|
||||||
@@ -321,17 +331,22 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addService(event: FormEvent<HTMLFormElement>) {
|
async function addService(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
|
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
|
||||||
const parsed = new URL(serviceUrl.trim());
|
const parsed = new URL(serviceUrl.trim());
|
||||||
if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.');
|
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()}`,
|
id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
|
||||||
label: serviceName.trim() || parsed.hostname,
|
label: serviceName.trim() || parsed.hostname,
|
||||||
url: parsed.href,
|
url: parsed.href,
|
||||||
}]);
|
}],
|
||||||
|
hiddenServiceIds,
|
||||||
|
});
|
||||||
|
if (saved === false) throw new Error('Не удалось сохранить сервис.');
|
||||||
setServiceName('');
|
setServiceName('');
|
||||||
setServiceUrl('');
|
setServiceUrl('');
|
||||||
setFormError('');
|
setFormError('');
|
||||||
@@ -342,37 +357,52 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
? Reflect.get(validationError, 'message')
|
? Reflect.get(validationError, 'message')
|
||||||
: undefined;
|
: undefined;
|
||||||
setFormError(typeof message === 'string' ? message : 'Проверьте адрес.');
|
setFormError(typeof message === 'string' ? message : 'Проверьте адрес.');
|
||||||
|
} finally {
|
||||||
|
setSettingsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeService(serviceId: string) {
|
function removeService(serviceId: string) {
|
||||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
finishRemoveService(serviceId);
|
void finishRemoveService(serviceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRemovingServiceId(serviceId);
|
setRemovingServiceId(serviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishRemoveService(serviceId: string) {
|
async function finishRemoveService(serviceId: string) {
|
||||||
const update = () => flushSync(() => {
|
const update = async () => {
|
||||||
if (serviceId === 'draft') {
|
if (serviceId === 'draft') {
|
||||||
|
flushSync(() => {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
setServiceName('');
|
setServiceName('');
|
||||||
setServiceUrl('');
|
setServiceUrl('');
|
||||||
setFormError('');
|
setFormError('');
|
||||||
} else if (serviceId.startsWith('custom-')) {
|
|
||||||
setCustomServices((services) => services.filter((service) => service.id !== serviceId));
|
|
||||||
} else {
|
|
||||||
setHiddenServiceIds((ids) => [...new Set([...ids, serviceId])]);
|
|
||||||
}
|
|
||||||
setRemovingServiceId('');
|
setRemovingServiceId('');
|
||||||
setResult(null);
|
|
||||||
});
|
});
|
||||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
||||||
update();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.startViewTransition(update);
|
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) {
|
||||||
|
await update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await document.startViewTransition(update).finished;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = status === 'running';
|
const pending = status === 'running';
|
||||||
@@ -380,7 +410,7 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)),
|
...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)),
|
||||||
...customServices,
|
...customServices,
|
||||||
];
|
];
|
||||||
const serviceEditorBlocked = pending || Boolean(removingServiceId);
|
const serviceEditorBlocked = pending || settingsSaving || Boolean(removingServiceId);
|
||||||
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
|
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
|
||||||
const { isOpen: open, panelRef, closeRef, close: onClose } = feature;
|
const { isOpen: open, panelRef, closeRef, close: onClose } = feature;
|
||||||
|
|
||||||
@@ -527,7 +557,7 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
<span
|
<span
|
||||||
className="client-delete-strike"
|
className="client-delete-strike"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onAnimationEnd={() => finishRemoveService(site.id)}
|
onAnimationEnd={() => void finishRemoveService(site.id)}
|
||||||
/>
|
/>
|
||||||
</div>;
|
</div>;
|
||||||
})}
|
})}
|
||||||
@@ -572,7 +602,7 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
<span
|
<span
|
||||||
className="client-delete-strike"
|
className="client-delete-strike"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onAnimationEnd={() => finishRemoveService('draft')}
|
onAnimationEnd={() => void finishRemoveService('draft')}
|
||||||
/>
|
/>
|
||||||
</form>}
|
</form>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
|
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
|
||||||
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
|
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
|
||||||
| 'gatewayAuto' | 'routeRules';
|
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings';
|
||||||
|
|
||||||
export interface OperationState {
|
export interface OperationState {
|
||||||
status: 'running';
|
status: 'running';
|
||||||
@@ -21,6 +21,7 @@ const OPERATION_KEYS: readonly OperationKey[] = [
|
|||||||
'profileDelete',
|
'profileDelete',
|
||||||
'gatewayAuto',
|
'gatewayAuto',
|
||||||
'routeRules',
|
'routeRules',
|
||||||
|
'diagnosticsSettings',
|
||||||
];
|
];
|
||||||
|
|
||||||
// The backend has one canonical revision and one mutation queue, so the UI mirrors that lock.
|
// The backend has one canonical revision and one mutation queue, so the UI mirrors that lock.
|
||||||
|
|||||||
@@ -81,10 +81,10 @@
|
|||||||
|
|
||||||
.client-diagnostics-row-name {
|
.client-diagnostics-row-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-row-name > span:first-child {
|
.client-diagnostics-row-name > span:first-child {
|
||||||
@@ -99,6 +99,10 @@
|
|||||||
flex: 0 0 24px;
|
flex: 0 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-row-refresh-wrap {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.client-diagnostics-row-refresh svg {
|
.client-diagnostics-row-refresh svg {
|
||||||
width: 13px;
|
width: 13px;
|
||||||
height: 13px;
|
height: 13px;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ test('connectivity diagnostics endpoint is available in Connect and Gateway thro
|
|||||||
assert.match(server, /const localConnectivityDiagnostics = !remoteDataplane/);
|
assert.match(server, /const localConnectivityDiagnostics = !remoteDataplane/);
|
||||||
assert.doesNotMatch(server, /settings\.appMode !== 'gateway'[\s\S]{0,120}ENDPOINT_NOT_FOUND/);
|
assert.doesNotMatch(server, /settings\.appMode !== 'gateway'[\s\S]{0,120}ENDPOINT_NOT_FOUND/);
|
||||||
assert.match(server, /runConnectivityDiagnostics\(services, target\)/);
|
assert.match(server, /runConnectivityDiagnostics\(services, target\)/);
|
||||||
|
assert.match(server, /sendState: \(res\) => stateRoute\.send\(res\)/);
|
||||||
assert.match(server, /createConnectivityDiagnosticsUseCase\(\{/);
|
assert.match(server, /createConnectivityDiagnosticsUseCase\(\{/);
|
||||||
assert.match(server, /createConnectivityDiagnosticsRoute\(\{/);
|
assert.match(server, /createConnectivityDiagnosticsRoute\(\{/);
|
||||||
assert.match(server, /connectivityDiagnosticsRoute\.handle\(req, res\)/);
|
assert.match(server, /connectivityDiagnosticsRoute\.handle\(req, res\)/);
|
||||||
@@ -69,6 +70,12 @@ test('connectivity diagnostics endpoint is available in Connect and Gateway thro
|
|||||||
test('connectivity use case captures applied server before probes and preserves result fields', async () => {
|
test('connectivity use case captures applied server before probes and preserves result fields', async () => {
|
||||||
const events = [];
|
const events = [];
|
||||||
let state = {
|
let state = {
|
||||||
|
revision: 3,
|
||||||
|
diagnostics: {
|
||||||
|
configured: true,
|
||||||
|
customServices: [{ id: 'custom-saved', label: 'Saved', url: 'https://example.com/' }],
|
||||||
|
hiddenServiceIds: [],
|
||||||
|
},
|
||||||
desiredProfileId: 'primary',
|
desiredProfileId: 'primary',
|
||||||
appliedProfileId: 'primary',
|
appliedProfileId: 'primary',
|
||||||
appliedServerId: 'applied',
|
appliedServerId: 'applied',
|
||||||
@@ -90,22 +97,25 @@ test('connectivity use case captures applied server before probes and preserves
|
|||||||
assessment: { summary: 'available' },
|
assessment: { summary: 'available' },
|
||||||
};
|
};
|
||||||
const useCase = createConnectivityDiagnosticsUseCase({
|
const useCase = createConnectivityDiagnosticsUseCase({
|
||||||
readState: () => {
|
state: {
|
||||||
|
read: () => {
|
||||||
events.push('state');
|
events.push('state');
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
|
update: () => { throw new Error('unexpected update'); },
|
||||||
|
},
|
||||||
runDiagnostics: async (services, target) => {
|
runDiagnostics: async (services, target) => {
|
||||||
events.push(['probe', services, target]);
|
events.push(['probe', services, target]);
|
||||||
await probe;
|
await probe;
|
||||||
return sourceResult;
|
return sourceResult;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const resultPromise = useCase.run({ raw: true }, 42);
|
const resultPromise = useCase.run(42);
|
||||||
state.profiles[0].servers[0].label = 'Changed during probe';
|
state.profiles[0].servers[0].label = 'Changed during probe';
|
||||||
releaseProbe();
|
releaseProbe();
|
||||||
const result = await resultPromise;
|
const result = await resultPromise;
|
||||||
|
|
||||||
assert.deepEqual(events, ['state', ['probe', { raw: true }, 42]]);
|
assert.deepEqual(events, ['state', ['probe', state.diagnostics.customServices, 42]]);
|
||||||
assert.deepEqual(result, {
|
assert.deepEqual(result, {
|
||||||
...sourceResult,
|
...sourceResult,
|
||||||
vpn: {
|
vpn: {
|
||||||
@@ -119,7 +129,8 @@ test('connectivity use case captures applied server before probes and preserves
|
|||||||
test('connectivity use case keeps applied priority, selected fallback and error identity', async () => {
|
test('connectivity use case keeps applied priority, selected fallback and error identity', async () => {
|
||||||
const result = { vpn: { available: false }, marker: true };
|
const result = { vpn: { available: false }, marker: true };
|
||||||
const selected = createConnectivityDiagnosticsUseCase({
|
const selected = createConnectivityDiagnosticsUseCase({
|
||||||
readState: () => ({
|
state: {
|
||||||
|
read: () => ({
|
||||||
desiredProfileId: 'primary',
|
desiredProfileId: 'primary',
|
||||||
appliedServerId: '',
|
appliedServerId: '',
|
||||||
profiles: [{
|
profiles: [{
|
||||||
@@ -128,15 +139,18 @@ test('connectivity use case keeps applied priority, selected fallback and error
|
|||||||
servers: [{ id: 'selected', label: 'Selected' }],
|
servers: [{ id: 'selected', label: 'Selected' }],
|
||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
|
update: () => { throw new Error('unexpected update'); },
|
||||||
|
},
|
||||||
runDiagnostics: async () => result,
|
runDiagnostics: async () => result,
|
||||||
});
|
});
|
||||||
assert.deepEqual((await selected.run(null, null)).vpn.server, {
|
assert.deepEqual((await selected.run(null)).vpn.server, {
|
||||||
id: 'selected',
|
id: 'selected',
|
||||||
label: 'Selected',
|
label: 'Selected',
|
||||||
});
|
});
|
||||||
|
|
||||||
const missingApplied = createConnectivityDiagnosticsUseCase({
|
const missingApplied = createConnectivityDiagnosticsUseCase({
|
||||||
readState: () => ({
|
state: {
|
||||||
|
read: () => ({
|
||||||
desiredProfileId: 'primary',
|
desiredProfileId: 'primary',
|
||||||
appliedProfileId: 'primary',
|
appliedProfileId: 'primary',
|
||||||
appliedServerId: 'missing',
|
appliedServerId: 'missing',
|
||||||
@@ -146,25 +160,67 @@ test('connectivity use case keeps applied priority, selected fallback and error
|
|||||||
servers: [{ id: 'selected', label: 'Selected' }],
|
servers: [{ id: 'selected', label: 'Selected' }],
|
||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
|
update: () => { throw new Error('unexpected update'); },
|
||||||
|
},
|
||||||
runDiagnostics: async () => result,
|
runDiagnostics: async () => result,
|
||||||
});
|
});
|
||||||
assert.equal((await missingApplied.run([], null)).vpn.server, null);
|
assert.equal((await missingApplied.run(null)).vpn.server, null);
|
||||||
|
|
||||||
const stateError = new Error('state failed');
|
const stateError = new Error('state failed');
|
||||||
let probes = 0;
|
let probes = 0;
|
||||||
const brokenState = createConnectivityDiagnosticsUseCase({
|
const brokenState = createConnectivityDiagnosticsUseCase({
|
||||||
readState: () => { throw stateError; },
|
state: {
|
||||||
|
read: () => { throw stateError; },
|
||||||
|
update: () => { throw new Error('unexpected update'); },
|
||||||
|
},
|
||||||
runDiagnostics: async () => { probes += 1; return result; },
|
runDiagnostics: async () => { probes += 1; return result; },
|
||||||
});
|
});
|
||||||
await assert.rejects(brokenState.run([], null), (error) => error === stateError);
|
await assert.rejects(brokenState.run(null), (error) => error === stateError);
|
||||||
assert.equal(probes, 0);
|
assert.equal(probes, 0);
|
||||||
|
|
||||||
const probeError = new Error('probe failed');
|
const probeError = new Error('probe failed');
|
||||||
const brokenProbe = createConnectivityDiagnosticsUseCase({
|
const brokenProbe = createConnectivityDiagnosticsUseCase({
|
||||||
readState: () => ({ servers: [] }),
|
state: {
|
||||||
|
read: () => ({ profiles: [] }),
|
||||||
|
update: () => { throw new Error('unexpected update'); },
|
||||||
|
},
|
||||||
runDiagnostics: async () => { throw probeError; },
|
runDiagnostics: async () => { throw probeError; },
|
||||||
});
|
});
|
||||||
await assert.rejects(brokenProbe.run([], null), (error) => error === probeError);
|
await assert.rejects(brokenProbe.run(null), (error) => error === probeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diagnostics settings validate and replace one canonical revision', () => {
|
||||||
|
let state = {
|
||||||
|
revision: 7,
|
||||||
|
diagnostics: { configured: false, customServices: [], hiddenServiceIds: [] },
|
||||||
|
};
|
||||||
|
const useCase = createConnectivityDiagnosticsUseCase({
|
||||||
|
state: {
|
||||||
|
read: () => state,
|
||||||
|
update: (mutator) => {
|
||||||
|
state = { ...mutator(state), revision: state.revision + 1 };
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runDiagnostics: async () => ({ vpn: {} }),
|
||||||
|
});
|
||||||
|
useCase.updateSettings({
|
||||||
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
||||||
|
hiddenServiceIds: ['speedtest'],
|
||||||
|
}, 7);
|
||||||
|
assert.deepEqual(state.diagnostics, {
|
||||||
|
configured: true,
|
||||||
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
||||||
|
hiddenServiceIds: ['speedtest'],
|
||||||
|
});
|
||||||
|
assert.equal(state.revision, 8);
|
||||||
|
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
|
||||||
|
code: 'STATE_CONFLICT',
|
||||||
|
});
|
||||||
|
assert.throws(() => useCase.updateSettings({
|
||||||
|
customServices: [{ id: 'bad', label: 'Router', url: 'http://router.local/' }],
|
||||||
|
hiddenServiceIds: [],
|
||||||
|
}, 8), { code: 'REQUEST_INVALID' });
|
||||||
});
|
});
|
||||||
|
|
||||||
function routeResponse() {
|
function routeResponse() {
|
||||||
@@ -189,34 +245,46 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
calls.push(args);
|
calls.push(args);
|
||||||
return { checkedAt: 'now', vpn: { server: null } };
|
return { checkedAt: 'now', vpn: { server: null } };
|
||||||
},
|
},
|
||||||
|
updateSettings: (...args) => calls.push(['settings', ...args]),
|
||||||
},
|
},
|
||||||
readBody: async () => {
|
readBody: async () => {
|
||||||
bodyReads += 1;
|
bodyReads += 1;
|
||||||
return body;
|
return body;
|
||||||
},
|
},
|
||||||
|
sendState: async (response) => {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
|
response.end(JSON.stringify({ success: true, state: {} }));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const res = routeResponse();
|
const res = routeResponse();
|
||||||
assert.equal(await route.handle({
|
assert.equal(await route.handle({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/diagnostics/connectivity',
|
url: '/api/diagnostics/connectivity',
|
||||||
}, res), true);
|
}, res), true);
|
||||||
assert.deepEqual(calls, [[[], null]]);
|
assert.deepEqual(calls, [[null]]);
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
||||||
assert.deepEqual(res.payload, { checkedAt: 'now', vpn: { server: null } });
|
assert.deepEqual(res.payload, { checkedAt: 'now', vpn: { server: null } });
|
||||||
|
|
||||||
body = { services: null, target: 17 };
|
body = { services: null, target: 17 };
|
||||||
await route.handle({ method: 'POST', url: '/api/diagnostics/connectivity' }, routeResponse());
|
await route.handle({ method: 'POST', url: '/api/diagnostics/connectivity' }, routeResponse());
|
||||||
assert.deepEqual(calls.at(-1), [null, 17]);
|
assert.deepEqual(calls.at(-1), [17]);
|
||||||
|
|
||||||
|
body = { settings: { customServices: [], hiddenServiceIds: [] }, expectedRevision: 4 };
|
||||||
|
const settingsResponse = routeResponse();
|
||||||
|
assert.equal(await route.handle({ method: 'PUT', url: '/api/diagnostics/settings' }, settingsResponse), true);
|
||||||
|
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
|
||||||
|
assert.equal(settingsResponse.payload.success, true);
|
||||||
|
|
||||||
for (const [method, url] of [
|
for (const [method, url] of [
|
||||||
['GET', '/api/diagnostics/connectivity'],
|
['GET', '/api/diagnostics/connectivity'],
|
||||||
['POST', '/api/diagnostics/connectivity?target=all'],
|
['POST', '/api/diagnostics/connectivity?target=all'],
|
||||||
|
['POST', '/api/diagnostics/settings'],
|
||||||
['POST', '/api/diagnostics/other'],
|
['POST', '/api/diagnostics/other'],
|
||||||
]) {
|
]) {
|
||||||
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
||||||
}
|
}
|
||||||
assert.equal(bodyReads, 2);
|
assert.equal(bodyReads, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { createServerId } from '../../dist/shared/serverIdentity.js';
|
import { createServerId } from '../../dist/shared/serverIdentity.js';
|
||||||
import { HARBOR_VERSIONS } from '../../dist/shared/versions.js';
|
import { HARBOR_VERSIONS } from '../../dist/shared/versions.js';
|
||||||
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
||||||
|
import { STATE_SCHEMA_VERSION } from '../../dist/server/services/stateStore.js';
|
||||||
|
|
||||||
const root = path.resolve(import.meta.dirname, '../..');
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
|
|
||||||
@@ -305,7 +306,7 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.equal(initial.route.localRulesPendingRestart, false);
|
assert.equal(initial.route.localRulesPendingRestart, false);
|
||||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||||
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
||||||
assert.equal(migratedState.schemaVersion, 6);
|
assert.equal(migratedState.schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
assert.equal(migratedState.profiles.length, 1);
|
assert.equal(migratedState.profiles.length, 1);
|
||||||
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
||||||
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
||||||
@@ -318,6 +319,7 @@ setInterval(() => {}, 60_000);
|
|||||||
'apiVersion',
|
'apiVersion',
|
||||||
'configExists',
|
'configExists',
|
||||||
'connection',
|
'connection',
|
||||||
|
'diagnostics',
|
||||||
'fetchedAt',
|
'fetchedAt',
|
||||||
'gatewayAuto',
|
'gatewayAuto',
|
||||||
'generatedAt',
|
'generatedAt',
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
|
|||||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('schema v5 migrates saved and applied rules to v6 with an exact backup', (t) => {
|
test('schema v5 migrates rules and diagnostics settings with an exact backup', (t) => {
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
const legacy = {
|
const legacy = {
|
||||||
schemaVersion: 5,
|
schemaVersion: 5,
|
||||||
@@ -97,7 +97,12 @@ test('schema v5 migrates saved and applied rules to v6 with an exact backup', (t
|
|||||||
});
|
});
|
||||||
const migrated = store.read();
|
const migrated = store.read();
|
||||||
|
|
||||||
assert.equal(migrated.schemaVersion, 6);
|
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
|
assert.deepEqual(migrated.diagnostics, {
|
||||||
|
configured: false,
|
||||||
|
customServices: [],
|
||||||
|
hiddenServiceIds: [],
|
||||||
|
});
|
||||||
assert.equal(migrated.routeRulesRevision, 7);
|
assert.equal(migrated.routeRulesRevision, 7);
|
||||||
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
||||||
assert.deepEqual(migrated.appliedRouteRules.map(({ type, outbound }) => [type, outbound]), [
|
assert.deepEqual(migrated.appliedRouteRules.map(({ type, outbound }) => [type, outbound]), [
|
||||||
@@ -105,7 +110,7 @@ test('schema v5 migrates saved and applied rules to v6 with an exact backup', (t
|
|||||||
['domain', 'direct'],
|
['domain', 'direct'],
|
||||||
]);
|
]);
|
||||||
assert.equal(store.migration.fromVersion, 5);
|
assert.equal(store.migration.fromVersion, 5);
|
||||||
assert.equal(store.migration.toVersion, 6);
|
assert.equal(store.migration.toVersion, STATE_SCHEMA_VERSION);
|
||||||
assert.match(store.migration.backupPath, /\.backup-v5-/);
|
assert.match(store.migration.backupPath, /\.backup-v5-/);
|
||||||
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
|
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,7 +113,14 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
|||||||
method: 'PUT', body: JSON.stringify({ mode: 'direct', expectedRevision: 9 }),
|
method: 'PUT', body: JSON.stringify({ mode: 'direct', expectedRevision: 9 }),
|
||||||
}],
|
}],
|
||||||
[() => api.diagnostics.connectivity(), '/api/diagnostics/connectivity', {
|
[() => api.diagnostics.connectivity(), '/api/diagnostics/connectivity', {
|
||||||
method: 'POST', body: JSON.stringify({ services: [], target: null }),
|
method: 'POST', body: JSON.stringify({ target: null }),
|
||||||
|
}],
|
||||||
|
[() => api.diagnostics.updateSettings({ customServices: [], hiddenServiceIds: [] }, 10), '/api/diagnostics/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: { customServices: [], hiddenServiceIds: [] },
|
||||||
|
expectedRevision: 10,
|
||||||
|
}),
|
||||||
}],
|
}],
|
||||||
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
|
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
|
||||||
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
|
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
|
||||||
|
|||||||
@@ -34,5 +34,6 @@ test('presentational components use only injected narrow actions', () => {
|
|||||||
assert.match(deviceFeature, /requestDeviceUpdate\(device\.id, patch, snapshot\.revision\)/);
|
assert.match(deviceFeature, /requestDeviceUpdate\(device\.id, patch, snapshot\.revision\)/);
|
||||||
assert.match(deviceFeature, /setDevicePolicy\(device\.id, mode, snapshot\.revision\)/);
|
assert.match(deviceFeature, /setDevicePolicy\(device\.id, mode, snapshot\.revision\)/);
|
||||||
assert.match(servers, /await pingServers\(profileId, ids\)/);
|
assert.match(servers, /await pingServers\(profileId, ids\)/);
|
||||||
assert.match(diagnostics, /await runConnectivityDiagnostics\(customServices, target\)/);
|
assert.match(diagnostics, /await runConnectivityDiagnostics\(target\)/);
|
||||||
|
assert.match(overview, /settings=\{state\.diagnostics\}[\s\S]*updateSettings=\{onUpdateDiagnosticsSettings\}/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,17 +76,21 @@ test('unknown target and legacy-full results pass one identity-preserving parser
|
|||||||
{ ...valid, vpn: { ...valid.vpn, server: undefined } },
|
{ ...valid, vpn: { ...valid.vpn, server: undefined } },
|
||||||
{ ...valid, vpn: { ...valid.vpn, server: { id: 1, label: 'Server' } } },
|
{ ...valid, vpn: { ...valid.vpn, server: { id: 1, label: 'Server' } } },
|
||||||
]) assert.throws(() => parseConnectivityResult(invalid), TypeError);
|
]) assert.throws(() => parseConnectivityResult(invalid), TypeError);
|
||||||
assert.match(panel, /parseConnectivityResult\(await runConnectivityDiagnostics\(customServices, target\)\)/);
|
assert.match(panel, /parseConnectivityResult\(await runConnectivityDiagnostics\(target\)\)/);
|
||||||
assert.match(panel, /const legacyFullResult = partial\.direct\.ipv4\.sources\.length > 1 \|\| partial\.direct\.sites\.length > 1/);
|
assert.match(panel, /const legacyFullResult = partial\.direct\.ipv4\.sources\.length > 1 \|\| partial\.direct\.sites\.length > 1/);
|
||||||
assert.match(panel, /next = legacyFullResult \? partial : mergeResult\(next, partial\)/);
|
assert.match(panel, /next = legacyFullResult \? partial : mergeResult\(next, partial\)/);
|
||||||
assert.doesNotMatch(model, /\sas\s(?:ConnectivityResult|Record<string, unknown>)/);
|
assert.doesNotMatch(model, /\sas\s(?:ConnectivityResult|Record<string, unknown>)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('serial probes, storage and editor behavior stay panel-owned', () => {
|
test('serial probes and editor stay panel-owned while backend state owns the service set', () => {
|
||||||
assert.match(panel, /const targets = onlyTarget \? \[onlyTarget\] : \[[\s\S]*CONNECTIVITY_NETWORK_SOURCE\.id[\s\S]*CONNECTIVITY_IP_SOURCES\.map[\s\S]*sites\.map/);
|
assert.match(panel, /const targets = onlyTarget \? \[onlyTarget\] : \[[\s\S]*CONNECTIVITY_NETWORK_SOURCE\.id[\s\S]*CONNECTIVITY_IP_SOURCES\.map[\s\S]*sites\.map/);
|
||||||
assert.match(panel, /for \(const target of targets\) \{[\s\S]*setActiveTarget\(target\)[\s\S]*await runConnectivityDiagnostics\(customServices, target\)[\s\S]*if \(legacyFullResult\) break/);
|
assert.match(panel, /for \(const target of targets\) \{[\s\S]*setActiveTarget\(target\)[\s\S]*await runConnectivityDiagnostics\(target\)[\s\S]*if \(legacyFullResult\) break/);
|
||||||
assert.match(panel, /CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services'/);
|
assert.match(panel, /CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services'/);
|
||||||
assert.match(panel, /HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services'/);
|
assert.match(panel, /HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services'/);
|
||||||
|
assert.match(panel, /if \(settings\.configured\)[\s\S]*clearLegacyServices\(\)/);
|
||||||
|
assert.match(panel, /updateSettings\(\{[\s\S]*customServices:[\s\S]*hiddenServiceIds/);
|
||||||
|
assert.doesNotMatch(panel, /localStorage\.setItem/);
|
||||||
|
assert.doesNotMatch(panel, /useState\(read(?:Custom|Hidden)Services\)/);
|
||||||
assert.match(panel, /slice\(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES\)/);
|
assert.match(panel, /slice\(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES\)/);
|
||||||
assert.match(panel, /parsed\.protocol !== 'https:'/);
|
assert.match(panel, /parsed\.protocol !== 'https:'/);
|
||||||
assert.match(panel, /document\.startViewTransition\(update\)/);
|
assert.match(panel, /document\.startViewTransition\(update\)/);
|
||||||
@@ -112,5 +116,7 @@ test('network identity is one stable compact row for Direct and VPN', () => {
|
|||||||
assert.match(panel, /data-diagnostic-target=\{CONNECTIVITY_NETWORK_SOURCE\.id\}[\s\S]*<NetworkCell[\s\S]*route="Напрямую, сеть"[\s\S]*<NetworkCell[\s\S]*route="VPN, сеть"/);
|
assert.match(panel, /data-diagnostic-target=\{CONNECTIVITY_NETWORK_SOURCE\.id\}[\s\S]*<NetworkCell[\s\S]*route="Напрямую, сеть"[\s\S]*<NetworkCell[\s\S]*route="VPN, сеть"/);
|
||||||
assert.match(panel, /path\?\.network\?\.asn[\s\S]*path\?\.network\?\.provider[\s\S]*path\?\.network\?\.city[\s\S]*path\?\.network\?\.country/);
|
assert.match(panel, /path\?\.network\?\.asn[\s\S]*path\?\.network\?\.provider[\s\S]*path\?\.network\?\.city[\s\S]*path\?\.network\?\.country/);
|
||||||
assert.match(panel, /<Status value=\{status\} route=\{route\} \/><br \/>[\s\S]*client-diagnostics-status is-muted[\s\S]* /);
|
assert.match(panel, /<Status value=\{status\} route=\{route\} \/><br \/>[\s\S]*client-diagnostics-status is-muted[\s\S]* /);
|
||||||
|
assert.match(panel, /if \(!value\) return <Status value=\{\['is-muted', '—'\]\}[\s\S]*if \(!value\?\.address\) return <Status value=\{\['is-error', 'Нет ответа'\]\}/);
|
||||||
|
assert.match(panel, /else if \(path\.network == null\) status = \['is-muted', '—'\][\s\S]*if \(!status && !identity && !location\) status = \['is-error', 'Нет ответа'\]/);
|
||||||
assert.doesNotMatch(panel, /traceroute|tracepath/);
|
assert.doesNotMatch(panel, /traceroute|tracepath/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -211,8 +211,9 @@ test('connectivity diagnostics render stable compact tables before the first run
|
|||||||
assert.match(diagnostics, /document\.startViewTransition\(update\)/);
|
assert.match(diagnostics, /document\.startViewTransition\(update\)/);
|
||||||
assert.match(diagnostics, /client-diagnostics-refresh/);
|
assert.match(diagnostics, /client-diagnostics-refresh/);
|
||||||
assert.match(diagnostics, /client-diagnostics-row-refresh/);
|
assert.match(diagnostics, /client-diagnostics-row-refresh/);
|
||||||
assert.match(rule('.client-diagnostics-row-name'), /display:\s*inline-flex[\s\S]*max-width:\s*100%/);
|
assert.match(rule('.client-diagnostics-row-name'), /display:\s*flex[\s\S]*width:\s*100%/);
|
||||||
assert.match(styles, /\.client-diagnostics-row-refresh-wrap,[\s\S]*flex:\s*0 0 24px/);
|
assert.match(styles, /\.client-diagnostics-row-refresh-wrap,[\s\S]*flex:\s*0 0 24px/);
|
||||||
|
assert.match(rule('.client-diagnostics-row-refresh-wrap'), /margin-left:\s*auto/);
|
||||||
assert.match(diagnostics, /isGateway \? 'Gateway' : 'Connect'/);
|
assert.match(diagnostics, /isGateway \? 'Gateway' : 'Connect'/);
|
||||||
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
|
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
|
||||||
assert.match(diagnostics, /\{Boolean\(error\) && <div className="client-diagnostics-feedback"/);
|
assert.match(diagnostics, /\{Boolean\(error\) && <div className="client-diagnostics-feedback"/);
|
||||||
@@ -221,7 +222,7 @@ test('connectivity diagnostics render stable compact tables before the first run
|
|||||||
assert.doesNotMatch(rule('.client-diagnostics-feedback'), /min-height:/);
|
assert.doesNotMatch(rule('.client-diagnostics-feedback'), /min-height:/);
|
||||||
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
|
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
|
||||||
assert.match(diagnostics, /for \(const target of targets\)/);
|
assert.match(diagnostics, /for \(const target of targets\)/);
|
||||||
assert.match(diagnostics, /runConnectivityDiagnostics\(customServices, target\)/);
|
assert.match(diagnostics, /runConnectivityDiagnostics\(target\)/);
|
||||||
assert.match(diagnostics, /const target = `ip:\$\{source\.id\}`;[\s\S]*activeTarget === target/);
|
assert.match(diagnostics, /const target = `ip:\$\{source\.id\}`;[\s\S]*activeTarget === target/);
|
||||||
assert.match(diagnostics, /activeTarget === `site:\$\{site\.id\}`/);
|
assert.match(diagnostics, /activeTarget === `site:\$\{site\.id\}`/);
|
||||||
assert.match(diagnostics, /data-diagnostic-target=\{target\}/);
|
assert.match(diagnostics, /data-diagnostic-target=\{target\}/);
|
||||||
|
|||||||
@@ -39,24 +39,24 @@ const acceptedLedger = {
|
|||||||
counts: {
|
counts: {
|
||||||
cascadeEdges: 954,
|
cascadeEdges: 954,
|
||||||
customProperties: 106,
|
customProperties: 106,
|
||||||
declarations: 3486,
|
declarations: 3487,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 48,
|
keyframes: 48,
|
||||||
media: 13,
|
media: 13,
|
||||||
rules: 970,
|
rules: 971,
|
||||||
variableReferences: 837,
|
variableReferences: 837,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: 'd53f6a2236717d6bc27d486fc19f0f80a33169fa7f68a6df2a19536fd30be3bb',
|
cascadeEdges: 'd53f6a2236717d6bc27d486fc19f0f80a33169fa7f68a6df2a19536fd30be3bb',
|
||||||
customProperties: 'fd6f16069f9fe3526f09d4d574431ddae67150d8e7a8a40e04c9fd2d179ec7ac',
|
customProperties: 'fd6f16069f9fe3526f09d4d574431ddae67150d8e7a8a40e04c9fd2d179ec7ac',
|
||||||
declarations: '1939d4a9a8b812ea799fbbb22a72befde73aa20159ef6736c9c35a5b3458d32c',
|
declarations: 'e1401e80ed2ebc5d3601d4d61a3b44c2adbf91db451907ef95a0998170ac5de5',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||||
keyframes: 'a0d69c5b3e5f235d3a76ed04bdd64f67a73fafc9c59fe2635ffda603709c0ad9',
|
keyframes: 'a0d69c5b3e5f235d3a76ed04bdd64f67a73fafc9c59fe2635ffda603709c0ad9',
|
||||||
ruleDeclarationSequences: '0a7655c97f5deb132f4c05d136e4d615a0abbbb9e86ab83c8155047bc409bfec',
|
ruleDeclarationSequences: '89f5f3b302f3a10df0ba90dfbbc63a7ccbc1d8a0c784be26a475321f44dfa8e8',
|
||||||
selectors: '0c1374fd3f414faac30f1519e6f0b04f63fc50aa69a05a84d53fbe33c426eccc',
|
selectors: 'dc1a2687ec872d1922a39e8258977949a9b775168764a59119ae6ea93a9f88a1',
|
||||||
variableReferences: '843a5f71b0fb74691ed623dbc7ebb7ddddc34e5736e1f419f17cf54e22d43073',
|
variableReferences: '843a5f71b0fb74691ed623dbc7ebb7ddddc34e5736e1f419f17cf54e22d43073',
|
||||||
witnesses: '83b49149eecdf0a67348412f7cfd49023aad76848b1572d25d6c0e9193e612d5',
|
witnesses: '81676a3fdb6f3bbd63e86b275b6cb6d40e930651b9d5cbde208408f3163ab2b4',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
|||||||
|
|
||||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||||
const witnesses = readStyleWitnesses(root);
|
const witnesses = readStyleWitnesses(root);
|
||||||
assert.equal(witnesses.length, 880);
|
assert.equal(witnesses.length, 884);
|
||||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||||
@@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
|||||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||||
|
|
||||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||||
assert.deepEqual(assets, ['index-DDl9J3Se.css']);
|
assert.deepEqual(assets, ['index-CdbGat4L.css']);
|
||||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||||
assert.equal(built.byteLength, 133627);
|
assert.equal(built.byteLength, 133670);
|
||||||
assert.equal(sha256(built), '68bf7b36cf345bfb460b7f6c9d973bde54e260a0c695176d035c1fe02ba0f3a1');
|
assert.equal(sha256(built), '1aaa222692046eb166e5bfb8a195e53cf0ef61692a82ae53ca0119b4c8f878a2');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user