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:
logan committed 2026-10-24 13:20:12 +08:00
1 parent 327e282356
commit f9c6c1d826
204 files changed
+14107 -9103

No files matched your search

+179 -103
View File
@@ -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}` : '';
}