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

+6 -9
View File
@@ -11,19 +11,16 @@ export interface LoginResponse {
}
export const authApi = {
// silent:错误密码返回 401 时不触发全局清凭证/跳登录
login: (username: string, password: string) =>
http.post<LoginResponse>('/auth/login', { username, password }),
register: (username: string, password: string, nickname: string, role?: string) =>
http.post<User>('/auth/register', {
username,
password,
nickname,
role: role || 'user',
}),
http.post<LoginResponse>('/auth/login', { username, password }, { silent: true }),
getCurrentUser: () => http.get<User>('/auth/user'),
changePassword: (oldPassword: string, newPassword: string) =>
http.post<{ message: string }>('/auth/password', { oldPassword, newPassword }),
// 后端探活(登录页检测 API 服务是否可达);short timeout + silent
health: () =>
http.get<{ status: string }>('/health', { timeout: 3000, silent: true }),
};
+17 -8
View File
@@ -1,19 +1,28 @@
// 集群域 API
import { http } from '../http';
import { http, buildQuery, type ListParams, type RequestOptions } from '../http';
import type { PageData, Cluster, ClusterInfo } from '@/types';
export interface ClusterCreateInput {
name: string;
description?: string;
configType?: string;
apiServer?: string;
kubeconfig?: string;
token?: string;
context?: string;
}
export type ClusterUpdateInput = Partial<ClusterCreateInput>;
export const clusterApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<Cluster>>(`/clusters?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<Cluster>>(`/clusters${buildQuery(params)}`, options),
get: (id: number) => http.get<Cluster>(`/clusters/${id}`),
create: (data: {
name: string; description?: string; configType?: string;
apiServer?: string; kubeconfig?: string; token?: string; context?: string;
}) => http.post<Cluster>('/clusters', data),
create: (data: ClusterCreateInput) => http.post<Cluster>('/clusters', data),
update: (id: number, data: Record<string, unknown>) =>
update: (id: number, data: ClusterUpdateInput) =>
http.put<{ message: string }>(`/clusters/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/clusters/${id}`),
+3 -3
View File
@@ -1,10 +1,10 @@
// 采集器域 API
import { http } from '../http';
import { http, buildQuery, type ListParams, type RequestOptions } from '../http';
import type { PageData, Collector, CreateCollectorRequest } from '@/types';
export const collectorApi = {
list: (page = 1, pageSize = 100) =>
http.get<PageData<Collector>>(`/collectors?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<Collector>>(`/collectors${buildQuery(params)}`, options),
get: (id: number) => http.get<Collector>(`/collectors/${id}`),
+35 -6
View File
@@ -1,5 +1,5 @@
// Loki 部署域 API
import { http } from '../http';
import { http, buildQuery, type ListParams, type RequestOptions } from '../http';
import type {
PageData,
LokiDeployment,
@@ -8,15 +8,17 @@ import type {
CreateDeploymentRequest,
} from '@/types';
export type DeploymentUpdateInput = Partial<CreateDeploymentRequest>;
export const deploymentApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<LokiDeployment>>(`/deployments?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<LokiDeployment>>(`/deployments${buildQuery(params)}`, options),
get: (id: number) => http.get<LokiDeployment>(`/deployments/${id}`),
create: (data: CreateDeploymentRequest) => http.post<LokiDeployment>('/deployments', data),
update: (id: number, data: Record<string, unknown>) =>
update: (id: number, data: DeploymentUpdateInput) =>
http.put<{ message: string }>(`/deployments/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/deployments/${id}`),
@@ -27,10 +29,37 @@ export const deploymentApi = {
restart: (id: number) => http.post<{ message: string }>(`/deployments/${id}/restart`),
logs: (id: number, page = 1, pageSize = 50) =>
logs: (id: number, params: ListParams = {}) =>
http.get<PageData<DeploymentLog>>(
`/deployments/${id}/logs?page=${page}&pageSize=${pageSize}`
`/deployments/${id}/logs${buildQuery({ page: 1, pageSize: 50, ...params })}`
),
stats: () => http.get<DashboardStats>('/deployments/stats'),
// 部署就绪探测(经自动注册数据源访问 Loki /ready)
ready: (id: number) => http.get<{ ready: boolean; message: string }>(`/deployments/${id}/ready`),
// 支持的 Loki 版本列表(部署表单版本下拉数据源)
versions: () => http.get<{ versions: string[] }>('/deployments/versions'),
// 向导预检:聚合存储/集群/主机/Loki/采集器状态(状态推导式向导数据源)
preflight: () =>
http.get<{
hasStorage: boolean
defaultStorage: string
hasActiveCluster: boolean
clusterNames: string[]
hasActiveHost: boolean
hostNames: string[]
runningLoki: null | {
id: number
name: string
mode: string
endpoint: string
namespace: string
grafana: boolean
}
activeCollectors: number
grafanaReady: boolean
}>('/deployments/preflight'),
};
+21 -9
View File
@@ -1,20 +1,32 @@
// 主机域 API
import { http } from '../http';
import { http, buildQuery, type ListParams, type RequestOptions } from '../http';
import type { PageData, Host, HostInfo } from '@/types';
export interface HostCreateInput {
name: string;
hostIP: string;
port?: number;
user: string;
authType: 'password' | 'key';
password?: string;
privateKey?: string;
keyPhrase?: string;
os?: string;
arch?: string;
labels?: string;
}
export type HostUpdateInput = Partial<HostCreateInput>;
export const hostApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<Host>>(`/hosts?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<Host>>(`/hosts${buildQuery(params)}`, options),
get: (id: number) => http.get<Host>(`/hosts/${id}`),
create: (data: {
name: string; hostIP: string; port?: number; user: string;
authType: 'password' | 'key'; password?: string; privateKey?: string;
keyPhrase?: string; os?: string; arch?: string; labels?: string;
}) => http.post<Host>('/hosts', data),
create: (data: HostCreateInput) => http.post<Host>('/hosts', data),
update: (id: number, data: Record<string, unknown>) =>
update: (id: number, data: HostUpdateInput) =>
http.put<{ message: string }>(`/hosts/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/hosts/${id}`),
+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}` : '';
}
+1
View File
@@ -7,3 +7,4 @@ export * from './host';
export * from './storage';
export * from './deployment';
export * from './collector';
export * from './user';
+55
View File
@@ -0,0 +1,55 @@
// LDAP 域 API — 配置(密码脱敏)/测试/用户预览/批量导入
import { http } from '../http';
export interface LdapConfig {
enabled: boolean
host: string
port: number
useSSL: boolean
bindDN: string
bindPassword: string // 提交时传空 = 保留已存密码;响应恒为空
baseDN: string
userFilter: string
usernameAttr: string
nameAttr: string
emailAttr: string
// 仅响应
hasPassword?: boolean
configured?: boolean
}
export interface LdapTestResult {
success: boolean
message: string
userInfo?: { dn: string; username: string; displayName: string; email: string }
}
export interface LdapUserPreview {
dn: string
username: string
displayName: string
email: string
}
export interface LdapImportResult {
successCount: number
skippedCount: number
updatedCount: number
failedCount: number
failedUsers: Array<{ username: string; error: string }>
}
export const ldapApi = {
getConfig: () => http.get<LdapConfig>('/settings/ldap'),
saveConfig: (data: LdapConfig) => http.put<LdapConfig>('/settings/ldap', data),
// 用表单参数测试(未保存也可测;密码空回退已存)
test: (data: LdapConfig & { testUsername?: string; testPassword?: string }) =>
http.post<LdapTestResult>('/settings/ldap/test', data),
listUsers: (ouDN?: string) => http.post<LdapUserPreview[]>('/settings/ldap/users', { ouDN: ouDN || '' }),
batchImport: (users: Array<{ dn: string; username: string; nickname: string; email: string }>) =>
http.post<LdapImportResult>('/users/batch-import-ldap', { users }),
}
+41 -10
View File
@@ -1,24 +1,55 @@
// 存储域 API
import { http } from '../http';
import { http, buildQuery, type ListParams, type RequestOptions } from '../http';
import type { PageData, StorageConfig } from '@/types';
export interface StorageCreateInput {
name: string;
storageType: 's3' | 'oss' | 'minio' | 'rustfs';
endpoint?: string;
bucket: string;
region?: string;
accessKey?: string;
secretKey?: string;
pathPrefix?: string;
insecure?: boolean;
isDefault?: boolean;
}
export type StorageUpdateInput = Partial<StorageCreateInput>;
export const storageApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<StorageConfig>>(`/storages?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<StorageConfig>>(`/storages${buildQuery(params)}`, options),
get: (id: number) => http.get<StorageConfig>(`/storages/${id}`),
create: (data: {
name: string; storageType: 's3' | 'oss' | 'minio' | 'rustfs';
endpoint?: string; bucket: string; region?: string;
accessKey?: string; secretKey?: string; pathPrefix?: string;
insecure?: boolean; isDefault?: boolean;
}) => http.post<StorageConfig>('/storages', data),
create: (data: StorageCreateInput) => http.post<StorageConfig>('/storages', data),
update: (id: number, data: Record<string, unknown>) =>
update: (id: number, data: StorageUpdateInput) =>
http.put<{ message: string }>(`/storages/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/storages/${id}`),
getDefault: () => http.get<StorageConfig>('/storages/default'),
// 测试连接(不落库):可达性/凭证/桶存在性,createIfMissing 时自动建桶
test: (data: {
storageType: StorageCreateInput['storageType'];
endpoint?: string;
bucket: string;
region?: string;
accessKey?: string;
secretKey?: string;
insecure?: boolean;
createIfMissing?: boolean;
}) =>
http.post<StorageTestResult>('/storages/test', data),
};
export interface StorageTestResult {
reachable: boolean;
authenticated: boolean;
bucketExists: boolean;
bucketCreated: boolean;
message: string;
}
+23
View File
@@ -0,0 +1,23 @@
// 用户管理域 API(全部端点仅 admin 可用,后端 AdminAuth 兜底)
import { http, buildQuery, type ListParams, type RequestOptions } from '../http';
import type { PageData, User } from '@/types';
export interface UserCreateInput {
username: string;
password: string;
nickname?: string;
role?: 'admin' | 'user';
}
export const userApi = {
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<User>>(`/users${buildQuery(params)}`, options),
create: (data: UserCreateInput) => http.post<User>('/users', data),
remove: (id: number) => http.delete<{ message: string }>(`/users/${id}`),
// 管理员重置密码(后端会吊销目标用户全部旧会话)
resetPassword: (id: number, newPassword: string) =>
http.post<{ message: string }>(`/users/${id}/reset-password`, { newPassword }),
};