Remove initial server health check and bump Harbor versions
This commit is contained in:
+34
-12
@@ -43,6 +43,11 @@ import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
|
||||
const TERMINAL_SUBSCRIPTION_CODES = new Set([
|
||||
'SUBSCRIPTION_EXPIRED',
|
||||
'SUBSCRIPTION_DISABLED',
|
||||
'SUBSCRIPTION_REJECTED',
|
||||
]);
|
||||
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
|
||||
@@ -215,6 +220,16 @@ function buildActiveConfig(
|
||||
const stopSingbox = () => singboxRuntime.stop();
|
||||
const startSingbox = () => singboxRuntime.apply();
|
||||
|
||||
function resetSavedSubscription({ stopRuntime = true } = {}) {
|
||||
return serializeControl(async () => {
|
||||
if (stopRuntime) await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
subscriptionCacheStore.remove();
|
||||
updateStoredState((state) => ({ routeRules: state.routeRules }));
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
});
|
||||
}
|
||||
|
||||
async function publicState() {
|
||||
const runtime = await singboxRuntime.refresh();
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
@@ -529,10 +544,17 @@ function refreshSavedSubscription() {
|
||||
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
||||
|
||||
subscriptionRefreshPromise = (async () => {
|
||||
const subscriptionUrl = stateStore.read().subscriptionUrl;
|
||||
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
return commitSubscription(subscriptionUrl, parsed);
|
||||
try {
|
||||
const subscriptionUrl = stateStore.read().subscriptionUrl;
|
||||
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
return await commitSubscription(subscriptionUrl, parsed);
|
||||
} catch (error) {
|
||||
if (TERMINAL_SUBSCRIPTION_CODES.has(error?.code)) {
|
||||
await resetSavedSubscription();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
subscriptionRefreshPromise = null;
|
||||
});
|
||||
@@ -654,13 +676,7 @@ async function handleApi(req, res) {
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||
await withOperation('subscription-forget', () => serializeControl(async () => {
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
subscriptionCacheStore.remove();
|
||||
updateStoredState((state) => ({ routeRules: state.routeRules }));
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
}));
|
||||
await withOperation('subscription-forget', () => resetSavedSubscription());
|
||||
return sendState(res);
|
||||
}
|
||||
|
||||
@@ -755,7 +771,13 @@ process.on('SIGINT', shutdown);
|
||||
await refreshGatewayAutoMode({ reconfigure: false })
|
||||
.catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`));
|
||||
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||
writeCurrentConfig();
|
||||
try {
|
||||
writeCurrentConfig();
|
||||
} catch (error) {
|
||||
if (!String(error?.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
||||
console.warn(`[storage] сохранённая подписка отклонена: ${error.message}; возврат к первичной настройке`);
|
||||
await resetSavedSubscription({ stopRuntime: false });
|
||||
}
|
||||
}
|
||||
await startSingbox()
|
||||
.then(() => {
|
||||
|
||||
@@ -10,6 +10,23 @@ import {
|
||||
import { atomicWriteFile } from './services/stateStore.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
const UNSPECIFIED_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
|
||||
|
||||
function usableProxyOutbound(outbound) {
|
||||
const host = String(outbound?.server || '').trim().toLowerCase();
|
||||
const port = Number(outbound?.server_port);
|
||||
return Boolean(host) && !UNSPECIFIED_HOSTS.has(host) && Number.isInteger(port) && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
function rejectedSubscriptionCode(outbounds) {
|
||||
const labels = outbounds.map((outbound) => String(outbound?.tag || '').toLowerCase()).join(' ');
|
||||
if (labels.includes('expired')) return 'SUBSCRIPTION_EXPIRED';
|
||||
if (labels.includes('disabled')) return 'SUBSCRIPTION_DISABLED';
|
||||
if (/traffic|quota|bandwidth|трафик/.test(labels)) return 'SUBSCRIPTION_TRAFFIC_EXHAUSTED';
|
||||
return outbounds.some((outbound) => UNSPECIFIED_HOSTS.has(String(outbound?.server || '').trim().toLowerCase()))
|
||||
? 'SUBSCRIPTION_REJECTED'
|
||||
: 'SUBSCRIPTION_INVALID';
|
||||
}
|
||||
|
||||
export function getHwid() {
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
@@ -114,9 +131,18 @@ export function normalizeSubscriptionConfig(value) {
|
||||
const parsedConfig = value && typeof value === 'object' ? value : {};
|
||||
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
|
||||
const servers = [];
|
||||
const rejectedOutbounds = [];
|
||||
const seen = new Set();
|
||||
const normalizedOutbounds = outbounds.flatMap((outbound) => {
|
||||
if (!outbound || typeof outbound !== 'object') {
|
||||
rejectedOutbounds.push(outbound);
|
||||
return [];
|
||||
}
|
||||
if (!PROXY_TYPES.has(outbound.type)) return [outbound];
|
||||
if (!usableProxyOutbound(outbound)) {
|
||||
rejectedOutbounds.push(outbound);
|
||||
return [];
|
||||
}
|
||||
const id = createServerId(outbound);
|
||||
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
|
||||
if (seen.has(id)) return [];
|
||||
@@ -125,7 +151,7 @@ export function normalizeSubscriptionConfig(value) {
|
||||
return [{ ...outbound, tag: id }];
|
||||
});
|
||||
|
||||
if (!servers.length) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
if (!servers.length) throw new HarborError(rejectedSubscriptionCode(rejectedOutbounds));
|
||||
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
|
||||
}
|
||||
|
||||
@@ -198,6 +224,12 @@ export async function fetchSubscription(url, options) {
|
||||
|
||||
const body = await response.text();
|
||||
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||
if (userInfo.expire > 0 && userInfo.expire * 1000 <= Date.now()) {
|
||||
throw new HarborError('SUBSCRIPTION_EXPIRED');
|
||||
}
|
||||
if (userInfo.total > 0 && (userInfo.upload || 0) + (userInfo.download || 0) >= userInfo.total) {
|
||||
throw new HarborError('SUBSCRIPTION_TRAFFIC_EXHAUSTED');
|
||||
}
|
||||
const parsed = parseSubscriptionBody(body);
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,10 @@ export const ERROR_DEFINITIONS = Object.freeze({
|
||||
REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false },
|
||||
ENDPOINT_NOT_FOUND: { status: 404, message: 'Запрошенный API-метод не найден.', retryable: false },
|
||||
SUBSCRIPTION_INVALID: { status: 400, message: 'Ссылка подписки недействительна.', retryable: false },
|
||||
SUBSCRIPTION_EXPIRED: { status: 400, message: 'Срок действия подписки истёк.', retryable: false },
|
||||
SUBSCRIPTION_TRAFFIC_EXHAUSTED: { status: 400, message: 'Трафик по подписке закончился.', retryable: false },
|
||||
SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false },
|
||||
SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false },
|
||||
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true },
|
||||
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
|
||||
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.7.23',
|
||||
gatewayClient: '0.7.23',
|
||||
gatewayBackend: '0.7.1',
|
||||
macClient: '0.7.36',
|
||||
gatewayClient: '0.7.34',
|
||||
gatewayBackend: '0.7.5',
|
||||
});
|
||||
|
||||
export function parseVersion(value) {
|
||||
|
||||
@@ -50,6 +50,11 @@ export const api = {
|
||||
state: () => request('/api/state'),
|
||||
version: () => request('/api/version'),
|
||||
subscription: {
|
||||
validate: (url, { signal } = {}) => request('/api/subscription/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
signal,
|
||||
}),
|
||||
fetch: (url) => request('/api/subscription/fetch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { instructionBlocks } from '../instructions.js';
|
||||
import { operationBlocked } from '../state/operations.js';
|
||||
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
|
||||
import { ServerPicker } from './ServerPicker.jsx';
|
||||
import { ERROR_DEFINITIONS } from '../../shared/errors.js';
|
||||
import { canAppendRouteRule } from '../../shared/routingRules.js';
|
||||
import {
|
||||
HARBOR_VERSIONS,
|
||||
@@ -134,10 +135,10 @@ function InlineError({ error, context }) {
|
||||
return (
|
||||
<div className={`client-inline-error is-${context}`} role="alert">
|
||||
<span>{error.message}</span>
|
||||
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
|
||||
{error.correlationId && (
|
||||
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
|
||||
)}
|
||||
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -234,10 +235,11 @@ const localRuleValues = (rules) => rules
|
||||
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
|
||||
const localRuleKey = ({ type, value, enabled }) => `${type}:${String(value || '').trim().toLowerCase()}:${enabled}`;
|
||||
|
||||
function localRuleStatus(rule, savedRules, activeRules) {
|
||||
function localRuleStatus(rule, savedRules, activeRules, runtimeActive) {
|
||||
const key = localRuleKey(rule);
|
||||
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
|
||||
if (!rule.enabled) return ['disabled', 'Выключено'];
|
||||
if (!runtimeActive) return ['saved', 'Сохранено'];
|
||||
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
|
||||
return ['pending', 'Ждёт перезапуска'];
|
||||
}
|
||||
@@ -340,6 +342,7 @@ function LocalRulesPanel({
|
||||
rules,
|
||||
savedRules,
|
||||
activeRules,
|
||||
runtimeActive,
|
||||
blocked,
|
||||
dirty,
|
||||
restartPending,
|
||||
@@ -399,7 +402,12 @@ function LocalRulesPanel({
|
||||
<span id="local-rules-list-title">Правила</span>
|
||||
<div className="client-local-rules-list">
|
||||
{rules.map((rule, index) => {
|
||||
const [status, statusLabel] = localRuleStatus(rule, savedRules, activeRules);
|
||||
const [status, statusLabel] = localRuleStatus(
|
||||
rule,
|
||||
savedRules,
|
||||
activeRules,
|
||||
runtimeActive,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
|
||||
@@ -626,6 +634,12 @@ export function ClientOverviewPage({
|
||||
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
|
||||
const [accessTab, setAccessTab] = useState('gateway');
|
||||
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||
const [subscriptionValidation, setSubscriptionValidation] = useState({
|
||||
url: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
});
|
||||
const [subscriptionValidationAttempt, setSubscriptionValidationAttempt] = useState(0);
|
||||
const [refreshingInfo, setRefreshingInfo] = useState(false);
|
||||
const [usageUpdated, setUsageUpdated] = useState(false);
|
||||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||
@@ -670,9 +684,21 @@ export function ClientOverviewPage({
|
||||
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
|
||||
: instructionGuides;
|
||||
const normalizedSubscriptionUrl = subscriptionUrl.trim();
|
||||
const currentSubscriptionValidation = subscriptionValidation.url === normalizedSubscriptionUrl
|
||||
? subscriptionValidation
|
||||
: null;
|
||||
const localSubscriptionError = normalizedSubscriptionUrl
|
||||
&& !isSubscriptionUrlValid(normalizedSubscriptionUrl)
|
||||
? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message }
|
||||
: null;
|
||||
const subscriptionError = currentSubscriptionValidation?.error
|
||||
|| localSubscriptionError
|
||||
|| (error?.context === 'subscription' ? error : null);
|
||||
const subscriptionValidationStatus = !normalizedSubscriptionUrl
|
||||
? 'idle'
|
||||
: isSubscriptionUrlValid(normalizedSubscriptionUrl) ? 'valid' : 'invalid';
|
||||
: subscriptionError || !isSubscriptionUrlValid(normalizedSubscriptionUrl)
|
||||
? 'invalid'
|
||||
: currentSubscriptionValidation?.status || 'checking';
|
||||
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
|
||||
const connectionBlocked = operationBlocked(operations, 'connection');
|
||||
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
|
||||
@@ -682,9 +708,12 @@ export function ClientOverviewPage({
|
||||
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
||||
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
|
||||
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
|
||||
const pendingLocalRulesCount = (state?.route?.localRules || []).filter((rule) => (
|
||||
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
|
||||
)).length;
|
||||
const localRulesPendingRestart = connected && state?.route?.localRulesPendingRestart === true;
|
||||
const pendingLocalRulesCount = localRulesPendingRestart
|
||||
? (state?.route?.localRules || []).filter((rule) => (
|
||||
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
|
||||
)).length
|
||||
: 0;
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
@@ -698,6 +727,36 @@ export function ClientOverviewPage({
|
||||
if (editingSubscription) subscriptionInputRef.current?.focus();
|
||||
}, [editingSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedSubscriptionUrl || !isSubscriptionUrlValid(normalizedSubscriptionUrl)) return undefined;
|
||||
const controller = new AbortController();
|
||||
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking', error: null });
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await api.subscription.validate(normalizedSubscriptionUrl, { signal: controller.signal });
|
||||
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid', error: null });
|
||||
} catch (requestError) {
|
||||
if (requestError?.name === 'AbortError') return;
|
||||
setSubscriptionValidation({
|
||||
url: normalizedSubscriptionUrl,
|
||||
status: 'invalid',
|
||||
error: {
|
||||
context: 'subscription',
|
||||
message: requestError.message,
|
||||
correlationId: requestError.correlationId,
|
||||
retry: requestError.retryable
|
||||
? () => setSubscriptionValidationAttempt((attempt) => attempt + 1)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [normalizedSubscriptionUrl, subscriptionValidationAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showIntro) return undefined;
|
||||
const timer = setTimeout(() => setShowIntro(false), 1200);
|
||||
@@ -956,7 +1015,7 @@ export function ClientOverviewPage({
|
||||
localRulesBaselineRef.current = JSON.stringify(rules);
|
||||
setLocalRulesRevision(result.state.route.localRulesRevision);
|
||||
setConfirmingLocalRulesClose(false);
|
||||
if (!result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
|
||||
if (!connected || !result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
|
||||
}
|
||||
|
||||
function requestCloseLocalRules() {
|
||||
@@ -1030,7 +1089,7 @@ export function ClientOverviewPage({
|
||||
</button>}
|
||||
{hasSubscription && subscriptionContentReady && <button
|
||||
ref={localRulesToggleRef}
|
||||
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${state?.route?.localRulesPendingRestart ? ' has-pending' : ''}`}
|
||||
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`}
|
||||
type="button"
|
||||
disabled={gatewayDirect}
|
||||
aria-expanded={localRulesOpen}
|
||||
@@ -1047,7 +1106,7 @@ export function ClientOverviewPage({
|
||||
</svg>
|
||||
<span>{gatewayDirect
|
||||
? 'Сейчас работают правила Gateway'
|
||||
: state?.route?.localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||||
: localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||||
</button>}
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}${hasSubscription ? ' has-subscription' : ''}`}>
|
||||
{showPower && (
|
||||
@@ -1287,7 +1346,11 @@ export function ClientOverviewPage({
|
||||
placeholder="Вставьте ссылку подписки"
|
||||
className={subscriptionUrl ? 'has-value' : ''}
|
||||
value={subscriptionUrl}
|
||||
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
||||
onChange={(event) => {
|
||||
if (error?.context === 'subscription') onDismissError();
|
||||
setSubscriptionValidation({ url: '', status: 'idle', error: null });
|
||||
setSubscriptionUrl(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && state?.hasSubscription) {
|
||||
setSubscriptionUrl('');
|
||||
@@ -1304,18 +1367,21 @@ export function ClientOverviewPage({
|
||||
<button
|
||||
className="client-subscription-submit"
|
||||
type="submit"
|
||||
aria-live="polite"
|
||||
aria-label={subscriptionValidationStatus === 'valid'
|
||||
? 'Сохранить подписку'
|
||||
: 'Ссылка подписки не распознана'}
|
||||
: subscriptionValidationStatus === 'checking'
|
||||
? 'Проверяем подписку'
|
||||
: subscriptionError?.message || 'Ссылка подписки не распознана'}
|
||||
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
|
||||
>
|
||||
{subscriptionValidationStatus === 'valid'
|
||||
? '✓'
|
||||
: '×'}
|
||||
: subscriptionValidationStatus === 'checking' ? '…' : '×'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<InlineError error={error} context="subscription" />
|
||||
<InlineError error={subscriptionError || error} context="subscription" />
|
||||
<InlineProgress operations={operations} context="subscription" />
|
||||
</div>
|
||||
|
||||
@@ -1406,9 +1472,10 @@ export function ClientOverviewPage({
|
||||
rules={localRulesDraft}
|
||||
savedRules={state?.route?.localRules || []}
|
||||
activeRules={state?.route?.activeLocalRules || []}
|
||||
runtimeActive={connected}
|
||||
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
|
||||
dirty={localRulesDirty}
|
||||
restartPending={state?.route?.localRulesPendingRestart === true}
|
||||
restartPending={localRulesPendingRestart}
|
||||
error={error}
|
||||
operations={operations}
|
||||
panelRef={localRulesPanelRef}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
autoServer,
|
||||
@@ -115,7 +115,6 @@ export function ServerPicker({
|
||||
const [collapsed, setCollapsed] = useState([]);
|
||||
const [pings, setPings] = useState({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const initialCheckStarted = useRef(false);
|
||||
const serverKey = servers.map(({ id }) => id).join('|');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -189,12 +188,6 @@ export function ServerPicker({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCheckStarted.current || !servers.length) return;
|
||||
initialCheckStarted.current = true;
|
||||
checkVisible();
|
||||
}, [serverKey]);
|
||||
|
||||
if (servers.length === 1) {
|
||||
return <section className="client-servers" aria-label="Выберите сервер">
|
||||
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
|
||||
|
||||
+40
-9
@@ -1930,6 +1930,12 @@ p {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.client-duration-unit {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.client-duration-unit .client-duration-part.is-value {
|
||||
display: inline-block;
|
||||
min-width: 2ch;
|
||||
@@ -1941,13 +1947,8 @@ p {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.client-duration-unit[data-unit='days'] .is-label { width: 4ch; }
|
||||
.client-duration-unit[data-unit='hours'] .is-label { width: 5ch; }
|
||||
.client-duration-unit[data-unit='minutes'] .is-label { width: 6ch; }
|
||||
.client-duration-unit[data-unit='seconds'] .is-label { width: 7ch; }
|
||||
|
||||
.client-duration-unit + .client-duration-unit::before {
|
||||
content: ' ';
|
||||
.client-duration-unit + .client-duration-unit {
|
||||
margin-left: 0.9em;
|
||||
}
|
||||
|
||||
.client-duration-seconds-value {
|
||||
@@ -2637,7 +2638,7 @@ p {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
width: min(100%, 210px);
|
||||
width: min(100%, 150px);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@@ -3055,7 +3056,6 @@ p {
|
||||
}
|
||||
|
||||
.client-servers.is-scalable .client-server-grid {
|
||||
width: 100%;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
@@ -3274,6 +3274,36 @@ p {
|
||||
top: calc(100% + 2px);
|
||||
}
|
||||
|
||||
.client-inline-error.is-subscription > span,
|
||||
.client-inline-error.is-subscription > button {
|
||||
animation: client-subscription-error-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-inline-error.is-subscription small {
|
||||
flex-basis: 100%;
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.45;
|
||||
animation: client-subscription-code-in 560ms 90ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes client-subscription-error-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(7px);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes client-subscription-code-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.client-inline-error small {
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
@@ -3598,6 +3628,7 @@ p {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.client-inline-error.is-subscription > *,
|
||||
.client-operation-progress::before,
|
||||
.client-delete-strike,
|
||||
.client-power,
|
||||
|
||||
Reference in New Issue
Block a user