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
@@ -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 }),
|
||||
};
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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
@@ -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}` : '';
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './host';
|
||||
export * from './storage';
|
||||
export * from './deployment';
|
||||
export * from './collector';
|
||||
export * from './user';
|
||||
@@ -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 }),
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
Reference in new issue
Block a user