Refactor VPN proxy components and update related behavior
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1099,7 +1099,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-gateway-summary-kicker,
|
||||
.client-gateway-traffic-total > small {
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user