fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, была неотличима от законной, и цикл учёта не имел признака, по которому её следовало завершить. У состояния есть путь без единой неудачи — Hysteria регистрирует соединение в Traffic Stats API только после возврата backend-auth, поэтому успешный /kick может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт по старому, пережившая сессия становится orphan. Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4, админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала лимит устройств, учёт трафика и принудительное отключение разом. Адрес зафиксирован, а расхождение файла с ним админка называет. Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним значением, а доступность Traffic Stats API выводилась из него же. Журнал Hysteria разбирается в фактическом формате upstream (time — дробное число), страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в браузер, санитайзер выгрузки следует по YAML-якорям. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
This commit is contained in:
@@ -1,312 +0,0 @@
|
||||
import type {
|
||||
Hysteria2ServerConfig,
|
||||
Hysteria2ServerConfigOutbound,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Нормализация конфига Hysteria на границе API.
|
||||
*
|
||||
* Зачем этот файл существует.
|
||||
*
|
||||
* `Hysteria2ServerConfig` описывает то, что РЕАЛЬНО приходит по сети, и почти
|
||||
* все его секции необязательны — потому что необязательны они и в upstream
|
||||
* YAML. Панель при этом показывает их как обычную форму: `dataForm.tls.cert`,
|
||||
* `dataForm.acme.dns.config`, `dataForm.resolver.https.sni`.
|
||||
*
|
||||
* Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
|
||||
* Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` в двух файлах — и он
|
||||
* прав: обращение через возможно отсутствующий объект в рантайме падает.
|
||||
* Спасало только то, что форма строится merge'ем поверх полного объекта
|
||||
* значений по умолчанию, то есть инвариант «секция есть всегда» существовал,
|
||||
* но держался на порядке присваиваний внутри компонента и нигде не был
|
||||
* выражен типом.
|
||||
*
|
||||
* Два способа это закрыть неверны:
|
||||
*
|
||||
* `?.` в 141 месте шаблона — прячет вопрос «а что показывать, если секции
|
||||
* нет», не отвечая на него, и делает шаблон нечитаемым;
|
||||
*
|
||||
* `as any` — выключает ровно ту проверку, ради которой обновлялся
|
||||
* typechecker.
|
||||
*
|
||||
* Здесь выбран третий: одно преобразование на входе. Ответ приходит в
|
||||
* `Hysteria2ServerConfig` (как есть, с необязательными секциями), а форма
|
||||
* работает с `Hysteria2ServerConfigView`, где присутствие каждой секции —
|
||||
* свойство типа. Шаблону больше не нужно знать ни одного нюанса
|
||||
* необязательности upstream-схемы.
|
||||
*
|
||||
* Важно, чего этот слой НЕ делает: он не участвует в экспорте. Выгрузка
|
||||
* серверного конфига идёт на backend от исходного YAML и сохраняет поля, о
|
||||
* которых HY2XS ещё не знает (см. docs/04). View-модель — только для
|
||||
* отображения, поэтому потеря неизвестных полей здесь безвредна.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DeepRequired делает обязательными все поля на всех уровнях.
|
||||
*
|
||||
* Массивы обрабатываются отдельно: без этой ветки `T[]` попал бы в `object` и
|
||||
* маппинг прошёлся бы по свойствам самого массива.
|
||||
*/
|
||||
type DeepRequired<T> = T extends (infer U)[]
|
||||
? DeepRequired<U>[]
|
||||
: T extends object
|
||||
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
|
||||
: T;
|
||||
|
||||
/** Конфиг Hysteria в том виде, в котором его показывает панель. */
|
||||
export type Hysteria2ServerConfigView = DeepRequired<Hysteria2ServerConfig>;
|
||||
|
||||
/** Один outbound в том же виде. */
|
||||
export type Hysteria2ServerConfigOutboundView =
|
||||
DeepRequired<Hysteria2ServerConfigOutbound>;
|
||||
|
||||
/**
|
||||
* Полное значение по умолчанию: каждая секция заполнена.
|
||||
*
|
||||
* Тип здесь не декоративный. `Hysteria2ServerConfigView` требует все поля, и
|
||||
* добавление секции в `Hysteria2ServerConfig` сломает компиляцию ровно здесь —
|
||||
* то есть новое поле upstream нельзя молча не отобразить.
|
||||
*/
|
||||
export const defaultHysteria2ServerConfigView: Hysteria2ServerConfigView = {
|
||||
listen: ":443",
|
||||
tls: {
|
||||
cert: "",
|
||||
key: "",
|
||||
sniGuard: "",
|
||||
clientCA: "",
|
||||
},
|
||||
ech: {
|
||||
keyPath: "",
|
||||
},
|
||||
acme: {
|
||||
domains: [],
|
||||
email: "",
|
||||
ca: "letsencrypt",
|
||||
listenHost: "0.0.0.0",
|
||||
dir: "/var/lib/hysteria/acme",
|
||||
type: "",
|
||||
http: {
|
||||
altPort: 8888,
|
||||
},
|
||||
tls: {
|
||||
altPort: 44333,
|
||||
},
|
||||
dns: {
|
||||
name: "cloudflare",
|
||||
config: {},
|
||||
},
|
||||
disableHTTP: false,
|
||||
disableTLSALPN: false,
|
||||
altHTTPPort: 80,
|
||||
altTLSALPNPort: 443,
|
||||
},
|
||||
obfs: {
|
||||
type: "gecko",
|
||||
salamander: {
|
||||
password: "",
|
||||
},
|
||||
gecko: {
|
||||
password: "",
|
||||
minPacketSize: 512,
|
||||
maxPacketSize: 1200,
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: 8388608,
|
||||
maxStreamReceiveWindow: 8388608,
|
||||
initConnReceiveWindow: 20971520,
|
||||
maxConnReceiveWindow: 20971520,
|
||||
maxIdleTimeout: "30s",
|
||||
maxIncomingStreams: 1024,
|
||||
disablePathMTUDiscovery: false,
|
||||
disableStatelessReset: false,
|
||||
},
|
||||
bandwidth: {
|
||||
up: "50 mbps",
|
||||
down: "50 mbps",
|
||||
disableLossCompensation: false,
|
||||
},
|
||||
congestion: {
|
||||
type: "bbr",
|
||||
bbrProfile: "standard",
|
||||
},
|
||||
ignoreClientBandwidth: false,
|
||||
speedTest: false,
|
||||
disableUDP: false,
|
||||
udpIdleTimeout: "60s",
|
||||
resolver: {
|
||||
type: "",
|
||||
tcp: {
|
||||
addr: "8.8.8.8:53",
|
||||
timeout: "4s",
|
||||
},
|
||||
udp: {
|
||||
addr: "8.8.4.4:53",
|
||||
timeout: "4s",
|
||||
},
|
||||
tls: {
|
||||
addr: "1.1.1.1:853",
|
||||
timeout: "10s",
|
||||
sni: "cloudflare-dns.com",
|
||||
insecure: false,
|
||||
},
|
||||
https: {
|
||||
addr: "1.1.1.1:443",
|
||||
timeout: "10s",
|
||||
sni: "cloudflare-dns.com",
|
||||
insecure: false,
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: true,
|
||||
timeout: "2s",
|
||||
rewriteDomain: false,
|
||||
tcpPorts: "80,443,8000-9000",
|
||||
udpPorts: "all",
|
||||
},
|
||||
acl: {
|
||||
file: "",
|
||||
inline: [],
|
||||
geoip: "",
|
||||
geosite: "",
|
||||
geoUpdateInterval: "168h",
|
||||
},
|
||||
outbounds: [],
|
||||
trafficStats: {
|
||||
listen: ":9999",
|
||||
},
|
||||
masquerade: {
|
||||
type: "",
|
||||
file: {
|
||||
dir: "",
|
||||
},
|
||||
proxy: {
|
||||
url: "",
|
||||
rewriteHost: true,
|
||||
insecure: false,
|
||||
xForwarded: false,
|
||||
},
|
||||
string: {
|
||||
content: "hello stupid world",
|
||||
headers: {},
|
||||
statusCode: 200,
|
||||
},
|
||||
listenHTTP: ":80",
|
||||
listenHTTPS: ":443",
|
||||
forceHTTPS: true,
|
||||
},
|
||||
mimic: {
|
||||
enabled: false,
|
||||
interface: "",
|
||||
xdpMode: "",
|
||||
path: "",
|
||||
extraArgs: [],
|
||||
},
|
||||
realm: {
|
||||
stunServers: [],
|
||||
stunTimeout: "",
|
||||
punchTimeout: "",
|
||||
heartbeatInterval: "",
|
||||
insecure: false,
|
||||
ipMode: "",
|
||||
portMapping: {
|
||||
enabled: false,
|
||||
timeout: "",
|
||||
lifetime: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивное наложение ответа сервера на значение по умолчанию.
|
||||
*
|
||||
* `null` и `undefined` игнорируются намеренно: в YAML отсутствующая секция и
|
||||
* секция со значением `null` означают одно и то же — «не задано», — и обе
|
||||
* обязаны оставить значение по умолчанию, а не обнулить поле формы.
|
||||
*
|
||||
* Массивы заменяются целиком, а не сливаются поэлементно: список ACL-правил
|
||||
* или outbounds с сервера — это весь список, а не патч к дефолтному.
|
||||
*/
|
||||
function mergeInto(target: Record<string, unknown>, source: unknown): void {
|
||||
if (!isPlainObject(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
target[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const existing = target[key];
|
||||
if (!isPlainObject(existing)) {
|
||||
target[key] = {};
|
||||
}
|
||||
mergeInto(target[key] as Record<string, unknown>, value);
|
||||
continue;
|
||||
}
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneDefaults(): Hysteria2ServerConfigView {
|
||||
// structuredClone есть во всех целевых браузерах и, в отличие от
|
||||
// JSON.parse(JSON.stringify(...)), не тратит проход на сериализацию.
|
||||
return structuredClone(defaultHysteria2ServerConfigView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит ответ сервера к модели, с которой работает форма.
|
||||
*
|
||||
* Пустой или отсутствующий ответ даёт полное значение по умолчанию: это то же
|
||||
* состояние, в котором форма находится до первого запроса.
|
||||
*/
|
||||
export function normalizeHysteriaViewModel(
|
||||
raw: Hysteria2ServerConfig | null | undefined
|
||||
): Hysteria2ServerConfigView {
|
||||
const view = cloneDefaults();
|
||||
mergeInto(view as unknown as Record<string, unknown>, raw);
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Значение по умолчанию для одного outbound. */
|
||||
export const defaultHysteria2ServerConfigOutboundView: Hysteria2ServerConfigOutboundView =
|
||||
{
|
||||
name: "",
|
||||
type: "socks5",
|
||||
socks5: {
|
||||
addr: "",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
http: {
|
||||
url: "",
|
||||
insecure: false,
|
||||
},
|
||||
direct: {
|
||||
mode: "auto",
|
||||
bindIPv4: "",
|
||||
bindIPv6: "",
|
||||
bindDevice: "",
|
||||
fastOpen: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Тот же приём для одного outbound: список приходит с необязательными
|
||||
* подблоками, а карточка показывает их как обычные поля.
|
||||
*/
|
||||
export function normalizeOutboundViewModel(
|
||||
raw: Hysteria2ServerConfigOutbound | null | undefined
|
||||
): Hysteria2ServerConfigOutboundView {
|
||||
const view = structuredClone(defaultHysteria2ServerConfigOutboundView);
|
||||
mergeInto(view as unknown as Record<string, unknown>, raw);
|
||||
return view;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ConfigsDto,
|
||||
ConfigUpdateDto,
|
||||
ConfigVo,
|
||||
Hysteria2ServerConfig,
|
||||
Hysteria2ProfileVo,
|
||||
} from "@/api/config/types";
|
||||
|
||||
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
|
||||
// listConfigApi, на стороне сервера — по allowlist.
|
||||
|
||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
||||
// Ответ описывает production-профиль, а не всю модель серверного конфига: он
|
||||
// отличает «не задано» от значения и отдельно перечисляет секции вне профиля.
|
||||
// Раньше сюда приезжала внутренняя модель целиком — вместе с паролем
|
||||
// обфускации, токенами ACME DNS и учётными данными outbound-прокси.
|
||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ProfileVo> {
|
||||
return request({
|
||||
url: "/config/getHysteria2Config",
|
||||
method: "get",
|
||||
|
||||
@@ -20,188 +20,103 @@ export interface ConfigUpdateDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфиг Hysteria в том виде, в котором он ПРИХОДИТ ПО СЕТИ.
|
||||
* Конфигурация Hysteria в терминах production-профиля HY2XS.
|
||||
*
|
||||
* Необязательность секций здесь не оплошность: ровно так устроен upstream YAML,
|
||||
* и модель обязана его повторять, иначе она врала бы об ответе сервера.
|
||||
* Что было. Здесь жил `Hysteria2ServerConfig` — полная модель серверного
|
||||
* конфига со всеми секциями upstream (resolver, sniff, acl, outbounds,
|
||||
* masquerade, mimic, realm, ech), а рядом, в `hysteriaViewModel.ts`, — её
|
||||
* копия с обязательными полями и полный объект значений по умолчанию, поверх
|
||||
* которого накладывался ответ сервера.
|
||||
*
|
||||
* Форма панели работает не с этим типом, а с `Hysteria2ServerConfigView` из
|
||||
* `hysteriaViewModel.ts`, где все секции обязательны. Значения по умолчанию
|
||||
* живут там же: пока они лежали здесь, рядом с описанием ответа сервера, они
|
||||
* выглядели частью протокола, хотя относятся исключительно к отображению.
|
||||
* Из-за этой конструкции экран отвечал не на тот вопрос. Отсутствующая секция
|
||||
* `trafficStats` показывалась как `:9999`, отсутствующий блок ACME — как набор
|
||||
* дефолтов UI, `speedTest: false` и `disableUDP: false` считались
|
||||
* ненастроенными и прятали свои вкладки. Диагностический экран скрывал ровно
|
||||
* тот дрейф, ради которого его открывают.
|
||||
*
|
||||
* Продуктом является ОДИН профиль: конфиг генерирует оркестратор и сам же
|
||||
* проверяет соответствие файла профилю. Поэтому панель показывает значения
|
||||
* профиля так, как они записаны, и отдельно перечисляет секции вне профиля.
|
||||
* Полный документ по-прежнему доступен санитизированной выгрузкой.
|
||||
*
|
||||
* `null` означает «в файле этого нет». Это единственный способ отличить
|
||||
* отсутствие от значения: `false`, `0` и пустая строка — законные значения.
|
||||
*/
|
||||
export interface Hysteria2ServerConfig {
|
||||
listen: string;
|
||||
tls?: {
|
||||
cert: string;
|
||||
key: string;
|
||||
sniGuard?: string;
|
||||
clientCA?: string;
|
||||
};
|
||||
ech?: {
|
||||
keyPath?: string;
|
||||
};
|
||||
acme?: {
|
||||
domains: string[];
|
||||
email: string;
|
||||
ca: string;
|
||||
listenHost: string;
|
||||
dir: string;
|
||||
type?: string;
|
||||
http?: {
|
||||
altPort: number;
|
||||
};
|
||||
tls?: {
|
||||
altPort: number;
|
||||
};
|
||||
dns?: {
|
||||
name: string;
|
||||
config: { [key: string]: string };
|
||||
};
|
||||
disableHTTP: boolean;
|
||||
disableTLSALPN: boolean;
|
||||
altHTTPPort: number;
|
||||
altTLSALPNPort: number;
|
||||
};
|
||||
obfs?: {
|
||||
type: string;
|
||||
salamander?: {
|
||||
password: string;
|
||||
};
|
||||
gecko?: {
|
||||
password: string;
|
||||
minPacketSize?: number;
|
||||
maxPacketSize?: number;
|
||||
};
|
||||
};
|
||||
quic?: {
|
||||
initStreamReceiveWindow?: number;
|
||||
maxStreamReceiveWindow?: number;
|
||||
initConnReceiveWindow?: number;
|
||||
maxConnReceiveWindow?: number;
|
||||
maxIdleTimeout?: string;
|
||||
maxIncomingStreams?: number;
|
||||
disablePathMTUDiscovery?: boolean;
|
||||
disableStatelessReset?: boolean;
|
||||
};
|
||||
bandwidth?: {
|
||||
up: string;
|
||||
down: string;
|
||||
disableLossCompensation?: boolean;
|
||||
};
|
||||
congestion?: {
|
||||
type?: string;
|
||||
bbrProfile?: string;
|
||||
};
|
||||
ignoreClientBandwidth?: boolean;
|
||||
speedTest?: boolean;
|
||||
disableUDP?: boolean;
|
||||
udpIdleTimeout?: string;
|
||||
resolver?: {
|
||||
type: string;
|
||||
tcp?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
};
|
||||
udp?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
};
|
||||
tls?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
sni: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
https?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
sni: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
};
|
||||
sniff?: {
|
||||
enable: boolean;
|
||||
timeout: string;
|
||||
rewriteDomain: boolean;
|
||||
tcpPorts?: string;
|
||||
udpPorts?: string;
|
||||
};
|
||||
acl?: {
|
||||
file?: string;
|
||||
inline?: string[];
|
||||
geoip?: string;
|
||||
geosite?: string;
|
||||
geoUpdateInterval?: string;
|
||||
};
|
||||
outbounds?: Hysteria2ServerConfigOutbound[];
|
||||
trafficStats: {
|
||||
listen: string;
|
||||
};
|
||||
masquerade?: {
|
||||
type: string;
|
||||
file?: {
|
||||
dir: string;
|
||||
};
|
||||
proxy?: {
|
||||
url: string;
|
||||
rewriteHost: boolean;
|
||||
insecure: boolean;
|
||||
xForwarded?: boolean;
|
||||
};
|
||||
string?: {
|
||||
content: string;
|
||||
headers?: { [key: string]: string };
|
||||
statusCode?: number;
|
||||
};
|
||||
listenHTTP?: string;
|
||||
listenHTTPS?: string;
|
||||
forceHTTPS?: boolean;
|
||||
};
|
||||
mimic?: {
|
||||
enabled?: boolean;
|
||||
interface?: string;
|
||||
xdpMode?: string;
|
||||
path?: string;
|
||||
extraArgs?: string[];
|
||||
};
|
||||
realm?: {
|
||||
stunServers?: string[];
|
||||
stunTimeout?: string;
|
||||
punchTimeout?: string;
|
||||
heartbeatInterval?: string;
|
||||
insecure?: boolean;
|
||||
ipMode?: string;
|
||||
portMapping?: {
|
||||
enabled?: boolean;
|
||||
timeout?: string;
|
||||
lifetime?: string;
|
||||
};
|
||||
};
|
||||
export interface Hysteria2ProfileVo {
|
||||
listen: string | null;
|
||||
auth: Hysteria2ProfileAuth | null;
|
||||
tls: Hysteria2ProfileTls | null;
|
||||
acme: Hysteria2ProfileAcme | null;
|
||||
obfs: Hysteria2ProfileObfs | null;
|
||||
bandwidth: Hysteria2ProfileBandwidth | null;
|
||||
ignoreClientBandwidth: boolean | null;
|
||||
congestion: Hysteria2ProfileCongestion | null;
|
||||
quic: Hysteria2ProfileQuic | null;
|
||||
trafficStats: Hysteria2ProfileTrafficStats | null;
|
||||
/** Секции файла, которых production-профиль не описывает. */
|
||||
drift: string[];
|
||||
}
|
||||
|
||||
export interface Hysteria2ServerConfigOutbound {
|
||||
name: string;
|
||||
type: string;
|
||||
socks5?: {
|
||||
addr: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
http?: {
|
||||
url: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
direct?: {
|
||||
mode: string;
|
||||
bindIPv4?: string;
|
||||
bindIPv6?: string;
|
||||
bindDevice?: string;
|
||||
fastOpen?: boolean;
|
||||
};
|
||||
export interface Hysteria2ProfileAuth {
|
||||
type: string | null;
|
||||
/** Адрес backend-auth без machine token: он вырезан на сервере. */
|
||||
url: string | null;
|
||||
insecure: boolean | null;
|
||||
}
|
||||
|
||||
export interface Tab {
|
||||
name: string;
|
||||
desc: string;
|
||||
export interface Hysteria2ProfileTls {
|
||||
cert: string | null;
|
||||
key: string | null;
|
||||
sniGuard: string | null;
|
||||
clientCA: string | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileAcme {
|
||||
domains: string[];
|
||||
email: string | null;
|
||||
ca: string | null;
|
||||
dir: string | null;
|
||||
listenHost: string | null;
|
||||
type: string | null;
|
||||
dnsProvider: string | null;
|
||||
/** Имена параметров DNS-провайдера; значения на сервер не отдаются. */
|
||||
dnsConfigKeys: string[];
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileObfs {
|
||||
type: string | null;
|
||||
/**
|
||||
* Пароль обфускации в панель не приходит: он входит в клиентскую ссылку и
|
||||
* выдаётся там, где нужен. Диагностичен только факт, что он задан.
|
||||
*/
|
||||
passwordSet: boolean;
|
||||
minPacketSize: number | null;
|
||||
maxPacketSize: number | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileBandwidth {
|
||||
up: string | null;
|
||||
down: string | null;
|
||||
disableLossCompensation: boolean | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileCongestion {
|
||||
type: string | null;
|
||||
bbrProfile: string | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileQuic {
|
||||
initStreamReceiveWindow: number | null;
|
||||
maxStreamReceiveWindow: number | null;
|
||||
initConnReceiveWindow: number | null;
|
||||
maxConnReceiveWindow: number | null;
|
||||
maxIdleTimeout: string | null;
|
||||
maxIncomingStreams: number | null;
|
||||
disablePathMTUDiscovery: boolean | null;
|
||||
disableStatelessReset: boolean | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileTrafficStats {
|
||||
listen: string | null;
|
||||
secretSet: boolean;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,23 @@ export interface DashboardSummaryVo {
|
||||
};
|
||||
hysteria: {
|
||||
version: string;
|
||||
/**
|
||||
* Состояние службы по systemd.
|
||||
*
|
||||
* `unknown` — это НЕ «остановлена»: `systemctl is-active` может не
|
||||
* ответить вовсе, и тогда о службе не известно ничего. Пока состояние было
|
||||
* булевым, второе выдавалось за первое, и оператор шёл перезапускать
|
||||
* работающий туннель.
|
||||
*/
|
||||
serviceState: "active" | "inactive" | "unknown";
|
||||
/** Ровно `serviceState === "active"`. */
|
||||
running: boolean;
|
||||
/**
|
||||
* Ответил ли Traffic Stats API на фактическое обращение.
|
||||
*
|
||||
* Независим от `serviceState`: раньше выводился из него и мог утверждать
|
||||
* «API доступен» при «служба остановлена», ни разу не сходив в API.
|
||||
*/
|
||||
apiReachable: boolean;
|
||||
lastApiError?: string;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
KickPeerDto,
|
||||
PeerClientConfigVo,
|
||||
PeerPageDto,
|
||||
PeerPageVo,
|
||||
PeerSaveDto,
|
||||
PeerUpdateDto,
|
||||
PeerVo,
|
||||
@@ -27,7 +28,9 @@ export function savePeerApi(data: PeerSaveDto): AxiosPromise {
|
||||
});
|
||||
}
|
||||
|
||||
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
|
||||
// Ответ страницы описан собственным типом, а не общим PageVo<PeerVo>: кроме
|
||||
// записей он несёт признак того, известна ли вообще картина подключений.
|
||||
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PeerPageVo> {
|
||||
return request({
|
||||
url: "/peers",
|
||||
method: "get",
|
||||
|
||||
@@ -62,9 +62,31 @@ export type PeerVo = {
|
||||
onlineDevices: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Известна ли панели картина подключений прямо сейчас.
|
||||
*
|
||||
* Признак относится к ответу целиком, а не к строке: при `unavailable` поле
|
||||
* `online` в строках не означает ничего. Раньше отказ Traffic Stats API молча
|
||||
* превращался в «все офлайн» — ответ, уводящий оператора искать проблему у
|
||||
* пользователей.
|
||||
*/
|
||||
export type PeerOnlineState = "ok" | "unavailable";
|
||||
|
||||
export interface PeerPageVo {
|
||||
records: PeerVo[];
|
||||
total: number;
|
||||
onlineState: PeerOnlineState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиентская ссылка пира.
|
||||
*
|
||||
* Поля `qrCode` здесь больше нет: QR рисуется в панели из самой ссылки
|
||||
* (qrcode.vue), и второй его экземпляр в ответе был лишним трафиком и вторым
|
||||
* способом получить то же самое.
|
||||
*/
|
||||
export interface PeerClientConfigVo {
|
||||
url: string;
|
||||
qrCode?: string | Uint8Array;
|
||||
}
|
||||
|
||||
export interface KickPeerDto {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag v-for="item in tags" :key="item" size="large">
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<el-text v-if="!tags.length" type="info">—</el-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
|
||||
/**
|
||||
* Список строковых значений конфигурации: ACME-домены, inline-правила ACL.
|
||||
*
|
||||
* Компонент только показывает список, и это приведение UI в соответствие с тем,
|
||||
* что продукт умеет, а не сокращение возможностей.
|
||||
*
|
||||
* Что было. Здесь жил редактор на `vuedraggable`: перетаскивание тегов,
|
||||
* closable-теги с удалением, поле ввода нового значения и
|
||||
* `emit("update:tags")`. Сохранить он ничего не мог — единственное место
|
||||
* использования — форма конфига Hysteria, отрисованная с `:disabled="true"`,
|
||||
* причём значения передаются как `:tags="dataForm.acme.domains"`, без v-model,
|
||||
* то есть слушателя у события не существует. Маршрутов записи серверного
|
||||
* конфига в API нет.
|
||||
*
|
||||
* Цена этого редактора была измеримой. `vuedraggable` поставляется UMD-сборкой,
|
||||
* поэтому её `require("vue")` разрешался в `vue/dist/vue.cjs.prod.js` — полную
|
||||
* сборку Vue вместе с рантайм-компилятором шаблонов. В бандл уезжало:
|
||||
*
|
||||
* vuedraggable ~155 КБ
|
||||
* sortablejs ~115 КБ
|
||||
* @vue/compiler-core + compiler-dom ~230 КБ
|
||||
*
|
||||
* то есть около полумегабайта исходников ради перетаскивания тегов в форме,
|
||||
* которая недоступна для редактирования.
|
||||
*/
|
||||
const props = defineProps({
|
||||
tags: {
|
||||
required: false,
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const tags = computed(() => props.tags ?? []);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag v-for="entry in entries" :key="entry.key" size="large">
|
||||
{{ entry.key }}: {{ entry.value }}
|
||||
</el-tag>
|
||||
<el-text v-if="!entries.length" type="info">—</el-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "mapObject",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
|
||||
/**
|
||||
* Словарь «ключ — значение» из конфигурации: параметры ACME DNS-провайдера,
|
||||
* заголовки masquerade.
|
||||
*
|
||||
* Как и ImputMultiple, компонент только показывает содержимое. Здесь был
|
||||
* редактор с диалогом добавления, closable-тегами и `emit("update:mapObject")`,
|
||||
* и он ничего не сохранял по тем же трём причинам: форма конфига Hysteria
|
||||
* отрисована с `:disabled="true"`, значения передаются как
|
||||
* `:map-object="dataForm.acme.dns.config"` без v-model, маршрутов записи
|
||||
* серверного конфига в API нет.
|
||||
*
|
||||
* Значение показывается рядом с ключом: прежний вариант прятал его во второй
|
||||
* диалог по клику на тег, что для read-only страницы лишний шаг — увидеть
|
||||
* настройку сервера нужно сразу.
|
||||
*/
|
||||
const props = defineProps({
|
||||
mapObject: {
|
||||
required: false,
|
||||
type: Object as PropType<{ [key: string]: string }>,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const entries = computed(() =>
|
||||
Object.entries(props.mapObject ?? {}).map(([key, value]) => ({ key, value }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div style="display: flex; align-items: center">
|
||||
<el-input-number
|
||||
v-model="capacity"
|
||||
placeholder="Please enter a value"
|
||||
:min="-1"
|
||||
:controls="false"
|
||||
:precision="0"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
/>
|
||||
<el-select
|
||||
v-model="unit"
|
||||
:placeholder="$t('peer.unit')"
|
||||
style="width: 100px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in units"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
import {
|
||||
calculateBytes,
|
||||
formatStorageCapacity,
|
||||
formatStorageUnit,
|
||||
} from "@/utils/byte";
|
||||
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
|
||||
|
||||
const props = defineProps({
|
||||
valueTmp: {
|
||||
type: Number as PropType<number>,
|
||||
required: true,
|
||||
},
|
||||
setValue: {
|
||||
type: Function as PropType<(newValue: number) => void>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
capacity: 0,
|
||||
unit: "GB",
|
||||
});
|
||||
|
||||
const { capacity, unit } = toRefs(state);
|
||||
|
||||
watch(
|
||||
[capacity, unit],
|
||||
([newC, newU]) => {
|
||||
const newValue = calculateBytes(newC, newU);
|
||||
props.setValue(newValue);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.valueTmp,
|
||||
(newValue) => {
|
||||
state.capacity = formatStorageCapacity(newValue);
|
||||
state.unit = formatStorageUnit(newValue);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -44,8 +44,18 @@ export default {
|
||||
download: "Download",
|
||||
upload: "Upload",
|
||||
total: "Total",
|
||||
serviceStateLabel: "Hysteria service",
|
||||
serviceState: {
|
||||
active: "Running",
|
||||
inactive: "Stopped",
|
||||
unknown: "State unknown",
|
||||
},
|
||||
trafficApiLabel: "Traffic Stats API",
|
||||
trafficApiReachable: "Reachable",
|
||||
trafficApiUnreachable: "Unreachable",
|
||||
security: {
|
||||
hysteriaStopped: "Hysteria2 is stopped",
|
||||
hysteriaStateUnknown: "Hysteria2 service state is unknown: systemd did not answer",
|
||||
trafficApiUnavailable: "Traffic API is unavailable",
|
||||
},
|
||||
error: {
|
||||
@@ -238,8 +248,11 @@ export default {
|
||||
onlineStatus: "Online Status",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
onlineUnknown: "Online unknown",
|
||||
onlineUnavailable: "Live connection state is currently unavailable",
|
||||
onlineUnavailableHint:
|
||||
"The Hysteria Traffic Stats API did not answer, so online status and device counts are unknown. Stored peer state in the table is accurate.",
|
||||
device: "Online Devices",
|
||||
unit: "Unit",
|
||||
loginAt: "Last login time",
|
||||
conAt: "Last connection time",
|
||||
createTime: "Create Time",
|
||||
@@ -274,17 +287,9 @@ export default {
|
||||
resetTrafficMonth: "Run once a month, midnight, first of month",
|
||||
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
|
||||
},
|
||||
monitor: {
|
||||
cpuPercent: "CPU Usage",
|
||||
memPercent: "Memory Usage",
|
||||
diskPercent: "Disk Usage",
|
||||
hysteria2UserTotal: "Number of online users",
|
||||
hysteria2DeviceTotal: "Number of online devices",
|
||||
hysteria2Version: "Hysteria2 Version",
|
||||
hysteria2Running: "Hysteria2 Status",
|
||||
hysteria2RunningTrue: "Running",
|
||||
hysteria2RunningFalse: "Stop",
|
||||
},
|
||||
// The `monitor` section is gone together with its only consumer — the
|
||||
// "Hysteria2 Status" tag on the config page. The service state has three
|
||||
// values, not two, and its phrases live in `dashboard.serviceState`.
|
||||
log: {
|
||||
numLine: "Number of lines",
|
||||
level: "Level",
|
||||
@@ -305,201 +310,32 @@ export default {
|
||||
notFoundBackHome: "Back to home",
|
||||
},
|
||||
hysteria: {
|
||||
enable: "Enable",
|
||||
disable: "Disable",
|
||||
addConfigItem: "Add Config Item",
|
||||
hysteria2Version: "Hysteria2 Version",
|
||||
hysteria2Running: "Hysteria2 Status",
|
||||
outboundsEmpty: "No outbounds are configured on the server",
|
||||
listen: "Listen",
|
||||
ownedByOrchestrator: "The Hysteria configuration is owned by the installer",
|
||||
ownedByOrchestratorHint:
|
||||
"The panel shows /etc/hysteria/config.yaml read-only. Changes are applied by `hy2xs-orchestrator reconfigure`.",
|
||||
listen: "Listen address",
|
||||
auth: "Peer authentication",
|
||||
tls: "TLS",
|
||||
obfs: "Obfuscation",
|
||||
quic: "QUIC parameters",
|
||||
bandwidth: "Bandwidth",
|
||||
congestion: "Congestion control",
|
||||
speedTest: "Speed Test",
|
||||
udp: "UDP",
|
||||
resolver: "Resolver",
|
||||
sniff: "Protocol Sniffing",
|
||||
acl: "ACL",
|
||||
outbounds: "Outbounds",
|
||||
http: "Traffic Stats API (HTTP)",
|
||||
masquerade: "Masquerade",
|
||||
config: {
|
||||
listen:
|
||||
"When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.",
|
||||
tlsType: "TLS type",
|
||||
tls: {
|
||||
cert: "The path to the Cert file.",
|
||||
key: "The path to the Key file.",
|
||||
sniGuard:
|
||||
'Verify the SNI provided by the client. Accept the connection only when it matches what\'s in the certificate. Terminate the TLS handshake otherwise. Set to strict to enforce this behavior. Set to disable to disable this entirely. The default is dns-san, which enables this feature only when the certificate contains the "Subject Alternative Name" extension with a domain name in it.',
|
||||
},
|
||||
acme: {
|
||||
domains: "Domains",
|
||||
email: "Email",
|
||||
ca: "The CA to use. Can be letsencrypt or zerossl.",
|
||||
listenHost:
|
||||
"The host address (not including the port) to listen on for the ACME challenge. If omitted, the server will listen on all interfaces.",
|
||||
dir: "The directory to store the ACME account key and certificates.",
|
||||
type: "ACME challenge type. Can be http, tls, or dns.",
|
||||
http: {
|
||||
altPort:
|
||||
"Listening port for HTTP challenges. (Note: Changing to a port other than 80 requires port forwarding or HTTP reverse proxy, or the challenge will fail!)",
|
||||
},
|
||||
tls: {
|
||||
altPort:
|
||||
"Listening port for TLS-ALPN challenges. (Note: Changing to a port other than 443 requires port forwarding or TLS reverse proxy, or the challenge will fail!)",
|
||||
},
|
||||
dns: {
|
||||
name: "DNS provider. For details, refer to ACME DNS Configuration.",
|
||||
config: "ACME DNS Configuration",
|
||||
},
|
||||
disableHTTP: "Disable HTTP challenge.",
|
||||
disableTLSALPN: "Disable TLS-ALPN challenge.",
|
||||
altHTTPPort:
|
||||
"Alternate HTTP challenge port. (Note: If you want to use anything other than 80, you must set up port forward/HTTP reverse proxy from 80 to that port, otherwise ACME will not be able to issue the certificate.)",
|
||||
altTLSALPNPort:
|
||||
"Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)",
|
||||
},
|
||||
obfs: {
|
||||
type: "Obfuscation type: gecko (HY2XS production default) or salamander (compatibility fallback).",
|
||||
salamander: {
|
||||
password: "Replace with a strong password of your choice.",
|
||||
},
|
||||
gecko: {
|
||||
password: "Replace with a strong password of your choice.",
|
||||
minPacketSize:
|
||||
"Minimum QUIC handshake fragment size. HY2XS baseline: 512.",
|
||||
maxPacketSize:
|
||||
"Maximum QUIC handshake fragment size. HY2XS baseline: 1200, upstream limit: 2048.",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
|
||||
maxStreamReceiveWindow: "The maximum QUIC stream receive window size.",
|
||||
initConnReceiveWindow:
|
||||
"The initial QUIC connection receive window size.",
|
||||
maxConnReceiveWindow:
|
||||
"The maximum QUIC connection receive window size.",
|
||||
maxIdleTimeout:
|
||||
"The maximum idle timeout. How long the server will consider the client still connected without any activity.",
|
||||
maxIncomingStreams:
|
||||
"The maximum number of concurrent incoming streams.",
|
||||
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
|
||||
disableStatelessReset:
|
||||
"Disable QUIC stateless reset. Kept off in the HY2XS baseline: stateless reset lets a client with a stale connection reconnect immediately after a server restart or device sleep.",
|
||||
},
|
||||
bandwidth: {
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
disableLossCompensation:
|
||||
"Disable loss compensation. Kept off in the HY2XS baseline, so compensation stays active.",
|
||||
},
|
||||
congestion: {
|
||||
type: "Fallback congestion controller: bbr or reno. Used when Brutal bandwidth is not negotiated by both sides.",
|
||||
bbrProfile: "BBR profile: standard, conservative or aggressive.",
|
||||
},
|
||||
ignoreClientBandwidth:
|
||||
"When enabled, makes the server to disregard any bandwidth hints set by clients",
|
||||
speedTest:
|
||||
"speedTest enables the built-in speed test server. When enabled, clients can test their download and upload speeds with the server. For more information, see the Speed Test documentation.",
|
||||
disableUDP:
|
||||
"disableUDP disables UDP forwarding, only allowing TCP connections.",
|
||||
udpIdleTimeout:
|
||||
"udpIdleTimeout specifies the amount of time the server will keep a local UDP port open for each UDP session that has no activity. This is conceptually similar to the NAT UDP session timeout.",
|
||||
resolver: {
|
||||
type: "Type",
|
||||
tcp: {
|
||||
addr: "The address of the TCP resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
},
|
||||
udp: {
|
||||
addr: "The address of the UDP resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
},
|
||||
tls: {
|
||||
addr: "The address of the TLS resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
sni: "The SNI to use for the TLS resolver.",
|
||||
insecure: "Disable TLS verification for the TLS resolver.",
|
||||
},
|
||||
https: {
|
||||
addr: "The address of the HTTPS resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
sni: "The SNI to use for the TLS resolver.",
|
||||
insecure: "Disable TLS verification for the TLS resolver.",
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: "Whether to enable protocol sniffing.",
|
||||
timeout:
|
||||
"Sniffing timeout. If the protocol/domain cannot be determined within this time, the original address will be used to initiate the connection.",
|
||||
rewriteDomain:
|
||||
"Whether to rewrite requests that are already in domain name form. If enabled, requests with the target address already in domain name form will still be sniffed.",
|
||||
tcpPorts:
|
||||
"List of TCP ports. Only TCP requests on these ports will be sniffed.",
|
||||
udpPorts:
|
||||
"List of UDP ports. Only UDP requests on these ports will be sniffed.",
|
||||
},
|
||||
aclType: "ACL type",
|
||||
acl: {
|
||||
file: "The path to the ACL file.",
|
||||
inline: "The list of inline ACL rules.",
|
||||
geoip:
|
||||
"Optional. Uncomment to enable. The path to the GeoIP database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
|
||||
geosite:
|
||||
"Optional. Uncomment to enable. The path to the GeoSite database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
|
||||
geoUpdateInterval:
|
||||
"Optional. The interval at which to refresh the GeoIP/GeoSite databases. 168 hours (1 week) by default. Only applies if the GeoIP/GeoSite databases are automatically downloaded. (Check the note below for more information.)",
|
||||
},
|
||||
outbounds: {
|
||||
name: "The name of the outbound. This is used in ACL rules.",
|
||||
type: "Type",
|
||||
socks5: {
|
||||
addr: "The address of the SOCKS5 proxy.",
|
||||
username:
|
||||
"Optional. The username for the SOCKS5 proxy, if authentication is required.",
|
||||
password:
|
||||
"Optional. The password for the SOCKS5 proxy, if authentication is required.",
|
||||
},
|
||||
http: {
|
||||
url: "The URL of the HTTP/HTTPS proxy. (Can be http:// or https://)",
|
||||
insecure:
|
||||
"Optional. Whether to disable TLS verification. Applies to HTTPS proxies only.",
|
||||
},
|
||||
direct: {
|
||||
mode: "Type",
|
||||
bindIPv4: "The local IPv4 address to bind to.",
|
||||
bindIPv6: "The local IPv6 address to bind to.",
|
||||
bindDevice: "The local network interface to bind to.",
|
||||
fastOpen: "Enable TCP fast open.",
|
||||
},
|
||||
},
|
||||
trafficStats: {
|
||||
listen: "The address to listen on.",
|
||||
},
|
||||
masquerade: {
|
||||
type: "Type",
|
||||
file: {
|
||||
dir: "The directory to serve files from.",
|
||||
},
|
||||
proxy: {
|
||||
url: "The URL of the website to proxy.",
|
||||
rewriteHost:
|
||||
"Whether to rewrite the Host header to match the proxied website. This is required if the target web server uses Host to determine which site to serve.",
|
||||
insecure: "Disable TLS verification for the proxied website.",
|
||||
},
|
||||
string: {
|
||||
content: "The string to return.",
|
||||
headers: "Optional. The headers to return.",
|
||||
statusCode: "Optional. The status code to return. 200 by default.",
|
||||
},
|
||||
listenHTTP: "HTTP (TCP) listen address.",
|
||||
listenHTTPS: "HTTPS (TCP) listen address.",
|
||||
forceHTTPS:
|
||||
"Whether to force HTTPS. If enabled, all HTTP requests will be redirected to HTTPS.",
|
||||
},
|
||||
},
|
||||
trafficStats: "Traffic Stats API",
|
||||
notSet: "not set",
|
||||
sectionMissing: "The section is absent from the configuration",
|
||||
tlsMissing: "Neither tls nor acme is configured",
|
||||
valuesHidden: "values are not shown",
|
||||
secretSet: "set",
|
||||
secretMissing: "not set",
|
||||
obfsPasswordHint: "handed out in the peer share link",
|
||||
driftTitle: "Configuration drift",
|
||||
driftHint:
|
||||
"The file contains sections outside the HY2XS production profile. The orchestrator neither creates nor supports them: the configuration was most likely edited by hand.",
|
||||
trafficStatsMissing: "Traffic Stats API is not configured",
|
||||
trafficStatsMissingHint:
|
||||
"Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.",
|
||||
trafficStatsNotLoopback:
|
||||
"the address is not loopback: the panel reaches the Traffic Stats API over 127.0.0.1 only",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,8 +43,21 @@ export default {
|
||||
download: "Скачано",
|
||||
upload: "Отдано",
|
||||
total: "Всего",
|
||||
serviceStateLabel: "Служба Hysteria",
|
||||
// Три состояния, а не два: «спросить systemd не удалось» — это не
|
||||
// «служба остановлена», и действия оператора у них разные.
|
||||
serviceState: {
|
||||
active: "Работает",
|
||||
inactive: "Остановлена",
|
||||
unknown: "Состояние неизвестно",
|
||||
},
|
||||
trafficApiLabel: "Traffic Stats API",
|
||||
trafficApiReachable: "Доступен",
|
||||
trafficApiUnreachable: "Недоступен",
|
||||
security: {
|
||||
hysteriaStopped: "Hysteria2 остановлена",
|
||||
hysteriaStateUnknown:
|
||||
"Состояние службы Hysteria2 неизвестно: systemd не ответил",
|
||||
trafficApiUnavailable: "Traffic API недоступен",
|
||||
},
|
||||
error: {
|
||||
@@ -243,13 +256,16 @@ export default {
|
||||
onlineStatus: "Онлайн",
|
||||
online: "Онлайн",
|
||||
offline: "Офлайн",
|
||||
onlineUnknown: "Онлайн неизвестен",
|
||||
onlineUnavailable: "Картина подключений сейчас недоступна",
|
||||
onlineUnavailableHint:
|
||||
"Traffic Stats API Hysteria не ответил, поэтому онлайн и число устройств неизвестны. Сохранённое состояние пиров в таблице верно.",
|
||||
device: "Устройства",
|
||||
createdAt: "Создан",
|
||||
bannedUntil: "Блокировка до",
|
||||
totalTraffic: "Суммарный трафик",
|
||||
copyUri: "Копировать URI",
|
||||
more: "Ещё",
|
||||
unit: "Ед. изм.",
|
||||
loginAt: "Последний вход",
|
||||
conAt: "Последнее подключение",
|
||||
createTime: "Создано",
|
||||
@@ -278,17 +294,10 @@ export default {
|
||||
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
|
||||
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
|
||||
},
|
||||
monitor: {
|
||||
cpuPercent: "CPU",
|
||||
memPercent: "Память",
|
||||
diskPercent: "Диск",
|
||||
hysteria2UserTotal: "Пользователей онлайн",
|
||||
hysteria2DeviceTotal: "Устройств онлайн",
|
||||
hysteria2Version: "Версия Hysteria2",
|
||||
hysteria2Running: "Статус Hysteria2",
|
||||
hysteria2RunningTrue: "Работает",
|
||||
hysteria2RunningFalse: "Остановлена",
|
||||
},
|
||||
// Раздел `monitor` удалён вместе со своим единственным потребителем —
|
||||
// плашкой «Статус Hysteria2» на странице конфигурации. Состояние службы
|
||||
// имеет три значения, а не два, и его фразы живут в `dashboard.serviceState`:
|
||||
// «остановлена» и «неизвестно» — разные ответы оператору.
|
||||
log: {
|
||||
numLine: "Количество строк",
|
||||
level: "Уровень",
|
||||
@@ -309,157 +318,35 @@ export default {
|
||||
notFoundBackHome: "На главную",
|
||||
},
|
||||
hysteria: {
|
||||
enable: "Включить",
|
||||
disable: "Отключить",
|
||||
addConfigItem: "Добавить параметр",
|
||||
hysteria2Version: "Версия Hysteria2",
|
||||
hysteria2Running: "Статус Hysteria2",
|
||||
// addOutbound удалён вместе с редактором outbounds: страница read-only,
|
||||
// маршрутов записи серверного конфига в API нет.
|
||||
outboundsEmpty: "Outbounds в конфигурации сервера не заданы",
|
||||
ownedByOrchestrator: "Конфигурацией Hysteria владеет установщик",
|
||||
ownedByOrchestratorHint:
|
||||
"Панель показывает файл /etc/hysteria/config.yaml только на чтение. Изменения вносит `hy2xs-orchestrator reconfigure`.",
|
||||
// Разделы production-профиля. Экран показывает то, что записано в файле,
|
||||
// и отдельно называет секции сверх профиля: универсального редактора всех
|
||||
// возможностей Hysteria здесь нет намеренно — конфиг создаёт оркестратор.
|
||||
listen: "Адрес прослушивания",
|
||||
auth: "Авторизация пиров",
|
||||
tls: "TLS",
|
||||
obfs: "Маскировка",
|
||||
quic: "Параметры QUIC",
|
||||
bandwidth: "Полоса",
|
||||
congestion: "Congestion control",
|
||||
speedTest: "Тест скорости",
|
||||
udp: "UDP",
|
||||
resolver: "DNS",
|
||||
sniff: "Sniffing протоколов",
|
||||
acl: "ACL",
|
||||
outbounds: "Outbounds",
|
||||
http: "Traffic Stats API (HTTP)",
|
||||
masquerade: "Masquerade",
|
||||
config: {
|
||||
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
|
||||
tlsType: "Тип TLS",
|
||||
tls: {
|
||||
cert: "Путь к cert-файлу",
|
||||
key: "Путь к key-файлу",
|
||||
sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.",
|
||||
},
|
||||
acme: {
|
||||
domains: "Домены",
|
||||
email: "Email",
|
||||
ca: "CA: letsencrypt или zerossl",
|
||||
listenHost: "Адрес для ACME challenge",
|
||||
dir: "Каталог ACME аккаунта и сертификатов",
|
||||
type: "Тип ACME challenge: http, tls или dns",
|
||||
http: { altPort: "Альтернативный порт HTTP challenge" },
|
||||
tls: { altPort: "Альтернативный порт TLS-ALPN challenge" },
|
||||
dns: { name: "DNS-провайдер", config: "Конфигурация ACME DNS" },
|
||||
disableHTTP: "Отключить HTTP challenge",
|
||||
disableTLSALPN: "Отключить TLS-ALPN challenge",
|
||||
altHTTPPort: "Альтернативный HTTP-порт",
|
||||
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
|
||||
},
|
||||
obfs: {
|
||||
type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)",
|
||||
salamander: { password: "Сильный пароль Salamander" },
|
||||
gecko: {
|
||||
password: "Сильный пароль Gecko",
|
||||
minPacketSize:
|
||||
"Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512",
|
||||
maxPacketSize:
|
||||
"Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
|
||||
maxStreamReceiveWindow: "Максимальное окно приёма QUIC stream",
|
||||
initConnReceiveWindow: "Начальное окно приёма QUIC connection",
|
||||
maxConnReceiveWindow: "Максимальное окно приёма QUIC connection",
|
||||
maxIdleTimeout: "Максимальный idle timeout",
|
||||
maxIncomingStreams: "Максимум входящих stream",
|
||||
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
|
||||
disableStatelessReset:
|
||||
"Отключить QUIC stateless reset. В baseline HY2XS выключено: stateless reset ускоряет переподключение клиента после перезапуска сервера или сна устройства",
|
||||
},
|
||||
bandwidth: {
|
||||
up: "Вверх",
|
||||
down: "Вниз",
|
||||
disableLossCompensation:
|
||||
"Отключить компенсацию потерь. В baseline HY2XS выключено, то есть компенсация работает",
|
||||
},
|
||||
congestion: {
|
||||
type: "Fallback congestion controller: bbr или reno. Применяется, когда Brutal bandwidth не согласован сторонами",
|
||||
bbrProfile: "Профиль BBR: standard, conservative или aggressive",
|
||||
},
|
||||
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
|
||||
speedTest: "Встроенный сервер теста скорости",
|
||||
disableUDP: "Отключить UDP forwarding",
|
||||
udpIdleTimeout: "Idle timeout для UDP-сессий",
|
||||
resolver: {
|
||||
type: "Тип",
|
||||
tcp: { addr: "TCP DNS resolver", timeout: "Timeout DNS-запросов" },
|
||||
udp: { addr: "UDP DNS resolver", timeout: "Timeout DNS-запросов" },
|
||||
tls: {
|
||||
addr: "DNS over TLS resolver",
|
||||
timeout: "Timeout DNS-запросов",
|
||||
sni: "SNI для TLS resolver",
|
||||
insecure: "Отключить TLS-проверку",
|
||||
},
|
||||
https: {
|
||||
addr: "DNS over HTTPS resolver",
|
||||
timeout: "Timeout DNS-запросов",
|
||||
sni: "SNI для HTTPS resolver",
|
||||
insecure: "Отключить TLS-проверку",
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: "Включить sniffing",
|
||||
timeout: "Timeout sniffing",
|
||||
rewriteDomain: "Повторно анализировать доменные запросы",
|
||||
tcpPorts: "TCP-порты для sniffing",
|
||||
udpPorts: "UDP-порты для sniffing",
|
||||
},
|
||||
aclType: "Тип ACL",
|
||||
acl: {
|
||||
file: "Путь к ACL-файлу",
|
||||
inline: "Inline ACL-правила",
|
||||
geoip: "Путь к GeoIP базе",
|
||||
geosite: "Путь к GeoSite базе",
|
||||
geoUpdateInterval: "Интервал обновления GeoIP/GeoSite",
|
||||
},
|
||||
outbounds: {
|
||||
name: "Имя outbound",
|
||||
type: "Тип",
|
||||
socks5: {
|
||||
addr: "Адрес SOCKS5",
|
||||
username: "Логин SOCKS5",
|
||||
password: "Пароль SOCKS5",
|
||||
},
|
||||
http: {
|
||||
url: "URL HTTP/HTTPS proxy",
|
||||
insecure: "Отключить TLS-проверку proxy",
|
||||
},
|
||||
direct: {
|
||||
mode: "Тип",
|
||||
bindIPv4: "Локальный IPv4",
|
||||
bindIPv6: "Локальный IPv6",
|
||||
bindDevice: "Сетевой интерфейс",
|
||||
fastOpen: "TCP fast open",
|
||||
},
|
||||
},
|
||||
trafficStats: { listen: "Адрес прослушивания" },
|
||||
masquerade: {
|
||||
type: "Тип",
|
||||
file: { dir: "Каталог файлов" },
|
||||
proxy: {
|
||||
url: "URL проксируемого сайта",
|
||||
rewriteHost: "Переписывать Host header",
|
||||
insecure: "Отключить TLS-проверку",
|
||||
},
|
||||
string: {
|
||||
content: "Ответ строкой",
|
||||
headers: "HTTP headers",
|
||||
statusCode: "HTTP status code",
|
||||
},
|
||||
listenHTTP: "HTTP listen address",
|
||||
listenHTTPS: "HTTPS listen address",
|
||||
forceHTTPS: "Принудительно использовать HTTPS",
|
||||
},
|
||||
},
|
||||
trafficStats: "Traffic Stats API",
|
||||
notSet: "не задано",
|
||||
sectionMissing: "Секция в конфигурации отсутствует",
|
||||
tlsMissing: "Ни tls, ни acme в конфигурации не заданы",
|
||||
valuesHidden: "значения не показываются",
|
||||
secretSet: "задан",
|
||||
secretMissing: "не задан",
|
||||
obfsPasswordHint: "выдаётся в клиентской ссылке пира",
|
||||
driftTitle: "Расхождение конфигурации",
|
||||
driftHint:
|
||||
"В файле есть секции вне production-профиля HY2XS. Оркестратор их не создаёт и не поддерживает: скорее всего, конфигурацию правили вручную.",
|
||||
trafficStatsMissing: "Traffic Stats API не настроен",
|
||||
trafficStatsMissingHint:
|
||||
"Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
|
||||
trafficStatsNotLoopback:
|
||||
"адрес не loopback: панель обращается к Traffic Stats API только по 127.0.0.1",
|
||||
},
|
||||
};
|
||||
|
||||
+3
-6
@@ -17,6 +17,8 @@ declare module 'vue' {
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
@@ -37,14 +39,12 @@ declare module 'vue' {
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
|
||||
@@ -53,15 +53,12 @@ declare module 'vue' {
|
||||
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
|
||||
IEpRefresh: typeof import('~icons/ep/refresh')['default']
|
||||
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
|
||||
ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
|
||||
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
|
||||
LogViewer: typeof import('./../components/LogViewer/index.vue')['default']
|
||||
MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
|
||||
Pagination: typeof import('./../components/Pagination/index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
|
||||
UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
|
||||
@@ -23,73 +23,9 @@ export const formatBytes = (bytes: number, decimals = 2): string => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
export const calculateBytes = (value = 0, unit = "Bytes"): number => {
|
||||
// Приведение единицы к верхнему регистру и удаление пробелов
|
||||
const formattedUnit = unit.toUpperCase().trim();
|
||||
|
||||
// Маппинг единиц хранения на количество байт
|
||||
const unitToBytes: Record<string, number> = {
|
||||
BYTES: 1,
|
||||
KB: 1024 ** 1,
|
||||
MB: 1024 ** 2,
|
||||
GB: 1024 ** 3,
|
||||
TB: 1024 ** 4,
|
||||
PB: 1024 ** 5,
|
||||
EB: 1024 ** 6,
|
||||
ZB: 1024 ** 7,
|
||||
YB: 1024 ** 8,
|
||||
};
|
||||
|
||||
// Проверка наличия единицы в маппинге
|
||||
if (!Object.prototype.hasOwnProperty.call(unitToBytes, formattedUnit)) {
|
||||
throw new Error("Invalid unit");
|
||||
}
|
||||
|
||||
if (value == -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Расчёт и возврат количества байт
|
||||
return value * unitToBytes[formattedUnit];
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование единицы хранения
|
||||
* @param bytes Объём хранения в байтах
|
||||
* @param decimals Количество знаков после запятой, по умолчанию 2
|
||||
* @returns Отформатированное значение объёма хранения
|
||||
*/
|
||||
export const formatStorageCapacity = (bytes: number, decimals = 2): number => {
|
||||
// Проверка корректности входных данных
|
||||
if (!bytes || bytes <= 0) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Расчёт единицы хранения
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
// Форматирование и возврат объёма хранения
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование единицы хранения
|
||||
* @param bytes Объём хранения в байтах
|
||||
* @returns Отформатированная единица хранения
|
||||
*/
|
||||
export const formatStorageUnit = (bytes: number): string => {
|
||||
// Проверка корректности входных данных
|
||||
if (!bytes || bytes <= 0) {
|
||||
return "Bytes";
|
||||
}
|
||||
|
||||
// Расчёт единицы хранения
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
// Возврат отформатированной единицы хранения
|
||||
return sizes[i];
|
||||
};
|
||||
// calculateBytes, formatStorageCapacity и formatStorageUnit удалены вместе с
|
||||
// компонентом UnitSelect, который был их единственным потребителем.
|
||||
//
|
||||
// Сам UnitSelect не использовался ни одной страницей панели: квота
|
||||
// вводится обычным полем в байтах, а показывается через formatBytes. Компонент
|
||||
// остался от поколения, в котором форма пира предлагала выбор единиц.
|
||||
|
||||
@@ -36,6 +36,37 @@
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<!--
|
||||
Два независимых факта показываются отдельно и называются своими именами.
|
||||
Раньше их было полтора: доступность Traffic Stats API выводилась из
|
||||
ответа systemctl, поэтому дашборд умел утверждать «служба остановлена» и
|
||||
«API доступен» одновременно.
|
||||
-->
|
||||
<el-card shadow="never" class="mt-2">
|
||||
<el-descriptions :column="3" size="small" border>
|
||||
<el-descriptions-item :label="$t('hysteria.hysteria2Version')">
|
||||
{{ summary.hysteria.version || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('dashboard.serviceStateLabel')">
|
||||
<el-tag size="small" :type="serviceStateTagType">
|
||||
{{ $t(`dashboard.serviceState.${summary.hysteria.serviceState}`) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('dashboard.trafficApiLabel')">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="summary.hysteria.apiReachable ? 'success' : 'danger'"
|
||||
>
|
||||
{{
|
||||
summary.hysteria.apiReachable
|
||||
? $t("dashboard.trafficApiReachable")
|
||||
: $t("dashboard.trafficApiUnreachable")
|
||||
}}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="10" class="mt-2">
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
><el-card shadow="never"
|
||||
@@ -58,16 +89,24 @@
|
||||
>{{ $t("dashboard.peers") }}: {{ summary.peers.total }}</el-card
|
||||
></el-col
|
||||
>
|
||||
<!--
|
||||
Пока Traffic Stats API недоступен, «0 онлайн» — не данные, а
|
||||
утверждение, которого никто не проверял. Раньше именно так и было:
|
||||
сборщик метрик получал пустую картину подключений из ответа systemctl и
|
||||
показывал ноль рядом с плашкой «служба остановлена».
|
||||
-->
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
><el-card shadow="never"
|
||||
>{{ $t("dashboard.onlinePeers") }}:
|
||||
{{ summary.peers.onlinePeers }}</el-card
|
||||
{{ summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?" }}</el-card
|
||||
></el-col
|
||||
>
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
><el-card shadow="never"
|
||||
>{{ $t("dashboard.onlineDevices") }}:
|
||||
{{ summary.peers.onlineDevices }}</el-card
|
||||
{{
|
||||
summary.hysteria.apiReachable ? summary.peers.onlineDevices : "?"
|
||||
}}</el-card
|
||||
></el-col
|
||||
>
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
@@ -183,7 +222,14 @@ const summary = ref<DashboardSummaryVo>({
|
||||
diskTotalBytes: 0,
|
||||
diskPercent: 0,
|
||||
},
|
||||
hysteria: { version: "-", running: false, apiReachable: false },
|
||||
// До первого ответа сервера о службе не известно ничего: `inactive` здесь
|
||||
// было бы утверждением, которого никто не проверял.
|
||||
hysteria: {
|
||||
version: "-",
|
||||
serviceState: "unknown",
|
||||
running: false,
|
||||
apiReachable: false,
|
||||
},
|
||||
peers: {
|
||||
total: 0,
|
||||
enabled: 0,
|
||||
@@ -231,6 +277,23 @@ const isStale = computed(() => {
|
||||
return Date.now() - lastSuccessAt.value > staleThresholdMs;
|
||||
});
|
||||
|
||||
/**
|
||||
* Цвет плашки состояния службы.
|
||||
*
|
||||
* «Неизвестно» — предупреждение, а не ошибка: это отказ ДИАГНОСТИКИ, и он не
|
||||
* означает, что туннель не работает.
|
||||
*/
|
||||
const serviceStateTagType = computed(() => {
|
||||
switch (summary.value.hysteria.serviceState) {
|
||||
case "active":
|
||||
return "success";
|
||||
case "inactive":
|
||||
return "danger";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
});
|
||||
|
||||
const loadDashboard = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag
|
||||
v-for="item in outbounds"
|
||||
:key="item.name"
|
||||
@click="handleInfo(item)"
|
||||
size="large"
|
||||
>
|
||||
{{ item.name }}
|
||||
</el-tag>
|
||||
|
||||
<el-text v-if="!outbounds.length" type="info">
|
||||
{{ $t("hysteria.outboundsEmpty") }}
|
||||
</el-text>
|
||||
|
||||
<el-dialog
|
||||
:title="outboundInfoDialog.title"
|
||||
v-model="outboundInfoDialog.visible"
|
||||
width="600px"
|
||||
append-to-body
|
||||
@close="outboundInfoDialog.visible = false"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.name')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="name" prop="name">
|
||||
<el-tag>{{ outboundInfo.name }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.type')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="type" prop="type">
|
||||
<el-tag>{{ outboundInfo.type }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<template v-if="outboundInfo.type === 'socks5'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.addr')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.addr" prop="socks5.addr">
|
||||
<el-tag>{{ outboundInfo.socks5.addr }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.username')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.username" prop="socks5.username">
|
||||
<el-tag>{{ outboundInfo.socks5.username }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.password')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.password" prop="socks5.password">
|
||||
<el-tag>{{ outboundInfo.socks5.password }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="outboundInfo.type === 'http'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.http.url')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="http.url" prop="http.url">
|
||||
<el-tag>{{ outboundInfo.http.url }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.http.insecure')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="http.insecure" prop="http.insecure">
|
||||
<el-tag>{{ outboundInfo.http.insecure }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="outboundInfo.type === 'direct'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.mode')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.mode" prop="direct.mode">
|
||||
<el-tag>{{ outboundInfo.direct.mode }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.bindIPv4')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.bindIPv4" prop="direct.bindIPv4">
|
||||
<el-tag>{{ outboundInfo.direct.bindIPv4 }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.bindIPv6')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.bindIPv6" prop="direct.bindIPv6">
|
||||
<el-tag>{{ outboundInfo.direct.bindIPv6 }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.bindDevice')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.bindDevice" prop="direct.bindDevice">
|
||||
<el-tag>{{ outboundInfo.direct.bindDevice }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.fastOpen')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.fastOpen" prop="direct.fastOpen">
|
||||
<el-tag>{{ outboundInfo.direct.fastOpen }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="outboundInfoDialog.visible = false"
|
||||
>{{ $t("common.close") }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "outbounds",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Hysteria2ServerConfigOutbound } from "@/api/config/types";
|
||||
import {
|
||||
defaultHysteria2ServerConfigOutboundView,
|
||||
normalizeOutboundViewModel,
|
||||
type Hysteria2ServerConfigOutboundView,
|
||||
} from "@/api/config/hysteriaViewModel";
|
||||
import { PropType } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Компонент только показывает outbounds, и это не сужение возможностей, а
|
||||
// приведение UI в соответствие с тем, что продукт умеет.
|
||||
//
|
||||
// Что было. Здесь жил полноценный редактор: кнопка «+», диалог создания,
|
||||
// submitForm с валидацией, closable-теги с удалением и `emit("update:outbounds")`.
|
||||
// Ни одно из этих действий не могло ничего сохранить:
|
||||
//
|
||||
// родительская форма отрисована с `:disabled="true"` — страница read-only и
|
||||
// прямо сообщает, что конфигом Hysteria владеет `hy2xs-orchestrator reconfigure`;
|
||||
//
|
||||
// родитель передаёт `:outbounds="dataForm.outbounds"` БЕЗ v-model, то есть
|
||||
// слушателя у `update:outbounds` не существует;
|
||||
//
|
||||
// маршрутов записи серверного конфига в API нет — они удалены вместе с
|
||||
// мёртвым updater/config-write слоем.
|
||||
//
|
||||
// То есть оператор мог добавить outbound, увидеть его в списке и уйти в
|
||||
// уверенности, что изменил конфигурацию сервера. Изменения не переживали даже
|
||||
// перехода на другую вкладку.
|
||||
const props = defineProps({
|
||||
outbounds: {
|
||||
required: true,
|
||||
type: Array as PropType<Array<Hysteria2ServerConfigOutbound>>,
|
||||
default: (): Array<Hysteria2ServerConfigOutbound> => [],
|
||||
},
|
||||
});
|
||||
|
||||
const outbounds = computed(() => props.outbounds ?? []);
|
||||
|
||||
const state = reactive({
|
||||
outboundInfoDialog: {
|
||||
title: t("hysteria.outbounds"),
|
||||
visible: false,
|
||||
},
|
||||
// Карточка работает с нормализованной моделью по той же причине, что и
|
||||
// основная форма: подблоки socks5/http/direct в схеме необязательны, а
|
||||
// шаблон обращается к их полям напрямую.
|
||||
outboundInfo: {
|
||||
...defaultHysteria2ServerConfigOutboundView,
|
||||
} as Hysteria2ServerConfigOutboundView,
|
||||
});
|
||||
|
||||
const { outboundInfoDialog, outboundInfo } = toRefs(state);
|
||||
|
||||
const handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
|
||||
state.outboundInfo = normalizeOutboundViewModel(outbound);
|
||||
state.outboundInfoDialog.visible = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,20 @@
|
||||
{{ $t("peer.exportBackup") }}
|
||||
</el-button>
|
||||
</div>
|
||||
<!--
|
||||
Недоступность Traffic Stats API называется прямо, а не изображается
|
||||
строками «офлайн». Это разные ответы: первый указывает на control
|
||||
plane, второй отправляет оператора искать проблему у пользователей.
|
||||
-->
|
||||
<el-alert
|
||||
v-if="onlineUnknown"
|
||||
:title="$t('peer.onlineUnavailable')"
|
||||
:description="$t('peer.onlineUnavailableHint')"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
/>
|
||||
<el-table v-loading="loading" :data="records">
|
||||
<el-table-column :label="$t('peer.name')" min-width="220">
|
||||
<template #default="scope">
|
||||
@@ -73,6 +87,14 @@
|
||||
}}</el-tag
|
||||
>
|
||||
<el-tag
|
||||
v-if="onlineUnknown"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
type="warning"
|
||||
>{{ $t("peer.onlineUnknown") }}</el-tag
|
||||
>
|
||||
<el-tag
|
||||
v-else
|
||||
size="small"
|
||||
class="ml-1"
|
||||
:type="scope.row.online ? 'success' : 'info'"
|
||||
@@ -102,8 +124,12 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('peer.devices')" min-width="120">
|
||||
<!--
|
||||
Число подключённых устройств известно только из Traffic Stats API.
|
||||
Пока он молчит, «0» было бы утверждением, а не данными.
|
||||
-->
|
||||
<template #default="scope"
|
||||
>{{ scope.row.onlineDevices }} /
|
||||
>{{ onlineUnknown ? "?" : scope.row.onlineDevices }} /
|
||||
{{ scope.row.maxDevices }}</template
|
||||
>
|
||||
</el-table-column>
|
||||
@@ -338,7 +364,8 @@
|
||||
{{ formatBytes(overview.data.uploadBytes) }}
|
||||
</p>
|
||||
<p>
|
||||
<b>{{ $t("peer.devices") }}:</b> {{ overview.data.onlineDevices }} /
|
||||
<b>{{ $t("peer.devices") }}:</b>
|
||||
{{ onlineUnknown ? "?" : overview.data.onlineDevices }} /
|
||||
{{ overview.data.maxDevices }}
|
||||
</p>
|
||||
<p>
|
||||
@@ -408,6 +435,7 @@ import {
|
||||
exportPeerApi,
|
||||
} from "@/api/peer";
|
||||
import {
|
||||
PeerOnlineState,
|
||||
PeerPageDto,
|
||||
PeerSaveDto,
|
||||
PeerUpdateDto,
|
||||
@@ -469,6 +497,14 @@ const { t } = useI18n();
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
const records = ref<PeerVo[]>([]);
|
||||
/**
|
||||
* Известна ли картина подключений.
|
||||
*
|
||||
* До первого ответа считается известной: пустая таблица и без того ничего не
|
||||
* утверждает, а предупреждение на пустом экране только сбивало бы.
|
||||
*/
|
||||
const onlineState = ref<PeerOnlineState>("ok");
|
||||
const onlineUnknown = computed(() => onlineState.value === "unavailable");
|
||||
const qrDialog = ref(false);
|
||||
const qrUrl = ref("");
|
||||
const importFileList = ref<UploadFile[]>([]);
|
||||
@@ -649,6 +685,10 @@ async function handleQuery() {
|
||||
const { data } = await pagePeerApi(queryParams);
|
||||
records.value = data.records;
|
||||
total.value = data.total;
|
||||
// Признак приходит один на страницу: недоступность Traffic Stats API —
|
||||
// свойство ответа целиком. При `unavailable` поле `online` в строках не
|
||||
// означает ничего, и показывать его как «офлайн» нельзя.
|
||||
onlineState.value = data.onlineState;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user