fix(admin): достроить вторые половины отзыва доступа, лимита и журнала

Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
This commit is contained in:
2026-09-01 17:17:17 +05:00
parent c0a43ae915
commit 162759c599
37 changed files with 3072 additions and 343 deletions
+11 -3
View File
@@ -57,9 +57,17 @@ export default tseslint.config(
...globals.browser,
...globals.node,
...autoImportGlobals,
// Объявлены в src/types/global.d.ts и используются как глобальные типы.
DialogType: "readonly",
OptionType: "readonly",
// Объявлены в src/types/global.d.ts и используются как глобальные
// типы. Списку положено совпадать с содержимым того файла: `no-undef`
// не знает о declare global, и отсутствующее здесь имя падает ошибкой
// линтера при совершенно корректном коде.
//
// DialogType и OptionType отсюда убраны: в src/types/global.d.ts их
// нет и в панели они не встречаются — это остаток чужого шаблона,
// разрешавший необъявленные имена.
IdDto: "readonly",
BaseDto: "readonly",
PageVo: "readonly",
},
},
rules: {
+6 -2
View File
@@ -8,7 +8,11 @@ export interface LogExportDto {
export interface LogSystemVo {
clientIp: string;
latencyTime: string;
// Числа объявлены числами: сервер шлёт их из vo.LogSystemVo как int64
// (latencyTime, statusCode), а не строками. Пока обе колонки не
// отображались, расхождение было безвредным; после того как строка журнала
// стала читаемой, оно стало бы обычной ошибкой сравнения или форматирования.
latencyTime: number;
level: string;
msg: string;
reqMethod: string;
@@ -17,7 +21,7 @@ export interface LogSystemVo {
reqPath: string;
// Только имена переданных query-параметров, без значений.
reqQueryKeys: string;
statusCode: string;
statusCode: number;
time: string;
}
+13
View File
@@ -35,10 +35,20 @@ export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
});
}
// Действия над строкой пира сообщают свой исход сами (см. reportPeerActionError
// в списке пиров), поэтому общий тост им не нужен: он был бы вторым сигналом о
// том же событии.
//
// Для отзыва доступа это не косметика. Операция умеет завершиться ЧАСТИЧНО —
// запрет новых подключений записан, активную сессию завершить не удалось, — и
// страница показывает такой исход предупреждением. Общий перехватчик показал бы
// рядом ошибку, то есть два противоречащих друг другу сообщения об одном
// результате.
export function deletePeerApi(data: IdDto): AxiosPromise {
return request({
url: `/peers/${data.id}`,
method: "delete",
skipErrorToast: true,
});
}
@@ -55,6 +65,7 @@ export function resetPeerTrafficApi(data: IdDto): AxiosPromise {
return request({
url: `/peers/${data.id}/reset-traffic`,
method: "patch",
skipErrorToast: true,
});
}
@@ -62,6 +73,7 @@ export function releaseKickPeerApi(data: IdDto): AxiosPromise {
return request({
url: `/peers/${data.id}/release-kick`,
method: "patch",
skipErrorToast: true,
});
}
@@ -70,6 +82,7 @@ export function kickPeerApi(id: number, data: KickPeerDto): AxiosPromise {
url: `/peers/${id}/kick`,
method: "patch",
data,
skipErrorToast: true,
});
}
@@ -0,0 +1,169 @@
<template>
<div class="app-container">
<div class="page-toolbar">
<el-form :model="queryParams" :inline="true">
<el-form-item :label="$t('log.numLine')" prop="numLine">
<el-select
v-model="queryParams.numLine"
style="width: 200px"
@change="load"
>
<el-option
v-for="option in LINE_OPTIONS"
:key="option"
:label="String(option)"
:value="option"
/>
</el-select>
</el-form-item>
<el-form-item prop="export">
<el-button :loading="exporting" @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
<el-form-item prop="refresh">
<el-button @click="load">
<template #icon>
<i-ep-refresh />
</template>
{{ $t("common.refresh") }}
</el-button>
</el-form-item>
</el-form>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="records" class="log-table">
<!--
Ширины заданы явно и по назначению колонки.
Без width/min-width Element Plus делит доступную ширину между
колонками практически поровну: уровень и время получали по трети
строки, а сообщение тоже треть, хотя именно оно и является
содержимым журнала. У Hysteria в msg приезжает диагностический JSON,
поэтому колонка сообщения растягивается за счёт остатка, а служебные
колонки зафиксированы.
-->
<el-table-column
prop="level"
label="level"
width="110"
align="center"
/>
<el-table-column prop="msg" label="msg" min-width="640" align="left">
<template #default="scope">
<!--
Сообщение ПЕРЕНОСИТСЯ, а не обрезается многоточием: у Hysteria
это диагностический JSON, и обрезанная строка не отвечает ни на
один вопрос, ради которого страницу открыли.
-->
<span class="log-message">{{ scope.row.msg }}</span>
</template>
</el-table-column>
<el-table-column prop="time" label="time" width="200" align="center" />
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
/**
* Общая страница журнала: системного и Hysteria.
*
* Обе страницы были побайтово одинаковы, кроме вызываемого API, и обе несли
* одни и те же три дефекта: колонки без ширин, экспорт с сетевым запросом ВНЕ
* try и пустой catch, глушивший всё остальное. Починка в двух местах разошлась
* бы снова — как разошлись форма ответа и обработка ошибок до этого.
*
* Различие между страницами вынесено в параметры: чем читать и что выгружать.
*/
import { AxiosPromise, AxiosResponse } from "axios";
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n";
import { exportLogApi } from "@/api/log";
import { LogDto } from "@/api/log/types";
import { saveResponseAsFile } from "@/utils/download";
/** Строка журнала в объёме, который показывает таблица. */
interface LogRow {
level: string;
msg: string;
time: string;
}
const props = defineProps<{
/** Чтение страницы журнала. */
fetchLogs: (query: LogDto) => AxiosPromise<PageVo<LogRow>>;
/** Значение option для выгрузки: 0 — журнал админки, 1 — журнал Hysteria. */
exportOption: number;
/** Имя файла, если сервер его не назвал. */
exportFileName: string;
}>();
const LINE_OPTIONS = [100, 200, 300];
const { t } = useI18n();
const loading = ref(false);
const exporting = ref(false);
const records = ref<LogRow[]>([]);
const queryParams = reactive<LogDto>({ numLine: LINE_OPTIONS[0] });
async function load() {
loading.value = true;
try {
const { data } = await props.fetchLogs(queryParams);
// Ответ обязан нести records на любой ветке — это контракт LogSystemPage.
// Запасное значение здесь стоит не вместо контракта, а чтобы отказ формы
// ответа не превращался в исключение внутри таблицы.
records.value = data?.records ?? [];
} finally {
loading.value = false;
}
}
/**
* Выгрузка журнала.
*
* Сетевой запрос находится ВНУТРИ try. Раньше он стоял перед ним, поэтому его
* отказ не ловился этим catch вовсе, а сам catch был пуст: оператор не получал
* ни файла, ни причины.
*
* Отказ показывается ОДИН раз. Выгрузка приходит бинарным потоком и не
* проходит через общий разбор конверта — у Blob нет полей code и errors, —
* поэтому фразу здесь даёт сама страница.
*/
async function handleExport() {
exporting.value = true;
try {
const response: AxiosResponse = await exportLogApi({
option: props.exportOption,
});
saveResponseAsFile(response, props.exportFileName);
ElMessage.success(t("common.downloadSuccess"));
} catch {
ElMessage.error(t("log.exportFailed"));
} finally {
exporting.value = false;
}
}
onMounted(load);
</script>
<style scoped>
.log-message {
display: inline-block;
width: 100%;
font-family: var(--el-font-family-mono, monospace);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
+27 -6
View File
@@ -154,12 +154,18 @@ export default {
credential_format: "“{field}”: contains characters that are not allowed",
rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed",
body_invalid: "Request could not be parsed: check field formats and types",
body_invalid:
"Request could not be parsed: check field formats and types",
peer_name_taken: "A peer with this name already exists",
peer_name_reserved: "This name is reserved for the installer peer",
peer_bootstrap_identity_locked:
"The installer peer's name and secret are mirrored in a file on the server and cannot be changed from the panel. Delete the bootstrap peer entirely if it is no longer needed.",
invalid_credentials: "Wrong username or password",
// The phrase must open with what has ALREADY been applied, otherwise it
// reads as "the operation failed" and the operator repeats an action
// that in fact went through.
peer_disconnect_failed:
"New connections for this peer are now refused, but its active session could not be terminated: the Hysteria Traffic Stats API is unreachable. An established connection may keep working until the client reconnects. Check the hysteria-server service and retry.",
import_file_extension: "Import accepts .json files only",
unauthorized: "Signing in is required",
session_expired: "Session expired",
@@ -185,8 +191,10 @@ export default {
secret: "Secret",
form: {
namePlaceholder: "client-01",
// The character set is stated as service.IsValidPeerName accepts it. The
// previous wording described the rule that was in force before EX-03.
nameHint:
"Peer identifier: 6 to 32 characters, latin letters, digits and hyphens. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
"Peer identifier: 6 to 32 characters latin letters, digits and !@#$%^&*()_+-=. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "laptop",
remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate one",
@@ -230,10 +238,22 @@ export default {
conAt: "Last connection time",
createTime: "Create Time",
releaseSuccess: "Release successful",
kick: "Kick",
kickTip: "Force user to log off",
releaseKick: "Release",
releaseKickTip: "Remove offline status",
kick: "Ban temporarily",
kickTip:
"Drop the session and refuse new connections until the ban expires",
kickConfirm:
"Ban peer “{name}” until {until}? The current session will be dropped and new connections refused until then.",
kickSuccess: "Peer banned temporarily",
releaseKick: "Lift ban",
releaseKickTip: "Lift the temporary ban early",
// Disabling and the temporary ban are separate mechanisms: the ban expires
// on its own, disabling is only undone by hand.
enablePeer: "Enable peer",
disablePeer: "Disable peer",
disableConfirm:
"Disable peer “{name}”? The current session will be dropped and new connections refused until the peer is enabled again.",
enableSuccess: "Peer enabled",
disableSuccess: "Peer disabled",
createdAt: "Created at",
bannedUntil: "Banned until",
totalTraffic: "Total traffic",
@@ -264,6 +284,7 @@ export default {
level: "Level",
message: "Message",
time: "Time",
exportFailed: "Could not export the log",
},
errorPage: {
back: "Back",
+26 -5
View File
@@ -163,6 +163,11 @@ export default {
peer_bootstrap_identity_locked:
"Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.",
invalid_credentials: "Неверный логин или пароль",
// Фраза обязана начинаться с того, что УЖЕ СДЕЛАНО: иначе оператор
// прочитает её как «операция не выполнена» и повторит действие, которое
// на самом деле применилось.
peer_disconnect_failed:
"Новые подключения пира запрещены, но завершить его активную сессию не удалось: Traffic Stats API Hysteria недоступен. Установленное соединение может работать до переподключения клиента. Проверьте состояние службы hysteria-server и повторите действие.",
import_file_extension: "Импорт принимает только файлы .json",
unauthorized: "Требуется вход в панель",
session_expired: "Сессия истекла",
@@ -189,8 +194,12 @@ export default {
secret: "Секрет",
form: {
namePlaceholder: "client-01",
// Набор символов назван так, как его понимает service.IsValidPeerName.
// Прежняя формулировка «латиница, цифры и дефис» осталась от правила,
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
// сервер принимает, и оператор не имел причин пробовать остальные.
nameHint:
"Идентификатор пира: от 6 до 32 символов, латиница, цифры и дефис. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
"Идентификатор пира: от 6 до 32 символов латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: "ноутбук",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем",
@@ -239,10 +248,21 @@ export default {
conAt: "Последнее подключение",
createTime: "Создано",
releaseSuccess: "Ограничение снято",
kick: "Отключить",
kickTip: "Принудительно отключить пользователя",
releaseKick: "Снять",
releaseKickTip: "Снять офлайн-статус",
kick: "Временно заблокировать",
kickTip: "Прервать соединение и не пускать до истечения срока",
kickConfirm:
"Заблокировать пир «{name}» до {until}? Текущее соединение будет разорвано, новые подключения до этого времени приниматься не будут.",
kickSuccess: "Пир временно заблокирован",
releaseKick: "Снять блокировку",
releaseKickTip: "Снять временную блокировку досрочно",
// Отключение и временная блокировка — разные механизмы: у блокировки есть
// срок, который истекает сам, отключение снимается только руками.
enablePeer: "Включить пир",
disablePeer: "Отключить пир",
disableConfirm:
"Отключить пир «{name}»? Текущее соединение будет разорвано, а новые подключения запрещены, пока пир не будет включён обратно.",
enableSuccess: "Пир включён",
disableSuccess: "Пир отключён",
},
config: {
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
@@ -268,6 +288,7 @@ export default {
level: "Уровень",
message: "Сообщение",
time: "Время",
exportFailed: "Не удалось выгрузить журнал",
},
errorPage: {
back: "Назад",
+16 -9
View File
@@ -28,17 +28,24 @@ const { isFullscreen, toggle } = useFullscreen();
/**
* Выход из системы.
*
* Отмена диалога — это ОТВЕТ оператора, а не ошибка. ElMessageBox отклоняет
* промис при нажатии «Отмена», и цепочка без разбора отказа оставляла в
* консоли необработанное отклонение промиса на каждый передумавший выход.
*/
function logout() {
ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
confirmButtonText: t("navbar.logout"),
cancelButtonText: t("common.cancel"),
type: "warning",
}).then(() => {
adminStore.logout().then(() => {
router.push(`/login?redirect=${route.fullPath}`);
async function logout() {
try {
await ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
confirmButtonText: t("navbar.logout"),
cancelButtonText: t("common.cancel"),
type: "warning",
});
});
} catch {
return;
}
await adminStore.logout();
router.push(`/login?redirect=${route.fullPath}`);
}
</script>
+1
View File
@@ -55,6 +55,7 @@ declare module 'vue' {
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']
+5
View File
@@ -47,6 +47,11 @@ export const ERR_CODE = {
peerNameTaken: "peer_name_taken",
peerNameReserved: "peer_name_reserved",
peerBootstrapLocked: "peer_bootstrap_identity_locked",
// ЧАСТИЧНЫЙ результат отзыва доступа, а не отказ операции: запрет новых
// подключений записан, активную сессию завершить не удалось. Панель
// показывает его предупреждением и обновляет строку — состояние в базе уже
// изменилось.
peerDisconnectFailed: "peer_disconnect_failed",
invalidCredentials: "invalid_credentials",
importFileExtension: "import_file_extension",
unauthorized: "unauthorized",
+73
View File
@@ -0,0 +1,73 @@
import { AxiosResponse } from "axios";
/**
* Скачивание бинарного ответа API одним способом на всю панель.
*
* Раньше эта последовательность — Blob, createObjectURL, временная ссылка,
* клик, revokeObjectURL — была скопирована в трёх местах, и все три копии
* успели разойтись: страница пиров разбирала Content-Disposition с запасным
* именем файла, а обе страницы логов падали на `undefined.split(...)`, если
* заголовка не оказалось.
*
* Хуже расхождения был порядок на страницах логов:
*
* const response = await exportLogApi(...); // <- запрос ВНЕ try
* try { ... } catch (e) { } // <- и пустой catch
*
* Сетевой запрос стоял ПЕРЕД try, поэтому его отказ этим catch не ловился
* вовсе, а всё, что происходило внутри, глушилось молча. Оператор нажимал
* «Экспорт» и не получал ни файла, ни объяснения.
*/
/** Имя файла по умолчанию, если сервер его не назвал. */
const CONTENT_DISPOSITION_PREFIX = "attachment; filename=";
/**
* Имя файла из заголовка ответа.
*
* Отсутствующий или незнакомый заголовок — не отказ: у скачивания есть
* разумное запасное имя, и терять из-за формата заголовка уже полученный файл
* незачем.
*/
export function fileNameFromResponse(
response: AxiosResponse,
fallback: string
): string {
const header = response.headers?.["content-disposition"];
if (typeof header !== "string") {
return fallback;
}
const index = header.indexOf(CONTENT_DISPOSITION_PREFIX);
if (index < 0) {
return fallback;
}
const name = header.slice(index + CONTENT_DISPOSITION_PREFIX.length).trim();
return name === "" ? fallback : name;
}
/**
* Отдаёт бинарный ответ пользователю как файл.
*
* Временная ссылка УДАЛЯЕТСЯ из документа, а objectURL освобождается в
* `finally`. Прежние копии оставляли `<a>` в body навсегда — по одному
* элементу на каждое нажатие «Экспорт» за всю жизнь вкладки.
*/
export function saveResponseAsFile(
response: AxiosResponse,
fallbackFileName: string
): void {
const blob = new Blob([response.data], { type: "application/octet-stream" });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
try {
anchor.href = url;
anchor.download = fileNameFromResponse(response, fallbackFileName);
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
} finally {
anchor.remove();
window.URL.revokeObjectURL(url);
}
}
+22 -4
View File
@@ -21,7 +21,13 @@ const t = i18n.global.t;
* Запрос может отказаться от общего тоста, если показывает причину сам.
*
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
* второй сигнал тостом там только шумит.
* второй сигнал тостом там только шумит. Так же поступают действия над строкой
* пира: отзыв доступа умеет завершиться частично, и такой исход показывается
* предупреждением, а не ошибкой.
*
* Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code`
* и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего,
* и молчание о нём означало бы операцию, не происходящую без объяснений.
*/
declare module "axios" {
export interface AxiosRequestConfig {
@@ -138,9 +144,21 @@ service.interceptors.response.use(
? t("common.systemError")
: t("common.networkError");
if (!error.config?.skipErrorToast) {
ElMessage.error(message);
}
// skipErrorToast здесь НЕ действует, и это существенно.
//
// Флаг существует ради одного случая: страница разбирает причину отказа
// сама — раскладывает её по полям формы или отличает частичный результат
// от полного отказа по коду. Всё это возможно только для отказа API, у
// которого есть конверт с `code` и `errors`.
//
// У транспортного отказа конверта нет вовсе. Страница о нём не может
// сказать ничего, кроме того же «сервер не отвечает», и все они пишут в
// своих обработчиках `// транспортный отказ уже показан общим
// перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было
// ложным: обрыв соединения при сохранении пира, его удалении или отзыве
// доступа не показывал оператору НИЧЕГО — операция просто не происходила
// молча.
ElMessage.error(message);
return Promise.reject(error);
}
);
+19 -15
View File
@@ -1050,6 +1050,7 @@ import Outbounds from "./components/Outbounds/index.vue";
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
import { useI18n } from "vue-i18n";
import { deepCopy } from "@/utils/copy";
import { saveResponseAsFile } from "@/utils/download";
import { dashboardSummaryApi } from "@/api/dashboard";
const { t } = useI18n();
@@ -1146,23 +1147,26 @@ const {
hysteria2Monitor,
} = toRefs(state);
/**
* Выгрузка серверного конфига Hysteria.
*
* Тот же дефект, что был на обеих страницах журнала: сетевой запрос стоял
* ПЕРЕД try, поэтому его отказ этим catch не ловился вовсе, а сам catch был
* пуст. Вдобавок `dis.split(...)` падал на отсутствующем Content-Disposition —
* и это исключение тоже глушилось. Оператор нажимал «Экспорт» и не получал ни
* файла, ни причины.
*
* Сборка ссылки на скачивание живёт в одном месте на всю панель.
*/
const handleExport = async () => {
let response = await exportHysteria2ConfigApi();
try {
const blob = new Blob([response.data], {
type: "application/octet-stream",
});
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
let dis = response.headers["content-disposition"];
a.download = dis.split("attachment; filename=")[1];
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.success"));
} catch (e) {
/* empty */
const response = await exportHysteria2ConfigApi();
saveResponseAsFile(response, "hysteria-config.yaml");
ElMessage.success(t("common.downloadSuccess"));
} catch {
// Выгрузка приходит бинарным потоком и не проходит через общий разбор
// конверта: у Blob нет полей code и errors.
ElMessage.error(t("common.systemError"));
}
};
+14 -99
View File
@@ -1,109 +1,24 @@
<template>
<div class="app-container">
<div class="page-toolbar">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item :label="$t('log.numLine')" prop="numLine">
<el-select
v-model="queryParams.numLine"
style="width: 200px"
@change="setRecords"
>
<el-option label="100" value="100" />
<el-option label="200" value="200" />
<el-option label="300" value="300" />
</el-select>
</el-form-item>
<el-form-item prop="export">
<el-button @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
<el-form-item prop="refresh">
<el-button @click="setRecords">
<template #icon>
<i-ep-refresh />
</template>
{{ $t("common.refresh") }}
</el-button>
</el-form-item>
</el-form>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="records">
<el-table-column
key="level"
label="level"
align="center"
prop="level"
/>
<el-table-column key="msg" label="msg" align="center" prop="msg" />
<el-table-column key="time" label="time" align="center" prop="time" />
</el-table>
</el-card>
</div>
<log-viewer
:fetch-logs="logHysteria2Api"
:export-option="1"
export-file-name="hysteria2.log"
/>
</template>
<script lang="ts">
export default {
name: "index",
name: "LogHysteria",
};
</script>
<script setup lang="ts">
import { LogDto, LogHysteria2Vo } from "@/api/log/types";
import { exportLogApi, logHysteria2Api } from "@/api/log";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const state = reactive({
loading: true,
total: 0,
records: [] as LogHysteria2Vo[],
queryParams: {
numLine: 100,
} as LogDto,
});
const { loading, records, queryParams } = toRefs(state);
const setRecords = async () => {
try {
state.loading = true;
const { data } = await logHysteria2Api(state.queryParams);
state.records = data.records;
state.total = data.total;
} finally {
state.loading = false;
}
};
const handleExport = async () => {
let response = await exportLogApi({ option: 1 });
try {
const blob = new Blob([response.data], {
type: "application/octet-stream",
});
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
let dis = response.headers["content-disposition"];
a.download = dis.split("attachment; filename=")[1];
// Имитация клика для скачивания
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.success"));
} catch (e) {
/* empty */
}
};
onMounted(() => {
setRecords();
});
/**
* Журнал Hysteria.
*
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer — см.
* страницу системного журнала.
*/
import LogViewer from "@/components/LogViewer/index.vue";
import { logHysteria2Api } from "@/api/log";
</script>
+15 -99
View File
@@ -1,109 +1,25 @@
<template>
<div class="app-container">
<div class="page-toolbar">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item :label="$t('log.numLine')" prop="numLine">
<el-select
v-model="queryParams.numLine"
style="width: 200px"
@change="setRecords"
>
<el-option label="100" value="100" />
<el-option label="200" value="200" />
<el-option label="300" value="300" />
</el-select>
</el-form-item>
<el-form-item prop="export">
<el-button @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
<el-form-item prop="refresh">
<el-button @click="setRecords">
<template #icon>
<i-ep-refresh />
</template>
{{ $t("common.refresh") }}
</el-button>
</el-form-item>
</el-form>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="records">
<el-table-column
key="level"
label="level"
align="center"
prop="level"
/>
<el-table-column key="msg" label="msg" align="center" prop="msg" />
<el-table-column key="time" label="time" align="center" prop="time" />
</el-table>
</el-card>
</div>
<log-viewer
:fetch-logs="logSystemApi"
:export-option="0"
export-file-name="hy2xs-admin.log"
/>
</template>
<script lang="ts">
export default {
name: "index",
name: "LogSystem",
};
</script>
<script setup lang="ts">
import { LogDto, LogSystemVo } from "@/api/log/types";
import { exportLogApi, logSystemApi } from "@/api/log";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const state = reactive({
loading: true,
total: 0,
records: [] as LogSystemVo[],
queryParams: {
numLine: 100,
} as LogDto,
});
const { loading, records, queryParams } = toRefs(state);
const setRecords = async () => {
try {
state.loading = true;
const { data } = await logSystemApi(state.queryParams);
state.records = data.records;
state.total = data.total;
} finally {
state.loading = false;
}
};
const handleExport = async () => {
let response = await exportLogApi({ option: 0 });
try {
const blob = new Blob([response.data], {
type: "application/octet-stream",
});
let url = window.URL.createObjectURL(blob);
let a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
let dis = response.headers["content-disposition"];
a.download = dis.split("attachment; filename=")[1];
// Имитация клика для скачивания
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.success"));
} catch (e) {
/* empty */
}
};
onMounted(() => {
setRecords();
});
/**
* Журнал админки.
*
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer: страница
* системного журнала и страница журнала Hysteria были побайтово одинаковы и
* несли одни и те же дефекты в двух экземплярах.
*/
import LogViewer from "@/components/LogViewer/index.vue";
import { logSystemApi } from "@/api/log";
</script>
+261 -57
View File
@@ -30,7 +30,17 @@
>
<el-button>{{ $t("common.import") }}</el-button>
</el-upload>
<el-tooltip :content="$t('peer.exportSettingsHint')" placement="bottom">
<!--
Ширина всплывающей подсказки ограничена штатным popper-style, а не
глобальным CSS. Без ограничения popper берёт естественную ширину
содержимого, и длинная подсказка растягивалась почти на весь экран
одной строкой.
-->
<el-tooltip
:content="$t('peer.exportSettingsHint')"
placement="bottom"
:popper-style="TOOLTIP_POPPER_STYLE"
>
<el-button class="ml-2" @click="handleExport">{{
$t("peer.exportSettings")
}}</el-button>
@@ -132,35 +142,53 @@
@click="copyUri(peerRow(scope.row))"
>{{ $t("peer.copyUri") }}</el-button
>
<el-dropdown trigger="click" placement="bottom-end">
<!--
Действия объявлены через command, а не обработчиками click на
каждом пункте. Это штатный API el-dropdown для меню действий:
команда приходит в одно место, и добавить пункт, забыв его
подключить, здесь уже нельзя.
-->
<el-dropdown
trigger="click"
placement="bottom-end"
@command="
(command: PeerCommand) =>
runCommand(command, peerRow(scope.row))
"
>
<el-button text circle class="peer-more-btn" @click.stop>
<i-ep-more-filled />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="showQr(peerRow(scope.row))">{{
<el-dropdown-item command="qr">{{
$t("peer.clientQr")
}}</el-dropdown-item>
<el-dropdown-item
@click="handleUpdate(peerRow(scope.row))"
>{{ $t("common.edit") }}</el-dropdown-item
>
<el-dropdown-item
@click="handleResetTraffic(peerRow(scope.row))"
>{{ $t("common.resetTraffic") }}</el-dropdown-item
>
<el-dropdown-item @click="handleKick(peerRow(scope.row))">{{
<el-dropdown-item command="edit">{{
$t("common.edit")
}}</el-dropdown-item>
<!--
Быстрое включение/отключение. Пункта здесь не было вовсе:
сменить состояние можно было только через форму
изменения, хотя это самое частое действие оператора.
-->
<el-dropdown-item command="toggle-disabled">{{
scope.row.disabled === 1
? $t("peer.enablePeer")
: $t("peer.disablePeer")
}}</el-dropdown-item>
<el-dropdown-item command="reset-traffic">{{
$t("common.resetTraffic")
}}</el-dropdown-item>
<el-dropdown-item divided command="kick">{{
$t("peer.kick")
}}</el-dropdown-item>
<el-dropdown-item
@click="handleReleaseKick(peerRow(scope.row))"
>{{ $t("peer.releaseKick") }}</el-dropdown-item
>
<el-dropdown-item
divided
@click="handleDelete(peerRow(scope.row))"
>{{ $t("common.delete") }}</el-dropdown-item
>
<el-dropdown-item command="release-kick">{{
$t("peer.releaseKick")
}}</el-dropdown-item>
<el-dropdown-item divided command="delete">{{
$t("common.delete")
}}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -214,7 +242,10 @@
/>
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.quota')" :error="serverErrors.quotaBytes">
<el-form-item
:label="$t('peer.quota')"
:error="serverErrors.quotaBytes"
>
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
</el-form-item>
@@ -231,7 +262,9 @@
:error="serverErrors.maxDevices"
><el-input-number v-model="dataForm.maxDevices" :min="1"
/></el-form-item>
<el-form-item :label="$t('peer.disabled')" :error="serverErrors.disabled"
<el-form-item
:label="$t('peer.disabled')"
:error="serverErrors.disabled"
><el-switch v-model="disabledBool"
/></el-form-item>
</el-form>
@@ -381,8 +414,34 @@ import {
PeerVo,
} from "@/api/peer/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
import { isApiError } from "@/utils/api-error";
import { ERR_CODE, isApiError } from "@/utils/api-error";
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
import { saveResponseAsFile } from "@/utils/download";
/**
* Ограничение ширины всплывающих подсказок.
*
* Объявлено значением, а не литералом в шаблоне: подсказок в панели больше
* одной, и «широкая» подсказка — это свойство панели, а не отдельной кнопки.
*/
const TOOLTIP_POPPER_STYLE = {
maxWidth: "360px",
whiteSpace: "normal",
lineHeight: "1.5",
} as const;
/** Срок временной блокировки по умолчанию — один час. */
const KICK_DURATION_MS = 60 * 60 * 1000;
/** Действия меню строки пира. */
type PeerCommand =
| "qr"
| "edit"
| "toggle-disabled"
| "reset-traffic"
| "kick"
| "release-kick"
| "delete";
/**
* Единственный переход от строки слота таблицы к модели пира.
@@ -477,7 +536,10 @@ watch(
return;
}
for (const key of Object.keys(serverErrors)) {
if (next[key as keyof typeof next] !== previous[key as keyof typeof previous]) {
if (
next[key as keyof typeof next] !==
previous[key as keyof typeof previous]
) {
delete serverErrors[key];
}
}
@@ -625,12 +687,14 @@ async function submitForm() {
const ok = await formRef.value.validate().catch(() => false);
if (!ok) return;
}
if (dialog.editId > 0 && dataForm.secret) {
await ElMessageBox.confirm(
t("peer.secretRotateConfirm"),
t("common.warning"),
{ type: "warning" }
);
// Отмена подтверждения — ответ оператора, а не ошибка: форма остаётся
// открытой с введённым, и в консоль ничего не улетает.
if (
dialog.editId > 0 &&
dataForm.secret &&
!(await confirmAction(t("peer.secretRotateConfirm")))
) {
return;
}
try {
@@ -685,39 +749,186 @@ function applyServerErrors(error: unknown) {
}
}
/**
* Единственная точка входа для действий меню строки.
*
* Раньше каждый пункт нёс собственный `@click`. Разница не только
* стилистическая: el-dropdown объявляет `command` штатным способом описывать
* меню действий, и при нём невозможно добавить пункт, забыв его подключить.
*/
function runCommand(command: PeerCommand, row: PeerVo) {
switch (command) {
case "qr":
return showQr(row);
case "edit":
return handleUpdate(row);
case "toggle-disabled":
return handleToggleDisabled(row);
case "reset-traffic":
return handleResetTraffic(row);
case "kick":
return handleKick(row);
case "release-kick":
return handleReleaseKick(row);
case "delete":
return handleDelete(row);
}
}
/**
* Подтверждение, отмена которого является ОТВЕТОМ, а не ошибкой.
*
* ElMessageBox отклоняет промис при нажатии «Отмена». Прежний код писал
* `await ElMessageBox.confirm(...)` без разбора отказа, поэтому каждая отмена
* оставляла в консоли необработанное отклонение промиса — в четырёх местах
* страницы сразу.
*/
async function confirmAction(
message: string,
title = t("common.warning"),
options: Record<string, unknown> = { type: "warning" }
): Promise<boolean> {
try {
await ElMessageBox.confirm(message, title, options);
return true;
} catch {
return false;
}
}
/**
* Отказ операции над пиром, показанный оператору один раз.
*
* Отдельно разбирается ЧАСТИЧНЫЙ результат отзыва доступа: запрет новых
* подключений в базу записан, а активную сессию завершить не удалось. Это не
* отказ операции, и показывать его как ошибку означало бы подтолкнуть
* оператора к неверному выводу «не сработало, состояние прежнее». Список при
* этом обновляется: строка обязана показать уже применённое состояние.
*/
function reportPeerActionError(error: unknown): void {
if (!isApiError(error)) {
// Транспортный отказ уже показан общим перехватчиком.
return;
}
if (error.hasCode(ERR_CODE.peerDisconnectFailed)) {
ElMessage.warning(describeApiError(error));
return;
}
ElMessage.error(describeApiError(error));
}
async function handleDelete(row: PeerVo) {
await ElMessageBox.confirm(
t("common.deleteConfirm", { username: row.name }),
t("common.warning"),
{ type: "warning" }
);
await deletePeerApi({ id: row.id });
if (
!(await confirmAction(t("common.deleteConfirm", { username: row.name })))
) {
return;
}
try {
await deletePeerApi({ id: row.id });
ElMessage.success(t("common.success"));
} catch (error) {
reportPeerActionError(error);
}
await handleQuery();
}
async function handleResetTraffic(row: PeerVo) {
await ElMessageBox.confirm(
t("common.resetTrafficConfirm"),
t("common.warning"),
{ type: "warning" }
);
await resetPeerTrafficApi({ id: row.id });
if (!(await confirmAction(t("common.resetTrafficConfirm")))) {
return;
}
try {
await resetPeerTrafficApi({ id: row.id });
ElMessage.success(t("common.success"));
} catch (error) {
reportPeerActionError(error);
}
await handleQuery();
}
/**
* Быстрое включение/отключение пира.
*
* Отключение — это ОБЕ половины официального контракта Hysteria: запрет новых
* подключений в базе и разрыв уже установленной сессии через Traffic Stats
* `/kick`. Обе выполняет сервер; панели остаётся сообщить о результате, в том
* числе о частичном, и показать применённое состояние.
*
* Подтверждение спрашивается только на отключении: включение доступа обратимо
* одним движением, отключение обрывает работающее соединение.
*/
async function handleToggleDisabled(row: PeerVo) {
const disable = row.disabled !== 1;
if (
disable &&
!(await confirmAction(t("peer.disableConfirm", { name: row.name })))
) {
return;
}
try {
await updatePeerApi({ id: row.id, disabled: disable ? 1 : 0 });
ElMessage.success(
disable ? t("peer.disableSuccess") : t("peer.enableSuccess")
);
} catch (error) {
reportPeerActionError(error);
}
// Список обновляется в любом случае: при частичном результате состояние в
// базе уже изменилось, и строка обязана это показать.
await handleQuery();
}
/**
* Временная блокировка.
*
* Срок называется оператору явно. Раньше значение `Date.now() + час` было
* зашито в обработчик, нигде не показывалось и не подтверждалось: пункт меню
* «Отключить» молча блокировал пира на час, а сколько именно продлится
* блокировка, не сообщалось ни до, ни после.
*
* Механизм независим от «Отключить пир»: у временной блокировки свой срок,
* который истекает сам, и снимается она отдельным пунктом.
*/
async function handleKick(row: PeerVo) {
await kickPeerApi(row.id, { bannedUntil: Date.now() + 60 * 60 * 1000 });
const bannedUntil = Date.now() + KICK_DURATION_MS;
if (
!(await confirmAction(
t("peer.kickConfirm", {
name: row.name,
until: timestampToDateTime(bannedUntil),
})
))
) {
return;
}
try {
await kickPeerApi(row.id, { bannedUntil });
ElMessage.success(t("peer.kickSuccess"));
} catch (error) {
reportPeerActionError(error);
}
await handleQuery();
}
async function handleReleaseKick(row: PeerVo) {
await releaseKickPeerApi({ id: row.id });
try {
await releaseKickPeerApi({ id: row.id });
ElMessage.success(t("peer.releaseSuccess"));
} catch (error) {
reportPeerActionError(error);
}
await handleQuery();
}
async function copyUri(row: PeerVo) {
// Копирование без подтверждения выглядит как ничего не сделавшая кнопка:
// буфер обмена оператору не виден.
const { data } = await getPeerClientConfigApi(row.id);
copy(data.url);
ElMessage.success(t("common.copySuccess"));
}
async function showQr(row: PeerVo) {
@@ -752,17 +963,7 @@ function beforeImport(file: UploadRawFile) {
async function downloadExport(includeSecrets: boolean) {
try {
const response = await exportPeerApi(includeSecrets);
const blob = new Blob([response.data], {
type: "application/octet-stream",
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
const dis = response.headers["content-disposition"] || "";
a.download = dis.split("attachment; filename=")[1] || "peers-export.json";
a.click();
window.URL.revokeObjectURL(url);
saveResponseAsFile(response, "peers-export.json");
ElMessage.success(t("common.downloadSuccess"));
} catch {
// Выгрузка приходит бинарным потоком, поэтому её отказ не проходит через
@@ -780,7 +981,7 @@ async function handleExport() {
// Подтверждение здесь обязательно — это единственное место в панели, где
// оператор осознанно выгружает наружу работающие учётные данные доступа.
async function handleExportBackup() {
await ElMessageBox.confirm(
const confirmed = await confirmAction(
t("peer.exportBackupConfirm"),
t("common.securityRisk"),
{
@@ -790,6 +991,9 @@ async function handleExportBackup() {
confirmButtonClass: "el-button--danger",
}
);
if (!confirmed) {
return;
}
await downloadExport(true);
}