Files
HY2XS_flamy/apps/frontend/src/utils/request.ts
T
founder 162759c599 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.
2026-09-01 17:17:17 +05:00

168 lines
7.6 KiB
Go

import axios, {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import { useAdminStoreHook } from "@/store/modules/admin";
import i18n from "@/lang/index";
import { API_CODE, ApiError, ApiErrorPayload } from "@/utils/api-error";
import { describeApiError } from "@/utils/api-message";
const dynamicBase = (window as any).__dynamic_base__ || "";
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
// под ним лежали и machine-to-machine auth Hysteria, и JWT-защищённый
// админский API, хотя middleware у них разные.
// Значение синхронизировано с constant.AdminAPIBase в админке и
// ADMIN_API_BASE в оркестраторе.
const API_BASE = "/api";
const t = i18n.global.t;
/**
* Запрос может отказаться от общего тоста, если показывает причину сам.
*
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
* второй сигнал тостом там только шумит. Так же поступают действия над строкой
* пира: отзыв доступа умеет завершиться частично, и такой исход показывается
* предупреждением, а не ошибкой.
*
* Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code`
* и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего,
* и молчание о нём означало бы операцию, не происходящую без объяснений.
*/
declare module "axios" {
export interface AxiosRequestConfig {
skipErrorToast?: boolean;
}
}
// Создание axios instance
const service = axios.create({
baseURL: `${dynamicBase}${API_BASE}`,
timeout: 50000,
headers: { "Content-Type": "application/json;charset=utf-8" },
});
// Request interceptor
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const adminStore = useAdminStoreHook();
if (adminStore.token) {
config.headers.Authorization = adminStore.token;
}
return config;
},
(error: any) => {
return Promise.reject(error);
}
);
/**
* Сессия кончилась под руками у оператора.
*
* Раньше эта ветка была недостижима, и не в одном месте, а в двух. Сервер
* отвечал HTTP 200 на любой отказ, поэтому обработчик ошибок axios (второй
* аргумент interceptors.response.use) для отказов API не вызывался вовсе — а
* жила ветка сессии именно там. Условие в ней проверяло `code === "A0230"` и
* поле `msg`, которых в этом API никогда не было: остатки чужого шаблона.
* Ключ common.sessionExpired существовал и был мёртвым.
*
* Диалог показывается ОДИН раз: истёкший токен обычно роняет сразу несколько
* параллельных запросов страницы, и без этого оператор получил бы стопку
* одинаковых окон.
*/
let sessionPromptOpen = false;
function promptSignIn(expired: boolean): void {
if (sessionPromptOpen) {
return;
}
sessionPromptOpen = true;
const finish = () => {
sessionPromptOpen = false;
// Сбрасывается ТОЛЬКО сессия. Прежний код звал localStorage.clear(), то
// есть заодно стирал выбранный оператором язык панели: при следующем входе
// интерфейс возвращался к значению по умолчанию без всякой причины.
useAdminStoreHook().resetToken();
const redirect = encodeURIComponent(
window.location.pathname + window.location.search
);
window.location.href = `/login?redirect=${redirect}`;
};
ElMessageBox.confirm(
expired ? t("common.sessionExpired") : t("common.signInRequired"),
t("common.warning"),
{
confirmButtonText: t("common.signIn"),
showCancelButton: false,
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false,
type: "warning",
}
)
.then(finish)
.catch(finish);
}
// Response interceptor
service.interceptors.response.use(
(response: AxiosResponse) => {
// Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан
// проверяться ДО обращения к его полям: у Blob их нет.
if (
response.data instanceof ArrayBuffer ||
response.data instanceof Blob
) {
return response;
}
const payload = response.data as ApiErrorPayload;
if (payload?.code === API_CODE.success) {
return response.data;
}
const apiError = new ApiError(payload ?? { code: API_CODE.systemError });
if (apiError.requiresSignIn) {
promptSignIn(apiError.sessionExpired);
return Promise.reject(apiError);
}
if (!response.config?.skipErrorToast) {
ElMessage.error(describeApiError(apiError));
}
return Promise.reject(apiError);
},
(error: AxiosError) => {
// Сюда приходит транспорт: сеть недоступна, таймаут, отменённый запрос,
// HTTP-статус вне 2xx. Прежний код читал error.response.data без проверки
// самого error.response — то есть при обрыве соединения падал с
// TypeError и подменял настоящую причину отказом внутри обработчика.
const message = error.response
? t("common.systemError")
: t("common.networkError");
// skipErrorToast здесь НЕ действует, и это существенно.
//
// Флаг существует ради одного случая: страница разбирает причину отказа
// сама — раскладывает её по полям формы или отличает частичный результат
// от полного отказа по коду. Всё это возможно только для отказа API, у
// которого есть конверт с `code` и `errors`.
//
// У транспортного отказа конверта нет вовсе. Страница о нём не может
// сказать ничего, кроме того же «сервер не отвечает», и все они пишут в
// своих обработчиках `// транспортный отказ уже показан общим
// перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было
// ложным: обрыв соединения при сохранении пира, его удалении или отзыве
// доступа не показывал оператору НИЧЕГО — операция просто не происходила
// молча.
ElMessage.error(message);
return Promise.reject(error);
}
);
// Export axios instance
export default service;