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:
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Назад",
|
||||
|
||||
@@ -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
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user