Refactor VPN proxy components and update related behavior
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 01:27:46 +03:00
parent c89e56942a
commit aa9c959368
58 changed files with 4234 additions and 2755 deletions
@@ -1,4 +1,8 @@
import type { StoredState } from '../../../shared/contracts/state.js';
import {
profileById,
type StoredProfile,
type StoredState,
} from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import { finishRollback } from '../../services/rollback.js';
@@ -7,11 +11,7 @@ interface ConnectionServiceDependencies {
read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
subscription: {
readConfig(): unknown | null;
};
config: {
exists(): boolean;
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
read(): string | null;
write(value: unknown): void;
@@ -25,6 +25,7 @@ interface ConnectionServiceDependencies {
stopCommand(): Promise<RuntimeCommandResult>;
restartCommand(): Promise<RuntimeCommandResult>;
};
route?: { isGatewayDirect(): boolean };
serialize<T>(operation: () => Promise<T>): Promise<T>;
now(): Date;
}
@@ -46,65 +47,125 @@ export async function captureRuntimeCommand(
}
}
function requireExpectedRevision(state: StoredState, expectedRevision: unknown) {
if (expectedRevision === undefined) return;
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) {
throw new HarborError('STATE_CONFLICT');
}
}
function resolveProfile(state: StoredState, profileId: unknown): StoredProfile {
const requested = String(profileId || '').trim();
const profile = profileById(state, requested)
|| (!requested && state.profiles.length === 1 ? state.profiles[0] : null);
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
return profile;
}
function withDesiredServer(state: StoredState, profile: StoredProfile, serverId: string) {
const nextProfile = { ...profile, desiredServerId: serverId };
return {
...state,
profiles: state.profiles.map((candidate) => candidate.id === profile.id ? nextProfile : candidate),
desiredProfileId: profile.id,
};
}
export function createConnectionService(dependencies: ConnectionServiceDependencies) {
const apply = (serverId: unknown, selectedTag: unknown) => dependencies.serialize(async () => {
const previousState = dependencies.state.read();
const requestedId = String(serverId).trim();
const requestedTag = String(selectedTag).trim();
const applyWithinQueue = async (
previousState: StoredState,
profile: StoredProfile,
serverIdValue: unknown,
selectedTagValue: unknown,
) => {
const requestedId = String(serverIdValue || '').trim();
const requestedTag = String(selectedTagValue || '').trim();
const resolvedId = requestedId || (() => {
const matches = previousState.servers.filter((server) => server.label === requestedTag);
const matches = profile.servers.filter((server) => server.label === requestedTag);
return matches.length === 1 ? matches[0].id : '';
})();
const selectedServer = previousState.servers.find((server) => server.id === resolvedId);
const selectedServer = profile.servers.find((server) => server.id === resolvedId);
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
if (!profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID');
if (dependencies.route?.isGatewayDirect()) {
dependencies.state.update((state) => withDesiredServer(state, profile, selectedServer.id));
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
}
const subscriptionConfig = dependencies.subscription.readConfig();
if (!subscriptionConfig) throw new HarborError('CONFIG_INVALID');
const nextConfig = dependencies.config.build(
subscriptionConfig,
profile.subscriptionConfig,
selectedServer.id,
previousState.routeRules,
);
const previousConfig = dependencies.config.read();
const wasRunning = await dependencies.runtime.isRunning();
let desiredCommitStarted = false;
let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
try {
desiredCommitStarted = true;
dependencies.state.update((state) => ({
...state,
selectedServerId: selectedServer.id,
connectionDesired: 'running',
}));
configMutationStarted = true;
dependencies.config.write(nextConfig);
runtimeMutationStarted = true;
await dependencies.runtime.start();
stateCommitStarted = true;
dependencies.state.update((state) => ({
...state,
...withDesiredServer(state, profile, selectedServer.id),
connectionDesired: 'running',
appliedProfileId: profile.id,
appliedServerId: selectedServer.id,
appliedServerSnapshot: selectedServer,
appliedAt: dependencies.now().toISOString(),
appliedRouteRules: state.routeRules,
}));
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
...(configMutationStarted ? [{
run: () => previousConfig === null
? dependencies.config.remove()
: dependencies.config.restore(previousConfig),
}] : []),
...(configMutationStarted ? [{
...(runtimeMutationStarted ? [{
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
runtime: true,
}] : []),
...(desiredCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
], 'Connection rollback failed');
}
return { serverId: selectedServer.id, selectedTag: selectedServer.label };
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
};
const apply = (
profileId: unknown,
serverId: unknown,
selectedTag: unknown = '',
expectedRevision?: unknown,
) => dependencies.serialize(async () => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
return applyWithinQueue(state, resolveProfile(state, profileId), serverId, selectedTag);
});
const activate = (profileId: unknown, expectedRevision?: unknown) => (
dependencies.serialize(async () => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
const profile = resolveProfile(state, profileId);
const selectedServer = profile.servers.find((server) => server.id === profile.desiredServerId);
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
const running = await dependencies.runtime.isRunning();
if (!running || dependencies.route?.isGatewayDirect()) {
if (state.desiredProfileId !== profile.id) {
dependencies.state.update((current) => ({ ...current, desiredProfileId: profile.id }));
}
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
}
return applyWithinQueue(state, profile, selectedServer.id, '');
})
);
const stop = () => dependencies.serialize(async () => {
const previousState = dependencies.state.read();
let wasRunning: boolean | null = null;
@@ -119,7 +180,13 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error;
stateCommitStarted = true;
dependencies.state.update((state) => ({ ...state, connectionDesired: 'stopped' }));
dependencies.state.update((state) => ({
...state,
connectionDesired: 'stopped',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
}));
} catch (error) {
await finishRollback(error, [
...(runtimeMutationStarted && wasRunning !== null ? [{
@@ -133,28 +200,53 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
const restart = () => dependencies.serialize(async () => {
const previousState = dependencies.state.read();
if (!dependencies.config.exists()) throw new HarborError('CONFIG_INVALID');
let wasRunning: boolean | null = null;
try {
wasRunning = await dependencies.runtime.isRunning();
} catch {}
const wasRunning = await dependencies.runtime.isRunning();
const targetProfileId = wasRunning
? previousState.appliedProfileId
: previousState.desiredProfileId;
const targetServerId = wasRunning
? previousState.appliedServerId
: resolveProfile(previousState, targetProfileId).desiredServerId;
const profile = resolveProfile(previousState, targetProfileId);
const server = profile.servers.find((candidate) => candidate.id === targetServerId)
|| (previousState.appliedServerSnapshot?.id === targetServerId
? previousState.appliedServerSnapshot
: null);
if (!server || !profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID');
const candidateConfig = dependencies.config.build(
profile.subscriptionConfig,
server.id,
previousState.routeRules,
);
const previousConfig = dependencies.config.read();
let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
try {
configMutationStarted = true;
dependencies.config.write(candidateConfig);
const command = await dependencies.runtime.restartCommand();
runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error;
stateCommitStarted = true;
dependencies.state.update((state) => ({
...state,
appliedServerId: state.selectedServerId,
desiredProfileId: wasRunning ? state.desiredProfileId : profile.id,
appliedProfileId: profile.id,
appliedServerId: server.id,
appliedServerSnapshot: server,
connectionDesired: 'running',
appliedRouteRules: state.routeRules,
}));
} catch (error) {
await finishRollback(error, [
...(runtimeMutationStarted && wasRunning !== null ? [{
...(configMutationStarted ? [{
run: () => previousConfig === null
? dependencies.config.remove()
: dependencies.config.restore(previousConfig),
}] : []),
...(runtimeMutationStarted ? [{
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
runtime: true,
}] : []),
@@ -163,7 +255,7 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
}
});
return { apply, stop, restart };
return { apply, activate, stop, restart };
}
export type ConnectionService = ReturnType<typeof createConnectionService>;
@@ -1,12 +1,11 @@
interface DiagnosticServer {
id: unknown;
label: unknown;
}
import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js';
interface DiagnosticState {
desiredProfileId?: unknown;
appliedProfileId?: unknown;
appliedServerId?: unknown;
selectedServerId?: unknown;
servers?: DiagnosticServer[];
appliedServerSnapshot?: HarborServer | null;
profiles?: StoredProfile[];
}
interface DiagnosticsResult extends Record<string, unknown> {
@@ -25,15 +24,24 @@ function diagnosticsResult(value: unknown): DiagnosticsResult {
return value as DiagnosticsResult;
}
function selectedServer(state: DiagnosticState) {
const profiles = Array.isArray(state.profiles) ? state.profiles : [];
const appliedProfile = profiles.find((profile) => profile.id === state.appliedProfileId);
const applied = appliedProfile?.servers.find((server) => server.id === state.appliedServerId)
|| (state.appliedServerSnapshot?.id === state.appliedServerId
? state.appliedServerSnapshot
: null);
if (state.appliedServerId) return applied;
const desiredProfile = profiles.find((profile) => profile.id === state.desiredProfileId);
return desiredProfile?.servers.find((server) => server.id === desiredProfile.desiredServerId) || null;
}
export function createConnectivityDiagnosticsUseCase(
dependencies: ConnectivityDiagnosticsDependencies,
) {
return {
async run(services: unknown, target: unknown) {
const state = dependencies.readState();
const appliedServerId = state.appliedServerId || state.selectedServerId;
const selected = (Array.isArray(state.servers) ? state.servers : [])
.find((server) => server.id === appliedServerId);
const selected = selectedServer(dependencies.readState());
const server = selected ? { id: selected.id, label: selected.label } : null;
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
return {
@@ -1,6 +1,12 @@
import { isDeepStrictEqual } from 'node:util';
import type { GatewayAutoState, StoredState } from '../../../shared/contracts/state.js';
import {
appliedProfile,
desiredProfile,
type GatewayAutoState,
type StoredState,
} from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import type { RuntimeCommandResult } from '../connection/index.js';
import { finishRollback } from '../../services/rollback.js';
@@ -25,7 +31,7 @@ interface GatewayAutoServiceDependencies {
read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
subscription: { readConfig(): unknown | null };
subscription: { readConfig(profileId: string): unknown | null };
config: {
build(
subscriptionConfig: unknown,
@@ -42,6 +48,7 @@ interface GatewayAutoServiceDependencies {
isRunning(): boolean;
applyCommand(): Promise<RuntimeCommandResult>;
restoreRunning(): Promise<unknown>;
stopCommand(): Promise<RuntimeCommandResult>;
};
discovery: {
readHostNetwork(): HostNetworkState | null;
@@ -108,22 +115,76 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
const previousGatewayAuto = current;
const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate);
const modeChanged = previousGatewayAuto.mode !== candidate.mode;
const leavesGatewayDirect = previousGatewayAuto.mode === 'gateway-direct'
&& candidate.mode !== 'gateway-direct';
if (!stateChanged && persistEnabled === undefined) return current;
const previousState = dependencies.state.read();
const subscriptionConfig = modeChanged
? dependencies.subscription.readConfig()
const wasRunning = modeChanged ? dependencies.runtime.isRunning() : false;
const targetProfile = wasRunning
? appliedProfile(previousState)
: desiredProfile(previousState);
const targetServerId = wasRunning
? previousState.appliedServerId
: targetProfile?.desiredServerId || '';
const subscriptionConfig = modeChanged && targetProfile
? dependencies.subscription.readConfig(targetProfile.id)
: null;
const candidateConfig = modeChanged && previousState.selectedServerId && subscriptionConfig
? dependencies.config.build(
const stopUnavailableTarget = async (cause: unknown) => {
let runtimeMutationStarted = false;
let gatewayAutoPublished = false;
let stateCommitStarted = false;
try {
const command = await dependencies.runtime.stopCommand();
runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error;
current = candidate;
gatewayAutoPublished = true;
stateCommitStarted = true;
dependencies.state.update((state) => ({
...state,
connectionDesired: 'stopped',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
...(persistEnabled === undefined ? {} : { gatewayAutoEnabled: persistEnabled }),
}));
} catch (error) {
await finishRollback(error, [
...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []),
...(runtimeMutationStarted ? [{
run: () => dependencies.runtime.restoreRunning(),
runtime: true,
}] : []),
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
], 'Gateway auto safe-stop rollback failed');
}
dependencies.onDiscoveryWarning(errorMessage(cause));
if (modeChanged) dependencies.onRouteChange(candidate);
throw cause;
};
if (modeChanged && wasRunning && (!targetProfile || !targetServerId || !subscriptionConfig)) {
if (leavesGatewayDirect) return stopUnavailableTarget(new HarborError('CONFIG_INVALID'));
throw new HarborError('CONFIG_INVALID');
}
let candidateConfig: unknown | null = null;
if (modeChanged && targetServerId && subscriptionConfig) {
try {
candidateConfig = dependencies.config.build(
subscriptionConfig,
previousState.selectedServerId,
targetServerId,
previousState.routeRules,
candidate,
)
: null;
);
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
if (wasRunning && leavesGatewayDirect && ['CONFIG_INVALID', 'SERVER_NOT_FOUND'].includes(code)) {
return stopUnavailableTarget(error);
}
throw error;
}
}
const previousConfig = candidateConfig === null ? null : dependencies.config.read();
const wasRunning = candidateConfig === null ? false : dependencies.runtime.isRunning();
let configMutationStarted = false;
let runtimeMutationStarted = false;
let gatewayAutoPublished = false;
@@ -171,7 +232,9 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
const runRefresh = async ({ reconfigure = true }: RefreshOptions) => {
const state = dependencies.state.read();
const network = state.subscriptionUrl
const profile = desiredProfile(state);
const subscriptionUrl = profile?.subscriptionUrl || '';
const network = subscriptionUrl
? dependencies.discovery.readHostNetwork()
: null;
@@ -182,7 +245,7 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
error: discoveryError,
});
const candidate = dependencies.transition.applyPreference(
state.subscriptionUrl
subscriptionUrl
? { ...discoveredState, lastError: discoveryError }
: discoveredState,
state.gatewayAutoEnabled !== false,
@@ -204,16 +267,17 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
try {
verifiedGateway = await dependencies.discovery.probeGateway({
gateway: network.gateway,
subscriptionUrl: String(state.subscriptionUrl),
subscriptionUrl,
});
} catch (error) {
const reason = errorMessage(error);
const latestState = dependencies.state.read();
const latestNetwork = latestState.subscriptionUrl
const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
const latestNetwork = latestSubscriptionUrl
? dependencies.discovery.readHostNetwork()
: null;
if (
latestState.subscriptionUrl !== state.subscriptionUrl ||
latestSubscriptionUrl !== subscriptionUrl ||
!dependencies.transition.sameRoute(network, latestNetwork)
) {
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
@@ -232,11 +296,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
}
const latestState = dependencies.state.read();
const latestNetwork = latestState.subscriptionUrl
const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
const latestNetwork = latestSubscriptionUrl
? dependencies.discovery.readHostNetwork()
: null;
if (
latestState.subscriptionUrl !== state.subscriptionUrl ||
latestSubscriptionUrl !== subscriptionUrl ||
!dependencies.transition.sameRoute(network, latestNetwork)
) {
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
@@ -1,6 +1,11 @@
import { isDeepStrictEqual } from 'node:util';
import type { RouteRule, StoredState } from '../../../shared/contracts/state.js';
import {
appliedProfile,
desiredProfile,
type RouteRule,
type StoredState,
} from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import { normalizeRouteRules } from '../../../shared/routingRules.js';
import type { RuntimeCommandResult } from '../connection/index.js';
@@ -11,7 +16,7 @@ interface RouteRulesDependencies {
read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
subscription: { readConfig(): unknown | null };
subscription: { readConfig(profileId: string): unknown | null };
config: {
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
read(): string | null;
@@ -30,8 +35,17 @@ interface RouteRulesDependencies {
export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
const subscriptionConfig = dependencies.subscription.readConfig();
if (!previousState.selectedServerId || !subscriptionConfig) {
const wasRunning = await dependencies.runtime.isRunning();
const targetProfile = wasRunning
? appliedProfile(previousState)
: desiredProfile(previousState);
const targetServerId = wasRunning
? previousState.appliedServerId
: targetProfile?.desiredServerId || '';
const subscriptionConfig = targetProfile
? dependencies.subscription.readConfig(targetProfile.id)
: null;
if (!targetServerId || !subscriptionConfig) {
let stateCommitStarted = false;
try {
stateCommitStarted = true;
@@ -50,11 +64,10 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const candidateConfig = dependencies.config.build(
subscriptionConfig,
previousState.selectedServerId,
targetServerId,
routeRules,
);
const previousConfig = dependencies.config.read();
const wasRunning = await dependencies.runtime.isRunning();
let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
+11 -6
View File
@@ -1,4 +1,5 @@
import type { HarborServer } from '../../../shared/contracts/state.js';
import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
export const SERVER_HEALTH_MAX_COUNT = 30;
export const SERVER_HEALTH_CONCURRENCY = 4;
@@ -36,19 +37,23 @@ export async function checkServerHealth(
}
interface ServerHealthDependencies {
readServers(): HarborServer[];
readProfiles(): StoredProfile[];
readDesiredProfileId(): string;
ping: Ping;
}
export function createServerHealthService(dependencies: ServerHealthDependencies) {
return {
check(serverIds: unknown) {
check(profileIdValue: unknown, serverIds: unknown) {
const requestedProfileId = String(profileIdValue || '').trim();
const profileId = requestedProfileId || dependencies.readDesiredProfileId();
const profile = dependencies.readProfiles().find((candidate) => candidate.id === profileId);
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
const servers = dependencies.readServers();
return checkServerHealth(
requestedIds.size
? servers.filter((server) => requestedIds.has(server.id))
: servers,
? profile.servers.filter((server) => requestedIds.has(server.id))
: profile.servers,
dependencies.ping,
);
},
@@ -1,14 +1,17 @@
import type {
GatewayAutoState,
HarborServer,
StoredState,
import crypto from 'node:crypto';
import {
profileById,
type GatewayAutoState,
type HarborServer,
type StoredProfile,
type StoredState,
} from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import { finishRollback } from '../../services/rollback.js';
interface ParsedSubscription {
config: unknown;
sourceConfig?: unknown;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt: string;
@@ -29,11 +32,6 @@ interface SubscriptionServiceDependencies {
read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
cache: {
read(): unknown;
write(value: unknown): void;
remove(): void;
};
config: {
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
read(): string | null;
@@ -57,216 +55,385 @@ interface SubscriptionServiceDependencies {
clearInterval(handle: TimerHandle): void;
};
onRefreshError(error: unknown): void;
now?: () => Date;
}
interface ResetOptions {
stopRuntime?: boolean;
expectedSubscription?: {
url: string;
generation: number;
};
}
export interface SubscriptionMutationResult extends Record<string, unknown> {
export interface ProfileMutationResult extends Record<string, unknown> {
success: true;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt: string;
selectedServerId: string;
selectedTag: string;
profileId: string;
label: string;
}
const TERMINAL_SUBSCRIPTION_CODES = new Set([
'SUBSCRIPTION_EXPIRED',
'SUBSCRIPTION_DISABLED',
'SUBSCRIPTION_REJECTED',
]);
const safeErrorCode = (error: unknown) => (
error && typeof error === 'object' && 'code' in error
? String(error.code)
: 'UNKNOWN'
);
const cleanLabel = (value: unknown) => String(value || '').trim();
const foldedLabel = (value: unknown) => cleanLabel(value).toLocaleLowerCase('ru');
function requireLabel(value: unknown) {
const label = cleanLabel(value);
if (!label || label.length > 64) throw new HarborError('REQUEST_INVALID');
return label;
}
function requireExpectedRevision(state: StoredState, expectedRevision: unknown) {
if (expectedRevision === undefined) return;
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) {
throw new HarborError('STATE_CONFLICT');
}
}
function requireProfile(state: StoredState, profileId: unknown) {
const profile = profileById(state, profileId);
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
return profile;
}
function assertUniqueLabel(state: StoredState, label: string, exceptProfileId = '') {
if (state.profiles.some((profile) => (
profile.id !== exceptProfileId && foldedLabel(profile.label) === foldedLabel(label)
))) throw new HarborError('PROFILE_NAME_CONFLICT');
}
function replaceProfile(state: StoredState, nextProfile: StoredProfile) {
return state.profiles.map((profile) => profile.id === nextProfile.id ? nextProfile : profile);
}
function mutationResult(profile: Pick<StoredProfile, 'id' | 'label'>): ProfileMutationResult {
return { success: true, profileId: profile.id, label: profile.label };
}
export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) {
let refreshPromise: Promise<SubscriptionMutationResult> | null = null;
const refreshPromises = new Map<string, Promise<ProfileMutationResult>>();
let refreshTimer: TimerHandle | null = null;
let subscriptionGeneration = 0;
const restoreCache = (previous: unknown) => {
if (previous !== null) dependencies.cache.write(previous);
else dependencies.cache.remove();
};
const now = dependencies.now || (() => new Date());
const restoreConfig = (previous: string | null) => {
if (previous === null) dependencies.config.remove();
else dependencies.config.restore(previous);
};
const commitSubscription = (
const preflightAddProfile = (labelValue: unknown, expectedRevision?: unknown) => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
assertUniqueLabel(state, requireLabel(labelValue));
};
const preflightRenameProfile = (
profileId: unknown,
labelValue: unknown,
expectedRevision?: unknown,
) => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
const profile = requireProfile(state, profileId);
assertUniqueLabel(state, requireLabel(labelValue), profile.id);
};
const addProfile = async (
labelValue: unknown,
subscriptionUrlValue: unknown,
expectedRevision?: unknown,
) => {
const label = requireLabel(labelValue);
const subscriptionUrl = String(subscriptionUrlValue || '').trim();
const preflight = dependencies.state.read();
requireExpectedRevision(preflight, expectedRevision);
assertUniqueLabel(preflight, label);
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
// Admission CAS already passed; background freshness may advance the global revision during provider I/O.
return dependencies.serialize(async () => {
const state = dependencies.state.read();
assertUniqueLabel(state, label);
const profile: StoredProfile = {
id: `profile_${crypto.randomUUID()}`,
label,
subscriptionUrl,
subscriptionConfig: parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
desiredServerId: '',
lastRefreshAttemptAt: parsed.fetchedAt,
lastRefreshErrorCode: null,
};
dependencies.state.update((current) => ({
...current,
profiles: [...current.profiles, profile],
desiredProfileId: current.profiles.length ? current.desiredProfileId : profile.id,
}));
return mutationResult(profile);
});
};
const renameProfile = (profileId: unknown, labelValue: unknown, expectedRevision?: unknown) => (
dependencies.serialize(async () => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
const profile = requireProfile(state, profileId);
const label = requireLabel(labelValue);
if (profile.label === label) return mutationResult(profile);
assertUniqueLabel(state, label, profile.id);
const renamed = { ...profile, label };
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, renamed),
}));
return mutationResult(renamed);
})
);
const selectProfileServer = (
profileId: unknown,
serverIdValue: unknown,
expectedRevision?: unknown,
) => dependencies.serialize(async () => {
const state = dependencies.state.read();
requireExpectedRevision(state, expectedRevision);
const profile = requireProfile(state, profileId);
const serverId = String(serverIdValue || '').trim();
if (!profile.servers.some((server) => server.id === serverId)) {
throw new HarborError('SERVER_NOT_FOUND');
}
if (profile.desiredServerId === serverId) return mutationResult(profile);
const selected = { ...profile, desiredServerId: serverId };
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, selected),
}));
return mutationResult(selected);
});
const recordRefreshError = async (
profileId: string,
subscriptionUrl: string,
error: unknown,
) => dependencies.serialize(async () => {
const state = dependencies.state.read();
const profile = requireProfile(state, profileId);
if (profile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT');
const failed = {
...profile,
lastRefreshAttemptAt: now().toISOString(),
lastRefreshErrorCode: safeErrorCode(error),
};
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, failed),
}));
});
const commitRefresh = (
profileId: string,
subscriptionUrl: string,
parsed: ParsedSubscription,
{ resetSelection = false, expectedGeneration }: {
resetSelection?: boolean;
expectedGeneration?: number;
} = {},
) => dependencies.serialize(async () => {
// Re-read the profile after provider I/O and guard its owner instead of rejecting background-only revisions.
const previousState = dependencies.state.read();
if (
subscriptionGeneration !== expectedGeneration ||
(!resetSelection && previousState.subscriptionUrl !== subscriptionUrl)
) {
throw new HarborError('STATE_CONFLICT');
}
const previousProfile = requireProfile(previousState, profileId);
if (previousProfile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT');
const selectedServerId = resetSelection
? ''
: dependencies.provider.selectRefreshedServer(
previousState.selectedServerId,
previousState.servers,
parsed.servers,
);
const candidateConfig = selectedServerId
? dependencies.config.build(parsed.config, selectedServerId, previousState.routeRules)
: null;
const previousCache = dependencies.cache.read();
const previousConfig = dependencies.config.read();
const previousGatewayAuto = dependencies.gatewayAuto.read();
const wasRunning = await dependencies.runtime.isRunning();
let restoreRuntime = false;
let stateCommitStarted = false;
try {
if ((resetSelection || !candidateConfig) && wasRunning) {
restoreRuntime = true;
await dependencies.runtime.stop();
}
if (candidateConfig) dependencies.config.write(candidateConfig);
else dependencies.config.remove();
dependencies.cache.write({
url: subscriptionUrl,
config: parsed.sourceConfig || parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
if (!resetSelection && wasRunning && candidateConfig) {
restoreRuntime = true;
await dependencies.runtime.start();
}
if (resetSelection) dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
stateCommitStarted = true;
dependencies.state.update((state) => ({
...(resetSelection ? {
routeRules: state.routeRules,
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
connectionDesired: 'stopped',
} : state),
subscriptionUrl,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
}));
subscriptionGeneration += 1;
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
{ run: () => restoreCache(previousCache) },
{ run: () => restoreConfig(previousConfig) },
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription rollback failed');
}
return {
success: true as const,
const desiredServerId = dependencies.provider.selectRefreshedServer(
previousProfile.desiredServerId,
previousProfile.servers,
parsed.servers,
);
const refreshedProfile: StoredProfile = {
...previousProfile,
subscriptionConfig: parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
desiredServerId,
lastRefreshAttemptAt: parsed.fetchedAt,
lastRefreshErrorCode: null,
};
const running = await dependencies.runtime.isRunning();
const refreshesApplied = running && previousState.appliedProfileId === profileId;
const nextAppliedServerId = refreshesApplied
? dependencies.provider.selectRefreshedServer(
previousState.appliedServerId,
previousProfile.servers,
parsed.servers,
)
: '';
if (!refreshesApplied || !nextAppliedServerId) {
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, refreshedProfile),
}));
return mutationResult(refreshedProfile);
}
const nextAppliedServer = parsed.servers.find((server) => server.id === nextAppliedServerId)!;
const candidateConfig = dependencies.config.build(
parsed.config,
nextAppliedServerId,
previousState.routeRules,
);
const previousConfig = dependencies.config.read();
let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
try {
configMutationStarted = true;
dependencies.config.write(candidateConfig);
runtimeMutationStarted = true;
await dependencies.runtime.start();
stateCommitStarted = true;
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, refreshedProfile),
appliedServerId: nextAppliedServerId,
appliedServerSnapshot: nextAppliedServer,
}));
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
...(runtimeMutationStarted ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription refresh rollback failed');
}
return mutationResult(refreshedProfile);
});
const importSubscription = async (subscriptionUrl: string) => {
const expectedGeneration = subscriptionGeneration;
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true, expectedGeneration });
const refreshProfile = (profileIdValue: unknown, expectedRevision?: unknown) => {
const profileId = String(profileIdValue || '').trim();
const existing = refreshPromises.get(profileId);
if (existing) return existing;
const initialState = dependencies.state.read();
requireExpectedRevision(initialState, expectedRevision);
const initialProfile = requireProfile(initialState, profileId);
const operation = (async () => {
let parsed: ParsedSubscription;
try {
parsed = await dependencies.provider.fetchSubscription(initialProfile.subscriptionUrl);
} catch (error) {
if (safeErrorCode(error) !== 'STATE_CONFLICT') {
await recordRefreshError(
profileId,
initialProfile.subscriptionUrl,
error,
);
}
throw error;
}
return commitRefresh(
profileId,
initialProfile.subscriptionUrl,
parsed,
);
})().finally(() => refreshPromises.delete(profileId));
refreshPromises.set(profileId, operation);
return operation;
};
const deleteProfile = (
profileIdValue: unknown,
modeValue: unknown = 'delete',
expectedRevision?: unknown,
) => dependencies.serialize(async () => {
const previousState = dependencies.state.read();
requireExpectedRevision(previousState, expectedRevision);
const profile = requireProfile(previousState, profileIdValue);
const mode = String(modeValue || 'delete');
if (!['delete', 'stop-and-delete'].includes(mode)) throw new HarborError('REQUEST_INVALID');
const running = await dependencies.runtime.isRunning();
const applied = running && previousState.appliedProfileId === profile.id;
if (applied && mode !== 'stop-and-delete') throw new HarborError('PROFILE_IN_USE');
const previousConfig = dependencies.config.read();
const previousGatewayAuto = dependencies.gatewayAuto.read();
const removesAppliedTarget = previousState.appliedProfileId === profile.id;
let runtimeMutationStarted = false;
let configMutationStarted = false;
let gatewayMutationStarted = false;
let stateCommitStarted = false;
try {
if (applied) {
runtimeMutationStarted = true;
await dependencies.runtime.stop();
}
if (removesAppliedTarget) {
configMutationStarted = true;
dependencies.config.remove();
}
if (
previousState.desiredProfileId === profile.id
&& !(running && previousState.appliedProfileId !== profile.id)
) {
gatewayMutationStarted = true;
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
}
stateCommitStarted = true;
dependencies.state.update((current) => ({
...current,
profiles: current.profiles.filter((candidate) => candidate.id !== profile.id),
desiredProfileId: current.desiredProfileId === profile.id ? '' : current.desiredProfileId,
appliedProfileId: current.appliedProfileId === profile.id ? '' : current.appliedProfileId,
appliedServerId: current.appliedProfileId === profile.id ? '' : current.appliedServerId,
appliedServerSnapshot: current.appliedProfileId === profile.id
? null
: current.appliedServerSnapshot,
...(current.appliedProfileId === profile.id ? { connectionDesired: 'stopped' } : {}),
}));
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
...(gatewayMutationStarted ? [{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) }] : []),
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
...(runtimeMutationStarted ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription delete rollback failed');
}
return mutationResult(profile);
});
// One-release compatibility for the old single-subscription client.
const importSubscription = (subscriptionUrl: string, expectedRevision?: unknown) => {
const state = dependencies.state.read();
if (state.profiles.length) throw new HarborError('STATE_CONFLICT');
return addProfile('Основной', subscriptionUrl, expectedRevision);
};
const refreshSavedSubscription = (expectedRevision?: unknown) => {
const state = dependencies.state.read();
if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT');
return refreshProfile(state.profiles[0].id, expectedRevision);
};
const resetSavedSubscription = ({
stopRuntime = true,
expectedSubscription,
}: ResetOptions = {}) => (
dependencies.serialize(async () => {
const previousState = dependencies.state.read();
if (expectedSubscription && (
previousState.subscriptionUrl !== expectedSubscription.url ||
subscriptionGeneration !== expectedSubscription.generation
)) return false;
const previousCache = dependencies.cache.read();
const previousConfig = dependencies.config.read();
const previousGatewayAuto = dependencies.gatewayAuto.read();
const wasRunning = stopRuntime ? await dependencies.runtime.isRunning() : false;
let restoreRuntime = false;
let stateCommitStarted = false;
try {
if (stopRuntime) {
restoreRuntime = wasRunning;
await dependencies.runtime.stop();
}
dependencies.config.remove();
dependencies.cache.remove();
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
stateCommitStarted = true;
dependencies.state.update(() => ({ routeRules: previousState.routeRules }));
subscriptionGeneration += 1;
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
{ run: () => restoreCache(previousCache) },
{ run: () => restoreConfig(previousConfig) },
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription rollback failed');
}
return true;
})
);
const refreshSavedSubscription = () => {
if (refreshPromise) return refreshPromise;
const subscriptionUrl = dependencies.state.read().subscriptionUrl;
const expectedGeneration = subscriptionGeneration;
const operation = (async () => {
try {
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
return await commitSubscription(subscriptionUrl, parsed, { expectedGeneration });
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error
? String(error.code)
: '';
if (subscriptionUrl && TERMINAL_SUBSCRIPTION_CODES.has(code)) {
const reset = await resetSavedSubscription({
expectedSubscription: { url: subscriptionUrl, generation: expectedGeneration },
});
if (!reset) throw new HarborError('STATE_CONFLICT');
}
throw error;
}
})().finally(() => {
refreshPromise = null;
});
refreshPromise = operation;
return operation;
expectedRevision,
}: { stopRuntime?: boolean; expectedRevision?: unknown } = {}) => {
const state = dependencies.state.read();
if (!state.profiles.length) return Promise.resolve(false);
if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT');
return deleteProfile(
state.profiles[0].id,
stopRuntime ? 'stop-and-delete' : 'delete',
expectedRevision,
).then(() => true);
};
const startAutoRefresh = (intervalMs: number) => {
if (refreshTimer) return;
refreshTimer = dependencies.scheduler.setInterval(() => {
if (!dependencies.state.read().subscriptionUrl) return;
void refreshSavedSubscription().catch(dependencies.onRefreshError);
void (async () => {
for (const { id } of dependencies.state.read().profiles) {
try {
await refreshProfile(id);
} catch (error) {
dependencies.onRefreshError(error);
}
}
})();
}, intervalMs);
refreshTimer.unref();
};
@@ -278,6 +445,13 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen
};
return {
preflightAddProfile,
preflightRenameProfile,
addProfile,
renameProfile,
selectProfileServer,
refreshProfile,
deleteProfile,
importSubscription,
refreshSavedSubscription,
resetSavedSubscription,
+19 -4
View File
@@ -5,18 +5,33 @@ import type { ConnectionService } from '../../features/connection/index.js';
interface ServerApplyRouteDependencies {
connection: Pick<ConnectionService, 'apply'>;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
sendState(res: ServerResponse, extra: { serverId: string; selectedTag: string }): Promise<void>;
withOperation<T>(
kind: string,
operation: (operationRevision: number) => Promise<T>,
options?: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown },
): Promise<T>;
sendState(res: ServerResponse, extra: { profileId: string; serverId: string; selectedTag: string }): Promise<void>;
}
export function createServerApplyRoute(dependencies: ServerApplyRouteDependencies) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'POST' || req.url !== '/api/apply') return false;
const { serverId = '', selectedTag = '' } = await dependencies.readBody(req);
const {
profileId = '',
serverId = '',
selectedTag = '',
expectedRevision,
} = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'apply-server',
() => dependencies.connection.apply(serverId, selectedTag),
(operationRevision) => dependencies.connection.apply(
profileId,
serverId,
selectedTag,
operationRevision,
),
{ expectedRevision, profileId, serverId },
);
await dependencies.sendState(res, result);
return true;
+10 -4
View File
@@ -5,16 +5,22 @@ import type { ServerHealthService } from '../../features/servers/index.js';
interface ServerHealthRouteDependencies {
serverHealth: ServerHealthService;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
sendState(res: ServerResponse, extra: { results: Array<Record<string, unknown>> }): Promise<void>;
sendState(res: ServerResponse, extra: {
profileId: string;
results: Array<Record<string, unknown>>;
}): Promise<void>;
}
export function createServerHealthRoute(dependencies: ServerHealthRouteDependencies) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'POST' || req.url !== '/api/servers/ping-all') return false;
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/servers\/ping$/);
if (req.method !== 'POST' || (!profileMatch && pathname !== '/api/servers/ping-all')) return false;
const { serverIds = [] } = await dependencies.readBody(req);
const results = await dependencies.serverHealth.check(serverIds);
await dependencies.sendState(res, { results });
const profileId = profileMatch ? decodeURIComponent(profileMatch[1]) : '';
const results = await dependencies.serverHealth.check(profileId, serverIds);
await dependencies.sendState(res, { profileId, results });
return true;
},
};
+1 -1
View File
@@ -46,7 +46,7 @@ function withStateV0Compatibility(
singboxRunning: snapshot.connection.process === 'running',
singboxStartedAt: snapshot.connection.startedAt,
subscriptionHost: snapshot.subscription.host,
hasSubscription: snapshot.subscription.status === 'ready',
hasSubscription: snapshot.subscription.status !== 'missing',
selectedTag: stored.selectedTag,
userInfo: snapshot.subscription.userInfo,
fetchedAt: snapshot.subscription.fetchedAt,
@@ -1,43 +1,170 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { ConnectionService } from '../../features/connection/index.js';
import type { SubscriptionService } from '../../features/subscription/index.js';
interface OperationOptions {
expectedRevision?: unknown;
profileId?: unknown;
serverId?: unknown;
}
interface SubscriptionMutationRouteDependencies {
subscriptionService: Pick<
SubscriptionService,
'importSubscription' | 'refreshSavedSubscription' | 'resetSavedSubscription'
| 'preflightAddProfile'
| 'preflightRenameProfile'
| 'addProfile'
| 'renameProfile'
| 'selectProfileServer'
| 'refreshProfile'
| 'deleteProfile'
| 'importSubscription'
| 'refreshSavedSubscription'
| 'resetSavedSubscription'
>;
connection: Pick<ConnectionService, 'activate'>;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
withOperation<T>(
kind: string,
operation: (operationRevision: number) => Promise<T>,
options?: OperationOptions,
): Promise<T>;
sendState(res: ServerResponse, extra?: Record<string, unknown>): Promise<void>;
}
export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)$/);
const serverMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/server$/);
const activateMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/activate$/);
const refreshMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/refresh$/);
if (req.method === 'POST' && pathname === '/api/profiles') {
const { label = '', url = '', expectedRevision } = await dependencies.readBody(req);
dependencies.subscriptionService.preflightAddProfile(label, expectedRevision);
const result = await dependencies.withOperation(
'profile-add',
(operationRevision) => dependencies.subscriptionService.addProfile(
label,
url,
operationRevision,
),
{ expectedRevision },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'PATCH' && profileMatch) {
const profileId = decodeURIComponent(profileMatch[1]);
const { label = '', expectedRevision } = await dependencies.readBody(req);
dependencies.subscriptionService.preflightRenameProfile(profileId, label, expectedRevision);
const result = await dependencies.withOperation(
'profile-rename',
(operationRevision) => dependencies.subscriptionService.renameProfile(
profileId,
label,
operationRevision,
),
{ expectedRevision, profileId },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'PUT' && serverMatch) {
const profileId = decodeURIComponent(serverMatch[1]);
const { serverId = '', expectedRevision } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'profile-select-server',
(operationRevision) => dependencies.subscriptionService.selectProfileServer(
profileId,
serverId,
operationRevision,
),
{ expectedRevision, profileId, serverId },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'POST' && activateMatch) {
const profileId = decodeURIComponent(activateMatch[1]);
const { expectedRevision } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'profile-activate',
(operationRevision) => dependencies.connection.activate(profileId, operationRevision),
{ expectedRevision, profileId },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'POST' && refreshMatch) {
const profileId = decodeURIComponent(refreshMatch[1]);
const { expectedRevision } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'profile-refresh',
(operationRevision) => dependencies.subscriptionService.refreshProfile(
profileId,
operationRevision,
),
{ expectedRevision, profileId },
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'DELETE' && profileMatch) {
const profileId = decodeURIComponent(profileMatch[1]);
const { mode = 'delete', expectedRevision } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'profile-delete',
(operationRevision) => dependencies.subscriptionService.deleteProfile(
profileId,
mode,
operationRevision,
),
{ expectedRevision, profileId },
);
await dependencies.sendState(res, result);
return true;
}
// One-release compatibility for the old single-subscription client.
if (req.method === 'POST' && pathname === '/api/subscription/fetch') {
const { url = '' } = await dependencies.readBody(req);
const result = await dependencies.withOperation(
'subscription-import',
() => dependencies.subscriptionService.importSubscription(String(url).trim()),
(operationRevision) => dependencies.subscriptionService.importSubscription(
String(url).trim(),
operationRevision,
),
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
const { success: _success, ...result } = await dependencies.withOperation(
if (req.method === 'POST' && pathname === '/api/subscription/refresh') {
const result = await dependencies.withOperation(
'subscription-refresh',
() => dependencies.subscriptionService.refreshSavedSubscription(),
(operationRevision) => dependencies.subscriptionService.refreshSavedSubscription(
operationRevision,
),
);
await dependencies.sendState(res, result);
return true;
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
if (req.method === 'DELETE' && pathname === '/api/subscription') {
await dependencies.withOperation(
'subscription-forget',
() => dependencies.subscriptionService.resetSavedSubscription(),
(operationRevision) => dependencies.subscriptionService.resetSavedSubscription({
expectedRevision: operationRevision,
}),
);
await dependencies.sendState(res);
return true;
+279 -86
View File
@@ -28,13 +28,19 @@ import {
selectRefreshedServer,
} from './subscription.js';
import {
desiredProfile,
normalizeStoredState,
type OperationState,
type RouteRule,
type StoredState,
} from '../shared/contracts/state.js';
import { serverIdentityKey } from '../shared/serverIdentity.js';
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
import {
atomicWriteFile,
createJsonStore,
createStateStore,
} from './services/stateStore.js';
import { createDevicePolicyService } from './services/devicePolicyService.js';
import {
createDeviceInventoryService,
@@ -93,11 +99,49 @@ function errorMessage(error: unknown) {
fs.mkdirSync(settings.dataDir, { recursive: true });
const stateStore = createStateStore(settings.statePath);
const stateFileExisted = fs.existsSync(settings.statePath);
const legacyStateBytes = stateFileExisted
? fs.readFileSync(settings.statePath, 'utf8')
: null;
let legacyStateRecord: Record<string, unknown> = {};
try {
legacyStateRecord = record(legacyStateBytes === null ? null : JSON.parse(legacyStateBytes));
} catch {}
const legacyStateVersion = Number.isSafeInteger(legacyStateRecord.schemaVersion)
? Number(legacyStateRecord.schemaVersion)
: 0;
const legacySubscriptionCacheBytes = fs.existsSync(settings.subscriptionCachePath)
? fs.readFileSync(settings.subscriptionCachePath, 'utf8')
: null;
const subscriptionCacheStore = createJsonStore({
filePath: settings.subscriptionCachePath,
defaultValue: null,
});
const rawLegacySubscriptionCache = subscriptionCacheStore.read();
const legacyCacheRecord = record(rawLegacySubscriptionCache);
const legacyStateSubscriptionUrl = String(legacyStateRecord.subscriptionUrl || '').trim();
const legacyCacheSubscriptionUrl = String(legacyCacheRecord.url || '').trim();
const legacyCacheOwnerMismatch = legacyStateVersion < 5
&& Boolean(legacyCacheRecord.config)
&& (legacyStateSubscriptionUrl
? legacyCacheSubscriptionUrl !== legacyStateSubscriptionUrl
: !legacyCacheSubscriptionUrl);
let legacySubscriptionCache = rawLegacySubscriptionCache;
let legacySubscriptionCacheRejected = Boolean(subscriptionCacheStore.recovery);
if (legacyCacheOwnerMismatch) {
legacySubscriptionCache = null;
} else if (legacyCacheRecord.config) {
try {
legacySubscriptionCache = {
...legacyCacheRecord,
...normalizeSubscriptionConfig(legacyCacheRecord.config),
};
} catch {
legacySubscriptionCache = null;
legacySubscriptionCacheRejected = true;
}
}
const stateStore = createStateStore(settings.statePath, { legacySubscriptionCache });
const deviceStore = createJsonStore<InventoryState>({
filePath: settings.deviceStatePath,
defaultValue: migrateDeviceInventoryState({}),
@@ -112,32 +156,77 @@ if (deviceStore.migration) {
if (deviceStore.recovery) {
console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`);
}
let cacheRecoveryLogged = false;
function readRawSubscriptionCache() {
const cached = subscriptionCacheStore.read();
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
cacheRecoveryLogged = true;
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
return cached;
}
function readSubscriptionCache() {
const raw = readRawSubscriptionCache();
const cached = record(raw);
return cached.config
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: raw }
: raw && typeof raw === 'object' && !Array.isArray(raw) ? cached : null;
}
const initialStoredState = stateStore.read();
let initialStoredState = stateStore.read();
if (stateStore.migration) {
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
}
if (stateStore.recovery) {
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
}
if (subscriptionCacheStore.recovery) {
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
const rejectedLegacyMigration = legacySubscriptionCacheRejected
&& (
!stateFileExisted
|| Boolean(stateStore.recovery)
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
);
const mismatchedLegacyMigration = legacyCacheOwnerMismatch
&& (
!stateFileExisted
|| Boolean(stateStore.recovery)
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
);
if (rejectedLegacyMigration) {
initialStoredState = stateStore.update((state) => ({
...state,
profiles: [],
desiredProfileId: '',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
connectionDesired: 'stopped',
}));
removeSingboxConfig();
} else if (mismatchedLegacyMigration) {
initialStoredState = stateStore.update((state) => ({
...state,
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
connectionDesired: 'stopped',
}));
removeSingboxConfig();
}
if (
legacySubscriptionCacheBytes !== null
&& (
Boolean(subscriptionCacheStore.recovery)
|| (
Boolean(legacyCacheRecord.config)
&& (
legacySubscriptionCacheRejected
|| legacyCacheOwnerMismatch
|| normalizeStoredState(initialStoredState).profiles.some((profile) => profile.subscriptionConfig)
)
)
)
) {
const backupPath = subscriptionCacheStore.recovery?.backupPath
|| `${settings.subscriptionCachePath}.backup-v1-${new Date().toISOString().replace(/[:.]/g, '-')}`;
if (!subscriptionCacheStore.recovery) atomicWriteFile(backupPath, legacySubscriptionCacheBytes);
subscriptionCacheStore.remove();
console.log(`[storage] legacy subscription cache migrated; backup: ${backupPath}`);
}
function readProfileConfig(profileId = '') {
const state = normalizeStoredState(stateStore.read());
const profile = profileId
? state.profiles.find((candidate) => candidate.id === profileId)
: desiredProfile(state);
return profile?.subscriptionConfig || null;
}
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
const versionInfo = buildVersionInfo(settings.appMode);
@@ -215,7 +304,7 @@ const gatewayAutoService = createGatewayAutoService({
update: updateStoredState,
},
subscription: {
readConfig: () => readSubscriptionCache()?.config || null,
readConfig: (profileId) => readProfileConfig(profileId),
},
config: {
build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => (
@@ -238,6 +327,7 @@ const gatewayAutoService = createGatewayAutoService({
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
),
restoreRunning: () => startSingbox(),
stopCommand: () => captureRuntimeCommand(() => stopSingbox()),
},
discovery: {
readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath),
@@ -291,7 +381,7 @@ const deviceInventoryRoute = createDeviceInventoryRoute({
});
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
readState: () => stateStore.read(),
readState: () => normalizeStoredState(stateStore.read()),
runDiagnostics: async (services, target) => remoteDataplane
? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
: requireLocalConnectivityDiagnostics().run({
@@ -306,7 +396,7 @@ const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
});
const gatewayPresenceRoute = createGatewayPresenceRoute({
appMode: settings.appMode,
readState: () => stateStore.read(),
readState: () => normalizeStoredState(stateStore.read()),
getHwid,
});
const sharedProxyRoute = createSharedProxyRoute({
@@ -332,11 +422,6 @@ const subscriptionService = createSubscriptionService({
read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState,
},
cache: {
read: readRawSubscriptionCache,
write: (value) => { subscriptionCacheStore.write(value); },
remove: () => subscriptionCacheStore.remove(),
},
config: {
build: (subscriptionConfig, selectedServerId, routeRules) => (
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
@@ -364,16 +449,12 @@ const subscriptionService = createSubscriptionService({
clearInterval: (timer) => clearInterval(timer),
},
onRefreshError: (error) => console.warn(`[control] подписка не обновлена: ${errorMessage(error)}`),
});
const subscriptionMutationRoute = createSubscriptionMutationRoute({
subscriptionService,
readBody,
withOperation,
sendState: (res, extra) => stateRoute.send(res, extra),
now: () => new Date(),
});
const serverHealthRoute = createServerHealthRoute({
serverHealth: createServerHealthService({
readServers: () => normalizeStoredState(stateStore.read()).servers,
readProfiles: () => normalizeStoredState(stateStore.read()).profiles,
readDesiredProfileId: () => normalizeStoredState(stateStore.read()).desiredProfileId,
ping: tcpPing,
}),
readBody,
@@ -384,11 +465,7 @@ const connectionService = createConnectionService({
read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState,
},
subscription: {
readConfig: () => readSubscriptionCache()?.config || null,
},
config: {
exists: () => fs.existsSync(settings.configPath),
build: (subscriptionConfig, selectedServerId, routeRules) => (
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
),
@@ -399,6 +476,10 @@ const connectionService = createConnectionService({
restore: restoreSingboxConfig,
remove: removeSingboxConfig,
},
route: {
isGatewayDirect: () => settings.appMode === 'client'
&& gatewayAutoService.read().mode === 'gateway-direct',
},
runtime: {
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
start: () => startSingbox(),
@@ -412,6 +493,13 @@ const connectionService = createConnectionService({
serialize: serializeControl,
now: () => new Date(),
});
const subscriptionMutationRoute = createSubscriptionMutationRoute({
subscriptionService,
connection: connectionService,
readBody,
withOperation,
sendState: (res, extra) => stateRoute.send(res, extra),
});
const serverApplyRoute = createServerApplyRoute({
connection: connectionService,
readBody,
@@ -429,7 +517,7 @@ const routeRulesService = createRouteRulesService({
update: updateStoredState,
},
subscription: {
readConfig: () => readSubscriptionCache()?.config || null,
readConfig: (profileId) => readProfileConfig(profileId),
},
config: {
build: (subscriptionConfig, selectedServerId, routeRules) => (
@@ -460,27 +548,64 @@ const routeRulesRoute = createRouteRulesRoute({
});
function updateStoredState(update: (state: StoredState) => Record<string, unknown>) {
return stateStore.update((stored) => {
return normalizeStoredState(stateStore.update((stored) => {
const current = normalizeStoredState(stored);
const schemaVersion = stored.schemaVersion;
const next = normalizeStoredState({ schemaVersion, ...update(current) });
revision = Math.max(revision, current.revision) + 1;
next.revision = revision;
return { ...next, schemaVersion };
});
}));
}
async function withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T> {
async function withOperation<T>(
kind: string,
operation: (operationRevision: number) => Promise<T>,
{
expectedRevision,
profileId = null,
serverId = null,
}: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown } = {},
): Promise<T> {
if (operationState.status === 'running') throw new HarborError('OPERATION_IN_PROGRESS');
const currentRevision = normalizeStoredState(stateStore.read()).revision;
if (expectedRevision !== undefined) {
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== currentRevision) {
throw new HarborError('STATE_CONFLICT');
}
}
operationState = {
kind,
status: 'running',
startedAt: new Date().toISOString(),
error: null,
profileId: profileId == null ? null : String(profileId),
serverId: serverId == null ? null : String(serverId),
};
updateStoredState((state) => state);
let operationRevision: number;
try {
const result = await operation();
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
operationRevision = updateStoredState((state) => state).revision;
} catch (error) {
operationState = {
kind: null,
status: 'idle',
startedAt: null,
error: null,
profileId: null,
serverId: null,
};
throw error;
}
try {
const result = await operation(operationRevision);
operationState = {
kind: null,
status: 'idle',
startedAt: null,
error: null,
profileId: null,
serverId: null,
};
updateStoredState((state) => state);
return result;
} catch (error) {
@@ -536,7 +661,8 @@ function buildActiveConfig(
selectedServerId: string,
routeRules: RouteRule[] = stateStore.read().routeRules,
) {
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
const normalizedConfig = normalizeSubscriptionConfig(subscriptionConfig).config;
return buildGatewayConfig(normalizedConfig, selectedServerId, {
clientDirect: settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct',
routeRules,
});
@@ -545,33 +671,22 @@ function buildActiveConfig(
const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply();
function writeCurrentConfig(onlyIfSelectionChanged = false) {
function writeCurrentConfig() {
const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache();
if (!state.selectedServerId || !cached?.config) return false;
const cachedServers = cached.servers as StoredState['servers'];
const selectedServerId = selectRefreshedServer(
state.selectedServerId,
state.servers,
cachedServers,
);
if (onlyIfSelectionChanged && selectedServerId === state.selectedServerId) return false;
const activeConfig = selectedServerId
? buildActiveConfig(cached.config, selectedServerId)
: null;
const hasAppliedTarget = Boolean(state.appliedProfileId && state.appliedServerId);
const profile = hasAppliedTarget
? state.profiles.find((candidate) => candidate.id === state.appliedProfileId) || null
: desiredProfile(state);
const serverId = hasAppliedTarget ? state.appliedServerId : profile?.desiredServerId;
const server = profile?.servers.find((candidate) => candidate.id === serverId);
const subscriptionConfig = profile ? readProfileConfig(profile.id) : null;
if (!profile || !server || !subscriptionConfig) return null;
const activeConfig = buildActiveConfig(subscriptionConfig, server.id);
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
try {
if (activeConfig) writeSingboxConfig(activeConfig);
else removeSingboxConfig();
updateStoredState((current) => ({
...current,
servers: cachedServers,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
}));
writeSingboxConfig(activeConfig);
} catch (error) {
try {
if (previousConfig === null) removeSingboxConfig();
@@ -581,7 +696,61 @@ function writeCurrentConfig(onlyIfSelectionChanged = false) {
}
throw error;
}
return Boolean(selectedServerId);
return { profile, server };
}
const CONFIG_PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
function currentConfigMatchesAppliedTarget(state: StoredState) {
if (!state.appliedProfileId || !state.appliedServerId || !state.appliedServerSnapshot) return false;
let config: Record<string, unknown>;
try {
config = record(JSON.parse(fs.readFileSync(settings.configPath, 'utf8')));
} catch {
return false;
}
const proxyOutbounds = (Array.isArray(config.outbounds) ? config.outbounds : [])
.map(record)
.filter((outbound) => CONFIG_PROXY_TYPES.has(String(outbound.type || '')));
const exactMatches = proxyOutbounds.filter((outbound) => (
String(outbound.tag || '') === state.appliedServerId
));
const targetMatches = exactMatches.length
? exactMatches
: proxyOutbounds.filter((outbound) => (
serverIdentityKey(outbound) === serverIdentityKey(state.appliedServerSnapshot)
));
if (targetMatches.length !== 1) return false;
const outboundTag = String(targetMatches[0].tag || '');
const routeFinal = String(record(config.route).final || '');
const expectsGatewayDirect = settings.appMode === 'client'
&& gatewayAutoService.read().mode === 'gateway-direct';
return expectsGatewayDirect ? routeFinal === 'direct' : routeFinal === outboundTag;
}
async function reconcileStoppedBoot({ removeConfig = false } = {}) {
try {
await stopSingbox();
} catch (error) {
console.warn(`[control] sing-box не остановлен при startup reconcile: ${errorMessage(error)}`);
return;
}
if (removeConfig) removeSingboxConfig();
const state = normalizeStoredState(stateStore.read());
if (
state.connectionDesired !== 'stopped'
|| state.appliedProfileId
|| state.appliedServerId
|| state.appliedServerSnapshot
) {
updateStoredState((current) => ({
...current,
connectionDesired: 'stopped',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
}));
}
}
async function handleApi(req: IncomingMessage, res: ServerResponse) {
@@ -654,21 +823,45 @@ process.on('SIGINT', shutdown);
await gatewayAutoService.refresh({ reconfigure: false })
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`));
try {
writeCurrentConfig(settings.appMode !== 'client' && fs.existsSync(settings.configPath));
} catch (error) {
const candidate = record(error);
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
const bootState = normalizeStoredState(stateStore.read());
const bootWantsRunning = bootState.connectionDesired === 'running'
|| (bootState.connectionDesired === undefined && fs.existsSync(settings.configPath));
if (bootWantsRunning) {
let target: ReturnType<typeof writeCurrentConfig> = null;
try {
target = writeCurrentConfig();
} catch (error) {
console.warn(`[storage] не удалось собрать сохранённую подписку: ${errorMessage(error)}`);
}
const canReuseCurrentConfig = target === null
&& fs.existsSync(settings.configPath)
&& currentConfigMatchesAppliedTarget(normalizeStoredState(stateStore.read()));
if (target || canReuseCurrentConfig) {
await startSingbox()
.then(() => {
const current = normalizeStoredState(stateStore.read());
const appliedProfile = target?.profile
|| current.profiles.find((profile) => profile.id === current.appliedProfileId);
const appliedServer = target?.server
|| current.appliedServerSnapshot;
if (appliedProfile && appliedServer) {
updateStoredState((state: StoredState) => ({
...state,
connectionDesired: 'running',
appliedProfileId: appliedProfile.id,
appliedServerId: appliedServer.id,
appliedServerSnapshot: appliedServer,
...(target ? { appliedRouteRules: state.routeRules } : {}),
}));
}
})
.catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`));
} else {
await reconcileStoppedBoot({ removeConfig: true });
}
} else {
await reconcileStoppedBoot();
}
await startSingbox()
.then(() => {
if (fs.existsSync(settings.configPath)) {
updateStoredState((state: StoredState) => ({ ...state, appliedRouteRules: state.routeRules }));
}
})
.catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`));
if (deviceInventory) {
await deviceInventory.reconcilePolicies()
+92 -10
View File
@@ -1,10 +1,19 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeStoredState, type StoredState } from '../../shared/contracts/state.js';
import {
normalizeStoredState,
type PersistedState,
} from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
import {
normalizeServers,
resolveServerId,
serverIdentityKey,
type NormalizedServer,
} from '../../shared/serverIdentity.js';
export const STATE_SCHEMA_VERSION = 4;
export const STATE_SCHEMA_VERSION = 5;
export interface AtomicWriteOptions {
beforeRename?: (temporaryPath: string, filePath: string) => void;
@@ -59,6 +68,23 @@ function record(value: unknown): Record<string, unknown> {
: {};
}
function remapLegacyServerId(
previousServers: NormalizedServer[],
nextServers: NormalizedServer[],
serverId: unknown,
legacyTag: unknown = '',
) {
const direct = resolveServerId(nextServers, serverId, legacyTag);
if (direct) return direct;
const previousId = resolveServerId(previousServers, serverId, legacyTag);
const previous = previousServers.find((server) => server.id === previousId);
if (!previous) return '';
const matches = nextServers.filter((server) => (
serverIdentityKey(server) === serverIdentityKey(previous)
));
return matches.length === 1 ? matches[0].id : '';
}
function syncDirectory(directory: string) {
let descriptor: number | undefined;
try {
@@ -105,7 +131,10 @@ export function atomicWriteJson(filePath: string, value: unknown, options?: Atom
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
}
export function migrateStoredState(value: unknown): StoredState & { schemaVersion: number } {
export function migrateStoredState(
value: unknown,
legacySubscriptionCache: unknown = null,
): PersistedState & { schemaVersion: number } {
const stored = record(value);
const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0;
if (version < 0 || version > STATE_SCHEMA_VERSION) {
@@ -114,10 +143,60 @@ export function migrateStoredState(value: unknown): StoredState & { schemaVersio
const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
: stored.routeRules;
const legacyCache = record(legacySubscriptionCache);
const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim();
const cachedSubscriptionUrl = String(legacyCache.url || '').trim();
const cacheOwnsStoredSubscription = Boolean(cachedSubscriptionUrl)
&& (!storedSubscriptionUrl || cachedSubscriptionUrl === storedSubscriptionUrl);
const previousServers = normalizeServers(stored.servers);
const cachedServers = cacheOwnsStoredSubscription
? normalizeServers(legacyCache.servers)
: [];
const migratedServers = cachedServers.length ? cachedServers : previousServers;
const selectedServerId = remapLegacyServerId(
previousServers,
migratedServers,
stored.selectedServerId,
stored.selectedTag,
);
const appliedServerId = remapLegacyServerId(
previousServers,
migratedServers,
stored.appliedServerId,
stored.appliedTag || stored.selectedTag,
);
const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped');
const normalized = normalizeStoredState({
...stored,
routeRules,
...(version < 5 && !Array.isArray(stored.profiles) ? {
subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''),
subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null,
servers: migratedServers,
selectedServerId,
selectedTag: '',
appliedServerId: keepLegacyApplied ? appliedServerId : '',
appliedServerSnapshot: keepLegacyApplied ? stored.appliedServerSnapshot : null,
appliedTag: '',
userInfo: stored.userInfo || (cacheOwnsStoredSubscription ? legacyCache.userInfo : undefined),
fetchedAt: stored.fetchedAt || (cacheOwnsStoredSubscription ? legacyCache.fetchedAt : undefined),
} : {}),
});
const canonical = { ...normalized } as Record<string, unknown>;
for (const key of [
'subscriptionUrl',
'selectedServerId',
'selectedTag',
'appliedTag',
'servers',
'userInfo',
'fetchedAt',
'subscriptionConfig',
]) delete canonical[key];
return {
...normalizeStoredState({ ...stored, routeRules }),
...canonical,
schemaVersion: STATE_SCHEMA_VERSION,
};
} as PersistedState & { schemaVersion: number };
}
export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
@@ -204,14 +283,17 @@ export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStor
export function createStateStore(
filePath: string,
options: Partial<Omit<JsonStoreOptions<StoredState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> = {},
options: Partial<Omit<JsonStoreOptions<PersistedState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> & {
legacySubscriptionCache?: unknown;
} = {},
) {
return createJsonStore<StoredState & { schemaVersion: number }>({
const { legacySubscriptionCache = null, ...storeOptions } = options;
return createJsonStore<PersistedState & { schemaVersion: number }>({
filePath,
defaultValue: migrateStoredState({}),
migrate: migrateStoredState,
defaultValue: migrateStoredState({}, legacySubscriptionCache),
migrate: (value) => migrateStoredState(value, legacySubscriptionCache),
initializeMissing: true,
backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion,
...options,
...storeOptions,
});
}
+238 -36
View File
@@ -20,18 +20,53 @@ export interface RouteRule {
enabled: boolean;
}
export interface StoredProfile {
id: string;
label: string;
subscriptionUrl: string;
subscriptionConfig: unknown;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt: string | null;
desiredServerId: string;
lastRefreshAttemptAt: string | null;
lastRefreshErrorCode: string | null;
}
export interface ProfileSnapshot {
id: string;
label: string;
subscription: {
status: 'ready' | 'stale';
host: string;
fetchedAt: string | null;
userInfo: Record<string, unknown>;
lastRefreshAttemptAt: string | null;
errorCode: string | null;
};
desiredServerId: string;
servers: HarborServer[];
}
export interface StateSnapshot {
apiVersion: 1;
revision: number;
generatedAt: string;
mode: HarborMode;
profiles: ProfileSnapshot[];
subscription: {
status: 'missing' | 'ready';
status: 'missing' | 'ready' | 'stale';
host: string;
fetchedAt: string | null;
userInfo: Record<string, unknown>;
};
selection: { desiredServerId: string; appliedServerId: string };
selection: {
desiredProfileId: string;
desiredServerId: string;
appliedProfileId: string;
appliedServerId: string;
appliedServerSnapshot: HarborServer | null;
};
connection: {
desired: ConnectionState;
process: ConnectionState;
@@ -56,24 +91,36 @@ export interface StateSnapshot {
status: OperationStatus;
startedAt: string | null;
error: string | null;
profileId: string | null;
serverId: string | null;
};
// One-release projection of the desired profile for older clients.
servers: HarborServer[];
}
export interface StoredState extends Record<string, unknown> {
export interface PersistedState extends Record<string, unknown> {
revision: number;
selectedServerId: string;
profiles: StoredProfile[];
desiredProfileId: string;
appliedProfileId: string;
appliedServerId: string;
selectedTag: string;
appliedTag: string;
servers: HarborServer[];
appliedServerSnapshot: HarborServer | null;
routeRules: RouteRule[];
appliedRouteRules: RouteRule[];
routeRulesRevision: number;
subscriptionUrl?: string;
connectionDesired?: ConnectionState;
gatewayAutoEnabled?: boolean;
userInfo?: Record<string, unknown>;
}
// Legacy fields are derived in memory for bounded callers during the v5 cutover.
// migrateStoredState strips them before every persisted write.
export interface StoredState extends PersistedState {
subscriptionUrl: string;
selectedServerId: string;
selectedTag: string;
appliedTag: string;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt?: string;
}
@@ -97,6 +144,8 @@ export interface OperationState {
status: OperationStatus;
startedAt: string | null;
error: string | null;
profileId?: string | null;
serverId?: string | null;
}
const MODES = new Set<HarborMode>(['client', 'gateway']);
@@ -109,37 +158,152 @@ const dateOrNull = (value: unknown) => (
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
);
export function normalizeStoredState(value: unknown): StoredState {
const state: Record<string, unknown> = value && typeof value === 'object' && !Array.isArray(value)
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const servers = normalizeServers(state.servers) as HarborServer[];
const selectedServerId = resolveServerId(
}
function publicSubscriptionHost(value: unknown) {
try {
return `${new URL(String(value)).host}/…`;
} catch {
return '';
}
}
function normalizeProfile(value: unknown, index: number): StoredProfile {
const candidate = record(value);
const servers = normalizeServers(candidate.servers) as HarborServer[];
const desiredServerId = resolveServerId(
servers,
identityText(candidate.desiredServerId),
identityText(candidate.selectedTag),
);
return {
id: identityText(candidate.id) || `profile_${index + 1}`,
label: identityText(candidate.label) || `Подписка ${index + 1}`,
subscriptionUrl: identityText(candidate.subscriptionUrl),
subscriptionConfig: candidate.subscriptionConfig ?? null,
servers,
userInfo: record(candidate.userInfo),
fetchedAt: dateOrNull(candidate.fetchedAt),
desiredServerId,
lastRefreshAttemptAt: dateOrNull(candidate.lastRefreshAttemptAt),
lastRefreshErrorCode: nullableText(candidate.lastRefreshErrorCode),
};
}
function normalizeAppliedServer(value: unknown): HarborServer | null {
return (normalizeServers(value ? [value] : []) as HarborServer[])[0] || null;
}
export function profileById(state: Pick<PersistedState, 'profiles'>, profileId: unknown) {
const id = identityText(profileId);
return state.profiles.find((profile) => profile.id === id) || null;
}
export function desiredProfile(state: Pick<PersistedState, 'profiles' | 'desiredProfileId'>) {
return profileById(state, state.desiredProfileId);
}
export function appliedProfile(state: Pick<PersistedState, 'profiles' | 'appliedProfileId'>) {
return profileById(state, state.appliedProfileId);
}
export function normalizeStoredState(value: unknown): StoredState {
const state = record(value);
const legacyServers = normalizeServers(state.servers) as HarborServer[];
const legacySelectedServerId = resolveServerId(
legacyServers,
identityText(state.selectedServerId),
identityText(state.selectedTag),
);
const appliedServerId = Object.hasOwn(state, 'appliedServerId')
? resolveServerId(servers, identityText(state.appliedServerId))
: resolveServerId(servers, '', identityText(state.appliedTag) || identityText(state.selectedTag));
const selectedServer = servers.find((server: HarborServer) => server.id === selectedServerId);
const appliedServer = servers.find((server: HarborServer) => server.id === appliedServerId);
const legacyAppliedServerId = Object.hasOwn(state, 'appliedServerId')
? resolveServerId(legacyServers, identityText(state.appliedServerId))
: resolveServerId(legacyServers, '', identityText(state.appliedTag) || identityText(state.selectedTag));
const suppliedProfiles = Array.isArray(state.profiles)
? state.profiles.map(normalizeProfile)
: [];
const profiles = Array.isArray(state.profiles)
? suppliedProfiles.filter((profile, index) => (
suppliedProfiles.findIndex((candidate) => candidate.id === profile.id) === index
))
: identityText(state.subscriptionUrl) || legacyServers.length
? [normalizeProfile({
id: 'profile_primary',
label: 'Основной',
subscriptionUrl: state.subscriptionUrl,
subscriptionConfig: state.subscriptionConfig,
servers: legacyServers,
userInfo: state.userInfo,
fetchedAt: state.fetchedAt,
desiredServerId: legacySelectedServerId,
}, 0)]
: [];
const requestedDesiredProfileId = identityText(state.desiredProfileId);
const desiredProfileId = profileById({ profiles }, requestedDesiredProfileId)?.id
|| (profiles.length === 1 ? profiles[0].id : '');
const requestedAppliedProfileId = identityText(state.appliedProfileId);
const appliedProfileId = profileById({ profiles }, requestedAppliedProfileId)?.id
|| (legacyAppliedServerId && profiles.length === 1 ? profiles[0].id : '');
const selectedProfile = profileById({ profiles }, desiredProfileId);
const currentAppliedProfile = profileById({ profiles }, appliedProfileId);
const normalizedSnapshot = normalizeAppliedServer(state.appliedServerSnapshot);
const explicitAppliedServerId = identityText(state.appliedServerId);
const appliedServerId = explicitAppliedServerId && (
currentAppliedProfile?.servers.some((server) => server.id === explicitAppliedServerId)
|| normalizedSnapshot?.id === explicitAppliedServerId
)
? explicitAppliedServerId
: legacyAppliedServerId;
const appliedServerSnapshot = currentAppliedProfile?.servers.find(
(server) => server.id === appliedServerId,
) || (normalizedSnapshot?.id === appliedServerId ? normalizedSnapshot : null);
const selectedServerId = selectedProfile?.desiredServerId || '';
const selectedServer = selectedProfile?.servers.find((server) => server.id === selectedServerId);
return {
...state,
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) && state.revision >= 0
? state.revision
: 0,
selectedServerId,
profiles,
desiredProfileId,
appliedProfileId,
appliedServerId,
selectedTag: selectedServer?.label || '',
appliedTag: appliedServer?.label || '',
servers,
appliedServerSnapshot,
routeRules: normalizeRouteRules(state.routeRules) as RouteRule[],
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules) as RouteRule[],
routeRulesRevision: typeof state.routeRulesRevision === 'number'
&& Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
? state.routeRulesRevision
: 0,
subscriptionUrl: selectedProfile?.subscriptionUrl || '',
selectedServerId,
selectedTag: selectedServer?.label || '',
appliedTag: appliedServerSnapshot?.label || '',
servers: selectedProfile?.servers || [],
userInfo: selectedProfile?.userInfo || {},
fetchedAt: selectedProfile?.fetchedAt || undefined,
};
}
function profileSnapshot(profile: StoredProfile): ProfileSnapshot {
return {
id: profile.id,
label: profile.label,
subscription: {
status: profile.lastRefreshErrorCode ? 'stale' : 'ready',
host: publicSubscriptionHost(profile.subscriptionUrl),
fetchedAt: dateOrNull(profile.fetchedAt),
userInfo: profile.userInfo,
lastRefreshAttemptAt: dateOrNull(profile.lastRefreshAttemptAt),
errorCode: nullableText(profile.lastRefreshErrorCode),
},
desiredServerId: profile.desiredServerId,
servers: profile.servers,
};
}
@@ -149,7 +313,6 @@ export function createStateSnapshot({
gatewayAuto,
appMode,
configExists,
subscriptionHost,
operation = { kind: null, status: 'idle', startedAt: null, error: null },
now = new Date(),
}: {
@@ -158,17 +321,19 @@ export function createStateSnapshot({
gatewayAuto?: GatewayAutoState | null;
appMode?: string;
configExists: boolean;
subscriptionHost: string;
subscriptionHost?: string;
operation?: OperationState;
now?: Date;
}): StateSnapshot {
const stored = normalizeStoredState(storedState);
const mode: HarborMode = appMode === 'client' || appMode === 'gateway' ? appMode : 'gateway';
const hasSubscription = Boolean(stored.subscriptionUrl);
const selectedProfile = desiredProfile(stored);
const profiles = stored.profiles.map(profileSnapshot);
const selectedSnapshot = profiles.find((profile) => profile.id === stored.desiredProfileId) || null;
const desired: ConnectionState = stored.connectionDesired && CONNECTION_STATES.has(stored.connectionDesired)
? stored.connectionDesired
: configExists ? 'running' : 'stopped';
const servers = stored.servers;
const running = Boolean(runtime?.running);
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
const routeReason = mode !== 'client'
@@ -178,26 +343,36 @@ export function createStateSnapshot({
: routeMode === 'gateway-direct'
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
const activeLocalRules = running ? stored.appliedRouteRules : [];
const appliedServerSnapshot = stored.appliedServerSnapshot;
return assertStateSnapshot({
apiVersion: 1,
revision: stored.revision,
generatedAt: now.toISOString(),
mode,
subscription: {
status: hasSubscription ? 'ready' : 'missing',
host: hasSubscription ? subscriptionHost : '',
fetchedAt: dateOrNull(stored.fetchedAt),
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
profiles,
subscription: selectedSnapshot ? {
status: selectedSnapshot.subscription.status,
host: selectedSnapshot.subscription.host,
fetchedAt: selectedSnapshot.subscription.fetchedAt,
userInfo: selectedSnapshot.subscription.userInfo,
} : {
status: 'missing',
host: '',
fetchedAt: null,
userInfo: {},
},
selection: {
desiredServerId: stored.selectedServerId,
desiredProfileId: selectedProfile?.id || '',
desiredServerId: selectedProfile?.desiredServerId || '',
appliedProfileId: stored.appliedProfileId,
appliedServerId: stored.appliedServerId,
appliedServerSnapshot,
},
connection: {
desired,
process: runtime?.running ? 'running' : 'stopped',
process: running ? 'running' : 'stopped',
startedAt: dateOrNull(runtime?.startedAt),
lastError: null,
},
@@ -219,8 +394,10 @@ export function createStateSnapshot({
status: operation.status,
startedAt: nullableText(operation.startedAt),
error: nullableText(operation.error),
profileId: nullableText(operation.profileId),
serverId: nullableText(operation.serverId),
},
servers: servers as HarborServer[],
servers: selectedProfile?.servers || [],
});
}
@@ -245,6 +422,23 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
Boolean(rule.value) &&
typeof rule.enabled === 'boolean'
);
const validProfile = (profile: ProfileSnapshot) => (
profile &&
typeof profile.id === 'string' && Boolean(profile.id) &&
typeof profile.label === 'string' && Boolean(profile.label) &&
!Object.hasOwn(profile, 'subscriptionUrl') &&
!Object.hasOwn(profile, 'subscriptionConfig') &&
profile.subscription &&
['ready', 'stale'].includes(profile.subscription.status) &&
typeof profile.subscription.host === 'string' &&
!Object.hasOwn(profile.subscription, 'url') &&
nullableDate(profile.subscription.fetchedAt) &&
profile.subscription.userInfo && typeof profile.subscription.userInfo === 'object' &&
nullableDate(profile.subscription.lastRefreshAttemptAt) &&
nullableString(profile.subscription.errorCode) &&
typeof profile.desiredServerId === 'string' &&
Array.isArray(profile.servers) && profile.servers.every(validServer)
);
if (
!snapshot ||
@@ -253,16 +447,22 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
candidate.revision < 0 ||
!validDate(candidate.generatedAt) ||
!MODES.has(candidate.mode) ||
!Array.isArray(candidate.profiles) ||
!candidate.profiles.every(validProfile) ||
new Set(candidate.profiles.map((profile) => profile.id)).size !== candidate.profiles.length ||
!candidate.subscription ||
!['missing', 'ready'].includes(candidate.subscription.status) ||
!['missing', 'ready', 'stale'].includes(candidate.subscription.status) ||
typeof candidate.subscription.host !== 'string' ||
Object.hasOwn(candidate.subscription, 'url') ||
!nullableDate(candidate.subscription.fetchedAt) ||
!candidate.subscription.userInfo ||
typeof candidate.subscription.userInfo !== 'object' ||
!candidate.selection ||
typeof candidate.selection.desiredProfileId !== 'string' ||
typeof candidate.selection.desiredServerId !== 'string' ||
typeof candidate.selection.appliedProfileId !== 'string' ||
typeof candidate.selection.appliedServerId !== 'string' ||
!(candidate.selection.appliedServerSnapshot === null || validServer(candidate.selection.appliedServerSnapshot)) ||
!candidate.connection ||
!CONNECTION_STATES.has(candidate.connection.desired) ||
!CONNECTION_STATES.has(candidate.connection.process) ||
@@ -288,6 +488,8 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
!OPERATION_STATES.has(candidate.operation.status) ||
!nullableDate(candidate.operation.startedAt) ||
!nullableString(candidate.operation.error) ||
!nullableString(candidate.operation.profileId) ||
!nullableString(candidate.operation.serverId) ||
!Array.isArray(candidate.servers) ||
!candidate.servers.every(validServer)
) {
+3
View File
@@ -14,6 +14,9 @@ export const ERROR_DEFINITIONS = Object.freeze({
SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false },
SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false },
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true },
PROFILE_NOT_FOUND: { status: 404, message: 'Подписка больше недоступна.', retryable: false },
PROFILE_NAME_CONFLICT: { status: 409, message: 'Подписка с таким именем уже существует.', retryable: false },
PROFILE_IN_USE: { status: 409, message: 'Сначала переключите или остановите активную подписку.', retryable: false },
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false },
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.22.1',
gatewayClient: '0.23.1',
gatewayBackend: '0.23.1',
macClient: '0.23.0',
gatewayClient: '0.24.0',
gatewayBackend: '0.24.0',
});
export interface ParsedVersion {
+144 -49
View File
@@ -18,7 +18,6 @@ import {
} from './state/operations.js';
const componentActions = {
validateSubscription: api.subscription.validate,
listDevices: api.devices.list,
refreshDevices: api.devices.refresh,
updateDevice: api.devices.update,
@@ -29,23 +28,43 @@ const componentActions = {
interface UiError {
context: string;
profileId: string;
message: string;
code: string;
correlationId: string;
retry: (() => unknown) | null;
}
const operationErrorContext: Record<string, string> = {
start: 'connection',
stop: 'connection',
'apply-server': 'connection',
'profile-activate': 'connection',
'gateway-auto': 'connection',
'profile-add': 'subscription',
'profile-rename': 'subscription',
'profile-select-server': 'subscription',
'profile-refresh': 'subscription',
'profile-delete': 'subscription',
'subscription-import': 'subscription',
'subscription-refresh': 'subscription',
'subscription-forget': 'subscription',
'route-rules': 'routing',
};
export function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
const [{ snapshot: state, transport }, dispatch] = useReducer(
harborReducer,
initialHarborState,
);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [operations, setOperations] = useState<OperationRegistrySnapshot>({});
const [error, setError] = useState<UiError | null>(null);
const [dismissedCanonicalError, setDismissedCanonicalError] = useState('');
const [versionInfo, setVersionInfo] = useState<unknown>(null);
const pollGeneration = useRef(0);
const revisionRef = useRef(0);
const hasAcceptedSnapshotRef = useRef(false);
const operationRegistry = useRef<ReturnType<typeof createOperationRegistry> | null>(null);
if (!operationRegistry.current) {
operationRegistry.current = createOperationRegistry((next) => {
@@ -53,16 +72,16 @@ export function App() {
});
}
function setPendingServerId(serverId: string) {
dispatch({ type: 'select-server', serverId });
}
async function loadState({ retry = false }: { retry?: boolean } = {}) {
if (retry) dispatch({ type: 'retry-sync' });
const generation = pollGeneration.current;
try {
const snapshot = await harborClient.getState();
if (generation === pollGeneration.current) {
if (!hasAcceptedSnapshotRef.current || snapshot.revision > revisionRef.current) {
hasAcceptedSnapshotRef.current = true;
revisionRef.current = snapshot.revision;
}
dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
}
} catch (requestError) {
@@ -98,30 +117,43 @@ export function App() {
if (favicon) favicon.href = isGateway ? '/harbor-gateway.svg?v=2' : '/harbor-connect.svg?v=2';
}, [state?.mode]);
function run(key: OperationKey, action: () => Promise<unknown>, context: string) {
const canonicalErrorId = state?.operation?.status === 'failed' && state.operation.error
? [state.operation.kind, state.operation.startedAt, state.operation.profileId, state.operation.error].join(':')
: '';
useEffect(() => setDismissedCanonicalError(''), [canonicalErrorId]);
function run(
key: OperationKey,
action: () => Promise<unknown>,
context: string,
target = '',
profileId = '',
) {
setError(null);
return operationRegistry.current!.run(key, async () => {
try {
return await applyMutation(action);
} catch (err) {
await loadState();
const candidate = err && typeof err === 'object' ? err as Record<string, unknown> : {};
const safeError = err instanceof HarborApiError
? err
: new HarborApiError({ code: candidate.code }, Number(candidate.status));
setError({
context,
profileId,
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
: safeError.message,
code: safeError.code,
correlationId: safeError.correlationId,
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
? () => run(key, action, context)
? () => run(key, action, context, target, profileId)
: null,
});
return false;
}
});
}, target);
}
async function applyMutation(action: () => Promise<unknown>) {
@@ -133,6 +165,10 @@ export function App() {
const result = response as Record<string, unknown>;
if (!result.state) throw new Error('Harbor API не вернул state snapshot');
const snapshot = parseHarborState(result.state);
if (!hasAcceptedSnapshotRef.current || snapshot.revision > revisionRef.current) {
hasAcceptedSnapshotRef.current = true;
revisionRef.current = snapshot.revision;
}
dispatch({
type: 'sync-succeeded',
snapshot,
@@ -141,36 +177,59 @@ export function App() {
return result;
}
async function fetchSubscription() {
return run('subscriptionImport', async () => {
const data = await api.subscription.fetch(subscriptionUrl);
dispatch({ type: 'clear-pending-server' });
return data;
}, 'subscription');
}
async function refreshSubscription() {
return run('subscriptionRefresh', api.subscription.refresh, 'subscription');
}
async function forgetSubscription() {
return run('subscriptionDelete', async () => {
const data = await api.subscription.forget();
setSubscriptionUrl('');
dispatch({ type: 'clear-pending-server' });
return data;
}, 'subscription');
}
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
const previewServer = {
id: 'preview-amsterdam',
label: 'Amsterdam',
host: '127.0.0.1',
port: 443,
protocol: 'vless',
};
const displayState = previewReady ? {
...state,
mode: 'client' as const,
profiles: [{
id: 'preview-personal',
label: 'Личный',
subscription: {
status: 'ready' as const,
host: 'harbor.example/…',
fetchedAt: new Date().toISOString(),
userInfo: {},
lastRefreshAttemptAt: new Date().toISOString(),
errorCode: null,
},
desiredServerId: previewServer.id,
servers: [previewServer],
}],
subscription: { ...state.subscription, status: 'ready' as const, host: 'harbor.example' },
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
selection: {
...state.selection,
desiredProfileId: 'preview-personal',
desiredServerId: 'preview-amsterdam',
appliedProfileId: 'preview-personal',
appliedServerId: 'preview-amsterdam',
appliedServerSnapshot: previewServer,
},
servers: [previewServer],
clientRuntime: { ...state.clientRuntime, proxyPort: 8082 },
} : state;
const canonicalErrorContext = operationErrorContext[state.operation.kind || ''];
const visibleError = error || (
canonicalErrorId
&& canonicalErrorId !== dismissedCanonicalError
&& canonicalErrorContext
? {
context: canonicalErrorContext,
profileId: state.operation.profileId || '',
message: state.operation.error || 'Операция не выполнена.',
code: 'UNKNOWN',
correlationId: '',
retry: null,
}
: null
);
return (
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
@@ -182,22 +241,55 @@ export function App() {
state={displayState}
versionInfo={versionInfo}
operations={operations}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={previewReady ? [{
id: 'preview-amsterdam',
label: 'Amsterdam',
host: '127.0.0.1',
port: 443,
protocol: 'vless',
}] : state.servers || []}
pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId}
setPendingServerId={setPendingServerId}
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onApply={(serverId: string) => run('serverApply', () => api.apply(serverId), 'connection')}
error={visibleError}
onAddProfile={(label: string, url: string) => run(
'profileAdd',
() => api.profiles.add(label, url, revisionRef.current),
'subscription',
label,
)}
onRenameProfile={(profileId: string, label: string) => run(
'profileRename',
() => api.profiles.rename(profileId, label, revisionRef.current),
'subscription',
profileId,
profileId,
)}
onSelectProfileServer={(profileId: string, serverId: string) => run(
'profileSelect',
() => api.profiles.selectServer(profileId, serverId, revisionRef.current),
'subscription',
`${profileId}:${serverId}`,
profileId,
)}
onActivateProfile={(profileId: string) => run(
'profileActivate',
() => api.profiles.activate(profileId, revisionRef.current),
'connection',
profileId,
profileId,
)}
onRefreshProfile={(profileId: string) => run(
'profileRefresh',
() => api.profiles.refresh(profileId, revisionRef.current),
'subscription',
profileId,
profileId,
)}
onForgetProfile={(profileId: string, mode: 'delete' | 'stop-and-delete') => run(
'profileDelete',
() => api.profiles.forget(profileId, mode, revisionRef.current),
'subscription',
profileId,
profileId,
)}
onApply={(profileId: string, serverId: string) => run(
'serverApply',
() => api.apply(profileId, serverId, revisionRef.current),
'connection',
`${profileId}:${serverId}`,
profileId,
)}
onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled: boolean) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
@@ -206,7 +298,10 @@ export function App() {
() => api.routeRules.update(rules, expectedRevision),
'routing',
)}
onDismissError={() => setError(null)}
onDismissError={() => {
setError(null);
setDismissedCanonicalError(canonicalErrorId);
}}
/>
</main>
</div>
+49 -5
View File
@@ -96,10 +96,50 @@ export const api = {
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (serverId: string) => request('/api/apply', {
profiles: {
add: (label: string, url: string, expectedRevision: number) => request('/api/profiles', {
method: 'POST',
body: JSON.stringify({ label, url, expectedRevision }),
}),
rename: (profileId: string, label: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}`,
{
method: 'PATCH',
body: JSON.stringify({ label, expectedRevision }),
},
),
selectServer: (profileId: string, serverId: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}/server`,
{
method: 'PUT',
body: JSON.stringify({ serverId, expectedRevision }),
},
),
activate: (profileId: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}/activate`,
{
method: 'POST',
body: JSON.stringify({ expectedRevision }),
},
),
refresh: (profileId: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}/refresh`,
{
method: 'POST',
body: JSON.stringify({ expectedRevision }),
},
),
forget: (profileId: string, mode: 'delete' | 'stop-and-delete', expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}`,
{
method: 'DELETE',
body: JSON.stringify({ mode, expectedRevision }),
},
),
},
apply: (profileId: string, serverId: string, expectedRevision: number) => request('/api/apply', {
method: 'POST',
// selectedTag keeps this client compatible with pre-ID Harbor backends.
body: JSON.stringify({ serverId, selectedTag: serverId }),
body: JSON.stringify({ profileId, serverId, expectedRevision }),
}),
gatewayAuto: {
setEnabled: (enabled: boolean) => request('/api/gateway-auto', {
@@ -145,10 +185,13 @@ export const api = {
restart: () => request('/api/singbox/restart', { method: 'POST' }),
},
servers: {
ping: (serverIds: string[]) => request('/api/servers/ping-all', {
ping: (profileId: string, serverIds: string[]) => request(
`/api/profiles/${encodeURIComponent(profileId)}/servers/ping`,
{
method: 'POST',
body: JSON.stringify({ serverIds }),
}),
},
),
},
};
@@ -177,6 +220,7 @@ export function parseHarborState(value: unknown): HarborClientState {
revision: snapshot.revision,
generatedAt: snapshot.generatedAt,
mode: snapshot.mode,
profiles: snapshot.profiles,
subscription: snapshot.subscription,
selection: snapshot.selection,
connection: snapshot.connection,
+166 -86
View File
@@ -51,7 +51,7 @@ import {
versionCompatibility,
} from '../../shared/versions.js';
import type {
HarborServer,
ProfileSnapshot,
RouteRule,
StateSnapshot,
} from '../../shared/contracts/state.js';
@@ -64,6 +64,7 @@ const VERSION_PARTS = [
interface UiError {
context?: string;
profileId?: string;
message?: string;
correlationId?: string;
retry?: (() => unknown) | null;
@@ -79,12 +80,11 @@ interface VersionBadgeProps {
}
interface ComponentActions {
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
listDevices: () => Promise<unknown>;
refreshDevices: () => Promise<unknown>;
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
pingServers: (ids: string[]) => Promise<unknown>;
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise<unknown>;
}
@@ -102,15 +102,13 @@ interface ClientOverviewPageProps {
versionInfo: unknown;
operations?: OperationRegistrySnapshot;
error: UiError | null;
subscriptionUrl: string;
setSubscriptionUrl: (value: string) => void;
servers: HarborServer[];
pendingServerId: string;
setPendingServerId: (id: string) => void;
onFetchSubscription: () => Promise<unknown>;
onRefreshSubscription: () => Promise<unknown>;
onForgetSubscription: () => Promise<unknown>;
onApply: (serverId: string) => Promise<unknown>;
onAddProfile: (label: string, url: string) => Promise<unknown>;
onRenameProfile: (profileId: string, label: string) => Promise<unknown>;
onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
onActivateProfile: (profileId: string) => Promise<unknown>;
onRefreshProfile: (profileId: string) => Promise<unknown>;
onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
onApply: (profileId: string, serverId: string) => Promise<unknown>;
onRestart: () => Promise<unknown>;
onStop: () => Promise<unknown>;
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
@@ -243,11 +241,30 @@ function InlineError({ error, context }: { error?: UiError | null; context: stri
const operationProgress: Partial<Record<keyof OperationRegistrySnapshot, readonly [string, string]>> = {
connection: ['connection', 'Меняем состояние подключения…'],
serverApply: ['connection', 'Применяем сервер…'],
subscriptionImport: ['subscription', 'Загружаем подписку…'],
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
profileActivate: ['connection', 'Переключаем подписку…'],
profileAdd: ['subscription', 'Добавляем подписку…'],
profileRefresh: ['subscription', 'Обновляем подписку…'],
profileDelete: ['subscription', 'Удаляем подписку…'],
routeRules: ['routing', 'Применяем локальные правила…'],
};
const canonicalOperationKeys: Record<string, OperationKey> = {
start: 'connection',
stop: 'connection',
'apply-server': 'serverApply',
'profile-add': 'profileAdd',
'profile-rename': 'profileRename',
'profile-select-server': 'profileSelect',
'profile-activate': 'profileActivate',
'profile-refresh': 'profileRefresh',
'profile-delete': 'profileDelete',
'gateway-auto': 'gatewayAuto',
'route-rules': 'routeRules',
'subscription-import': 'profileAdd',
'subscription-refresh': 'profileRefresh',
'subscription-forget': 'profileDelete',
};
function InlineProgress({ operations, context }: {
operations: OperationRegistrySnapshot;
context: string;
@@ -266,6 +283,29 @@ function InlineProgress({ operations, context }: {
);
}
function AppliedIdentity({ identity, operation }: { identity: string; operation: string }) {
const [current, setCurrent] = useState(identity);
const [previous, setPrevious] = useState('');
useEffect(() => {
if (identity === current) return undefined;
setPrevious(current);
setCurrent(identity);
const timer = setTimeout(() => setPrevious(''), 360);
return () => clearTimeout(timer);
}, [identity]);
return <div className="client-applied-identity" aria-label={identity}>
<span className="client-applied-value" aria-hidden="true">
{previous && <strong className="is-leaving">{previous}</strong>}
<strong key={current} className="is-active">{current}</strong>
</span>
<div className="client-applied-operation">
{operation && <span>{operation}</span>}
</div>
</div>;
}
function HarborBrand({ isGateway, connected, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: {
isGateway: boolean;
connected: boolean;
@@ -369,14 +409,12 @@ export function ClientOverviewPage({
versionInfo,
operations = {},
error,
subscriptionUrl,
setSubscriptionUrl,
servers,
pendingServerId,
setPendingServerId,
onFetchSubscription,
onRefreshSubscription,
onForgetSubscription,
onAddProfile,
onRenameProfile,
onSelectProfileServer,
onActivateProfile,
onRefreshProfile,
onForgetProfile,
onApply,
onRestart,
onStop,
@@ -388,12 +426,18 @@ export function ClientOverviewPage({
const gatewayDirect = !isGateway && state?.route?.mode === 'gateway-direct';
const gatewayAvailable = !isGateway && Boolean(state?.clientRuntime?.gatewayAvailable);
const connected = state?.connection?.process === 'running';
const hasSubscription = state?.subscription?.status === 'ready';
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
const profiles = state?.profiles || [];
const hasSubscription = profiles.length > 0;
const desiredProfile = profiles.find(({ id }) => id === state?.selection?.desiredProfileId);
const appliedProfile = profiles.find(({ id }) => id === state?.selection?.appliedProfileId);
const selectedServerId = desiredProfile?.desiredServerId || '';
const appliedServerId = state?.selection?.appliedServerId || '';
const appliedServer = servers.find(({ id }) => id === appliedServerId);
const desiredServer = servers.find(({ id }) => id === selectedServerId);
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
const appliedServer = appliedProfile?.servers.find(({ id }) => id === appliedServerId)
|| (state?.selection?.appliedServerSnapshot?.id === appliedServerId
? state.selection.appliedServerSnapshot
: null);
const desiredServer = desiredProfile?.servers.find(({ id }) => id === selectedServerId);
const showPower = hasSubscription;
const [now, setNow] = useState(Date.now());
const [showIntro, setShowIntro] = useState(true);
const [copyFeedback, setCopyFeedback] = useState<CopyFeedbackMap>({});
@@ -403,32 +447,62 @@ export function ClientOverviewPage({
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const controlHost = window.location.host || `${gatewayAddress}:3456`;
const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress);
const connectionBlocked = operationBlocked(operations, 'connection');
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
const switchingServer = Boolean(
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
);
const canonicalOperationKey = state.operation.status === 'running'
? canonicalOperationKeys[state.operation.kind || '']
: undefined;
const canonicalTarget = state.operation.profileId
? `${state.operation.profileId}${state.operation.serverId ? `:${state.operation.serverId}` : ''}`
: '';
const visibleOperations = canonicalOperationKey && !operations[canonicalOperationKey]
? {
...operations,
[canonicalOperationKey]: {
status: 'running' as const,
startedAt: state.operation.startedAt || state.generatedAt,
target: canonicalTarget,
},
}
: operations;
const connectionBlocked = operationBlocked(visibleOperations, 'connection');
const serverApplyBlocked = operationBlocked(visibleOperations, 'serverApply');
const gatewayAutoBlocked = operationBlocked(visibleOperations, 'gatewayAuto');
const localApplyTarget = operations.serverApply?.target.split(':') || [];
const canonicalSwitch = state.operation.status === 'running'
&& ['profile-activate', 'apply-server'].includes(state.operation.kind || '');
const operationProfileId = canonicalSwitch
? state.operation.profileId || ''
: operations.profileActivate?.target || localApplyTarget[0] || '';
const operationProfile = profiles.find(({ id }) => id === operationProfileId);
const operationServerId = canonicalSwitch
? state.operation.serverId || operationProfile?.desiredServerId || ''
: localApplyTarget[1] || operationProfile?.desiredServerId || '';
const operationServer = operationProfile?.servers.find(({ id }) => id === operationServerId);
const localSwitch = operations.profileActivate?.status === 'running'
|| operations.serverApply?.status === 'running';
const switchingServer = connected
&& !gatewayDirect
&& (canonicalSwitch || localSwitch)
&& Boolean(operationProfile && operationServer)
&& (operationProfile?.id !== appliedProfile?.id || operationServer?.id !== appliedServer?.id);
const subscriptionFeature = useSubscriptionFeature({
subscription: state?.subscription,
subscriptionUrl,
setSubscriptionUrl,
operations,
profiles,
selection: state.selection,
connected,
operations: visibleOperations,
error,
serverCount: servers.length,
isGateway,
gatewayDirect,
validateSubscription: actions.validateSubscription,
onImport: onFetchSubscription,
onRefresh: onRefreshSubscription,
onForget: onForgetSubscription,
onAdd: onAddProfile,
onRename: onRenameProfile,
onRefresh: onRefreshProfile,
onForget: onForgetProfile,
onActivate: onActivateProfile,
onDismissError,
});
const subscriptionContentReady = subscriptionFeature.contentReady;
const routingFeature = useRoutingFeature({
route: state?.route,
connected,
operations,
operations: visibleOperations,
onSave: onSaveRouteRules,
onDismissError,
});
@@ -446,7 +520,7 @@ export function ClientOverviewPage({
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
controlHost,
});
const diagnosticsAvailable = isGateway || (hasSubscription && subscriptionContentReady);
const diagnosticsAvailable = hasSubscription;
useEffect(() => {
setNow(Date.now());
@@ -465,11 +539,9 @@ export function ClientOverviewPage({
useEffect(() => {
if (!hasSubscription) {
routingFeature.forceClose();
if (!isGateway) {
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
}
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
}
}, [hasSubscription, isGateway]);
@@ -482,9 +554,12 @@ export function ClientOverviewPage({
copyAttemptsRef.current = {};
}, []);
function selectServer(serverId: string) {
setPendingServerId(serverId);
if (connected && serverId) onApply(serverId);
function selectServer(profile: ProfileSnapshot, serverId: string) {
if (connected && !gatewayDirect && state.selection.appliedProfileId === profile.id) {
onApply(profile.id, serverId);
return;
}
onSelectProfileServer(profile.id, serverId);
}
async function copyProxy(kind: CopyKind) {
@@ -529,16 +604,31 @@ export function ClientOverviewPage({
routingFeature.open();
}
const mainIdentity = gatewayDirect
? 'Gateway · сервер не определён'
: connected
? appliedProfile && appliedServer
? `${appliedProfile.label} · ${appliedServer.label}`
: 'VPN · сервер не определён'
: desiredProfile && desiredServer
? `Выбран: ${desiredProfile.label} · ${desiredServer.label}`
: 'Сервер не выбран';
const switchIdentity = gatewayDirect
? 'Данные применённого сервера Gateway недоступны'
: switchingServer && operationProfile && operationServer
? `Переключаем на ${operationProfile.label} · ${operationServer.label}`
: '';
return (
<div
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
className={`client-shell${!hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
<span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span>
</div>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{isGateway && <SubscriptionToggle
{hasSubscription && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
<SubscriptionToggle
feature={subscriptionFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
@@ -547,7 +637,7 @@ export function ClientOverviewPage({
diagnosticsFeature.close();
subscriptionFeature.toggle();
}}
/>}
/>
<InstructionsToggle
feature={instructionsFeature}
onToggle={() => {
@@ -586,14 +676,13 @@ export function ClientOverviewPage({
onOpen={openRouting}
/>
</nav>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway && hasSubscription ? ' is-gateway-home' : ''}`}>
<ConnectionPanel
visible={showPower}
isGateway={isGateway}
connected={connected}
gatewayDirect={gatewayDirect}
selectedServerId={selectedServerId}
configured={Boolean(state?.clientRuntime?.configured)}
startedAt={state?.connection?.startedAt}
gatewayAddress={gatewayAddress}
gatewayUiOrigin={state?.route?.gatewayUiOrigin}
@@ -603,7 +692,7 @@ export function ClientOverviewPage({
blocked={connectionBlocked}
brandSlot={<HarborBrand
isGateway={isGateway}
connected={connected}
connected={connected || gatewayDirect}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
@@ -611,56 +700,47 @@ export function ClientOverviewPage({
/>}
copyFeedback={copyFeedback}
onCopyProxy={copyProxy}
onApply={onApply}
onRestart={onRestart}
onApply={(serverId) => desiredProfile && onApply(desiredProfile.id, serverId)}
onStop={onStop}
routingSlot={<RoutingPendingStatus
feature={routingFeature}
blocked={connectionBlocked}
onRestart={onRestart}
/>}
serverSlot={isGateway && <div className="client-gateway-route-summary" aria-labelledby="gateway-summary-title">
<span className="client-gateway-summary-kicker">Сейчас</span>
<strong id="gateway-summary-title">
{appliedServer?.label || 'VPN-сервер не используется'}
</strong>
<div className="client-gateway-route-slot">
{switchingServer && desiredServer && <span>Переключаем на {desiredServer.label}</span>}
</div>
</div>}
serverSlot={<AppliedIdentity identity={mainIdentity} operation={switchIdentity} />}
statusSlot={<>
<InlineError error={error} context="connection" />
<InlineProgress operations={operations} context="connection" />
<InlineProgress operations={visibleOperations} context="connection" />
</>}
/>
{isGateway && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
{isGateway && hasSubscription && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
<SubscriptionPanel
feature={subscriptionFeature}
statusSlot={<>
<InlineError error={subscriptionFeature.error || error} context="subscription" />
<InlineProgress operations={operations} context="subscription" />
</>}
serverSlot={hasSubscription && subscriptionContentReady && <ServerPicker
renderServerPicker={(profile, pickerState) => <ServerPicker
profileId={profile.id}
pingServers={actions.pingServers}
servers={servers}
selectedServerId={selectedServerId}
disabled={serverApplyBlocked}
prompt={!showPower}
leaving={subscriptionFeature.serversLeaving}
revealVersion={subscriptionFeature.serverRevealVersion}
onSelect={selectServer}
servers={profile.servers}
selectedServerId={profile.desiredServerId}
disabled={serverApplyBlocked || pickerState.disabled}
prompt={!profile.desiredServerId}
leaving={pickerState.leaving}
revealVersion={pickerState.revealVersion}
onSelect={(serverId) => selectServer(profile, serverId)}
/>}
/>
</main>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <InstructionsPanel
{hasSubscription && <InstructionsPanel
feature={instructionsFeature}
isGateway={isGateway}
/>}
{isGateway && <DevicesPanel feature={devicesFeature} />}
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature}
@@ -668,11 +748,11 @@ export function ClientOverviewPage({
isGateway={isGateway}
/>}
{hasSubscription && subscriptionContentReady && <RoutingPanel
{hasSubscription && <RoutingPanel
feature={routingFeature}
statusSlot={<>
<InlineError error={error} context="routing" />
<InlineProgress operations={operations} context="routing" />
<InlineProgress operations={visibleOperations} context="routing" />
</>}
/>}
<RoutingDiscardDialog feature={routingFeature} />
+21 -21
View File
@@ -26,7 +26,6 @@ interface ConnectionPanelProps {
connected: boolean;
gatewayDirect: boolean;
selectedServerId: string;
configured: boolean;
startedAt?: string | null;
gatewayAddress: string;
gatewayUiOrigin?: string | null;
@@ -41,7 +40,6 @@ interface ConnectionPanelProps {
statusSlot?: ReactNode;
onCopyProxy: (kind: CopyKind) => unknown;
onApply: (serverId: string) => unknown;
onRestart: () => unknown;
onStop: () => unknown;
}
@@ -67,7 +65,6 @@ export function ConnectionPanel({
connected,
gatewayDirect,
selectedServerId,
configured,
startedAt,
gatewayAddress,
gatewayUiOrigin,
@@ -82,7 +79,6 @@ export function ConnectionPanel({
statusSlot,
onCopyProxy,
onApply,
onRestart,
onStop,
}: ConnectionPanelProps) {
const [durationMode, setDurationMode] = useState(() => {
@@ -93,8 +89,9 @@ export function ConnectionPanel({
}
});
const [confirmingStop, setConfirmingStop] = useState(false);
const canStart = Boolean(selectedServerId || configured);
const powerUnavailable = isGateway && !connected && !canStart;
const remoteOwned = !isGateway && gatewayDirect;
const canStart = Boolean(selectedServerId);
const powerUnavailable = remoteOwned || (!connected && !canStart);
const proxyUrls = localProxyUrls(proxyPort, gatewayAddress);
const duration = connectionDurationParts(startedAt, now);
const clockUnits: Array<[string, DurationUnit]> = [
@@ -104,9 +101,9 @@ export function ConnectionPanel({
];
const wordClockDuration = clockUnits
.filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено';
const connectionTitle = remoteOwned
? 'Gateway подключён'
: connected ? 'VPN включён' : 'Подключение выключено';
const proxyKinds: Array<[CopyKind, string]> = isGateway
? [
['gateway', 'GATEWAY'],
@@ -119,13 +116,12 @@ export function ConnectionPanel({
];
function toggleConnection() {
const action = connectionAction({ connected, selectedServerId, configExists: configured });
const action = connectionAction({ connected, selectedServerId });
if (action?.type === 'stop') {
setConfirmingStop(true);
return;
}
if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart();
}
async function stopConnection() {
@@ -149,12 +145,14 @@ export function ConnectionPanel({
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={isGateway
aria-checked={connected || remoteOwned}
aria-label={remoteOwned
? 'Подключением управляет Harbor Gateway'
: isGateway
? connected ? 'Остановить VPN' : 'Запустить VPN'
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
disabled={blocked || (!connected && !canStart)}
disabled={blocked || powerUnavailable}
onClick={toggleConnection}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -165,7 +163,7 @@ export function ConnectionPanel({
return <>
{visible ? <section className={`client-power-section${isGateway ? ' is-gateway' : ''}`} aria-labelledby="connection-title">
<div
className={`client-power-control${isGateway ? ' client-tooltip-anchor' : ''}`}
className={`client-power-control${powerUnavailable ? ' client-tooltip-anchor' : ''}`}
tabIndex={powerUnavailable ? 0 : undefined}
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
@@ -173,11 +171,11 @@ export function ConnectionPanel({
{brandSlot}
{powerButton}
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip">
Сначала добавьте подписку и выберите сервер
{remoteOwned ? 'Подключением управляет Harbor Gateway' : 'Сначала добавьте подписку и выберите сервер'}
</span>}
</div>
<div className="client-state-detail">
{connected ? (
{connected && !remoteOwned ? (
<button
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button"
@@ -224,16 +222,18 @@ export function ConnectionPanel({
</button>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
{remoteOwned
? 'Управляется Harbor Gateway'
: canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
{routingSlot}
<div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
<span className={!connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={remoteOwned ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2>
{serverSlot}
</div>
+21 -13
View File
@@ -53,9 +53,9 @@ function write(key: string, value: string | string[]) {
}
}
function readAuto() {
function readAuto(key: string) {
try {
return localStorage.getItem(AUTO_KEY) === 'true';
return localStorage.getItem(key) === 'true';
} catch {
return false;
}
@@ -153,7 +153,8 @@ function ServerRow({
}
interface ServerPickerProps {
pingServers: (ids: string[]) => Promise<unknown>;
profileId: string;
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
servers: PickerServer[];
selectedServerId: string;
disabled: boolean;
@@ -164,6 +165,7 @@ interface ServerPickerProps {
}
export function ServerPicker({
profileId,
pingServers,
servers,
selectedServerId,
@@ -173,13 +175,16 @@ export function ServerPicker({
revealVersion,
onSelect,
}: ServerPickerProps) {
const favoritesKey = `${FAVORITES_KEY}:${profileId}`;
const recentKey = `${RECENT_KEY}:${profileId}`;
const autoKey = `${AUTO_KEY}:${profileId}`;
const [query, setQuery] = useState('');
const [advanced, setAdvanced] = useState(false);
const [view, setView] = useState<'all' | 'favorites' | 'recent'>('all');
const [page, setPage] = useState(0);
const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY));
const [recent, setRecent] = useState(() => readList(RECENT_KEY));
const [autoActive, setAutoActive] = useState(readAuto);
const [favorites, setFavorites] = useState(() => readList(favoritesKey));
const [recent, setRecent] = useState(() => readList(recentKey));
const [autoActive, setAutoActive] = useState(() => readAuto(autoKey));
const [collapsed, setCollapsed] = useState<string[]>([]);
const [pings, setPings] = useState<PingState>({});
const [checking, setChecking] = useState(false);
@@ -208,18 +213,18 @@ export function ServerPicker({
function toggleFavorite(id: string) {
setFavorites((current) => {
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
write(FAVORITES_KEY, next);
write(favoritesKey, next);
return next;
});
}
function select(id: string, automatic = false) {
setAutoActive(automatic);
write(AUTO_KEY, String(automatic));
write(autoKey, String(automatic));
if (!automatic) {
setRecent((current) => {
const next = [id, ...current.filter((item) => item !== id)].slice(0, 5);
write(RECENT_KEY, next);
write(recentKey, next);
return next;
});
}
@@ -236,7 +241,7 @@ export function ServerPicker({
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])),
}));
try {
const results = parseServerPingResults(await pingServers(ids)) as PingResult[];
const results = parseServerPingResults(await pingServers(profileId, ids)) as PingResult[];
setPings((current) => ({
...current,
...Object.fromEntries(results.map((result) => [result.id, { ...result, checking: true }])),
@@ -251,7 +256,10 @@ export function ServerPicker({
}])),
}));
} finally {
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 900 - (performance.now() - startedAt))));
const elapsed = performance.now() - startedAt;
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const completeAt = reduced ? elapsed : Math.max(900, Math.ceil(elapsed / 900) * 900);
await new Promise((resolve) => setTimeout(resolve, Math.max(0, completeAt - elapsed)));
setPings((current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])),
@@ -324,7 +332,7 @@ export function ServerPicker({
inert={advanced ? true : undefined}
>
<div className="client-server-mode-panel-inner">
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${serverKey}:${revealVersion}`}>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${revealVersion}`}>
<div className="client-server-grid">
{simpleServers.map((server, index) => <ServerRow
key={server.id}
@@ -401,7 +409,7 @@ export function ServerPicker({
/>}
</div>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${serverKey}:${revealVersion}`}>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${revealVersion}`}>
{!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
{grouped ? groupServers(visible).map(([group, items]) => {
const isCollapsed = collapsed.includes(group);
File diff suppressed because it is too large Load Diff
-24
View File
@@ -4,7 +4,6 @@ export type SyncErrorKind = 'incompatible-api' | 'control-unreachable' | 'fatal'
export interface HarborReducerState {
snapshot: HarborClientState | null;
pendingServerId: string;
transport: {
bootStatus: 'loading' | 'ready' | SyncErrorKind;
lastSuccessfulSyncAt: string | null;
@@ -15,15 +14,12 @@ export interface HarborReducerState {
}
export type HarborAction =
| { type: 'select-server'; serverId: string }
| { type: 'clear-pending-server' }
| { type: 'retry-sync' }
| { type: 'sync-failed'; error: unknown }
| { type: 'sync-succeeded'; snapshot: HarborClientState; receivedAt: string };
export const initialHarborState: HarborReducerState = {
snapshot: null,
pendingServerId: '',
transport: {
bootStatus: 'loading',
lastSuccessfulSyncAt: null,
@@ -43,24 +39,7 @@ export function classifySyncError(error: unknown): SyncErrorKind {
return 'fatal';
}
function reconcilePendingServer(pendingServerId: string, snapshot: HarborClientState) {
if (!pendingServerId || snapshot.selection.desiredServerId === pendingServerId) return '';
return snapshot.servers.some((server) => server.id === pendingServerId)
? pendingServerId
: '';
}
export function harborReducer(current: HarborReducerState, action: HarborAction): HarborReducerState {
if (action.type === 'select-server') {
return action.serverId === current.pendingServerId
? current
: { ...current, pendingServerId: action.serverId };
}
if (action.type === 'clear-pending-server') {
return current.pendingServerId ? { ...current, pendingServerId: '' } : current;
}
if (action.type === 'retry-sync') {
return current.snapshot ? current : {
...current,
@@ -95,9 +74,6 @@ export function harborReducer(current: HarborReducerState, action: HarborAction)
return {
snapshot: newer ? snapshot : current.snapshot,
pendingServerId: newer
? reconcilePendingServer(current.pendingServerId, snapshot)
: current.pendingServerId,
transport: {
bootStatus: 'ready',
lastSuccessfulSyncAt: action.receivedAt,
+35 -21
View File
@@ -1,23 +1,36 @@
export type OperationKey = 'connection' | 'serverApply' | 'subscriptionImport'
| 'subscriptionRefresh' | 'subscriptionDelete' | 'gatewayAuto' | 'routeRules';
export interface OperationState { status: 'running'; startedAt: string }
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
| 'gatewayAuto' | 'routeRules';
export interface OperationState {
status: 'running';
startedAt: string;
target: string;
}
export type OperationRegistrySnapshot = Partial<Record<OperationKey, OperationState>>;
export const OPERATION_CONFLICTS: Readonly<Record<OperationKey, readonly OperationKey[]>> = Object.freeze({
connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto', 'routeRules'],
gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'routeRules'],
routeRules: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
});
const OPERATION_KEYS: readonly OperationKey[] = [
'connection',
'serverApply',
'profileAdd',
'profileRename',
'profileSelect',
'profileActivate',
'profileRefresh',
'profileDelete',
'gatewayAuto',
'routeRules',
];
// The backend has one canonical revision and one mutation queue, so the UI mirrors that lock.
export const OPERATION_CONFLICTS = Object.freeze(Object.fromEntries(
OPERATION_KEYS.map((key) => [key, OPERATION_KEYS.filter((candidate) => candidate !== key)]),
) as unknown as Record<OperationKey, readonly OperationKey[]>);
export function operationBlocked(operations: OperationRegistrySnapshot, key: OperationKey) {
if (operations[key]?.status === 'running') return true;
return (OPERATION_CONFLICTS[key] || []).some(
(conflict) => operations[conflict]?.status === 'running',
);
return OPERATION_CONFLICTS[key].some((conflict) => operations[conflict]?.status === 'running');
}
export function createOperationRegistry(
@@ -25,14 +38,15 @@ export function createOperationRegistry(
now = () => new Date().toISOString(),
) {
let operations: OperationRegistrySnapshot = {};
const inFlight = new Map<OperationKey, Promise<unknown>>();
const inFlight = new Map<string, Promise<unknown>>();
function run<T>(key: OperationKey, action: () => T | Promise<T>): Promise<T | false> {
const existing = inFlight.get(key);
function run<T>(key: OperationKey, action: () => T | Promise<T>, target = ''): Promise<T | false> {
const identity = `${key}:${target}`;
const existing = inFlight.get(identity);
if (existing) return existing as Promise<T>;
if (operationBlocked(operations, key)) return Promise.resolve(false);
operations = { ...operations, [key]: { status: 'running', startedAt: now() } };
operations = { ...operations, [key]: { status: 'running', startedAt: now(), target } };
onChange(operations);
const promise: Promise<T> = Promise.resolve()
@@ -40,10 +54,10 @@ export function createOperationRegistry(
.finally(() => {
const { [key]: completed, ...remaining } = operations;
operations = remaining;
inFlight.delete(key);
inFlight.delete(identity);
onChange(operations);
});
inFlight.set(key, promise as Promise<unknown>);
inFlight.set(identity, promise as Promise<unknown>);
return promise;
}
+59
View File
@@ -28,6 +28,65 @@
min-height: 76px;
}
.client-applied-identity {
min-height: 38px;
display: grid;
justify-items: center;
align-content: start;
gap: 3px;
padding-top: 5px;
}
.client-applied-value {
max-width: min(320px, calc(100vw - 42px));
display: grid;
}
.client-applied-value strong {
grid-area: 1 / 1;
max-width: min(320px, calc(100vw - 42px));
overflow: hidden;
color: var(--client-text);
font-size: 12px;
font-weight: 650;
letter-spacing: -0.025em;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity 260ms ease, filter 360ms cubic-bezier(0.16, 1, 0.3, 1), transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-applied-value strong.is-active {
animation: client-applied-identity-in 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-applied-value strong.is-leaving {
opacity: 0;
filter: blur(5px);
transform: translateY(-2px);
}
.client-applied-operation {
width: 100%;
min-height: 15px;
overflow: hidden;
color: var(--client-accent);
font-size: 8px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes client-applied-identity-in {
from { opacity: 0; filter: blur(5px); transform: translateY(2px); }
}
@media (prefers-reduced-motion: reduce) {
.client-applied-value strong {
transition: none;
animation: none;
}
}
.client-power-control {
position: relative;
width: 96px;
-1
View File
@@ -1099,7 +1099,6 @@
text-align: center;
}
.client-gateway-summary-kicker,
.client-gateway-traffic-total > small {
color: var(--client-muted);
font-size: 9px;
+439 -452
View File
@@ -1,454 +1,3 @@
.client-icon-tooltip {
width: 32px;
height: 32px;
display: grid;
place-items: center;
}
.client-subscription-drawer .client-subscription,
.client-subscription-drawer .client-usage,
.client-subscription-drawer .client-servers {
width: min(100%, 300px);
margin-inline: auto;
}
.client-subscription {
position: relative;
min-width: 0;
min-height: 88px;
display: grid;
align-items: center;
justify-items: center;
}
.client-subscription-refresh,
.client-subscription-delete {
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--client-muted);
cursor: pointer;
transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-refresh svg,
.client-subscription-delete svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.client-subscription-delete .client-trash-lid {
transform-origin: 12px 7px;
transition: transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-delete:hover:not(:disabled) .client-trash-lid,
.client-subscription-delete:focus-visible .client-trash-lid {
transform: translateY(-2px) rotate(-10deg);
}
.client-subscription-refresh:hover:not(:disabled) {
color: var(--client-accent);
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 55%, transparent));
transform: rotate(90deg);
}
.client-subscription-refresh:disabled {
color: var(--client-accent);
cursor: wait;
animation: client-spin 900ms linear infinite;
}
.client-subscription-refresh:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-subscription-delete:hover:not(:disabled) {
color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 6px oklch(0.68 0.15 28 / 0.45));
}
.client-subscription-delete:disabled {
opacity: 0.4;
cursor: wait;
}
.client-subscription-delete:focus-visible {
outline: 2px solid oklch(0.68 0.15 28);
outline-offset: 2px;
}
.client-subscription.is-editing .client-subscription-refresh,
.client-subscription.is-editing .client-subscription-delete {
opacity: 0;
pointer-events: none;
}
.client-subscription.is-editing .client-icon-tooltip {
opacity: 0;
pointer-events: none;
}
.client-subscription-edit,
.client-subscription-summary {
grid-area: 1 / 1;
width: 100%;
border: 0;
background: transparent;
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1), transform 650ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-edit {
width: min(100%, 380px);
min-height: 44px;
display: block;
position: relative;
opacity: 0;
filter: blur(16px);
transform: translateY(6px) scale(0.96);
pointer-events: none;
}
.client-subscription-edit::before,
.client-subscription-edit::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
pointer-events: none;
}
.client-subscription-edit::before {
background: var(--client-border);
}
.client-subscription-edit::after {
background: var(--client-accent);
opacity: 0.72;
filter: blur(0.5px);
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
transition: background 500ms ease, box-shadow 500ms ease;
}
.client-subscription-edit.is-invalid::after {
background: oklch(0.68 0.15 28);
box-shadow: 0 0 8px oklch(0.68 0.15 28 / 0.72), 0 0 18px oklch(0.68 0.15 28 / 0.24);
}
.client-subscription.is-timing-out .client-subscription-edit::after {
animation: client-subscription-timeout 5s linear forwards;
}
@keyframes client-subscription-timeout {
0% {
opacity: 0.9;
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
}
100% {
opacity: 0.08;
box-shadow: 0 0 0 transparent;
}
}
.client-subscription.is-editing .client-subscription-edit {
opacity: 1;
filter: blur(0);
transform: translateY(0);
pointer-events: auto;
}
.client-subscription-edit input {
width: 100%;
min-width: 0;
height: 44px;
padding: 0 42px;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
caret-color: transparent;
font-size: 12px;
text-align: center;
}
.client-subscription-edit input::placeholder {
color: var(--client-muted);
}
.client-subscription-edit input.has-value {
color: transparent;
caret-color: transparent;
}
.client-subscription-edit input:-webkit-autofill,
.client-subscription-edit input:-webkit-autofill:hover,
.client-subscription-edit input:-webkit-autofill:focus {
box-shadow: 0 0 0 1000px var(--client-bg) inset;
-webkit-text-fill-color: transparent;
}
.client-subscription-domain {
position: absolute;
top: 50%;
left: 42px;
right: 42px;
overflow: hidden;
color: var(--client-text);
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
transform: translateY(-50%);
white-space: nowrap;
pointer-events: none;
}
.client-subscription-submit {
position: absolute;
top: 4px;
right: 0;
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font-size: 19px;
font-weight: 700;
cursor: pointer;
transition: color 250ms ease, filter 500ms ease, opacity 250ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-submit:hover:not(:disabled),
.client-subscription-submit:focus-visible {
filter: drop-shadow(0 0 5px var(--client-accent)) drop-shadow(0 0 14px var(--client-accent));
transform: scale(1.14);
}
.client-subscription-submit:disabled {
cursor: default;
}
.client-subscription-edit.is-invalid .client-subscription-submit {
color: oklch(0.68 0.15 28);
opacity: 0.9;
}
.client-subscription-heading {
width: 100%;
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 64px;
align-items: center;
}
.client-subscription-status {
grid-column: 2;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--client-accent);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-subscription-status i {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.client-subscription-actions {
grid-column: 3;
display: flex;
justify-content: flex-end;
}
.client-subscription-domain-button {
width: 100%;
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.client-subscription-domain-button .client-subscription-label {
display: block;
margin-bottom: 3px;
}
.client-subscription-summary {
min-width: 0;
max-width: 100%;
min-height: 88px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 2px;
color: var(--client-muted);
text-align: center;
cursor: default;
overflow: visible;
}
.client-subscription.is-editing .client-subscription-summary {
opacity: 0;
filter: blur(18px);
transform: translateY(-4px) scale(1.06);
pointer-events: none;
}
.client-subscription-label {
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.client-subscription-domain-button strong {
display: block;
overflow: hidden;
color: var(--client-text);
font-size: 30px;
font-weight: 650;
letter-spacing: -0.05em;
opacity: 0.96;
text-shadow: 0 0 20px color-mix(in oklch, var(--client-accent) 24%, transparent);
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
text-overflow: ellipsis;
white-space: nowrap;
}
.client-subscription-domain-button:hover strong {
opacity: 1;
transform: translateY(-2px);
text-shadow: 0 0 28px color-mix(in oklch, var(--client-accent) 42%, transparent);
}
.client-usage {
width: min(100%, 280px);
min-height: 76px;
display: grid;
align-content: start;
gap: 9px;
margin: -6px auto 0;
color: var(--client-muted);
text-align: center;
}
.client-usage-summary {
display: grid;
gap: 3px;
}
.client-usage-summary > span,
.client-usage-details > span {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-usage-summary > strong {
color: var(--client-text);
font-size: 16px;
font-weight: 600;
transition: color 700ms ease, filter 700ms ease, text-shadow 700ms ease;
}
.client-usage.is-updated {
animation: client-usage-glow 1100ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-usage.is-updated .client-usage-summary > strong {
color: var(--client-accent);
filter: brightness(1.25);
text-shadow: 0 0 8px var(--client-accent), 0 0 22px color-mix(in oklch, var(--client-accent) 70%, transparent);
}
.client-usage.is-updated .client-usage-bar i {
animation: client-bar-flare 1100ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes client-usage-glow {
30% { filter: drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 78%, transparent)); }
}
@keyframes client-bar-flare {
30% { box-shadow: 0 0 6px var(--client-accent), 0 0 20px var(--client-accent); filter: brightness(1.45); }
}
.client-usage-summary > strong small {
color: var(--client-muted);
font-size: 10px;
font-weight: 500;
}
.client-usage-bar {
height: 2px;
overflow: hidden;
background: var(--client-border);
}
.client-usage-bar i {
display: block;
height: 100%;
background: var(--client-accent);
box-shadow: 0 0 8px var(--client-accent);
transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-usage-details {
min-height: 24px;
display: grid;
grid-template-columns: auto auto;
justify-content: center;
align-items: baseline;
column-gap: 6px;
font-size: 9px;
white-space: nowrap;
}
.client-usage-details > span {
grid-column: 1 / -1;
font-size: 8px;
opacity: 0.72;
}
.client-usage-details > strong {
color: var(--client-text);
font-size: 10px;
font-weight: 600;
}
.client-usage-details > small {
color: var(--client-muted);
font-size: 9px;
}
.client-inline-error.is-subscription {
top: calc(100% + 2px);
}
@@ -483,8 +32,446 @@
}
}
.client-subscription-sheet {
width: 100%;
display: block;
padding: 32px 30px 76px;
}
.client-profiles-header {
min-height: 44px;
display: flex;
align-items: center;
gap: 8px;
padding-right: 76px;
}
.client-profiles-header h2 {
flex: 1;
margin: 0;
font-size: 16px;
letter-spacing: 0.02em;
}
.client-profiles-header > button:not(.client-drawer-close) {
width: 40px;
height: 40px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 300 24px/1 inherit;
cursor: pointer;
}
.client-profiles-header > button:hover,
.client-profiles-header > button:focus-visible {
color: var(--client-accent);
}
.client-profiles-operation,
.client-profiles-current {
min-height: 42px;
display: flex;
align-items: center;
margin-top: 14px;
padding: 8px 12px;
border: 1px solid var(--client-border);
color: var(--client-muted);
font-size: 9px;
}
.client-profiles-operation {
gap: 8px;
}
.client-profiles-operation.is-active::before {
content: '';
width: 7px;
height: 7px;
border: 1px solid currentColor;
border-radius: 50%;
}
.client-profiles-current {
gap: 8px;
margin-bottom: 18px;
}
.client-profiles-current strong {
min-width: 0;
overflow: hidden;
color: var(--client-text);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-profile-list {
border-top: 1px solid var(--client-border);
}
.client-profile-group {
position: relative;
border-bottom: 1px solid var(--client-border);
}
.client-profile-header {
min-height: 72px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto 36px 36px;
align-items: center;
gap: 4px;
}
.client-profile-disclosure {
min-width: 0;
min-height: 56px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
padding: 6px 0;
border: 0;
background: transparent;
color: var(--client-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.client-profile-chevron {
color: var(--client-muted);
font-size: 20px;
transform: rotate(0);
transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-profile-disclosure[aria-expanded='true'] .client-profile-chevron {
transform: rotate(90deg);
}
.client-profile-title {
min-width: 0;
display: grid;
gap: 4px;
}
.client-profile-title > span {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.client-profile-title strong,
.client-profile-title small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-profile-title strong {
font-size: 12px;
letter-spacing: -0.02em;
}
.client-profile-title small {
color: var(--client-muted);
font-size: 9px;
}
.client-profile-title em {
padding: 3px 6px;
border: 1px solid color-mix(in oklch, var(--client-accent) 30%, transparent);
border-radius: 3px;
background: color-mix(in oklch, var(--client-accent) 8%, transparent);
color: var(--client-accent);
font-size: 7px;
font-style: normal;
letter-spacing: 0.08em;
}
.client-profile-usage {
max-width: 126px;
overflow: hidden;
color: var(--client-muted);
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-profile-refresh,
.client-profile-menu-toggle {
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
font: 400 18px/1 inherit;
cursor: pointer;
}
.client-profile-refresh:hover:not(:disabled),
.client-profile-refresh:focus-visible,
.client-profile-menu-toggle:hover,
.client-profile-menu-toggle:focus-visible {
color: var(--client-accent);
}
.client-profile-refresh.is-refreshing {
color: var(--client-accent);
animation: client-spin 900ms linear infinite;
}
.client-profile-refresh:disabled,
.client-profile-menu-toggle:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-menu-wrap {
position: relative;
}
.client-profile-menu {
position: absolute;
top: 34px;
right: 0;
z-index: 4;
width: 150px;
display: grid;
padding: 6px;
border: 1px solid var(--client-border);
background: var(--client-bg);
box-shadow: 0 16px 38px color-mix(in oklch, var(--client-bg) 80%, transparent);
}
.client-profile-menu button {
min-height: 34px;
padding: 0 8px;
border: 0;
background: transparent;
color: var(--client-text);
font: 500 9px/1.2 inherit;
text-align: left;
cursor: pointer;
}
.client-profile-menu button:hover,
.client-profile-menu button:focus-visible {
background: color-mix(in oklch, var(--client-accent) 7%, transparent);
}
.client-profile-menu button.is-danger {
color: oklch(0.68 0.15 28);
}
.client-profile-body {
max-height: 3200px;
padding: 0 0 20px 18px;
overflow: hidden;
opacity: 1;
visibility: visible;
transition: max-height 560ms cubic-bezier(0.16, 1, 0.3, 1), opacity 260ms ease, padding 420ms ease, visibility 0s;
}
.client-profile-body[aria-hidden='true'] {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
visibility: hidden;
transition: max-height 420ms cubic-bezier(0.16, 1, 0.3, 1), opacity 180ms ease, padding 320ms ease, visibility 0s 420ms;
}
.client-profile-body .client-servers {
width: 100%;
margin: 0;
}
.client-profile-activate,
.client-profile-add-trigger {
min-height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font: 600 9px/1 inherit;
letter-spacing: 0.03em;
text-transform: uppercase;
cursor: pointer;
}
.client-profile-activate {
display: block;
margin-left: auto;
}
.client-profile-activate:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-add-trigger {
width: 100%;
margin-top: 24px;
text-align: left;
}
.client-profile-server-hint,
.client-profile-local-status,
.client-profile-form-status {
min-height: 18px;
margin: 0;
color: var(--client-muted);
font-size: 8px;
line-height: 1.45;
}
.client-profile-local-status {
padding-left: 18px;
color: oklch(0.68 0.14 72);
}
.client-profile-add,
.client-profile-rename {
display: grid;
gap: 10px;
padding: 16px 0 20px;
border-bottom: 1px solid var(--client-border);
}
.client-profile-add label {
display: grid;
gap: 6px;
color: var(--client-muted);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-profile-add input,
.client-profile-rename input {
min-width: 0;
height: 40px;
padding: 0 10px;
border: 1px solid var(--client-border);
border-radius: 0;
outline: 0;
background: transparent;
color: var(--client-text);
font: 500 10px/1 inherit;
}
.client-profile-add input:focus,
.client-profile-rename input:focus {
border: 1px solid var(--client-accent);
}
.client-profile-add input[aria-invalid='true'],
.client-profile-rename input[aria-invalid='true'] {
border: 1px solid oklch(0.68 0.15 28);
}
.client-profile-form-actions,
.client-profile-rename {
grid-template-columns: 1fr auto auto;
align-items: center;
}
.client-profile-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.client-profile-form-actions button,
.client-profile-rename button {
min-height: 34px;
padding: 0 10px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 600 9px/1 inherit;
cursor: pointer;
}
.client-profile-form-actions button.is-primary,
.client-profile-rename button[type='submit'] {
color: var(--client-accent);
}
.client-profile-form-actions button:disabled,
.client-profile-rename button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-rename {
grid-template-columns: minmax(0, 1fr) auto auto;
padding-left: 18px;
}
.client-profile-rename > span {
grid-column: 1 / -1;
min-height: 12px;
color: oklch(0.68 0.15 28);
font-size: 8px;
}
.client-subscription-first-run {
gap: 18px;
}
.client-first-run-copy {
display: grid;
gap: 6px;
text-align: center;
}
.client-first-run-copy span {
color: var(--client-muted);
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-first-run-copy strong {
font-size: 16px;
}
@media (max-width: 520px) {
.client-subscription-sheet {
padding: 24px 18px 64px;
}
.client-profile-header {
grid-template-columns: minmax(0, 1fr) 36px 36px;
}
.client-profile-usage {
display: none;
}
.client-profile-body {
padding-left: 8px;
}
.client-profile-disclosure,
.client-profile-refresh,
.client-profile-menu-toggle,
.client-profiles-header > button:not(.client-drawer-close) {
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.client-usage-summary > strong {
.client-profile-chevron,
.client-profile-body,
.client-profile-refresh {
transition: none;
animation: none;
}
}
-42
View File
@@ -558,40 +558,6 @@
row-gap: 36px;
}
.client-gateway-route-summary {
min-height: 54px;
display: grid;
justify-items: center;
gap: 2px;
margin-top: 6px;
}
.client-gateway-route-summary strong {
max-width: min(300px, 100%);
min-height: 20px;
overflow: hidden;
color: var(--client-text);
font-size: 15px;
letter-spacing: -0.035em;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-gateway-route-slot {
width: 100%;
min-height: 16px;
overflow: hidden;
color: var(--client-accent);
font-size: 9px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-panel.has-subscription .client-form {
height: var(--client-work-height);
}
.client-panel.is-gateway-home .client-state-copy {
min-height: 84px;
}
@@ -602,10 +568,6 @@
--client-power-top: calc((var(--client-work-height) - 96px) / 2);
transform: translateY(-9vh);
}
.client-panel.has-subscription .client-form-content {
padding-top: var(--client-power-top);
}
}
@media (max-width: 920px) {
@@ -715,10 +677,6 @@
bottom: 0;
}
.client-form-content {
gap: 24px;
}
.harbor-brand {
transform: translateX(-50%) scale(1.35);
}
+1 -18
View File
@@ -490,15 +490,6 @@
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-form-content {
display: grid;
align-content: start;
gap: 24px;
opacity: 1;
filter: blur(0);
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-drawer {
width: min(480px, 100vw);
}
@@ -645,13 +636,7 @@
cursor: wait;
}
.client-form.is-waiting {
opacity: 0;
filter: blur(10px);
pointer-events: none;
}
.client-shell:has(.harbor-brand.is-gateway-active) .client-form:not(.is-waiting) {
.client-shell:has(.harbor-brand.is-gateway-active) .client-form {
opacity: 0.34;
filter: grayscale(1) saturate(0);
}
@@ -660,8 +645,6 @@
to { transform: rotate(360deg); }
}
.client-subscription-edit button:focus-visible,
.client-subscription-domain-button:focus-visible,
.client-server:focus-visible,
.client-power:focus-visible {
outline: 2px solid var(--client-accent);
+1 -13
View File
@@ -21,16 +21,11 @@
.client-power::before,
.client-power::after,
.client-power svg,
.client-usage-bar i,
.client-subscription-refresh,
.client-subscription-delete,
.client-power-section,
.client-gateway-traffic-chart,
.client-gateway-traffic-freshness,
.client-subscription-drawer,
.client-form,
.client-usage,
.client-usage > strong,
.client-server,
.client-server-auto,
.client-server-health > span,
@@ -93,11 +88,6 @@
.client-devices-refresh-ring circle,
.client-devices-refresh-icon,
.client-text-morph-value,
.client-subscription-edit,
.client-subscription-edit::after,
.client-subscription-submit,
.client-subscription-summary,
.client-subscription-summary strong,
.client-proxy-label > span {
transition: none;
animation: none;
@@ -180,12 +170,10 @@
animation: none;
}
.client-form-content,
.client-confirmation-popup,
.client-confirmation-dialog,
.client-confirmation-dialog > *,
.client-confirmation-actions button,
.client-subscription-delete .client-trash-lid {
.client-confirmation-actions button {
transition: none;
animation: none;
}
+1 -4
View File
@@ -1,17 +1,14 @@
interface ConnectionActionInput {
connected: boolean;
selectedServerId: string;
configExists: boolean;
}
export function connectionAction({ connected, selectedServerId, configExists }: ConnectionActionInput):
export function connectionAction({ connected, selectedServerId }: ConnectionActionInput):
| { type: 'stop' }
| { type: 'apply'; serverId: string }
| { type: 'restart' }
| null {
if (connected) return { type: 'stop' };
if (selectedServerId) return { type: 'apply', serverId: selectedServerId };
if (configExists) return { type: 'restart' };
return null;
}