refactor(frontend): P1-P2 架构统一 — utils/hooks/api 模块化
P1: - utils/storage.ts、theme.ts、validate.ts + index 统一出口 - request/store/useTheme 接入 storage;清零 confirm/alert - PipelineManager、TerminalAuditTab 拆分子模块(<700行红线) P2: - api/monitor、api/deploy 子目录化 - hooks/api 扩展:servers/pipelines/alerts/audit/sessionCommands - PipelineManager、AuditOperationsTab 接入 TanStack Query
This commit is contained in:
1 parent
d19a03d427
commit
f45e203e21
196 files changed
+3228
-2018
No files matched your search
+2
-153
@@ -1,154 +1,3 @@
|
||||
// CI/CD 流水线/发布配置/凭证
|
||||
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
|
||||
// CI/CD API — 子模块 re-export(保持 @/api 导入路径不变) // Logan.Li
|
||||
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
|
||||
export const pipelineApi = {
|
||||
getPipelines: async (params?: { page?: number; page_size?: number }) => {
|
||||
return http.get<ListResponse>('/pipelines', params);
|
||||
},
|
||||
getPipelineDetail: async (id: number | string) => {
|
||||
return http.get(`/pipelines/${id}`);
|
||||
},
|
||||
createPipeline: async ( data: any) => {
|
||||
return http.post('/pipelines', data);
|
||||
},
|
||||
updatePipeline: async (id: number | string, data: any) => {
|
||||
return http.put(`/pipelines/${id}`, data);
|
||||
},
|
||||
deletePipeline: async (id: number | string) => {
|
||||
return http.delete(`/pipelines/${id}`);
|
||||
},
|
||||
triggerPipeline: async (id: number | string) => {
|
||||
return http.post(`/pipelines/${id}/trigger`);
|
||||
},
|
||||
getRuns: async (id: number | string) => {
|
||||
return http.get(`/pipelines/${id}/runs`);
|
||||
},
|
||||
// 运行控制
|
||||
stopRun: async (pipelineId: number | string, runId: number | string) => {
|
||||
return http.post(`/pipelines/${pipelineId}/runs/${runId}/stop`);
|
||||
},
|
||||
getRunLog: async (pipelineId: number | string, runId: number | string) => {
|
||||
return http.get(`/pipelines/${pipelineId}/runs/${runId}/log`);
|
||||
},
|
||||
// 成员管理
|
||||
getMembers: async (pipelineId: number | string) => {
|
||||
return http.get(`/pipelines/${pipelineId}/members`);
|
||||
},
|
||||
addMember: async (pipelineId: number | string, data: { username: string; role: string }) => {
|
||||
return http.post(`/pipelines/${pipelineId}/members`, data);
|
||||
},
|
||||
removeMember: async (pipelineId: number | string, memberId: number | string) => {
|
||||
return http.delete(`/pipelines/${pipelineId}/members/${memberId}`);
|
||||
},
|
||||
// 组授权
|
||||
assignGroup: async (pipelineId: number | string, data: { group_id: number; role: string }) => {
|
||||
return http.post(`/pipelines/${pipelineId}/groups`, data);
|
||||
},
|
||||
// 按 code 操作(列表页/详情页常用) // Logan.Li
|
||||
getByCode: (code: string) => http.get(`/pipelines/${code}`),
|
||||
getRunsByCode: (code: string, pageSize = 50) =>
|
||||
http.get<ListResponse>(`/pipelines/${code}/runs`, { page_size: pageSize }),
|
||||
triggerByCode: (code: string) => http.post(`/pipelines/${code}/trigger`, {}),
|
||||
stopRunByCode: (code: string, runId: number | string) =>
|
||||
http.post(`/pipelines/${code}/runs/${runId}/stop`, {}),
|
||||
batchDeleteRuns: (code: string, ids: number[]) =>
|
||||
http.post(`/pipelines/${code}/runs/batch-delete`, { ids }),
|
||||
getRunLogStream: (runId: number | string) => http.get(`/pipelines/runs/${runId}/log-stream`),
|
||||
getQueue: (code: string) => http.get(`/pipelines/${code}/queue`),
|
||||
removeFromQueue: (code: string, runId: number | string) =>
|
||||
http.delete(`/pipelines/${code}/queue/${runId}`),
|
||||
getWorkspace: (runId: number | string) => http.get(`/pipelines/runs/${runId}/workspace`),
|
||||
listRunFiles: (runId: number | string, path?: string) =>
|
||||
http.get(`/pipelines/runs/${runId}/files`, path ? { path } : undefined),
|
||||
};
|
||||
|
||||
// ==================== 应用发布 API ====================
|
||||
|
||||
export const releaseApi = {
|
||||
getReleases: async (params?: { page?: number; page_size?: number }) => {
|
||||
return http.get<ListResponse>('/releases', params);
|
||||
},
|
||||
createRelease: async ( data: any) => {
|
||||
return http.post('/releases', data);
|
||||
},
|
||||
approveRelease: async (id: number | string) => {
|
||||
return http.post(`/releases/${id}/approve`);
|
||||
},
|
||||
rejectRelease: async (id: number | string) => {
|
||||
return http.post(`/releases/${id}/reject`);
|
||||
},
|
||||
deployRelease: async (id: number | string) => {
|
||||
return http.post(`/releases/${id}/deploy`);
|
||||
},
|
||||
rollbackRelease: async (id: number | string) => {
|
||||
return http.post(`/releases/${id}/rollback`);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 定时任务 API ====================
|
||||
|
||||
export const deployApi = {
|
||||
// Git 仓库认证
|
||||
getRepos: () => http.get<ListResponse>('/deploy/repos'),
|
||||
getRepo: (id: number | string) => http.get(`/deploy/repos/${id}`),
|
||||
createRepo: ( data: any) => http.post('/deploy/repos', data),
|
||||
updateRepo: (id: number | string, data: any) => http.put(`/deploy/repos/${id}`, data),
|
||||
deleteRepo: (id: number | string) => http.delete(`/deploy/repos/${id}`),
|
||||
// 镜像仓库认证
|
||||
getRegistries: () => http.get<ListResponse>('/deploy/registries'),
|
||||
getRegistry: (id: number | string) => http.get(`/deploy/registries/${id}`),
|
||||
createRegistry: ( data: any) => http.post('/deploy/registries', data),
|
||||
updateRegistry: (id: number | string, data: any) => http.put(`/deploy/registries/${id}`, data),
|
||||
deleteRegistry: (id: number | string) => http.delete(`/deploy/registries/${id}`),
|
||||
// 环境管理
|
||||
getEnvironments: () => http.get<ListResponse>('/deploy/environments'),
|
||||
createEnvironment: ( data: any) => http.post('/deploy/environments', data),
|
||||
updateEnvironment: (id: number | string, data: any) => http.put(`/deploy/environments/${id}`, data),
|
||||
deleteEnvironment: (id: number | string) => http.delete(`/deploy/environments/${id}`),
|
||||
// Item Role 权限管理 (Jenkins Role Strategy 风格)
|
||||
getItemRoles: () => http.get<ListResponse>('/admin/item-roles'),
|
||||
createItemRole: ( data: any) => http.post('/admin/item-roles', data),
|
||||
updateItemRole: (id: number | string, data: any) => http.put(`/admin/item-roles/${id}`, data),
|
||||
deleteItemRole: (id: number | string) => http.delete(`/admin/item-roles/${id}`),
|
||||
getUserItemRoles: (userId: number | string) => http.get(`/admin/users/${userId}/item-roles`),
|
||||
assignItemRole: (userId: number | string, data: { user_id: number; item_role_id: number }) => http.post(`/admin/users/${userId}/item-roles`, data),
|
||||
removeItemRole: (userId: number | string, roleId: number | string) => http.delete(`/admin/users/${userId}/item-roles/${roleId}`),
|
||||
// 用户组管理
|
||||
getGroups: () => http.get<ListResponse>('/deploy/groups'),
|
||||
createGroup: ( data: any) => http.post('/deploy/groups', data),
|
||||
updateGroup: (id: number | string, data: any) => http.put(`/deploy/groups/${id}`, data),
|
||||
deleteGroup: (id: number | string) => http.delete(`/deploy/groups/${id}`),
|
||||
getGroupMembers: (id: number | string) => http.get(`/deploy/groups/${id}/members`),
|
||||
addGroupMember: (id: number | string, data: { username: string }) => http.post(`/deploy/groups/${id}/members`, data),
|
||||
removeGroupMember: (id: number | string, memberId: number | string) => http.delete(`/deploy/groups/${id}/members/${memberId}`),
|
||||
// 工具配置 // Logan.Li
|
||||
getTools: () => http.get<ListResponse>('/deploy/tools'),
|
||||
getTool: (id: number | string) => http.get(`/deploy/tools/${id}`),
|
||||
updateTool: (id: number | string, data: any) => http.put(`/deploy/tools/${id}`, data),
|
||||
installTool: (id: number | string, version?: string) =>
|
||||
http.post(`/deploy/tools/${id}/install`, version ? { version } : {}),
|
||||
checkTool: (id: number | string) => http.post(`/deploy/tools/${id}/check`),
|
||||
getToolVersions: (id: number | string) => http.get(`/deploy/tools/${id}/versions`),
|
||||
};
|
||||
|
||||
// ==================== Ansible 自动化 API ====================
|
||||
|
||||
export const credentialApi = {
|
||||
list: (params?: { page?: number; page_size?: number; secret_type?: string; search?: string }) =>
|
||||
http.get<{ list: any[]; total: number }>('/credentials', params),
|
||||
get: (id: number | string) => http.get(`/credentials/${id}`),
|
||||
create: (data: Record<string, unknown>) => http.post('/credentials', data),
|
||||
update: (id: number | string, data: Record<string, unknown>) => http.put(`/credentials/${id}`, data),
|
||||
delete: (id: number | string) => http.delete(`/credentials/${id}`),
|
||||
toggleActive: (id: number | string, isActive: boolean) =>
|
||||
http.put(`/credentials/${id}`, { is_active: isActive }),
|
||||
};
|
||||
|
||||
// 服务器凭据关联 API
|
||||
|
||||
export const serverCredentialApi = {
|
||||
assign: (serverId: number | string, credentialId: number) =>
|
||||
http.post(`/servers/${serverId}/assign-credential`, { credential_id: credentialId }),
|
||||
};
|
||||
export * from './deploy/index';
|
||||
@@ -0,0 +1,47 @@
|
||||
// 部署配置:仓库 / 镜像 / 环境 / 组 / 工具 / Item Role // Logan.Li
|
||||
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
|
||||
export const deployApi = {
|
||||
// Git 仓库认证
|
||||
getRepos: () => http.get<ListResponse>('/deploy/repos'),
|
||||
getRepo: (id: number | string) => http.get(`/deploy/repos/${id}`),
|
||||
createRepo: ( data: any) => http.post('/deploy/repos', data),
|
||||
updateRepo: (id: number | string, data: any) => http.put(`/deploy/repos/${id}`, data),
|
||||
deleteRepo: (id: number | string) => http.delete(`/deploy/repos/${id}`),
|
||||
// 镜像仓库认证
|
||||
getRegistries: () => http.get<ListResponse>('/deploy/registries'),
|
||||
getRegistry: (id: number | string) => http.get(`/deploy/registries/${id}`),
|
||||
createRegistry: ( data: any) => http.post('/deploy/registries', data),
|
||||
updateRegistry: (id: number | string, data: any) => http.put(`/deploy/registries/${id}`, data),
|
||||
deleteRegistry: (id: number | string) => http.delete(`/deploy/registries/${id}`),
|
||||
// 环境管理
|
||||
getEnvironments: () => http.get<ListResponse>('/deploy/environments'),
|
||||
createEnvironment: ( data: any) => http.post('/deploy/environments', data),
|
||||
updateEnvironment: (id: number | string, data: any) => http.put(`/deploy/environments/${id}`, data),
|
||||
deleteEnvironment: (id: number | string) => http.delete(`/deploy/environments/${id}`),
|
||||
// Item Role 权限管理 (Jenkins Role Strategy 风格)
|
||||
getItemRoles: () => http.get<ListResponse>('/admin/item-roles'),
|
||||
createItemRole: ( data: any) => http.post('/admin/item-roles', data),
|
||||
updateItemRole: (id: number | string, data: any) => http.put(`/admin/item-roles/${id}`, data),
|
||||
deleteItemRole: (id: number | string) => http.delete(`/admin/item-roles/${id}`),
|
||||
getUserItemRoles: (userId: number | string) => http.get(`/admin/users/${userId}/item-roles`),
|
||||
assignItemRole: (userId: number | string, data: { user_id: number; item_role_id: number }) => http.post(`/admin/users/${userId}/item-roles`, data),
|
||||
removeItemRole: (userId: number | string, roleId: number | string) => http.delete(`/admin/users/${userId}/item-roles/${roleId}`),
|
||||
// 用户组管理
|
||||
getGroups: () => http.get<ListResponse>('/deploy/groups'),
|
||||
createGroup: ( data: any) => http.post('/deploy/groups', data),
|
||||
updateGroup: (id: number | string, data: any) => http.put(`/deploy/groups/${id}`, data),
|
||||
deleteGroup: (id: number | string) => http.delete(`/deploy/groups/${id}`),
|
||||
getGroupMembers: (id: number | string) => http.get(`/deploy/groups/${id}/members`),
|
||||
addGroupMember: (id: number | string, data: { username: string }) => http.post(`/deploy/groups/${id}/members`, data),
|
||||
removeGroupMember: (id: number | string, memberId: number | string) => http.delete(`/deploy/groups/${id}/members/${memberId}`),
|
||||
// 工具配置
|
||||
getTools: () => http.get<ListResponse>('/deploy/tools'),
|
||||
getTool: (id: number | string) => http.get(`/deploy/tools/${id}`),
|
||||
updateTool: (id: number | string, data: any) => http.put(`/deploy/tools/${id}`, data),
|
||||
installTool: (id: number | string, version?: string) =>
|
||||
http.post(`/deploy/tools/${id}/install`, version ? { version } : {}),
|
||||
checkTool: (id: number | string) => http.post(`/deploy/tools/${id}/check`),
|
||||
getToolVersions: (id: number | string) => http.get(`/deploy/tools/${id}/versions`),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
// 凭据 / 服务器凭据关联 API // Logan.Li
|
||||
|
||||
import { http } from '@/utils/request';
|
||||
|
||||
export const credentialApi = {
|
||||
list: (params?: { page?: number; page_size?: number; secret_type?: string; search?: string }) =>
|
||||
http.get<{ list: any[]; total: number }>('/credentials', params),
|
||||
get: (id: number | string) => http.get(`/credentials/${id}`),
|
||||
create: (data: Record<string, unknown>) => http.post('/credentials', data),
|
||||
update: (id: number | string, data: Record<string, unknown>) => http.put(`/credentials/${id}`, data),
|
||||
delete: (id: number | string) => http.delete(`/credentials/${id}`),
|
||||
toggleActive: (id: number | string, isActive: boolean) =>
|
||||
http.put(`/credentials/${id}`, { is_active: isActive }),
|
||||
};
|
||||
|
||||
export const serverCredentialApi = {
|
||||
assign: (serverId: number | string, credentialId: number) =>
|
||||
http.post(`/servers/${serverId}/assign-credential`, { credential_id: credentialId }),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
// CI/CD API 聚合出口 // Logan.Li
|
||||
|
||||
export { pipelineApi } from './pipeline';
|
||||
export { releaseApi } from './release';
|
||||
export { deployApi } from './config';
|
||||
export { credentialApi, serverCredentialApi } from './credential';
|
||||
@@ -0,0 +1,64 @@
|
||||
// CI/CD 流水线 API // Logan.Li
|
||||
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
|
||||
export const pipelineApi = {
|
||||
getPipelines: async (params?: { page?: number; page_size?: number }) => {
|
||||
return http.get<ListResponse>('/pipelines', params);
|
||||
},
|
||||
getPipelineDetail: async (id: number | string) => {
|
||||
return http.get(`/pipelines/${id}`);
|
||||
},
|
||||
createPipeline: async ( data: any) => {
|
||||
return http.post('/pipelines', data);
|
||||
},
|
||||
updatePipeline: async (id: number | string, data: any) => {
|
||||
return http.put(`/pipelines/${id}`, data);
|
||||
},
|
||||
deletePipeline: async (id: number | string) => {
|
||||
return http.delete(`/pipelines/${id}`);
|
||||
},
|
||||
triggerPipeline: async (id: number | string) => {
|
||||
return http.post(`/pipelines/${id}/trigger`);
|
||||
},
|
||||
getRuns: async (id: number | string) => {
|
||||
return http.get(`/pipelines/${id}/runs`);
|
||||
},
|
||||
// 运行控制
|
||||
stopRun: async (pipelineId: number | string, runId: number | string) => {
|
||||
return http.post(`/pipelines/${pipelineId}/runs/${runId}/stop`);
|
||||
},
|
||||
getRunLog: async (pipelineId: number | string, runId: number | string) => {
|
||||
return http.get(`/pipelines/${pipelineId}/runs/${runId}/log`);
|
||||
},
|
||||
// 成员管理
|
||||
getMembers: async (pipelineId: number | string) => {
|
||||
return http.get(`/pipelines/${pipelineId}/members`);
|
||||
},
|
||||
addMember: async (pipelineId: number | string, data: { username: string; role: string }) => {
|
||||
return http.post(`/pipelines/${pipelineId}/members`, data);
|
||||
},
|
||||
removeMember: async (pipelineId: number | string, memberId: number | string) => {
|
||||
return http.delete(`/pipelines/${pipelineId}/members/${memberId}`);
|
||||
},
|
||||
// 组授权
|
||||
assignGroup: async (pipelineId: number | string, data: { group_id: number; role: string }) => {
|
||||
return http.post(`/pipelines/${pipelineId}/groups`, data);
|
||||
},
|
||||
// 按 code 操作(列表页/详情页常用)
|
||||
getByCode: (code: string) => http.get(`/pipelines/${code}`),
|
||||
getRunsByCode: (code: string, pageSize = 50) =>
|
||||
http.get<ListResponse>(`/pipelines/${code}/runs`, { page_size: pageSize }),
|
||||
triggerByCode: (code: string) => http.post(`/pipelines/${code}/trigger`, {}),
|
||||
stopRunByCode: (code: string, runId: number | string) =>
|
||||
http.post(`/pipelines/${code}/runs/${runId}/stop`, {}),
|
||||
batchDeleteRuns: (code: string, ids: number[]) =>
|
||||
http.post(`/pipelines/${code}/runs/batch-delete`, { ids }),
|
||||
getRunLogStream: (runId: number | string) => http.get(`/pipelines/runs/${runId}/log-stream`),
|
||||
getQueue: (code: string) => http.get(`/pipelines/${code}/queue`),
|
||||
removeFromQueue: (code: string, runId: number | string) =>
|
||||
http.delete(`/pipelines/${code}/queue/${runId}`),
|
||||
getWorkspace: (runId: number | string) => http.get(`/pipelines/runs/${runId}/workspace`),
|
||||
listRunFiles: (runId: number | string, path?: string) =>
|
||||
http.get(`/pipelines/runs/${runId}/files`, path ? { path } : undefined),
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// 应用发布 API // Logan.Li
|
||||
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
|
||||
export const releaseApi = {
|
||||
getReleases: async (params?: { page?: number; page_size?: number }) => {
|
||||
return http.get<ListResponse>('/releases', params);
|
||||
},
|
||||
createRelease: async ( data: any) => {
|
||||
return http.post('/releases', data);
|
||||
},
|
||||
approveRelease: async (id: number | string) => {
|
||||
return http.post(`/releases/${id}/approve`);
|
||||
},
|
||||
rejectRelease: async (id: number | string) => {
|
||||
return http.post(`/releases/${id}/reject`);
|
||||
},
|
||||
deployRelease: async (id: number | string) => {
|
||||
return http.post(`/releases/${id}/deploy`);
|
||||
},
|
||||
rollbackRelease: async (id: number | string) => {
|
||||
return http.post(`/releases/${id}/rollback`);
|
||||
},
|
||||
};
|
||||
+2
-221
@@ -1,222 +1,3 @@
|
||||
// 监控告警 (规则/通知/大屏/Prometheus/Zabbix)
|
||||
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
|
||||
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
|
||||
export const alertApi = {
|
||||
getAlerts: async (params?: { page?: number; page_size?: number; status?: string; severity?: string }) => {
|
||||
return http.get<ListResponse>('/alerts', params);
|
||||
},
|
||||
getRules: async () => {
|
||||
return http.get('/alerts/rules');
|
||||
},
|
||||
createRule: async ( data: any) => {
|
||||
return http.post('/alerts/rules', data);
|
||||
},
|
||||
updateRule: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/rules/${id}`, data);
|
||||
},
|
||||
deleteRule: async (id: number | string) => {
|
||||
return http.delete(`/alerts/rules/${id}`);
|
||||
},
|
||||
batchDeleteRules: async (ids: number[]) => {
|
||||
return http.delete('/alerts/rules/batch-delete', { ids });
|
||||
},
|
||||
toggleRule: async (id: number | string) => {
|
||||
return http.post(`/alerts/rules/${id}/toggle`);
|
||||
},
|
||||
acknowledge: async (id: number | string) => {
|
||||
return http.post(`/alerts/${id}/ack`);
|
||||
},
|
||||
// 通知渠道管理
|
||||
getNotifyChannels: async () => {
|
||||
return http.get('/alerts/notify-channels');
|
||||
},
|
||||
createNotifyChannel: async ( data: any) => {
|
||||
return http.post('/alerts/notify-channels', data);
|
||||
},
|
||||
updateNotifyChannel: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/notify-channels/${id}`, data);
|
||||
},
|
||||
deleteNotifyChannel: async (id: number | string) => {
|
||||
return http.delete(`/alerts/notify-channels/${id}`);
|
||||
},
|
||||
testNotifyChannel: async (id: number | string) => {
|
||||
return http.post(`/alerts/notify-channels/${id}/test`);
|
||||
},
|
||||
// 报警联系人
|
||||
getContacts: async () => {
|
||||
return http.get('/alerts/contacts');
|
||||
},
|
||||
createContact: async ( data: any) => {
|
||||
return http.post('/alerts/contacts', data);
|
||||
},
|
||||
updateContact: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/contacts/${id}`, data);
|
||||
},
|
||||
deleteContact: async (id: number | string) => {
|
||||
return http.delete(`/alerts/contacts/${id}`);
|
||||
},
|
||||
// 报警联系组
|
||||
getContactGroups: async () => {
|
||||
return http.get('/alerts/contact-groups');
|
||||
},
|
||||
createContactGroup: async ( data: any) => {
|
||||
return http.post('/alerts/contact-groups', data);
|
||||
},
|
||||
updateContactGroup: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/contact-groups/${id}`, data);
|
||||
},
|
||||
deleteContactGroup: async (id: number | string) => {
|
||||
return http.delete(`/alerts/contact-groups/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 流水线 API ====================
|
||||
|
||||
export const alertCenterApi = {
|
||||
getAlerts: async (params?: { page?: number; page_size?: number; status?: string; severity?: string }) => {
|
||||
return http.get<ListResponse>('/alerts', params);
|
||||
},
|
||||
getRules: async () => {
|
||||
return http.get('/alerts/rules');
|
||||
},
|
||||
getChannels: async () => {
|
||||
return http.get('/alerts/notify-channels');
|
||||
},
|
||||
acknowledge: async (id: number | string) => {
|
||||
return http.post(`/alerts/${id}/ack`);
|
||||
},
|
||||
resolve: async (id: number | string) => {
|
||||
return http.post(`/alerts/${id}/ack`);
|
||||
},
|
||||
// 告警静默规则 CRUD
|
||||
getSilences: async (params?: { status?: string }) => {
|
||||
return http.get<ListResponse>('/alerts/silences', params);
|
||||
},
|
||||
createSilence: async (data: Record<string, unknown>) => {
|
||||
return http.post('/alerts/silences', data);
|
||||
},
|
||||
updateSilence: async (id: number | string, data: Record<string, unknown>) => {
|
||||
return http.put(`/alerts/silences/${id}`, data);
|
||||
},
|
||||
deleteSilence: async (id: number | string) => {
|
||||
return http.delete(`/alerts/silences/${id}`);
|
||||
},
|
||||
toggleSilence: async (id: number | string) => {
|
||||
return http.post(`/alerts/silences/${id}/toggle`);
|
||||
},
|
||||
toggleRule: async (id: number | string) => {
|
||||
return http.post(`/alerts/rules/${id}/toggle`);
|
||||
},
|
||||
createRule: async ( data: any) => {
|
||||
return http.post('/alerts/rules', data);
|
||||
},
|
||||
deleteRule: async (id: number | string) => {
|
||||
return http.delete(`/alerts/rules/${id}`);
|
||||
},
|
||||
// 一键报警 — 后端批量创建默认规则
|
||||
quickSetup: async (type: string = 'ecs') => {
|
||||
return http.post('/alerts/quick-setup', { type });
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 站点拨测 API ====================
|
||||
|
||||
export const siteApi = {
|
||||
getStats: async () => {
|
||||
return http.get('/site/stats');
|
||||
},
|
||||
getTasks: async () => {
|
||||
return http.get<{ list: any[]; total: number }>('/site/tasks');
|
||||
},
|
||||
createTask: async (data: { name: string; url: string; type: string; frequency_sec?: number; timeout_sec?: number; expected_status_code?: number }) => {
|
||||
return http.post('/site/tasks', data);
|
||||
},
|
||||
deleteTask: async (id: number | string) => {
|
||||
return http.delete(`/site/tasks/${id}`);
|
||||
},
|
||||
toggleTask: async (id: number | string, enabled: boolean) => {
|
||||
return http.put(`/site/tasks/${id}`, { enabled });
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 链路追踪 API(暂无后端,返回空结果优雅降级) ====================
|
||||
|
||||
export const traceApi = {
|
||||
getTraces: async ( _params: any): Promise<{ data: any[]; total: number }> => ({ data: [], total: 0 }),
|
||||
getServices: async (_timeRange: string): Promise<{ data: any[]; total: number }> => ({ data: [], total: 0 }),
|
||||
getTraceDetail: async (_traceId: string): Promise<{ data: { spans: any[] } }> => ({ data: { spans: [] } }),
|
||||
};
|
||||
|
||||
// ==================== Grafana 仪表盘 API(暂无后端,返回空结果优雅降级) ====================
|
||||
|
||||
export const dashboardApi = {
|
||||
getDashboards: async (): Promise<{ data: any[]; total: number }> => ({ data: [], total: 0 }),
|
||||
getDatasources: async (): Promise<{ data: any[]; total: number }> => ({ data: [], total: 0 }),
|
||||
createDashboard: async ( _data: any) => ({ success: false }),
|
||||
updateDashboard: async (_id: number | string, _data: any) => ({ success: false }),
|
||||
deleteDashboard: async (_id: number | string) => ({ success: false }),
|
||||
createDatasource: async ( _data: any) => ({ success: false }),
|
||||
};
|
||||
|
||||
// ==================== Prometheus 数据源管理 API ==================== // Logan.Li
|
||||
|
||||
export const promDatasourceApi = {
|
||||
list: () => http.get('/prometheus-datasources'),
|
||||
get: (id: number | string) => http.get(`/prometheus-datasources/${id}`),
|
||||
create: ( data: any) => http.post('/prometheus-datasources', data),
|
||||
update: (id: number | string, data: any) => http.put(`/prometheus-datasources/${id}`, data),
|
||||
delete: (id: number | string) => http.delete(`/prometheus-datasources/${id}`),
|
||||
setDefault: (id: number | string) => http.post(`/prometheus-datasources/${id}/default`),
|
||||
testConnection: ( data: any) => http.post('/prometheus-datasources/test', data),
|
||||
};
|
||||
|
||||
// ==================== Zabbix 数据源管理 API ==================== // Logan.Li
|
||||
|
||||
export const zabbixDatasourceApi = {
|
||||
list: () => http.get('/zabbix-datasources'),
|
||||
get: (id: number | string) => http.get(`/zabbix-datasources/${id}`),
|
||||
create: ( data: any) => http.post('/zabbix-datasources', data),
|
||||
update: (id: number | string, data: any) => http.put(`/zabbix-datasources/${id}`, data),
|
||||
delete: (id: number | string) => http.delete(`/zabbix-datasources/${id}`),
|
||||
setDefault: (id: number | string) => http.post(`/zabbix-datasources/${id}/default`),
|
||||
testConnection: ( data: any) => http.post('/zabbix-datasources/test', data),
|
||||
};
|
||||
|
||||
// ==================== 指标探索 / 中间件监控 API ==================== // Logan.Li
|
||||
|
||||
export const metricsApi = {
|
||||
getTargets: (params?: Record<string, string | number | boolean | undefined>) =>
|
||||
http.get('/metrics/targets', params),
|
||||
getNames: () => http.get('/metrics/names'),
|
||||
query: (params: {
|
||||
target_id: string | number;
|
||||
metric_name: string;
|
||||
from: string;
|
||||
to: string;
|
||||
limit?: number;
|
||||
}) => http.get('/metrics/query', params),
|
||||
};
|
||||
|
||||
// ==================== SNMP 监控 API ==================== // Logan.Li
|
||||
|
||||
export const snmpApi = {
|
||||
getStats: () => http.get('/snmp/stats'),
|
||||
getDevices: () => http.get('/snmp/devices'),
|
||||
createDevice: (data: Record<string, unknown>) => http.post('/snmp/devices', data),
|
||||
deleteDevice: (id: number | string) => http.delete(`/snmp/devices/${id}`),
|
||||
};
|
||||
|
||||
// ==================== Prometheus / Zabbix 大屏代理 API ==================== // Logan.Li
|
||||
|
||||
export const prometheusProxyApi = {
|
||||
connect: (url: string) => http.post('/prometheus/connect', { url }),
|
||||
dashboard: (url: string, type: string) => http.post('/prometheus/dashboard', { url, type }),
|
||||
};
|
||||
|
||||
export const zabbixProxyApi = {
|
||||
connect: (url: string, username: string, password: string) =>
|
||||
http.post('/zabbix/connect', { url, username, password }),
|
||||
};
|
||||
// 监控告警 API — 子模块 re-export(保持 @/api 导入路径不变) // Logan.Li
|
||||
|
||||
export * from './monitor/index';
|
||||
@@ -0,0 +1,50 @@
|
||||
// 告警中心 / 静默规则 API // Logan.Li
|
||||
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
|
||||
export const alertCenterApi = {
|
||||
getAlerts: async (params?: { page?: number; page_size?: number; status?: string; severity?: string }) => {
|
||||
return http.get<ListResponse>('/alerts', params);
|
||||
},
|
||||
getRules: async () => {
|
||||
return http.get('/alerts/rules');
|
||||
},
|
||||
getChannels: async () => {
|
||||
return http.get('/alerts/notify-channels');
|
||||
},
|
||||
acknowledge: async (id: number | string) => {
|
||||
return http.post(`/alerts/${id}/ack`);
|
||||
},
|
||||
resolve: async (id: number | string) => {
|
||||
return http.post(`/alerts/${id}/ack`);
|
||||
},
|
||||
// 告警静默规则 CRUD
|
||||
getSilences: async (params?: { status?: string }) => {
|
||||
return http.get<ListResponse>('/alerts/silences', params);
|
||||
},
|
||||
createSilence: async (data: Record<string, unknown>) => {
|
||||
return http.post('/alerts/silences', data);
|
||||
},
|
||||
updateSilence: async (id: number | string, data: Record<string, unknown>) => {
|
||||
return http.put(`/alerts/silences/${id}`, data);
|
||||
},
|
||||
deleteSilence: async (id: number | string) => {
|
||||
return http.delete(`/alerts/silences/${id}`);
|
||||
},
|
||||
toggleSilence: async (id: number | string) => {
|
||||
return http.post(`/alerts/silences/${id}/toggle`);
|
||||
},
|
||||
toggleRule: async (id: number | string) => {
|
||||
return http.post(`/alerts/rules/${id}/toggle`);
|
||||
},
|
||||
createRule: async ( data: any) => {
|
||||
return http.post('/alerts/rules', data);
|
||||
},
|
||||
deleteRule: async (id: number | string) => {
|
||||
return http.delete(`/alerts/rules/${id}`);
|
||||
},
|
||||
// 一键报警 — 后端批量创建默认规则
|
||||
quickSetup: async (type: string = 'ecs') => {
|
||||
return http.post('/alerts/quick-setup', { type });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
// 告警规则 / 通知渠道 / 联系人 API // Logan.Li
|
||||
|
||||
import { http, type ListResponse } from '@/utils/request';
|
||||
|
||||
export const alertApi = {
|
||||
getAlerts: async (params?: { page?: number; page_size?: number; status?: string; severity?: string }) => {
|
||||
return http.get<ListResponse>('/alerts', params);
|
||||
},
|
||||
getRules: async () => {
|
||||
return http.get('/alerts/rules');
|
||||
},
|
||||
createRule: async ( data: any) => {
|
||||
return http.post('/alerts/rules', data);
|
||||
},
|
||||
updateRule: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/rules/${id}`, data);
|
||||
},
|
||||
deleteRule: async (id: number | string) => {
|
||||
return http.delete(`/alerts/rules/${id}`);
|
||||
},
|
||||
batchDeleteRules: async (ids: number[]) => {
|
||||
return http.delete('/alerts/rules/batch-delete', { ids });
|
||||
},
|
||||
toggleRule: async (id: number | string) => {
|
||||
return http.post(`/alerts/rules/${id}/toggle`);
|
||||
},
|
||||
acknowledge: async (id: number | string) => {
|
||||
return http.post(`/alerts/${id}/ack`);
|
||||
},
|
||||
// 通知渠道管理
|
||||
getNotifyChannels: async () => {
|
||||
return http.get('/alerts/notify-channels');
|
||||
},
|
||||
createNotifyChannel: async ( data: any) => {
|
||||
return http.post('/alerts/notify-channels', data);
|
||||
},
|
||||
updateNotifyChannel: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/notify-channels/${id}`, data);
|
||||
},
|
||||
deleteNotifyChannel: async (id: number | string) => {
|
||||
return http.delete(`/alerts/notify-channels/${id}`);
|
||||
},
|
||||
testNotifyChannel: async (id: number | string) => {
|
||||
return http.post(`/alerts/notify-channels/${id}/test`);
|
||||
},
|
||||
// 报警联系人
|
||||
getContacts: async () => {
|
||||
return http.get('/alerts/contacts');
|
||||
},
|
||||
createContact: async ( data: any) => {
|
||||
return http.post('/alerts/contacts', data);
|
||||
},
|
||||
updateContact: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/contacts/${id}`, data);
|
||||
},
|
||||
deleteContact: async (id: number | string) => {
|
||||
return http.delete(`/alerts/contacts/${id}`);
|
||||
},
|
||||
// 报警联系组
|
||||
getContactGroups: async () => {
|
||||
return http.get('/alerts/contact-groups');
|
||||
},
|
||||
createContactGroup: async ( data: any) => {
|
||||
return http.post('/alerts/contact-groups', data);
|
||||
},
|
||||
updateContactGroup: async (id: number | string, data: any) => {
|
||||
return http.put(`/alerts/contact-groups/${id}`, data);
|
||||
},
|
||||
deleteContactGroup: async (id: number | string) => {
|
||||
return http.delete(`/alerts/contact-groups/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
// Prometheus / Zabbix 数据源管理 API // Logan.Li
|
||||
|
||||
import { http } from '@/utils/request';
|
||||
|
||||
export const promDatasourceApi = {
|
||||
list: () => http.get('/prometheus-datasources'),
|
||||
get: (id: number | string) => http.get(`/prometheus-datasources/${id}`),
|
||||
create: ( data: any) => http.post('/prometheus-datasources', data),
|
||||
update: (id: number | string, data: any) => http.put(`/prometheus-datasources/${id}`, data),
|
||||
delete: (id: number | string) => http.delete(`/prometheus-datasources/${id}`),
|
||||
setDefault: (id: number | string) => http.post(`/prometheus-datasources/${id}/default`),
|
||||
testConnection: ( data: any) => http.post('/prometheus-datasources/test', data),
|
||||
};
|
||||
|
||||
export const zabbixDatasourceApi = {
|
||||
list: () => http.get('/zabbix-datasources'),
|
||||
get: (id: number | string) => http.get(`/zabbix-datasources/${id}`),
|
||||
create: ( data: any) => http.post('/zabbix-datasources', data),
|
||||
update: (id: number | string, data: any) => http.put(`/zabbix-datasources/${id}`, data),
|
||||
delete: (id: number | string) => http.delete(`/zabbix-datasources/${id}`),
|
||||
setDefault: (id: number | string) => http.post(`/zabbix-datasources/${id}/default`),
|
||||
testConnection: ( data: any) => http.post('/zabbix-datasources/test', data),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// Grafana 仪表盘 API(暂无后端,返回空结果优雅降级) // Logan.Li
|
||||
|
||||
export const dashboardApi = {
|
||||
getDashboards: async (): Promise<{ data: any[]; total: number }> => ({ data: [], total: 0 }),
|
||||
getDatasources: async (): Promise<{ data: any[]; total: number }> => ({ data: [], total: 0 }),
|
||||
createDashboard: async ( _data: any) => ({ success: false }),
|
||||
updateDashboard: async (_id: number | string, _data: any) => ({ success: false }),
|
||||
deleteDashboard: async (_id: number | string) => ({ success: false }),
|
||||
createDatasource: async ( _data: any) => ({ success: false }),
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// 监控告警 API 聚合出口 // Logan.Li
|
||||
|
||||
export { alertApi } from './alert';
|
||||
export { alertCenterApi } from './alert-center';
|
||||
export { siteApi } from './site';
|
||||
export { traceApi } from './trace';
|
||||
export { dashboardApi } from './grafana';
|
||||
export { promDatasourceApi, zabbixDatasourceApi } from './datasource';
|
||||
export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi } from './metrics';
|
||||
@@ -0,0 +1,33 @@
|
||||
// 指标探索 / SNMP / 大屏代理 API // Logan.Li
|
||||
|
||||
import { http } from '@/utils/request';
|
||||
|
||||
export const metricsApi = {
|
||||
getTargets: (params?: Record<string, string | number | boolean | undefined>) =>
|
||||
http.get('/metrics/targets', params),
|
||||
getNames: () => http.get('/metrics/names'),
|
||||
query: (params: {
|
||||
target_id: string | number;
|
||||
metric_name: string;
|
||||
from: string;
|
||||
to: string;
|
||||
limit?: number;
|
||||
}) => http.get('/metrics/query', params),
|
||||
};
|
||||
|
||||
export const snmpApi = {
|
||||
getStats: () => http.get('/snmp/stats'),
|
||||
getDevices: () => http.get('/snmp/devices'),
|
||||
createDevice: (data: Record<string, unknown>) => http.post('/snmp/devices', data),
|
||||
deleteDevice: (id: number | string) => http.delete(`/snmp/devices/${id}`),
|
||||
};
|
||||
|
||||
export const prometheusProxyApi = {
|
||||
connect: (url: string) => http.post('/prometheus/connect', { url }),
|
||||
dashboard: (url: string, type: string) => http.post('/prometheus/dashboard', { url, type }),
|
||||
};
|
||||
|
||||
export const zabbixProxyApi = {
|
||||
connect: (url: string, username: string, password: string) =>
|
||||
http.post('/zabbix/connect', { url, username, password }),
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
// 站点拨测 API // Logan.Li
|
||||
|
||||
import { http } from '@/utils/request';
|
||||
|
||||
export const siteApi = {
|
||||
getStats: async () => {
|
||||
return http.get('/site/stats');
|
||||
},
|
||||
getTasks: async () => {
|
||||
return http.get<{ list: any[]; total: number }>('/site/tasks');
|
||||
},
|
||||
createTask: async (data: { name: string; url: string; type: string; frequency_sec?: number; timeout_sec?: number; expected_status_code?: number }) => {
|
||||
return http.post('/site/tasks', data);
|
||||
},
|
||||
deleteTask: async (id: number | string) => {
|
||||
return http.delete(`/site/tasks/${id}`);
|
||||
},
|
||||
toggleTask: async (id: number | string, enabled: boolean) => {
|
||||
return http.put(`/site/tasks/${id}`, { enabled });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// 链路追踪 API(暂无后端,返回空结果优雅降级) // Logan.Li
|
||||
|
||||
export const traceApi = {
|
||||
getTraces: async ( _params: any): Promise<{ data: any[]; total: number }> => ({ data: [], total: 0 }),
|
||||
getServices: async (_timeRange: string): Promise<{ data: any[]; total: number }> => ({ data: [], total: 0 }),
|
||||
getTraceDetail: async (_traceId: string): Promise<{ data: { spans: any[] } }> => ({ data: { spans: [] } }),
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,230 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Play,
|
||||
GitBranch,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
StopCircle,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Container,
|
||||
Globe,
|
||||
History,
|
||||
Server,
|
||||
Network,
|
||||
ArrowRight,
|
||||
Package,
|
||||
ChevronRight as ChevronRightSmall,
|
||||
} from 'lucide-react';
|
||||
import { getStatusBadge } from '../pipeline-helpers';
|
||||
import { STAGE_DEFS, fmtDuration, parseStages } from './constants';
|
||||
import { PipelineRunPanel } from './PipelineRunPanel';
|
||||
|
||||
interface PipelineCardProps {
|
||||
pipeline: Record<string, unknown>;
|
||||
isExpanded: boolean;
|
||||
isLoadingRuns: boolean;
|
||||
pipelineRuns: Record<string, unknown>[];
|
||||
onToggleExpand: () => void;
|
||||
onTrigger: () => void;
|
||||
onStopRunning: () => void;
|
||||
onDelete: () => void;
|
||||
onStopRun: (runId: number | string) => void;
|
||||
}
|
||||
|
||||
// 单条流水线卡片(含展开/折叠) // Logan.Li
|
||||
export function PipelineCard({
|
||||
pipeline,
|
||||
isExpanded,
|
||||
isLoadingRuns,
|
||||
pipelineRuns,
|
||||
onToggleExpand,
|
||||
onTrigger,
|
||||
onStopRunning,
|
||||
onDelete,
|
||||
onStopRun,
|
||||
}: PipelineCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const stages = parseStages(pipeline.stages);
|
||||
const repo = pipeline.repo as Record<string, unknown> | undefined;
|
||||
const registry = pipeline.registry as Record<string, unknown> | undefined;
|
||||
const environment = pipeline.environment as Record<string, unknown> | undefined;
|
||||
const code = (pipeline.code || pipeline.id) as string;
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-[var(--dg-text-primary)]">{String(pipeline.name)}</h3>
|
||||
{(pipeline.run_count as number) > 0 && (
|
||||
<Badge variant="outline" className="text-[11px] text-[var(--dg-text-secondary)] border-[var(--dg-border)]">
|
||||
#{String(pipeline.run_count)}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusBadge(pipeline.last_status)}
|
||||
{environment && (
|
||||
<Badge
|
||||
style={{ backgroundColor: `${environment.color}20`, color: environment.color as string }}
|
||||
className="text-xs"
|
||||
>
|
||||
{String(environment.display_name)}
|
||||
</Badge>
|
||||
)}
|
||||
{pipeline.job_name && (
|
||||
<Badge variant="secondary" className="text-xs font-mono opacity-60">{String(pipeline.job_name)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-1 line-clamp-1">
|
||||
{String(pipeline.description || '暂无描述')}
|
||||
</p>
|
||||
{/* 最近运行摘要行:branch + commit → image:tag // Logan.Li */}
|
||||
{(pipeline.last_run_branch || pipeline.last_commit_id || pipeline.last_image_tag) && (
|
||||
<div className="flex items-center gap-1 mt-1.5 text-xs font-mono">
|
||||
{pipeline.last_run_branch && (
|
||||
<span className="flex items-center gap-0.5 text-indigo-400">
|
||||
<GitBranch className="h-3 w-3" />{String(pipeline.last_run_branch)}
|
||||
</span>
|
||||
)}
|
||||
{pipeline.last_run_branch && pipeline.last_commit_id && (
|
||||
<span className="text-[var(--dg-text-secondary)] opacity-50 text-[10px] mx-0.5">+</span>
|
||||
)}
|
||||
{pipeline.last_commit_id && (
|
||||
<code className="text-amber-400 bg-amber-400/10 px-1 py-0.5 rounded text-[11px]">
|
||||
{String(pipeline.last_commit_id).slice(0, 8)}
|
||||
</code>
|
||||
)}
|
||||
{(pipeline.last_run_branch || pipeline.last_commit_id) && pipeline.last_image_tag && (
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-40 mx-0.5" />
|
||||
)}
|
||||
{pipeline.last_image_tag && (
|
||||
<span className="flex items-center gap-0.5 text-cyan-400">
|
||||
<Package className="h-3 w-3" />
|
||||
<code className="bg-cyan-400/10 px-1 py-0.5 rounded text-[11px]">{String(pipeline.last_image_tag)}</code>
|
||||
</span>
|
||||
)}
|
||||
{(pipeline.last_duration as number) > 0 && (
|
||||
<span className="flex items-center gap-0.5 ml-auto text-[var(--dg-text-secondary)] opacity-50">
|
||||
<Clock className="h-3 w-3" />{fmtDuration(pipeline.last_duration as number)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-[var(--dg-text-secondary)] opacity-60">
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{pipeline.deploy_mode === 'tag' ? 'Tag发版' : String(pipeline.branch || 'main')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{(pipeline.deploy_target || 'ssh') === 'k8s' ? (
|
||||
<><Network className="h-3 w-3" />Kubernetes</>
|
||||
) : (
|
||||
<><Server className="h-3 w-3" />SSH 主机</>
|
||||
)}
|
||||
</span>
|
||||
{repo && <span className="flex items-center gap-1"><Globe className="h-3 w-3" />{String(repo.name)}</span>}
|
||||
{registry && <span className="flex items-center gap-1"><Container className="h-3 w-3" />{String(registry.name)}</span>}
|
||||
{pipeline.last_run_at && (
|
||||
<span><Clock className="h-3 w-3 inline mr-0.5" />{new Date(pipeline.last_run_at as string).toLocaleString('zh-CN')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-4 shrink-0">
|
||||
{pipeline.last_status === 'running' ? (
|
||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStopRunning}>
|
||||
<StopCircle className="h-3.5 w-3.5 mr-1" />停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" className="h-8" onClick={onTrigger}>
|
||||
<Play className="h-3.5 w-3.5 mr-1" />执行
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}`)}>编辑</Button>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}/runs`)}>
|
||||
<History className="h-3.5 w-3.5 mr-1" />记录
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={onToggleExpand}>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={onDelete}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stages.length > 0 && (
|
||||
<div className="flex items-center mt-2.5">
|
||||
{stages.map((s, i) => {
|
||||
const def = STAGE_DEFS.find((d) => d.label === s.name);
|
||||
// 从 last_run_stages 获取最新运行状态 // Logan.Li
|
||||
let runStatus = '';
|
||||
if (pipeline.last_run_stages) {
|
||||
try {
|
||||
const rs = typeof pipeline.last_run_stages === 'string'
|
||||
? JSON.parse(pipeline.last_run_stages as string)
|
||||
: pipeline.last_run_stages;
|
||||
const match = Array.isArray(rs)
|
||||
? rs.find((r: Record<string, unknown>) => r.name === s.name || r.type === def?.key)
|
||||
: null;
|
||||
runStatus = String(match?.status || '');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const isSuccess = runStatus === 'success' || runStatus === 'completed';
|
||||
const isRunning = runStatus === 'running' || runStatus === 'in_progress';
|
||||
const isFailed = runStatus === 'failed' || runStatus === 'error';
|
||||
const color = isFailed ? '#EF4444' : isRunning ? '#3B82F6' : isSuccess ? '#10B981' : '#6B7280';
|
||||
const bgColor = isFailed ? '#EF444428' : isRunning ? '#3B82F628' : isSuccess ? '#10B98128' : '#6B728018';
|
||||
const icon = isFailed ? (
|
||||
<XCircle className="h-3 w-3" />
|
||||
) : isRunning ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : isSuccess ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<span className="text-[10px] opacity-40">○</span>
|
||||
);
|
||||
return (
|
||||
<div key={i} className="flex items-center">
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-semibold border shadow-sm"
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
borderColor: `${color}70`,
|
||||
color,
|
||||
borderRadius: i === 0 ? '8px 0 0 8px' : i === stages.length - 1 ? '0 8px 8px 0' : '0',
|
||||
}}
|
||||
>
|
||||
{icon}{String(s.name)}
|
||||
</div>
|
||||
{i < stages.length - 1 && (
|
||||
<div className="flex items-center justify-center w-5 h-5 -mx-px z-10">
|
||||
<ChevronRightSmall className="h-3.5 w-3.5" style={{ color: `${color}CC` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<PipelineRunPanel
|
||||
pipeline={pipeline}
|
||||
pipelineRuns={pipelineRuns}
|
||||
isLoadingRuns={isLoadingRuns}
|
||||
onStopRun={onStopRun}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Rocket,
|
||||
Plus,
|
||||
GitBranch,
|
||||
Tag,
|
||||
Server,
|
||||
Network,
|
||||
Globe,
|
||||
Container,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ChevronRight as ChevronRightSmall,
|
||||
} from 'lucide-react';
|
||||
import { credentialApi, deployApi, pipelineApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { nameToCode } from '../pipeline-helpers';
|
||||
import { STAGE_DEFS } from './constants';
|
||||
|
||||
interface PipelineCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
// 新建流水线对话框(含表单状态) // Logan.Li
|
||||
export function PipelineCreateDialog({ open, onOpenChange, onCreated }: PipelineCreateDialogProps) {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newJobName, setNewJobName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newSource, setNewSource] = useState('');
|
||||
const [newBranch, setNewBranch] = useState('main');
|
||||
const [newDockerfile, setNewDockerfile] = useState('');
|
||||
const [selectedStages, setSelectedStages] = useState<string[]>(['checkout', 'build', 'image', 'deploy']);
|
||||
const [newDeployMode, setNewDeployMode] = useState('branch');
|
||||
const [newDeployTarget, setNewDeployTarget] = useState('ssh');
|
||||
const [newEnvironmentId, setNewEnvironmentId] = useState<number | string>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [gitCredentials, setGitCredentials] = useState<Record<string, unknown>[]>([]);
|
||||
const [dockerCredentials, setDockerCredentials] = useState<Record<string, unknown>[]>([]);
|
||||
const [newGitCredentialId, setNewGitCredentialId] = useState<number | string>('');
|
||||
const [newRegistryCredentialId, setNewRegistryCredentialId] = useState<number | string>('');
|
||||
|
||||
const [newTriggerMode, setNewTriggerMode] = useState('manual');
|
||||
const [newWatchBranches, setNewWatchBranches] = useState('');
|
||||
const [newExcludeBranch, setNewExcludeBranch] = useState('');
|
||||
const [newCronExpr, setNewCronExpr] = useState('');
|
||||
const [newWebhookToken, setNewWebhookToken] = useState('');
|
||||
const [newEnvVars, setNewEnvVars] = useState<{ key: string; value: string; desc: string }[]>([]);
|
||||
const [newCachePaths, setNewCachePaths] = useState('');
|
||||
const [newMaxExecutors, setNewMaxExecutors] = useState(2);
|
||||
const [environments, setEnvironments] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const loadRefs = useCallback(async () => {
|
||||
try {
|
||||
const [rEnv, rCred] = await Promise.all([
|
||||
deployApi.getEnvironments().catch(() => ({ list: [] })),
|
||||
credentialApi.list({ page_size: 100 }).catch(() => ({ list: [] })),
|
||||
] as const);
|
||||
setEnvironments(Array.isArray((rEnv as Record<string, unknown>)?.list) ? (rEnv as Record<string, unknown>).list as Record<string, unknown>[] : []);
|
||||
const allCreds: Record<string, unknown>[] = (rCred as Record<string, unknown>)?.list as Record<string, unknown>[] || [];
|
||||
setGitCredentials(allCreds.filter((c) => c.secret_type === 'api'));
|
||||
setDockerCredentials(allCreds.filter((c) => c.secret_type === 'docker'));
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadRefs();
|
||||
}, [open, loadRefs]);
|
||||
|
||||
const resetForm = () => {
|
||||
setNewName('');
|
||||
setNewJobName('');
|
||||
setNewDesc('');
|
||||
setNewSource('');
|
||||
setNewBranch('main');
|
||||
setNewDockerfile('');
|
||||
setSelectedStages(['checkout', 'build', 'image', 'deploy']);
|
||||
setNewDeployMode('branch');
|
||||
setNewDeployTarget('ssh');
|
||||
setNewEnvironmentId('');
|
||||
setNewGitCredentialId('');
|
||||
setNewRegistryCredentialId('');
|
||||
setNewTriggerMode('manual');
|
||||
setNewWatchBranches('');
|
||||
setNewExcludeBranch('');
|
||||
setNewCronExpr('');
|
||||
setNewWebhookToken('');
|
||||
setNewEnvVars([]);
|
||||
setNewCachePaths('');
|
||||
setNewMaxExecutors(2);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) { toast.error('请输入流水线名称'); return; }
|
||||
if (selectedStages.includes('checkout') && !newSource.trim()) { toast.error('代码检出阶段需要填写 Git URL'); return; }
|
||||
if (selectedStages.includes('image') && !newRegistryCredentialId) { toast.error('镜像打包阶段需要关联 Docker 凭证'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const stageNames = selectedStages.map((k) => STAGE_DEFS.find((s) => s.key === k)?.label || k);
|
||||
const stages = JSON.stringify(stageNames.map((name, i) => ({
|
||||
id: `stage-${i + 1}`,
|
||||
name,
|
||||
type: selectedStages[i] || 'custom',
|
||||
tasks: [],
|
||||
})));
|
||||
await pipelineApi.createPipeline({
|
||||
name: newName,
|
||||
code: nameToCode(newName),
|
||||
job_name: newJobName || undefined,
|
||||
description: newDesc,
|
||||
source: newSource,
|
||||
branch: newBranch,
|
||||
dockerfile: newDockerfile,
|
||||
type: 'deploy',
|
||||
stages,
|
||||
deploy_mode: newDeployMode,
|
||||
deploy_target: newDeployTarget,
|
||||
environment_id: newEnvironmentId ? Number(newEnvironmentId) : null,
|
||||
repo_id: null,
|
||||
registry_id: null,
|
||||
git_credential_id: newGitCredentialId ? Number(newGitCredentialId) : null,
|
||||
registry_credential_id: newRegistryCredentialId ? Number(newRegistryCredentialId) : null,
|
||||
trigger_mode: newTriggerMode,
|
||||
watch_branches: newWatchBranches || undefined,
|
||||
exclude_branch: newExcludeBranch || undefined,
|
||||
cron_expr: newCronExpr || undefined,
|
||||
webhook_token: newWebhookToken || undefined,
|
||||
variables: newEnvVars.length > 0 ? JSON.stringify(newEnvVars) : undefined,
|
||||
cache_paths: newCachePaths || undefined,
|
||||
max_executors: newMaxExecutors,
|
||||
});
|
||||
toast.success('流水线已创建');
|
||||
onOpenChange(false);
|
||||
resetForm();
|
||||
onCreated();
|
||||
} catch {
|
||||
toast.error('创建失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Rocket className="h-5 w-5" />新建流水线</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>名称 <span className="text-red-400">*</span></Label>
|
||||
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="例如: demo-jar 后端部署" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Job 名称 <span className="text-xs text-[var(--dg-text-secondary)]">(Webhook URL)</span></Label>
|
||||
<Input value={newJobName} onChange={(e) => setNewJobName(e.target.value)} placeholder="cex-admin-server" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-sm" />
|
||||
{newJobName && <p className="text-[10px] text-[var(--dg-text-secondary)] opacity-60">Webhook: POST .../webhook/job/<b>{newJobName}</b></p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>描述</Label>
|
||||
<Textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="流水线描述..." rows={2} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>部署模式</Label>
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
|
||||
{[
|
||||
{ key: 'branch', label: '分支', icon: GitBranch },
|
||||
{ key: 'tag', label: 'Tag 发版', icon: Tag },
|
||||
].map((m) => {
|
||||
const active = newDeployMode === m.key;
|
||||
return (
|
||||
<button
|
||||
key={m.key}
|
||||
onClick={() => setNewDeployMode(m.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
active ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<m.icon className="h-3.5 w-3.5" />{m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>部署目标</Label>
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
|
||||
{[
|
||||
{ key: 'ssh', label: 'SSH 主机', icon: Server },
|
||||
{ key: 'k8s', label: 'Kubernetes 集群', icon: Network },
|
||||
].map((t) => {
|
||||
const active = newDeployTarget === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setNewDeployTarget(t.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
active ? 'bg-green-500 text-white' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="h-3.5 w-3.5" />{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>环境分组</Label>
|
||||
<Select value={String(newEnvironmentId)} onValueChange={(v) => setNewEnvironmentId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择环境" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不指定</SelectItem>
|
||||
{environments.map((env) => (
|
||||
<SelectItem key={env.id as number} value={String(env.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: env.color as string }} />
|
||||
{String(env.display_name)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Git URL {selectedStages.includes('checkout') && <span className="text-red-400">*</span>}</Label>
|
||||
<Input value={newSource} onChange={(e) => setNewSource(e.target.value)} placeholder="https://gitee.com/user/repo.git" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-xs" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>分支 / Tag</Label>
|
||||
<Input value={newBranch} onChange={(e) => setNewBranch(e.target.value)} placeholder={newDeployMode === 'tag' ? 'v1.0.0' : 'main'} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Git 凭证 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(API Token)</span></Label>
|
||||
<Select value={String(newGitCredentialId)} onValueChange={(v) => setNewGitCredentialId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择凭证" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不关联</SelectItem>
|
||||
{gitCredentials.map((c) => (
|
||||
<SelectItem key={c.id as number} value={String(c.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="h-3 w-3 opacity-60" />{String(c.name)} {c.summary && <span className="text-[10px] opacity-50">({String(c.summary)})</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!newGitCredentialId && selectedStages.includes('checkout') && <p className="text-xs text-amber-400">建议关联凭证</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>镜像仓库凭证 {selectedStages.includes('image') && <span className="text-red-400">*</span>}</Label>
|
||||
<Select value={String(newRegistryCredentialId)} onValueChange={(v) => setNewRegistryCredentialId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择 Docker 凭证" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不关联</SelectItem>
|
||||
{dockerCredentials.map((c) => (
|
||||
<SelectItem key={c.id as number} value={String(c.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Container className="h-3 w-3 opacity-60" />{String(c.name)} {c.summary && <span className="text-[10px] opacity-50">({String(c.summary)})</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!newRegistryCredentialId && selectedStages.includes('image') && <p className="text-xs text-amber-400">需要关联 Docker 凭证</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newRegistryCredentialId && newJobName && (
|
||||
<div className="p-2.5 rounded-md bg-blue-500/5 border border-blue-500/20 text-xs text-[var(--dg-text-secondary)]">
|
||||
<span className="text-blue-400">镜像命名:</span>
|
||||
<code className="text-[var(--dg-text-primary)]">{'{registry}'}/{'{namespace}'}/{'{'}{'{JOB_BASE_NAME}'}{'}'}:{'{'}{'{tag}'}{'}'}</code>
|
||||
<span className="ml-2 opacity-60">例:</span>
|
||||
<code className="opacity-60">harbor.ast1001.com/test/{newJobName}:latest</code>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label>Dockerfile 路径</Label>
|
||||
<Input value={newDockerfile} onChange={(e) => setNewDockerfile(e.target.value)} placeholder="jar/Dockerfile" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>发布流程 <span className="text-xs text-[var(--dg-text-secondary)] opacity-70 ml-1">按顺序选择阶段</span></Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STAGE_DEFS.map((def) => {
|
||||
const active = selectedStages.includes(def.key);
|
||||
return (
|
||||
<Badge
|
||||
key={def.key}
|
||||
className={`cursor-pointer transition-all text-xs px-3 py-1.5 ${active ? 'border-current' : 'border-[var(--dg-border)] opacity-50 hover:opacity-80'}`}
|
||||
style={active ? { backgroundColor: `${def.color}20`, color: def.color, borderColor: def.color } : {}}
|
||||
onClick={() => setSelectedStages(active ? selectedStages.filter((t) => t !== def.key) : [...selectedStages, def.key])}
|
||||
title={def.desc}
|
||||
>
|
||||
<def.icon className="h-3 w-3 mr-1" />{def.label} {def.needs.includes('repo') ? '🔗' : ''}{def.needs.includes('registry') ? '📦' : ''}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedStages.length > 0 && (
|
||||
<div className="flex items-center mt-2">
|
||||
{selectedStages.map((key, i) => {
|
||||
const def = STAGE_DEFS.find((t) => t.key === key)!;
|
||||
return (
|
||||
<div key={i} className="flex items-center">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium border"
|
||||
style={{
|
||||
backgroundColor: `${def.color}20`,
|
||||
borderColor: `${def.color}40`,
|
||||
color: def.color,
|
||||
borderRadius: i === 0 ? '6px 0 0 6px' : i === selectedStages.length - 1 ? '0 6px 6px 0' : '0',
|
||||
}}
|
||||
>
|
||||
<def.icon className="h-3 w-3" />{def.label}
|
||||
</div>
|
||||
{i < selectedStages.length - 1 && (
|
||||
<div className="flex items-center justify-center w-4 h-5 -mx-px z-10">
|
||||
<ChevronRightSmall className="h-3 w-3" style={{ color: def.color }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<Label className="mb-2 block">触发方式</Label>
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
|
||||
{[
|
||||
{ key: 'manual', label: '手动', desc: '手动触发执行' },
|
||||
{ key: 'git_push', label: '代码推送', desc: 'Git Push 自动触发' },
|
||||
{ key: 'cron', label: '定时', desc: 'Cron 定时执行' },
|
||||
{ key: 'webhook', label: 'Webhook', desc: '外部 Webhook 调用' },
|
||||
].map((t) => {
|
||||
const active = newTriggerMode === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setNewTriggerMode(t.key)}
|
||||
className={`flex-1 flex flex-col items-center gap-0.5 px-2 py-2 rounded-md text-xs font-medium transition-all ${
|
||||
active ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
title={t.desc}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{newTriggerMode === 'git_push' && (
|
||||
<div className="mt-3 space-y-2 p-3 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">监听分支 <span className="text-[var(--dg-text-secondary)] opacity-60">(逗号分隔,支持 * 通配)</span></Label>
|
||||
<Input value={newWatchBranches} onChange={(e) => setNewWatchBranches(e.target.value)} placeholder="release/*, main" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">排除分支 <span className="text-[var(--dg-text-secondary)] opacity-60">(不触发构建)</span></Label>
|
||||
<Input value={newExcludeBranch} onChange={(e) => setNewExcludeBranch(e.target.value)} placeholder="wip/*, draft/*" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newTriggerMode === 'cron' && (
|
||||
<div className="mt-3 space-y-2 p-3 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Cron 表达式 <span className="text-[var(--dg-text-secondary)] opacity-60">(标准 5 位)</span></Label>
|
||||
<Input value={newCronExpr} onChange={(e) => setNewCronExpr(e.target.value)} placeholder="0 2 * * *" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
{ expr: '*/5 * * * *', label: '每5分钟' },
|
||||
{ expr: '0 * * * *', label: '每小时' },
|
||||
{ expr: '0 2 * * *', label: '每天2点' },
|
||||
{ expr: '0 0 * * 0', label: '每周日' },
|
||||
].map((c) => (
|
||||
<Badge key={c.expr} variant="outline" className="cursor-pointer text-[10px] hover:bg-blue-500/10" onClick={() => setNewCronExpr(c.expr)}>
|
||||
{c.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newTriggerMode === 'webhook' && (
|
||||
<div className="mt-3 space-y-2 p-3 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Webhook Token <span className="text-[var(--dg-text-secondary)] opacity-60">(签名验证,可选)</span></Label>
|
||||
<Input value={newWebhookToken} onChange={(e) => setNewWebhookToken(e.target.value)} placeholder="随机字符串" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-60">
|
||||
Webhook URL: <code className="text-[var(--dg-blue)]">POST /api/v1/webhook/pipeline/{'{code}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>环境变量 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(${'{VAR_NAME}'} 引用)</span></Label>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setNewEnvVars([...newEnvVars, { key: '', value: '', desc: '' }])}>
|
||||
<Plus className="h-3 w-3 mr-1" />添加变量
|
||||
</Button>
|
||||
</div>
|
||||
{newEnvVars.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无变量,点击"添加变量"新增</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{newEnvVars.map((v, i) => (
|
||||
<div key={i} className="grid grid-cols-12 gap-2 items-start p-2 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<Input
|
||||
value={v.key}
|
||||
onChange={(e) => {
|
||||
const nv = [...newEnvVars];
|
||||
nv[i] = { ...nv[i], key: e.target.value };
|
||||
setNewEnvVars(nv);
|
||||
}}
|
||||
placeholder="变量名"
|
||||
className="col-span-3 h-8 text-xs font-mono bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
<Input
|
||||
value={v.value}
|
||||
onChange={(e) => {
|
||||
const nv = [...newEnvVars];
|
||||
nv[i] = { ...nv[i], value: e.target.value };
|
||||
setNewEnvVars(nv);
|
||||
}}
|
||||
placeholder="变量值"
|
||||
className="col-span-4 h-8 text-xs font-mono bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
<Input
|
||||
value={v.desc}
|
||||
onChange={(e) => {
|
||||
const nv = [...newEnvVars];
|
||||
nv[i] = { ...nv[i], desc: e.target.value };
|
||||
setNewEnvVars(nv);
|
||||
}}
|
||||
placeholder="说明"
|
||||
className="col-span-4 h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="col-span-1 h-8 w-8 text-red-400" onClick={() => setNewEnvVars(newEnvVars.filter((_, idx) => idx !== i))}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<Label className="mb-2 block">构建缓存 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(加速后续构建)</span></Label>
|
||||
<Input value={newCachePaths} onChange={(e) => setNewCachePaths(e.target.value)} placeholder="node_modules, .m2, target" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-8 text-xs font-mono" />
|
||||
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">多个路径用逗号分隔,相同的 Key 会命中已有缓存</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<Label className="mb-2 block">并发执行数 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(Number of executors)</span></Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(newMaxExecutors)} onValueChange={(v) => setNewMaxExecutors(Number(v))}>
|
||||
<SelectTrigger className="w-32 h-8 text-xs bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 — 串行</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="4">4</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">设为 1 则按顺序串行执行,默认 2 可并发构建</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-[var(--dg-border)]">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Rocket className="h-4 w-4 mr-1" />}
|
||||
{saving ? '创建中...' : '创建流水线'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
StopCircle,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { pipelineApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { fmtDuration, type LogEntry } from './constants';
|
||||
|
||||
interface PipelineRunPanelProps {
|
||||
pipeline: Record<string, unknown>;
|
||||
pipelineRuns: Record<string, unknown>[];
|
||||
isLoadingRuns: boolean;
|
||||
onStopRun: (runId: number | string) => void;
|
||||
}
|
||||
|
||||
// 流水线队列面板(Jenkins 风格) // Logan.Li
|
||||
function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> }) {
|
||||
const [queue, setQueue] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const code = (pipeline.code || pipeline.id) as string;
|
||||
|
||||
const loadQueue = async () => {
|
||||
if (!code) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: Record<string, unknown> = await pipelineApi.getQueue(code);
|
||||
setQueue((res?.data || res) as Record<string, unknown>);
|
||||
} catch {
|
||||
setQueue(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadQueue();
|
||||
const t = setInterval(loadQueue, 10000);
|
||||
return () => clearInterval(t);
|
||||
}, [code]);
|
||||
|
||||
const handleCancel = async (runId: number) => {
|
||||
try {
|
||||
await pipelineApi.removeFromQueue(code, runId);
|
||||
toast.success('已取消');
|
||||
loadQueue();
|
||||
} catch {
|
||||
toast.error('取消失败');
|
||||
}
|
||||
};
|
||||
|
||||
const running = (queue?.running as number) || 0;
|
||||
const queued = (queue?.queued as number) || 0;
|
||||
const queueIds = (queue?.queue_ids as number[]) || [];
|
||||
const max = (pipeline.max_executors as number) || 2;
|
||||
const pendingRuns = (queue?.pending_runs as Record<string, unknown>[]) || [];
|
||||
|
||||
if (loading && !queue) return null;
|
||||
if (running === 0 && queued === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-[var(--dg-text-secondary)]">构建执行状态</span>
|
||||
<span className="font-bold text-[var(--dg-text-primary)]">{running}/{max}</span>
|
||||
{queued > 0 && (
|
||||
<span className="text-[var(--dg-text-secondary)] ml-2">
|
||||
构建队列 <span className="font-bold text-amber-400">({queued})</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{queued > 0 && pendingRuns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pendingRuns.map((run) => (
|
||||
<div key={run.id as number} className="flex items-center justify-between bg-[var(--dg-bg-tertiary)] rounded px-3 py-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Clock className="h-3 w-3 text-amber-400" />
|
||||
<span className="text-[var(--dg-text-secondary)] font-mono">#{String(run.build_number ?? run.id ?? '')}</span>
|
||||
<span className="text-[var(--dg-text-secondary)]">{String(run.branch || '-')}</span>
|
||||
<span className="text-[var(--dg-text-secondary)] opacity-50">{String(run.triggered_by || '-')}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs text-red-400 hover:text-red-300" onClick={() => handleCancel(run.id as number)}>
|
||||
<XCircle className="h-3 w-3 mr-1" />取消
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queued > 0 && pendingRuns.length === 0 && queueIds.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{queueIds.map((id) => (
|
||||
<div key={id} className="flex items-center justify-between bg-[var(--dg-bg-tertiary)] rounded px-3 py-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Clock className="h-3 w-3 text-amber-400" />
|
||||
<span className="text-[var(--dg-text-muted)] font-mono">Run #{id}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs text-red-400 hover:text-red-300" onClick={() => handleCancel(id)}>
|
||||
<XCircle className="h-3 w-3 mr-1" />取消
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 展开后的运行列表面板 // Logan.Li
|
||||
export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStopRun }: PipelineRunPanelProps) {
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const [logRun, setLogRun] = useState<Record<string, unknown> | null>(null);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const code = (pipeline.code || pipeline.id) as string;
|
||||
|
||||
const handleViewLog = async (run: Record<string, unknown>) => {
|
||||
setLogRun(run);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
const data = await pipelineApi.getRunLog(code, run.id as number | string) as Record<string, unknown>;
|
||||
setLogs((data?.logs as LogEntry[]) || []);
|
||||
} catch {
|
||||
setLogs([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--dg-border)]">
|
||||
<PipelineQueuePanel pipeline={pipeline} />
|
||||
|
||||
{isLoadingRuns ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-[var(--dg-text-secondary)]" />
|
||||
</div>
|
||||
) : pipelineRuns.length === 0 ? (
|
||||
<div className="py-6 text-center text-[var(--dg-text-secondary)] text-sm">暂无执行记录</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-[var(--dg-border)]">
|
||||
<tr className="bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">#</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">触发人</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">耗时</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">时间</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pipelineRuns.map((r) => (
|
||||
<tr key={r.id as number} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-4 py-2 font-medium text-[var(--dg-text-primary)]">#{String(r.build_number ?? r.id ?? '')}</td>
|
||||
<td className="px-4 py-2">
|
||||
{r.status === 'success' ? (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-[11px]">
|
||||
<CheckCircle className="h-3 w-3 mr-0.5" />成功
|
||||
</Badge>
|
||||
) : r.status === 'failed' ? (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-[11px]">
|
||||
<XCircle className="h-3 w-3 mr-0.5" />失败
|
||||
</Badge>
|
||||
) : r.status === 'running' ? (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-[11px]">
|
||||
<Loader2 className="h-3 w-3 animate-spin mr-0.5" />执行中
|
||||
</Badge>
|
||||
) : r.status === 'cancelled' ? (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30 text-[11px]">
|
||||
<StopCircle className="h-3 w-3 mr-0.5" />已停止
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[11px]">{String(r.status)}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[var(--dg-text-secondary)] text-xs">{String(r.triggered_by || 'admin')}</td>
|
||||
<td className="px-4 py-2 text-[var(--dg-text-secondary)] text-xs">{fmtDuration(r.duration as number | undefined)}</td>
|
||||
<td className="px-4 py-2 text-[var(--dg-text-secondary)] text-xs">
|
||||
{r.created_at ? new Date(r.created_at as string).toLocaleString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-blue-400 hover:text-blue-300" onClick={() => handleViewLog(r)}>
|
||||
<FileText className="h-3.5 w-3.5 mr-1" />日志
|
||||
</Button>
|
||||
{(r.status === 'running' || r.status === 'pending') && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => onStopRun(r.id as number | string)}>
|
||||
<StopCircle className="h-3.5 w-3.5 mr-1" />停止
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<Dialog open={logOpen} onOpenChange={setLogOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />执行日志 #{String(logRun?.build_number ?? logRun?.id ?? '')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
|
||||
<span>触发方式: {String(logRun?.triggered_by || 'manual')}</span>
|
||||
<span>分支: {String(logRun?.branch || '-')}</span>
|
||||
<span>耗时: {fmtDuration(logRun?.duration as number | undefined)}</span>
|
||||
</div>
|
||||
<div className="bg-slate-950 rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-slate-500 py-4 text-center">暂无日志</div>
|
||||
) : (
|
||||
logs.map((l, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="text-slate-500 shrink-0">[{l.time}]</span>
|
||||
<span className={l.level === 'error' ? 'text-red-400' : l.level === 'warn' ? 'text-yellow-400' : l.level === 'success' ? 'text-green-400' : 'text-slate-300'}>
|
||||
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
GitBranch,
|
||||
Box,
|
||||
CheckCircle,
|
||||
Container,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
// 流水线阶段定义(固定顺序) // Logan.Li
|
||||
export const STAGE_DEFS = [
|
||||
{ key: 'checkout', label: '代码检出', icon: GitBranch, color: '#6366F1', order: 1, needs: ['repo'], desc: '从 Git 仓库拉取代码' },
|
||||
{ key: 'build', label: '构建', icon: Box, color: '#F59E0B', order: 2, needs: [], desc: '编译/打包项目' },
|
||||
{ key: 'test', label: '测试', icon: CheckCircle, color: '#10B981', order: 3, needs: [], desc: '运行单元/集成测试' },
|
||||
{ key: 'image', label: '镜像打包', icon: Container, color: '#06B6D4', order: 4, needs: ['registry'], desc: 'Docker 构建 & 推送' },
|
||||
{ key: 'deploy', label: '部署', icon: Rocket, color: '#EF4444', order: 5, needs: [], desc: '部署到目标环境' },
|
||||
{ key: 'verify', label: '验证', icon: ShieldCheck, color: '#3B82F6', order: 6, needs: [], desc: '部署后健康检查' },
|
||||
].sort((a, b) => a.order - b.order);
|
||||
|
||||
// 运行日志条目 // Logan.Li
|
||||
export interface LogEntry {
|
||||
time: string;
|
||||
level: string;
|
||||
message: string;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
// 格式化耗时 // Logan.Li
|
||||
export function fmtDuration(s: number | undefined): string {
|
||||
if (!s) return '-';
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return sec > 0 ? `${m}分${sec}秒` : `${m}分`;
|
||||
}
|
||||
|
||||
// 解析 stages 字段 // Logan.Li
|
||||
export function parseStages(raw: unknown): Record<string, unknown>[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const s = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(s) ? s : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { PodExecTerminal } from './PodExecTerminal';
|
||||
import { PodLogsViewer } from './PodLogsViewer';
|
||||
import { PodFileBrowser } from './PodFileBrowser';
|
||||
@@ -48,6 +49,7 @@ interface DeploymentDetailProps {
|
||||
|
||||
export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) {
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [deployment, setDeployment] = useState<any>(null);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [hpas, setHpas] = useState<any[]>([]);
|
||||
@@ -174,7 +176,13 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
|
||||
const handleHpaToggle = async ( hpa: any) => {
|
||||
// Not direct toggle — delete HPA
|
||||
if (!confirm(`确定删除 HPA "${hpa.name}"?`)) return;
|
||||
const ok = await confirm({
|
||||
title: '删除 HPA',
|
||||
description: `确定删除 HPA "${hpa.name}"?`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await kubernetesApi.deleteHPA(clusterCode, namespace, hpa.name);
|
||||
toast({ title: 'HPA 已删除' });
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { kubernetesApi } from '@/api';
|
||||
|
||||
interface Props {
|
||||
@@ -72,6 +73,7 @@ const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.
|
||||
|
||||
export function DeploymentEditService({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [services, setServices] = useState<SvcSummary[]>([]);
|
||||
const [ingresses, setIngresses] = useState<IngSummary[]>([]);
|
||||
@@ -265,7 +267,13 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
|
||||
|
||||
// 删除资源
|
||||
const deleteResource = async (kind: 'service' | 'ingress', name: string) => {
|
||||
if (!window.confirm(`确认删除 ${kind === 'service' ? 'Service' : 'Ingress'} 「${name}」?此操作不可撤销。`)) return;
|
||||
const ok = await confirm({
|
||||
title: '删除资源',
|
||||
description: `确认删除 ${kind === 'service' ? 'Service' : 'Ingress'} 「${name}」?此操作不可撤销。`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const resource = kind === 'service' ? 'services' : 'ingresses';
|
||||
await kubernetesApi.deleteResourceYaml(clusterCode, namespace, resource, name);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS,
|
||||
} from './k8s-yaml-utils';
|
||||
@@ -76,6 +77,7 @@ function getPodTimeline(pod: PodItem) {
|
||||
export function DeploymentView(props: DeploymentViewProps) {
|
||||
const { clusterCode, namespace, deployment, pods, revisions, onRefresh } = props;
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [tab, setTab] = useState('pods');
|
||||
const [expandedPod, setExpandedPod] = useState<string | null>(null);
|
||||
const [rollbackLoading, setRollbackLoading] = useState<number | null>(null);
|
||||
@@ -190,7 +192,13 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
// Rollback
|
||||
const handleRollback = async ( rev: any) => {
|
||||
const revision = rev.revision ?? rev.revision_number ?? rev.number;
|
||||
if (!confirm(`确认回滚到修订版本 #${revision}?`)) return;
|
||||
const ok = await confirm({
|
||||
title: '回滚版本',
|
||||
description: `确认回滚到修订版本 #${revision}?`,
|
||||
confirmText: '回滚',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
setRollbackLoading(revision);
|
||||
try {
|
||||
await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision));
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { useUserStore } from '@/store';
|
||||
import { YamlViewer } from './YamlViewer';
|
||||
import { DeploymentView } from './DeploymentView';
|
||||
@@ -73,6 +74,7 @@ type ActionModal =
|
||||
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [deployment, setDeployment] = useState<any>(null);
|
||||
const [manifest, setManifest] = useState<any>(null);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
@@ -180,7 +182,12 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
if (!confirm(`确认滚动重启 Deployment "${deploymentName}"?`)) return;
|
||||
const ok = await confirm({
|
||||
title: '滚动重启',
|
||||
description: `确认滚动重启 Deployment "${deploymentName}"?`,
|
||||
confirmText: '重启',
|
||||
});
|
||||
if (!ok) return;
|
||||
setActionLoading('restart');
|
||||
try {
|
||||
await kubernetesApi.restartDeployment(clusterCode, namespace, deploymentName);
|
||||
@@ -194,7 +201,12 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
const handleTogglePause = async () => {
|
||||
const isPaused = !!deployment?.paused;
|
||||
const action = isPaused ? '恢复' : '暂停';
|
||||
if (!confirm(`确认${action} Deployment "${deploymentName}" 的滚动更新?`)) return;
|
||||
const ok = await confirm({
|
||||
title: `${action}滚动更新`,
|
||||
description: `确认${action} Deployment "${deploymentName}" 的滚动更新?`,
|
||||
confirmText: action,
|
||||
});
|
||||
if (!ok) return;
|
||||
setActionLoading(isPaused ? 'resume' : 'pause');
|
||||
try {
|
||||
if (isPaused) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Shield, Activity, AlertTriangle, CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface NamespaceDetail {
|
||||
name: string;
|
||||
@@ -75,7 +76,7 @@ export default function NamespaceManagementPage() {
|
||||
setCreateOpen(false);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err?.message || '创建失败');
|
||||
toast.error(err?.message || '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@@ -89,7 +90,7 @@ export default function NamespaceManagementPage() {
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err?.message || '删除失败');
|
||||
toast.error(err?.message || '删除失败');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { YamlViewer } from './YamlViewer';
|
||||
import { PodExecTerminal } from './PodExecTerminal';
|
||||
import { PodFileBrowser } from './PodFileBrowser';
|
||||
@@ -55,6 +56,7 @@ export default function WorkloadViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const clusterCode = params.clusterCode || '';
|
||||
const namespace = params.namespace || '';
|
||||
@@ -114,7 +116,12 @@ export default function WorkloadViewPage() {
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
if (!confirm(`确认重启 ${kind} ${name}?\n将触发滚动重启。`)) return;
|
||||
const ok = await confirm({
|
||||
title: '滚动重启',
|
||||
description: `确认重启 ${kind} ${name}?将触发滚动重启。`,
|
||||
confirmText: '重启',
|
||||
});
|
||||
if (!ok) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
if (kind === 'StatefulSet') {
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
|
||||
import { ContainerEditor } from './ContainerEditor';
|
||||
import type { ContainerGroupSectionProps } from './types';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
|
||||
export function ContainerGroupSection({
|
||||
title, description, icon, containers, accentColor,
|
||||
onAdd, onRemove, onUpdate, onDuplicate,
|
||||
}: ContainerGroupSectionProps) {
|
||||
const confirm = useConfirm();
|
||||
const [openIndices, setOpenIndices] = useState<Set<number>>(new Set([0]));
|
||||
const toggleOpen = (idx: number) => {
|
||||
setOpenIndices(prev => {
|
||||
@@ -70,7 +72,15 @@ export function ContainerGroupSection({
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" title="删除"
|
||||
onClick={() => { if (confirm(`确认删除容器「${c.name}」?`)) onRemove(idx); }}>
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: '删除容器',
|
||||
description: `确认删除容器「${c.name}」?`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (ok) onRemove(idx);
|
||||
}}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, RotateCcw, Home, Bug } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -47,7 +48,9 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
const { error, errorInfo } = this.state;
|
||||
const body = `## 错误报告\n\n**时间:** ${new Date().toISOString()}\n**URL:** ${window.location.href}\n**用户代理:** ${navigator.userAgent}\n\n### 错误信息\n\n\`\`\`\n${error?.name}: ${error?.message}\n\`\`\`\n\n### 组件堆栈\n\n\`\`\`\n${errorInfo?.componentStack}\n\`\`\`\n`;
|
||||
navigator.clipboard.writeText(body).then(() => {
|
||||
alert('错误信息已复制到剪贴板,请粘贴到 Issue 中');
|
||||
toast.success('错误信息已复制到剪贴板,请粘贴到 Issue 中');
|
||||
}).catch(() => {
|
||||
toast.error('复制失败,请手动复制错误信息');
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// hooks/api 统一出口 // Logan.Li
|
||||
|
||||
export { queryKeys } from './queryKeys';
|
||||
export { unwrapList, unwrapTotal } from './unwrap';
|
||||
export { useTerminalSessions, useTerminalSessionMutations, type TerminalSession } from './useTerminalSessions';
|
||||
export { useHostList, useHostHistory, useHealthProbes, usePortPolicy } from './useHostMonitor';
|
||||
export { useServers, useServerMutations } from './useServers';
|
||||
export { usePipelines, usePipelineRuns, usePipelineMutations } from './usePipelines';
|
||||
export { useAlertRules, useAlerts, useAlertMutations } from './useAlerts';
|
||||
export { useAuditLogs } from './useAuditLogs';
|
||||
export { useSessionCommands, type SessionCommand } from './useSessionCommands';
|
||||
@@ -1,4 +1,4 @@
|
||||
// TanStack Query key 工厂 — 统一缓存键 // Logan.Li
|
||||
// TanStack Query key 工厂 // Logan.Li
|
||||
|
||||
export const queryKeys = {
|
||||
terminalSessions: (params?: Record<string, unknown>) => ['terminal-sessions', params] as const,
|
||||
@@ -8,4 +8,12 @@ export const queryKeys = {
|
||||
probes: (hostname: string) => ['host-monitor', 'probes', hostname] as const,
|
||||
portPolicy: (hostname: string) => ['host-monitor', 'port-policy', hostname] as const,
|
||||
},
|
||||
servers: (params?: Record<string, unknown>) => ['servers', params] as const,
|
||||
pipelines: (params?: Record<string, unknown>) => ['pipelines', params] as const,
|
||||
pipelineRuns: (code: string) => ['pipeline-runs', code] as const,
|
||||
alerts: (params?: Record<string, unknown>) => ['alerts', params] as const,
|
||||
alertRules: () => ['alert-rules'] as const,
|
||||
auditLogs: (params?: Record<string, unknown>) => ['audit-logs', params] as const,
|
||||
sessionCommands: (params?: Record<string, unknown>) => ['session-commands', params] as const,
|
||||
k8sClusters: () => ['k8s-clusters'] as const,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// 列表响应解包 — http.ts 已取 data,兼容数组/list 形态 // Logan.Li
|
||||
|
||||
export function unwrapList<T>(data: unknown): T[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === 'object') {
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (Array.isArray(obj.list)) return obj.list as T[];
|
||||
if (Array.isArray(obj.data)) return obj.data as T[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function unwrapTotal(data: unknown): number {
|
||||
if (data && typeof data === 'object' && 'total' in data) {
|
||||
return Number((data as { total: number }).total) || 0;
|
||||
}
|
||||
return unwrapList(data).length;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 告警规则 / 告警事件 — TanStack Query // Logan.Li
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { alertApi, alertCenterApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList } from './unwrap';
|
||||
|
||||
export function useAlertRules() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.alertRules(),
|
||||
queryFn: async () => {
|
||||
const resp = await alertApi.getRules();
|
||||
return unwrapList<Record<string, unknown>>(resp);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlerts(params: { page?: number; page_size?: number; status?: string; severity?: string } = {}) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.alerts(params),
|
||||
queryFn: async () => {
|
||||
const resp = await alertCenterApi.getAlerts(params);
|
||||
return unwrapList<Record<string, unknown>>(resp);
|
||||
},
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidateRules = () => qc.invalidateQueries({ queryKey: queryKeys.alertRules() });
|
||||
const invalidateAlerts = () => qc.invalidateQueries({ queryKey: ['alerts'] });
|
||||
|
||||
const toggleRuleMutation = useMutation({
|
||||
mutationFn: (id: number | string) => alertApi.toggleRule(id),
|
||||
onSuccess: invalidateRules,
|
||||
});
|
||||
|
||||
const deleteRuleMutation = useMutation({
|
||||
mutationFn: (id: number | string) => alertApi.deleteRule(id),
|
||||
onSuccess: invalidateRules,
|
||||
});
|
||||
|
||||
const ackMutation = useMutation({
|
||||
mutationFn: (id: number | string) => alertCenterApi.acknowledge(id),
|
||||
onSuccess: invalidateAlerts,
|
||||
});
|
||||
|
||||
return { toggleRuleMutation, deleteRuleMutation, ackMutation, invalidateRules, invalidateAlerts };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 审计日志 — TanStack Query // Logan.Li
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { auditApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList, unwrapTotal } from './unwrap';
|
||||
|
||||
type AuditParams = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
module?: string;
|
||||
action?: string;
|
||||
username?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
};
|
||||
|
||||
export function useAuditLogs(params: AuditParams = { page: 1, page_size: 50 }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.auditLogs(params),
|
||||
queryFn: async () => {
|
||||
const resp = await auditApi.getLogs(params);
|
||||
return {
|
||||
list: unwrapList<Record<string, unknown>>(resp),
|
||||
total: unwrapTotal(resp),
|
||||
};
|
||||
},
|
||||
staleTime: 20_000,
|
||||
});
|
||||
}
|
||||
@@ -3,16 +3,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { agentApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
function unwrapList<T>(data: unknown): T[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === 'object') {
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (Array.isArray(obj.list)) return obj.list as T[];
|
||||
if (Array.isArray(obj.data)) return obj.data as T[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
import { unwrapList } from './unwrap';
|
||||
|
||||
export function useHostList() {
|
||||
return useQuery({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// 流水线 — TanStack Query // Logan.Li
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { pipelineApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList } from './unwrap';
|
||||
|
||||
type ListParams = { page?: number; page_size?: number };
|
||||
|
||||
export function usePipelines(params: ListParams = { page: 1, page_size: 100 }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pipelines(params),
|
||||
queryFn: async () => {
|
||||
const resp = await pipelineApi.getPipelines(params);
|
||||
return unwrapList<Record<string, unknown>>(resp);
|
||||
},
|
||||
staleTime: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePipelineRuns(code: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pipelineRuns(code),
|
||||
queryFn: async () => {
|
||||
const resp = await pipelineApi.getRunsByCode(code, 50);
|
||||
return unwrapList<Record<string, unknown>>(resp);
|
||||
},
|
||||
enabled: !!code && enabled,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePipelineMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['pipelines'] });
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: (code: string) => pipelineApi.triggerByCode(code),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number | string) => pipelineApi.deletePipeline(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const stopRunMutation = useMutation({
|
||||
mutationFn: ({ code, runId }: { code: string; runId: number | string }) =>
|
||||
pipelineApi.stopRunByCode(code, runId),
|
||||
});
|
||||
|
||||
return { triggerMutation, deleteMutation, stopRunMutation, invalidate };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// 服务器列表 — TanStack Query // Logan.Li
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { serverApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList } from './unwrap';
|
||||
|
||||
type ListParams = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export function useServers(params: ListParams = { page: 1, page_size: 100 }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.servers(params),
|
||||
queryFn: async () => {
|
||||
const resp = await serverApi.getServers(params);
|
||||
return unwrapList<Record<string, unknown>>(resp);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useServerMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['servers'] });
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number | string) => serverApi.deleteServer(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (ids: number[]) => serverApi.batchDelete(ids),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { deleteMutation, batchDeleteMutation, invalidate };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 会话命令列表 — TanStack Query // Logan.Li
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { sessionCommandApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList } from './unwrap';
|
||||
|
||||
export interface SessionCommand {
|
||||
id: number;
|
||||
session_id: number;
|
||||
username: string;
|
||||
command: string;
|
||||
exit_code: number;
|
||||
output_size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type ListParams = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
session_id?: number;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export function useSessionCommands(params: ListParams = { page: 1, page_size: 200 }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sessionCommands(params),
|
||||
queryFn: async () => {
|
||||
const resp = await sessionCommandApi.list(params);
|
||||
return unwrapList<SessionCommand>(resp);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { terminalSessionApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList } from './unwrap';
|
||||
|
||||
export interface TerminalSession {
|
||||
id: number;
|
||||
@@ -28,14 +29,6 @@ type ListParams = {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
function unwrapList<T>(data: unknown): T[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === 'object' && 'list' in data) {
|
||||
return (data as { list: T[] }).list ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function useTerminalSessions(params: ListParams = { page: 1, page_size: 100 }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.terminalSessions(params),
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
type Theme,
|
||||
type ThemeOption,
|
||||
THEME_OPTIONS,
|
||||
getInitialTheme,
|
||||
persistTheme,
|
||||
applyThemeToDocument,
|
||||
getNextTheme,
|
||||
} from '@/utils/theme';
|
||||
|
||||
export type Theme =
|
||||
| 'ocean-deep'
|
||||
| 'light-professional'
|
||||
| 'midnight-purple'
|
||||
| 'pine-green'
|
||||
| 'orange-vitality';
|
||||
|
||||
export interface ThemeOption {
|
||||
id: Theme;
|
||||
name: string;
|
||||
description: string;
|
||||
isDark: boolean;
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: 'ocean-deep', name: '深海蓝', description: '专业稳重的深蓝色调', isDark: true, primaryColor: '#3b82f6' },
|
||||
{ id: 'light-professional', name: '明亮白', description: '清爽干净的浅色主题', isDark: false, primaryColor: '#2563eb' },
|
||||
{ id: 'midnight-purple', name: '午夜紫', description: '神秘优雅的紫色调', isDark: true, primaryColor: '#a855f7' },
|
||||
{ id: 'pine-green', name: '松林绿', description: '自然清新的绿色主题', isDark: true, primaryColor: '#22c55e' },
|
||||
{ id: 'orange-vitality', name: '活力橙', description: '温暖活力的橙色主题', isDark: true, primaryColor: '#f97316' },
|
||||
];
|
||||
|
||||
const THEME_ORDER: Theme[] = THEME_OPTIONS.map((o) => o.id);
|
||||
const DEFAULT_THEME: Theme = 'ocean-deep';
|
||||
const STORAGE_KEY = 'aiops-theme';
|
||||
|
||||
// Backward compatibility: map old theme names to new ones
|
||||
function migrateLegacyTheme(value: string | null): Theme | null {
|
||||
if (value === 'dark') return 'ocean-deep';
|
||||
if (value === 'light') return 'light-professional';
|
||||
return null;
|
||||
}
|
||||
export type { Theme, ThemeOption };
|
||||
export { THEME_OPTIONS };
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
@@ -42,53 +20,29 @@ interface ThemeContextType {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return DEFAULT_THEME;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const migrated = migrateLegacyTheme(stored);
|
||||
if (migrated) {
|
||||
// Persist the migrated value so we don't migrate every time
|
||||
localStorage.setItem(STORAGE_KEY, migrated);
|
||||
return migrated;
|
||||
}
|
||||
if (stored && THEME_ORDER.includes(stored as Theme)) {
|
||||
return stored as Theme;
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
applyThemeToDocument(theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => {
|
||||
const idx = THEME_ORDER.indexOf(prev);
|
||||
const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length];
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
const next = getNextTheme(prev);
|
||||
persistTheme(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
localStorage.setItem(STORAGE_KEY, t);
|
||||
persistTheme(t);
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
}),
|
||||
[theme, toggleTheme, setTheme]
|
||||
() => ({ theme, toggleTheme, setTheme }),
|
||||
[theme, toggleTheme, setTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
@@ -98,4 +52,4 @@ export function useTheme(): ThemeContextType {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { approvalWorkflowApi, userApi } from '@/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
@@ -439,6 +440,7 @@ function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps)
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function ApprovalWorkflowPage() {
|
||||
const confirm = useConfirm();
|
||||
const [items, setItems] = useState<WorkflowItem[]>([]);
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -488,13 +490,19 @@ export default function ApprovalWorkflowPage() {
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(async (id: number, name: string) => {
|
||||
if (!window.confirm(`确定删除审批流程「${name}」?删除后不可恢复。`)) return;
|
||||
const ok = await confirm({
|
||||
title: '删除审批流程',
|
||||
description: `确定删除审批流程「${name}」?删除后不可恢复。`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await approvalWorkflowApi.delete(id);
|
||||
setItems((prev) => prev.filter((w) => w.id !== id));
|
||||
toast.success('已删除');
|
||||
} catch { toast.error('删除失败'); }
|
||||
}, []);
|
||||
}, [confirm]);
|
||||
|
||||
const setDefault = useCallback(async (id: number) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { auditApi } from '@/api';
|
||||
import { useAuditLogs } from '@/hooks/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import type { OperationLog } from '@/types';
|
||||
|
||||
@@ -53,13 +54,23 @@ interface AuditStatistics {
|
||||
|
||||
export function AuditOperationsTab() {
|
||||
const confirm = useConfirm();
|
||||
const [logs, setLogs] = useState<OperationLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Filters
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [moduleFilter, setModuleFilter] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
|
||||
const auditParams = useMemo(() => ({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
...(moduleFilter ? { module: moduleFilter } : {}),
|
||||
...(actionFilter ? { action: actionFilter } : {}),
|
||||
}), [moduleFilter, actionFilter]);
|
||||
|
||||
const { data: auditData, isLoading: loading, refetch: refetchLogs } = useAuditLogs(auditParams);
|
||||
const logs = useMemo(
|
||||
() => (auditData?.list || []).map((raw) => normalizeLog(raw)),
|
||||
[auditData],
|
||||
);
|
||||
const [modules, setModules] = useState<string[]>([]);
|
||||
const [actions, setActions] = useState<string[]>([]);
|
||||
|
||||
@@ -71,22 +82,6 @@ export function AuditOperationsTab() {
|
||||
const [statistics, setStatistics] = useState<AuditStatistics | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page: 1, page_size: 100 };
|
||||
if (moduleFilter) params.module = moduleFilter;
|
||||
if (actionFilter) params.action = actionFilter;
|
||||
const resp = await auditApi.getLogs(params);
|
||||
const list = (resp as any)?.list ?? [];
|
||||
setLogs(list.map(normalizeLog));
|
||||
} catch {
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [moduleFilter, actionFilter]);
|
||||
|
||||
const fetchOptions = useCallback(async () => {
|
||||
try {
|
||||
const [modRes, actRes] = await Promise.all([auditApi.getModules(), auditApi.getActions()]);
|
||||
@@ -98,9 +93,8 @@ export function AuditOperationsTab() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
fetchOptions();
|
||||
}, [fetchLogs, fetchOptions]);
|
||||
}, [fetchOptions]);
|
||||
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
@@ -198,7 +192,7 @@ export function AuditOperationsTab() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchLogs}
|
||||
onClick={() => refetchLogs()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
|
||||
@@ -1,123 +1,12 @@
|
||||
// 终端审计 Tab — 3 个子标签页:在线会话 / 历史会话 / 会话命令
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// 终端审计 Tab — 子标签编排 // Logan.Li
|
||||
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Play,
|
||||
Download,
|
||||
XOctagon,
|
||||
Eye,
|
||||
Monitor,
|
||||
Activity,
|
||||
Timer,
|
||||
TimerOff,
|
||||
} from 'lucide-react';
|
||||
import { terminalSessionApi, sessionCommandApi } from '@/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { ReplayDialog } from '@/components/system/ReplayDialog';
|
||||
import { LiveMonitorDialog } from '@/components/system/LiveMonitorDialog';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
interface TerminalSession {
|
||||
id: number;
|
||||
user_id?: number;
|
||||
username: string;
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
server_ip: string;
|
||||
server_port: number;
|
||||
client_ip: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
record_size: number;
|
||||
start_time: string;
|
||||
end_time?: string | null;
|
||||
}
|
||||
|
||||
interface SessionCommand {
|
||||
id: number;
|
||||
session_id: number;
|
||||
username: string;
|
||||
command: string;
|
||||
exit_code: number;
|
||||
output_size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type SubTabId = 'active' | 'history' | 'commands';
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
if (!sec || sec < 0) return '--';
|
||||
if (sec < 60) return `${sec}秒`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
if (m < 60) return `${m}分${s > 0 ? `${s}秒` : ''}`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}时${rm > 0 ? `${rm}分` : ''}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 inline-block animate-pulse" />
|
||||
活动中
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
失败
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||
已关闭
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Computes elapsed time for active sessions
|
||||
function useElapsedTimer(startTime: string | undefined): number {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!startTime) return;
|
||||
const start = new Date(startTime).getTime();
|
||||
const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [startTime]);
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
// ---- Sub-Tab definitions ----
|
||||
import { Activity, Monitor, Search } from 'lucide-react';
|
||||
import { ActiveSessionsPanel } from './terminal-audit/ActiveSessionsPanel';
|
||||
import { HistorySessionsPanel } from './terminal-audit/HistorySessionsPanel';
|
||||
import { SessionCommandsPanel } from './terminal-audit/SessionCommandsPanel';
|
||||
import type { SubTabId } from './terminal-audit/types';
|
||||
|
||||
const subTabs: { id: SubTabId; name: string; icon: React.ElementType }[] = [
|
||||
{ id: 'active', name: '在线会话', icon: Activity },
|
||||
@@ -125,181 +14,12 @@ const subTabs: { id: SubTabId; name: string; icon: React.ElementType }[] = [
|
||||
{ id: 'commands', name: '会话命令', icon: Search },
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// MAIN COMPONENT
|
||||
// =========================================================================
|
||||
|
||||
export function TerminalAuditTab() {
|
||||
const confirm = useConfirm();
|
||||
const [subTab, setSubTab] = useState<SubTabId>('active');
|
||||
|
||||
// ---- Active Sessions State ----
|
||||
const [activeSessions, setActiveSessions] = useState<TerminalSession[]>([]);
|
||||
const [activeLoading, setActiveLoading] = useState(true);
|
||||
const [activeKeyword, setActiveKeyword] = useState('');
|
||||
|
||||
// ---- History Sessions State ----
|
||||
const [historySessions, setHistorySessions] = useState<TerminalSession[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(true);
|
||||
const [historyKeyword, setHistoryKeyword] = useState('');
|
||||
const [replayId, setReplayId] = useState<number | null>(null);
|
||||
const [replayOpen, setReplayOpen] = useState(false);
|
||||
|
||||
// ---- Session Commands State ----
|
||||
const [commands, setCommands] = useState<SessionCommand[]>([]);
|
||||
const [commandsLoading, setCommandsLoading] = useState(true);
|
||||
const [commandsKeyword, setCommandsKeyword] = useState('');
|
||||
const [commandsSessionId, setCommandsSessionId] = useState('');
|
||||
|
||||
// ---- Real-time monitoring dialog ----
|
||||
const [monitorOpen, setMonitorOpen] = useState(false);
|
||||
const [monitorSessionId, setMonitorSessionId] = useState<number | null>(null);
|
||||
|
||||
// ---- Auto-refresh ----
|
||||
const [autoRefresh, setAutoRefresh] = useState(true); // 页面打开即自动刷新
|
||||
const [userToggledOff, setUserToggledOff] = useState(false); // 用户手动关闭后不再自动开启
|
||||
const REFRESH_SEC = 10;
|
||||
|
||||
// 切换到「在线会话」时自动开启刷新(除非用户已手动关闭)
|
||||
useEffect(() => {
|
||||
if (subTab === 'active' && !userToggledOff) {
|
||||
setAutoRefresh(true);
|
||||
} else if (subTab !== 'active') {
|
||||
setAutoRefresh(false);
|
||||
}
|
||||
}, [subTab, userToggledOff]);
|
||||
|
||||
// ==================== Active Sessions ====================
|
||||
|
||||
const fetchActiveSessions = useCallback(async () => {
|
||||
setActiveLoading(true);
|
||||
try {
|
||||
const resp = (await terminalSessionApi.list({ page: 1, page_size: 100, status: 'active' })) as any;
|
||||
setActiveSessions(resp?.list ?? []);
|
||||
} catch {
|
||||
setActiveSessions([]);
|
||||
} finally {
|
||||
setActiveLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchActiveSessions();
|
||||
}, [fetchActiveSessions]);
|
||||
|
||||
// 自动刷新在线会话列表
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const id = setInterval(fetchActiveSessions, REFRESH_SEC * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, fetchActiveSessions]);
|
||||
|
||||
const filteredActive = activeSessions.filter((s) => {
|
||||
const kw = activeKeyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(s.username || '').toLowerCase().includes(kw) ||
|
||||
(s.server_name || '').toLowerCase().includes(kw) ||
|
||||
(s.server_ip || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
const handleKill = async (id: number, username: string) => {
|
||||
const ok = await confirm({
|
||||
title: '阻断会话',
|
||||
description: `确认强制断开用户「${username}」的当前会话吗?`,
|
||||
confirmText: '阻断',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await terminalSessionApi.kill(id);
|
||||
await fetchActiveSessions();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
const openMonitor = (sessionId: number) => {
|
||||
setMonitorSessionId(sessionId);
|
||||
setMonitorOpen(true);
|
||||
};
|
||||
|
||||
// ==================== History Sessions ====================
|
||||
|
||||
const fetchHistorySessions = useCallback(async () => {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const resp = (await terminalSessionApi.list({ page: 1, page_size: 100, status: 'closed' })) as any;
|
||||
setHistorySessions(resp?.list ?? []);
|
||||
} catch {
|
||||
setHistorySessions([]);
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistorySessions();
|
||||
}, [fetchHistorySessions]);
|
||||
|
||||
const filteredHistory = historySessions.filter((s) => {
|
||||
const kw = historyKeyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(s.username || '').toLowerCase().includes(kw) ||
|
||||
(s.server_name || '').toLowerCase().includes(kw) ||
|
||||
(s.server_ip || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
const openReplay = (id: number) => {
|
||||
setReplayId(id);
|
||||
setReplayOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = async (id: number) => {
|
||||
try {
|
||||
await terminalSessionApi.download(id);
|
||||
} catch {
|
||||
// download error is handled in http.download
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Session Commands ====================
|
||||
|
||||
const fetchCommands = useCallback(async () => {
|
||||
setCommandsLoading(true);
|
||||
try {
|
||||
const params: any = { page: 1, page_size: 200 };
|
||||
if (commandsSessionId) params.session_id = Number(commandsSessionId);
|
||||
const resp = (await sessionCommandApi.list(params)) as any;
|
||||
setCommands(resp?.list ?? []);
|
||||
} catch {
|
||||
setCommands([]);
|
||||
} finally {
|
||||
setCommandsLoading(false);
|
||||
}
|
||||
}, [commandsSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCommands();
|
||||
}, [fetchCommands]);
|
||||
|
||||
const filteredCommands = commands.filter((c) => {
|
||||
const kw = commandsKeyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(c.username || '').toLowerCase().includes(kw) ||
|
||||
(c.command || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
// ==================== RENDER ====================
|
||||
const [userToggledOff, setUserToggledOff] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Sub-Tab Bar */}
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] p-1 rounded-lg border border-[var(--dg-border)] w-fit">
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
@@ -318,410 +38,14 @@ export function TerminalAuditTab() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ==================== 在线会话 ==================== */}
|
||||
{subTab === 'active' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/服务器..."
|
||||
value={activeKeyword}
|
||||
onChange={(e) => setActiveKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchActiveSessions}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? 'default' : 'outline'}
|
||||
className={autoRefresh
|
||||
? 'bg-emerald-600 hover:bg-emerald-700 text-white'
|
||||
: 'border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800'
|
||||
}
|
||||
onClick={() => {
|
||||
if (autoRefresh) setUserToggledOff(true); // 手动关闭后不再自动开启
|
||||
setAutoRefresh(!autoRefresh);
|
||||
}}
|
||||
title={autoRefresh ? `每 ${REFRESH_SEC}s 自动刷新 · 点击关闭` : '点击开启自动刷新'}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<><Timer className="h-4 w-4 mr-1.5 animate-pulse" />{REFRESH_SEC}s</>
|
||||
) : (
|
||||
<><TimerOff className="h-4 w-4 mr-2" />自动刷新</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{activeLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredActive.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Activity className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无在线会话</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">ID</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">目标服务器</TableHead>
|
||||
<TableHead className="text-slate-400">端口</TableHead>
|
||||
<TableHead className="text-slate-400">开始时间</TableHead>
|
||||
<TableHead className="text-slate-400">时长</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredActive.map((s) => (
|
||||
<ActiveSessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
onKill={handleKill}
|
||||
onMonitor={openMonitor}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
<ActiveSessionsPanel
|
||||
userToggledOff={userToggledOff}
|
||||
onUserToggledOff={() => setUserToggledOff(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ==================== 历史会话 ==================== */}
|
||||
{subTab === 'history' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/服务器/IP..."
|
||||
value={historyKeyword}
|
||||
onChange={(e) => setHistoryKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchHistorySessions}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredHistory.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Monitor className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无历史会话</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">ID</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">目标服务器</TableHead>
|
||||
<TableHead className="text-slate-400">端口</TableHead>
|
||||
<TableHead className="text-slate-400">开始时间</TableHead>
|
||||
<TableHead className="text-slate-400">结束时间</TableHead>
|
||||
<TableHead className="text-slate-400">时长</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredHistory.map((s) => (
|
||||
<TableRow key={s.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{s.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">{s.server_name || '--'}</span>
|
||||
<span className="text-xs text-slate-500">{s.server_ip}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.server_port}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{s.end_time ? new Date(s.end_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{formatDuration(s.duration)}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(s.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
onClick={() => openReplay(s.id)}
|
||||
title="回放"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-green-400 hover:bg-slate-800"
|
||||
onClick={() => handleDownload(s.id)}
|
||||
title="下载录制"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ==================== 会话命令 ==================== */}
|
||||
{subTab === 'commands' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/命令..."
|
||||
value={commandsKeyword}
|
||||
onChange={(e) => setCommandsKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="会话 ID(可选)"
|
||||
value={commandsSessionId}
|
||||
onChange={(e) => setCommandsSessionId(e.target.value)}
|
||||
className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={fetchCommands}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{commandsLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Search className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无会话命令记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">时间</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">命令</TableHead>
|
||||
<TableHead className="text-slate-400">输出大小</TableHead>
|
||||
<TableHead className="text-slate-400">关联会话</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCommands.map((c) => (
|
||||
<TableRow key={c.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{c.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
|
||||
{c.command || '--'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-slate-500">#{c.session_id}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Replay Dialog */}
|
||||
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
|
||||
|
||||
{/* Real-time monitor dialog */}
|
||||
<LiveMonitorDialog open={monitorOpen} onOpenChange={setMonitorOpen} sessionId={monitorSessionId} />
|
||||
{subTab === 'history' && <HistorySessionsPanel />}
|
||||
{subTab === 'commands' && <SessionCommandsPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Active Session Row (with live timer)
|
||||
// =========================================================================
|
||||
|
||||
function ActiveSessionRow({
|
||||
session,
|
||||
onKill,
|
||||
onMonitor,
|
||||
}: {
|
||||
session: TerminalSession;
|
||||
onKill: (id: number, username: string) => void;
|
||||
onMonitor: (id: number) => void;
|
||||
}) {
|
||||
const elapsed = useElapsedTimer(session.start_time);
|
||||
|
||||
return (
|
||||
<TableRow className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{session.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{session.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">{session.server_name || '--'}</span>
|
||||
<span className="text-xs text-slate-500">{session.server_ip}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{session.server_port}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{session.start_time ? new Date(session.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-emerald-400 font-mono">{formatDuration(elapsed)}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(session.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800"
|
||||
onClick={() => onKill(session.id, session.username)}
|
||||
title="阻断会话"
|
||||
>
|
||||
<XOctagon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
onClick={() => onMonitor(session.id)}
|
||||
title="实时查看"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Real-time Monitor Stub Dialog
|
||||
// =========================================================================
|
||||
|
||||
function MonitorStubDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sessionId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: number | null;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => onOpenChange(false)} />
|
||||
<div className="relative bg-slate-900 border border-slate-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Monitor className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-slate-100">实时查看</h3>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
会话 #{sessionId} 实时监控功能即将上线
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-2 bg-slate-800 rounded-md p-2">
|
||||
WebSocket 流式传输功能开发中,敬请期待。届时支持终端实时镜像查看与会话录制同步回放。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,15 @@ import {
|
||||
Clock, Plus, Trash2, RefreshCw, Edit, CheckCircle, XCircle,
|
||||
} from 'lucide-react';
|
||||
import { DEFAULT_PROBE_FORM, type ProbeForm } from './types';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface HealthProbeSectionProps {
|
||||
selectedHost: string;
|
||||
}
|
||||
|
||||
export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
const confirm = useConfirm();
|
||||
const qc = useQueryClient();
|
||||
const { data: probes = [], refetch } = useHealthProbes(selectedHost);
|
||||
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
|
||||
@@ -64,15 +67,20 @@ export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
|
||||
setProbeDialogOpen(false);
|
||||
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
|
||||
} catch {
|
||||
alert('保存失败');
|
||||
toast.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProbe = async (p: Record<string, unknown>) => {
|
||||
if (confirm(`确定删除探针"${p.name}"?`)) {
|
||||
await agentApi.deleteHealthProbe(p.id as number);
|
||||
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: '删除探针',
|
||||
description: `确定删除探针「${p.name}」?`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
await agentApi.deleteHealthProbe(p.id as number);
|
||||
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 端口策略对话框 // Logan.Li
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { parsePortList } from '@/utils/validate';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -69,8 +70,7 @@ export function PortPolicyDialog({
|
||||
onChange={e => {
|
||||
if (portPolicyMode === 'whitelist') return;
|
||||
const val = e.target.value;
|
||||
const nums = val.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0 && n <= 65535);
|
||||
onWhitelistChange(nums);
|
||||
onWhitelistChange(parsePortList(val));
|
||||
}}
|
||||
placeholder={portPolicyMode === 'whitelist' ? '白名单模式下所有端口自动上报' : '80,443,3306,6379'}
|
||||
readOnly={portPolicyMode === 'whitelist'}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// 终端审计 — 在线会话子面板 // Logan.Li
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, RefreshCw, XOctagon, Eye, Activity, Timer, TimerOff } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { LiveMonitorDialog } from '@/components/system/LiveMonitorDialog';
|
||||
import { useTerminalSessions, useTerminalSessionMutations } from '@/hooks/api';
|
||||
import type { TerminalSession } from './types';
|
||||
import { formatDuration, getStatusBadge } from './helpers';
|
||||
|
||||
const REFRESH_SEC = 10;
|
||||
|
||||
interface ActiveSessionsPanelProps {
|
||||
userToggledOff: boolean;
|
||||
onUserToggledOff: () => void;
|
||||
}
|
||||
|
||||
function useElapsedTimer(startTime: string | undefined): number {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!startTime) return;
|
||||
const start = new Date(startTime).getTime();
|
||||
const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [startTime]);
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
function ActiveSessionRow({
|
||||
session,
|
||||
onKill,
|
||||
onMonitor,
|
||||
}: {
|
||||
session: TerminalSession;
|
||||
onKill: (id: number, username: string) => void;
|
||||
onMonitor: (id: number) => void;
|
||||
}) {
|
||||
const elapsed = useElapsedTimer(session.start_time);
|
||||
|
||||
return (
|
||||
<TableRow className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{session.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{session.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">{session.server_name || '--'}</span>
|
||||
<span className="text-xs text-slate-500">{session.server_ip}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{session.server_port}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{session.start_time ? new Date(session.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-emerald-400 font-mono">{formatDuration(elapsed)}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(session.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800"
|
||||
onClick={() => onKill(session.id, session.username)}
|
||||
title="阻断会话"
|
||||
>
|
||||
<XOctagon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
onClick={() => onMonitor(session.id)}
|
||||
title="实时查看"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: ActiveSessionsPanelProps) {
|
||||
const confirm = useConfirm();
|
||||
const { killMutation } = useTerminalSessionMutations();
|
||||
const { data: sessions = [], isLoading, refetch } = useTerminalSessions({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(() => !userToggledOff);
|
||||
const [monitorOpen, setMonitorOpen] = useState(false);
|
||||
const [monitorSessionId, setMonitorSessionId] = useState<number | null>(null);
|
||||
|
||||
// 用户未手动关闭时,回到在线会话自动开启刷新
|
||||
useEffect(() => {
|
||||
if (!userToggledOff) setAutoRefresh(true);
|
||||
}, [userToggledOff]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const id = setInterval(() => refetch(), REFRESH_SEC * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, refetch]);
|
||||
|
||||
const filtered = sessions.filter((s) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(s.username || '').toLowerCase().includes(kw) ||
|
||||
(s.server_name || '').toLowerCase().includes(kw) ||
|
||||
(s.server_ip || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
const handleKill = async (id: number, username: string) => {
|
||||
const ok = await confirm({
|
||||
title: '阻断会话',
|
||||
description: `确认强制断开用户「${username}」的当前会话吗?`,
|
||||
confirmText: '阻断',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await killMutation.mutateAsync(id);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
|
||||
const openMonitor = (sessionId: number) => {
|
||||
setMonitorSessionId(sessionId);
|
||||
setMonitorOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/服务器..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? 'default' : 'outline'}
|
||||
className={
|
||||
autoRefresh
|
||||
? 'bg-emerald-600 hover:bg-emerald-700 text-white'
|
||||
: 'border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800'
|
||||
}
|
||||
onClick={() => {
|
||||
if (autoRefresh) onUserToggledOff();
|
||||
setAutoRefresh(!autoRefresh);
|
||||
}}
|
||||
title={autoRefresh ? `每 ${REFRESH_SEC}s 自动刷新 · 点击关闭` : '点击开启自动刷新'}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<>
|
||||
<Timer className="h-4 w-4 mr-1.5 animate-pulse" />
|
||||
{REFRESH_SEC}s
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TimerOff className="h-4 w-4 mr-2" />
|
||||
自动刷新
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Activity className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无在线会话</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">ID</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">目标服务器</TableHead>
|
||||
<TableHead className="text-slate-400">端口</TableHead>
|
||||
<TableHead className="text-slate-400">开始时间</TableHead>
|
||||
<TableHead className="text-slate-400">时长</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((s) => (
|
||||
<ActiveSessionRow key={s.id} session={s} onKill={handleKill} onMonitor={openMonitor} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LiveMonitorDialog open={monitorOpen} onOpenChange={setMonitorOpen} sessionId={monitorSessionId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// 终端审计 — 历史会话子面板 // Logan.Li
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, RefreshCw, Play, Download, Monitor } from 'lucide-react';
|
||||
import { terminalSessionApi } from '@/api';
|
||||
import { ReplayDialog } from '@/components/system/ReplayDialog';
|
||||
import { useTerminalSessions } from '@/hooks/api';
|
||||
import { formatDuration, getStatusBadge } from './helpers';
|
||||
|
||||
export function HistorySessionsPanel() {
|
||||
const { data: sessions = [], isLoading, refetch } = useTerminalSessions({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
status: 'closed',
|
||||
});
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [replayId, setReplayId] = useState<number | null>(null);
|
||||
const [replayOpen, setReplayOpen] = useState(false);
|
||||
|
||||
const filtered = sessions.filter((s) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(s.username || '').toLowerCase().includes(kw) ||
|
||||
(s.server_name || '').toLowerCase().includes(kw) ||
|
||||
(s.server_ip || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
const openReplay = (id: number) => {
|
||||
setReplayId(id);
|
||||
setReplayOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = async (id: number) => {
|
||||
try {
|
||||
await terminalSessionApi.download(id);
|
||||
} catch {
|
||||
// download error is handled in http.download
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/服务器/IP..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Monitor className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无历史会话</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">ID</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">目标服务器</TableHead>
|
||||
<TableHead className="text-slate-400">端口</TableHead>
|
||||
<TableHead className="text-slate-400">开始时间</TableHead>
|
||||
<TableHead className="text-slate-400">结束时间</TableHead>
|
||||
<TableHead className="text-slate-400">时长</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((s) => (
|
||||
<TableRow key={s.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{s.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">{s.server_name || '--'}</span>
|
||||
<span className="text-xs text-slate-500">{s.server_ip}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.server_port}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{s.end_time ? new Date(s.end_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{formatDuration(s.duration)}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(s.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||
onClick={() => openReplay(s.id)}
|
||||
title="回放"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-green-400 hover:bg-slate-800"
|
||||
onClick={() => handleDownload(s.id)}
|
||||
title="下载录制"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// 终端审计 — 会话命令子面板 // Logan.Li
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, RefreshCw } from 'lucide-react';
|
||||
import { useSessionCommands } from '@/hooks/api';
|
||||
import { formatSize } from './helpers';
|
||||
|
||||
export function SessionCommandsPanel() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [sessionIdInput, setSessionIdInput] = useState('');
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
...(sessionIdInput ? { session_id: Number(sessionIdInput) } : {}),
|
||||
}),
|
||||
[sessionIdInput],
|
||||
);
|
||||
|
||||
const { data: commands = [], isLoading, refetch } = useSessionCommands(queryParams);
|
||||
|
||||
const filtered = commands.filter((c) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(c.username || '').toLowerCase().includes(kw) ||
|
||||
(c.command || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索用户/命令..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="会话 ID(可选)"
|
||||
value={sessionIdInput}
|
||||
onChange={(e) => setSessionIdInput(e.target.value)}
|
||||
className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Search className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无会话命令记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">时间</TableHead>
|
||||
<TableHead className="text-slate-400">用户</TableHead>
|
||||
<TableHead className="text-slate-400">命令</TableHead>
|
||||
<TableHead className="text-slate-400">输出大小</TableHead>
|
||||
<TableHead className="text-slate-400">关联会话</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((c) => (
|
||||
<TableRow key={c.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{c.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
|
||||
{c.command || '--'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-slate-500">#{c.session_id}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 终端审计 — 展示辅助 // Logan.Li
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export function formatDuration(sec: number): string {
|
||||
if (!sec || sec < 0) return '--';
|
||||
if (sec < 60) return `${sec}秒`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
if (m < 60) return `${m}分${s > 0 ? `${s}秒` : ''}`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}时${rm > 0 ? `${rm}分` : ''}`;
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function getStatusBadge(status: string) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 inline-block animate-pulse" />
|
||||
活动中
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
失败
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||
已关闭
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 终端审计 — 类型定义 // Logan.Li
|
||||
|
||||
export interface TerminalSession {
|
||||
id: number;
|
||||
user_id?: number;
|
||||
username: string;
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
server_ip: string;
|
||||
server_port: number;
|
||||
client_ip: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
record_size: number;
|
||||
start_time: string;
|
||||
end_time?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionCommand {
|
||||
id: number;
|
||||
session_id: number;
|
||||
username: string;
|
||||
command: string;
|
||||
exit_code: number;
|
||||
output_size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type SubTabId = 'active' | 'history' | 'commands';
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from '@/types';
|
||||
import { removeItem, STORAGE_KEYS } from '@/utils/storage';
|
||||
|
||||
// 主题状态
|
||||
interface ThemeState {
|
||||
@@ -40,7 +41,10 @@ export const useUserStore = create<UserState>()(
|
||||
setToken: (token) => set({ token }),
|
||||
setPermissions: (permissions) => set({ permissions }),
|
||||
login: (user, token, permissions) => set({ user, token, isAuthenticated: true, permissions: permissions ?? null }),
|
||||
logout: () => { localStorage.removeItem('token'); set({ user: null as User | null, token: null as string | null, isAuthenticated: false, permissions: null }); },
|
||||
logout: () => {
|
||||
removeItem(STORAGE_KEYS.token);
|
||||
set({ user: null as User | null, token: null as string | null, isAuthenticated: false, permissions: null });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'user-storage',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 通用工具出口 — 对齐 Vue 后台惯例(request / export 等) // Logan.Li
|
||||
// 通用工具出口 — 对齐 Vue 后台惯例 // Logan.Li
|
||||
|
||||
export {
|
||||
http,
|
||||
@@ -11,4 +11,36 @@ export {
|
||||
type ListResponse,
|
||||
} from './request';
|
||||
|
||||
export { exportAsCSV, exportAsJSON } from './export';
|
||||
export { exportAsCSV, exportAsJSON } from './export';
|
||||
|
||||
export {
|
||||
STORAGE_KEYS,
|
||||
getItem,
|
||||
setItem,
|
||||
removeItem,
|
||||
getToken,
|
||||
setToken,
|
||||
clearToken,
|
||||
} from './storage';
|
||||
|
||||
export {
|
||||
type Theme,
|
||||
type ThemeOption,
|
||||
THEME_OPTIONS,
|
||||
THEME_ORDER,
|
||||
DEFAULT_THEME,
|
||||
migrateLegacyTheme,
|
||||
getInitialTheme,
|
||||
persistTheme,
|
||||
applyThemeToDocument,
|
||||
getNextTheme,
|
||||
} from './theme';
|
||||
|
||||
export {
|
||||
isNotEmpty,
|
||||
isValidPort,
|
||||
isValidIPv4,
|
||||
isValidHostname,
|
||||
isValidUrl,
|
||||
parsePortList,
|
||||
} from './validate';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useUserStore } from '@/store';
|
||||
import { getToken, clearToken } from './storage';
|
||||
|
||||
const API_PREFIX = '/api/v1';
|
||||
|
||||
@@ -52,7 +53,7 @@ export function getWsBase(): string {
|
||||
}
|
||||
|
||||
export function getAuthToken(): string {
|
||||
return useUserStore.getState().token || localStorage.getItem('token') || '';
|
||||
return getToken();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -89,6 +90,7 @@ async function request<T = any>(
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
useUserStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
throw new Error('登录已过期,请重新登录');
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// 本地存储封装 — token / 通用 KV // Logan.Li
|
||||
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
token: 'token',
|
||||
theme: 'aiops-theme',
|
||||
user: 'user-storage',
|
||||
} as const;
|
||||
|
||||
export function getItem(key: string): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setItem(key: string, value: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
}
|
||||
|
||||
export function removeItem(key: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取 JWT — zustand 优先,localStorage 兜底 // Logan.Li */
|
||||
export function getToken(): string {
|
||||
return useUserStore.getState().token || getItem(STORAGE_KEYS.token) || '';
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
setItem(STORAGE_KEYS.token, token);
|
||||
useUserStore.getState().setToken(token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
removeItem(STORAGE_KEYS.token);
|
||||
useUserStore.getState().setToken(null);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// 主题常量与持久化 — 供 useTheme / ThemeProvider 使用 // Logan.Li
|
||||
|
||||
import { getItem, setItem, STORAGE_KEYS } from './storage';
|
||||
|
||||
export type Theme =
|
||||
| 'ocean-deep'
|
||||
| 'light-professional'
|
||||
| 'midnight-purple'
|
||||
| 'pine-green'
|
||||
| 'orange-vitality';
|
||||
|
||||
export interface ThemeOption {
|
||||
id: Theme;
|
||||
name: string;
|
||||
description: string;
|
||||
isDark: boolean;
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: 'ocean-deep', name: '深海蓝', description: '专业稳重的深蓝色调', isDark: true, primaryColor: '#3b82f6' },
|
||||
{ id: 'light-professional', name: '明亮白', description: '清爽干净的浅色主题', isDark: false, primaryColor: '#2563eb' },
|
||||
{ id: 'midnight-purple', name: '午夜紫', description: '神秘优雅的紫色调', isDark: true, primaryColor: '#a855f7' },
|
||||
{ id: 'pine-green', name: '松林绿', description: '自然清新的绿色主题', isDark: true, primaryColor: '#22c55e' },
|
||||
{ id: 'orange-vitality', name: '活力橙', description: '温暖活力的橙色主题', isDark: true, primaryColor: '#f97316' },
|
||||
];
|
||||
|
||||
export const THEME_ORDER: Theme[] = THEME_OPTIONS.map((o) => o.id);
|
||||
export const DEFAULT_THEME: Theme = 'ocean-deep';
|
||||
|
||||
/** 兼容旧版 dark/light 主题名 // Logan.Li */
|
||||
export function migrateLegacyTheme(value: string | null): Theme | null {
|
||||
if (value === 'dark') return 'ocean-deep';
|
||||
if (value === 'light') return 'light-professional';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return DEFAULT_THEME;
|
||||
const stored = getItem(STORAGE_KEYS.theme);
|
||||
const migrated = migrateLegacyTheme(stored);
|
||||
if (migrated) {
|
||||
setItem(STORAGE_KEYS.theme, migrated);
|
||||
return migrated;
|
||||
}
|
||||
if (stored && THEME_ORDER.includes(stored as Theme)) {
|
||||
return stored as Theme;
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
export function persistTheme(theme: Theme): void {
|
||||
setItem(STORAGE_KEYS.theme, theme);
|
||||
}
|
||||
|
||||
export function applyThemeToDocument(theme: Theme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
export function getNextTheme(current: Theme): Theme {
|
||||
const idx = THEME_ORDER.indexOf(current);
|
||||
return THEME_ORDER[(idx + 1) % THEME_ORDER.length];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 通用表单校验 // Logan.Li
|
||||
|
||||
export function isNotEmpty(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false;
|
||||
return String(value).trim().length > 0;
|
||||
}
|
||||
|
||||
export function isValidPort(port: unknown): boolean {
|
||||
const n = Number(port);
|
||||
return Number.isInteger(n) && n > 0 && n <= 65535;
|
||||
}
|
||||
|
||||
export function isValidIPv4(ip: string): boolean {
|
||||
const parts = ip.trim().split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
return parts.every((p) => {
|
||||
const n = Number(p);
|
||||
return Number.isInteger(n) && n >= 0 && n <= 255 && String(n) === p;
|
||||
});
|
||||
}
|
||||
|
||||
export function isValidHostname(host: string): boolean {
|
||||
const h = host.trim();
|
||||
if (!h) return false;
|
||||
return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(h);
|
||||
}
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePortList(text: string): number[] {
|
||||
return text
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => isValidPort(n));
|
||||
}
|
||||
Reference in new issue
Block a user