feat: opsx-loki 全面升级 — 前端重构/用户与LDAP/部署向导/日志查询
基于 WEB-UI-Development-Guide 标准的整体升级,并更名 LokiDeploy → opsx-loki。 前端(Gin+React+shadcn/ui): - 按 02/07 章重构:页面域化拆分(无超 400 行文件)、PageContainer/ListQuery、 反馈分级(成功静默/失败 toast/危险确认)、401 单飞刷新+队列重放 - 暗色模式(next-themes)+ 语义 Token(--primary 品牌色/success/warning/info) - 部署向导(状态推导四步:环境→存储→Loki→采集,内嵌表单闭环) - 日志查询页:LogQL 标签构建器/拖拽直方图/日志上下文/Live Tail/查询历史 - 按用户+标签策略的查询授权(服务端强制合并策略) 后端(Go+Gin+JSON DB): - 简单用户管理(仅 admin,无 RBAC)+ TokenVersion 会话吊销 + 登录 IP 限流 - LDAP 接入:配置(AES 加密存储/hasPassword 脱敏)/测试连接/批量导入/登录 - 存储配置测试连接(SigV4 零依赖)+ 桶不存在自动创建 - Loki 三模式部署修复:SSH 密钥认证、3.x 配置模板、ruler 对象存储嵌套块、 凭证解密、孤儿 deploying 状态恢复、Grafana 部署(可选默认关)+面板导入 - DaemonSet 采集纯 YAML(去 helm 依赖)+ DNS 自愈回退 ClusterIP + 集群名标签 - 日志查询代理:cluster-svc 经 kube-apiserver proxy/url 直连 + cursor 翻页 - 统一 log 命名空间;部署就绪探活;Grafana 访问地址自动记录 修复:run.sh 增量编译检测、Sanitized 脱敏补全、LokiDeployment Update 白名单、 健康检查 CreatedAt 零值等 10+ 存量 bug。 数据兼容:DB 沿用旧 lokideploy.json(新名优先);localStorage 一次性迁移; HKDF 加密前缀保留(已加密凭证不受影响)。
This commit is contained in:
1 parent
327e282356
commit
f9c6c1d826
204 files changed
+14107
-9103
No files matched your search
+179
-103
@@ -1,51 +1,108 @@
|
||||
// HTTP 请求引擎 — 统一的 fetch 封装,提供 request<T> 与 http.get/post/put/delete 两种用法。
|
||||
// 参考 aiops 的 @/utils/request 模式:超时/取消、token 注入、401 自动登出、统一错误处理。
|
||||
// HTTP 请求引擎 — 统一 fetch 封装:超时/取消、token 注入、结构化错误、
|
||||
// 401 单飞刷新 + 队列重放(见开发标准 03 章)。组件一律通过 api 层调用,禁止直接 fetch。
|
||||
import type { ApiResponse } from '@/types';
|
||||
import {
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
saveTokenPair,
|
||||
clearAuth,
|
||||
} from '@/lib/auth-storage';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
const DEFAULT_TIMEOUT = 30000; // 30秒默认超时
|
||||
const DEFAULT_TIMEOUT = 30000; // 30 秒默认超时
|
||||
const REFRESH_TIMEOUT = 10000; // 刷新 token 接口独立超时
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
/** 结构化 API 错误:status 为 HTTP 状态码(网络/超时等非 HTTP 错误为 0),code 为后端业务码 */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: number;
|
||||
|
||||
constructor(message: string, status = 0, code?: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
export interface RequestOptions extends RequestInit {
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
/** 静默模式:401 失效不触发清凭证/跳登录的全局副作用,仅抛错(登录、轮询接口用) */
|
||||
silent?: boolean;
|
||||
/** 内部标记:刷新 token 后的重放请求,防止 401 无限循环 */
|
||||
retried?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心请求函数:带超时与取消支持,自动注入 Authorization,统一处理 401。
|
||||
*/
|
||||
export async function request<T>(
|
||||
url: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
// —— 401 单飞刷新状态 ——
|
||||
// 并发 N 个 401 只触发一次刷新;刷新期间的请求进入队列,拿到新 token 后统一重放。
|
||||
let isRefreshing = false;
|
||||
let refreshQueue: Array<(token: string | null) => void> = [];
|
||||
|
||||
// 这些端点自身的 401 不触发登出跳转(错误密码、refresh 失效等场景)
|
||||
const AUTH_ENDPOINTS = ['/auth/login', '/auth/refresh'];
|
||||
|
||||
async function doRefresh(): Promise<string | null> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT);
|
||||
const response = await fetch(`${API_BASE}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json()) as ApiResponse<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
}>;
|
||||
if (body.code !== 0 || !body.data?.accessToken) return null;
|
||||
saveTokenPair(body.data);
|
||||
return body.data.accessToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let redirecting = false;
|
||||
|
||||
/** 清凭证并防抖跳转登录(并发失效只产生一次跳转,携带当前路径供回跳) */
|
||||
function redirectToLogin(): void {
|
||||
clearAuth();
|
||||
if (redirecting) return;
|
||||
redirecting = true;
|
||||
const current = window.location.pathname + window.location.search;
|
||||
const target =
|
||||
current.startsWith('/login')
|
||||
? '/login'
|
||||
: `/login?redirect=${encodeURIComponent(current)}`;
|
||||
window.location.href = target;
|
||||
}
|
||||
|
||||
/** 单次 HTTP 往返:组头、超时、取消、解包。401 以 ApiError(status=401) 抛出,由上层统一处理 */
|
||||
async function doRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
const { timeout = DEFAULT_TIMEOUT, signal, ...fetchOptions } = options;
|
||||
|
||||
const token = getToken();
|
||||
const token = getAccessToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((fetchOptions.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const fullUrl = `${API_BASE}/api/v1${url}`;
|
||||
|
||||
// 超时控制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
// 外部 signal 联动
|
||||
const externalSignal = signal;
|
||||
const onExternalAbort = () => {
|
||||
controller.abort();
|
||||
};
|
||||
if (externalSignal) {
|
||||
externalSignal.addEventListener('abort', onExternalAbort);
|
||||
const onExternalAbort = () => controller.abort();
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', onExternalAbort);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -55,53 +112,109 @@ export async function request<T>(
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
throw new Error('Unauthorized');
|
||||
throw new ApiError('未授权或登录已过期', 401);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`Server returned non-JSON (${response.status}): ${text.substring(0, 200)}`
|
||||
throw new ApiError(
|
||||
`服务端返回了非 JSON 响应(${response.status}):${text.substring(0, 200)}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ApiResponse<T>;
|
||||
if (data.code !== 0) {
|
||||
throw new Error(data.message || 'Request failed');
|
||||
const body: unknown = await response.json();
|
||||
// 多数接口走 {code, data, message} 包装;/health 等基础端点直接返回裸 JSON
|
||||
if (
|
||||
body !== null &&
|
||||
typeof body === 'object' &&
|
||||
'code' in body &&
|
||||
typeof (body as ApiResponse<T>).code === 'number'
|
||||
) {
|
||||
const data = body as ApiResponse<T>;
|
||||
if (data.code !== 0) {
|
||||
throw new ApiError(data.message || '请求失败', response.status, data.code);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
return data.data;
|
||||
return body as T;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
if (controller.signal.aborted && !externalSignal?.aborted) {
|
||||
throw new Error(`Request timeout after ${timeout}ms`);
|
||||
}
|
||||
throw new Error('Request was cancelled');
|
||||
}
|
||||
if (err instanceof TypeError && err.message.includes('fetch')) {
|
||||
throw new Error(
|
||||
'Cannot connect to backend server. Please ensure the API server is running.'
|
||||
);
|
||||
if (err instanceof ApiError) throw err;
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
if (controller.signal.aborted && !signal?.aborted) {
|
||||
throw new ApiError(`请求超时(${timeout}ms)`);
|
||||
}
|
||||
throw new ApiError('请求已取消');
|
||||
}
|
||||
throw err;
|
||||
if (err instanceof TypeError) {
|
||||
throw new ApiError('无法连接到后端服务,请确认 API 服务已启动');
|
||||
}
|
||||
throw err instanceof Error ? new ApiError(err.message) : new ApiError(String(err));
|
||||
} finally {
|
||||
if (externalSignal) {
|
||||
externalSignal.removeEventListener('abort', onExternalAbort);
|
||||
clearTimeout(timeoutId);
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心请求函数:在 doRequest 之上处理 401 —— 有 refreshToken 时单飞刷新并重放;
|
||||
* 刷新失败或无 refreshToken 时清凭证跳登录(silent 请求除外)。
|
||||
*/
|
||||
export async function request<T>(
|
||||
url: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await doRequest<T>(url, options);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError) || err.status !== 401) throw err;
|
||||
|
||||
// 静默请求 / 鉴权端点自身 / 已重放过:不再刷新、不再跳转,直接失败
|
||||
if (options.silent || options.retried || AUTH_ENDPOINTS.some((p) => url.startsWith(p))) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!getRefreshToken()) {
|
||||
redirectToLogin();
|
||||
throw new ApiError('登录已过期,请重新登录', 401);
|
||||
}
|
||||
|
||||
// 已有刷新在进行中:挂入队列,等新 token 下发后重放本请求
|
||||
if (isRefreshing) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
refreshQueue.push((token) => {
|
||||
if (!token) {
|
||||
reject(new ApiError('登录已过期,请重新登录', 401));
|
||||
return;
|
||||
}
|
||||
request<T>(url, { ...options, retried: true }).then(resolve, reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
let newToken: string | null = null;
|
||||
try {
|
||||
newToken = await doRefresh();
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
const queue = refreshQueue;
|
||||
refreshQueue = [];
|
||||
queue.forEach((cb) => cb(newToken));
|
||||
}
|
||||
|
||||
if (!newToken) {
|
||||
redirectToLogin();
|
||||
throw new ApiError('登录已过期,请重新登录', 401);
|
||||
}
|
||||
return request<T>(url, { ...options, retried: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语义化 HTTP 助手:自动处理 method 与 JSON 序列化,减少调用样板。
|
||||
* @example
|
||||
@@ -129,57 +242,20 @@ export const http = {
|
||||
request<T>(url, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建可取消的请求辅助函数
|
||||
*/
|
||||
export function createCancelableRequest() {
|
||||
const controller = new AbortController();
|
||||
|
||||
return {
|
||||
request: async <T>(url: string, options: Omit<RequestOptions, 'signal'> = {}) => {
|
||||
return request<T>(url, { ...options, signal: controller.signal });
|
||||
},
|
||||
cancel: () => controller.abort(),
|
||||
get signal() {
|
||||
return controller.signal;
|
||||
},
|
||||
};
|
||||
/** 列表查询参数(分页) */
|
||||
export interface ListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带自动重试的请求函数(不重试 401/取消/超时)
|
||||
*/
|
||||
export async function requestWithRetry<T>(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
retries = 3,
|
||||
delay = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await request<T>(url, options);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (
|
||||
err.message === 'Unauthorized' ||
|
||||
err.message === 'Request was cancelled' ||
|
||||
err.message.startsWith('Request timeout')
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (i === retries - 1) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)));
|
||||
/** 列表查询参数:统一编码拼接,避免模板字符串拼 query 无转义 */
|
||||
export function buildQuery(params: ListParams): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== '') {
|
||||
search.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
Reference in new issue
Block a user