Introduce stable server IDs for subscription state
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-12 11:59:22 +03:00
parent 005c7a101b
commit 267afc5c7e
16 changed files with 323 additions and 145 deletions

View File

@@ -13,7 +13,7 @@ import { createOperationRegistry } from './state/operations.js';
function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, pendingServerId: pendingTag, transport }, dispatch] = useReducer(
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
harborReducer,
initialHarborState,
);
@@ -27,7 +27,7 @@ function App() {
operationRegistry.current = createOperationRegistry(setOperations);
}
function setPendingTag(serverId) {
function setPendingServerId(serverId) {
dispatch({ type: 'select-server', serverId });
}
@@ -144,19 +144,32 @@ function App() {
<div className="app-body client-mode">
<main className="app-main">
<ClientOverviewPage
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
state={previewReady ? {
...state,
mode: 'client',
hasSubscription: true,
subscriptionHost: 'harbor.example',
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
proxyPort: 8082,
} : state}
versionInfo={versionInfo}
operations={operations}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : state.servers || []}
pendingTag={previewReady ? 'Amsterdam' : pendingTag}
setPendingTag={setPendingTag}
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={(tag) => run('serverApply', () => api.apply(tag), 'connection')}
onApply={(serverId) => run('serverApply', () => api.apply(serverId), 'connection')}
onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}

View File

@@ -57,9 +57,10 @@ export const api = {
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (selectedTag) => request('/api/apply', {
apply: (serverId) => request('/api/apply', {
method: 'POST',
body: JSON.stringify({ selectedTag }),
// selectedTag keeps this client compatible with pre-ID Harbor backends.
body: JSON.stringify({ serverId, selectedTag: serverId }),
}),
gatewayAuto: {
setEnabled: (enabled) => request('/api/gateway-auto', {

View File

@@ -566,8 +566,8 @@ export function ClientOverviewPage({
subscriptionUrl,
setSubscriptionUrl,
servers,
pendingTag,
setPendingTag,
pendingServerId,
setPendingServerId,
onFetchSubscription,
onRefreshSubscription,
onForgetSubscription,
@@ -583,9 +583,9 @@ export function ClientOverviewPage({
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available);
const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedTag = pendingTag || state?.selectedTag || '';
const showPower = hasSubscription && Boolean(selectedTag);
const canStart = Boolean(selectedTag || state?.configExists);
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
const showPower = hasSubscription && Boolean(selectedServerId);
const canStart = Boolean(selectedServerId || state?.configExists);
const [now, setNow] = useState(Date.now());
const [durationMode, setDurationMode] = useState(() => {
try {
@@ -620,7 +620,7 @@ export function ClientOverviewPage({
const localRulesToggleRef = useRef(null);
const localRulesBaselineRef = useRef('[]');
const previousHasSubscriptionRef = useRef(hasSubscription);
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
const serverKey = servers.map((server) => server.id).join('|');
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
@@ -674,18 +674,18 @@ export function ClientOverviewPage({
}
let cancelled = false;
setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }])));
setPings(Object.fromEntries(servers.map((server) => [server.id, { checking: true }])));
api.servers.pingAll()
.then((data) => {
if (cancelled) return;
setPings(Object.fromEntries((data.results || []).map((ping) => [
String(ping.tag || '').trim(),
ping.id || ping.tag,
ping,
])));
})
.catch(() => {
if (!cancelled) {
setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }])));
setPings(Object.fromEntries(servers.map((server) => [server.id, { ok: false }])));
}
});
@@ -822,15 +822,15 @@ export function ClientOverviewPage({
}, [instructionsOpen]);
async function toggleConnection() {
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
const action = connectionAction({ connected, selectedServerId, configExists: state?.configExists });
if (action?.type === 'stop') return onStop();
if (action?.type === 'apply') return onApply(action.selectedTag);
if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart();
}
function selectServer(tag) {
setPendingTag(tag);
if (connected && tag) onApply(tag);
function selectServer(serverId) {
setPendingServerId(serverId);
if (connected && serverId) onApply(serverId);
}
async function submitSubscription(event) {
@@ -1314,8 +1314,8 @@ export function ClientOverviewPage({
key={`${serverKey}:${serverRevealVersion}`}
>
{servers.map((server, index) => {
const ping = pings[server.tag];
const selected = server.tag === selectedTag;
const ping = pings[server.id];
const selected = server.id === selectedServerId;
const pingText = ping?.checking
? 'Проверка…'
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
@@ -1327,13 +1327,14 @@ export function ClientOverviewPage({
<button
className={`client-server ${selected ? 'is-selected' : ''}`}
type="button"
key={server.tag}
key={server.id}
disabled={serverApplyBlocked}
aria-pressed={selected}
aria-label={`${server.label}, ${server.host}:${server.port}, ${pingText}`}
style={{ '--server-index': index }}
onClick={() => selectServer(server.tag)}
onClick={() => selectServer(server.id)}
>
<strong>{server.tag}</strong>
<strong>{server.label}</strong>
<small className={pingClass}>{pingText}</small>
</button>
);

View File

@@ -1,6 +1,6 @@
export function connectionAction({ connected, selectedTag, configExists }) {
export function connectionAction({ connected, selectedServerId, configExists }) {
if (connected) return { type: 'stop' };
if (selectedTag) return { type: 'apply', selectedTag };
if (selectedServerId) return { type: 'apply', serverId: selectedServerId };
if (configExists) return { type: 'restart' };
return null;
}