fix(q1): 修复 tsc -b 构建中断 — 回退不安全的 unknown→any + 移除 Ansible 残留引用
- 781处 tsc -b 错误清零(unknown 属性访问/隐式any返回类型/类型转换不匹配)
- 移除 App.tsx 中已删除的 AnsibleManager/AnsibleWorkflow 导入和路由
- 修复 DeploymentView 中 _prefix 解构导致的属性不存在错误
- 修复 Header/Sidebar/PipelineEditor 中无效的类型断言
- asList() 返回类型 unknown[]→any[], 恢复 AgentMonitor 正常工作
- 放宽 noImplicitAny(临时), 待 types/ 定义完善后逐步恢复
- ESLint: unused=0, console=0, any=1041(Q1 后续降低)
- npm run build: ✅ 0 errors
This commit is contained in:
1 parent
4dd5e10b46
commit
5364d93ed7
149 files changed
+2021
-3128
No files matched your search
@@ -80,7 +80,7 @@ export function AgentAlerts() {
|
||||
setRuleOpen(true);
|
||||
};
|
||||
|
||||
const openEditRule = (rule: any) => {
|
||||
const openEditRule = ( rule: any) => {
|
||||
setEditingRule(rule);
|
||||
setRuleOpen(true);
|
||||
};
|
||||
@@ -97,7 +97,7 @@ export function AgentAlerts() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRule = async (rule: any) => {
|
||||
const handleToggleRule = async ( rule: any) => {
|
||||
try {
|
||||
await agentApi.updateRule(rule.id, { ...rule, enabled: !rule.enabled });
|
||||
loadData();
|
||||
|
||||
@@ -19,13 +19,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
@@ -107,7 +101,7 @@ export function AgentMonitor() {
|
||||
setCmdSubmitting(true);
|
||||
setCmdResult(null);
|
||||
try {
|
||||
const res: any = await agentApi.execCommand({
|
||||
const res: Record<string, any> = await agentApi.execCommand({
|
||||
hostname: cmdHost,
|
||||
command: cmdCommand.trim(),
|
||||
timeout: Number(cmdTimeout) || 30,
|
||||
@@ -214,7 +208,7 @@ export function AgentMonitor() {
|
||||
const cpu = Number(host.cpu_percent ?? host.cpu ?? 0);
|
||||
const mem = Number(host.mem_percent ?? host.mem ?? 0);
|
||||
const disk = Number(host.disk_percent ?? host.disk ?? 0);
|
||||
const ports: any[] = Array.isArray(host.ports) ? host.ports : [];
|
||||
const ports: Record<string, any>[] = Array.isArray(host.ports) ? host.ports : [];
|
||||
return (
|
||||
<Card
|
||||
key={host.hostname}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function AgentRuleDialog({ open, onOpenChange, editingRule, onSuccess }:
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
const payload: Record<string, any> = {
|
||||
name: ruleForm.name.trim(),
|
||||
metric: ruleForm.metric,
|
||||
operator: ruleForm.operator,
|
||||
|
||||
@@ -166,7 +166,7 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
ssh_key: sshKey,
|
||||
auth_type: authType,
|
||||
} : undefined;
|
||||
const res: any = await serverApi.installAgent(server.id, token, creds);
|
||||
const res: Record<string, unknown> = await serverApi.installAgent(server.id, token, creds);
|
||||
const installed = res?.installed || false;
|
||||
if (installed) {
|
||||
toast.success(`${server.hostname} Agent 安装成功`);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function HostDetailDialog({
|
||||
if (!host) return null;
|
||||
|
||||
const online = isOnline(host.last_report || host.last_reported_at || host.updated_at);
|
||||
const ports: any[] = Array.isArray(host.ports) ? host.ports : [];
|
||||
const ports: Record<string, any>[] = Array.isArray(host.ports) ? host.ports : [];
|
||||
|
||||
const chartData = history.map((h) => ({
|
||||
time: new Date(h.timestamp || h.time || h.created_at || Date.now()).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
|
||||
@@ -33,7 +33,7 @@ export function getMetricLabel(metric: string) {
|
||||
}
|
||||
|
||||
// 兼容多种列表响应格式(数组 / {list} / {items} / {data}) // Logan.Li
|
||||
export function asList(data: any): any[] {
|
||||
export function asList( data: any): unknown[] {
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.list)) return data.list;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Agent 监控相关工具函数(从 AgentMonitor.tsx 提取,供多组件共享) // Logan.Li
|
||||
|
||||
/** 在线判定:3 分钟内有上报视为在线 */
|
||||
export function isOnline(lastReport: any): boolean {
|
||||
export function isOnline( lastReport: any): boolean {
|
||||
if (!lastReport) return false;
|
||||
const t = new Date(lastReport).getTime();
|
||||
if (Number.isNaN(t)) return false;
|
||||
@@ -9,7 +9,7 @@ export function isOnline(lastReport: any): boolean {
|
||||
}
|
||||
|
||||
/** 相对时间格式化("3分钟前" / "2小时前") */
|
||||
export function timeAgo(ts: any): string {
|
||||
export function timeAgo( ts: any): string {
|
||||
if (!ts) return '--';
|
||||
const t = new Date(ts).getTime();
|
||||
if (Number.isNaN(t)) return '--';
|
||||
@@ -21,7 +21,8 @@ export function timeAgo(ts: any): string {
|
||||
}
|
||||
|
||||
/** 兼容多种列表响应包装:数组 / {list} / {items} / {data} */
|
||||
export function asList(data: any): any[] {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function asList( data: any): any[] {
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.list)) return data.list;
|
||||
|
||||
@@ -61,7 +61,7 @@ export function AIAlertAnalyzer() {
|
||||
const loadAlerts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await alertApi.getAlerts({ page: 1, page_size: 50 });
|
||||
const res: Record<string, any> = await alertApi.getAlerts({ page: 1, page_size: 50 });
|
||||
// 列表响应格式:http.ts 已提取 .data,此处仍是 {list, total}
|
||||
const list = res?.list || res || [];
|
||||
setAlerts(list.map(mapAlertEvent));
|
||||
@@ -102,7 +102,7 @@ export function AIAlertAnalyzer() {
|
||||
}`;
|
||||
|
||||
const res = await aiApi.chat([{ role: 'user', content: prompt }]);
|
||||
const content = (res as any)?.content || (res as any)?.message?.content || '';
|
||||
const content = (res as Record<string, any>)?.content || (res as Record<string, any>)?.message?.content || '';
|
||||
if (content) {
|
||||
setAnalysis(parseAIResponse(content, fallback));
|
||||
} else {
|
||||
|
||||
@@ -68,7 +68,7 @@ const defaultConfig: LLMConfigData = {
|
||||
// 拉取最近告警事件,格式化为紧凑列表供 LLM 分析
|
||||
async function loadAlertsContext(): Promise<string> {
|
||||
const res = await alertApi.getAlerts({ page: 1, page_size: 20 });
|
||||
const alerts: any[] = (res as any)?.list || [];
|
||||
const alerts: Record<string, any>[] = (res as Record<string, any>)?.list || [];
|
||||
if (!alerts.length) return '(当前无告警事件)';
|
||||
const lines = alerts.slice(0, 15).map((a, i) =>
|
||||
`${i + 1}. [${a.severity || '未知'}|${a.status || '-'}] ${a.rule_name || '告警'} → 目标:${a.target || '-'} 值:${a.value ?? '-'} 时间:${(a.started_at || '').slice(0, 16)}`,
|
||||
@@ -80,7 +80,7 @@ async function loadAlertsContext(): Promise<string> {
|
||||
// 拉取 Agent 实时主机指标,识别高负载主机
|
||||
async function loadMetricsContext(): Promise<string> {
|
||||
const res = await agentApi.getLatest();
|
||||
const hosts: any[] = Array.isArray(res) ? res : (res as any)?.list || [];
|
||||
const hosts: Record<string, any>[] = Array.isArray(res) ? res : (res as Record<string, any>)?.list || [];
|
||||
if (!hosts.length) return '(当前无 Agent 上报数据,可能未部署监控 Agent)';
|
||||
const lines = hosts.slice(0, 20).map((h, i) => {
|
||||
const cpu = h.cpu_usage ?? '-';
|
||||
@@ -101,11 +101,11 @@ async function loadReportContext(): Promise<string> {
|
||||
dashboardStatsApi.getAlerts(10).catch(() => null),
|
||||
dashboardStatsApi.getReleases(10).catch(() => null),
|
||||
]);
|
||||
const s: any = stats || {};
|
||||
const s: Record<string, any> = stats || {};
|
||||
const ss = s.server_stats || {}, ks = s.kubernetes_stats || {}, as = s.alert_stats || {}, ps = s.pipeline_stats || {}, cs = s.cost_stats || {};
|
||||
const overview = `服务器:${ss.total ?? 0}(运行${ss.running ?? 0}) | Kubernetes集群:${ks.total ?? 0} 节点:${ks.total_nodes ?? 0} Pod:${ks.total_pods ?? 0} | 告警:${as.total ?? 0}(firing ${as.firing ?? 0}) | 流水线:${ps.total ?? 0}(成功率${ps.success_rate ?? 0}%) | 本月成本:¥${cs.month_cost ?? 0}`;
|
||||
const alertList: any[] = Array.isArray(alerts) ? alerts : (alerts as any)?.list || [];
|
||||
const releaseList: any[] = Array.isArray(releases) ? releases : (releases as any)?.list || [];
|
||||
const alertList: Record<string, any>[] = Array.isArray(alerts) ? alerts : (alerts as Record<string, any>)?.list || [];
|
||||
const releaseList: Record<string, any>[] = Array.isArray(releases) ? releases : (releases as Record<string, any>)?.list || [];
|
||||
const alertLines = alertList.slice(0, 10).map((a, i) => `${i + 1}.[${a.severity || '?'}|${a.status || '?'}] ${a.rule_name || '-'} → ${a.target || '-'}`);
|
||||
const releaseLines = releaseList.slice(0, 10).map((r, i) => `${i + 1}.${r.app_name || '-'} ${r.version || ''} [${r.environment || '-'}/${r.status || '-'}] by ${r.author || '-'}`);
|
||||
return `平台概览:${overview}\n\n最近告警:\n${alertLines.join('\n') || '(无)'}\n\n最近发布:\n${releaseLines.join('\n') || '(无)'}`;
|
||||
@@ -188,7 +188,7 @@ export function AIOpsAssistant() {
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const res = await aiApi.getHistory(sessionId);
|
||||
const items: ChatHistoryItem[] = (res as any)?.list || [];
|
||||
const items: ChatHistoryItem[] = (res as Record<string, any>)?.list || [];
|
||||
setMessages(
|
||||
items.map((m) => ({
|
||||
id: String(m.id),
|
||||
@@ -227,7 +227,7 @@ export function AIOpsAssistant() {
|
||||
try {
|
||||
const saved = await aiApi.saveConfig(config);
|
||||
setConfig(saved);
|
||||
setConfigReady(!!(saved.enabled && (saved as any).configured));
|
||||
setConfigReady(!!(saved.enabled && (saved as Record<string, any>).configured));
|
||||
toast.success('配置已保存');
|
||||
setIsConfigOpen(false);
|
||||
} catch (e) {
|
||||
|
||||
@@ -66,9 +66,9 @@ export function AIPredictiveAnalytics() {
|
||||
]);
|
||||
setRawMetrics(Array.isArray(metricsData) ? metricsData : []);
|
||||
setServerStats(statsResp || {});
|
||||
const alertsRaw = (alertsResp as any)?.list || (alertsResp as any) || [];
|
||||
const alertsRaw = (alertsResp as Record<string, any>)?.list || (alertsResp as Record<string, any>) || [];
|
||||
setAlertEvents(Array.isArray(alertsRaw) ? alertsRaw : []);
|
||||
const rulesRaw = (rulesResp as any)?.list || (rulesResp as any) || [];
|
||||
const rulesRaw = (rulesResp as Record<string, any>)?.list || (rulesResp as Record<string, any>) || [];
|
||||
setAlertRules(Array.isArray(rulesRaw) ? rulesRaw : []);
|
||||
} catch {
|
||||
// 保持空数据,组件显示空状态
|
||||
|
||||
@@ -145,7 +145,7 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis {
|
||||
|
||||
// ─── 后端告警事件 → AlertItem 映射 ───
|
||||
// 后端 alert event 字段为 snake_case(rule_name/target/started_at/value 等),此处统一映射为前端 AlertItem
|
||||
export function mapAlertEvent(event: any): AlertItem {
|
||||
export function mapAlertEvent( event: any): AlertItem {
|
||||
const expr = event.rule?.expression || '';
|
||||
// 从表达式(如 "cpu_usage > 80")中提取指标名与阈值
|
||||
const metricMatch = expr.match(/([\w_]+)\s*[><=!]+\s*([\d.]+)/);
|
||||
|
||||
@@ -88,7 +88,7 @@ export function linearRegression(values: number[]): { slope: number; r2: number
|
||||
}
|
||||
|
||||
/** 从时序指标中提取按时间排序的数值序列 */
|
||||
export function extractSeries(metrics: any[], name: string): number[] {
|
||||
export function extractSeries( metrics: any[], name: string): number[] {
|
||||
return metrics
|
||||
.filter((m) => m.name === name)
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
|
||||
@@ -143,7 +143,7 @@ export function ApprovalCreateDialog({
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
|
||||
{parseFields(selectedTemplate.form_schema).map((field: any) => {
|
||||
{parseFields(selectedTemplate.form_schema).map(( field: any) => {
|
||||
const isTextarea = field.type === 'textarea';
|
||||
return (
|
||||
<div key={field.key} className={cn('space-y-1.5', isTextarea && 'col-span-2')}>
|
||||
|
||||
@@ -22,8 +22,8 @@ interface ApprovalDetailDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
instance: any;
|
||||
templates: any[];
|
||||
onApprove: (instance: any) => void;
|
||||
onReject: (instance: any) => void;
|
||||
onApprove: ( instance: any) => void;
|
||||
onReject: ( instance: any) => void;
|
||||
}
|
||||
|
||||
// 审批详情弹窗 — 展示流程详情/表单/进度/操作记录 // Logan.Li
|
||||
@@ -91,7 +91,7 @@ export function ApprovalDetailDialog({
|
||||
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4">
|
||||
<p className="text-sm font-medium text-[var(--dg-text-secondary)] mb-3">操作记录</p>
|
||||
<div className="space-y-2">
|
||||
{instance.actions.map((action: any, index: number) => (
|
||||
{instance.actions.map(( action: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-3 text-sm">
|
||||
{action.action === 'approve' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
|
||||
@@ -95,7 +95,7 @@ export function ApprovalFlow() {
|
||||
});
|
||||
|
||||
// 打开详情弹窗 // Logan.Li
|
||||
const handleView = (instance: any) => {
|
||||
const handleView = ( instance: any) => {
|
||||
setSelectedInstance(instance);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
@@ -123,7 +123,7 @@ export function ApprovalFlow() {
|
||||
setActionDialogOpen(false);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error('操作失败', { description: error.message });
|
||||
toast.error('操作失败', { description: (error as Record<string, any>).message });
|
||||
} finally {
|
||||
setActionSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -76,9 +76,9 @@ interface ApprovalProcessTabProps {
|
||||
setSearchQuery: (v: string) => void;
|
||||
statusFilter: string;
|
||||
setStatusFilter: (v: string) => void;
|
||||
onView: (instance: any) => void;
|
||||
onApprove: (instance: any) => void;
|
||||
onReject: (instance: any) => void;
|
||||
onView: ( instance: any) => void;
|
||||
onApprove: ( instance: any) => void;
|
||||
onReject: ( instance: any) => void;
|
||||
}
|
||||
|
||||
export function ApprovalProcessTab({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Filter, Edit, GitBranch, ArrowRight } from 'lucide-react';
|
||||
|
||||
function parseSteps(steps: any) {
|
||||
function parseSteps( steps: any) {
|
||||
if (Array.isArray(steps)) return steps;
|
||||
if (typeof steps === 'string') {
|
||||
try {
|
||||
@@ -31,14 +31,14 @@ const getApprovalTypeColor = (value: string): string =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
|
||||
|
||||
// 模拟审批流程图
|
||||
function renderFlowChart(flow: any) {
|
||||
function renderFlowChart( flow: any) {
|
||||
const nodes = parseSteps(flow.steps);
|
||||
if (nodes.length === 0) {
|
||||
return <div className="text-[var(--dg-text-muted)] text-center py-4">暂无流程节点</div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto py-4">
|
||||
{nodes.map((node: any, index: number) => (
|
||||
{nodes.map(( node: any, index: number) => (
|
||||
<div key={node.id || index} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
// 解析审批步骤数组(兼容 JSON 字符串) // Logan.Li
|
||||
export function parseSteps(steps: any) {
|
||||
export function parseSteps( steps: any) {
|
||||
if (Array.isArray(steps)) return steps;
|
||||
if (typeof steps === 'string') {
|
||||
try {
|
||||
@@ -24,7 +24,7 @@ export function parseSteps(steps: any) {
|
||||
}
|
||||
|
||||
// 解析模板的 form_schema,返回字段定义数组 // Logan.Li
|
||||
export function parseFields(formSchema: any): any[] {
|
||||
export function parseFields( formSchema: any): any[] {
|
||||
if (!formSchema) return [];
|
||||
const schema = typeof formSchema === 'string' ? (() => { try { return JSON.parse(formSchema); } catch { return null; } })() : formSchema;
|
||||
if (!schema) return [];
|
||||
@@ -78,26 +78,26 @@ export const getApprovalTypeIcon = (value: string) =>
|
||||
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.icon || FileText;
|
||||
|
||||
// 获取审批实例的类型(优先从关联模板取) // Logan.Li
|
||||
export const getInstanceType = (instance: any): string =>
|
||||
export const getInstanceType = ( instance: any): string =>
|
||||
instance?.template?.type || instance?.type || '';
|
||||
|
||||
// 计算审批进度信息 // Logan.Li
|
||||
export function getProgressInfo(instance: any) {
|
||||
export function getProgressInfo( instance: any) {
|
||||
const steps = parseSteps(instance.steps);
|
||||
const currentIndex = instance.current_step ?? 0;
|
||||
const total = steps.length;
|
||||
const completed = steps.filter((s: any) => s.status === 'approved' || s.status === 'completed').length;
|
||||
const completed = steps.filter(( s: any) => s.status === 'approved' || s.status === 'completed').length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
return { steps, currentIndex, total, completed, percentage };
|
||||
}
|
||||
|
||||
// 渲染审批进度(用于详情弹窗) // Logan.Li
|
||||
export function renderStepProgress(instance: any) {
|
||||
export function renderStepProgress( instance: any) {
|
||||
const { steps, currentIndex } = getProgressInfo(instance);
|
||||
if (steps.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{steps.map((step: any, index: number) => {
|
||||
{steps.map(( step: any, index: number) => {
|
||||
const isCompleted = step.status === 'approved' || step.status === 'completed';
|
||||
const isCurrent = index === currentIndex;
|
||||
const isRejected = step.status === 'rejected';
|
||||
|
||||
@@ -85,12 +85,12 @@ export function Login() {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await userApi.login(username, password);
|
||||
const result = await userApi.login(username, password) as { user: any; token: string };
|
||||
useUserStore.getState().login(result.user, result.token);
|
||||
localStorage.setItem('token', result.token);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '登录失败,请检查用户名和密码';
|
||||
} catch (err: any) {
|
||||
const message = err instanceof Error ? (err as Record<string, any>).message : '登录失败,请检查用户名和密码';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Ansible 查看 Playbook 内容弹窗 // Logan.Li
|
||||
import { FileCode, Edit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { Playbook } from './ansible-helpers';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
playbook: Playbook | null;
|
||||
onEdit: (pb: Playbook) => void;
|
||||
}
|
||||
|
||||
const AnsibleDetailDialog: React.FC<Props> = ({ open, onOpenChange, playbook, onEdit }) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileCode className="w-5 h-5 text-blue-500" />
|
||||
{playbook?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{playbook?.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[500px] w-full rounded-md border bg-black">
|
||||
<pre className="p-4 font-mono text-sm text-green-400 whitespace-pre-wrap">
|
||||
{playbook?.content || '(空)'}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
{playbook && (
|
||||
<Button onClick={() => onEdit(playbook)}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnsibleDetailDialog;
|
||||
@@ -1,73 +0,0 @@
|
||||
// Ansible Playbook 执行记录表格(6 列:#/Playbook/状态/开始时间/耗时/操作) // Logan.Li
|
||||
import React from 'react';
|
||||
import { RotateCcw, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getStatusBadge, formatDate, type ExecutionRecord } from './ansible-helpers';
|
||||
|
||||
interface AnsibleExecutionsTableProps {
|
||||
executions: ExecutionRecord[];
|
||||
onRefresh: () => void;
|
||||
onViewLogs: (exec: ExecutionRecord) => void;
|
||||
}
|
||||
|
||||
const AnsibleExecutionsTable: React.FC<AnsibleExecutionsTableProps> = ({
|
||||
executions, onRefresh, onViewLogs,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={onRefresh}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">#</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Playbook</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">开始时间</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">耗时</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{executions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
暂无执行记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
executions.map((job) => (
|
||||
<tr key={job.id} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{job.id}</td>
|
||||
<td className="px-4 py-3 text-sm">{job.task_name}</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(job.status)}</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{formatDate(job.started_at)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{job.duration ? `${(job.duration / 1000).toFixed(1)}s` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button variant="ghost" size="sm" title="查看日志" onClick={() => onViewLogs(job)}>
|
||||
<Terminal className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnsibleExecutionsTable;
|
||||
@@ -1,111 +0,0 @@
|
||||
// Ansible 新建/编辑 Playbook 弹窗 // Logan.Li
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { categories } from './ansible-helpers';
|
||||
|
||||
export interface PlaybookFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingId: number | null;
|
||||
formData: PlaybookFormData;
|
||||
setFormData: React.Dispatch<React.SetStateAction<PlaybookFormData>>;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const AnsibleFormDialog: React.FC<Props> = ({ open, onOpenChange, editingId, formData, setFormData, onSave }) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId !== null ? '编辑 Playbook' : '新建 Playbook'}</DialogTitle>
|
||||
<DialogDescription>编写 Ansible Playbook 实现自动化运维</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 overflow-y-auto max-h-[60vh] pr-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Playbook 名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>分类</Label>
|
||||
<Select value={formData.category} onValueChange={(v) => setFormData({ ...formData, category: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map(c => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Playbook 描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>标签(逗号分隔)</Label>
|
||||
<Input
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
placeholder="如: nginx, web, 部署"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>超时(秒)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.timeout}
|
||||
onChange={(e) => setFormData({ ...formData, timeout: Number(e.target.value) })}
|
||||
placeholder="600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Playbook 内容 (YAML)</Label>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
className="min-h-[380px] font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={onSave}>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
{editingId !== null ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnsibleFormDialog;
|
||||
@@ -1,38 +0,0 @@
|
||||
// Ansible 执行日志弹窗 // Logan.Li
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { getStatusBadge, type ExecutionRecord } from './ansible-helpers';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
exec: ExecutionRecord | null;
|
||||
}
|
||||
|
||||
const AnsibleLogsDialog: React.FC<Props> = ({ open, onOpenChange, exec }) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>执行日志</DialogTitle>
|
||||
<DialogDescription>
|
||||
{exec?.task_name} · {getStatusBadge(exec?.status || '')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[480px] w-full rounded-md border bg-black">
|
||||
<pre className="p-4 font-mono text-sm text-green-400 whitespace-pre-wrap">
|
||||
{exec?.output || '暂无日志输出...'}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnsibleLogsDialog;
|
||||
@@ -1,414 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
RotateCcw, Plus, Search, Trash2, Loader2,
|
||||
Eye, Pencil, Copy, Play, Tags,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { ansibleApi, taskExecutionApi } from '@/services/api';
|
||||
import {
|
||||
mapPlaybook, categories, samplePlaybook, formatDate,
|
||||
type Playbook, type ExecutionRecord,
|
||||
} from './ansible-helpers';
|
||||
import AnsibleFormDialog, { type PlaybookFormData } from './AnsibleFormDialog';
|
||||
import AnsibleDetailDialog from './AnsibleDetailDialog';
|
||||
import AnsibleLogsDialog from './AnsibleLogsDialog';
|
||||
import AnsibleExecutionsTable from './AnsibleExecutionsTable';
|
||||
|
||||
const AnsibleManager: React.FC = () => {
|
||||
const _confirm = useConfirm();
|
||||
const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
|
||||
const [executions, setExecutions] = useState<ExecutionRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [activeTab, setActiveTab] = useState('playbooks');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
// 弹窗状态
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [selectedPlaybook, setSelectedPlaybook] = useState<Playbook | null>(null);
|
||||
const [selectedExec, setSelectedExec] = useState<ExecutionRecord | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<PlaybookFormData>({
|
||||
name: '', description: '', content: '', category: 'deployment', tags: '', timeout: 600,
|
||||
});
|
||||
|
||||
const fetchPlaybooks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await ansibleApi.getPlaybooks({ page_size: 200 });
|
||||
const items = (res as any)?.list || [];
|
||||
setPlaybooks(items.map(mapPlaybook));
|
||||
} catch {
|
||||
toast.error('获取 Playbook 列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchExecutions = async () => {
|
||||
try {
|
||||
const res = await taskExecutionApi.getExecutions({ task_type: 'playbook', page_size: 50 });
|
||||
setExecutions((res as any)?.list || []);
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaybooks();
|
||||
fetchExecutions();
|
||||
}, []);
|
||||
|
||||
// ---- 新建/编辑 ----
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setFormData({ name: '', description: '', content: samplePlaybook, category: 'deployment', tags: '', timeout: 600 });
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (pb: Playbook) => {
|
||||
setEditingId(pb.id);
|
||||
setFormData({
|
||||
name: pb.name, description: pb.description, content: pb.content,
|
||||
category: pb.category, tags: pb.tags.join(', '), timeout: pb.timeout,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const tagsToJson = (raw: string): string => {
|
||||
const arr = raw.split(',').map(t => t.trim()).filter(Boolean);
|
||||
return JSON.stringify(arr);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name || !formData.content) {
|
||||
toast.error('请填写名称和 Playbook 内容');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: formData.name, description: formData.description, content: formData.content,
|
||||
category: formData.category, tags: tagsToJson(formData.tags), timeout: Number(formData.timeout) || 600,
|
||||
};
|
||||
try {
|
||||
if (editingId !== null) {
|
||||
await ansibleApi.updatePlaybook(editingId, payload);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
await ansibleApi.createPlaybook(payload);
|
||||
toast.success('创建成功');
|
||||
}
|
||||
setEditorOpen(false);
|
||||
fetchPlaybooks();
|
||||
} catch {
|
||||
toast.error(editingId !== null ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 克隆 ----
|
||||
const handleClone = async (pb: Playbook) => {
|
||||
try {
|
||||
await ansibleApi.clonePlaybook(pb.id);
|
||||
toast.success('克隆成功');
|
||||
fetchPlaybooks();
|
||||
} catch {
|
||||
toast.error('克隆失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 单个删除 ----
|
||||
const handleDelete = async (pb: Playbook) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除 Playbook',
|
||||
description: `确定删除「${pb.name}」吗?此操作不可恢复。`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await ansibleApi.deletePlaybook(pb.id);
|
||||
toast.success('已删除');
|
||||
fetchPlaybooks();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 批量删除 ----
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.length === filtered.length && filtered.length > 0) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(filtered.map(p => p.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const ok = await _confirm({
|
||||
title: '批量删除',
|
||||
description: `确定删除选中的 ${selectedIds.length} 个 Playbook 吗?`,
|
||||
confirmText: '批量删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await ansibleApi.batchDeletePlaybooks(selectedIds);
|
||||
toast.success(`已删除 ${selectedIds.length} 个`);
|
||||
setSelectedIds([]);
|
||||
fetchPlaybooks();
|
||||
} catch {
|
||||
toast.error('批量删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 执行 ----
|
||||
const handleExecute = async (pb: Playbook) => {
|
||||
const ok = await _confirm({
|
||||
title: '执行 Playbook',
|
||||
description: `将在 AIOps 主机本地执行「${pb.name}」(ansible-playbook --connection=local)。`,
|
||||
confirmText: '开始执行',
|
||||
});
|
||||
if (!ok) return;
|
||||
setExecuting(true);
|
||||
try {
|
||||
const res = await ansibleApi.executePlaybook(pb.id);
|
||||
const exec = (res as any)?.data ?? res;
|
||||
if (exec?.status === 'success') {
|
||||
toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`);
|
||||
} else {
|
||||
toast.error(`执行失败,耗时 ${exec?.duration ?? 0}ms`);
|
||||
}
|
||||
fetchPlaybooks();
|
||||
fetchExecutions();
|
||||
} catch {
|
||||
toast.error('执行请求失败');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 过滤 ----
|
||||
const filtered = playbooks.filter(pb => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return pb.name.toLowerCase().includes(q) || pb.description.toLowerCase().includes(q) ||
|
||||
pb.tags.some(t => t.toLowerCase().includes(q));
|
||||
}).filter(pb => categoryFilter === 'all' || pb.category === categoryFilter);
|
||||
|
||||
const catLabel = (v: string) => categories.find(c => c.value === v)?.label || v;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={v => { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}>
|
||||
<TabsList className="grid w-full grid-cols-2 lg:w-[360px]">
|
||||
<TabsTrigger value="playbooks">模板管理</TabsTrigger>
|
||||
<TabsTrigger value="jobs">执行记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ============ 模板管理 ============ */}
|
||||
<TabsContent value="playbooks" className="space-y-4">
|
||||
{/* 搜索栏 + 新建按钮 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索名称、描述、标签..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
{categories.map(c => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.length > 0 && (
|
||||
<Button variant="destructive" size="sm" onClick={handleBatchDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
删除 ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={fetchPlaybooks}>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新建模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-3 text-left">
|
||||
<Checkbox
|
||||
checked={selectedIds.length === filtered.length && filtered.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left font-medium">名称</th>
|
||||
<th className="px-3 py-3 text-left font-medium hidden md:table-cell">分类</th>
|
||||
<th className="px-3 py-3 text-left font-medium hidden lg:table-cell">标签</th>
|
||||
<th className="px-3 py-3 text-left font-medium hidden xl:table-cell">执行次数</th>
|
||||
<th className="px-3 py-3 text-left font-medium hidden xl:table-cell">更新时间</th>
|
||||
<th className="px-3 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
暂无模板,点击"新建模板"创建
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map(pb => (
|
||||
<tr key={pb.id} className="border-b hover:bg-muted/30 transition-colors">
|
||||
<td className="px-3 py-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(pb.id)}
|
||||
onCheckedChange={() => toggleSelect(pb.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="font-medium truncate max-w-[200px]">{pb.name}</div>
|
||||
{pb.description && (
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">{pb.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 hidden md:table-cell">
|
||||
<Badge variant="secondary" className="text-xs">{catLabel(pb.category)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-3 hidden lg:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pb.tags.length > 0 ? pb.tags.map(t => (
|
||||
<Badge key={t} variant="outline" className="text-xs"><Tags className="w-3 h-3 mr-0.5" />{t}</Badge>
|
||||
)) : <span className="text-muted-foreground">-</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 hidden xl:table-cell text-muted-foreground">{pb.run_count}</td>
|
||||
<td className="px-3 py-3 hidden xl:table-cell text-muted-foreground text-xs">
|
||||
{formatDate(pb.updated_at)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8"
|
||||
title="查看内容"
|
||||
onClick={() => { setSelectedPlaybook(pb); setDetailOpen(true); }}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8"
|
||||
title="编辑"
|
||||
onClick={() => openEdit(pb)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8"
|
||||
title="克隆"
|
||||
onClick={() => handleClone(pb)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-green-600"
|
||||
title="执行"
|
||||
disabled={executing}
|
||||
onClick={() => handleExecute(pb)}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-destructive"
|
||||
title="删除"
|
||||
onClick={() => handleDelete(pb)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ============ 执行记录 ============ */}
|
||||
<TabsContent value="jobs" className="space-y-4">
|
||||
<AnsibleExecutionsTable
|
||||
executions={executions}
|
||||
onRefresh={fetchExecutions}
|
||||
onViewLogs={exec => { setSelectedExec(exec); setLogsOpen(true); }}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 新建/编辑对话框 */}
|
||||
<AnsibleFormDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
editingId={editingId}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 查看内容对话框 */}
|
||||
<AnsibleDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
playbook={selectedPlaybook}
|
||||
onEdit={pb => { setDetailOpen(false); openEdit(pb); }}
|
||||
/>
|
||||
|
||||
{/* 执行日志对话框 */}
|
||||
<AnsibleLogsDialog
|
||||
open={logsOpen}
|
||||
onOpenChange={setLogsOpen}
|
||||
exec={selectedExec}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnsibleManager;
|
||||
@@ -1,81 +0,0 @@
|
||||
// Ansible Playbook 卡片(复选框 + 名称 + 分类徽章 + 描述 + 标签 + 元信息 + 5 行操作按钮) // Logan.Li
|
||||
import React from 'react';
|
||||
import { FileCode, Eye, Edit, Copy, Trash2, Play, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { categories, type Playbook } from './ansible-helpers';
|
||||
|
||||
interface AnsiblePlaybookCardProps {
|
||||
playbook: Playbook;
|
||||
selected: boolean;
|
||||
executing: boolean;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onView: (pb: Playbook) => void;
|
||||
onExecute: (pb: Playbook) => void;
|
||||
onEdit: (pb: Playbook) => void;
|
||||
onClone: (pb: Playbook) => void;
|
||||
onDelete: (pb: Playbook) => void;
|
||||
}
|
||||
|
||||
const AnsiblePlaybookCard: React.FC<AnsiblePlaybookCardProps> = ({
|
||||
playbook, selected, executing,
|
||||
onToggleSelect, onView, onExecute, onEdit, onClone, onDelete,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() => onToggleSelect(playbook.id)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<FileCode className="w-5 h-5 text-blue-500 shrink-0" />
|
||||
<CardTitle className="text-base truncate">{playbook.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{categories.find(c => c.value === playbook.category)?.label || playbook.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">{playbook.description || '暂无描述'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{playbook.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{playbook.tags.map((tag, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
|
||||
<span>执行 {playbook.run_count} 次</span>
|
||||
<span>超时 {playbook.timeout}s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" title="查看内容" onClick={() => onView(playbook)}>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="执行" disabled={executing} onClick={() => onExecute(playbook)}>
|
||||
{executing
|
||||
? <Loader2 className="w-4 h-4 animate-spin text-blue-600" />
|
||||
: <Play className="w-4 h-4 text-green-600" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="编辑" onClick={() => onEdit(playbook)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="复制" onClick={() => onClone(playbook)}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="删除" onClick={() => onDelete(playbook)}>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnsiblePlaybookCard;
|
||||
@@ -1,74 +0,0 @@
|
||||
// Ansible Playbook 统计卡片(4 张:总数/执行中/今日成功/今日失败) // Logan.Li
|
||||
import React from 'react';
|
||||
import { FileCode, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface AnsibleStatsCardsProps {
|
||||
total: number;
|
||||
running: number;
|
||||
todaySuccess: number;
|
||||
todayFailed: number;
|
||||
}
|
||||
|
||||
const AnsibleStatsCards: React.FC<AnsibleStatsCardsProps> = ({
|
||||
total, running, todaySuccess, todayFailed,
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Playbook 总数</p>
|
||||
<p className="text-2xl font-bold">{total}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-500/10 rounded-lg">
|
||||
<FileCode className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">执行中</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{running}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-500/10 rounded-lg">
|
||||
<Loader2 className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">今日成功</p>
|
||||
<p className="text-2xl font-bold text-green-600">{todaySuccess}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-500/10 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">今日失败</p>
|
||||
<p className="text-2xl font-bold text-red-600">{todayFailed}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-red-500/10 rounded-lg">
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnsibleStatsCards;
|
||||
@@ -1,886 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { toast } from 'sonner';
|
||||
import { ansibleApi } from '@/services/api';
|
||||
import {
|
||||
Play, Server, ChevronRight, ChevronLeft, CheckCircle, Loader2,
|
||||
Search, RefreshCw, Terminal, Clock, XCircle, FileText, Zap,
|
||||
Boxes, Shield, Database, Activity, Wrench, Network,
|
||||
Copy, Plus, Trash2, Pencil, Eye,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
mapPlaybook, categories, getStatusBadge, formatDate, samplePlaybook,
|
||||
type Playbook, type ExecutionRecord,
|
||||
} from './ansible-helpers';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import AnsibleFormDialog, { type PlaybookFormData } from './AnsibleFormDialog';
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
interface MatchedHost {
|
||||
id: number;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
os: string;
|
||||
status: string;
|
||||
has_auth: boolean;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
type Step = 'template' | 'hosts' | 'review' | 'execute';
|
||||
|
||||
// ============ Icons per category ============
|
||||
|
||||
const catIcons: Record<string, React.ComponentType<any>> = {
|
||||
deployment: Boxes,
|
||||
configuration: Wrench,
|
||||
maintenance: Activity,
|
||||
security: Shield,
|
||||
backup: Database,
|
||||
monitoring: Network,
|
||||
};
|
||||
|
||||
// ============ Main Component ============
|
||||
|
||||
export default function AnsibleWorkflow() {
|
||||
const _confirm = useConfirm();
|
||||
|
||||
// ---- State ----
|
||||
const [step, setStep] = useState<Step>('template');
|
||||
const [activeTab, setActiveTab] = useState('workflow');
|
||||
|
||||
// Template
|
||||
const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
|
||||
const [loadingPB, setLoadingPB] = useState(false);
|
||||
const [searchPB, setSearchPB] = useState('');
|
||||
const [catFilter, setCatFilter] = useState('all');
|
||||
const [selectedPB, setSelectedPB] = useState<Playbook | null>(null);
|
||||
const [selectedPBs, setSelectedPBs] = useState<Set<number>>(new Set()); // 批量选择
|
||||
|
||||
// 模板编辑弹窗
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<PlaybookFormData>({
|
||||
name: '', description: '', content: '', category: 'deployment', tags: '', timeout: 600,
|
||||
});
|
||||
|
||||
// ---- 新建模板 ----
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setFormData({ name: '', description: '', content: samplePlaybook, category: 'deployment', tags: '', timeout: 600 });
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
// ---- 编辑模板 ----
|
||||
const openEdit = (pb: Playbook) => {
|
||||
setEditingId(pb.id);
|
||||
setFormData({
|
||||
name: pb.name, description: pb.description, content: pb.content,
|
||||
category: pb.category, tags: pb.tags.join(', '), timeout: pb.timeout,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const tagsToJson = (raw: string): string => {
|
||||
const arr = raw.split(',').map(t => t.trim()).filter(Boolean);
|
||||
return JSON.stringify(arr);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name || !formData.content) {
|
||||
toast.error('请填写名称和 YAML 内容');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: formData.name, description: formData.description, content: formData.content,
|
||||
category: formData.category, tags: tagsToJson(formData.tags), timeout: Number(formData.timeout) || 600,
|
||||
};
|
||||
try {
|
||||
if (editingId !== null) {
|
||||
await ansibleApi.updatePlaybook(editingId, payload);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
await ansibleApi.createPlaybook(payload);
|
||||
toast.success('创建成功');
|
||||
}
|
||||
setEditorOpen(false);
|
||||
fetchPlaybooks();
|
||||
} catch {
|
||||
toast.error(editingId !== null ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
// Hosts
|
||||
const [hostPattern, setHostPattern] = useState('');
|
||||
const [matchType, setMatchType] = useState('contains');
|
||||
const [hosts, setHosts] = useState<MatchedHost[]>([]);
|
||||
const [hostsTotal, setHostsTotal] = useState(0);
|
||||
const [loadingHosts, setLoadingHosts] = useState(false);
|
||||
const [selectedHosts, setSelectedHosts] = useState<Set<number>>(new Set());
|
||||
const [hostSearch, setHostSearch] = useState('');
|
||||
|
||||
// Execution
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [execStatus, setExecStatus] = useState('');
|
||||
const [execOutput, setExecOutput] = useState('');
|
||||
const [execDuration, setExecDuration] = useState(0);
|
||||
const [execJobId, setExecJobId] = useState<number | null>(null);
|
||||
|
||||
// History
|
||||
const [executions, setExecutions] = useState<ExecutionRecord[]>([]);
|
||||
const [loadingExecs, setLoadingExecs] = useState(false);
|
||||
|
||||
// ============ Data Fetching ============
|
||||
|
||||
const fetchPlaybooks = useCallback(async () => {
|
||||
setLoadingPB(true);
|
||||
try {
|
||||
const res = await ansibleApi.getPlaybooks({ page_size: 100 });
|
||||
const items = (res as any)?.list || [];
|
||||
setPlaybooks(items.map(mapPlaybook));
|
||||
} catch { toast.error('获取 Playbook 失败'); }
|
||||
finally { setLoadingPB(false); }
|
||||
}, []);
|
||||
|
||||
// 克隆 Playbook 为自定义模板 // Logan.Li
|
||||
const clonePlaybook = async (pb: Playbook, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
try {
|
||||
const res = await ansibleApi.clonePlaybook(pb.id);
|
||||
const cloned = mapPlaybook((res as any)?.data ?? res);
|
||||
toast.success(`已克隆「${cloned.name}」,可在模板管理中编辑`);
|
||||
await fetchPlaybooks();
|
||||
setSelectedPB(cloned);
|
||||
} catch { toast.error('克隆失败'); }
|
||||
};
|
||||
|
||||
const fetchExecutions = useCallback(async () => {
|
||||
setLoadingExecs(true);
|
||||
try {
|
||||
const res = await ansibleApi.getJobs({ page_size: 30 });
|
||||
setExecutions((res as any)?.list || []);
|
||||
} catch { /* silent */ }
|
||||
finally { setLoadingExecs(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchPlaybooks(); fetchExecutions(); }, []);
|
||||
|
||||
// ============ Host Matching ============
|
||||
|
||||
const matchHosts = useCallback(async (pattern: string, type: string) => {
|
||||
if (!pattern && type === 'contains') return; // skip empty contains
|
||||
setLoadingHosts(true);
|
||||
try {
|
||||
const res = await ansibleApi.matchHosts({
|
||||
pattern: pattern || undefined,
|
||||
match_type: type,
|
||||
page_size: 100,
|
||||
});
|
||||
const list = (res as any)?.list || [];
|
||||
setHosts(list);
|
||||
setHostsTotal((res as any)?.total || list.length);
|
||||
} catch { toast.error('主机匹配失败'); }
|
||||
finally { setLoadingHosts(false); }
|
||||
}, []);
|
||||
|
||||
// Auto-match when entering hosts step
|
||||
useEffect(() => {
|
||||
if (step === 'hosts') matchHosts(hostPattern, matchType);
|
||||
}, [step]);
|
||||
|
||||
// ============ Execution ============
|
||||
|
||||
const executePlaybook = async () => {
|
||||
if (!selectedPB) return;
|
||||
const hostList = Array.from(selectedHosts).map(id => {
|
||||
const h = hosts.find(x => x.id === id);
|
||||
return h?.hostname || h?.ip || String(id);
|
||||
});
|
||||
|
||||
const ok = await _confirm({
|
||||
title: '确认执行',
|
||||
description: `将在 ${hostList.length} 台主机上执行「${selectedPB.name}」`,
|
||||
confirmText: '开始执行',
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setStep('execute');
|
||||
setExecuting(true);
|
||||
setExecStatus('running');
|
||||
setExecOutput('正在连接目标主机...\n');
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const res = await ansibleApi.executePlaybook(
|
||||
selectedPB.id,
|
||||
hostList.length > 0 ? hostList : undefined
|
||||
);
|
||||
const job = (res as any)?.data ?? res;
|
||||
setExecJobId(job?.id);
|
||||
setExecStatus(job?.status || 'completed');
|
||||
setExecOutput(job?.output || '执行完成(无输出)');
|
||||
setExecDuration(job?.duration || (Date.now() - startTime));
|
||||
} catch (e: any) {
|
||||
setExecStatus('failed');
|
||||
setExecOutput(`执行失败: ${e?.message || '未知错误'}`);
|
||||
setExecDuration(Date.now() - startTime);
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
fetchExecutions();
|
||||
}
|
||||
};
|
||||
|
||||
// ============ Render Helpers ============
|
||||
|
||||
const toggleHost = (id: number) => {
|
||||
setSelectedHosts(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllHosts = () => {
|
||||
if (selectedHosts.size === filteredHosts.length && filteredHosts.length > 0) {
|
||||
setSelectedHosts(new Set());
|
||||
} else {
|
||||
setSelectedHosts(new Set(filteredHosts.map(h => h.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const filteredHosts = hosts.filter(h =>
|
||||
h.hostname.toLowerCase().includes(hostSearch.toLowerCase()) ||
|
||||
h.ip.includes(hostSearch)
|
||||
);
|
||||
|
||||
const filteredPlaybooks = playbooks.filter(pb => {
|
||||
const q = searchPB.toLowerCase();
|
||||
const match = pb.name.toLowerCase().includes(q) ||
|
||||
pb.description.toLowerCase().includes(q) ||
|
||||
pb.tags.some(t => t.toLowerCase().includes(q));
|
||||
return match && (catFilter === 'all' || pb.category === catFilter);
|
||||
});
|
||||
|
||||
const stepsList: { key: Step; label: string; icon: React.ComponentType<any> }[] = [
|
||||
{ key: 'template', label: '选择模板', icon: FileText },
|
||||
{ key: 'hosts', label: '匹配主机', icon: Server },
|
||||
{ key: 'review', label: '确认执行', icon: CheckCircle },
|
||||
{ key: 'execute', label: '执行监控', icon: Terminal },
|
||||
];
|
||||
|
||||
const currentStepIdx = stepsList.findIndex(s => s.key === step);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">Ansible 自动化部署</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">模板 → 匹配主机 → 执行部署 → 历史记录</p>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-[280px] grid-cols-2">
|
||||
<TabsTrigger value="workflow">工作流</TabsTrigger>
|
||||
<TabsTrigger value="history">执行记录</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
{activeTab === 'workflow' && (
|
||||
<div className="flex items-center gap-2">
|
||||
{stepsList.map((s, i) => (
|
||||
<div key={s.key} className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => i <= currentStepIdx && setStep(s.key)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
i === currentStepIdx
|
||||
? 'bg-blue-600 text-white'
|
||||
: i < currentStepIdx
|
||||
? 'bg-emerald-500/20 text-emerald-400 cursor-pointer hover:bg-emerald-500/30'
|
||||
: 'bg-slate-800 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{i < currentStepIdx ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<s.icon className="h-4 w-4" />
|
||||
)}
|
||||
{s.label}
|
||||
</button>
|
||||
{i < stepsList.length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 text-slate-600" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ Workflow Content ============ */}
|
||||
{activeTab === 'workflow' && (
|
||||
<>
|
||||
{/* Step 1: Template Selection — 表格列表 */}
|
||||
{step === 'template' && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
选择 Playbook 模板
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 搜索/过滤栏 */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索名称、标签…"
|
||||
value={searchPB}
|
||||
onChange={e => setSearchPB(e.target.value)}
|
||||
className="pl-10 bg-slate-800 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<Select value={catFilter} onValueChange={setCatFilter}>
|
||||
<SelectTrigger className="w-36 bg-slate-800 border-slate-700">
|
||||
<SelectValue placeholder="分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
{categories.map(c => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={fetchPlaybooks} className="border-slate-700">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />刷新
|
||||
</Button>
|
||||
<Button onClick={openCreate} size="sm" className="gap-1">
|
||||
<Plus className="h-4 w-4" />新建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedPBs.size > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded bg-slate-800/50 border border-slate-700">
|
||||
<span className="text-sm text-slate-400">
|
||||
已选 {selectedPBs.size} 项
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const ids = Array.from(selectedPBs);
|
||||
const ok = await _confirm({
|
||||
title: '批量删除',
|
||||
description: `确定删除选中的 ${ids.length} 个模板吗?此操作不可恢复。`,
|
||||
confirmText: '批量删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await ansibleApi.batchDeletePlaybooks(ids);
|
||||
toast.success(`已删除 ${ids.length} 个`);
|
||||
setSelectedPBs(new Set());
|
||||
fetchPlaybooks();
|
||||
} catch { toast.error('删除失败'); }
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />批量删除
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表格 */}
|
||||
{loadingPB ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />加载中…
|
||||
</div>
|
||||
) : filteredPlaybooks.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">暂无模板</div>
|
||||
) : (
|
||||
<div className="border border-slate-800 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="w-10 text-slate-500">
|
||||
<Checkbox
|
||||
checked={selectedPBs.size === filteredPlaybooks.length && filteredPlaybooks.length > 0}
|
||||
onCheckedChange={() => {
|
||||
if (selectedPBs.size === filteredPlaybooks.length) {
|
||||
setSelectedPBs(new Set());
|
||||
} else {
|
||||
setSelectedPBs(new Set(filteredPlaybooks.map(p => p.id)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400 hidden md:table-cell">分类</TableHead>
|
||||
<TableHead className="text-slate-400 hidden lg:table-cell">标签</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPlaybooks.map(pb => {
|
||||
const selected = selectedPB?.id === pb.id;
|
||||
const batchChecked = selectedPBs.has(pb.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={pb.id}
|
||||
onClick={() => setSelectedPB(selected ? null : pb)}
|
||||
className={`border-slate-800 cursor-pointer transition-colors ${
|
||||
selected
|
||||
? 'bg-blue-500/10 hover:bg-blue-500/15'
|
||||
: 'hover:bg-slate-800/40'
|
||||
}`}
|
||||
>
|
||||
<TableCell onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={batchChecked}
|
||||
onCheckedChange={() => {
|
||||
const next = new Set(selectedPBs);
|
||||
batchChecked ? next.delete(pb.id) : next.add(pb.id);
|
||||
setSelectedPBs(next);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium text-slate-100 text-sm truncate max-w-[220px]">
|
||||
{pb.name}
|
||||
</div>
|
||||
{pb.description && (
|
||||
<div className="text-xs text-slate-500 truncate max-w-[220px] mt-0.5">
|
||||
{pb.description}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Badge variant="secondary" className="text-xs bg-slate-700/50 text-slate-400">
|
||||
{categories.find(c => c.value === pb.category)?.label || pb.category}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pb.tags.slice(0, 3).map(t => (
|
||||
<Badge key={t} variant="outline" className="text-[10px] border-slate-700 text-slate-500 px-1.5 py-0">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1" onClick={e => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-slate-200"
|
||||
title="编辑(修改名称/分类/YAML)"
|
||||
onClick={() => openEdit(pb)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
|
||||
title="克隆"
|
||||
onClick={() => clonePlaybook(pb)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-red-400"
|
||||
title="删除"
|
||||
onClick={async () => {
|
||||
const ok = await _confirm({
|
||||
title: '删除模板',
|
||||
description: `确定删除「${pb.name}」吗?此操作不可恢复。`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await ansibleApi.deletePlaybook(pb.id);
|
||||
toast.success('已删除');
|
||||
if (selectedPB?.id === pb.id) setSelectedPB(null);
|
||||
fetchPlaybooks();
|
||||
} catch { toast.error('删除失败'); }
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => { setStep('hosts'); }}
|
||||
disabled={!selectedPB}
|
||||
className="gap-2"
|
||||
>
|
||||
下一步:匹配主机 <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Host Matching */}
|
||||
{step === 'hosts' && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-emerald-400" />
|
||||
匹配目标主机
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="主机名/IP 匹配(支持正则):web* / ^k8s-.* / 10.10.10."
|
||||
value={hostPattern}
|
||||
onChange={e => setHostPattern(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && matchHosts(hostPattern, matchType)}
|
||||
className="pl-10 bg-slate-800 border-slate-700 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Select value={matchType} onValueChange={v => { setMatchType(v); matchHosts(hostPattern, v); }}>
|
||||
<SelectTrigger className="w-28 bg-slate-800 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="prefix">前缀</SelectItem>
|
||||
<SelectItem value="contains">模糊</SelectItem>
|
||||
<SelectItem value="regex">正则</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => matchHosts(hostPattern, matchType)} className="border-slate-700">
|
||||
<Search className="h-4 w-4 mr-2" />匹配
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Host table */}
|
||||
{loadingHosts ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />匹配中…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">
|
||||
匹配 <span className="text-slate-200 font-medium">{hostsTotal}</span> 台主机
|
||||
{selectedHosts.size > 0 && (
|
||||
<span className="text-emerald-400 ml-2">(已选 {selectedHosts.size} 台)</span>
|
||||
)}
|
||||
</span>
|
||||
{filteredHosts.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedHosts.size === filteredHosts.length && filteredHosts.length > 0}
|
||||
onCheckedChange={toggleAllHosts}
|
||||
/>
|
||||
<span className="text-xs text-slate-500">全选</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-800 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="w-10 text-slate-500">选</TableHead>
|
||||
<TableHead className="text-slate-400">主机名</TableHead>
|
||||
<TableHead className="text-slate-400">IP</TableHead>
|
||||
<TableHead className="text-slate-400">OS</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">认证</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredHosts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
||||
输入匹配模式后点击"匹配"预览主机
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredHosts.map(h => (
|
||||
<TableRow
|
||||
key={h.id}
|
||||
className="border-slate-800 hover:bg-slate-800/30 cursor-pointer"
|
||||
onClick={() => toggleHost(h.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox checked={selectedHosts.has(h.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-slate-200">{h.hostname}</TableCell>
|
||||
<TableCell className="text-slate-400 font-mono text-sm">{h.ip}</TableCell>
|
||||
<TableCell className="text-slate-500 text-sm">{h.os || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={h.status === 'running' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>
|
||||
{h.status === 'running' ? '运行中' : h.status || '未知'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{h.has_auth ? (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400">已配置</Badge>
|
||||
) : (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400">未配置</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep('template')} className="gap-2 border-slate-700">
|
||||
<ChevronLeft className="h-4 w-4" />上一步
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep('review')}
|
||||
disabled={selectedHosts.size === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
下一步:确认执行 <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Review & Confirm */}
|
||||
{step === 'review' && selectedPB && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-amber-400" />
|
||||
确认执行
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Playbook summary */}
|
||||
<div className="p-4 rounded-lg bg-slate-800/50 border border-slate-700">
|
||||
<h3 className="text-sm font-medium text-slate-300 mb-2">Playbook</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/20">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-100 font-medium">{selectedPB.name}</p>
|
||||
<p className="text-xs text-slate-500">{selectedPB.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host list summary */}
|
||||
<div className="p-4 rounded-lg bg-slate-800/50 border border-slate-700">
|
||||
<h3 className="text-sm font-medium text-slate-300 mb-2">
|
||||
目标主机 <span className="text-emerald-400">({selectedHosts.size} 台)</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from(selectedHosts).map(id => {
|
||||
const h = hosts.find(x => x.id === id);
|
||||
return (
|
||||
<Badge key={id} variant="outline" className="border-slate-600 text-slate-300 py-1 px-3">
|
||||
<Server className="h-3 w-3 mr-1 text-emerald-400" />
|
||||
{h?.hostname || `ID:${id}`}
|
||||
<span className="text-slate-500 ml-1 text-xs">({h?.ip})</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playbook content preview */}
|
||||
<details className="p-4 rounded-lg bg-slate-800/50 border border-slate-700">
|
||||
<summary className="text-sm font-medium text-slate-400 cursor-pointer hover:text-slate-300">
|
||||
📄 查看 Playbook YAML 内容
|
||||
</summary>
|
||||
<pre className="mt-3 text-xs text-slate-400 bg-slate-950 p-3 rounded overflow-x-auto max-h-64">
|
||||
{selectedPB.content || '(空)'}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep('hosts')} className="gap-2 border-slate-700">
|
||||
<ChevronLeft className="h-4 w-4" />上一步
|
||||
</Button>
|
||||
<Button onClick={executePlaybook} className="gap-2 bg-emerald-600 hover:bg-emerald-700">
|
||||
<Zap className="h-4 w-4" />开始执行
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 4: Execution & Monitor */}
|
||||
{step === 'execute' && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-cyan-400" />
|
||||
执行监控
|
||||
{executing && <Loader2 className="h-4 w-4 animate-spin text-blue-400" />}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg ${
|
||||
execStatus === 'success' ? 'bg-emerald-500/10 border border-emerald-500/30' :
|
||||
execStatus === 'failed' ? 'bg-red-500/10 border border-red-500/30' :
|
||||
'bg-blue-500/10 border border-blue-500/30'
|
||||
}`}>
|
||||
{execStatus === 'success' ? <CheckCircle className="h-5 w-5 text-emerald-400" /> :
|
||||
execStatus === 'failed' ? <XCircle className="h-5 w-5 text-red-400" /> :
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-400" />}
|
||||
<span className={`font-medium ${
|
||||
execStatus === 'success' ? 'text-emerald-400' :
|
||||
execStatus === 'failed' ? 'text-red-400' : 'text-blue-400'
|
||||
}`}>
|
||||
{execStatus === 'success' ? '执行成功' :
|
||||
execStatus === 'failed' ? '执行失败' :
|
||||
executing ? '执行中…' : '等待执行'}
|
||||
</span>
|
||||
</div>
|
||||
{execDuration > 0 && (
|
||||
<span className="text-sm text-slate-500 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{execDuration >= 1000
|
||||
? `${(execDuration / 1000).toFixed(1)}s`
|
||||
: `${execDuration}ms`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output terminal */}
|
||||
<div className="bg-slate-950 border border-slate-800 rounded-lg p-4 font-mono text-sm text-slate-300 max-h-96 overflow-auto whitespace-pre-wrap">
|
||||
{execOutput || (executing ? '等待输出…' : '无输出')}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setStep('template'); setSelectedPB(null); setSelectedHosts(new Set()); setExecOutput(''); setExecStatus(''); }}
|
||||
className="gap-2 border-slate-700"
|
||||
>
|
||||
<Zap className="h-4 w-4" />新任务
|
||||
</Button>
|
||||
{execJobId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setActiveTab('history'); }}
|
||||
className="gap-2 border-slate-700"
|
||||
>
|
||||
<Clock className="h-4 w-4" />查看历史
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============ History Tab ============ */}
|
||||
{activeTab === 'history' && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-slate-400" />
|
||||
执行记录
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={fetchExecutions} className="border-slate-700 h-8">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingExecs ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />加载中…
|
||||
</div>
|
||||
) : executions.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">暂无执行记录</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Playbook</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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{executions.map(e => (
|
||||
<TableRow key={e.id} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{e.task_name}</TableCell>
|
||||
<TableCell>{getStatusBadge(e.status)}</TableCell>
|
||||
<TableCell className="text-slate-400 text-sm">
|
||||
{e.duration >= 1000 ? `${(e.duration / 1000).toFixed(1)}s` : `${e.duration}ms`}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-500 text-sm">{e.triggered_by || '-'}</TableCell>
|
||||
<TableCell className="text-slate-500 text-sm">{formatDate(e.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
{e.output ? (
|
||||
<details>
|
||||
<summary className="text-xs text-blue-400 cursor-pointer hover:text-blue-300">
|
||||
查看输出
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-slate-400 bg-slate-950 p-2 rounded max-h-32 overflow-auto">
|
||||
{e.output.slice(0, 2000)}
|
||||
</pre>
|
||||
</details>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新建/编辑模板弹窗 */}
|
||||
<AnsibleFormDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
editingId={editingId}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function CronJobFormDialog({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// 拉取脚本库
|
||||
scriptApi.getScripts({ page_size: 200 }).then((res: any) => {
|
||||
scriptApi.getScripts({ page_size: 200 }).then(( res: any) => {
|
||||
setScripts(res?.list || []);
|
||||
}).catch(() => setScripts([]));
|
||||
|
||||
@@ -71,7 +71,7 @@ export function CronJobFormDialog({
|
||||
});
|
||||
// 如果关联了脚本,拉取脚本详情
|
||||
if (editJob.script_id) {
|
||||
scriptApi.getScript(editJob.script_id).then((res: any) => {
|
||||
scriptApi.getScript(editJob.script_id).then(( res: any) => {
|
||||
setSelectedScript(res);
|
||||
}).catch(() => setSelectedScript(null));
|
||||
} else {
|
||||
@@ -120,7 +120,7 @@ export function CronJobFormDialog({
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const payload: any = {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
cron_expr: form.cron_expr,
|
||||
|
||||
@@ -46,7 +46,7 @@ export function CronJobManager() {
|
||||
setIsFormDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (job: any) => {
|
||||
const openEdit = ( job: any) => {
|
||||
setEditJob(job);
|
||||
setIsFormDialogOpen(true);
|
||||
};
|
||||
@@ -76,7 +76,7 @@ export function CronJobManager() {
|
||||
(job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleToggleJob = async (id: any) => {
|
||||
const handleToggleJob = async ( id: any) => {
|
||||
try {
|
||||
await cronJobApi.toggleJob(id);
|
||||
toast.success('状态已切换');
|
||||
@@ -87,7 +87,7 @@ export function CronJobManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteJob = async (id: any) => {
|
||||
const handleExecuteJob = async ( id: any) => {
|
||||
setExecutingId(id);
|
||||
try {
|
||||
const result = await cronJobApi.executeJob(id);
|
||||
@@ -107,7 +107,7 @@ export function CronJobManager() {
|
||||
};
|
||||
|
||||
// 删除任务(带确认框) // Logan.Li
|
||||
const handleDeleteJob = async (job: any) => {
|
||||
const handleDeleteJob = async ( job: any) => {
|
||||
const ok = await _confirm({
|
||||
title: '删除定时任务',
|
||||
description: `确定删除「${job.name}」吗?此操作不可恢复。`,
|
||||
|
||||
@@ -49,9 +49,9 @@ export function ScriptManager() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await scriptApi.getScripts({ page_size: 100 });
|
||||
const items = (res as any)?.list || [];
|
||||
const items = (res as Record<string, any>)?.list || [];
|
||||
setScripts(items.map(mapScript));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('获取脚本列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -61,9 +61,9 @@ export function ScriptManager() {
|
||||
const fetchExecutions = async () => {
|
||||
try {
|
||||
const res = await taskExecutionApi.getExecutions({ task_type: 'script', page_size: 50 });
|
||||
const items = (res as any)?.list || [];
|
||||
const items = (res as Record<string, any>)?.list || [];
|
||||
setExecutions(items);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
};
|
||||
@@ -134,7 +134,7 @@ export function ScriptManager() {
|
||||
}
|
||||
setEditorOpen(false);
|
||||
fetchScripts();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(editingId !== null ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
@@ -153,7 +153,7 @@ export function ScriptManager() {
|
||||
});
|
||||
toast.success('复制成功');
|
||||
fetchScripts();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('复制失败');
|
||||
}
|
||||
};
|
||||
@@ -172,7 +172,7 @@ export function ScriptManager() {
|
||||
await scriptApi.deleteScript(sc.id);
|
||||
toast.success('删除成功');
|
||||
fetchScripts();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
@@ -189,7 +189,7 @@ export function ScriptManager() {
|
||||
setExecuting(true);
|
||||
try {
|
||||
const res = await scriptApi.executeScript(sc.id);
|
||||
const exec = (res as any)?.data ?? res;
|
||||
const exec = (res as Record<string, any>)?.data ?? res;
|
||||
if (exec?.status === 'success') {
|
||||
toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`);
|
||||
} else {
|
||||
@@ -199,7 +199,7 @@ export function ScriptManager() {
|
||||
}
|
||||
fetchScripts();
|
||||
fetchExecutions();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('执行请求失败');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
@@ -243,7 +243,7 @@ export function ScriptManager() {
|
||||
toast.success(`已删除 ${selectedIds.length} 个脚本`);
|
||||
setSelectedIds([]);
|
||||
fetchScripts();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('批量删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// Ansible 模块共享类型与辅助函数 // Logan.Li
|
||||
import { Clock, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
// ---- 类型定义 ----
|
||||
|
||||
export interface Playbook {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
timeout: number;
|
||||
run_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionRecord {
|
||||
id: number;
|
||||
task_type: string;
|
||||
task_id: number;
|
||||
task_name: string;
|
||||
status: string;
|
||||
output: string;
|
||||
duration: number;
|
||||
triggered_by: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 将后端返回的 playbook 原始对象映射为前端结构(解析 tags JSON) // Logan.Li
|
||||
export function mapPlaybook(raw: any): Playbook {
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
if (raw.tags) tags = JSON.parse(raw.tags);
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
description: raw.description || '',
|
||||
content: raw.content || '',
|
||||
category: raw.category || 'deployment',
|
||||
tags,
|
||||
timeout: raw.timeout || 600,
|
||||
run_count: raw.run_count || 0,
|
||||
created_at: raw.created_at,
|
||||
updated_at: raw.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// Playbook 分类选项 // Logan.Li
|
||||
export const categories = [
|
||||
{ value: 'deployment', label: '应用部署' },
|
||||
{ value: 'configuration', label: '配置管理' },
|
||||
{ value: 'maintenance', label: '系统维护' },
|
||||
{ value: 'security', label: '安全加固' },
|
||||
{ value: 'backup', label: '备份恢复' },
|
||||
{ value: 'monitoring', label: '监控配置' },
|
||||
];
|
||||
|
||||
// 默认 Playbook 模板 // Logan.Li
|
||||
export const samplePlaybook = `---
|
||||
- name: Deploy Application
|
||||
hosts: all
|
||||
become: yes
|
||||
vars:
|
||||
app_name: myapp
|
||||
deploy_path: /opt/{{ app_name }}
|
||||
|
||||
tasks:
|
||||
- name: Install dependencies
|
||||
package:
|
||||
name:
|
||||
- nginx
|
||||
state: present
|
||||
|
||||
- name: Create deploy directory
|
||||
file:
|
||||
path: "{{ deploy_path }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
handlers:
|
||||
- name: restart app
|
||||
service:
|
||||
name: nginx
|
||||
state: restarted`;
|
||||
|
||||
// 执行状态 Badge 组件 // Logan.Li
|
||||
export function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />等待中</Badge>;
|
||||
case 'running':
|
||||
return <Badge variant="default" className="bg-blue-500 gap-1"><Loader2 className="w-3 h-3 animate-spin" />执行中</Badge>;
|
||||
case 'success':
|
||||
return <Badge variant="default" className="bg-green-600 gap-1"><CheckCircle className="w-3 h-3" />成功</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />失败</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status || '未知'}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期格式化 // Logan.Li
|
||||
export function formatDate(s: string | null): string {
|
||||
if (!s) return '-';
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export interface ExecutionRecord {
|
||||
}
|
||||
|
||||
// 将后端返回的脚本原始对象映射为前端结构
|
||||
export function mapScript(raw: any): Script {
|
||||
export function mapScript( raw: any): Script {
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
|
||||
@@ -80,7 +80,7 @@ export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSucce
|
||||
onSuccess();
|
||||
toast('保存成功');
|
||||
} catch (e: any) {
|
||||
toast.error('保存失败', { description: e.message });
|
||||
toast.error('保存失败', { description: (e as Record<string, any>).message });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
|
||||
onSuccess();
|
||||
toast('保存成功');
|
||||
} catch (e: any) {
|
||||
toast.error('保存失败', { description: e.message });
|
||||
toast.error('保存失败', { description: (e as Record<string, any>).message });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function CloudAccountManager() {
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (account: any) => {
|
||||
const handleOpenEdit = ( account: any) => {
|
||||
setEditingAccount(account);
|
||||
setFormData({
|
||||
name: account.name || '',
|
||||
@@ -89,7 +89,7 @@ export function CloudAccountManager() {
|
||||
if (!formData.name.trim() || !formData.provider.trim()) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const payload: any = {
|
||||
const payload: Record<string, any> = {
|
||||
name: formData.name.trim(),
|
||||
provider: formData.provider,
|
||||
access_key: formData.access_key.trim(),
|
||||
@@ -114,7 +114,7 @@ export function CloudAccountManager() {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await cloudApi.getAccounts();
|
||||
const res: Record<string, any> = await cloudApi.getAccounts();
|
||||
setAccounts(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载云账号列表失败:', e);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function CloudBilling() {
|
||||
const res = await billingApi.getStats();
|
||||
setStats(res);
|
||||
} catch (e: any) {
|
||||
toast.error('加载统计失败', { description: e.message });
|
||||
toast.error('加载统计失败', { description: (e as Record<string, any>).message });
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
@@ -69,14 +69,14 @@ export function CloudBilling() {
|
||||
const loadRecords = useCallback(async () => {
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: Record<string, any> = {};
|
||||
if (filterProvider) params.provider = filterProvider;
|
||||
if (filterStart) params.start_date = filterStart;
|
||||
if (filterEnd) params.end_date = filterEnd;
|
||||
const res = await billingApi.list(params);
|
||||
setRecords(res.list || []);
|
||||
} catch (e: any) {
|
||||
toast.error('加载账单失败', { description: e.message });
|
||||
toast.error('加载账单失败', { description: (e as Record<string, any>).message });
|
||||
} finally {
|
||||
setRecordsLoading(false);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function CloudBilling() {
|
||||
const res = await billingApi.listAlertRules();
|
||||
setRules(res.list || []);
|
||||
} catch (e: any) {
|
||||
toast.error('加载预警规则失败', { description: e.message });
|
||||
toast.error('加载预警规则失败', { description: (e as Record<string, any>).message });
|
||||
} finally {
|
||||
setRulesLoading(false);
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export function CloudBilling() {
|
||||
setRecordDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditRecord = (r: any) => {
|
||||
const openEditRecord = ( r: any) => {
|
||||
setEditingRecord(r);
|
||||
setRecordDialogOpen(true);
|
||||
};
|
||||
@@ -121,7 +121,7 @@ export function CloudBilling() {
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditRule = (r: any) => {
|
||||
const openEditRule = ( r: any) => {
|
||||
setEditingRule(r);
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
@@ -135,7 +135,7 @@ export function CloudBilling() {
|
||||
loadStats();
|
||||
toast('删除成功');
|
||||
} catch (e: any) {
|
||||
toast.error('删除失败', { description: e.message });
|
||||
toast.error('删除失败', { description: (e as Record<string, any>).message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ export function CloudBilling() {
|
||||
loadRules();
|
||||
toast('删除成功');
|
||||
} catch (e: any) {
|
||||
toast.error('删除失败', { description: e.message });
|
||||
toast.error('删除失败', { description: (e as Record<string, any>).message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,7 +157,7 @@ export function CloudBilling() {
|
||||
loadRules();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
toast.error('操作失败', { description: e.message });
|
||||
toast.error('操作失败', { description: (e as Record<string, any>).message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -241,7 +241,7 @@ export function CloudBilling() {
|
||||
<span className="text-sm font-medium text-amber-300">成本预警</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{stats.alerts.map((a: any, i: number) => (
|
||||
{stats.alerts.map(( a: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-amber-500/50 text-amber-300">{a.rule_name}</Badge>
|
||||
@@ -275,7 +275,7 @@ export function CloudBilling() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex h-8 rounded-lg overflow-hidden">
|
||||
{stats.provider_dist.map((p: any, i: number) => (
|
||||
{stats.provider_dist.map(( p: any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${providerColors[p.provider] || 'bg-slate-600'} flex items-center justify-center text-xs text-white font-medium`}
|
||||
@@ -287,7 +287,7 @@ export function CloudBilling() {
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{stats.provider_dist.map((p: any, i: number) => (
|
||||
{stats.provider_dist.map(( p: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded ${providerColors[p.provider] || 'bg-slate-600'}`} />
|
||||
|
||||
@@ -66,7 +66,7 @@ export function DomainManager() {
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (domain: any) => {
|
||||
const handleOpenEdit = ( domain: any) => {
|
||||
setEditingDomain(domain);
|
||||
// 后端返回 RFC3339 时间,截取 YYYY-MM-DD 部分给 date input
|
||||
const toDateInput = (v: string) => (v ? v.substring(0, 10) : '');
|
||||
@@ -87,7 +87,7 @@ export function DomainManager() {
|
||||
if (!formData.name.trim()) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const payload: any = {
|
||||
const payload: Record<string, any> = {
|
||||
name: formData.name.trim(),
|
||||
registrar: formData.registrar.trim(),
|
||||
status: formData.status,
|
||||
@@ -114,7 +114,7 @@ export function DomainManager() {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await cloudApi.getDomains();
|
||||
const res: Record<string, any> = await cloudApi.getDomains();
|
||||
setDomains(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载域名列表失败:', e);
|
||||
|
||||
@@ -60,7 +60,7 @@ export function PlatformManager() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await platformApi.list();
|
||||
const items = (res as any)?.list || [];
|
||||
const items = (res as Record<string, any>)?.list || [];
|
||||
setPlatforms(items);
|
||||
} catch {
|
||||
setPlatforms([]);
|
||||
@@ -84,7 +84,7 @@ export function PlatformManager() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: any) => {
|
||||
const openEdit = ( item: any) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
name: item.name || '', code: item.code || '', type: item.type || 'public',
|
||||
|
||||
@@ -76,7 +76,7 @@ export function RegionManager() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await regionApi.list();
|
||||
const items = (res as any)?.list || [];
|
||||
const items = (res as Record<string, any>)?.list || [];
|
||||
setRegions(items);
|
||||
} catch {
|
||||
setRegions([]);
|
||||
@@ -113,7 +113,7 @@ export function RegionManager() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: any) => {
|
||||
const openEdit = ( item: any) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
name: item.name || '', code: item.code || '', provider: item.provider || 'aliyun',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Clock, Server, Cloud, Bell, DollarSign, TrendingUp, TrendingDown, ArrowRight } from 'lucide-react';
|
||||
import { Clock, Server, Cloud, Bell, DollarSign, ArrowRight} from 'lucide-react';
|
||||
import { dashboardStatsApi, serverApi, kubernetesApi } from '@/services/api';
|
||||
import { StatCard } from './DashboardStats';
|
||||
import { CostOverviewCard, TopResourceUsageCard, ResourceUsageCard, computeMonthChange } from './DashboardCharts';
|
||||
|
||||
@@ -32,7 +32,7 @@ const CLOUD_COLORS: Record<string, string> = {
|
||||
};
|
||||
const FALLBACK_CLOUD_COLORS = ['#ff6a00', '#ff9900', '#006eff', '#ff0000', '#0078d4', '#10b981', '#a855f7', '#ec4899'];
|
||||
|
||||
export function computeMonthChange(monthTrend: any[]): {
|
||||
export function computeMonthChange( monthTrend: any[]): {
|
||||
pct: number;
|
||||
label: string;
|
||||
direction: 'up' | 'down' | 'neutral';
|
||||
@@ -65,7 +65,7 @@ function generateMetricsData(avgCpu: number, avgMemory: number, avgDisk: number,
|
||||
return data;
|
||||
}
|
||||
|
||||
function buildMetricsFromReal(metrics: any[], avgDisk: number) {
|
||||
function buildMetricsFromReal( metrics: any[], avgDisk: number) {
|
||||
if (!metrics || metrics.length === 0) return [];
|
||||
const buckets: Record<string, { cpu?: number; memory?: number }> = {};
|
||||
for (const m of metrics) {
|
||||
@@ -93,7 +93,7 @@ function CustomTooltip({ active, payload, label }: any) {
|
||||
return (
|
||||
<div className="chart-tooltip px-3 py-2">
|
||||
<p className="text-xs font-medium mb-1.5" style={{ color: 'var(--dg-text-secondary)' }}>{label}</p>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
{payload.map(( entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<span style={{ color: 'var(--dg-text-muted)' }}>{entry.name}:</span>
|
||||
@@ -106,9 +106,9 @@ function CustomTooltip({ active, payload, label }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CostOverviewCard({ costStats }: { costStats: any }) {
|
||||
const cloudCosts: any[] = costStats?.cloud_costs ?? [];
|
||||
const monthTrend: any[] = costStats?.month_trend ?? [];
|
||||
export function CostOverviewCard({ costStats }: { costStats: Record<string, any> }) {
|
||||
const cloudCosts: Record<string, any>[] = costStats?.cloud_costs ?? [];
|
||||
const monthTrend: Record<string, any>[] = costStats?.month_trend ?? [];
|
||||
|
||||
const costData = cloudCosts.map((c, i) => ({
|
||||
name: c.cloud,
|
||||
@@ -201,13 +201,12 @@ export function CostOverviewCard({ costStats }: { costStats: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TopResourceUsageCard({ topUsage }: { topUsage: any }) {
|
||||
export function TopResourceUsageCard({ topUsage }: { topUsage: any}) {
|
||||
const topCpuServers = (topUsage?.cpu ?? []).slice(0, 5);
|
||||
const topMemoryServers = (topUsage?.memory ?? []).slice(0, 5);
|
||||
const topDiskServers = (topUsage?.disk ?? []).slice(0, 5);
|
||||
|
||||
const renderList = (
|
||||
items: any[],
|
||||
const renderList = ( items: any[],
|
||||
color: string,
|
||||
BarColor: string,
|
||||
icon: React.ReactNode,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Rocket,
|
||||
AlertTriangle,
|
||||
|
||||
@@ -75,7 +75,7 @@ export function AppReleaseManager() {
|
||||
});
|
||||
|
||||
// 回滚发布 // Logan.Li
|
||||
const handleRollback = async (id: any) => {
|
||||
const handleRollback = async ( id: any) => {
|
||||
try {
|
||||
await releaseApi.rollbackRelease(id);
|
||||
loadData();
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function CredentialFormDialog({ open, onOpenChange, type, editId,
|
||||
|
||||
const loadCredentials = async () => {
|
||||
try {
|
||||
const res: any = await http.get('/credentials?page_size=100');
|
||||
const res: Record<string, any> = await http.get('/credentials?page_size=100');
|
||||
setCredentials(res?.list || []);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Plus, Pencil, Trash2, RefreshCw, Key, Shield, Globe, Search,
|
||||
Lock, Eye, EyeOff, Copy, Server, Container, Database, FileText,
|
||||
Cloud, Tag, Clock, AlertTriangle, CheckCircle2, XCircle, Zap,
|
||||
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Key,
|
||||
Shield,
|
||||
Globe,
|
||||
Search,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
Server,
|
||||
Container,
|
||||
Database,
|
||||
FileText,
|
||||
Cloud,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -64,7 +85,7 @@ function CredDetailDialog({ open, onClose, credId }: { open: boolean; onClose: (
|
||||
useEffect(() => {
|
||||
if (!credId || !open) return;
|
||||
setLoading(true);
|
||||
http.get(`/credentials/${credId}`).then((res: any) => {
|
||||
http.get(`/credentials/${credId}`).then(( res: any) => {
|
||||
setDetail(res);
|
||||
}).catch(() => toast.error('加载详情失败')).finally(() => setLoading(false));
|
||||
}, [credId, open]);
|
||||
@@ -254,10 +275,10 @@ function CredFormDialog({ open, onClose, onSaved, editData }: {
|
||||
const [tlsKey, setTlsKey] = useState('');
|
||||
const [tlsCa, setTlsCa] = useState('');
|
||||
// AKSK
|
||||
const [akskProvider, setAkskProvider] = useState('');
|
||||
const [, setAkskProvider] = useState('');
|
||||
const [akskAccessKey, setAkskAccessKey] = useState('');
|
||||
const [akskSecretKey, setAkskSecretKey] = useState('');
|
||||
const [akskRegion, setAkskRegion] = useState('');
|
||||
const [, setAkskRegion] = useState('');
|
||||
// Database
|
||||
const [dbType, setDbType] = useState('mysql');
|
||||
const [dbHost, setDbHost] = useState('');
|
||||
@@ -308,7 +329,7 @@ function CredFormDialog({ open, onClose, onSaved, editData }: {
|
||||
if (!name.trim()) { toast.error('请填写凭据名称'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
const payload: Record<string, any> = {
|
||||
name: name.trim(),
|
||||
secret_type: secretType,
|
||||
description: description.trim(),
|
||||
@@ -626,7 +647,7 @@ export default function CredentialManager() {
|
||||
if (filterType) params.set('secret_type', filterType);
|
||||
if (filterSearch) params.set('search', filterSearch);
|
||||
params.set('page_size', '100');
|
||||
const res: any = await http.get(`/credentials?${params.toString()}`);
|
||||
const res: Record<string, any> = await http.get(`/credentials?${params.toString()}`);
|
||||
setCredentials(res?.list || []);
|
||||
} catch { toast.error('获取凭证列表失败'); }
|
||||
finally { setLoading(false); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Shield, Globe, Wrench } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Globe, Wrench} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -110,9 +110,9 @@ export default function DeployCredentials() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]);
|
||||
setRepos((rR as any)?.list || []);
|
||||
setRegistries((rReg as any)?.list || []);
|
||||
setEnvironments((rEnv as any)?.list || []);
|
||||
setRepos((rR as Record<string, any>)?.list || []);
|
||||
setRegistries((rReg as Record<string, any>)?.list || []);
|
||||
setEnvironments((rEnv as Record<string, any>)?.list || []);
|
||||
} catch { toast.error('获取数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Shield, Plus, Trash2, Save, Loader2, Users, Regex, Check, X, GitBranch, UserPlus, Search } from 'lucide-react';
|
||||
import { Shield, Plus, Trash2, Save, Loader2, Users, Regex, Check, X, GitBranch, UserPlus} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -60,9 +60,9 @@ export default function DeployRBAC() {
|
||||
pipelineApi.getPipelines({ page_size: 200 }),
|
||||
userApi.getUsers({ page_size: 500 }),
|
||||
]);
|
||||
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as any)?.list || (rolesRes as any)?.data || []);
|
||||
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as any)?.list || (pipesRes as any)?.data || []);
|
||||
const uList = Array.isArray(usersRes) ? usersRes : (usersRes as any)?.list || (usersRes as any)?.data || [];
|
||||
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as Record<string, any>)?.list || (rolesRes as Record<string, any>)?.data || []);
|
||||
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as Record<string, any>)?.list || (pipesRes as Record<string, any>)?.data || []);
|
||||
const uList = Array.isArray(usersRes) ? usersRes : (usersRes as Record<string, any>)?.list || (usersRes as Record<string, any>)?.data || [];
|
||||
setUsers(uList);
|
||||
} catch { /* */ }
|
||||
finally { setLoading(false); }
|
||||
@@ -73,7 +73,7 @@ export default function DeployRBAC() {
|
||||
const loadAssignments = async () => {
|
||||
if (!selectedUserId) { setAssignments([]); return; }
|
||||
try {
|
||||
const res: any = await deployApi.getUserItemRoles(Number(selectedUserId));
|
||||
const res: Record<string, any> = await deployApi.getUserItemRoles(Number(selectedUserId));
|
||||
setAssignments(Array.isArray(res) ? res : res?.data || res?.list || []);
|
||||
} catch { setAssignments([]); }
|
||||
};
|
||||
|
||||
@@ -131,7 +131,7 @@ export function PipelineDetailDialog({
|
||||
<div className="text-[var(--dg-text-secondary)] text-center py-8 text-sm">暂无执行记录</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pipelineRuns.map((run: any) => (
|
||||
{pipelineRuns.map(( run: any) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]"
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Plus, Trash2, ChevronUp, ChevronDown, ChevronRight, Save, Play, Loader2,
|
||||
GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container,
|
||||
Clock, Webhook, Hand, Info, Copy, Check, X, StopCircle, ScrollText,
|
||||
Users, UserPlus, Shield, Eye, FileText,
|
||||
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Save,
|
||||
Play,
|
||||
Loader2,
|
||||
GitBranch,
|
||||
Wrench,
|
||||
Server,
|
||||
FlaskConical,
|
||||
Zap,
|
||||
Terminal,
|
||||
FileCode,
|
||||
Container,
|
||||
Clock,
|
||||
Webhook,
|
||||
Hand,
|
||||
Check,
|
||||
X,
|
||||
StopCircle,
|
||||
ScrollText,
|
||||
Users,
|
||||
UserPlus,
|
||||
Shield,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -68,7 +93,7 @@ function TaskCard({ task, selected, onSelect, onDelete }: { task: Task; selected
|
||||
}
|
||||
|
||||
// ---- Task Editor Panel ----
|
||||
function TaskEditor({ task, onUpdate, onClose }: { task: Task | null; onUpdate: (t: Task) => void; onClose: () => void }) {
|
||||
function TaskEditor({ task, onUpdate }: { task: Task | null; onUpdate: (t: Task) => void; onClose: () => void }) {
|
||||
if (!task) return <div className="p-6 text-center text-[var(--dg-text-secondary)] text-sm">选择一个任务开始编辑</div>;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -169,7 +194,7 @@ export default function PipelineEditor() {
|
||||
const loadEnvironments = useCallback(async () => {
|
||||
try {
|
||||
const res = await deployApi.getEnvironments();
|
||||
setEnvironments(Array.isArray(res) ? res : (res as any)?.list || []);
|
||||
setEnvironments(Array.isArray(res) ? res : (res as Record<string, any>)?.list || []);
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
@@ -177,7 +202,7 @@ export default function PipelineEditor() {
|
||||
setLoadingMembers(true);
|
||||
try {
|
||||
const res = await pipelineApi.getMembers(pipelineCode);
|
||||
setMembers(Array.isArray(res) ? res : (res as any)?.data || []);
|
||||
setMembers(Array.isArray(res) ? res : (res as Record<string, any>)?.data || []);
|
||||
} catch { setMembers([]); }
|
||||
finally { setLoadingMembers(false); }
|
||||
}, [pipelineCode]);
|
||||
@@ -185,7 +210,7 @@ export default function PipelineEditor() {
|
||||
const loadRuns = useCallback(async () => {
|
||||
try {
|
||||
const res = await pipelineApi.getRuns(pipelineCode);
|
||||
setRuns(Array.isArray(res) ? res : (res as any)?.list || []);
|
||||
setRuns(Array.isArray(res) ? res : (res as Record<string, any>)?.list || []);
|
||||
} catch { setRuns([]); }
|
||||
}, [pipelineCode]);
|
||||
|
||||
@@ -193,7 +218,7 @@ export default function PipelineEditor() {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const p = await pipelineApi.getPipelineDetail(pipelineCode) as any;
|
||||
const p = await pipelineApi.getPipelineDetail(pipelineCode) as Record<string, any>;
|
||||
// http.get 已自动解包 json.data,p 即为 pipeline 对象
|
||||
if (!p || !p.id) { setPipeline(null); setLoading(false); return; }
|
||||
setPipeline(p);
|
||||
@@ -269,11 +294,11 @@ export default function PipelineEditor() {
|
||||
} catch { toast.error('停止失败'); }
|
||||
};
|
||||
|
||||
const handleViewLog = async (run: any) => {
|
||||
const handleViewLog = async ( run: any) => {
|
||||
setLogRun(run);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
const data = await pipelineApi.getRunLog(pipelineCode, run.id) as any;
|
||||
const data = await pipelineApi.getRunLog(pipelineCode, run.id) as Record<string, any>;
|
||||
setLogs(data?.logs || []);
|
||||
} catch { setLogs([]); }
|
||||
};
|
||||
@@ -457,7 +482,7 @@ export default function PipelineEditor() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r: any) => (
|
||||
{runs.map(( r: any) => (
|
||||
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td>
|
||||
<td className="px-3 py-2">
|
||||
@@ -527,8 +552,8 @@ export default function PipelineEditor() {
|
||||
|
||||
{/* Members list — split direct users vs group assignments */}
|
||||
{(() => {
|
||||
const directUsers = members.filter((m: any) => m.username);
|
||||
const groupAssignments = members.filter((m: any) => m.group_id);
|
||||
const directUsers = members.filter(( m: any) => m.username);
|
||||
const groupAssignments = members.filter(( m: any) => m.group_id);
|
||||
return (
|
||||
<>
|
||||
{/* Direct users */}
|
||||
@@ -543,7 +568,7 @@ export default function PipelineEditor() {
|
||||
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">暂无直接用户</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border)]">
|
||||
{directUsers.map((m: any) => (
|
||||
{directUsers.map(( m: any) => (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 text-blue-400" /></div>
|
||||
@@ -572,7 +597,7 @@ export default function PipelineEditor() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border)]">
|
||||
{groupAssignments.map((m: any) => (
|
||||
{groupAssignments.map(( m: any) => (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 text-purple-400" /></div>
|
||||
|
||||
@@ -8,14 +8,38 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Play, Plus, Search, GitBranch, Clock, CheckCircle, XCircle,
|
||||
Loader2, StopCircle, FileText, Trash2, ChevronDown,
|
||||
ChevronRight, Rocket, Box, Workflow, Code, Container, ShieldCheck, Tag,
|
||||
Globe, ArrowRight, RotateCcw, Layers, History, Server, Network,
|
||||
ChevronRight as ChevronRightSmall, Package,
|
||||
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
GitBranch,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
StopCircle,
|
||||
FileText,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Rocket,
|
||||
Box,
|
||||
Workflow,
|
||||
Code,
|
||||
Container,
|
||||
ShieldCheck,
|
||||
Tag,
|
||||
Globe,
|
||||
ArrowRight,
|
||||
Layers,
|
||||
History,
|
||||
Server,
|
||||
Network,
|
||||
ChevronRight as ChevronRightSmall,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { pipelineApi, deployApi } from '@/services/api';
|
||||
import http from '@/services/http';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
@@ -37,7 +61,7 @@ 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}分`;
|
||||
}
|
||||
function parseStages(raw: any): any[] {
|
||||
function parseStages( raw: any): any[] {
|
||||
if (!raw) return []; try { const s = typeof raw === 'string' ? JSON.parse(raw) : raw; return Array.isArray(s) ? s : []; } catch { return []; }
|
||||
}
|
||||
|
||||
@@ -60,8 +84,8 @@ export function PipelineManager() {
|
||||
const [newBranch, setNewBranch] = useState('main');
|
||||
const [newDockerfile, setNewDockerfile] = useState('');
|
||||
const [selectedStages, setSelectedStages] = useState<string[]>(['checkout', 'build', 'image', 'deploy']);
|
||||
const [newRepoId, setNewRepoId] = useState<number | string>('');
|
||||
const [newRegistryId, setNewRegistryId] = useState<number | string>('');
|
||||
const [, setNewRepoId] = useState<number | string>('');
|
||||
const [, setNewRegistryId] = useState<number | string>('');
|
||||
const [newDeployMode, setNewDeployMode] = useState('branch');
|
||||
const [newDeployTarget, setNewDeployTarget] = useState('ssh');
|
||||
const [newEnvironmentId, setNewEnvironmentId] = useState<number | string>('');
|
||||
@@ -88,11 +112,11 @@ export function PipelineManager() {
|
||||
const [newMaxExecutors, setNewMaxExecutors] = useState(2);
|
||||
|
||||
// Reference data
|
||||
const [repos, setRepos] = useState<any[]>([]);
|
||||
const [registries, setRegistries] = useState<any[]>([]);
|
||||
const [, setRepos] = useState<any[]>([]);
|
||||
const [, setRegistries] = useState<any[]>([]);
|
||||
const [environments, setEnvironments] = useState<any[]>([]);
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [, _setServers] = useState<any[]>([]);
|
||||
const [, _setClusters] = useState<any[]>([]);
|
||||
|
||||
// Log dialog
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
@@ -118,12 +142,12 @@ export function PipelineManager() {
|
||||
deployApi.getEnvironments().catch(() => ({ list: [] })),
|
||||
http.get('/credentials', { page_size: 100 }).catch(() => ({ list: [] })),
|
||||
] as const);
|
||||
setRepos(Array.isArray((rR as any)?.list) ? (rR as any).list : []);
|
||||
setRegistries(Array.isArray((rReg as any)?.list) ? (rReg as any).list : []);
|
||||
setEnvironments(Array.isArray((rEnv as any)?.list) ? (rEnv as any).list : []);
|
||||
const allCreds: any[] = (rCred as any)?.list || [];
|
||||
setGitCredentials(allCreds.filter((c: any) => c.secret_type === 'api'));
|
||||
setDockerCredentials(allCreds.filter((c: any) => c.secret_type === 'docker'));
|
||||
setRepos(Array.isArray((rR as Record<string, any>)?.list) ? (rR as Record<string, any>).list : []);
|
||||
setRegistries(Array.isArray((rReg as Record<string, any>)?.list) ? (rReg as Record<string, any>).list : []);
|
||||
setEnvironments(Array.isArray((rEnv as Record<string, any>)?.list) ? (rEnv as Record<string, any>).list : []);
|
||||
const allCreds: Record<string, any>[] = (rCred as Record<string, any>)?.list || [];
|
||||
setGitCredentials(allCreds.filter(( c: any) => c.secret_type === 'api'));
|
||||
setDockerCredentials(allCreds.filter(( c: any) => c.secret_type === 'docker'));
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
@@ -141,9 +165,9 @@ export function PipelineManager() {
|
||||
useEffect(() => {
|
||||
pipelines.forEach(p => {
|
||||
if (p.last_status === 'running' && !runningRunMap[p.id]) {
|
||||
pipelineApi.getRuns(p.id).then((res: any) => {
|
||||
pipelineApi.getRuns(p.id).then(( res: any) => {
|
||||
const runsList = res?.list || [];
|
||||
const running = runsList.find((r: any) => r.status === 'running' || r.status === 'pending');
|
||||
const running = runsList.find(( r: any) => r.status === 'running' || r.status === 'pending');
|
||||
if (running) setRunningRunMap(prev => ({ ...prev, [p.id]: running }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -152,7 +176,7 @@ export function PipelineManager() {
|
||||
|
||||
const loadRuns = async (pipelineId: number, code: string) => {
|
||||
setLoadingRuns(p => ({ ...p, [pipelineId]: true }));
|
||||
try { const res = await pipelineApi.getRuns(code); setRuns(p => ({ ...p, [pipelineId]: (res as any)?.list || [] })); }
|
||||
try { const res = await pipelineApi.getRuns(code); setRuns(p => ({ ...p, [pipelineId]: (res as Record<string, any>)?.list || [] })); }
|
||||
catch { setRuns(p => ({ ...p, [pipelineId]: [] })); }
|
||||
finally { setLoadingRuns(p => ({ ...p, [pipelineId]: false })); }
|
||||
};
|
||||
@@ -224,8 +248,8 @@ export function PipelineManager() {
|
||||
const runningRun = runningRunMap[pipelineId];
|
||||
if (!runningRun) {
|
||||
try {
|
||||
const res: any = await pipelineApi.getRuns(code);
|
||||
const running = (res?.list || []).find((r: any) => r.status === 'running' || r.status === 'pending');
|
||||
const res: Record<string, any> = await pipelineApi.getRuns(code);
|
||||
const running = (res?.list || []).find(( r: any) => r.status === 'running' || r.status === 'pending');
|
||||
if (running) await handleStop(pipelineId, code, running.id);
|
||||
else toast.error('未找到运行中的任务');
|
||||
} catch { toast.error('停止失败'); }
|
||||
@@ -235,10 +259,10 @@ export function PipelineManager() {
|
||||
};
|
||||
const handleViewLog = async (code: string, run: any) => {
|
||||
setLogRun(run); setLogOpen(true);
|
||||
try { const data = await pipelineApi.getRunLog(code, run.id) as any; setLogs(data?.logs || []); }
|
||||
try { const data = await pipelineApi.getRunLog(code, run.id) as Record<string, any>; setLogs(data?.logs || []); }
|
||||
catch { setLogs([]); }
|
||||
};
|
||||
const handleDelete = async (pipeline: any) => {
|
||||
const handleDelete = async ( pipeline: any) => {
|
||||
const ok = await _confirm({ title: '删除流水线', description: `确定删除「${pipeline.name}」?`, variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await pipelineApi.deletePipeline(pipeline.code); toast.success('已删除'); loadPipelines(); }
|
||||
@@ -441,15 +465,15 @@ export function PipelineManager() {
|
||||
</div>
|
||||
{stages.length > 0 && (
|
||||
<div className="flex items-center mt-2.5">
|
||||
{stages.map((s: any, i: number) => {
|
||||
{stages.map(( s: any, i: number) => {
|
||||
const def = STAGE_DEFS.find(d => d.label === s.name);
|
||||
const Icon = def?.icon || Tag;
|
||||
const _Icon = def?.icon || Tag;
|
||||
// 从 last_run_stages 获取最新运行状态 // Logan.Li
|
||||
let runStatus: string = '';
|
||||
if (pipeline.last_run_stages) {
|
||||
try {
|
||||
const rs = typeof pipeline.last_run_stages === 'string' ? JSON.parse(pipeline.last_run_stages) : pipeline.last_run_stages;
|
||||
const match = Array.isArray(rs) ? rs.find((r: any) => r.name === s.name || r.type === def?.key) : null;
|
||||
const match = Array.isArray(rs) ? rs.find(( r: any) => r.name === s.name || r.type === def?.key) : null;
|
||||
runStatus = match?.status || '';
|
||||
} catch {}
|
||||
}
|
||||
@@ -494,7 +518,7 @@ export function PipelineManager() {
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{pipelineRuns.map((r: any) => (
|
||||
{pipelineRuns.map(( r: any) => (
|
||||
<tr key={r.id} 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)]">#{r.build_number || r.id}</td>
|
||||
<td className="px-4 py-2">
|
||||
@@ -883,7 +907,7 @@ function PipelineQueuePanel({ pipeline, clusterId: _cid }: { pipeline: any; clus
|
||||
if (!code) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await http.get(`/pipelines/${code}/queue`);
|
||||
const res: Record<string, any> = await http.get(`/pipelines/${code}/queue`);
|
||||
setQueue(res?.data || res);
|
||||
} catch { setQueue(null); }
|
||||
finally { setLoading(false); }
|
||||
@@ -924,7 +948,7 @@ function PipelineQueuePanel({ pipeline, clusterId: _cid }: { pipeline: any; clus
|
||||
{/* 排队中的构建 */}
|
||||
{queued > 0 && pendingRuns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pendingRuns.map((run: any) => (
|
||||
{pendingRuns.map(( run: any) => (
|
||||
<div key={run.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" />
|
||||
|
||||
@@ -101,8 +101,8 @@ function getRunStageStats(run: PipelineRun | null): Map<string, string> {
|
||||
// 如果 run.stages 有数据,直接用
|
||||
const statuses = parseStageStatuses(run.stages);
|
||||
statuses.forEach(s => {
|
||||
if (s.name || (s as any).type) {
|
||||
const key = (s as any).type || (STAGE_DEFS.find(d => d.label === s.name)?.key);
|
||||
if (s.name || (s as Record<string, any>).type) {
|
||||
const key = (s as Record<string, any>).type || (STAGE_DEFS.find(d => d.label === s.name)?.key);
|
||||
if (key) map.set(key, s.status);
|
||||
}
|
||||
});
|
||||
@@ -147,7 +147,7 @@ export default function PipelineRunDetail() {
|
||||
// 加载流水线详情
|
||||
const fetchPipeline = useCallback(async () => {
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/${pipelineCode}`);
|
||||
const data: Record<string, any> = await http.get(`/pipelines/${pipelineCode}`);
|
||||
setPipeline(data?.data || data);
|
||||
} catch { /* silent, pipeline info optional */ }
|
||||
}, [pipelineCode]);
|
||||
@@ -156,7 +156,7 @@ export default function PipelineRunDetail() {
|
||||
const fetchRuns = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/${pipelineCode}/runs?page_size=${pageSize}`);
|
||||
const data: Record<string, any> = await http.get(`/pipelines/${pipelineCode}/runs?page_size=${pageSize}`);
|
||||
const list = data?.list || [];
|
||||
setRuns(list);
|
||||
if (!selectedRun && list.length > 0) {
|
||||
@@ -180,7 +180,7 @@ export default function PipelineRunDetail() {
|
||||
// 加载日志
|
||||
const fetchLogs = useCallback(async (run: PipelineRun) => {
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/runs/${run.id}/log-stream`);
|
||||
const data: Record<string, any> = await http.get(`/pipelines/runs/${run.id}/log-stream`);
|
||||
if (data?.logs) {
|
||||
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
|
||||
setLogs(Array.isArray(parsed) ? parsed : []);
|
||||
@@ -351,7 +351,7 @@ export default function PipelineRunDetail() {
|
||||
const isRunning = status === 'running' || status === 'in_progress';
|
||||
const isFailed = status === 'failed' || status === 'error';
|
||||
const isActive = selectedStage === def.key;
|
||||
const isSelected = selectedStage === def.key;
|
||||
const _isSelected = selectedStage === def.key;
|
||||
const hasData = stageStats.size > 0;
|
||||
|
||||
// 颜色: 失败红 / 运行中蓝 / 成功绿 / 未运行灰
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ReleaseManager() {
|
||||
return matchesSearch && matchesEnv && matchesStatus;
|
||||
});
|
||||
|
||||
const handleDeploy = async (id: any, appName: string, version: string) => {
|
||||
const handleDeploy = async ( id: any, appName: string, version: string) => {
|
||||
const ok = await _confirm({
|
||||
title: '确认部署',
|
||||
description: `确定部署 ${appName} ${version} 到目标环境?`,
|
||||
@@ -90,7 +90,7 @@ export function ReleaseManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRollback = async (id: any, appName: string, version: string) => {
|
||||
const handleRollback = async ( id: any, appName: string, version: string) => {
|
||||
const ok = await _confirm({
|
||||
title: '确认回滚',
|
||||
description: `确定回滚 ${appName} ${version}?此操作将恢复到上一版本。`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Wrench, RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown } from 'lucide-react';
|
||||
import { RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -28,10 +28,10 @@ const categoryColor: Record<string, string> = {
|
||||
automation: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
other: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
};
|
||||
const methodLabel: Record<string, string> = {
|
||||
const _methodLabel: Record<string, string> = {
|
||||
preinstall: '预装', lazy: '懒加载', disabled: '禁用',
|
||||
};
|
||||
const methodColor: Record<string, string> = {
|
||||
const _methodColor: Record<string, string> = {
|
||||
preinstall: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
lazy: 'bg-violet-500/20 text-violet-400 border-violet-500/30',
|
||||
disabled: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
@@ -55,8 +55,8 @@ export default function ToolConfigTab() {
|
||||
const fetchTools = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await deployApi.getTools();
|
||||
setTools((res as any)?.list || []);
|
||||
const res: Record<string, any> = await deployApi.getTools();
|
||||
setTools((res as Record<string, any>)?.list || []);
|
||||
} catch { toast.error('获取工具配置失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
@@ -65,8 +65,8 @@ export default function ToolConfigTab() {
|
||||
|
||||
const openVersionDialog = async (tool: ToolConfig) => {
|
||||
try {
|
||||
const res: any = await deployApi.getToolVersions(tool.id);
|
||||
const versions = (res as any)?.versions || [];
|
||||
const res: Record<string, any> = await deployApi.getToolVersions(tool.id);
|
||||
const versions = (res as Record<string, any>)?.versions || [];
|
||||
if (versions.length === 0) {
|
||||
// No versions — install directly
|
||||
handleInstall(tool);
|
||||
@@ -99,7 +99,7 @@ export default function ToolConfigTab() {
|
||||
|
||||
const handleCheckStatus = async (tool: ToolConfig) => {
|
||||
try {
|
||||
const res: any = await deployApi.checkTool(tool.id);
|
||||
const res: Record<string, any> = await deployApi.checkTool(tool.id);
|
||||
toast.success(res?.available ? `${tool.display_name} 可用` : `${tool.display_name} 不可用`);
|
||||
fetchTools();
|
||||
} catch { toast.error('检测失败'); }
|
||||
@@ -119,9 +119,9 @@ export default function ToolConfigTab() {
|
||||
const start = Date.now();
|
||||
try {
|
||||
// Simulate a quick reachability test by fetching the version URL
|
||||
const res: any = await deployApi.getToolVersions(tool.id);
|
||||
const res: Record<string, any> = await deployApi.getToolVersions(tool.id);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
const available = (res as any)?.versions?.length > 0;
|
||||
const available = (res as Record<string, any>)?.versions?.length > 0;
|
||||
if (available) {
|
||||
toast.success(`${tool.display_name} 镜像可访问 (${elapsed}s)`);
|
||||
} else {
|
||||
@@ -290,7 +290,7 @@ export default function ToolConfigTab() {
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
{versionDialog?.versions.map((v, i) => (
|
||||
{versionDialog?.versions.map((v, _i) => (
|
||||
<button
|
||||
key={v.version}
|
||||
onClick={() => setSelectedVersion(v.version)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { deployApi } from '@/services/api';
|
||||
@@ -36,7 +35,7 @@ export default function UserGroupsManager() {
|
||||
|
||||
const loadGroups = async () => {
|
||||
setLoading(true);
|
||||
try { const res = await deployApi.getGroups(); setGroups((res as any)?.list || []); }
|
||||
try { const res = await deployApi.getGroups(); setGroups((res as Record<string, any>)?.list || []); }
|
||||
catch { toast.error('加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ export function WorkspaceTermDialog({
|
||||
useEffect(() => {
|
||||
if (!open || !runId) return;
|
||||
http.get(`/pipelines/runs/${runId}/workspace`)
|
||||
.then((data: any) => setWorkspace(data?.workspace_path || ''))
|
||||
.then(( data: any) => setWorkspace(data?.workspace_path || ''))
|
||||
.catch(() => {});
|
||||
}, [open, runId]);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const pipelineTemplates = [
|
||||
];
|
||||
|
||||
// 解析 stages 字段(兼容 JSON 数组 / 逗号分隔字符串) // Logan.Li
|
||||
export function parseStages(stages: any): PipelineStage[] {
|
||||
export function parseStages( stages: any): PipelineStage[] {
|
||||
if (Array.isArray(stages)) return stages;
|
||||
if (typeof stages === 'string') {
|
||||
try {
|
||||
@@ -42,7 +42,7 @@ export function nameToCode(name: string): string {
|
||||
}
|
||||
|
||||
// 状态徽章(使用后端 last_status 字段) // Logan.Li
|
||||
export function getStatusBadge(status: any) {
|
||||
export function getStatusBadge( status: any) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
// 发布状态徽章(列表 + 详情共用) // Logan.Li
|
||||
export function getStatusBadge(status: any) {
|
||||
export function getStatusBadge( status: any) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
@@ -59,7 +59,7 @@ export function getStatusBadge(status: any) {
|
||||
}
|
||||
|
||||
// 环境徽章 // Logan.Li
|
||||
export function getEnvBadge(env: any) {
|
||||
export function getEnvBadge( env: any) {
|
||||
const colors: Record<string, string> = {
|
||||
dev: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
|
||||
test: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
|
||||
|
||||
@@ -19,7 +19,7 @@ export const ENVIRONMENTS: Record<string, { label: string; color: string }> = {
|
||||
};
|
||||
|
||||
// 发布状态徽章(审批流:待审批/已审批/已驳回/发布中/发布成功/发布失败/已回滚) // Logan.Li
|
||||
export function getStatusBadge(status: any) {
|
||||
export function getStatusBadge( status: any) {
|
||||
const map: Record<string, { icon: any; label: string; cls: string }> = {
|
||||
pending: { icon: Clock, label: '待审批', cls: 'border-amber-500/30 text-amber-400 bg-amber-500/10' },
|
||||
approved: { icon: Check, label: '已审批', cls: 'border-purple-500/30 text-purple-400 bg-purple-500/10' },
|
||||
@@ -41,7 +41,7 @@ export function getStatusBadge(status: any) {
|
||||
}
|
||||
|
||||
// 环境徽章 // Logan.Li
|
||||
export function getEnvironmentBadge(env: any) {
|
||||
export function getEnvironmentBadge( env: any) {
|
||||
const cfg = ENVIRONMENTS[env];
|
||||
return (
|
||||
<Badge variant="outline" className={cfg?.color || 'border-slate-500/30 text-slate-400'}>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function CapacityPage() {
|
||||
const [activeCluster, setActiveCluster] = useState(clusterCode || '');
|
||||
|
||||
useEffect(() => {
|
||||
kubernetesApi.getClusters().then((res: any) => {
|
||||
kubernetesApi.getClusters().then(( res: any) => {
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (!activeCluster && list.length > 0) {
|
||||
@@ -36,7 +36,7 @@ export default function CapacityPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCluster) return;
|
||||
kubernetesApi.getNamespaces(activeCluster).then((res: any) => {
|
||||
kubernetesApi.getNamespaces(activeCluster).then(( res: any) => {
|
||||
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
|
||||
}).catch(() => {});
|
||||
}, [activeCluster]);
|
||||
@@ -46,7 +46,7 @@ export default function CapacityPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ns = selectedNs === 'all' ? undefined : selectedNs;
|
||||
const res: any = await kubernetesApi.getCapacity(activeCluster, ns);
|
||||
const res: Record<string, any> = await kubernetesApi.getCapacity(activeCluster, ns);
|
||||
setCapacity(Array.isArray(res) ? res : (res?.list || []));
|
||||
} catch { setCapacity([]); } finally { setLoading(false); }
|
||||
}, [activeCluster, selectedNs]);
|
||||
@@ -54,8 +54,8 @@ export default function CapacityPage() {
|
||||
useEffect(() => { loadCapacity(); }, [loadCapacity]);
|
||||
|
||||
const rows = useMemo(() =>
|
||||
capacity.flatMap((d: any) =>
|
||||
(d.containers || []).map((c: any) => ({
|
||||
capacity.flatMap(( d: any) =>
|
||||
(d.containers || []).map(( c: any) => ({
|
||||
namespace: d.namespace,
|
||||
deployment: d.name,
|
||||
replicas: d.replicas,
|
||||
@@ -83,7 +83,7 @@ export default function CapacityPage() {
|
||||
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/capacity`); }}>
|
||||
<SelectTrigger className="w-40 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{clusters.map((c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
|
||||
{clusters.map(( c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedNs} onValueChange={setSelectedNs}>
|
||||
|
||||
@@ -12,9 +12,21 @@ import {
|
||||
PieChart, Pie, Cell,
|
||||
} from 'recharts';
|
||||
import {
|
||||
RefreshCw, Cloud, Cpu, Container, Boxes, Activity, Server, Shield,
|
||||
HardDrive, Layers, Network, ChevronRight, TrendingUp,
|
||||
|
||||
RefreshCw,
|
||||
Cloud,
|
||||
Cpu,
|
||||
Container,
|
||||
Boxes,
|
||||
Activity,
|
||||
Server,
|
||||
HardDrive,
|
||||
Layers,
|
||||
Network,
|
||||
ChevronRight,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
|
||||
const CHART_COLORS = ['#3B82F6', '#207060', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
|
||||
@@ -29,7 +41,7 @@ export default function ClusterComparePage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res: any = await kubernetesApi.getMultiClusterComparison();
|
||||
const res: Record<string, any> = await kubernetesApi.getMultiClusterComparison();
|
||||
setData(res || null);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || '加载失败');
|
||||
@@ -41,11 +53,11 @@ export default function ClusterComparePage() {
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const clusters: any[] = data?.clusters || [];
|
||||
const totals: any = data?.totals || {};
|
||||
const clusters: Record<string, any>[] = data?.clusters || [];
|
||||
const totals: Record<string, any> = data?.totals || {};
|
||||
|
||||
// 图表数据准备
|
||||
const resourceBarData = clusters.map((c: any) => ({
|
||||
const resourceBarData = clusters.map(( c: any) => ({
|
||||
name: c.code || c.name,
|
||||
节点: c.node_count || 0,
|
||||
Pod: c.pod_count || 0,
|
||||
@@ -53,22 +65,22 @@ export default function ClusterComparePage() {
|
||||
Service: c.service_count || 0,
|
||||
}));
|
||||
|
||||
const usageBarData = clusters.map((c: any) => ({
|
||||
const usageBarData = clusters.map(( c: any) => ({
|
||||
name: c.code || c.name,
|
||||
'CPU %': Number((c.cpu_usage || 0).toFixed(1)),
|
||||
'内存 %': Number((c.memory_usage || 0).toFixed(1)),
|
||||
}));
|
||||
|
||||
const radarData = clusters.length > 0 ? [
|
||||
{ metric: '节点数', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, c.node_count || 0])) },
|
||||
{ metric: 'Pod数', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, Math.min(c.pod_count || 0, 100)])) },
|
||||
{ metric: '部署数', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, c.deployment_count || 0])) },
|
||||
{ metric: 'CPU使用', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, Number((c.cpu_usage || 0).toFixed(1))])) },
|
||||
{ metric: '内存使用', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, Number((c.memory_usage || 0).toFixed(1))])) },
|
||||
{ metric: 'CPU核数', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, c.total_cpu_cores || 0])) },
|
||||
{ metric: '节点数', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, c.node_count || 0])) },
|
||||
{ metric: 'Pod数', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, Math.min(c.pod_count || 0, 100)])) },
|
||||
{ metric: '部署数', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, c.deployment_count || 0])) },
|
||||
{ metric: 'CPU使用', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, Number((c.cpu_usage || 0).toFixed(1))])) },
|
||||
{ metric: '内存使用', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, Number((c.memory_usage || 0).toFixed(1))])) },
|
||||
{ metric: 'CPU核数', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, c.total_cpu_cores || 0])) },
|
||||
] : [];
|
||||
|
||||
const nodeRolePie = clusters.flatMap((c: any) => [
|
||||
const nodeRolePie = clusters.flatMap(( c: any) => [
|
||||
{ name: `${c.code}-Master`, value: c.master_count || 0, fill: CHART_COLORS[1] },
|
||||
{ name: `${c.code}-Worker`, value: c.worker_count || 0, fill: CHART_COLORS[0] },
|
||||
]).filter(d => d.value > 0);
|
||||
@@ -226,7 +238,7 @@ export default function ClusterComparePage() {
|
||||
<PolarGrid stroke="#1e293b" />
|
||||
<PolarAngleAxis dataKey="metric" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<PolarRadiusAxis tick={{ fill: '#64748b', fontSize: 10 }} />
|
||||
{clusters.map((c: any, i: number) => (
|
||||
{clusters.map(( c: any, i: number) => (
|
||||
<Radar key={i} name={c.code || c.name}
|
||||
dataKey={c.code || c.name}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
@@ -253,7 +265,7 @@ export default function ClusterComparePage() {
|
||||
<PieChart>
|
||||
<Pie data={nodeRolePie} cx="50%" cy="45%" innerRadius={50} outerRadius={90}
|
||||
paddingAngle={3} dataKey="value"
|
||||
label={(e: any) => `${e.name}: ${e.value}`}
|
||||
label={( e: any) => `${e.name}: ${e.value}`}
|
||||
labelLine={{ stroke: '#475569' }}
|
||||
>
|
||||
{nodeRolePie.map((_, i) => (
|
||||
@@ -298,7 +310,7 @@ export default function ClusterComparePage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clusters.map((c: any) => (
|
||||
{clusters.map(( c: any) => (
|
||||
<TableRow key={c.id}
|
||||
className="border-slate-800/50 hover:bg-slate-800/30 cursor-pointer"
|
||||
onClick={() => navigate(`/kubernetes/${c.code}`)}
|
||||
|
||||
@@ -7,9 +7,19 @@ import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
RefreshCw, Loader2, Cpu, Server, Container,
|
||||
ChevronRight, Cloud, Boxes, Network, FileText, Shield, Activity, Clock,
|
||||
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Cpu,
|
||||
Server,
|
||||
Container,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
Boxes,
|
||||
Shield,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
@@ -25,7 +35,7 @@ export default function ClusterOverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [, setClusters] = useState<any[]>([]);
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [clusterInfo, setClusterInfo] = useState<any>(null);
|
||||
@@ -34,10 +44,10 @@ export default function ClusterOverviewPage() {
|
||||
// Load cluster list + current cluster info
|
||||
const loadClusterInfo = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await kubernetesApi.getClusters();
|
||||
const res: Record<string, any> = await kubernetesApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
const current = list.find((c: any) => (c.code || String(c.id)) === clusterCode);
|
||||
const current = list.find(( c: any) => (c.code || String(c.id)) === clusterCode);
|
||||
setClusterInfo(current || null);
|
||||
if (!current && list.length > 0 && clusterCode) {
|
||||
toast({ title: '集群未找到', description: `找不到集群 ${clusterCode}`, variant: 'destructive' });
|
||||
@@ -62,7 +72,7 @@ export default function ClusterOverviewPage() {
|
||||
useEffect(() => { loadClusterInfo(); }, [loadClusterInfo]);
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const getNodeRole = (node: any) => {
|
||||
const getNodeRole = ( node: any) => {
|
||||
if (node?.role === 'master' || node?.role === 'control-plane') return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
};
|
||||
@@ -147,7 +157,7 @@ export default function ClusterOverviewPage() {
|
||||
) : nodes.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Cpu className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无节点</TableCell></TableRow>
|
||||
) : (
|
||||
nodes.map((n: any) => {
|
||||
nodes.map(( n: any) => {
|
||||
const cpuCores = n.cpu || 0;
|
||||
const cpuUsageM = n.cpu_usage_m ? parseFloat(n.cpu_usage_m.replace('m','')) : 0;
|
||||
const cpuPct = cpuCores > 0 ? (cpuUsageM / 1000 / cpuCores) * 100 : 0;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
@@ -17,13 +16,25 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
ArrowLeft, Minus, Plus, RotateCw, Upload, FileText, Terminal,
|
||||
Gauge, Clock, Play, Pause, Trash2, RefreshCw, Loader2,
|
||||
CheckCircle, AlertTriangle, XCircle, ExternalLink, FolderOpen,
|
||||
|
||||
ArrowLeft,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Upload,
|
||||
FileText,
|
||||
Terminal,
|
||||
Gauge,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
XCircle,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useUserStore } from '@/store';
|
||||
import { PodExecTerminal } from './PodExecTerminal';
|
||||
import { PodLogsViewer } from './PodLogsViewer';
|
||||
import { PodFileBrowser } from './PodFileBrowser';
|
||||
@@ -45,7 +56,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
const [actionLoading, setActionLoading] = useState('');
|
||||
|
||||
// Scale
|
||||
const [scaleValue, setScaleValue] = useState(0);
|
||||
const [, setScaleValue] = useState(0);
|
||||
// Image update
|
||||
const [newImage, setNewImage] = useState('');
|
||||
const [showImageUpdate, setShowImageUpdate] = useState(false);
|
||||
@@ -65,10 +76,10 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
const [logsTabPod, setLogsTabPod] = useState<string>('');
|
||||
|
||||
// 从 Pod 对象提取容器名列表(兼容多种字段)
|
||||
const extractContainers = (pod: any): string[] => {
|
||||
const extractContainers = ( pod: any): string[] => {
|
||||
const c = pod?.containers;
|
||||
if (Array.isArray(c)) return c.map((x: any) => typeof x === 'string' ? x : (x?.name || x?.Name)).filter(Boolean);
|
||||
if (Array.isArray(pod?.container_statuses)) return pod.container_statuses.map((x: any) => x?.name).filter(Boolean);
|
||||
if (Array.isArray(c)) return c.map(( x: any) => typeof x === 'string' ? x : (x?.name || x?.Name)).filter(Boolean);
|
||||
if (Array.isArray(pod?.container_statuses)) return pod.container_statuses.map(( x: any) => x?.name).filter(Boolean);
|
||||
if (Array.isArray(pod?.containerNames)) return pod.containerNames.filter(Boolean);
|
||||
return [];
|
||||
};
|
||||
@@ -81,12 +92,12 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => ({ items: [] })),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || []));
|
||||
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || []));
|
||||
const allDeps = toArray(depRes);
|
||||
const allPods = toArray(podRes);
|
||||
const allHpas = toArray(hpaRes);
|
||||
|
||||
const dep = allDeps.find((d: any) => d.name === deploymentName && d.namespace === namespace);
|
||||
const dep = allDeps.find(( d: any) => d.name === deploymentName && d.namespace === namespace);
|
||||
setDeployment(dep || null);
|
||||
if (dep) {
|
||||
setScaleValue(dep.replicas || 0);
|
||||
@@ -94,13 +105,13 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
}
|
||||
|
||||
// Filter pods by deployment label (prefix match on pod name)
|
||||
const depPods = allPods.filter((p: any) =>
|
||||
const depPods = allPods.filter(( p: any) =>
|
||||
p.namespace === namespace && p.name.startsWith(deploymentName)
|
||||
);
|
||||
setPods(depPods);
|
||||
|
||||
// Filter HPAs targeting this deployment (flattened data)
|
||||
const depHpas = allHpas.filter((h: any) => {
|
||||
const depHpas = allHpas.filter(( h: any) => {
|
||||
return h.target_name === deploymentName && h.namespace === namespace;
|
||||
});
|
||||
setHpas(depHpas);
|
||||
@@ -149,19 +160,19 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleLoadLogs = async (podName: string) => {
|
||||
const _handleLoadLogs = async (podName: string) => {
|
||||
setLogPod(podName);
|
||||
setShowLogs(true);
|
||||
setLogs('');
|
||||
try {
|
||||
const res: any = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, logTail);
|
||||
const res: Record<string, any> = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, logTail);
|
||||
setLogs(res?.data?.logs || res?.logs || '无日志');
|
||||
} catch {
|
||||
setLogs('获取日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHpaToggle = async (hpa: any) => {
|
||||
const handleHpaToggle = async ( hpa: any) => {
|
||||
// Not direct toggle — delete HPA
|
||||
if (!confirm(`确定删除 HPA "${hpa.name}"?`)) return;
|
||||
try {
|
||||
@@ -370,7 +381,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pods.map((pod: any) => (
|
||||
pods.map(( pod: any) => (
|
||||
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
|
||||
<TableCell>
|
||||
@@ -434,7 +445,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
variant="outline" size="sm"
|
||||
className="border-blue-700 text-blue-400 text-xs h-7"
|
||||
onClick={() => {
|
||||
const pod = pods.find((p: any) => p.name === logPod);
|
||||
const pod = pods.find(( p: any) => p.name === logPod);
|
||||
setLogsViewerPod({ name: logPod, containers: pod ? extractContainers(pod) : [] });
|
||||
setLogsViewerOpen(true);
|
||||
}}
|
||||
@@ -466,7 +477,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
<SelectValue placeholder="选择 Pod…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pods.map((p: any) => (
|
||||
{pods.map(( p: any) => (
|
||||
<SelectItem key={p.name} value={p.name}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -476,7 +487,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
className="bg-blue-600 hover:bg-blue-700 h-8"
|
||||
disabled={!logsTabPod}
|
||||
onClick={() => {
|
||||
const pod = pods.find((p: any) => p.name === logsTabPod);
|
||||
const pod = pods.find(( p: any) => p.name === logsTabPod);
|
||||
setLogsViewerPod({ name: logsTabPod, containers: pod ? extractContainers(pod) : [] });
|
||||
setLogsViewerOpen(true);
|
||||
}}
|
||||
@@ -532,7 +543,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.map((hpa: any) => {
|
||||
{hpas.map(( hpa: any) => {
|
||||
const cpu = hpa.cpu_percent || 0;
|
||||
const mem = hpa.mem_percent || 0;
|
||||
const metricsParts = [];
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Save, Loader2, Settings, RefreshCw, Server, Shield, Clock, Network, Plus, Trash2, Globe, MapPin,
|
||||
} from 'lucide-react';
|
||||
@@ -119,13 +118,13 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
|
||||
// 加载节点列表
|
||||
if (clusterCode) {
|
||||
setNodesLoading(true);
|
||||
kubernetesApi.getClusterNodes(clusterCode).then((res: any) => {
|
||||
const nodes: any[] = Array.isArray(res) ? res : (res?.items || res?.list || []);
|
||||
const mapped = nodes.map((n: any) => {
|
||||
kubernetesApi.getClusterNodes(clusterCode).then(( res: any) => {
|
||||
const nodes: Record<string, any>[] = Array.isArray(res) ? res : (res?.items || res?.list || []);
|
||||
const mapped = nodes.map(( n: any) => {
|
||||
const meta = n.metadata || {};
|
||||
const status = n.status || {};
|
||||
const addresses: any[] = Array.isArray(status.addresses) ? status.addresses : [];
|
||||
const internalIP = addresses.find((a: any) => a.type === 'InternalIP')?.address || '';
|
||||
const addresses: Record<string, any>[] = Array.isArray(status.addresses) ? status.addresses : [];
|
||||
const internalIP = addresses.find(( a: any) => a.type === 'InternalIP')?.address || '';
|
||||
return { name: meta.name || '', ip: internalIP, labels: meta.labels || {} };
|
||||
});
|
||||
setAvailableNodes(mapped);
|
||||
@@ -143,7 +142,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
|
||||
}
|
||||
|
||||
// tolerations
|
||||
const tols: any[] = Array.isArray(podSpec.tolerations) ? podSpec.tolerations : [];
|
||||
const tols: Record<string, any>[] = Array.isArray(podSpec.tolerations) ? podSpec.tolerations : [];
|
||||
setTolerations(tols.map(t => ({
|
||||
key: t.key || '',
|
||||
operator: t.operator || 'Equal',
|
||||
@@ -172,7 +171,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
|
||||
setShareProcessNamespace(!!podSpec.shareProcessNamespace);
|
||||
|
||||
// hostAliases(M8 补全)
|
||||
const ha: any[] = Array.isArray(podSpec.hostAliases) ? podSpec.hostAliases : [];
|
||||
const ha: Record<string, any>[] = Array.isArray(podSpec.hostAliases) ? podSpec.hostAliases : [];
|
||||
setHostAliases(ha.map(a => ({
|
||||
ip: a.ip || '',
|
||||
hostnames: Array.isArray(a.hostnames) ? a.hostnames.join(', ') : '',
|
||||
@@ -182,7 +181,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
|
||||
const dc = podSpec.dnsConfig || {};
|
||||
setDnsNameservers(Array.isArray(dc.nameservers) ? dc.nameservers.join('\n') : '');
|
||||
setDnsSearches(Array.isArray(dc.searches) ? dc.searches.join('\n') : '');
|
||||
const opts: any[] = Array.isArray(dc.options) ? dc.options : [];
|
||||
const opts: Record<string, any>[] = Array.isArray(dc.options) ? dc.options : [];
|
||||
setDnsOptions(opts.map(o => ({ name: o.name || '', value: o.value || '' })));
|
||||
|
||||
setLoading(false);
|
||||
@@ -195,9 +194,9 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
|
||||
setLabelSelectorKv(prev => prev.map((n, idx) => (idx === i ? { ...n, ...patch } : n)));
|
||||
|
||||
// tolerations 操作
|
||||
const addTol = () => setTolerations(prev => [...prev, { key: '', operator: 'Equal', value: '', effect: 'NoSchedule', tolerationSeconds: '' }]);
|
||||
const removeTol = (i: number) => setTolerations(prev => prev.filter((_, idx) => idx !== i));
|
||||
const updateTol = (i: number, patch: Partial<Toleration>) =>
|
||||
const _addTol = () => setTolerations(prev => [...prev, { key: '', operator: 'Equal', value: '', effect: 'NoSchedule', tolerationSeconds: '' }]);
|
||||
const _removeTol = (i: number) => setTolerations(prev => prev.filter((_, idx) => idx !== i));
|
||||
const _updateTol = (i: number, patch: Partial<Toleration>) =>
|
||||
setTolerations(prev => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t)));
|
||||
|
||||
const save = async () => {
|
||||
@@ -235,7 +234,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
|
||||
if (podSpec.nodeName) delete podSpec.nodeName;
|
||||
if (podSpec.affinity) delete podSpec.affinity.nodeAffinity;
|
||||
} else if (scheduleMode === 'nodeAffinity') {
|
||||
const matchExpressions: any[] = labelSelectorKv
|
||||
const matchExpressions: Record<string, any>[] = labelSelectorKv
|
||||
.filter(s => s.key.trim())
|
||||
.map(s => ({ key: s.key.trim(), operator: 'In', values: [s.value] }));
|
||||
if (matchExpressions.length > 0) {
|
||||
@@ -256,7 +255,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
|
||||
const tolsK8s = tolerations
|
||||
.filter(t => t.key || t.operator === 'Exists')
|
||||
.map(t => {
|
||||
const out: any = {};
|
||||
const out: Record<string, any> = {};
|
||||
if (t.key) out.key = t.key;
|
||||
if (t.operator) out.operator = t.operator;
|
||||
if (t.value && t.operator !== 'Exists') out.value = t.value;
|
||||
@@ -275,7 +274,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
|
||||
if (serviceAccountName) podSpec.serviceAccountName = serviceAccountName;
|
||||
|
||||
// Pod 安全上下文
|
||||
const sc: any = {};
|
||||
const sc: Record<string, any> = {};
|
||||
if (podFsGroup) sc.fsGroup = Number(podFsGroup);
|
||||
if (podRunAsUser) sc.runAsUser = Number(podRunAsUser);
|
||||
if (podRunAsGroup) sc.runAsGroup = Number(podRunAsGroup);
|
||||
@@ -301,13 +300,13 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
|
||||
else delete podSpec.hostAliases;
|
||||
|
||||
// dnsConfig(M8 补全)— 仅 dnsPolicy=None 时 dnsConfig 才有意义,但仍允许编辑
|
||||
const dcObj: any = {};
|
||||
const dcObj: Record<string, any> = {};
|
||||
const ns = dnsNameservers.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (ns.length > 0) dcObj.nameservers = ns;
|
||||
const sr = dnsSearches.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (sr.length > 0) dcObj.searches = sr;
|
||||
const dkOpts = dnsOptions.filter(o => o.name.trim()).map(o => {
|
||||
const out: any = { name: o.name.trim() };
|
||||
const out: Record<string, any> = { name: o.name.trim() };
|
||||
if (o.value.trim()) out.value = o.value.trim();
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// DeploymentEditBasic.tsx — 编辑器「基本信息」Tab
|
||||
// 编辑内容:replicas / labels / annotations(name/namespace 只读)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect} from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -10,7 +10,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { cleanYAML } from './k8s-yaml-utils';
|
||||
|
||||
interface DeploymentEditBasicProps {
|
||||
clusterCode: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Kuboard 风格容器编辑器:工作容器 + 初始化容器完整编辑
|
||||
// 编辑字段:镜像/拉取策略/命令/参数/环境变量/资源/端口/健康检查/生命周期/安全上下文/卷挂载
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect} from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -13,14 +13,28 @@ import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
import {
|
||||
Collapsible, CollapsibleContent, CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Container, Cpu, Heart, FolderOpen,
|
||||
ChevronDown, ChevronRight, Terminal, Shield, Zap, Network, KeyRound,
|
||||
Package, Play, Settings2, RotateCw,
|
||||
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
Loader2,
|
||||
Container,
|
||||
Cpu,
|
||||
Heart,
|
||||
FolderOpen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
Shield,
|
||||
Zap,
|
||||
Network,
|
||||
KeyRound,
|
||||
Play,
|
||||
RotateCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
|
||||
@@ -88,8 +102,8 @@ interface DeploymentEditContainersProps {
|
||||
|
||||
// ============ K8s container spec → form conversion ============
|
||||
|
||||
function toContainerForm(c: any): ContainerForm {
|
||||
const probe = (p: any): ProbeDef | null => {
|
||||
function toContainerForm( c: any): ContainerForm {
|
||||
const probe = ( p: any): ProbeDef | null => {
|
||||
if (!p) return null;
|
||||
let type: ProbeDef['type'] = 'none';
|
||||
if (p.httpGet) type = 'httpGet';
|
||||
@@ -109,7 +123,7 @@ function toContainerForm(c: any): ContainerForm {
|
||||
failureThreshold: p.failureThreshold,
|
||||
};
|
||||
};
|
||||
const lh = (h: any): LifecycleHandler => {
|
||||
const lh = ( h: any): LifecycleHandler => {
|
||||
if (!h) return { type: 'none' };
|
||||
let type: LifecycleHandler['type'] = 'none';
|
||||
if (h.exec) type = 'exec';
|
||||
@@ -131,8 +145,8 @@ function toContainerForm(c: any): ContainerForm {
|
||||
command: Array.isArray(c.command) ? c.command : [],
|
||||
args: Array.isArray(c.args) ? c.args : [],
|
||||
workingDir: c.workingDir || '',
|
||||
env: (Array.isArray(c.env) ? c.env : []).map((e: any) => ({ name: e.name || '', value: e.value ?? '' })),
|
||||
ports: (Array.isArray(c.ports) ? c.ports : []).map((p: any) => ({ name: p.name || '', containerPort: p.containerPort ?? '', protocol: p.protocol || 'TCP' })),
|
||||
env: (Array.isArray(c.env) ? c.env : []).map(( e: any) => ({ name: e.name || '', value: e.value ?? '' })),
|
||||
ports: (Array.isArray(c.ports) ? c.ports : []).map(( p: any) => ({ name: p.name || '', containerPort: p.containerPort ?? '', protocol: p.protocol || 'TCP' })),
|
||||
resources: {
|
||||
requests: c.resources?.requests || {},
|
||||
limits: c.resources?.limits || {},
|
||||
@@ -154,7 +168,7 @@ function toContainerForm(c: any): ContainerForm {
|
||||
capabilitiesAdd: sc.capabilities?.add || [],
|
||||
capabilitiesDrop: sc.capabilities?.drop || [],
|
||||
} : null,
|
||||
volumeMounts: (Array.isArray(c.volumeMounts) ? c.volumeMounts : []).map((m: any) => ({
|
||||
volumeMounts: (Array.isArray(c.volumeMounts) ? c.volumeMounts : []).map(( m: any) => ({
|
||||
name: m.name || '', mountPath: m.mountPath || '', subPath: m.subPath || '', readOnly: m.readOnly || false,
|
||||
})),
|
||||
};
|
||||
@@ -174,7 +188,7 @@ function emptyContainer(name = ''): ContainerForm {
|
||||
|
||||
function probeToK8s(p: ProbeDef | null | undefined): any {
|
||||
if (!p || p.type === 'none' || !p.type) return undefined;
|
||||
const out: any = {};
|
||||
const out: Record<string, any> = {};
|
||||
if (p.type === 'httpGet') {
|
||||
out.httpGet = {};
|
||||
if (p.httpPath) out.httpGet.path = p.httpPath;
|
||||
@@ -202,13 +216,13 @@ function handlerToK8s(h: LifecycleHandler | undefined): any {
|
||||
return cmds.length ? { exec: { command: cmds } } : undefined;
|
||||
}
|
||||
if (h.type === 'httpGet') {
|
||||
const o: any = { httpGet: {} };
|
||||
const o: Record<string, any> = { httpGet: {} };
|
||||
if (h.httpPath) o.httpGet.path = h.httpPath;
|
||||
if (h.httpPort !== '' && h.httpPort != null) o.httpGet.port = Number(h.httpPort);
|
||||
return o;
|
||||
}
|
||||
if (h.type === 'tcpSocket') {
|
||||
const o: any = { tcpSocket: {} };
|
||||
const o: Record<string, any> = { tcpSocket: {} };
|
||||
if (h.tcpPort !== '' && h.tcpPort != null) o.tcpSocket.port = Number(h.tcpPort);
|
||||
return o;
|
||||
}
|
||||
@@ -216,7 +230,7 @@ function handlerToK8s(h: LifecycleHandler | undefined): any {
|
||||
}
|
||||
|
||||
function containerToK8s(c: ContainerForm): any {
|
||||
const out: any = { name: c.name };
|
||||
const out: Record<string, any> = { name: c.name };
|
||||
if (c.image) out.image = c.image;
|
||||
if (c.imagePullPolicy) out.imagePullPolicy = c.imagePullPolicy;
|
||||
if (c.workingDir) out.workingDir = c.workingDir;
|
||||
@@ -224,14 +238,14 @@ function containerToK8s(c: ContainerForm): any {
|
||||
if (c.args.length > 0) out.args = c.args;
|
||||
if (c.env.length > 0) {
|
||||
out.env = c.env.filter(e => e.name.trim()).map(e => {
|
||||
const o: any = { name: e.name };
|
||||
const o: Record<string, any> = { name: e.name };
|
||||
if (e.value != null) o.value = e.value;
|
||||
return o;
|
||||
});
|
||||
}
|
||||
if (c.ports.length > 0) {
|
||||
out.ports = c.ports.filter(p => p.containerPort !== '').map(p => {
|
||||
const o: any = { containerPort: typeof p.containerPort === 'number' ? p.containerPort : Number(p.containerPort) };
|
||||
const o: Record<string, any> = { containerPort: typeof p.containerPort === 'number' ? p.containerPort : Number(p.containerPort) };
|
||||
if (p.name) o.name = p.name;
|
||||
if (p.protocol) o.protocol = p.protocol;
|
||||
return o;
|
||||
@@ -258,14 +272,14 @@ function containerToK8s(c: ContainerForm): any {
|
||||
const rp = probeToK8s(c.readinessProbe); if (rp) out.readinessProbe = rp;
|
||||
const sp = probeToK8s(c.startupProbe); if (sp) out.startupProbe = sp;
|
||||
if (c.lifecycle) {
|
||||
const lc: any = {};
|
||||
const lc: Record<string, any> = {};
|
||||
const ps = handlerToK8s(c.lifecycle.postStart); if (ps) lc.postStart = ps;
|
||||
const pst = handlerToK8s(c.lifecycle.preStop); if (pst) lc.preStop = pst;
|
||||
if (Object.keys(lc).length > 0) out.lifecycle = lc;
|
||||
}
|
||||
if (c.securityContext) {
|
||||
const s = c.securityContext;
|
||||
const sc: any = {};
|
||||
const sc: Record<string, any> = {};
|
||||
if (s.runAsUser !== '' && s.runAsUser != null) sc.runAsUser = Number(s.runAsUser);
|
||||
if (s.runAsGroup !== '' && s.runAsGroup != null) sc.runAsGroup = Number(s.runAsGroup);
|
||||
if (s.runAsNonRoot != null) sc.runAsNonRoot = s.runAsNonRoot;
|
||||
@@ -283,7 +297,7 @@ function containerToK8s(c: ContainerForm): any {
|
||||
}
|
||||
if (c.volumeMounts.length > 0) {
|
||||
out.volumeMounts = c.volumeMounts.filter(m => m.name.trim() && m.mountPath.trim()).map(m => {
|
||||
const o: any = { name: m.name, mountPath: m.mountPath };
|
||||
const o: Record<string, any> = { name: m.name, mountPath: m.mountPath };
|
||||
if (m.subPath) o.subPath = m.subPath;
|
||||
if (m.readOnly) o.readOnly = true;
|
||||
return o;
|
||||
@@ -314,7 +328,7 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
|
||||
const podSpec = manifest?.spec?.template?.spec || {};
|
||||
setContainers((Array.isArray(podSpec.containers) ? podSpec.containers : []).map(toContainerForm));
|
||||
setInitContainers((Array.isArray(podSpec.initContainers) ? podSpec.initContainers : []).map(toContainerForm));
|
||||
setImagePullSecrets((Array.isArray(podSpec.imagePullSecrets) ? podSpec.imagePullSecrets : []).map((s: any) => s.name || ''));
|
||||
setImagePullSecrets((Array.isArray(podSpec.imagePullSecrets) ? podSpec.imagePullSecrets : []).map(( s: any) => s.name || ''));
|
||||
setLoading(false);
|
||||
}, [manifest]);
|
||||
|
||||
@@ -735,7 +749,7 @@ function ContainerEditor({ container, onUpdate }: {
|
||||
<ProbeEditor key={pt}
|
||||
label={{ livenessProbe: '存活探针 (liveness)', readinessProbe: '就绪探针 (readiness)', startupProbe: '启动探针 (startup)' }[pt]}
|
||||
probe={c[pt]}
|
||||
onChange={(p) => onUpdate({ [pt]: p } as any)}
|
||||
onChange={(p) => onUpdate({ [pt]: p } as Record<string, any>)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,21 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Network, GitBranch, Loader2, RefreshCw, ExternalLink, Plus, Copy,
|
||||
CheckCircle, AlertTriangle, Server, Trash2, Minus, Zap,
|
||||
|
||||
Network,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
CheckCircle,
|
||||
Server,
|
||||
Trash2,
|
||||
Minus,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useUserStore } from '@/store';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
|
||||
interface Props {
|
||||
@@ -39,7 +49,7 @@ interface IngSummary {
|
||||
name: string;
|
||||
className: string;
|
||||
hosts: string[];
|
||||
rules: Array<{ host: string; paths: Array<{ path: string; backend: { service: string; port: any } }> }>;
|
||||
rules: Array<{ host: string; paths: Array<{ path: string; backend: { service: string; port: unknown} }> }>;
|
||||
raw: any;
|
||||
}
|
||||
|
||||
@@ -50,7 +60,7 @@ const PORT_PRESETS = [
|
||||
{ label: 'gRPC', protocol: 'TCP' as const, port: 8002, targetPort: 8002, name: 'grpc' },
|
||||
];
|
||||
|
||||
interface SvcPortForm {
|
||||
interface _SvcPortForm {
|
||||
name: string;
|
||||
port: string;
|
||||
targetPort: string;
|
||||
@@ -58,7 +68,7 @@ interface SvcPortForm {
|
||||
nodePort: string;
|
||||
}
|
||||
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
|
||||
export function DeploymentEditService({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
|
||||
const { toast } = useToast();
|
||||
@@ -120,12 +130,12 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
|
||||
const allSvcs = toArray(svcRes);
|
||||
const allIngs = toArray(ingRes);
|
||||
|
||||
const svcSummaries: SvcSummary[] = allSvcs.map((s: any) => {
|
||||
const svcSummaries: SvcSummary[] = allSvcs.map(( s: any) => {
|
||||
const spec = s.spec || {};
|
||||
const ports = Array.isArray(spec.ports) ? spec.ports.map((p: any) => ({
|
||||
const ports = Array.isArray(spec.ports) ? spec.ports.map(( p: any) => ({
|
||||
port: p.port, targetPort: p.targetPort, protocol: p.protocol || 'TCP',
|
||||
nodePort: p.nodePort, name: p.name,
|
||||
})) : Array.isArray(s.portList) ? s.portList.map((p: any) => ({
|
||||
})) : Array.isArray(s.portList) ? s.portList.map(( p: any) => ({
|
||||
port: p.port, targetPort: p.targetPort, protocol: p.protocol || 'TCP',
|
||||
nodePort: p.nodePort, name: p.name,
|
||||
})) : [];
|
||||
@@ -141,12 +151,12 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
|
||||
};
|
||||
});
|
||||
|
||||
const ingSummaries: IngSummary[] = allIngs.map((i: any) => {
|
||||
const ingSummaries: IngSummary[] = allIngs.map(( i: any) => {
|
||||
const spec = i.spec || {};
|
||||
const rawRules: any[] = Array.isArray(spec.rules) ? spec.rules : (Array.isArray(i.rules) ? i.rules : []);
|
||||
const rules = rawRules.map((r: any) => ({
|
||||
const rawRules: Record<string, any>[] = Array.isArray(spec.rules) ? spec.rules : (Array.isArray(i.rules) ? i.rules : []);
|
||||
const rules = rawRules.map(( r: any) => ({
|
||||
host: r.host || '',
|
||||
paths: (r.http?.paths || r.paths || []).map((p: any) => ({
|
||||
paths: (r.http?.paths || r.paths || []).map(( p: any) => ({
|
||||
path: p.path || '/',
|
||||
backend: {
|
||||
service: p.backend?.service?.name || p.backendService || '',
|
||||
@@ -157,7 +167,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
|
||||
return {
|
||||
name: i.name || i.metadata?.name || '',
|
||||
className: spec.ingressClassName || i.ingressClass || '<none>',
|
||||
hosts: rules.map((r: any) => r.host).filter(Boolean),
|
||||
hosts: rules.map(( r: any) => r.host).filter(Boolean),
|
||||
rules,
|
||||
raw: i,
|
||||
};
|
||||
@@ -187,7 +197,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
|
||||
const createService = async () => {
|
||||
const name = svcForm.name.trim() || `${deploymentName}-svc`;
|
||||
const k8sPorts = svcForm.ports.map(p => {
|
||||
const sp: any = {
|
||||
const sp: Record<string, any> = {
|
||||
name: p.name || 'http',
|
||||
port: Number(p.port) || 80,
|
||||
targetPort: /^\d+$/.test(String(p.targetPort)) ? Number(p.targetPort) : p.targetPort,
|
||||
@@ -197,7 +207,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
|
||||
return sp;
|
||||
});
|
||||
|
||||
const svcManifest: any = {
|
||||
const svcManifest: Record<string, any> = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Service',
|
||||
metadata: { name, namespace, labels: { app: deploymentName } },
|
||||
@@ -223,7 +233,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
|
||||
const name = ingForm.name.trim() || `${deploymentName}-ingress`;
|
||||
const targetSvc = matchedSvcs[0]?.name || `${deploymentName}-svc`;
|
||||
const port = Number(ingForm.svcPort) || 80;
|
||||
const manifest: any = {
|
||||
const manifest: Record<string, any> = {
|
||||
apiVersion: 'networking.k8s.io/v1',
|
||||
kind: 'Ingress',
|
||||
metadata: { name, namespace, labels: { app: deploymentName } },
|
||||
@@ -528,7 +538,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
|
||||
<code className="text-slate-300">{r.host || '*'}</code>
|
||||
{r.paths.map((p, j) => (
|
||||
<code key={j} className="bg-slate-800/60 px-1.5 py-0.5 rounded text-slate-400">
|
||||
{p.path} → {p.backend.service}:{p.backend.port}
|
||||
{p.path} → {(p as any).backend?.service}:{(p as any).backend?.port}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,8 @@ import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger} from '@/components/ui/select';
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server,
|
||||
} from 'lucide-react';
|
||||
@@ -59,7 +58,7 @@ const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardD
|
||||
{ key: 'secret', label: '密文', icon: Lock, color: 'text-rose-400', desc: 'Secret' },
|
||||
];
|
||||
|
||||
function volumeToForm(v: any): VolumeForm {
|
||||
function volumeToForm( v: any): VolumeForm {
|
||||
if (!v || typeof v !== 'object') return { name: '', type: 'emptyDir' };
|
||||
const name = v.name || '';
|
||||
if (v.emptyDir) return { name, type: 'emptyDir', medium: v.emptyDir.medium || '', sizeLimit: v.emptyDir.sizeLimit || '' };
|
||||
@@ -72,10 +71,10 @@ function volumeToForm(v: any): VolumeForm {
|
||||
}
|
||||
|
||||
function formToVolume(f: VolumeForm): any {
|
||||
const v: any = { name: f.name.trim() };
|
||||
const v: Record<string, any> = { name: f.name.trim() };
|
||||
if (!v.name) return null;
|
||||
switch (f.type) {
|
||||
case 'emptyDir': { const ed: any = {}; if (f.medium) ed.medium = f.medium; if (f.sizeLimit) ed.sizeLimit = f.sizeLimit; v.emptyDir = ed; break; }
|
||||
case 'emptyDir': { const ed: Record<string, any> = {}; if (f.medium) ed.medium = f.medium; if (f.sizeLimit) ed.sizeLimit = f.sizeLimit; v.emptyDir = ed; break; }
|
||||
case 'hostPath': v.hostPath = { path: f.path || '', type: f.pathType || 'Directory' }; break;
|
||||
case 'configMap': { v.configMap = { name: f.configMapName || '' }; if (f.cmItems) try { v.configMap.items = JSON.parse(f.cmItems); } catch {} break; }
|
||||
case 'secret': { v.secret = { secretName: f.secretName || '' }; if (f.secItems) try { v.secret.items = JSON.parse(f.secItems); } catch {} break; }
|
||||
@@ -101,7 +100,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
|
||||
const podSpec = m?.spec?.template?.spec || {};
|
||||
|
||||
// 数据卷
|
||||
const vols: any[] = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
|
||||
const vols: Record<string, any>[] = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
|
||||
setVolumes(vols.map(volumeToForm));
|
||||
|
||||
// 容器列表
|
||||
@@ -109,14 +108,14 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
|
||||
...(podSpec.containers || []),
|
||||
...(podSpec.initContainers || []),
|
||||
];
|
||||
const containerNames = allContainers.map((c: any) => c.name || '(unnamed)');
|
||||
const containerNames = allContainers.map(( c: any) => c.name || '(unnamed)');
|
||||
setContainers(containerNames);
|
||||
|
||||
// 挂载点
|
||||
const allMounts: MountForm[] = [];
|
||||
for (const c of allContainers) {
|
||||
const cName = c.name || '(unnamed)';
|
||||
const vms: any[] = Array.isArray(c.volumeMounts) ? c.volumeMounts : [];
|
||||
const vms: Record<string, any>[] = Array.isArray(c.volumeMounts) ? c.volumeMounts : [];
|
||||
for (const vm of vms) {
|
||||
allMounts.push({
|
||||
containerName: cName,
|
||||
@@ -168,7 +167,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
|
||||
const cMounts = mounts
|
||||
.filter(m => m.containerName === cName && m.volumeName && m.mountPath)
|
||||
.map(m => {
|
||||
const vm: any = { name: m.volumeName, mountPath: m.mountPath, readOnly: m.readOnly };
|
||||
const vm: Record<string, any> = { name: m.volumeName, mountPath: m.mountPath, readOnly: m.readOnly };
|
||||
if (m.subPath) vm.subPath = m.subPath;
|
||||
return vm;
|
||||
});
|
||||
@@ -177,7 +176,7 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
|
||||
}
|
||||
|
||||
// Rebuild containers arrays
|
||||
podSpec.containers = allContainers.filter((c: any) => !(podSpec.initContainers || []).includes(c));
|
||||
podSpec.containers = allContainers.filter(( c: any) => !(podSpec.initContainers || []).includes(c));
|
||||
podSpec.initContainers = (podSpec.initContainers || []);
|
||||
|
||||
const json = JSON.stringify(base, null, 2);
|
||||
|
||||
@@ -8,16 +8,28 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Clock, Cpu, MemoryStick, HardDrive, Network, Activity,
|
||||
RotateCcw, Container, History, ChevronDown, ChevronRight,
|
||||
Terminal, FileText, Server, ExternalLink, Minus, Plus,
|
||||
|
||||
Clock,
|
||||
HardDrive,
|
||||
Network,
|
||||
Activity,
|
||||
RotateCcw,
|
||||
Container,
|
||||
History,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
Minus,
|
||||
Plus,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
@@ -49,20 +61,20 @@ interface DeploymentViewProps {
|
||||
|
||||
// ─── Helpers ───
|
||||
function getPodTimeline(pod: PodItem) {
|
||||
const cond: any[] = (pod as any).conditions || (pod as any).pod_conditions || [];
|
||||
const find = (t: string) => cond.find((c: any) => (c.type || c.Type) === t);
|
||||
const ts = (c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time;
|
||||
const cond: Record<string, any>[] = (pod as Record<string, any>).conditions || (pod as Record<string, any>).pod_conditions || [];
|
||||
const find = (t: string) => cond.find(( c: any) => (c.type || c.Type) === t);
|
||||
const ts = ( c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time;
|
||||
return {
|
||||
scheduled: ts(find('PodScheduled') || {}),
|
||||
initialized: ts(find('Initialized') || {}),
|
||||
containersReady: ts(find('ContainersReady') || {}),
|
||||
ready: ts(find('Ready') || {}),
|
||||
startTime: pod.start_time || (pod as any).startTime,
|
||||
startTime: pod.start_time || (pod as Record<string, any>).startTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function DeploymentView(props: DeploymentViewProps) {
|
||||
const { clusterCode, namespace, deployment, pods, replicaSets, revisions, onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser } = props;
|
||||
const { clusterCode, namespace, deployment, pods, revisions, onRefresh } = props;
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState('pods');
|
||||
const [expandedPod, setExpandedPod] = useState<string | null>(null);
|
||||
@@ -94,39 +106,39 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []),
|
||||
]).then(([svcRes, ingRes]) => {
|
||||
const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const toArr = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const allSvcs = toArr(svcRes);
|
||||
const allIngs = toArr(ingRes);
|
||||
|
||||
// Filter services matching deployment selector
|
||||
const selector = deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {};
|
||||
const matchedSvcs = allSvcs.filter((s: any) => {
|
||||
const matchedSvcs = allSvcs.filter(( s: any) => {
|
||||
const svcSel = s.spec?.selector || s.selector || {};
|
||||
if (Object.keys(svcSel).length === 0) return false;
|
||||
return Object.entries(svcSel).every(([k, v]) => selector[k] === v);
|
||||
}).map((s: any) => ({
|
||||
}).map(( s: any) => ({
|
||||
name: s.name || s.metadata?.name,
|
||||
type: s.spec?.type || s.type || 'ClusterIP',
|
||||
clusterIP: s.spec?.clusterIP || s.cluster_ip || '<none>',
|
||||
ports: (s.spec?.ports || s.ports || []).map((p: any) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`),
|
||||
ports: (s.spec?.ports || s.ports || []).map(( p: any) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`),
|
||||
selector: s.spec?.selector || s.selector || {},
|
||||
age: s.age || '',
|
||||
}));
|
||||
|
||||
// Filter ingresses pointing to matched services
|
||||
const svcNames = new Set(matchedSvcs.map((s: any) => s.name));
|
||||
const matchedIngs = allIngs.filter((i: any) => {
|
||||
const svcNames = new Set(matchedSvcs.map(( s: any) => s.name));
|
||||
const matchedIngs = allIngs.filter(( i: any) => {
|
||||
const rules = i.spec?.rules || i.rules || [];
|
||||
return rules.some((r: any) =>
|
||||
(r.http?.paths || r.paths || []).some((p: any) => {
|
||||
return rules.some(( r: any) =>
|
||||
(r.http?.paths || r.paths || []).some(( p: any) => {
|
||||
const backendSvc = p.backend?.service?.name || p.backendService || '';
|
||||
return svcNames.has(backendSvc);
|
||||
})
|
||||
);
|
||||
}).map((i: any) => ({
|
||||
}).map(( i: any) => ({
|
||||
name: i.name || i.metadata?.name,
|
||||
className: i.spec?.ingressClassName || i.ingressClass || '-',
|
||||
hosts: (i.spec?.rules || i.rules || []).map((r: any) => r.host).filter(Boolean),
|
||||
hosts: (i.spec?.rules || i.rules || []).map(( r: any) => r.host).filter(Boolean),
|
||||
rules: i.spec?.rules || i.rules || [],
|
||||
age: i.age || '',
|
||||
}));
|
||||
@@ -140,13 +152,13 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
useEffect(() => {
|
||||
if (tab !== 'events') return;
|
||||
setTabLoading('events');
|
||||
kubernetesApi.getEvents(clusterCode, namespace, undefined).then((res: any) => {
|
||||
kubernetesApi.getEvents(clusterCode, namespace, undefined).then(( res: any) => {
|
||||
const allEvents = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []);
|
||||
const depEvents = allEvents.filter((e: any) => {
|
||||
const depEvents = allEvents.filter(( e: any) => {
|
||||
const involved = e.involvedObject || e.involved_object || {};
|
||||
const objName = involved.name || '';
|
||||
return objName === deployment?.name || objName.startsWith(deployment?.name + '-');
|
||||
}).map((e: any) => ({
|
||||
}).map(( e: any) => ({
|
||||
type: e.type || 'Normal',
|
||||
reason: e.reason || '',
|
||||
message: e.message || '',
|
||||
@@ -167,16 +179,16 @@ export function DeploymentView(props: DeploymentViewProps) {
|
||||
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []),
|
||||
Promise.resolve([]), // CronHPA 待后端支持
|
||||
]).then(([hpaRes]) => {
|
||||
const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const toArr = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const allHpas = toArr(hpaRes);
|
||||
const depName = deployment?.name || '';
|
||||
setHpas(allHpas.filter((h: any) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName));
|
||||
setHpas(allHpas.filter(( h: any) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName));
|
||||
setCronHpas([]);
|
||||
}).catch(() => {}).finally(() => setTabLoading(''));
|
||||
}, [tab, clusterCode, namespace, deployment?.name]);
|
||||
|
||||
// Rollback
|
||||
const handleRollback = async (rev: any) => {
|
||||
const handleRollback = async ( rev: any) => {
|
||||
const revision = rev.revision ?? rev.revision_number ?? rev.number;
|
||||
if (!confirm(`确认回滚到修订版本 #${revision}?`)) return;
|
||||
setRollbackLoading(revision);
|
||||
@@ -348,7 +360,7 @@ function PodConditionsRow({ pod }: { pod: PodItem }) {
|
||||
}
|
||||
|
||||
// ═══════════════ TAB: 容器组 ═══════════════
|
||||
function PodsTab({ clusterCode, namespace, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser, expandedPod, setExpandedPod }: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
|
||||
function PodsTab({ clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodFileBrowser, expandedPod, setExpandedPod }: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
|
||||
if (pods.length === 0) {
|
||||
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>;
|
||||
}
|
||||
@@ -394,9 +406,9 @@ function PodsTab({ clusterCode, namespace, pods, onShowPodLogs, onShowPodTermina
|
||||
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
|
||||
<PodConditionsRow pod={pod} />
|
||||
{/* Container details */}
|
||||
{(pod as any).containers && ((pod as any).containers as any[]).length > 0 && (
|
||||
{(pod as Record<string, any>).containers && ((pod as Record<string, any>).containers as any[]).length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{((pod as any).containers as any[]).map((c: any, idx: number) => (
|
||||
{((pod as Record<string, any>).containers as any[]).map(( c: any, idx: number) => (
|
||||
<div key={idx} className="p-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
|
||||
@@ -424,7 +436,7 @@ function PodsTab({ clusterCode, namespace, pods, onShowPodLogs, onShowPodTermina
|
||||
}
|
||||
|
||||
// ═══════════════ TAB: 访问方式 ═══════════════
|
||||
function ServicesTab({ services, ingresses, loading, namespace }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) {
|
||||
function ServicesTab({ services, ingresses, loading }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) {
|
||||
if (loading === 'services') {
|
||||
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
|
||||
}
|
||||
@@ -494,7 +506,7 @@ function ServicesTab({ services, ingresses, loading, namespace }: { services: Sv
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.className}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.hosts.join(', ')}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
||||
{ing.rules.flatMap((r: any) => (r.http?.paths || r.paths || []).map((p: any) => p.path || '/')).join(', ')}
|
||||
{ing.rules.flatMap(( r: any) => (r.http?.paths || r.paths || []).map(( p: any) => p.path || '/')).join(', ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -585,20 +597,20 @@ function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any[]; cro
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.map((hpa: any, i: number) => {
|
||||
{hpas.map(( hpa: any, i: number) => {
|
||||
const minR = hpa.spec?.minReplicas ?? hpa.minReplicas ?? 1;
|
||||
const maxR = hpa.spec?.maxReplicas ?? hpa.maxReplicas ?? 10;
|
||||
const metrics = hpa.spec?.metrics || hpa.metrics || [];
|
||||
const curR = hpa.status?.currentReplicas ?? hpa.currentReplicas ?? 0;
|
||||
const conditions = hpa.status?.conditions || hpa.conditions || [];
|
||||
const ableToScale = conditions.find((c: any) => c.type === 'AbleToScale');
|
||||
const ableToScale = conditions.find(( c: any) => c.type === 'AbleToScale');
|
||||
const ok = ableToScale?.status === 'True';
|
||||
return (
|
||||
<TableRow key={hpa.name || i} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{hpa.name || hpa.metadata?.name}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)] font-mono">{minR} → {maxR}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">
|
||||
{metrics.map((m: any, j: number) => {
|
||||
{metrics.map(( m: any, j: number) => {
|
||||
const resource = m.resource || m.object?.metric?.name || '';
|
||||
const target = m.resource?.target?.averageUtilization || m.resource?.target?.averageValue || m.resource?.target?.type || '-';
|
||||
return <div key={j}>{resource?.name || resource}: {target}{m.resource?.target?.averageUtilization ? '%' : ''}</div>;
|
||||
@@ -641,16 +653,16 @@ function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any[]; cro
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cronHpas.map((cron: any, i: number) => {
|
||||
{cronHpas.map(( cron: any, i: number) => {
|
||||
const jobs = cron.spec?.jobs || cron.jobs || [];
|
||||
const tz = cron.spec?.timezone || 'UTC';
|
||||
return (
|
||||
<TableRow key={cron.name || i} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{cron.name || cron.metadata?.name}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{
|
||||
jobs.map((j: any) => j.targetSize || j.targetReplicas || '?').join(', ')
|
||||
jobs.map(( j: any) => j.targetSize || j.targetReplicas || '?').join(', ')
|
||||
}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">{jobs.map((j: any) => j.schedule || '?').join(', ')}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">{jobs.map(( j: any) => j.schedule || '?').join(', ')}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{tz}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)]">已启用</Badge>
|
||||
@@ -669,7 +681,7 @@ function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any[]; cro
|
||||
}
|
||||
|
||||
// ═══════════════ TAB: 历史版本 ═══════════════
|
||||
function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: any[]; rollbackLoading: number | null; onRollback: (rev: any) => void }) {
|
||||
function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: any[]; rollbackLoading: number | null; onRollback: ( rev: any) => void }) {
|
||||
if (revisions.length === 0) {
|
||||
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无历史版本</div>;
|
||||
}
|
||||
|
||||
@@ -9,16 +9,31 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ArrowLeft, Pencil, FileCode, Image as ImageIcon, Minus, Plus, RotateCw,
|
||||
Trash2, FileText, Terminal, RefreshCw, Loader2, X,
|
||||
ChevronDown, Cpu, Network, GitBranch, Info,
|
||||
CheckCircle, AlertTriangle, XCircle,
|
||||
Pause, Play, Rocket, Container, Settings, Database, HardDrive,
|
||||
|
||||
ArrowLeft,
|
||||
Image as ImageIcon,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
X,
|
||||
Network,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Pause,
|
||||
Play,
|
||||
Rocket,
|
||||
Container,
|
||||
Settings,
|
||||
Database,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useUserStore } from '@/store';
|
||||
@@ -74,7 +89,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
const [fileBrowserPod, setFileBrowserPod] = useState<string | null>(null);
|
||||
const clusterId = deployment?.cluster_id || deployment?.clusterId || clusterCode;
|
||||
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -92,13 +107,13 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
|
||||
const allPods = toArray(podRes);
|
||||
// 过滤当前 Deployment 的 Pod(按名称前缀,兼容 ownerReference 不可用场景)
|
||||
const depPods = allPods.filter((p: any) =>
|
||||
const depPods = allPods.filter(( p: any) =>
|
||||
p.namespace === namespace && (p.name || '').startsWith(deploymentName)
|
||||
);
|
||||
setPods(depPods);
|
||||
|
||||
const allRs = toArray(rsRes);
|
||||
const depRs = allRs.filter((rs: any) =>
|
||||
const depRs = allRs.filter(( rs: any) =>
|
||||
(rs.namespace === namespace) &&
|
||||
((rs.owner_deployment === deploymentName) || (rs.name || '').startsWith(deploymentName))
|
||||
);
|
||||
@@ -118,11 +133,11 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
const yamlText = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName);
|
||||
// 简易 YAML→JSON:交给浏览器原生 JSON.parse 兜底(K8s API 多以 JSON 返回);
|
||||
// 若是纯 YAML,则尝试用 js-yaml(如项目已安装),否则保留为 null 让编辑模式降级。
|
||||
let parsed: any = null;
|
||||
let parsed: Record<string, any> = null;
|
||||
try { parsed = JSON.parse(yamlText); } catch {
|
||||
// 非纯 JSON,尝试动态加载 js-yaml
|
||||
try {
|
||||
const jsyaml: any = await import('js-yaml');
|
||||
const jsyaml: Record<string, any> = await import('js-yaml');
|
||||
parsed = jsyaml.load(yamlText);
|
||||
} catch {
|
||||
parsed = null;
|
||||
@@ -139,7 +154,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
useEffect(() => { if (mode === 'edit' && !manifest) loadManifest(); }, [mode, manifest, loadManifest]);
|
||||
|
||||
// ============ 操作 ============
|
||||
const handleScale = async (delta: number) => {
|
||||
const _handleScale = async (delta: number) => {
|
||||
const newReplicas = (deployment?.replicas || 0) + delta;
|
||||
if (newReplicas < 0) return;
|
||||
setActionLoading('scale');
|
||||
@@ -196,7 +211,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
|
||||
const handleCICD = () => {
|
||||
// 打开 Webhook 弹窗(不跳转流水线)
|
||||
setModal({ kind: 'cicd' as any });
|
||||
setModal({ kind: 'cicd' });
|
||||
};
|
||||
|
||||
const handleUpdateImage = async () => {
|
||||
@@ -230,12 +245,12 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
};
|
||||
|
||||
// 日志流(Pod 实时日志)
|
||||
const handleShowPodLogs = (pod: any) => setModal({ kind: 'podLogs', pod: pod.name });
|
||||
const handleShowPodYaml = (pod: any) => setModal({ kind: 'podYaml', pod: pod.name });
|
||||
const handleShowPodTerminal = (pod: any) => {
|
||||
const handleShowPodLogs = ( pod: any) => setModal({ kind: 'podLogs', pod: pod.name });
|
||||
const handleShowPodYaml = ( pod: any) => setModal({ kind: 'podYaml', pod: pod.name });
|
||||
const handleShowPodTerminal = ( pod: any) => {
|
||||
setTerminalPod(pod.name);
|
||||
};
|
||||
const handleShowPodFileBrowser = (pod: any) => {
|
||||
const handleShowPodFileBrowser = ( pod: any) => {
|
||||
setFileBrowserPod(pod.name);
|
||||
};
|
||||
|
||||
@@ -266,7 +281,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
|
||||
const total = deployment.replicas || 0;
|
||||
const isHealthy = ready === total && total > 0;
|
||||
const viewBase = `/kubernetes/${clusterCode}/namespace/${namespace}/workload`;
|
||||
const _viewBase = `/kubernetes/${clusterCode}/namespace/${namespace}/workload`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -499,7 +514,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={modal.pod}
|
||||
containers={(pods.find(p => p.name === modal.pod)?.containers || []).map((c: any) => c.name).filter(Boolean)}
|
||||
containers={(pods.find(p => p.name === modal.pod)?.containers || []).map(( c: any) => c.name).filter(Boolean)}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -511,7 +526,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
podName={terminalPod || ''}
|
||||
containers={(pods.find((p: any) => p.name === terminalPod)?.containers || []).map((c: any) => c.name).filter(Boolean)}
|
||||
containers={(pods.find(( p: any) => p.name === terminalPod)?.containers || []).map(( c: any) => c.name).filter(Boolean)}
|
||||
/>
|
||||
{/* ============ Pod 文件浏览器 ============ */}
|
||||
<PodFileBrowser
|
||||
@@ -520,7 +535,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={fileBrowserPod || ''}
|
||||
containers={(pods.find((p: any) => p.name === fileBrowserPod)?.containers || []).map((c: any) => c.name).filter(Boolean)}
|
||||
containers={(pods.find(( p: any) => p.name === fileBrowserPod)?.containers || []).map(( c: any) => c.name).filter(Boolean)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -636,7 +651,7 @@ function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, o
|
||||
// ============ CI/CD Webhook 内容 ============
|
||||
function CICDWebhookContent({ clusterCode, namespace, deploymentName }: { clusterCode: string; namespace: string; deploymentName: string }) {
|
||||
const { toast } = useToast();
|
||||
const token = useUserStore((s: any) => s.token) || localStorage.getItem('token') || '';
|
||||
const token = useUserStore(( s: any) => s.token) || localStorage.getItem('token') || '';
|
||||
const webhookUrl = `${window.location.origin}/api/v1/clusters/${clusterCode}/namespaces/${namespace}/deployments/${deploymentName}/update-image`;
|
||||
const curlCmd = `curl -X POST '${webhookUrl}' \\
|
||||
-H 'Authorization: Bearer ${token ? token.substring(0, 16) + '...' : 'YOUR_TOKEN'}' \\
|
||||
@@ -701,7 +716,7 @@ function PodLogsModal({ clusterCode, namespace, podName, containers, onClose }:
|
||||
const loadLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined);
|
||||
const res: Record<string, any> = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined);
|
||||
setLogs(res?.data?.logs || res?.logs || res || '无日志');
|
||||
} catch {
|
||||
setLogs('获取日志失败');
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function EventsPage() {
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
kubernetesApi.getClusters().then((res: any) => {
|
||||
kubernetesApi.getClusters().then(( res: any) => {
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (!activeCluster && list.length > 0) {
|
||||
@@ -53,7 +53,7 @@ export default function EventsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCluster) return;
|
||||
kubernetesApi.getNamespaces(activeCluster).then((res: any) => {
|
||||
kubernetesApi.getNamespaces(activeCluster).then(( res: any) => {
|
||||
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
|
||||
}).catch(() => {});
|
||||
}, [activeCluster]);
|
||||
@@ -64,7 +64,7 @@ export default function EventsPage() {
|
||||
try {
|
||||
const ns = selectedNs === 'all' ? undefined : selectedNs;
|
||||
const ft = filterType === 'all' ? undefined : filterType;
|
||||
const res: any = await kubernetesApi.getEvents(activeCluster, ns, ft);
|
||||
const res: Record<string, any> = await kubernetesApi.getEvents(activeCluster, ns, ft);
|
||||
setEvents(Array.isArray(res) ? res : (res?.list || []));
|
||||
} catch { setEvents([]); } finally { setLoading(false); }
|
||||
}, [activeCluster, selectedNs, filterType]);
|
||||
@@ -74,7 +74,7 @@ export default function EventsPage() {
|
||||
try {
|
||||
const ns = selectedNs === 'all' ? undefined : selectedNs;
|
||||
const hours = parseInt(timeWindow) || undefined;
|
||||
const res: any = await kubernetesApi.getEventsSummary(activeCluster, ns, hours);
|
||||
const res: Record<string, any> = await kubernetesApi.getEventsSummary(activeCluster, ns, hours);
|
||||
setSummary(res || null);
|
||||
} catch { setSummary(null); }
|
||||
}, [activeCluster, selectedNs, timeWindow]);
|
||||
@@ -100,17 +100,17 @@ export default function EventsPage() {
|
||||
// Chart data from summary
|
||||
const reasonData = useMemo(() => {
|
||||
const list = summary?.by_reason || [];
|
||||
return list.map((r: any) => ({ name: r.reason || 'Unknown', count: r.count }));
|
||||
return list.map(( r: any) => ({ name: r.reason || 'Unknown', count: r.count }));
|
||||
}, [summary]);
|
||||
|
||||
const nsData = useMemo(() => {
|
||||
const list = summary?.by_namespace || [];
|
||||
return list.map((n: any) => ({ name: n.namespace || '<cluster>', count: n.count }));
|
||||
return list.map(( n: any) => ({ name: n.namespace || '<cluster>', count: n.count }));
|
||||
}, [summary]);
|
||||
|
||||
const kindData = useMemo(() => {
|
||||
const list = summary?.by_kind || [];
|
||||
return list.map((k: any) => ({ name: k.kind || 'Unknown', count: k.count }));
|
||||
return list.map(( k: any) => ({ name: k.kind || 'Unknown', count: k.count }));
|
||||
}, [summary]);
|
||||
|
||||
const formatTime = (ts: string) => {
|
||||
@@ -148,7 +148,7 @@ export default function EventsPage() {
|
||||
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}>
|
||||
<SelectTrigger className="w-40 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{clusters.map((c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
|
||||
{clusters.map(( c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedNs} onValueChange={v => { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}>
|
||||
@@ -259,8 +259,8 @@ export default function EventsPage() {
|
||||
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={(d: any) => setSelectedReason(prev => prev === d?.name ? '' : d?.name)}>
|
||||
{reasonData.map((_: any, i: number) => (
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={( d: any) => setSelectedReason(prev => prev === d?.name ? '' : d?.name)}>
|
||||
{reasonData.map(( _: any, i: number) => (
|
||||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
@@ -309,8 +309,8 @@ export default function EventsPage() {
|
||||
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={(d: any) => setSelectedKind(prev => prev === d?.name ? '' : d?.name)}>
|
||||
{kindData.map((_: any, i: number) => (
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={( d: any) => setSelectedKind(prev => prev === d?.name ? '' : d?.name)}>
|
||||
{kindData.map(( _: any, i: number) => (
|
||||
<Cell key={i} fill={['#8B5CF6', '#6366F1', '#EC4899', '#14B8A6'][i % 4]} fillOpacity={0.7} />
|
||||
))}
|
||||
</Bar>
|
||||
@@ -331,7 +331,7 @@ export default function EventsPage() {
|
||||
<AlertTriangle className="h-4 w-4" />最新告警事件 ({summary.recent_warnings.length})
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{summary.recent_warnings.map((w: any, i: number) => (
|
||||
{summary.recent_warnings.map(( w: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs py-1.5 px-2 rounded bg-slate-900/40 hover:bg-slate-800/40 transition-colors">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-red-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
|
||||
@@ -60,7 +60,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on
|
||||
kubernetesApi.getServices(clusterId).catch(() => []),
|
||||
kubernetesApi.getIngresses(clusterId).catch(() => []),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
setNodes(toArray(nodesRes));
|
||||
setPods(toArray(podsRes));
|
||||
setDeployments(toArray(depRes));
|
||||
|
||||
@@ -48,7 +48,7 @@ export function KubernetesClusterDialog({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editingCluster && editingCluster.id) {
|
||||
kubernetesApi.getClusterDetail(editingCluster.id).then((res: any) => {
|
||||
kubernetesApi.getClusterDetail(editingCluster.id).then(( res: any) => {
|
||||
const d = res || {};
|
||||
setHasToken(!!d.has_token);
|
||||
setHasKubeConfig(!!d.has_kube_config);
|
||||
|
||||
@@ -57,7 +57,7 @@ export function KubernetesClusters() {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await kubernetesApi.getClusters();
|
||||
const res: Record<string, any> = await kubernetesApi.getClusters();
|
||||
setClusters(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载集群列表失败:', e);
|
||||
@@ -72,14 +72,14 @@ export function KubernetesClusters() {
|
||||
}, []);
|
||||
|
||||
// 新建后自动同步
|
||||
const [pendingSyncId, setPendingSyncId] = useState<number | null>(null);
|
||||
const [, _setPendingSyncId] = useState<number | null>(null);
|
||||
|
||||
const handleSaved = () => {
|
||||
loadData().then(() => {
|
||||
if (!editingCluster) {
|
||||
// 新建集群后,等列表刷新完再取最新一条同步
|
||||
setTimeout(async () => {
|
||||
const res: any = await kubernetesApi.getClusters();
|
||||
const res: Record<string, any> = await kubernetesApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
if (list.length > 0) {
|
||||
handleSync(list[list.length - 1].id, true);
|
||||
@@ -101,7 +101,7 @@ export function KubernetesClusters() {
|
||||
const handleSync = async (id: number | string, silent?: boolean) => {
|
||||
try {
|
||||
if (!silent) toast.info('正在同步集群资源...');
|
||||
const res: any = await kubernetesApi.syncCluster(id);
|
||||
const res: Record<string, any> = await kubernetesApi.syncCluster(id);
|
||||
if (!silent) toast.success(res?.message || '同步完成');
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function KubernetesNodes() {
|
||||
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await kubernetesApi.getClusters();
|
||||
const res: Record<string, any> = await kubernetesApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (list.length > 0 && !selectedCluster) setSelectedCluster(String(list[0].id));
|
||||
@@ -35,7 +35,7 @@ export function KubernetesNodes() {
|
||||
if (!selectedCluster) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await kubernetesApi.getNodes(selectedCluster);
|
||||
const res: Record<string, any> = await kubernetesApi.getNodes(selectedCluster);
|
||||
setNodes(Array.isArray(res) ? res : (res?.list || []));
|
||||
} catch { setNodes([]); } finally { setLoading(false); }
|
||||
}, [selectedCluster]);
|
||||
|
||||
@@ -11,9 +11,17 @@ import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
RefreshCw, Search, Boxes, Loader2, Container,
|
||||
Plus, Minus, RotateCw, Upload, FileText, Eye,
|
||||
|
||||
RefreshCw,
|
||||
Search,
|
||||
Boxes,
|
||||
Loader2,
|
||||
Container,
|
||||
Plus,
|
||||
Minus,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { DeploymentDetail } from './DeploymentDetail';
|
||||
@@ -29,14 +37,14 @@ export function KubernetesWorkloads() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('deployments');
|
||||
const [actionLoading, setActionLoading] = useState<Record<string, string>>({});
|
||||
const [, setActionLoading] = useState<Record<string, string>>({});
|
||||
|
||||
// Detail view
|
||||
const [selectedDeployment, setSelectedDeployment] = useState<{ name: string; namespace: string } | null>(null);
|
||||
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await kubernetesApi.getClusters();
|
||||
const res: Record<string, any> = await kubernetesApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (list.length > 0 && !selectedCluster) setSelectedCluster(list[0].code || String(list[0].id));
|
||||
@@ -49,7 +57,7 @@ export function KubernetesWorkloads() {
|
||||
const loadNamespaces = useCallback(async () => {
|
||||
if (!selectedCluster) return;
|
||||
try {
|
||||
const res: any = await kubernetesApi.getNamespaces(selectedCluster);
|
||||
const res: Record<string, any> = await kubernetesApi.getNamespaces(selectedCluster);
|
||||
const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []);
|
||||
setNamespaces(nsList);
|
||||
} catch { setNamespaces([]); }
|
||||
@@ -67,7 +75,7 @@ export function KubernetesWorkloads() {
|
||||
kubernetesApi.getNamespaceDeployments(clusterCode, selectedNamespace).catch(() => []),
|
||||
kubernetesApi.getNamespacePods(clusterCode, selectedNamespace).catch(() => []),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
setDeployments(toArray(depRes));
|
||||
setPods(toArray(podRes));
|
||||
} else {
|
||||
@@ -75,7 +83,7 @@ export function KubernetesWorkloads() {
|
||||
kubernetesApi.getAllDeployments(clusterCode).catch(() => []),
|
||||
kubernetesApi.getAllPods(clusterCode).catch(() => []),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
setDeployments(toArray(depRes));
|
||||
setPods(toArray(podRes));
|
||||
}
|
||||
@@ -103,7 +111,7 @@ export function KubernetesWorkloads() {
|
||||
return <Badge className={map[status] || 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{status || 'Unknown'}</Badge>;
|
||||
};
|
||||
|
||||
const handleQuickScale = async (dep: any, delta: number) => {
|
||||
const handleQuickScale = async ( dep: any, delta: number) => {
|
||||
const key = `scale-${dep.name}`;
|
||||
setActionLoading((prev) => ({ ...prev, [key]: 'loading' }));
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
RefreshCw, Loader2, ChevronRight, Cloud,
|
||||
Boxes, Container, Network, FileText, Shield, Activity, Clock,
|
||||
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
Boxes,
|
||||
Container,
|
||||
Network,
|
||||
FileText,
|
||||
Shield,
|
||||
Activity,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
|
||||
const RESOURCE_LINKS = [
|
||||
@@ -37,7 +45,7 @@ export default function NamespaceOverviewPage() {
|
||||
kubernetesApi.getHPAs(clusterCode, namespace!),
|
||||
kubernetesApi.getCronJobs(clusterCode, namespace!),
|
||||
]);
|
||||
const toArr = (v: any) => Array.isArray(v) ? v : (v?.list || v?.data || []);
|
||||
const toArr = ( v: any) => Array.isArray(v) ? v : (v?.list || v?.data || []);
|
||||
setCounts({
|
||||
deployments: toArr(results[0].status === 'fulfilled' ? results[0].value : []).length,
|
||||
pods: toArr(results[1].status === 'fulfilled' ? results[1].value : []).length,
|
||||
|
||||
@@ -33,7 +33,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
subtitle: string;
|
||||
icon: React.ComponentType<any>;
|
||||
columns: string[];
|
||||
renderRow: (item: any, opts: { clusterCode: string; namespace: string; onDetail?: (item: any) => void; onYaml?: (name: string) => void; onEdit?: (name: string) => void; onTerminal?: (item: any) => void; onFileBrowser?: (item: any) => void }) => React.ReactNode;
|
||||
renderRow: ( item: any, opts: { clusterCode: string; namespace: string; onDetail?: ( item: any) => void; onYaml?: (name: string) => void; onEdit?: (name: string) => void; onTerminal?: ( item: any) => void; onFileBrowser?: ( item: any) => void }) => React.ReactNode;
|
||||
}> = {
|
||||
deployments: {
|
||||
title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes,
|
||||
@@ -84,9 +84,9 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10" title="终端"
|
||||
onClick={() => (opts as any).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5 mr-1" />终端</Button>
|
||||
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5 mr-1" />终端</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10" title="文件浏览器"
|
||||
onClick={() => (opts as any).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器</Button>
|
||||
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看YAML"
|
||||
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
|
||||
</div>
|
||||
@@ -176,7 +176,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
hpas: {
|
||||
title: 'HPAs', subtitle: '水平自动伸缩', icon: Activity,
|
||||
columns: ['名称', '命名空间', '目标', '最小/最大', 'CPU%', 'Mem%'],
|
||||
renderRow: (h, opts) => (
|
||||
renderRow: (h, _opts) => (
|
||||
<TableRow key={h.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{h.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{h.namespace}</Badge></TableCell>
|
||||
@@ -334,7 +334,7 @@ export default function NamespaceResourcePage() {
|
||||
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { toast: _toast } = useToast();
|
||||
const type = useMemo<ResourceType>(() => {
|
||||
const segments = location.pathname.split('/');
|
||||
const last = segments[segments.length - 1];
|
||||
@@ -358,7 +358,7 @@ export default function NamespaceResourcePage() {
|
||||
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
kubernetesApi.getClusters().then((res: any) => {
|
||||
kubernetesApi.getClusters().then(( res: any) => {
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (!clusterCode && list.length > 0) {
|
||||
@@ -370,7 +370,7 @@ export default function NamespaceResourcePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!clusterCode) return;
|
||||
kubernetesApi.getNamespaces(clusterCode).then((res: any) => {
|
||||
kubernetesApi.getNamespaces(clusterCode).then(( res: any) => {
|
||||
const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []);
|
||||
setNamespaces(nsList);
|
||||
if (!namespace && nsList.length > 0) {
|
||||
@@ -383,7 +383,7 @@ export default function NamespaceResourcePage() {
|
||||
if (!clusterCode || !namespace) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
let res: any = [];
|
||||
let res: Record<string, any> = [];
|
||||
switch (type) {
|
||||
case 'deployments': res = await kubernetesApi.getNamespaceDeployments(clusterCode, namespace).catch(() => []); break;
|
||||
case 'pods': res = await kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []); break;
|
||||
@@ -406,9 +406,9 @@ export default function NamespaceResourcePage() {
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const filtered = data.filter((d: any) => (d.name || '').toLowerCase().includes(search.toLowerCase()));
|
||||
const filtered = data.filter(( d: any) => (d.name || '').toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
const handleDetail = (item: any) => {
|
||||
const handleDetail = ( item: any) => {
|
||||
if (type === 'deployments') {
|
||||
// Kuboard 风格双模式路由(默认 view 模式)
|
||||
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${item.name}`);
|
||||
@@ -527,7 +527,7 @@ export default function NamespaceResourcePage() {
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 {config.title}</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: (p: any) => setTerminalPod(p), onFileBrowser: (p: any) => setFileBrowserPod(p) }))
|
||||
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: ( p: any) => setTerminalPod(p), onFileBrowser: ( p: any) => setFileBrowserPod(p) }))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -541,7 +541,7 @@ export default function NamespaceResourcePage() {
|
||||
clusterId={clusters[0]?.id || clusters[0]?.code || clusterCode || ''}
|
||||
namespace={namespace || ''}
|
||||
podName={terminalPod?.name || ''}
|
||||
containers={(terminalPod?.containers || []).map((c: any) => c.name).filter(Boolean)}
|
||||
containers={(terminalPod?.containers || []).map(( c: any) => c.name).filter(Boolean)}
|
||||
/>
|
||||
{/* ============ Pod 文件浏览器 ============ */}
|
||||
<PodFileBrowser
|
||||
@@ -550,7 +550,7 @@ export default function NamespaceResourcePage() {
|
||||
clusterCode={clusterCode || ''}
|
||||
namespace={namespace || ''}
|
||||
podName={fileBrowserPod?.name || ''}
|
||||
containers={(fileBrowserPod?.containers || []).map((c: any) => c.name).filter(Boolean)}
|
||||
containers={(fileBrowserPod?.containers || []).map(( c: any) => c.name).filter(Boolean)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,14 +7,24 @@ import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
RefreshCw, Loader2, Cpu, Server, Clock, HardDrive, Activity,
|
||||
ChevronRight, Container, Network, Info, AlertTriangle, CheckCircle2,
|
||||
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Server,
|
||||
Activity,
|
||||
ChevronRight,
|
||||
Container,
|
||||
Network,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
|
||||
export default function NodeDetailPage() {
|
||||
const { clusterCode, nodeName } = useParams<{ clusterCode: string; nodeName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const _navigate = useNavigate();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -22,14 +32,14 @@ export default function NodeDetailPage() {
|
||||
if (!clusterCode || !nodeName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await kubernetesApi.getNodeDetail(clusterCode, nodeName);
|
||||
const res: Record<string, unknown> = await kubernetesApi.getNodeDetail(clusterCode, nodeName);
|
||||
setData(res);
|
||||
} catch { setData(null); } finally { setLoading(false); }
|
||||
}, [clusterCode, nodeName]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const formatNum = (v: any, suffix = '') => {
|
||||
const _formatNum = ( v: any, suffix = '') => {
|
||||
if (v === undefined || v === null || v === '') return '-';
|
||||
const n = typeof v === 'number' ? v : parseFloat(v);
|
||||
return isNaN(n) ? '-' : `${n.toLocaleString()}${suffix}`;
|
||||
@@ -258,7 +268,7 @@ export default function NodeDetailPage() {
|
||||
<p className="text-xs text-slate-500 py-4 text-center">无条件数据</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.conditions.map((cond: any, i: number) => (
|
||||
{data.conditions.map(( cond: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs py-1.5 border-b border-slate-800/30 last:border-0">
|
||||
<Badge className={conditionColor(cond.type, cond.status)}>
|
||||
{cond.status === 'True' ? <CheckCircle2 className="h-3 w-3 mr-1" /> : null}
|
||||
@@ -285,7 +295,7 @@ export default function NodeDetailPage() {
|
||||
<p className="text-xs text-slate-500 py-4 text-center">无污点</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.taints.map((t: any, i: number) => (
|
||||
{data.taints.map(( t: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<Badge className={taintColor(t.effect)}>{t.effect}</Badge>
|
||||
<span className="text-slate-300 font-mono">{t.key}</span>
|
||||
@@ -306,7 +316,7 @@ export default function NodeDetailPage() {
|
||||
<Network className="h-4 w-4 text-slate-400" />标签 ({data.labels.length})
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.labels.map((l: any, i: number) => (
|
||||
{data.labels.map(( l: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-1 bg-slate-800/50 border border-slate-700/50 rounded px-2 py-0.5 text-[11px]">
|
||||
<span className="text-slate-400">{l.key}</span>
|
||||
<span className="text-slate-600">:</span>
|
||||
@@ -344,7 +354,7 @@ export default function NodeDetailPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.pods.map((p: any, i: number) => (
|
||||
{data.pods.map(( p: any, i: number) => (
|
||||
<TableRow key={i} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="text-sm text-slate-400">{p.namespace}</TableCell>
|
||||
<TableCell className="text-sm font-medium text-slate-200">{p.name}</TableCell>
|
||||
|
||||
@@ -78,7 +78,6 @@ export function PodExecTerminal({
|
||||
|
||||
// ─── 连接 WebSocket ───
|
||||
const connect = useCallback(() => {
|
||||
console.log('[PodExec:connect] START clusterId=', clusterId, 'namespace=', namespace, 'podName=', podName, 'container=', container, 'shell=', shell);
|
||||
|
||||
if (!termRef.current) {
|
||||
console.error('[PodExec:connect] ABORT: termRef.current is null');
|
||||
@@ -87,7 +86,6 @@ export function PodExecTerminal({
|
||||
const term = termRef.current;
|
||||
|
||||
if (wsRef.current) {
|
||||
console.log('[PodExec:connect] closing previous WS');
|
||||
try { wsRef.current.close(); } catch { /* noop */ }
|
||||
wsRef.current = null;
|
||||
}
|
||||
@@ -97,18 +95,15 @@ export function PodExecTerminal({
|
||||
term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} shell=${shell} ...]\x1b[0m`);
|
||||
|
||||
const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined, shell);
|
||||
console.log('[PodExec:connect] WS URL:', wsUrl, '\n location.protocol=', window.location.protocol, '\n location.host=', window.location.host, '\n VITE_API_BASE_URL=', import.meta.env.VITE_API_BASE_URL);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[PodExec:connect] WS onopen — connected, readyState=', ws.readyState);
|
||||
setConnState('connected');
|
||||
term.reset();
|
||||
try {
|
||||
const { cols, rows } = term;
|
||||
console.log('[PodExec:connect] sending resize:', cols, 'x', rows);
|
||||
ws.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
} catch (e) { console.error('[PodExec:connect] resize error:', e); }
|
||||
const tryFocus = () => { try { term.focus(); } catch { /* noop */ } };
|
||||
@@ -134,7 +129,7 @@ export function PodExecTerminal({
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
ws.onclose = (_ev) => {
|
||||
setConnState((s) => (s === 'error' ? s : 'closed'));
|
||||
if (!errorShownRef.current) {
|
||||
term.write('\r\n\x1b[33m[连接已关闭]\x1b[0m\r\n');
|
||||
@@ -149,12 +144,10 @@ export function PodExecTerminal({
|
||||
}, [clusterId, namespace, podName, container, shell]);
|
||||
|
||||
// DEBUG: 组件入口
|
||||
console.log('[PodExec:render] open=', open, 'clusterId=', clusterId, 'podName=', podName, 'namespace=', namespace, 'containers=', containers.length, 'container=', container, 'shell=', shell, 'connState=', connState);
|
||||
|
||||
// ─── 创建 xterm + 连接(用 rAF 重试等 Dialog 渲染完)───
|
||||
useEffect(() => {
|
||||
console.log('[PodExec:initEffect] TRIGGERED open=', open, 'clusterId=', clusterId, 'podName=', podName, 'namespace=', namespace);
|
||||
if (!open) { console.log('[PodExec:initEffect] ABORT: open=false'); return; }
|
||||
if (!open) { return; }
|
||||
if (!clusterId) { console.error('[PodExec:initEffect] ABORT: clusterId is falsy:', clusterId); return; }
|
||||
if (!podName) { console.error('[PodExec:initEffect] ABORT: podName is falsy:', podName); return; }
|
||||
|
||||
@@ -163,9 +156,8 @@ export function PodExecTerminal({
|
||||
const startTime = performance.now();
|
||||
|
||||
const tryInit = () => {
|
||||
if (disposed) { console.log('[PodExec:tryInit] disposed, aborting'); return; }
|
||||
if (disposed) { return; }
|
||||
const el = containerRef.current;
|
||||
console.log('[PodExec:tryInit] retry=', retryCount, 'el=', el ? `DIV ${el.offsetWidth}x${el.offsetHeight}` : 'NULL', 'el.parentElement=', el?.parentElement ? 'YES' : 'NO');
|
||||
if (!el) {
|
||||
retryCount++;
|
||||
if (retryCount < 20) {
|
||||
@@ -176,7 +168,6 @@ export function PodExecTerminal({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PodExec:tryInit] SUCCESS: container ready after', retryCount, 'retries, size:', el.offsetWidth, 'x', el.offsetHeight, 'elapsed:', Math.round(performance.now()-startTime), 'ms');
|
||||
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
@@ -202,7 +193,6 @@ export function PodExecTerminal({
|
||||
|
||||
term.onData((data: string) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
console.debug('[PodExec:term] TX stdin, len=', data.length, 'bytes=', [...data].map(c => c.charCodeAt(0)).slice(0, 10));
|
||||
wsRef.current.send(JSON.stringify({ status: 0, data }));
|
||||
} else {
|
||||
console.warn('[PodExec:term] TX dropped — WS not open, readyState=', wsRef.current?.readyState);
|
||||
@@ -210,7 +200,6 @@ export function PodExecTerminal({
|
||||
});
|
||||
|
||||
term.open(el);
|
||||
console.log('[PodExec:tryInit] xterm.open() done, rows=', term.rows, 'cols=', term.cols);
|
||||
requestAnimationFrame(() => { try { fit.fit(); } catch { /* noop */ } });
|
||||
|
||||
mountedRef.current = true;
|
||||
@@ -228,7 +217,6 @@ export function PodExecTerminal({
|
||||
// ─── cleanup on close ───
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
console.log('[PodExec:cleanup] dialog closed, cleaning up WS + xterm');
|
||||
try { wsRef.current?.close(); } catch { /* noop */ }
|
||||
try { termRef.current?.dispose(); } catch { /* noop */ }
|
||||
termRef.current = null;
|
||||
@@ -242,10 +230,8 @@ export function PodExecTerminal({
|
||||
// ─── 容器/shell 变化时重连 ───
|
||||
useEffect(() => {
|
||||
if (open && termRef.current && mountedRef.current) {
|
||||
console.log('[PodExec:reconnect] container=', container, 'shell=', shell, 'mounted=', mountedRef.current);
|
||||
connect();
|
||||
} else {
|
||||
console.log('[PodExec:reconnect] SKIP: open=', open, 'hasTerm=', !!termRef.current, 'mounted=', mountedRef.current);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [container, shell]);
|
||||
|
||||
@@ -4,12 +4,21 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Loader2, X, Folder, File, ChevronRight, ChevronDown,
|
||||
Download, Upload, RefreshCw, ArrowLeft, HardDrive,
|
||||
|
||||
Loader2,
|
||||
X,
|
||||
Folder,
|
||||
File,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Upload,
|
||||
RefreshCw,
|
||||
ArrowLeft,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
|
||||
|
||||
export interface PodFileBrowserProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -45,10 +54,10 @@ export function PodFileBrowser({
|
||||
setError('');
|
||||
setPreview(null);
|
||||
try {
|
||||
const res: any = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, path, container || undefined);
|
||||
const res: Record<string, any> = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, path, container || undefined);
|
||||
// 兼容后端 {files: [...]} 和 {entries: [...]} 两种返回格式
|
||||
const raw: any[] = res?.entries || res?.files || res?.data?.entries || res?.data?.files || (Array.isArray(res) ? res : []);
|
||||
const list: FileEntry[] = raw.map((it: any) => ({
|
||||
const raw: Record<string, any>[] = res?.entries || res?.files || res?.data?.entries || res?.data?.files || (Array.isArray(res) ? res : []);
|
||||
const list: FileEntry[] = raw.map(( it: any) => ({
|
||||
name: it.name ?? it.Name ?? '',
|
||||
isDir: Boolean(it.isDir ?? it.is_dir ?? it.IsDir ?? it.dir ?? it.directory),
|
||||
size: typeof it.size === 'number' ? it.size : (it.Size ? Number(it.Size) : undefined),
|
||||
@@ -57,7 +66,7 @@ export function PodFileBrowser({
|
||||
setEntries(list);
|
||||
setCurrentPath(path);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载文件列表失败');
|
||||
setError((e as Record<string, any>).message || '加载文件列表失败');
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -74,7 +83,7 @@ export function PodFileBrowser({
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await kubernetesApi.execPod(clusterCode, namespace, podName, ['pwd'], container || undefined);
|
||||
const res: Record<string, any> = await kubernetesApi.execPod(clusterCode, namespace, podName, ['pwd'], container || undefined);
|
||||
const stdout = (res?.stdout || res?.data?.stdout || '').trim();
|
||||
const workdir = stdout && stdout.startsWith('/') ? stdout : '/';
|
||||
workdirDetected.current = true;
|
||||
@@ -107,7 +116,7 @@ export function PodFileBrowser({
|
||||
const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, filePath, container || undefined);
|
||||
setPreview({ file: fileName, content: text });
|
||||
} catch (e: any) {
|
||||
setPreview({ file: fileName, content: `读取失败: ${e.message}` });
|
||||
setPreview({ file: fileName, content: `读取失败: ${(e as Record<string, any>).message}` });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,7 +134,7 @@ export function PodFileBrowser({
|
||||
await kubernetesApi.uploadPodFile(clusterCode, namespace, podName, currentPath, file, container || undefined);
|
||||
loadFiles(currentPath);
|
||||
} catch (err: any) {
|
||||
setError(`上传失败: ${err.message}`);
|
||||
setError(`上传失败: ${(err as Record<string, any>).message}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
|
||||
@@ -153,7 +153,7 @@ export function PodLogsViewer({
|
||||
linesRef.current = [];
|
||||
setLines([]);
|
||||
try {
|
||||
const res: any = await kubernetesApi.getPodLogsEnhanced(clusterCode, namespace, podName, {
|
||||
const res: Record<string, any> = await kubernetesApi.getPodLogsEnhanced(clusterCode, namespace, podName, {
|
||||
container: container || undefined,
|
||||
tailLines: tailLines,
|
||||
previous,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
@@ -24,7 +23,7 @@ export default function PodMetricsPage() {
|
||||
const [activeCluster, setActiveCluster] = useState(clusterCode || '');
|
||||
|
||||
useEffect(() => {
|
||||
kubernetesApi.getClusters().then((res: any) => {
|
||||
kubernetesApi.getClusters().then(( res: any) => {
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (!activeCluster && list.length > 0) {
|
||||
@@ -37,7 +36,7 @@ export default function PodMetricsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCluster) return;
|
||||
kubernetesApi.getNamespaces(activeCluster).then((res: any) => {
|
||||
kubernetesApi.getNamespaces(activeCluster).then(( res: any) => {
|
||||
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
|
||||
}).catch(() => {});
|
||||
}, [activeCluster]);
|
||||
@@ -47,7 +46,7 @@ export default function PodMetricsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ns = selectedNs === 'all' ? undefined : selectedNs;
|
||||
const res: any = await kubernetesApi.getPodMetrics(activeCluster, ns);
|
||||
const res: Record<string, any> = await kubernetesApi.getPodMetrics(activeCluster, ns);
|
||||
setMetrics(Array.isArray(res) ? res : (res?.list || []));
|
||||
} catch { setMetrics([]); } finally { setLoading(false); }
|
||||
}, [activeCluster, selectedNs]);
|
||||
@@ -56,8 +55,8 @@ export default function PodMetricsPage() {
|
||||
|
||||
// Flatten for table
|
||||
const rows = useMemo(() =>
|
||||
metrics.flatMap((pm: any) =>
|
||||
(pm.containers || []).map((c: any) => ({
|
||||
metrics.flatMap(( pm: any) =>
|
||||
(pm.containers || []).map(( c: any) => ({
|
||||
pod: pm.name,
|
||||
namespace: pm.namespace,
|
||||
container: c.name,
|
||||
@@ -92,7 +91,7 @@ export default function PodMetricsPage() {
|
||||
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/metrics/pods`); }}>
|
||||
<SelectTrigger className="w-44 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{clusters.map((c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
|
||||
{clusters.map(( c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedNs} onValueChange={setSelectedNs}>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 替代纯 YAML 编辑,提供表单式交互体验
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Save, RefreshCw, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight } from 'lucide-react';
|
||||
import { X, Save, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -61,13 +61,13 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
||||
toast({ title: '可视化编辑不可用', description: '无法解析资源结构,已切换到 YAML 编辑模式' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: '加载失败', description: e.message, variant: 'destructive' });
|
||||
toast({ title: '加载失败', description: (e as Record<string, any>).message, variant: 'destructive' });
|
||||
} finally { setLoading(false); }
|
||||
}, [clusterCode, namespace, resource, name]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleSave = async (updatedManifest: any) => {
|
||||
const handleSave = async ( updatedManifest: any) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Serialize manifest back to YAML
|
||||
@@ -77,7 +77,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
toast({ title: '保存失败', description: e.message, variant: 'destructive' });
|
||||
toast({ title: '保存失败', description: (e as Record<string, any>).message, variant: 'destructive' });
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
@@ -89,12 +89,12 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
toast({ title: '保存失败', description: e.message, variant: 'destructive' });
|
||||
toast({ title: '保存失败', description: (e as Record<string, any>).message, variant: 'destructive' });
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// Manifest → YAML string (JSON serialization works as YAML superset for K8s API)
|
||||
function manifestToYaml(m: any): string {
|
||||
function manifestToYaml( m: any): string {
|
||||
// Use 2-space indented JSON — K8s API accepts JSON as YAML
|
||||
return JSON.stringify(m, null, 2);
|
||||
}
|
||||
@@ -192,7 +192,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
||||
// ══════════════════════════════════════════════
|
||||
// ConfigMap 编辑器 — 键值对表格
|
||||
// ══════════════════════════════════════════════
|
||||
function ConfigMapEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||
function ConfigMapEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) {
|
||||
const data = manifest?.data || {};
|
||||
const [entries, setEntries] = useState<{ key: string; value: string }[]>(
|
||||
Object.entries(data).map(([key, value]) => ({ key, value: String(value) }))
|
||||
@@ -264,7 +264,7 @@ function ConfigMapEditor({ manifest, onSave, saving }: { manifest: any; onSave:
|
||||
// ══════════════════════════════════════════════
|
||||
// Secret 编辑器 — 键值对表格 (Base64 编码/解码)
|
||||
// ══════════════════════════════════════════════
|
||||
function SecretEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||
function SecretEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) {
|
||||
const rawData = manifest?.data || {};
|
||||
// Decode base64 values for editing
|
||||
const [entries, setEntries] = useState<{ key: string; value: string; encoded: boolean }[]>(
|
||||
@@ -353,11 +353,11 @@ function SecretEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m:
|
||||
// ══════════════════════════════════════════════
|
||||
// Service 编辑器 — 端口配置 + 类型 + 选择器
|
||||
// ══════════════════════════════════════════════
|
||||
function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||
function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) {
|
||||
const spec = manifest?.spec || {};
|
||||
const [svcType, setSvcType] = useState(spec.type || 'ClusterIP');
|
||||
const [ports, setPorts] = useState<any[]>(
|
||||
(spec.ports || []).map((p: any) => ({
|
||||
(spec.ports || []).map(( p: any) => ({
|
||||
name: p.name || '',
|
||||
port: String(p.port || ''),
|
||||
targetPort: String(p.targetPort || ''),
|
||||
@@ -386,10 +386,10 @@ function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m
|
||||
const removeSelector = (i: number) => setSelector(selector.filter((_, idx) => idx !== i));
|
||||
|
||||
const handleSave = () => {
|
||||
const newSpec: any = { ...spec };
|
||||
const newSpec: Record<string, any> = { ...spec };
|
||||
newSpec.type = svcType;
|
||||
newSpec.ports = ports.map(p => {
|
||||
const port: any = {
|
||||
const port: Record<string, any> = {
|
||||
port: parseInt(p.port) || 80,
|
||||
targetPort: parseInt(p.targetPort) || parseInt(p.port) || 80,
|
||||
protocol: p.protocol,
|
||||
@@ -484,15 +484,15 @@ function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m
|
||||
// ══════════════════════════════════════════════
|
||||
// Ingress 编辑器 — Host/Path 路由规则
|
||||
// ══════════════════════════════════════════════
|
||||
function IngressEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||
function IngressEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) {
|
||||
const spec = manifest?.spec || {};
|
||||
const [rules, setRules] = useState<{ host: string; paths: { path: string; pathType: string; serviceName: string; servicePort: string }[] }[]>(
|
||||
(spec.rules || []).map((r: any) => ({
|
||||
(spec.rules || []).map(( r: any) => ({
|
||||
host: r.host || '',
|
||||
paths: (r.http?.backend?.servicePort != null
|
||||
? // Old-style single backend
|
||||
[{ path: '/', pathType: 'Prefix', serviceName: r.http?.backend?.serviceName || '', servicePort: String(r.http?.backend?.servicePort || '80') }]
|
||||
: (r.http?.paths || []).map((p: any) => ({
|
||||
: (r.http?.paths || []).map(( p: any) => ({
|
||||
path: p.path || '/',
|
||||
pathType: p.pathType || 'Prefix',
|
||||
serviceName: p.backend?.service?.name || p.backend?.serviceName || '',
|
||||
@@ -594,7 +594,7 @@ function IngressEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m
|
||||
// ══════════════════════════════════════════════
|
||||
// PVC 编辑器 — 存储大小/访问模式/StorageClass
|
||||
// ══════════════════════════════════════════════
|
||||
function PvcEditor({ manifest, onSave, saving }: { manifest: any; onSave: (m: any) => void; saving: boolean }) {
|
||||
function PvcEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) {
|
||||
const spec = manifest?.spec || {};
|
||||
const resources = spec.resources?.requests || {};
|
||||
const [size, setSize] = useState(resources.storage || '1Gi');
|
||||
|
||||
@@ -42,7 +42,7 @@ export function TopologyView() {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [connectMode, setConnectMode] = useState(false);
|
||||
const [connectSource, setConnectSource] = useState<string | null>(null);
|
||||
const [savedLayout, setSavedLayout] = useState<TopologyLayoutData | null>(null);
|
||||
const [, setSavedLayout] = useState<TopologyLayoutData | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [customIdCounter, setCustomIdCounter] = useState(1);
|
||||
// 编辑节点 Dialog
|
||||
@@ -52,13 +52,13 @@ export function TopologyView() {
|
||||
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await kubernetesApi.getClusters();
|
||||
const res: Record<string, any> = await kubernetesApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
// Auto-select from URL param (clusterCode) if provided, otherwise first cluster
|
||||
if (!selectedClusterId) {
|
||||
if (clusterCode) {
|
||||
const matched = list.find((c: any) => (c.code || String(c.id)) === clusterCode);
|
||||
const matched = list.find(( c: any) => (c.code || String(c.id)) === clusterCode);
|
||||
if (matched) setSelectedClusterId(String(matched.id));
|
||||
} else if (list.length > 0) {
|
||||
setSelectedClusterId(String(list[0].id));
|
||||
@@ -72,8 +72,8 @@ export function TopologyView() {
|
||||
// 加载已保存的布局 // Logan.Li
|
||||
const loadSavedLayout = useCallback(async (clusterId: string) => {
|
||||
try {
|
||||
const res: any = await kubernetesApi.getTopology(clusterId);
|
||||
const layout = res as any;
|
||||
const res: Record<string, any> = await kubernetesApi.getTopology(clusterId);
|
||||
const layout = res as Record<string, any>;
|
||||
if (layout?.layout_data) {
|
||||
const data = JSON.parse(layout.layout_data) as TopologyLayoutData;
|
||||
setSavedLayout(data);
|
||||
@@ -94,7 +94,7 @@ export function TopologyView() {
|
||||
if (!selectedClusterId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
const [nodesRes, svcRes, depRes, ingRes] = await Promise.all([
|
||||
kubernetesApi.getNodes(selectedClusterId).catch(() => []),
|
||||
kubernetesApi.getServices(selectedClusterId).catch(() => []),
|
||||
@@ -196,7 +196,7 @@ export function TopologyView() {
|
||||
|
||||
const handleEditNode = (node: TopologyNode) => {
|
||||
setEditingNode(node);
|
||||
setEditForm({ name: node.name, type: node.type, note: (node.data as any)?.note || '' });
|
||||
setEditForm({ name: node.name, type: node.type, note: (node.data as Record<string, any>)?.note || '' });
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function WorkloadViewPage() {
|
||||
kubernetesApi.getPods(clusterCode).catch(() => []),
|
||||
]);
|
||||
// yamlRes is raw YAML text — try to parse as JSON (K8s API accepts JSON as YAML superset)
|
||||
let parsed: any = null;
|
||||
let parsed: Record<string, any> = null;
|
||||
if (yamlRes && typeof yamlRes === 'string') {
|
||||
try { parsed = JSON.parse(yamlRes); } catch { parsed = { _raw: yamlRes }; }
|
||||
} else if (yamlRes && typeof yamlRes === 'object') {
|
||||
@@ -93,7 +93,7 @@ export default function WorkloadViewPage() {
|
||||
|
||||
// Filter pods by name prefix and namespace
|
||||
const podList = Array.isArray(podsRes) ? podsRes : (podsRes?.list || podsRes?.data || []);
|
||||
const filtered = podList.filter((p: any) => {
|
||||
const filtered = podList.filter(( p: any) => {
|
||||
const podName = p.name || p.metadata?.name || '';
|
||||
const podNs = p.namespace || p.namespace_name || '';
|
||||
return podName.startsWith(name) && (!podNs || podNs === namespace);
|
||||
@@ -144,8 +144,8 @@ export default function WorkloadViewPage() {
|
||||
// Parse status from manifest
|
||||
const spec = manifest?.spec || {};
|
||||
const status = manifest?.status || {};
|
||||
const containers: any[] = spec?.template?.spec?.containers || [];
|
||||
const initContainers: any[] = spec?.template?.spec?.initContainers || [];
|
||||
const containers: Record<string, any>[] = spec?.template?.spec?.containers || [];
|
||||
const initContainers: Record<string, any>[] = spec?.template?.spec?.initContainers || [];
|
||||
|
||||
// For StatefulSet
|
||||
const replicas = spec.replicas ?? 1;
|
||||
@@ -170,7 +170,7 @@ export default function WorkloadViewPage() {
|
||||
const strategyType = updateStrategy.type || (isSts ? 'RollingUpdate' : 'RollingUpdate');
|
||||
|
||||
// VolumeClaimTemplates (StatefulSet only)
|
||||
const vcts: any[] = spec.volumeClaimTemplates || [];
|
||||
const vcts: Record<string, any>[] = spec.volumeClaimTemplates || [];
|
||||
|
||||
// Selector
|
||||
const selectorLabels = spec.selector?.matchLabels || {};
|
||||
@@ -346,7 +346,7 @@ export default function WorkloadViewPage() {
|
||||
<div>
|
||||
<p className="text-slate-500 mb-1.5">Annotations</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(annotations).slice(0, 8).map(([k, v]) => (
|
||||
{Object.entries(annotations).slice(0, 8).map(([k, _v]) => (
|
||||
<Badge key={k} variant="outline" className="border-slate-700 text-slate-500 text-[10px] font-mono max-w-[300px] truncate">
|
||||
{k}
|
||||
</Badge>
|
||||
@@ -377,7 +377,7 @@ export default function WorkloadViewPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vcts.map((vct: any, idx: number) => {
|
||||
{vcts.map(( vct: any, idx: number) => {
|
||||
const accessModes = (vct.spec?.accessModes || []).join(', ');
|
||||
const storageClass = vct.spec?.storageClassName || vct.spec?.storageClassName || '-';
|
||||
const size = vct.spec?.resources?.requests?.storage || '-';
|
||||
@@ -408,7 +408,7 @@ export default function WorkloadViewPage() {
|
||||
<div className="mb-3">
|
||||
<p className="text-[11px] text-slate-500 mb-2 uppercase tracking-wide">Init Containers</p>
|
||||
<div className="space-y-2">
|
||||
{initContainers.map((c: any, idx: number) => (
|
||||
{initContainers.map(( c: any, idx: number) => (
|
||||
<ContainerInfo key={`init-${idx}`} c={c} />
|
||||
))}
|
||||
</div>
|
||||
@@ -419,7 +419,7 @@ export default function WorkloadViewPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-slate-500 mb-1 uppercase tracking-wide">Containers</p>
|
||||
{containers.map((c: any, idx: number) => (
|
||||
{containers.map(( c: any, idx: number) => (
|
||||
<ContainerInfo key={idx} c={c} />
|
||||
))}
|
||||
</div>
|
||||
@@ -449,7 +449,7 @@ export default function WorkloadViewPage() {
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-8">暂无 Pod</TableCell></TableRow>
|
||||
) : pods.map((pod: any) => (
|
||||
) : pods.map(( pod: any) => (
|
||||
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
|
||||
<TableCell>
|
||||
@@ -524,7 +524,7 @@ export default function WorkloadViewPage() {
|
||||
clusterId={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={terminalPod.name}
|
||||
containers={terminalPod.containers?.map((c: any) => c.name).filter(Boolean) || []}
|
||||
containers={terminalPod.containers?.map(( c: any) => c.name).filter(Boolean) || []}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -536,7 +536,7 @@ export default function WorkloadViewPage() {
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={fileBrowserPod.name}
|
||||
containers={fileBrowserPod.containers?.map((c: any) => c.name).filter(Boolean) || []}
|
||||
containers={fileBrowserPod.containers?.map(( c: any) => c.name).filter(Boolean) || []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -544,7 +544,7 @@ export default function WorkloadViewPage() {
|
||||
}
|
||||
|
||||
// Container info sub-component
|
||||
function ContainerInfo({ c }: { c: any }) {
|
||||
function ContainerInfo({ c }: { c: any}) {
|
||||
return (
|
||||
<div className="border border-slate-800 rounded-lg p-3 bg-slate-950/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -563,7 +563,7 @@ function ContainerInfo({ c }: { c: any }) {
|
||||
</div>
|
||||
<div className="text-slate-400">
|
||||
端口: <span className="text-slate-200 font-mono">
|
||||
{(c.ports || []).map((p: any) => p.containerPort || p.port).join(', ') || '-'}
|
||||
{(c.ports || []).map(( p: any) => p.containerPort || p.port).join(', ') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// YamlViewer.tsx — YAML 查看/编辑器(CodeMirror 6 语法高亮)
|
||||
// 支持:语法高亮 + 行号 + 基本校验 + 编辑/保存 + 只读字段清理
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { X, Copy, Check, Save, RefreshCw, Loader2, Sparkles } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo} from 'react';
|
||||
import { X, Copy, Check, Save, Loader2, Sparkles} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
@@ -53,7 +53,7 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
setRawYaml(text);
|
||||
setEditYaml(effectiveClean ? cleanYAML(text) : text);
|
||||
} catch (e: any) {
|
||||
toast({ title: '获取YAML失败', description: e.message, variant: 'destructive' });
|
||||
toast({ title: '获取YAML失败', description: (e as Record<string, any>).message, variant: 'destructive' });
|
||||
const err = `# Error: ${e.message}`;
|
||||
setRawYaml(err);
|
||||
setEditYaml(err);
|
||||
@@ -82,7 +82,7 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
setRawYaml(editYaml);
|
||||
onSaved?.();
|
||||
} catch (e: any) {
|
||||
toast({ title: '保存失败', description: e.message, variant: 'destructive' });
|
||||
toast({ title: '保存失败', description: (e as Record<string, any>).message, variant: 'destructive' });
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
|
||||
@@ -61,8 +61,8 @@ export function cleanYAML(yaml: string): string {
|
||||
let inInitContainers = false;
|
||||
let inVolumeMounts = false;
|
||||
let inVolumes = false;
|
||||
let metadataIndent = 0;
|
||||
let specIndent = 0;
|
||||
let _metadataIndent = 0;
|
||||
let _specIndent = 0;
|
||||
let templateSpecIndent = 0;
|
||||
|
||||
function getIndent(line: string): number {
|
||||
@@ -99,7 +99,7 @@ export function cleanYAML(yaml: string): string {
|
||||
}
|
||||
|
||||
// 检查 volumeMount 是否指向系统挂载路径
|
||||
function isSystemMountLine(line: string): boolean {
|
||||
function _isSystemMountLine(line: string): boolean {
|
||||
return SYSTEM_MOUNT_PATHS.some(p => line.includes(`mountPath: ${p}`));
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function cleanYAML(yaml: string): string {
|
||||
// === 顶层 metadata: 跟踪作用域 ===
|
||||
if (indent === 0 && key === 'metadata') {
|
||||
inMetadata = true;
|
||||
metadataIndent = 0;
|
||||
_metadataIndent = 0;
|
||||
out.push(line);
|
||||
i++;
|
||||
continue;
|
||||
@@ -138,7 +138,7 @@ export function cleanYAML(yaml: string): string {
|
||||
// === 顶层 spec: 跟踪作用域 ===
|
||||
if (indent === 0 && key === 'spec') {
|
||||
inSpec = true;
|
||||
specIndent = 0;
|
||||
_specIndent = 0;
|
||||
out.push(line);
|
||||
i++;
|
||||
continue;
|
||||
|
||||
@@ -59,12 +59,7 @@ export function getNodeColor(type: string) {
|
||||
* buildTopology — 从真实 K8s 资源构建分层拓扑图
|
||||
* 层级: Cluster → Ingress → Service → Deployment → Node
|
||||
*/
|
||||
export function buildTopology(
|
||||
cluster: any,
|
||||
ingresses: any[],
|
||||
services: any[],
|
||||
deployments: any[],
|
||||
nodes: any[],
|
||||
export function buildTopology( cluster: any, ingresses: any[], services: any[], deployments: any[], nodes: any[],
|
||||
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
|
||||
const tNodes: TopologyNode[] = [];
|
||||
const tEdges: TopologyEdge[] = [];
|
||||
@@ -133,7 +128,7 @@ export function buildTopology(
|
||||
if (svcCap.length > 0 && depCap.length > 0) {
|
||||
svcCap.forEach((svc) => {
|
||||
const svcNodeId = `service-${svc.id}`;
|
||||
let matched: any[] = [];
|
||||
let matched: Record<string, unknown>[] = [];
|
||||
try {
|
||||
const selector = typeof svc.selector === 'string' ? JSON.parse(svc.selector || '{}') : (svc.selector || {});
|
||||
const selectorEntries = Object.entries(selector);
|
||||
|
||||
@@ -26,15 +26,13 @@ import {
|
||||
CheckCircle,
|
||||
Palette,
|
||||
Check,
|
||||
X,
|
||||
Keyboard,
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from '@/lib/utils';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
|
||||
// Keyboard shortcut handler
|
||||
function useKeyboardShortcuts() {
|
||||
const navigate = useNavigate();
|
||||
const _navigate = useNavigate();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,7 +65,7 @@ export function Header() {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const { searchOpen, setSearchOpen } = useKeyboardShortcuts();
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect} from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useThemeStore, useMenuStore } from '@/store';
|
||||
import {
|
||||
LayoutDashboard, Server, Cloud, Bell, Monitor,
|
||||
CheckSquare, PlayCircle, Settings, ChevronDown, ChevronRight,
|
||||
Terminal, Cpu, Database, FileCode, Shield, Users,
|
||||
Menu, Network, CloudCog, Globe, MapPin, Key,
|
||||
Brain, Activity, BarChart3, GitBranch,
|
||||
Bot, AlertTriangle, Container, Boxes, Clock, Rocket,
|
||||
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Cloud,
|
||||
Bell,
|
||||
Monitor,
|
||||
CheckSquare,
|
||||
PlayCircle,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Terminal,
|
||||
Cpu,
|
||||
Database,
|
||||
FileCode,
|
||||
Shield,
|
||||
Users,
|
||||
Menu,
|
||||
Network,
|
||||
CloudCog,
|
||||
Globe,
|
||||
MapPin,
|
||||
Key,
|
||||
Brain,
|
||||
Activity,
|
||||
BarChart3,
|
||||
GitBranch,
|
||||
Bot,
|
||||
AlertTriangle,
|
||||
Container,
|
||||
Boxes,
|
||||
Clock,
|
||||
Rocket,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
@@ -45,7 +45,7 @@ export function AlertChannelDialog({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const openEditChannel = (ch: any) => {
|
||||
const openEditChannel = ( ch: any) => {
|
||||
setEditingChannel(ch);
|
||||
setChannelForm({
|
||||
name: ch.name || '',
|
||||
@@ -83,7 +83,7 @@ export function AlertChannelDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChannel = async (id: any) => {
|
||||
const handleDeleteChannel = async ( id: any) => {
|
||||
{ const ok = await _confirm({ title: '删除通知渠道', description: '确定删除此通知渠道?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
|
||||
try {
|
||||
await alertApi.deleteNotifyChannel(id);
|
||||
@@ -94,7 +94,7 @@ export function AlertChannelDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleChannel = async (ch: any) => {
|
||||
const handleToggleChannel = async ( ch: any) => {
|
||||
try {
|
||||
await alertApi.updateNotifyChannel(ch.id, { enabled: !ch.enabled });
|
||||
onChannelsChanged();
|
||||
@@ -103,7 +103,7 @@ export function AlertChannelDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestChannel = async (id: any) => {
|
||||
const handleTestChannel = async ( id: any) => {
|
||||
setChannelTesting(id);
|
||||
try {
|
||||
await alertApi.testNotifyChannel(id);
|
||||
|
||||
@@ -38,7 +38,7 @@ export function AlertEventsTab({
|
||||
return matchesSearch && matchesSeverity && matchesStatus;
|
||||
});
|
||||
|
||||
const getSeverityBadge = (severity: any) => {
|
||||
const getSeverityBadge = ( severity: any) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return (
|
||||
@@ -66,7 +66,7 @@ export function AlertEventsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: any) => {
|
||||
const getStatusBadge = ( status: any) => {
|
||||
switch (status) {
|
||||
case 'firing':
|
||||
return (
|
||||
@@ -85,7 +85,7 @@ export function AlertEventsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcknowledge = async (id: any) => {
|
||||
const handleAcknowledge = async ( id: any) => {
|
||||
try {
|
||||
await alertApi.acknowledge(id);
|
||||
onReload();
|
||||
|
||||
@@ -36,9 +36,9 @@ export function AlertManager() {
|
||||
alertApi.getRules(),
|
||||
alertApi.getNotifyChannels(),
|
||||
]);
|
||||
setAlerts((alertsData as any)?.list || []);
|
||||
setRules((rulesData as any)?.list || (Array.isArray(rulesData) ? rulesData : []));
|
||||
setNotifyChannels((channelsData as any)?.list || []);
|
||||
setAlerts((alertsData as Record<string, any>)?.list || []);
|
||||
setRules((rulesData as Record<string, any>)?.list || (Array.isArray(rulesData) ? rulesData : []));
|
||||
setNotifyChannels((channelsData as Record<string, any>)?.list || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load alert data:', error);
|
||||
} finally {
|
||||
@@ -55,7 +55,7 @@ export function AlertManager() {
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditRule = (rule: any) => {
|
||||
const openEditRule = ( rule: any) => {
|
||||
setEditingRule(rule);
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export function AlertRulesTab({
|
||||
rules: any[];
|
||||
notifyChannels: any[];
|
||||
onReload: () => void;
|
||||
onEditRule: (rule: any) => void;
|
||||
onEditRule: ( rule: any) => void;
|
||||
}) {
|
||||
const confirm = useConfirm();
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
@@ -50,7 +50,7 @@ export function AlertRulesTab({
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (id: any) => {
|
||||
const handleDeleteRule = async ( id: any) => {
|
||||
const ok = await confirm({
|
||||
title: '删除告警规则',
|
||||
description: '确定删除此告警规则?',
|
||||
@@ -89,7 +89,7 @@ export function AlertRulesTab({
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRule = async (id: any) => {
|
||||
const handleToggleRule = async ( id: any) => {
|
||||
try {
|
||||
await alertApi.toggleRule(id);
|
||||
onReload();
|
||||
|
||||
@@ -72,7 +72,7 @@ const AlertCenter: React.FC = () => {
|
||||
const fetchAlerts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await alertCenterApi.getAlerts({
|
||||
const res: Record<string, any> = await alertCenterApi.getAlerts({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
severity: severityFilter !== 'all' ? severityFilter : undefined,
|
||||
@@ -117,14 +117,14 @@ const AlertCenter: React.FC = () => {
|
||||
|
||||
const fetchContacts = async () => {
|
||||
try {
|
||||
const res: any = await alertApi.getContacts();
|
||||
const res: Record<string, any> = await alertApi.getContacts();
|
||||
setContacts((res?.data?.list || res?.list || []) as Contact[]);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const fetchContactGroups = async () => {
|
||||
try {
|
||||
const res: any = await alertApi.getContactGroups();
|
||||
const res: Record<string, any> = await alertApi.getContactGroups();
|
||||
setContactGroups((res?.data?.list || res?.list || []) as ContactGroup[]);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await alertApi.getNotifyChannels();
|
||||
const list = (res as any)?.list || [];
|
||||
const list = (res as Record<string, any>)?.list || [];
|
||||
setChannels(list);
|
||||
} catch {
|
||||
toast.error('获取通知渠道失败');
|
||||
@@ -60,7 +60,7 @@ export const AlertChannelsTab: React.FC<{ refreshKey: number }> = ({ refreshKey
|
||||
const handleTest = async (id: number) => {
|
||||
try {
|
||||
const res = await alertApi.testNotifyChannel(id);
|
||||
const status = (res as any)?.status;
|
||||
const status = (res as Record<string, any>)?.status;
|
||||
if (status === 'success') {
|
||||
toast.success('测试通知已发送');
|
||||
} else {
|
||||
|
||||
@@ -76,7 +76,7 @@ export interface ContactGroup {
|
||||
}
|
||||
|
||||
// Logan.Li — 后端字段映射到前端 Alert 接口
|
||||
export function mapAlert(a: any): Alert {
|
||||
export function mapAlert( a: any): Alert {
|
||||
return {
|
||||
id: String(a.id),
|
||||
title: a.rule_name || a.title || `告警 #${a.id}`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Mail, Phone, MessageSquare, Bell } from 'lucide-react';
|
||||
|
||||
// 将 notify_channels (可能是数组/JSON字符串/逗号分隔) 解析为数组
|
||||
export function parseChannels(notifyChannels: any): string[] {
|
||||
export function parseChannels( notifyChannels: any): string[] {
|
||||
if (!notifyChannels) return [];
|
||||
if (Array.isArray(notifyChannels)) return notifyChannels;
|
||||
if (typeof notifyChannels === 'string') {
|
||||
@@ -71,7 +71,7 @@ export function getChannelIcon(type: string) {
|
||||
}
|
||||
|
||||
// 渲染规则的通知渠道引用列表(兼容新格式=渠道ID 和 旧格式=类型字符串)
|
||||
export function renderRuleChannels(rule: any, notifyChannels: any[]) {
|
||||
export function renderRuleChannels( rule: any, notifyChannels: any[]) {
|
||||
const refs = parseChannels(rule.notify_channels);
|
||||
if (refs.length === 0) return <span className="text-xs text-slate-600">--</span>;
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus + 自定义仪表盘 // Logan.Li
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock,
|
||||
Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown,
|
||||
ExternalLink, Download, Rocket, PackageOpen, Loader2, Info, Trash2
|
||||
|
||||
Monitor,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
BarChart3,
|
||||
Server,
|
||||
Activity,
|
||||
Database,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ExternalLink,
|
||||
Rocket,
|
||||
PackageOpen,
|
||||
Loader2,
|
||||
Info,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -80,7 +99,7 @@ const AgentTab: React.FC = () => {
|
||||
const toggle = (f: SortField) => { if (sf === f) setSd(d => d === 'asc' ? 'desc' : 'asc'); else { setSf(f); setSd('desc'); } };
|
||||
const sorted = [...hosts].sort((a, b) => {
|
||||
let va: any = (a as any)[sf] ?? '', vb: any = (b as any)[sf] ?? '';
|
||||
if (sf === 'agent_status') { va = a.agent_status || ''; vb = b.agent_status || ''; }
|
||||
if (sf === 'agent_status') { va = (a as any).agent_status || ''; vb = (b as any).agent_status || ''; }
|
||||
if (typeof va === 'number') return sd === 'asc' ? va - vb : vb - va;
|
||||
return sd === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||||
});
|
||||
@@ -152,7 +171,7 @@ const AgentTab: React.FC = () => {
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={() => {
|
||||
const picked = selAny ? hosts.find(h => selected.has(String(h.server_id))) : null;
|
||||
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: (picked as any).has_auth } : null);
|
||||
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: (picked as Record<string, any>).has_auth } : null);
|
||||
setWizardOpen(true);
|
||||
}}>
|
||||
<Rocket className="w-3 h-3" />安装 Agent
|
||||
@@ -375,9 +394,9 @@ const ZabbixTab: React.FC = () => {
|
||||
const [ds, setDs] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
zabbixDatasourceApi.list().then((r: any) => {
|
||||
zabbixDatasourceApi.list().then(( r: any) => {
|
||||
const list = r?.data || r || [];
|
||||
setDs(list.find((d: any) => d.is_default) || list[0] || null);
|
||||
setDs(list.find(( d: any) => d.is_default) || list[0] || null);
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载数据源…</p></div>;
|
||||
@@ -389,9 +408,9 @@ const PrometheusTab: React.FC = () => {
|
||||
const [ds, setDs] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
promDatasourceApi.list().then((r: any) => {
|
||||
promDatasourceApi.list().then(( r: any) => {
|
||||
const list = r?.data || r || [];
|
||||
setDs(list.find((d: any) => d.is_default) || list[0] || null);
|
||||
setDs(list.find(( d: any) => d.is_default) || list[0] || null);
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载数据源…</p></div>;
|
||||
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user