refactor(frontend): API 子模块化、大组件拆分与 TanStack Query hooks

P1: kubernetes/system API 拆为子目录模块,导出工具下沉到 lib/export-utils,
    http 统一 buildApiUrl/getWsBase;HostMonitor、DeploymentEdit* 拆至子组件。

P2: 新增 hooks/api(queryKeys、useTerminalSessions、useHostMonitor),
    TerminalSessionsTab 与 HostMonitor 接入 TanStack Query。

P3: 保留 services/api 路径,未迁移至 api/ 目录。
This commit is contained in:
Logan.Li committed 2026-06-26 00:02:29 +08:00
1 parent 7fb2236b4d
commit 0b404b4611
226 files changed
+3040 -2596

No files matched your search

+1 -1
View File
@@ -4972,7 +4972,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -1,4 +1,4 @@
// DeploymentEditAdvanced.tsx — 编辑器「高级设置」Tab (M8)
// DeploymentEditAdvanced.tsx — 编辑器「高级设置」Tab (M8) // Logan.Li
// 编辑内容:更新策略 / 历史版本 / 调度(节点选择/容忍) / DNS / 安全上下文 / 终止宽限期 / 调度器 / SA
// 作者: Logan <admin@attacker.club>
import { useState, useEffect } from 'react';
@@ -7,10 +7,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Save, Loader2, Settings, RefreshCw, Server, Shield, Clock, Network, Plus, Trash2, Globe, MapPin,
Save, Loader2, Settings, RefreshCw, Shield, Clock, Network,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/services/api';
import { SchedulingSection, type ScheduleMode } from './deployment-edit-advanced/SchedulingSection';
import { DnsHostSection } from './deployment-edit-advanced/DnsHostSection';
interface Props {
clusterCode: string;
@@ -45,13 +47,11 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const [progressDeadlineSeconds, setProgressDeadlineSeconds] = useState('600');
// ──── 调度策略 ────
const [scheduleMode, setScheduleMode] = useState<'nodeName' | 'nodeSelector' | 'nodeAffinity'>('nodeSelector');
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('nodeSelector');
const [nodeName, setNodeName] = useState('');
const [availableNodes, setAvailableNodes] = useState<Array<{ name: string; ip: string; labels: Record<string, string> }>>([]);
const [nodesLoading, setNodesLoading] = useState(false);
// 标签选择器 — key=value 列表(替代原 nodeSelectorKv)
const [labelSelectorKv, setLabelSelectorKv] = useState<Array<{ key: string; value: string }>>([]);
// 预置标签(从节点聚合)
const [nodeLabels, setNodeLabels] = useState<Record<string, string[]>>({});
const [tolerations, setTolerations] = useState<Toleration[]>([]);
const [dnsPolicy, setDnsPolicy] = useState('ClusterFirst');
@@ -118,13 +118,13 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
// 加载节点列表
if (clusterCode) {
setNodesLoading(true);
kubernetesApi.getClusterNodes(clusterCode).then(( res: 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 mapped = nodes.map((n: any) => {
const meta = n.metadata || {};
const status = n.status || {};
const addresses: Record<string, any>[] = Array.isArray(status.addresses) ? status.addresses : [];
const internalIP = addresses.find(( a: any) => a.type === 'InternalIP')?.address || '';
const internalIP = addresses.find((a: any) => a.type === 'InternalIP')?.address || '';
return { name: meta.name || '', ip: internalIP, labels: meta.labels || {} };
});
setAvailableNodes(mapped);
@@ -193,12 +193,6 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const updateNs = (i: number, patch: Partial<{ key: string; value: string }>) =>
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>) =>
setTolerations(prev => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t)));
const save = async () => {
setSaving(true);
try {
@@ -404,137 +398,19 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
</CardContent>
</Card>
{/* 调度:节点名称 / 标签选择器 / 节点亲和性 */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Server className="h-4 w-4 text-emerald-400" />调度 (Scheduling)
</CardTitle>
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-0.5 border border-slate-700">
{([
{ key: 'nodeName' as const, label: '节点名称', desc: '指定节点' },
{ key: 'nodeSelector' as const, label: '标签选择器', desc: 'key=value' },
{ key: 'nodeAffinity' as const, label: '节点亲和性', desc: '高级规则' },
]).map(m => (
<button key={m.key} onClick={() => setScheduleMode(m.key)}
className={`px-2.5 py-1 rounded text-[10px] font-medium transition-all ${
scheduleMode === m.key
? 'bg-emerald-500/20 text-emerald-300 shadow-sm'
: 'text-slate-400 hover:text-slate-300'
}`}>
{m.label}
</button>
))}
</div>
</div>
<p className="text-xs text-slate-500 mt-1">
{scheduleMode === 'nodeName' && 'Pod 将直接调度到指定节点'}
{scheduleMode === 'nodeSelector' && 'Pod 将调度到匹配标签的节点'}
{scheduleMode === 'nodeAffinity' && '通过亲和性规则控制 Pod 调度到指定节点'}
</p>
</CardHeader>
<CardContent className="pt-0">
{/* 节点名称模式 */}
{scheduleMode === 'nodeName' && (
<div className="space-y-2">
<Label className="text-slate-400 text-xs">请选择节点</Label>
{nodesLoading ? (
<div className="flex items-center gap-1 text-slate-500 text-xs"><Loader2 className="h-3 w-3 animate-spin" />加载节点列表...</div>
) : (
<select value={nodeName} onChange={e => setNodeName(e.target.value)}
className="h-8 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2">
<option value="">(不指定)</option>
{availableNodes.map(n => (
<option key={n.name} value={n.name}>{n.name} {n.ip ? `(${n.ip})` : ''}</option>
))}
</select>
)}
{nodeName && (
<div className="text-[10px] text-slate-500">
已选择: <code className="bg-slate-800 px-1 py-0.5 rounded text-emerald-400">{nodeName}</code>
</div>
)}
</div>
)}
{/* 标签选择器模式 */}
{scheduleMode === 'nodeSelector' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-400 text-xs">请选择标签</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={addNs}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{labelSelectorKv.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置标签选择器</p>
) : labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2">
{/* 标签 Key 下拉 */}
<select value={n.key} onChange={e => updateNs(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5">
<option value="">选择标签</option>
{Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option>
))}
</select>
{/* 标签 Value 下拉 */}
<select value={n.value} onChange={e => updateNs(i, { value: e.target.value })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
disabled={!n.key}>
<option value="">选择值</option>
{(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400" onClick={() => removeNs(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* 节点亲和性模式 */}
{scheduleMode === 'nodeAffinity' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-400 text-xs">
请选择标签 <span className="text-slate-600">(生成 requiredDuringScheduling 规则)</span>
</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={addNs}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{labelSelectorKv.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置亲和性规则</p>
) : labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2">
<select value={n.key} onChange={e => updateNs(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5">
<option value="">选择标签</option>
{Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option>
))}
</select>
<select value={n.value} onChange={e => updateNs(i, { value: e.target.value })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
disabled={!n.key}>
<option value="">选择值</option>
{(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400" onClick={() => removeNs(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<SchedulingSection
scheduleMode={scheduleMode}
onScheduleModeChange={setScheduleMode}
nodeName={nodeName}
onNodeNameChange={setNodeName}
availableNodes={availableNodes}
nodesLoading={nodesLoading}
labelSelectorKv={labelSelectorKv}
onAddLabelSelector={addNs}
onRemoveLabelSelector={removeNs}
onUpdateLabelSelector={updateNs}
nodeLabels={nodeLabels}
/>
{/* Pod 级别杂项 */}
<Card className="bg-slate-900 border-slate-800">
@@ -645,92 +521,16 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
</CardContent>
</Card>
{/* DNS 配置(M8 补全) */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Globe className="h-4 w-4 text-indigo-400" />DNS 配置 (dnsConfig)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<p className="text-[11px] text-slate-500">
提示:dnsConfig 通常与 <code className="text-slate-400">dnsPolicy: None</code> 搭配使用。每行一个值。
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-slate-500 text-xs">nameservers(每行一个 IP)</Label>
<textarea value={dnsNameservers} onChange={e => setDnsNameservers(e.target.value)}
placeholder={'8.8.8.8\n114.114.114.114'}
rows={3}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 font-mono p-2 resize-y" />
</div>
<div>
<Label className="text-slate-500 text-xs">searches(搜索域,每行一个)</Label>
<textarea value={dnsSearches} onChange={e => setDnsSearches(e.target.value)}
placeholder={'svc.cluster.local\ncluster.local'}
rows={3}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 font-mono p-2 resize-y" />
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">options(DNS 选项)</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400"
onClick={() => setDnsOptions(prev => [...prev, { name: '', value: '' }])}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{dnsOptions.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : dnsOptions.map((o, i) => (
<div key={i} className="flex items-center gap-2 mb-1.5">
<Input value={o.name} onChange={e => setDnsOptions(prev => prev.map((x, idx) => idx === i ? { ...x, name: e.target.value } : x))}
placeholder="ndots" className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<span className="text-slate-600">:</span>
<Input value={o.value} onChange={e => setDnsOptions(prev => prev.map((x, idx) => idx === i ? { ...x, value: e.target.value } : x))}
placeholder="5(可留空)" className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400"
onClick={() => setDnsOptions(prev => prev.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
{/* 主机别名(M8 补全) */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<MapPin className="h-4 w-4 text-amber-400" />主机别名 (hostAliases)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">IP → 主机名映射(注入 Pod 的 /etc/hosts)</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400"
onClick={() => setHostAliases(prev => [...prev, { ip: '', hostnames: '' }])}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{hostAliases.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : hostAliases.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<Input value={a.ip} onChange={e => setHostAliases(prev => prev.map((x, idx) => idx === i ? { ...x, ip: e.target.value } : x))}
placeholder="10.10.10.72" className="w-40 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<span className="text-slate-600">→</span>
<Input value={a.hostnames} onChange={e => setHostAliases(prev => prev.map((x, idx) => idx === i ? { ...x, hostnames: e.target.value } : x))}
placeholder="gitlab.local, registry.local" className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400"
onClick={() => setHostAliases(prev => prev.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</CardContent>
</Card>
<DnsHostSection
dnsNameservers={dnsNameservers}
onDnsNameserversChange={setDnsNameservers}
dnsSearches={dnsSearches}
onDnsSearchesChange={setDnsSearches}
dnsOptions={dnsOptions}
onDnsOptionsChange={setDnsOptions}
hostAliases={hostAliases}
onHostAliasesChange={setHostAliases}
/>
{/* 操作栏 */}
<div className="flex justify-end gap-2 pt-2">
@@ -742,4 +542,4 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
</div>
</div>
);
}
}
@@ -2,316 +2,29 @@
// Kuboard 风格容器编辑器:工作容器 + 初始化容器完整编辑
// 编辑字段:镜像/拉取策略/命令/参数/环境变量/资源/端口/健康检查/生命周期/安全上下文/卷挂载
// 作者: Logan <admin@attacker.club>
import { useState, useEffect} 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';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import {
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';
// ============ Types ============
interface EnvVar { name: string; value?: string; }
interface PortDef { name?: string; containerPort: number | string; protocol?: string; }
interface VolumeMount { name: string; mountPath: string; subPath?: string; readOnly?: boolean; }
interface ResourceMap { cpu?: string; memory?: string; }
interface ProbeDef {
type?: 'httpGet' | 'tcpSocket' | 'exec' | 'none';
// httpGet
httpPath?: string; httpPort?: number | string; httpHost?: string;
// tcpSocket
tcpPort?: number | string;
// exec
execCommand?: string;
// timing
initialDelaySeconds?: number; periodSeconds?: number;
timeoutSeconds?: number; successThreshold?: number; failureThreshold?: number;
}
interface LifecycleHandler {
type?: 'exec' | 'httpGet' | 'tcpSocket' | 'none';
execCommand?: string;
httpPath?: string; httpPort?: number | string;
tcpPort?: number | string;
}
interface LifecycleDef { postStart?: LifecycleHandler; preStop?: LifecycleHandler; }
interface SecurityContext {
runAsUser?: number | string;
runAsGroup?: number | string;
runAsNonRoot?: boolean;
privileged?: boolean;
readOnlyRootFilesystem?: boolean;
allowPrivilegeEscalation?: boolean;
capabilitiesAdd?: string[];
capabilitiesDrop?: string[];
}
interface ContainerForm {
name: string;
image: string;
imagePullPolicy?: string;
command: string[];
args: string[];
workingDir?: string;
env: EnvVar[];
ports: PortDef[];
resources: { requests?: ResourceMap; limits?: ResourceMap; };
livenessProbe?: ProbeDef | null;
readinessProbe?: ProbeDef | null;
startupProbe?: ProbeDef | null;
lifecycle?: LifecycleDef | null;
securityContext?: SecurityContext | null;
volumeMounts: VolumeMount[];
}
interface DeploymentEditContainersProps {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
// ============ K8s container spec → form conversion ============
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';
else if (p.tcpSocket) type = 'tcpSocket';
else if (p.exec) type = 'exec';
return {
type,
httpPath: p.httpGet?.path || '',
httpPort: p.httpGet?.port ?? '',
httpHost: p.httpGet?.host || '',
tcpPort: p.tcpSocket?.port ?? '',
execCommand: Array.isArray(p.exec?.command) ? p.exec.command.join('\n') : (p.exec?.command || ''),
initialDelaySeconds: p.initialDelaySeconds,
periodSeconds: p.periodSeconds,
timeoutSeconds: p.timeoutSeconds,
successThreshold: p.successThreshold,
failureThreshold: p.failureThreshold,
};
};
const lh = ( h: any): LifecycleHandler => {
if (!h) return { type: 'none' };
let type: LifecycleHandler['type'] = 'none';
if (h.exec) type = 'exec';
else if (h.httpGet) type = 'httpGet';
else if (h.tcpSocket) type = 'tcpSocket';
return {
type,
execCommand: Array.isArray(h.exec?.command) ? h.exec.command.join('\n') : '',
httpPath: h.httpGet?.path || '',
httpPort: h.httpGet?.port ?? '',
tcpPort: h.tcpSocket?.port ?? '',
};
};
const sc = c.securityContext;
return {
name: c.name || '',
image: c.image || '',
imagePullPolicy: c.imagePullPolicy || '',
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' })),
resources: {
requests: c.resources?.requests || {},
limits: c.resources?.limits || {},
},
livenessProbe: probe(c.livenessProbe),
readinessProbe: probe(c.readinessProbe),
startupProbe: probe(c.startupProbe),
lifecycle: c.lifecycle ? {
postStart: c.lifecycle.postStart ? lh(c.lifecycle.postStart) : undefined,
preStop: c.lifecycle.preStop ? lh(c.lifecycle.preStop) : undefined,
} : null,
securityContext: sc ? {
runAsUser: sc.runAsUser,
runAsGroup: sc.runAsGroup,
runAsNonRoot: sc.runAsNonRoot,
privileged: sc.privileged,
readOnlyRootFilesystem: sc.readOnlyRootFilesystem,
allowPrivilegeEscalation: sc.allowPrivilegeEscalation,
capabilitiesAdd: sc.capabilities?.add || [],
capabilitiesDrop: sc.capabilities?.drop || [],
} : null,
volumeMounts: (Array.isArray(c.volumeMounts) ? c.volumeMounts : []).map(( m: any) => ({
name: m.name || '', mountPath: m.mountPath || '', subPath: m.subPath || '', readOnly: m.readOnly || false,
})),
};
}
// ============ form → K8s container spec ============
function emptyContainer(name = ''): ContainerForm {
return {
name, image: '', imagePullPolicy: 'IfNotPresent',
command: [], args: [], workingDir: '',
env: [], ports: [], resources: {},
livenessProbe: null, readinessProbe: null, startupProbe: null,
lifecycle: null, securityContext: null, volumeMounts: [],
};
}
function probeToK8s(p: ProbeDef | null | undefined): any {
if (!p || p.type === 'none' || !p.type) return undefined;
const out: Record<string, any> = {};
if (p.type === 'httpGet') {
out.httpGet = {};
if (p.httpPath) out.httpGet.path = p.httpPath;
if (p.httpPort !== '' && p.httpPort != null) out.httpGet.port = typeof p.httpPort === 'number' ? p.httpPort : Number(p.httpPort);
if (p.httpHost) out.httpGet.host = p.httpHost;
} else if (p.type === 'tcpSocket') {
out.tcpSocket = {};
if (p.tcpPort !== '' && p.tcpPort != null) out.tcpSocket.port = typeof p.tcpPort === 'number' ? p.tcpPort : Number(p.tcpPort);
} else if (p.type === 'exec') {
const cmds = (p.execCommand || '').split('\n').map(s => s.trim()).filter(Boolean);
if (cmds.length) out.exec = { command: cmds };
}
if (p.initialDelaySeconds != null) out.initialDelaySeconds = p.initialDelaySeconds;
if (p.periodSeconds != null) out.periodSeconds = p.periodSeconds;
if (p.timeoutSeconds != null) out.timeoutSeconds = p.timeoutSeconds;
if (p.successThreshold != null) out.successThreshold = p.successThreshold;
if (p.failureThreshold != null) out.failureThreshold = p.failureThreshold;
return Object.keys(out).length > 0 ? out : undefined;
}
function handlerToK8s(h: LifecycleHandler | undefined): any {
if (!h || h.type === 'none' || !h.type) return undefined;
if (h.type === 'exec') {
const cmds = (h.execCommand || '').split('\n').map(s => s.trim()).filter(Boolean);
return cmds.length ? { exec: { command: cmds } } : undefined;
}
if (h.type === '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: Record<string, any> = { tcpSocket: {} };
if (h.tcpPort !== '' && h.tcpPort != null) o.tcpSocket.port = Number(h.tcpPort);
return o;
}
return undefined;
}
function containerToK8s(c: ContainerForm): any {
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;
if (c.command.length > 0) out.command = c.command;
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: 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: 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;
});
}
const req = c.resources.requests;
const lim = c.resources.limits;
const hasReq = req && Object.values(req).some(v => v && v.trim());
const hasLim = lim && Object.values(lim).some(v => v && v.trim());
if (hasReq || hasLim) {
out.resources = {};
if (hasReq) {
out.resources.requests = {};
if (req!.cpu?.trim()) out.resources.requests.cpu = req!.cpu;
if (req!.memory?.trim()) out.resources.requests.memory = req!.memory;
}
if (hasLim) {
out.resources.limits = {};
if (lim!.cpu?.trim()) out.resources.limits.cpu = lim!.cpu;
if (lim!.memory?.trim()) out.resources.limits.memory = lim!.memory;
}
}
const lp = probeToK8s(c.livenessProbe); if (lp) out.livenessProbe = lp;
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: 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: 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;
if (s.privileged != null) sc.privileged = s.privileged;
if (s.readOnlyRootFilesystem != null) sc.readOnlyRootFilesystem = s.readOnlyRootFilesystem;
if (s.allowPrivilegeEscalation != null) sc.allowPrivilegeEscalation = s.allowPrivilegeEscalation;
const capAdd = (s.capabilitiesAdd || []).filter(Boolean);
const capDrop = (s.capabilitiesDrop || []).filter(Boolean);
if (capAdd.length || capDrop.length) {
sc.capabilities = {};
if (capAdd.length) sc.capabilities.add = capAdd;
if (capDrop.length) sc.capabilities.drop = capDrop;
}
if (Object.keys(sc).length > 0) out.securityContext = sc;
}
if (c.volumeMounts.length > 0) {
out.volumeMounts = c.volumeMounts.filter(m => m.name.trim() && m.mountPath.trim()).map(m => {
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;
});
}
return out;
}
// ============ String array helper (command/args editing) ============
function arrToText(arr: string[]): string { return arr.join('\n'); }
function textToArr(text: string): string[] { return text.split('\n').map(s => s.trim()).filter(Boolean); }
// ============ Main Component ============
import { ContainerGroupSection } from './deployment-edit/ContainerGroupSection';
import {
toContainerForm, emptyContainer, containerToK8s,
} from './deployment-edit/container-utils';
import type { ContainerForm, DeploymentEditContainersProps } from './deployment-edit/types';
export function DeploymentEditContainers({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditContainersProps) {
const { toast } = useToast();
@@ -328,11 +41,11 @@ 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]);
// ---- container list ops ----
// ---- container list ops ---- // Logan.Li
const updateContainer = (idx: number, patch: Partial<ContainerForm>) => {
setContainers(prev => prev.map((c, i) => (i === idx ? { ...c, ...patch } : c)));
};
@@ -362,7 +75,7 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
setInitContainers(prev => prev.filter((_, i) => i !== idx));
};
// ---- save ----
// ---- save ---- // Logan.Li
const save = async () => {
// validate
for (const c of [...initContainers, ...containers]) {
@@ -494,538 +207,4 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
</div>
</div>
);
}
// ============ Container Group Section ============
function ContainerGroupSection({
title, description, icon, containers, accentColor,
onAdd, onRemove, onUpdate, onDuplicate,
}: {
title: string;
description: string;
icon: React.ReactNode;
containers: ContainerForm[];
accentColor: 'blue' | 'purple';
onAdd: () => void;
onRemove: (idx: number) => void;
onUpdate: (idx: number, patch: Partial<ContainerForm>) => void;
onDuplicate?: (idx: number) => void;
}) {
const [openIndices, setOpenIndices] = useState<Set<number>>(new Set([0]));
const toggleOpen = (idx: number) => {
setOpenIndices(prev => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx); else next.add(idx);
return next;
});
};
const borderClass = accentColor === 'blue' ? 'border-blue-500/30' : 'border-purple-500/30';
const headerBg = accentColor === 'blue' ? 'bg-blue-500/5' : 'bg-purple-500/5';
return (
<Card className={`bg-slate-900 border-slate-800`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-sm text-slate-200">{title}</CardTitle>
<Badge variant="outline" className={`border-slate-700 text-slate-400 text-[10px]`}>{containers.length}</Badge>
</div>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={onAdd}>
<Plus className="h-3 w-3 mr-1" />新增容器
</Button>
</div>
<p className="text-[11px] text-slate-600">{description}</p>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{containers.length === 0 && (
<div className="text-center py-6 text-slate-600 text-xs">暂无容器,点击「新增容器」添加</div>
)}
{containers.map((c, idx) => {
const isOpen = openIndices.has(idx) || containers.length === 1;
return (
<div key={idx} className={`rounded-lg border ${borderClass} overflow-hidden`}>
{/* container header */}
<div className={`flex items-center justify-between px-3 py-2 ${headerBg} cursor-pointer`} onClick={() => toggleOpen(idx)}>
<div className="flex items-center gap-2">
{isOpen ? <ChevronDown className="h-4 w-4 text-slate-500" /> : <ChevronRight className="h-4 w-4 text-slate-500" />}
<Container className={`h-4 w-4 ${accentColor === 'blue' ? 'text-blue-400' : 'text-purple-400'}`} />
<span className="text-sm font-mono text-slate-200 font-semibold">{c.name || '(未命名)'}</span>
{c.image && <code className="text-[10px] text-slate-500 font-mono truncate max-w-[300px]">{c.image}</code>}
</div>
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
{onDuplicate && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-500 hover:text-slate-300" title="复制"
onClick={() => onDuplicate(idx)}>
<RotateCw className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" title="删除"
onClick={() => { if (confirm(`确认删除容器「${c.name}」?`)) onRemove(idx); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* container body */}
{isOpen && (
<div className="p-3 space-y-4 bg-slate-950/30">
<ContainerEditor container={c} onUpdate={(patch) => onUpdate(idx, patch)} />
</div>
)}
</div>
);
})}
</CardContent>
</Card>
);
}
// ============ Single Container Editor (all sections) ============
function ContainerEditor({ container, onUpdate }: {
container: ContainerForm;
onUpdate: (patch: Partial<ContainerForm>) => void;
}) {
const c = container;
const sectionBtn = "flex items-center gap-1.5 text-xs font-semibold text-slate-300 mb-2";
return (
<>
{/* ---- 基本设置 ---- */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-slate-400 text-xs">容器名称</Label>
<Input value={c.name} onChange={e => onUpdate({ name: e.target.value })}
placeholder="my-app"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-slate-400 text-xs">容器镜像</Label>
<Input value={c.image} onChange={e => onUpdate({ image: e.target.value })}
placeholder="nginx:1.25"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-slate-400 text-xs">镜像拉取策略</Label>
<Select value={c.imagePullPolicy || '__none'} onValueChange={v => onUpdate({ imagePullPolicy: v === '__none' ? '' : v })}>
<SelectTrigger className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-700">
<SelectItem value="__none" className="text-slate-400">不设置(默认 IfNotPresent)</SelectItem>
<SelectItem value="Always" className="text-slate-200">Always</SelectItem>
<SelectItem value="IfNotPresent" className="text-slate-200">IfNotPresent</SelectItem>
<SelectItem value="Never" className="text-slate-200">Never</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-slate-400 text-xs">工作目录 (workingDir)</Label>
<Input value={c.workingDir || ''} onChange={e => onUpdate({ workingDir: e.target.value })}
placeholder="/app"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
</div>
{/* ---- 命令与参数 ---- */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-slate-400 text-xs">启动命令 (command) — 每行一个</Label>
<Textarea value={arrToText(c.command)} placeholder={"/bin/sh\n-c"}
onChange={e => onUpdate({ command: textToArr(e.target.value) })}
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[60px]" />
</div>
<div>
<Label className="text-slate-400 text-xs">启动参数 (args) — 每行一个</Label>
<Textarea value={arrToText(c.args)} placeholder={"--config=/etc/app.yaml\n--debug"}
onChange={e => onUpdate({ args: textToArr(e.target.value) })}
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[60px]" />
</div>
</div>
{/* ---- 环境变量 ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<div className={sectionBtn}><Terminal className="h-3.5 w-3.5 text-cyan-400" /> 环境变量</div>
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
onClick={() => onUpdate({ env: [...c.env, { name: '', value: '' }] })}>
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
<div className="space-y-1.5">
{c.env.map((env, ei) => (
<div key={ei} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={env.name} placeholder="KEY"
onChange={e => { const n = [...c.env]; n[ei] = { ...env, name: e.target.value }; onUpdate({ env: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={env.value || ''} placeholder="value"
onChange={e => { const n = [...c.env]; n[ei] = { ...env, value: e.target.value }; onUpdate({ env: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400"
onClick={() => onUpdate({ env: c.env.filter((_, j) => j !== ei) })}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{c.env.length === 0 && <p className="text-[11px] text-slate-600">无环境变量</p>}
</div>
</div>
{/* ---- 端口 ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<div className={sectionBtn}><Network className="h-3.5 w-3.5 text-indigo-400" /> 端口</div>
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
onClick={() => onUpdate({ ports: [...c.ports, { name: '', containerPort: '', protocol: 'TCP' }] })}>
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
<div className="space-y-1.5">
{c.ports.map((p, pi) => (
<div key={pi} className="grid grid-cols-[1fr_1fr_100px_auto] gap-2">
<Input value={p.name || ''} placeholder="http"
onChange={e => { const n = [...c.ports]; n[pi] = { ...p, name: e.target.value }; onUpdate({ ports: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input type="number" value={p.containerPort} placeholder="8080"
onChange={e => { const n = [...c.ports]; n[pi] = { ...p, containerPort: e.target.value }; onUpdate({ ports: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Select value={p.protocol || 'TCP'} onValueChange={v => { const n = [...c.ports]; n[pi] = { ...p, protocol: v }; onUpdate({ ports: n }); }}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-700">
<SelectItem value="TCP" className="text-slate-200">TCP</SelectItem>
<SelectItem value="UDP" className="text-slate-200">UDP</SelectItem>
<SelectItem value="SCTP" className="text-slate-200">SCTP</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400"
onClick={() => onUpdate({ ports: c.ports.filter((_, j) => j !== pi) })}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{c.ports.length === 0 && <p className="text-[11px] text-slate-600">无端口暴露</p>}
</div>
</div>
{/* ---- 资源 requests/limits ---- */}
<div>
<div className={sectionBtn}><Cpu className="h-3.5 w-3.5 text-amber-400" /> 资源 (requests / limits)</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<div>
<Label className="text-[11px] text-slate-500">CPU 请求</Label>
<Input value={c.resources.requests?.cpu || ''} placeholder="100m"
onChange={e => onUpdate({ resources: { ...c.resources, requests: { ...c.resources.requests, cpu: e.target.value } } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">CPU 上限</Label>
<Input value={c.resources.limits?.cpu || ''} placeholder="500m"
onChange={e => onUpdate({ resources: { ...c.resources, limits: { ...c.resources.limits, cpu: e.target.value } } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">内存请求</Label>
<Input value={c.resources.requests?.memory || ''} placeholder="128Mi"
onChange={e => onUpdate({ resources: { ...c.resources, requests: { ...c.resources.requests, memory: e.target.value } } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">内存上限</Label>
<Input value={c.resources.limits?.memory || ''} placeholder="512Mi"
onChange={e => onUpdate({ resources: { ...c.resources, limits: { ...c.resources.limits, memory: e.target.value } } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
</div>
</div>
{/* ---- 健康检查 ---- */}
<div>
<div className={sectionBtn}><Heart className="h-3.5 w-3.5 text-rose-400" /> 健康检查 (Probes)</div>
<div className="space-y-2">
{(['livenessProbe', 'readinessProbe', 'startupProbe'] as const).map(pt => (
<ProbeEditor key={pt}
label={{ livenessProbe: '存活探针 (liveness)', readinessProbe: '就绪探针 (readiness)', startupProbe: '启动探针 (startup)' }[pt]}
probe={c[pt]}
onChange={(p) => onUpdate({ [pt]: p } as Record<string, any>)}
/>
))}
</div>
</div>
{/* ---- 生命周期 ---- */}
<div>
<div className={sectionBtn}><Zap className="h-3.5 w-3.5 text-yellow-400" /> 生命周期回调 (Lifecycle)</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<LifecycleHandlerEditor label="启动后 (postStart)" handler={c.lifecycle?.postStart}
onChange={h => onUpdate({ lifecycle: { ...c.lifecycle, postStart: h } })} />
<LifecycleHandlerEditor label="终止前 (preStop)" handler={c.lifecycle?.preStop}
onChange={h => onUpdate({ lifecycle: { ...c.lifecycle, preStop: h } })} />
</div>
{!c.lifecycle && (
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px] mt-1"
onClick={() => onUpdate({ lifecycle: { postStart: { type: 'none' }, preStop: { type: 'none' } } })}>
<Plus className="h-3 w-3 mr-1" />启用生命周期回调
</Button>
)}
</div>
{/* ---- 安全上下文 ---- */}
<div>
<div className={sectionBtn}><Shield className="h-3.5 w-3.5 text-emerald-400" /> 安全上下文 (SecurityContext)</div>
{c.securityContext ? (
<div className="space-y-2 border border-slate-800 rounded-md p-3 bg-slate-950/30">
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
<div>
<Label className="text-[11px] text-slate-500">runAsUser</Label>
<Input type="number" value={c.securityContext.runAsUser ?? ''} placeholder="1000"
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, runAsUser: e.target.value === '' ? undefined : Number(e.target.value) } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">runAsGroup</Label>
<Input type="number" value={c.securityContext.runAsGroup ?? ''} placeholder="1000"
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, runAsGroup: e.target.value === '' ? undefined : Number(e.target.value) } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">Capabilities Add (逗号分隔)</Label>
<Input value={(c.securityContext.capabilitiesAdd || []).join(',')} placeholder="NET_ADMIN"
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, capabilitiesAdd: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">Capabilities Drop (逗号分隔)</Label>
<Input value={(c.securityContext.capabilitiesDrop || []).join(',')} placeholder="ALL"
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, capabilitiesDrop: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
</div>
<div className="flex items-center gap-4 flex-wrap">
<BoolToggle label="runAsNonRoot" value={!!c.securityContext.runAsNonRoot}
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, runAsNonRoot: v } })} />
<BoolToggle label="privileged" value={!!c.securityContext.privileged}
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, privileged: v } })} />
<BoolToggle label="readOnlyRootFilesystem" value={!!c.securityContext.readOnlyRootFilesystem}
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, readOnlyRootFilesystem: v } })} />
<BoolToggle label="allowPrivilegeEscalation" value={c.securityContext.allowPrivilegeEscalation ?? false}
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, allowPrivilegeEscalation: v } })} />
</div>
<Button size="sm" variant="ghost" className="h-6 text-red-400 text-[11px]"
onClick={() => onUpdate({ securityContext: null })}>
<Trash2 className="h-3 w-3 mr-1" />移除安全上下文
</Button>
</div>
) : (
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
onClick={() => onUpdate({ securityContext: { runAsNonRoot: false, privileged: false } })}>
<Plus className="h-3 w-3 mr-1" />启用安全上下文
</Button>
)}
</div>
{/* ---- 卷挂载 ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<div className={sectionBtn}><FolderOpen className="h-3.5 w-3.5 text-teal-400" /> 卷挂载 (VolumeMounts)</div>
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
onClick={() => onUpdate({ volumeMounts: [...c.volumeMounts, { name: '', mountPath: '', subPath: '', readOnly: false }] })}>
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
<div className="space-y-1.5">
{c.volumeMounts.map((m, mi) => (
<div key={mi} className="grid grid-cols-[1fr_1fr_1fr_60px_auto] gap-2 items-center">
<Input value={m.name} placeholder="volume 名称"
onChange={e => { const n = [...c.volumeMounts]; n[mi] = { ...m, name: e.target.value }; onUpdate({ volumeMounts: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={m.mountPath} placeholder="/data"
onChange={e => { const n = [...c.volumeMounts]; n[mi] = { ...m, mountPath: e.target.value }; onUpdate({ volumeMounts: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={m.subPath || ''} placeholder="subPath (可选)"
onChange={e => { const n = [...c.volumeMounts]; n[mi] = { ...m, subPath: e.target.value }; onUpdate({ volumeMounts: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<label className="flex items-center gap-1 text-[10px] text-slate-500 cursor-pointer">
<Switch checked={!!m.readOnly}
onCheckedChange={v => { const n = [...c.volumeMounts]; n[mi] = { ...m, readOnly: v }; onUpdate({ volumeMounts: n }); }} />
RO
</label>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400"
onClick={() => onUpdate({ volumeMounts: c.volumeMounts.filter((_, j) => j !== mi) })}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{c.volumeMounts.length === 0 && <p className="text-[11px] text-slate-600">无卷挂载</p>}
</div>
</div>
</>
);
}
// ============ Probe Editor ============
function ProbeEditor({ label, probe, onChange }: {
label: string;
probe: ProbeDef | null | undefined;
onChange: (p: ProbeDef | null) => void;
}) {
const enabled = !!probe;
const p = probe || { type: 'httpGet' as const };
const setField = (patch: Partial<ProbeDef>) => onChange({ ...p, ...patch });
return (
<div className="border border-slate-800 rounded-md p-2.5 bg-slate-950/30">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-300 font-medium">{label}</span>
<Switch checked={enabled}
onCheckedChange={v => onChange(v ? { type: 'httpGet', httpPath: '/', httpPort: 8080, initialDelaySeconds: 10, periodSeconds: 10 } : null)} />
</div>
{enabled && (
<>
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] text-slate-500 w-8">类型</span>
<Select value={p.type || 'httpGet'} onValueChange={v => setField({ type: v as ProbeDef['type'] })}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-700">
<SelectItem value="httpGet" className="text-slate-200">HTTP GET</SelectItem>
<SelectItem value="tcpSocket" className="text-slate-200">TCP Socket</SelectItem>
<SelectItem value="exec" className="text-slate-200">Exec 命令</SelectItem>
</SelectContent>
</Select>
</div>
{p.type === 'httpGet' && (
<div className="grid grid-cols-3 gap-2 mb-2">
<Input value={p.httpPath || ''} placeholder="/healthz"
onChange={e => setField({ httpPath: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
<Input type="number" value={p.httpPort ?? ''} placeholder="8080"
onChange={e => setField({ httpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
<Input value={p.httpHost || ''} placeholder="host (可选)"
onChange={e => setField({ httpHost: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
)}
{p.type === 'tcpSocket' && (
<div className="mb-2">
<Input type="number" value={p.tcpPort ?? ''} placeholder="端口"
onChange={e => setField({ tcpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
)}
{p.type === 'exec' && (
<div className="mb-2">
<Textarea value={p.execCommand || ''} placeholder="每行一个命令片段,如:\ncat\n/tmp/healthy"
onChange={e => setField({ execCommand: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[50px]" />
</div>
)}
<div className="grid grid-cols-5 gap-2">
<div>
<Label className="text-[10px] text-slate-500">延迟(s)</Label>
<Input type="number" value={p.initialDelaySeconds ?? ''} placeholder="10"
onChange={e => setField({ initialDelaySeconds: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
<div>
<Label className="text-[10px] text-slate-500">间隔(s)</Label>
<Input type="number" value={p.periodSeconds ?? ''} placeholder="10"
onChange={e => setField({ periodSeconds: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
<div>
<Label className="text-[10px] text-slate-500">超时(s)</Label>
<Input type="number" value={p.timeoutSeconds ?? ''} placeholder="1"
onChange={e => setField({ timeoutSeconds: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
<div>
<Label className="text-[10px] text-slate-500">成功</Label>
<Input type="number" value={p.successThreshold ?? ''} placeholder="1"
onChange={e => setField({ successThreshold: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
<div>
<Label className="text-[10px] text-slate-500">失败</Label>
<Input type="number" value={p.failureThreshold ?? ''} placeholder="3"
onChange={e => setField({ failureThreshold: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
</div>
</>
)}
</div>
);
}
// ============ Lifecycle Handler Editor ============
function LifecycleHandlerEditor({ label, handler, onChange }: {
label: string;
handler: LifecycleHandler | undefined;
onChange: (h: LifecycleHandler) => void;
}) {
const h = handler || { type: 'none' as const };
const selVal = (!h.type || h.type === 'none') ? '__none' : h.type;
return (
<div className="border border-slate-800 rounded-md p-2.5 bg-slate-950/30">
<span className="text-xs text-slate-300 font-medium block mb-2">{label}</span>
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] text-slate-500 w-8">类型</span>
<Select value={selVal} onValueChange={v => onChange({ ...h, type: (v === '__none' ? 'none' : v) as LifecycleHandler['type'] })}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-700">
<SelectItem value="__none" className="text-slate-400">不设置</SelectItem>
<SelectItem value="exec" className="text-slate-200">Exec 命令</SelectItem>
<SelectItem value="httpGet" className="text-slate-200">HTTP GET</SelectItem>
<SelectItem value="tcpSocket" className="text-slate-200">TCP Socket</SelectItem>
</SelectContent>
</Select>
</div>
{h.type === 'exec' && (
<Textarea value={h.execCommand || ''} placeholder="每行一个命令片段"
onChange={e => onChange({ ...h, execCommand: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[50px]" />
)}
{h.type === 'httpGet' && (
<div className="grid grid-cols-2 gap-2">
<Input value={h.httpPath || ''} placeholder="/shutdown"
onChange={e => onChange({ ...h, httpPath: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
<Input type="number" value={h.httpPort ?? ''} placeholder="8080"
onChange={e => onChange({ ...h, httpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
)}
{h.type === 'tcpSocket' && (
<Input type="number" value={h.tcpPort ?? ''} placeholder="端口"
onChange={e => onChange({ ...h, tcpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
)}
</div>
);
}
// ============ Bool Toggle helper ============
function BoolToggle({ label, value, onChange }: {
label: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-center gap-1.5 cursor-pointer">
<Switch checked={value} onCheckedChange={onChange} />
<span className="text-[11px] text-slate-400 font-mono">{label}</span>
</label>
);
}
}
@@ -0,0 +1,161 @@
// DnsHostSection.tsx — DNS 配置 + 主机别名 // Logan.Li
import { Globe, MapPin, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface DnsOptionEntry {
name: string;
value: string;
}
export interface HostAliasEntry {
ip: string;
hostnames: string;
}
export interface DnsHostSectionProps {
dnsNameservers: string;
onDnsNameserversChange: (value: string) => void;
dnsSearches: string;
onDnsSearchesChange: (value: string) => void;
dnsOptions: DnsOptionEntry[];
onDnsOptionsChange: (options: DnsOptionEntry[]) => void;
hostAliases: HostAliasEntry[];
onHostAliasesChange: (aliases: HostAliasEntry[]) => void;
}
export function DnsHostSection({
dnsNameservers,
onDnsNameserversChange,
dnsSearches,
onDnsSearchesChange,
dnsOptions,
onDnsOptionsChange,
hostAliases,
onHostAliasesChange,
}: DnsHostSectionProps) {
const addDnsOption = () => onDnsOptionsChange([...dnsOptions, { name: '', value: '' }]);
const updateDnsOption = (index: number, patch: Partial<DnsOptionEntry>) =>
onDnsOptionsChange(dnsOptions.map((o, i) => (i === index ? { ...o, ...patch } : o)));
const removeDnsOption = (index: number) =>
onDnsOptionsChange(dnsOptions.filter((_, i) => i !== index));
const addHostAlias = () => onHostAliasesChange([...hostAliases, { ip: '', hostnames: '' }]);
const updateHostAlias = (index: number, patch: Partial<HostAliasEntry>) =>
onHostAliasesChange(hostAliases.map((a, i) => (i === index ? { ...a, ...patch } : a)));
const removeHostAlias = (index: number) =>
onHostAliasesChange(hostAliases.filter((_, i) => i !== index));
return (
<>
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Globe className="h-4 w-4 text-indigo-400" />DNS 配置 (dnsConfig)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<p className="text-[11px] text-slate-500">
提示:dnsConfig 通常与 <code className="text-slate-400">dnsPolicy: None</code> 搭配使用。每行一个值。
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-slate-500 text-xs">nameservers(每行一个 IP)</Label>
<textarea
value={dnsNameservers}
onChange={e => onDnsNameserversChange(e.target.value)}
placeholder={'8.8.8.8\n114.114.114.114'}
rows={3}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 font-mono p-2 resize-y"
/>
</div>
<div>
<Label className="text-slate-500 text-xs">searches(搜索域,每行一个)</Label>
<textarea
value={dnsSearches}
onChange={e => onDnsSearchesChange(e.target.value)}
placeholder={'svc.cluster.local\ncluster.local'}
rows={3}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 font-mono p-2 resize-y"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">options(DNS 选项)</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={addDnsOption}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{dnsOptions.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : (
dnsOptions.map((o, i) => (
<div key={i} className="flex items-center gap-2 mb-1.5">
<Input
value={o.name}
onChange={e => updateDnsOption(i, { name: e.target.value })}
placeholder="ndots"
className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono"
/>
<span className="text-slate-600">:</span>
<Input
value={o.value}
onChange={e => updateDnsOption(i, { value: e.target.value })}
placeholder="5(可留空)"
className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono"
/>
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400" onClick={() => removeDnsOption(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<MapPin className="h-4 w-4 text-amber-400" />主机别名 (hostAliases)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">IP → 主机名映射(注入 Pod 的 /etc/hosts)</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={addHostAlias}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{hostAliases.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : (
hostAliases.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={a.ip}
onChange={e => updateHostAlias(i, { ip: e.target.value })}
placeholder="10.10.10.72"
className="w-40 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono"
/>
<span className="text-slate-600">→</span>
<Input
value={a.hostnames}
onChange={e => updateHostAlias(i, { hostnames: e.target.value })}
placeholder="gitlab.local, registry.local"
className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono"
/>
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400" onClick={() => removeHostAlias(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
)}
</CardContent>
</Card>
</>
);
}
@@ -0,0 +1,212 @@
// SchedulingSection.tsx — 调度:节点名称 / 标签选择器 / 节点亲和性 // Logan.Li
import { Loader2, Plus, Server, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
export type ScheduleMode = 'nodeName' | 'nodeSelector' | 'nodeAffinity';
export interface LabelSelectorEntry {
key: string;
value: string;
}
export interface ClusterNodeOption {
name: string;
ip: string;
labels: Record<string, string>;
}
export interface SchedulingSectionProps {
scheduleMode: ScheduleMode;
onScheduleModeChange: (mode: ScheduleMode) => void;
nodeName: string;
onNodeNameChange: (name: string) => void;
availableNodes: ClusterNodeOption[];
nodesLoading: boolean;
labelSelectorKv: LabelSelectorEntry[];
onAddLabelSelector: () => void;
onRemoveLabelSelector: (index: number) => void;
onUpdateLabelSelector: (index: number, patch: Partial<LabelSelectorEntry>) => void;
nodeLabels: Record<string, string[]>;
}
const SCHEDULE_MODES: Array<{ key: ScheduleMode; label: string }> = [
{ key: 'nodeName', label: '节点名称' },
{ key: 'nodeSelector', label: '标签选择器' },
{ key: 'nodeAffinity', label: '节点亲和性' },
];
export function SchedulingSection({
scheduleMode,
onScheduleModeChange,
nodeName,
onNodeNameChange,
availableNodes,
nodesLoading,
labelSelectorKv,
onAddLabelSelector,
onRemoveLabelSelector,
onUpdateLabelSelector,
nodeLabels,
}: SchedulingSectionProps) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Server className="h-4 w-4 text-emerald-400" />调度 (Scheduling)
</CardTitle>
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-0.5 border border-slate-700">
{SCHEDULE_MODES.map(m => (
<button
key={m.key}
onClick={() => onScheduleModeChange(m.key)}
className={`px-2.5 py-1 rounded text-[10px] font-medium transition-all ${
scheduleMode === m.key
? 'bg-emerald-500/20 text-emerald-300 shadow-sm'
: 'text-slate-400 hover:text-slate-300'
}`}
>
{m.label}
</button>
))}
</div>
</div>
<p className="text-xs text-slate-500 mt-1">
{scheduleMode === 'nodeName' && 'Pod 将直接调度到指定节点'}
{scheduleMode === 'nodeSelector' && 'Pod 将调度到匹配标签的节点'}
{scheduleMode === 'nodeAffinity' && '通过亲和性规则控制 Pod 调度到指定节点'}
</p>
</CardHeader>
<CardContent className="pt-0">
{scheduleMode === 'nodeName' && (
<div className="space-y-2">
<Label className="text-slate-400 text-xs">请选择节点</Label>
{nodesLoading ? (
<div className="flex items-center gap-1 text-slate-500 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />加载节点列表...
</div>
) : (
<select
value={nodeName}
onChange={e => onNodeNameChange(e.target.value)}
className="h-8 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2"
>
<option value="">(不指定)</option>
{availableNodes.map(n => (
<option key={n.name} value={n.name}>
{n.name} {n.ip ? `(${n.ip})` : ''}
</option>
))}
</select>
)}
{nodeName && (
<div className="text-[10px] text-slate-500">
已选择: <code className="bg-slate-800 px-1 py-0.5 rounded text-emerald-400">{nodeName}</code>
</div>
)}
</div>
)}
{scheduleMode === 'nodeSelector' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-400 text-xs">请选择标签</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={onAddLabelSelector}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{labelSelectorKv.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置标签选择器</p>
) : (
labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2">
<select
value={n.key}
onChange={e => onUpdateLabelSelector(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
>
<option value="">选择标签</option>
{Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option>
))}
</select>
<select
value={n.value}
onChange={e => onUpdateLabelSelector(i, { value: e.target.value })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
disabled={!n.key}
>
<option value="">选择值</option>
{(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-400"
onClick={() => onRemoveLabelSelector(i)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
)}
{scheduleMode === 'nodeAffinity' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-400 text-xs">
请选择标签 <span className="text-slate-600">(生成 requiredDuringScheduling 规则)</span>
</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={onAddLabelSelector}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{labelSelectorKv.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置亲和性规则</p>
) : (
labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2">
<select
value={n.key}
onChange={e => onUpdateLabelSelector(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
>
<option value="">选择标签</option>
{Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option>
))}
</select>
<select
value={n.value}
onChange={e => onUpdateLabelSelector(i, { value: e.target.value })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
disabled={!n.key}
>
<option value="">选择值</option>
{(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-400"
onClick={() => onRemoveLabelSelector(i)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,453 @@
// ContainerEditor.tsx — 单容器完整编辑(基本/命令/环境变量/端口/资源/探针/生命周期/安全/卷挂载)
// 作者: Logan <admin@attacker.club>
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import {
Plus,
Trash2,
Cpu,
Heart,
FolderOpen,
Terminal,
Shield,
Zap,
Network,
} from 'lucide-react';
import { arrToText, textToArr } from './container-utils';
import type {
ContainerEditorProps, ProbeEditorProps, LifecycleHandlerEditorProps,
BoolToggleProps, ProbeDef, LifecycleHandler,
} from './types';
export function ContainerEditor({ container, onUpdate }: ContainerEditorProps) {
const c = container;
const sectionBtn = "flex items-center gap-1.5 text-xs font-semibold text-slate-300 mb-2";
return (
<>
{/* ---- 基本设置 ---- */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-slate-400 text-xs">容器名称</Label>
<Input value={c.name} onChange={e => onUpdate({ name: e.target.value })}
placeholder="my-app"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-slate-400 text-xs">容器镜像</Label>
<Input value={c.image} onChange={e => onUpdate({ image: e.target.value })}
placeholder="nginx:1.25"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-slate-400 text-xs">镜像拉取策略</Label>
<Select value={c.imagePullPolicy || '__none'} onValueChange={v => onUpdate({ imagePullPolicy: v === '__none' ? '' : v })}>
<SelectTrigger className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-700">
<SelectItem value="__none" className="text-slate-400">不设置(默认 IfNotPresent)</SelectItem>
<SelectItem value="Always" className="text-slate-200">Always</SelectItem>
<SelectItem value="IfNotPresent" className="text-slate-200">IfNotPresent</SelectItem>
<SelectItem value="Never" className="text-slate-200">Never</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-slate-400 text-xs">工作目录 (workingDir)</Label>
<Input value={c.workingDir || ''} onChange={e => onUpdate({ workingDir: e.target.value })}
placeholder="/app"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
</div>
{/* ---- 命令与参数 ---- */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-slate-400 text-xs">启动命令 (command) — 每行一个</Label>
<Textarea value={arrToText(c.command)} placeholder={"/bin/sh\n-c"}
onChange={e => onUpdate({ command: textToArr(e.target.value) })}
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[60px]" />
</div>
<div>
<Label className="text-slate-400 text-xs">启动参数 (args) — 每行一个</Label>
<Textarea value={arrToText(c.args)} placeholder={"--config=/etc/app.yaml\n--debug"}
onChange={e => onUpdate({ args: textToArr(e.target.value) })}
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[60px]" />
</div>
</div>
{/* ---- 环境变量 ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<div className={sectionBtn}><Terminal className="h-3.5 w-3.5 text-cyan-400" /> 环境变量</div>
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
onClick={() => onUpdate({ env: [...c.env, { name: '', value: '' }] })}>
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
<div className="space-y-1.5">
{c.env.map((env, ei) => (
<div key={ei} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={env.name} placeholder="KEY"
onChange={e => { const n = [...c.env]; n[ei] = { ...env, name: e.target.value }; onUpdate({ env: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={env.value || ''} placeholder="value"
onChange={e => { const n = [...c.env]; n[ei] = { ...env, value: e.target.value }; onUpdate({ env: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400"
onClick={() => onUpdate({ env: c.env.filter((_, j) => j !== ei) })}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{c.env.length === 0 && <p className="text-[11px] text-slate-600">无环境变量</p>}
</div>
</div>
{/* ---- 端口 ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<div className={sectionBtn}><Network className="h-3.5 w-3.5 text-indigo-400" /> 端口</div>
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
onClick={() => onUpdate({ ports: [...c.ports, { name: '', containerPort: '', protocol: 'TCP' }] })}>
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
<div className="space-y-1.5">
{c.ports.map((p, pi) => (
<div key={pi} className="grid grid-cols-[1fr_1fr_100px_auto] gap-2">
<Input value={p.name || ''} placeholder="http"
onChange={e => { const n = [...c.ports]; n[pi] = { ...p, name: e.target.value }; onUpdate({ ports: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input type="number" value={p.containerPort} placeholder="8080"
onChange={e => { const n = [...c.ports]; n[pi] = { ...p, containerPort: e.target.value }; onUpdate({ ports: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Select value={p.protocol || 'TCP'} onValueChange={v => { const n = [...c.ports]; n[pi] = { ...p, protocol: v }; onUpdate({ ports: n }); }}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-700">
<SelectItem value="TCP" className="text-slate-200">TCP</SelectItem>
<SelectItem value="UDP" className="text-slate-200">UDP</SelectItem>
<SelectItem value="SCTP" className="text-slate-200">SCTP</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400"
onClick={() => onUpdate({ ports: c.ports.filter((_, j) => j !== pi) })}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{c.ports.length === 0 && <p className="text-[11px] text-slate-600">无端口暴露</p>}
</div>
</div>
{/* ---- 资源 requests/limits ---- */}
<div>
<div className={sectionBtn}><Cpu className="h-3.5 w-3.5 text-amber-400" /> 资源 (requests / limits)</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<div>
<Label className="text-[11px] text-slate-500">CPU 请求</Label>
<Input value={c.resources.requests?.cpu || ''} placeholder="100m"
onChange={e => onUpdate({ resources: { ...c.resources, requests: { ...c.resources.requests, cpu: e.target.value } } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">CPU 上限</Label>
<Input value={c.resources.limits?.cpu || ''} placeholder="500m"
onChange={e => onUpdate({ resources: { ...c.resources, limits: { ...c.resources.limits, cpu: e.target.value } } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">内存请求</Label>
<Input value={c.resources.requests?.memory || ''} placeholder="128Mi"
onChange={e => onUpdate({ resources: { ...c.resources, requests: { ...c.resources.requests, memory: e.target.value } } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">内存上限</Label>
<Input value={c.resources.limits?.memory || ''} placeholder="512Mi"
onChange={e => onUpdate({ resources: { ...c.resources, limits: { ...c.resources.limits, memory: e.target.value } } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
</div>
</div>
{/* ---- 健康检查 ---- */}
<div>
<div className={sectionBtn}><Heart className="h-3.5 w-3.5 text-rose-400" /> 健康检查 (Probes)</div>
<div className="space-y-2">
{(['livenessProbe', 'readinessProbe', 'startupProbe'] as const).map(pt => (
<ProbeEditor key={pt}
label={{ livenessProbe: '存活探针 (liveness)', readinessProbe: '就绪探针 (readiness)', startupProbe: '启动探针 (startup)' }[pt]}
probe={c[pt]}
onChange={(p) => onUpdate({ [pt]: p } as Record<string, any>)}
/>
))}
</div>
</div>
{/* ---- 生命周期 ---- */}
<div>
<div className={sectionBtn}><Zap className="h-3.5 w-3.5 text-yellow-400" /> 生命周期回调 (Lifecycle)</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<LifecycleHandlerEditor label="启动后 (postStart)" handler={c.lifecycle?.postStart}
onChange={h => onUpdate({ lifecycle: { ...c.lifecycle, postStart: h } })} />
<LifecycleHandlerEditor label="终止前 (preStop)" handler={c.lifecycle?.preStop}
onChange={h => onUpdate({ lifecycle: { ...c.lifecycle, preStop: h } })} />
</div>
{!c.lifecycle && (
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px] mt-1"
onClick={() => onUpdate({ lifecycle: { postStart: { type: 'none' }, preStop: { type: 'none' } } })}>
<Plus className="h-3 w-3 mr-1" />启用生命周期回调
</Button>
)}
</div>
{/* ---- 安全上下文 ---- */}
<div>
<div className={sectionBtn}><Shield className="h-3.5 w-3.5 text-emerald-400" /> 安全上下文 (SecurityContext)</div>
{c.securityContext ? (
<div className="space-y-2 border border-slate-800 rounded-md p-3 bg-slate-950/30">
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
<div>
<Label className="text-[11px] text-slate-500">runAsUser</Label>
<Input type="number" value={c.securityContext.runAsUser ?? ''} placeholder="1000"
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, runAsUser: e.target.value === '' ? undefined : Number(e.target.value) } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">runAsGroup</Label>
<Input type="number" value={c.securityContext.runAsGroup ?? ''} placeholder="1000"
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, runAsGroup: e.target.value === '' ? undefined : Number(e.target.value) } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">Capabilities Add (逗号分隔)</Label>
<Input value={(c.securityContext.capabilitiesAdd || []).join(',')} placeholder="NET_ADMIN"
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, capabilitiesAdd: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">Capabilities Drop (逗号分隔)</Label>
<Input value={(c.securityContext.capabilitiesDrop || []).join(',')} placeholder="ALL"
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, capabilitiesDrop: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
</div>
<div className="flex items-center gap-4 flex-wrap">
<BoolToggle label="runAsNonRoot" value={!!c.securityContext.runAsNonRoot}
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, runAsNonRoot: v } })} />
<BoolToggle label="privileged" value={!!c.securityContext.privileged}
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, privileged: v } })} />
<BoolToggle label="readOnlyRootFilesystem" value={!!c.securityContext.readOnlyRootFilesystem}
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, readOnlyRootFilesystem: v } })} />
<BoolToggle label="allowPrivilegeEscalation" value={c.securityContext.allowPrivilegeEscalation ?? false}
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, allowPrivilegeEscalation: v } })} />
</div>
<Button size="sm" variant="ghost" className="h-6 text-red-400 text-[11px]"
onClick={() => onUpdate({ securityContext: null })}>
<Trash2 className="h-3 w-3 mr-1" />移除安全上下文
</Button>
</div>
) : (
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
onClick={() => onUpdate({ securityContext: { runAsNonRoot: false, privileged: false } })}>
<Plus className="h-3 w-3 mr-1" />启用安全上下文
</Button>
)}
</div>
{/* ---- 卷挂载 ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<div className={sectionBtn}><FolderOpen className="h-3.5 w-3.5 text-teal-400" /> 卷挂载 (VolumeMounts)</div>
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
onClick={() => onUpdate({ volumeMounts: [...c.volumeMounts, { name: '', mountPath: '', subPath: '', readOnly: false }] })}>
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
<div className="space-y-1.5">
{c.volumeMounts.map((m, mi) => (
<div key={mi} className="grid grid-cols-[1fr_1fr_1fr_60px_auto] gap-2 items-center">
<Input value={m.name} placeholder="volume 名称"
onChange={e => { const n = [...c.volumeMounts]; n[mi] = { ...m, name: e.target.value }; onUpdate({ volumeMounts: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={m.mountPath} placeholder="/data"
onChange={e => { const n = [...c.volumeMounts]; n[mi] = { ...m, mountPath: e.target.value }; onUpdate({ volumeMounts: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={m.subPath || ''} placeholder="subPath (可选)"
onChange={e => { const n = [...c.volumeMounts]; n[mi] = { ...m, subPath: e.target.value }; onUpdate({ volumeMounts: n }); }}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<label className="flex items-center gap-1 text-[10px] text-slate-500 cursor-pointer">
<Switch checked={!!m.readOnly}
onCheckedChange={v => { const n = [...c.volumeMounts]; n[mi] = { ...m, readOnly: v }; onUpdate({ volumeMounts: n }); }} />
RO
</label>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400"
onClick={() => onUpdate({ volumeMounts: c.volumeMounts.filter((_, j) => j !== mi) })}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{c.volumeMounts.length === 0 && <p className="text-[11px] text-slate-600">无卷挂载</p>}
</div>
</div>
</>
);
}
function ProbeEditor({ label, probe, onChange }: ProbeEditorProps) {
const enabled = !!probe;
const p = probe || { type: 'httpGet' as const };
const setField = (patch: Partial<ProbeDef>) => onChange({ ...p, ...patch });
return (
<div className="border border-slate-800 rounded-md p-2.5 bg-slate-950/30">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-300 font-medium">{label}</span>
<Switch checked={enabled}
onCheckedChange={v => onChange(v ? { type: 'httpGet', httpPath: '/', httpPort: 8080, initialDelaySeconds: 10, periodSeconds: 10 } : null)} />
</div>
{enabled && (
<>
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] text-slate-500 w-8">类型</span>
<Select value={p.type || 'httpGet'} onValueChange={v => setField({ type: v as ProbeDef['type'] })}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-700">
<SelectItem value="httpGet" className="text-slate-200">HTTP GET</SelectItem>
<SelectItem value="tcpSocket" className="text-slate-200">TCP Socket</SelectItem>
<SelectItem value="exec" className="text-slate-200">Exec 命令</SelectItem>
</SelectContent>
</Select>
</div>
{p.type === 'httpGet' && (
<div className="grid grid-cols-3 gap-2 mb-2">
<Input value={p.httpPath || ''} placeholder="/healthz"
onChange={e => setField({ httpPath: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
<Input type="number" value={p.httpPort ?? ''} placeholder="8080"
onChange={e => setField({ httpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
<Input value={p.httpHost || ''} placeholder="host (可选)"
onChange={e => setField({ httpHost: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
)}
{p.type === 'tcpSocket' && (
<div className="mb-2">
<Input type="number" value={p.tcpPort ?? ''} placeholder="端口"
onChange={e => setField({ tcpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
)}
{p.type === 'exec' && (
<div className="mb-2">
<Textarea value={p.execCommand || ''} placeholder="每行一个命令片段,如:\ncat\n/tmp/healthy"
onChange={e => setField({ execCommand: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[50px]" />
</div>
)}
<div className="grid grid-cols-5 gap-2">
<div>
<Label className="text-[10px] text-slate-500">延迟(s)</Label>
<Input type="number" value={p.initialDelaySeconds ?? ''} placeholder="10"
onChange={e => setField({ initialDelaySeconds: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
<div>
<Label className="text-[10px] text-slate-500">间隔(s)</Label>
<Input type="number" value={p.periodSeconds ?? ''} placeholder="10"
onChange={e => setField({ periodSeconds: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
<div>
<Label className="text-[10px] text-slate-500">超时(s)</Label>
<Input type="number" value={p.timeoutSeconds ?? ''} placeholder="1"
onChange={e => setField({ timeoutSeconds: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
<div>
<Label className="text-[10px] text-slate-500">成功</Label>
<Input type="number" value={p.successThreshold ?? ''} placeholder="1"
onChange={e => setField({ successThreshold: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
<div>
<Label className="text-[10px] text-slate-500">失败</Label>
<Input type="number" value={p.failureThreshold ?? ''} placeholder="3"
onChange={e => setField({ failureThreshold: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
</div>
</>
)}
</div>
);
}
function LifecycleHandlerEditor({ label, handler, onChange }: LifecycleHandlerEditorProps) {
const h = handler || { type: 'none' as const };
const selVal = (!h.type || h.type === 'none') ? '__none' : h.type;
return (
<div className="border border-slate-800 rounded-md p-2.5 bg-slate-950/30">
<span className="text-xs text-slate-300 font-medium block mb-2">{label}</span>
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] text-slate-500 w-8">类型</span>
<Select value={selVal} onValueChange={v => onChange({ ...h, type: (v === '__none' ? 'none' : v) as LifecycleHandler['type'] })}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-700">
<SelectItem value="__none" className="text-slate-400">不设置</SelectItem>
<SelectItem value="exec" className="text-slate-200">Exec 命令</SelectItem>
<SelectItem value="httpGet" className="text-slate-200">HTTP GET</SelectItem>
<SelectItem value="tcpSocket" className="text-slate-200">TCP Socket</SelectItem>
</SelectContent>
</Select>
</div>
{h.type === 'exec' && (
<Textarea value={h.execCommand || ''} placeholder="每行一个命令片段"
onChange={e => onChange({ ...h, execCommand: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[50px]" />
)}
{h.type === 'httpGet' && (
<div className="grid grid-cols-2 gap-2">
<Input value={h.httpPath || ''} placeholder="/shutdown"
onChange={e => onChange({ ...h, httpPath: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
<Input type="number" value={h.httpPort ?? ''} placeholder="8080"
onChange={e => onChange({ ...h, httpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
</div>
)}
{h.type === 'tcpSocket' && (
<Input type="number" value={h.tcpPort ?? ''} placeholder="端口"
onChange={e => onChange({ ...h, tcpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
)}
</div>
);
}
function BoolToggle({ label, value, onChange }: BoolToggleProps) {
return (
<label className="flex items-center gap-1.5 cursor-pointer">
<Switch checked={value} onCheckedChange={onChange} />
<span className="text-[11px] text-slate-400 font-mono">{label}</span>
</label>
);
}
@@ -0,0 +1,90 @@
// ContainerGroupSection.tsx — 容器分组(初始化/工作容器)折叠列表
// 作者: Logan <admin@attacker.club>
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Plus,
Trash2,
Container,
ChevronDown,
ChevronRight,
RotateCw,
} from 'lucide-react';
import { ContainerEditor } from './ContainerEditor';
import type { ContainerGroupSectionProps } from './types';
export function ContainerGroupSection({
title, description, icon, containers, accentColor,
onAdd, onRemove, onUpdate, onDuplicate,
}: ContainerGroupSectionProps) {
const [openIndices, setOpenIndices] = useState<Set<number>>(new Set([0]));
const toggleOpen = (idx: number) => {
setOpenIndices(prev => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx); else next.add(idx);
return next;
});
};
const borderClass = accentColor === 'blue' ? 'border-blue-500/30' : 'border-purple-500/30';
const headerBg = accentColor === 'blue' ? 'bg-blue-500/5' : 'bg-purple-500/5';
return (
<Card className={`bg-slate-900 border-slate-800`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-sm text-slate-200">{title}</CardTitle>
<Badge variant="outline" className={`border-slate-700 text-slate-400 text-[10px]`}>{containers.length}</Badge>
</div>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={onAdd}>
<Plus className="h-3 w-3 mr-1" />新增容器
</Button>
</div>
<p className="text-[11px] text-slate-600">{description}</p>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{containers.length === 0 && (
<div className="text-center py-6 text-slate-600 text-xs">暂无容器,点击「新增容器」添加</div>
)}
{containers.map((c, idx) => {
const isOpen = openIndices.has(idx) || containers.length === 1;
return (
<div key={idx} className={`rounded-lg border ${borderClass} overflow-hidden`}>
{/* container header */}
<div className={`flex items-center justify-between px-3 py-2 ${headerBg} cursor-pointer`} onClick={() => toggleOpen(idx)}>
<div className="flex items-center gap-2">
{isOpen ? <ChevronDown className="h-4 w-4 text-slate-500" /> : <ChevronRight className="h-4 w-4 text-slate-500" />}
<Container className={`h-4 w-4 ${accentColor === 'blue' ? 'text-blue-400' : 'text-purple-400'}`} />
<span className="text-sm font-mono text-slate-200 font-semibold">{c.name || '(未命名)'}</span>
{c.image && <code className="text-[10px] text-slate-500 font-mono truncate max-w-[300px]">{c.image}</code>}
</div>
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
{onDuplicate && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-500 hover:text-slate-300" title="复制"
onClick={() => onDuplicate(idx)}>
<RotateCw className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" title="删除"
onClick={() => { if (confirm(`确认删除容器「${c.name}」?`)) onRemove(idx); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* container body */}
{isOpen && (
<div className="p-3 space-y-4 bg-slate-950/30">
<ContainerEditor container={c} onUpdate={(patch) => onUpdate(idx, patch)} />
</div>
)}
</div>
);
})}
</CardContent>
</Card>
);
}
@@ -0,0 +1,213 @@
// container-utils.ts — K8s container spec ↔ form 转换工具
// 作者: Logan <admin@attacker.club>
import type {
ContainerForm, ProbeDef, LifecycleHandler,
} from './types';
// K8s container spec → form conversion // Logan.Li
export 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';
else if (p.tcpSocket) type = 'tcpSocket';
else if (p.exec) type = 'exec';
return {
type,
httpPath: p.httpGet?.path || '',
httpPort: p.httpGet?.port ?? '',
httpHost: p.httpGet?.host || '',
tcpPort: p.tcpSocket?.port ?? '',
execCommand: Array.isArray(p.exec?.command) ? p.exec.command.join('\n') : (p.exec?.command || ''),
initialDelaySeconds: p.initialDelaySeconds,
periodSeconds: p.periodSeconds,
timeoutSeconds: p.timeoutSeconds,
successThreshold: p.successThreshold,
failureThreshold: p.failureThreshold,
};
};
const lh = (h: any): LifecycleHandler => {
if (!h) return { type: 'none' };
let type: LifecycleHandler['type'] = 'none';
if (h.exec) type = 'exec';
else if (h.httpGet) type = 'httpGet';
else if (h.tcpSocket) type = 'tcpSocket';
return {
type,
execCommand: Array.isArray(h.exec?.command) ? h.exec.command.join('\n') : '',
httpPath: h.httpGet?.path || '',
httpPort: h.httpGet?.port ?? '',
tcpPort: h.tcpSocket?.port ?? '',
};
};
const sc = c.securityContext;
return {
name: c.name || '',
image: c.image || '',
imagePullPolicy: c.imagePullPolicy || '',
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' })),
resources: {
requests: c.resources?.requests || {},
limits: c.resources?.limits || {},
},
livenessProbe: probe(c.livenessProbe),
readinessProbe: probe(c.readinessProbe),
startupProbe: probe(c.startupProbe),
lifecycle: c.lifecycle ? {
postStart: c.lifecycle.postStart ? lh(c.lifecycle.postStart) : undefined,
preStop: c.lifecycle.preStop ? lh(c.lifecycle.preStop) : undefined,
} : null,
securityContext: sc ? {
runAsUser: sc.runAsUser,
runAsGroup: sc.runAsGroup,
runAsNonRoot: sc.runAsNonRoot,
privileged: sc.privileged,
readOnlyRootFilesystem: sc.readOnlyRootFilesystem,
allowPrivilegeEscalation: sc.allowPrivilegeEscalation,
capabilitiesAdd: sc.capabilities?.add || [],
capabilitiesDrop: sc.capabilities?.drop || [],
} : null,
volumeMounts: (Array.isArray(c.volumeMounts) ? c.volumeMounts : []).map((m: any) => ({
name: m.name || '', mountPath: m.mountPath || '', subPath: m.subPath || '', readOnly: m.readOnly || false,
})),
};
}
// form → K8s container spec // Logan.Li
export function emptyContainer(name = ''): ContainerForm {
return {
name, image: '', imagePullPolicy: 'IfNotPresent',
command: [], args: [], workingDir: '',
env: [], ports: [], resources: {},
livenessProbe: null, readinessProbe: null, startupProbe: null,
lifecycle: null, securityContext: null, volumeMounts: [],
};
}
export function probeToK8s(p: ProbeDef | null | undefined): any {
if (!p || p.type === 'none' || !p.type) return undefined;
const out: Record<string, any> = {};
if (p.type === 'httpGet') {
out.httpGet = {};
if (p.httpPath) out.httpGet.path = p.httpPath;
if (p.httpPort !== '' && p.httpPort != null) out.httpGet.port = typeof p.httpPort === 'number' ? p.httpPort : Number(p.httpPort);
if (p.httpHost) out.httpGet.host = p.httpHost;
} else if (p.type === 'tcpSocket') {
out.tcpSocket = {};
if (p.tcpPort !== '' && p.tcpPort != null) out.tcpSocket.port = typeof p.tcpPort === 'number' ? p.tcpPort : Number(p.tcpPort);
} else if (p.type === 'exec') {
const cmds = (p.execCommand || '').split('\n').map(s => s.trim()).filter(Boolean);
if (cmds.length) out.exec = { command: cmds };
}
if (p.initialDelaySeconds != null) out.initialDelaySeconds = p.initialDelaySeconds;
if (p.periodSeconds != null) out.periodSeconds = p.periodSeconds;
if (p.timeoutSeconds != null) out.timeoutSeconds = p.timeoutSeconds;
if (p.successThreshold != null) out.successThreshold = p.successThreshold;
if (p.failureThreshold != null) out.failureThreshold = p.failureThreshold;
return Object.keys(out).length > 0 ? out : undefined;
}
export function handlerToK8s(h: LifecycleHandler | undefined): any {
if (!h || h.type === 'none' || !h.type) return undefined;
if (h.type === 'exec') {
const cmds = (h.execCommand || '').split('\n').map(s => s.trim()).filter(Boolean);
return cmds.length ? { exec: { command: cmds } } : undefined;
}
if (h.type === '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: Record<string, any> = { tcpSocket: {} };
if (h.tcpPort !== '' && h.tcpPort != null) o.tcpSocket.port = Number(h.tcpPort);
return o;
}
return undefined;
}
export function containerToK8s(c: ContainerForm): any {
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;
if (c.command.length > 0) out.command = c.command;
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: 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: 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;
});
}
const req = c.resources.requests;
const lim = c.resources.limits;
const hasReq = req && Object.values(req).some(v => v && v.trim());
const hasLim = lim && Object.values(lim).some(v => v && v.trim());
if (hasReq || hasLim) {
out.resources = {};
if (hasReq) {
out.resources.requests = {};
if (req!.cpu?.trim()) out.resources.requests.cpu = req!.cpu;
if (req!.memory?.trim()) out.resources.requests.memory = req!.memory;
}
if (hasLim) {
out.resources.limits = {};
if (lim!.cpu?.trim()) out.resources.limits.cpu = lim!.cpu;
if (lim!.memory?.trim()) out.resources.limits.memory = lim!.memory;
}
}
const lp = probeToK8s(c.livenessProbe); if (lp) out.livenessProbe = lp;
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: 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: 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;
if (s.privileged != null) sc.privileged = s.privileged;
if (s.readOnlyRootFilesystem != null) sc.readOnlyRootFilesystem = s.readOnlyRootFilesystem;
if (s.allowPrivilegeEscalation != null) sc.allowPrivilegeEscalation = s.allowPrivilegeEscalation;
const capAdd = (s.capabilitiesAdd || []).filter(Boolean);
const capDrop = (s.capabilitiesDrop || []).filter(Boolean);
if (capAdd.length || capDrop.length) {
sc.capabilities = {};
if (capAdd.length) sc.capabilities.add = capAdd;
if (capDrop.length) sc.capabilities.drop = capDrop;
}
if (Object.keys(sc).length > 0) out.securityContext = sc;
}
if (c.volumeMounts.length > 0) {
out.volumeMounts = c.volumeMounts.filter(m => m.name.trim() && m.mountPath.trim()).map(m => {
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;
});
}
return out;
}
// String array helper (command/args editing) // Logan.Li
export function arrToText(arr: string[]): string { return arr.join('\n'); }
export function textToArr(text: string): string[] { return text.split('\n').map(s => s.trim()).filter(Boolean); }
@@ -0,0 +1,105 @@
// deployment-edit/types.ts — 容器编辑器类型定义
// 作者: Logan <admin@attacker.club>
export interface EnvVar { name: string; value?: string; }
export interface PortDef { name?: string; containerPort: number | string; protocol?: string; }
export interface VolumeMount { name: string; mountPath: string; subPath?: string; readOnly?: boolean; }
export interface ResourceMap { cpu?: string; memory?: string; }
export interface ProbeDef {
type?: 'httpGet' | 'tcpSocket' | 'exec' | 'none';
// httpGet
httpPath?: string; httpPort?: number | string; httpHost?: string;
// tcpSocket
tcpPort?: number | string;
// exec
execCommand?: string;
// timing
initialDelaySeconds?: number; periodSeconds?: number;
timeoutSeconds?: number; successThreshold?: number; failureThreshold?: number;
}
export interface LifecycleHandler {
type?: 'exec' | 'httpGet' | 'tcpSocket' | 'none';
execCommand?: string;
httpPath?: string; httpPort?: number | string;
tcpPort?: number | string;
}
export interface LifecycleDef { postStart?: LifecycleHandler; preStop?: LifecycleHandler; }
export interface SecurityContext {
runAsUser?: number | string;
runAsGroup?: number | string;
runAsNonRoot?: boolean;
privileged?: boolean;
readOnlyRootFilesystem?: boolean;
allowPrivilegeEscalation?: boolean;
capabilitiesAdd?: string[];
capabilitiesDrop?: string[];
}
export interface ContainerForm {
name: string;
image: string;
imagePullPolicy?: string;
command: string[];
args: string[];
workingDir?: string;
env: EnvVar[];
ports: PortDef[];
resources: { requests?: ResourceMap; limits?: ResourceMap; };
livenessProbe?: ProbeDef | null;
readinessProbe?: ProbeDef | null;
startupProbe?: ProbeDef | null;
lifecycle?: LifecycleDef | null;
securityContext?: SecurityContext | null;
volumeMounts: VolumeMount[];
}
export interface DeploymentEditContainersProps {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
export interface ContainerGroupSectionProps {
title: string;
description: string;
icon: React.ReactNode;
containers: ContainerForm[];
accentColor: 'blue' | 'purple';
onAdd: () => void;
onRemove: (idx: number) => void;
onUpdate: (idx: number, patch: Partial<ContainerForm>) => void;
onDuplicate?: (idx: number) => void;
}
export interface ContainerEditorProps {
container: ContainerForm;
onUpdate: (patch: Partial<ContainerForm>) => void;
}
export interface ProbeEditorProps {
label: string;
probe: ProbeDef | null | undefined;
onChange: (p: ProbeDef | null) => void;
}
export interface LifecycleHandlerEditorProps {
label: string;
handler: LifecycleHandler | undefined;
onChange: (h: LifecycleHandler) => void;
}
export interface BoolToggleProps {
label: string;
value: boolean;
onChange: (v: boolean) => void;
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -12,26 +12,10 @@ import {
TableRow,
} from '@/components/ui/table';
import { Search, RefreshCw, Trash2, Play } from 'lucide-react';
import { terminalSessionApi } from '@/services/api';
import { useTerminalSessions, useTerminalSessionMutations, type TerminalSession } from '@/hooks/api/useTerminalSessions';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { ReplayDialog } from './ReplayDialog';
interface TerminalSession {
id: number;
user_id?: number;
username: string;
server_id: number;
server_name: string;
server_ip: string;
server_port: number;
client_ip: string;
status: string;
duration: number;
record_size: number;
start_time: string;
end_time?: string | null;
}
function formatDuration(sec: number): string {
if (!sec || sec < 0) return '--';
if (sec < 60) return `${sec}秒`;
@@ -55,30 +39,14 @@ function formatSize(bytes: number): string {
*/
export function TerminalSessionsTab() {
const confirm = useConfirm();
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [loading, setLoading] = useState(true);
const { data: sessions = [], isLoading: loading, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
const { deleteMutation, batchDeleteMutation } = useTerminalSessionMutations();
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [selected, setSelected] = useState<Set<number>>(new Set());
const [replayId, setReplayId] = useState<number | null>(null);
const [replayOpen, setReplayOpen] = useState(false);
const fetchSessions = async () => {
setLoading(true);
try {
const resp = await terminalSessionApi.list({ page: 1, page_size: 100 }) as any;
setSessions(resp?.list ?? []);
} catch {
setSessions([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSessions();
}, []);
const filtered = sessions.filter((s) => {
const kw = keyword.toLowerCase();
const kwMatch =
@@ -142,13 +110,12 @@ export function TerminalSessionsTab() {
});
if (!ok) return;
try {
await terminalSessionApi.delete(id);
await deleteMutation.mutateAsync(id);
setSelected((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
await fetchSessions();
} catch {
/* noop */
}
@@ -164,9 +131,8 @@ export function TerminalSessionsTab() {
});
if (!ok) return;
try {
await terminalSessionApi.batchDelete(Array.from(selected));
await batchDeleteMutation.mutateAsync(Array.from(selected));
setSelected(new Set());
await fetchSessions();
} catch {
/* noop */
}
@@ -207,7 +173,7 @@ export function TerminalSessionsTab() {
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={fetchSessions}
onClick={() => refetch()}
>
<RefreshCw className="h-4 w-4 mr-2" />
刷新
+11
View File
@@ -0,0 +1,11 @@
// TanStack Query key 工厂 — 统一缓存键 // Logan.Li
export const queryKeys = {
terminalSessions: (params?: Record<string, unknown>) => ['terminal-sessions', params] as const,
hostMonitor: {
hosts: () => ['host-monitor', 'hosts'] as const,
history: (hostname: string, hours: number) => ['host-monitor', 'history', hostname, hours] as const,
probes: (hostname: string) => ['host-monitor', 'probes', hostname] as const,
portPolicy: (hostname: string) => ['host-monitor', 'port-policy', hostname] as const,
},
};
+66
View File
@@ -0,0 +1,66 @@
// 主机监控数据 — TanStack Query // Logan.Li
import { useQuery } from '@tanstack/react-query';
import { agentApi } from '@/services/api';
import { queryKeys } from './queryKeys';
function unwrapList<T>(data: unknown): T[] {
if (Array.isArray(data)) return data;
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>;
if (Array.isArray(obj.list)) return obj.list as T[];
if (Array.isArray(obj.data)) return obj.data as T[];
}
return [];
}
export function useHostList() {
return useQuery({
queryKey: queryKeys.hostMonitor.hosts(),
queryFn: async () => {
const data = await agentApi.getLatest();
return unwrapList<Record<string, unknown>>(data);
},
staleTime: 30_000,
});
}
export function useHostHistory(hostname: string, hours: number) {
return useQuery({
queryKey: queryKeys.hostMonitor.history(hostname, hours),
queryFn: async () => {
if (!hostname) return [];
const data = await agentApi.getHistory(hostname, hours);
return unwrapList<Record<string, unknown>>(data);
},
enabled: !!hostname,
staleTime: 30_000,
});
}
export function useHealthProbes(hostname: string) {
return useQuery({
queryKey: queryKeys.hostMonitor.probes(hostname),
queryFn: async () => {
if (!hostname) return [];
const data = await agentApi.getHealthProbes({ hostname });
return unwrapList<Record<string, unknown>>(data);
},
enabled: !!hostname,
});
}
export function usePortPolicy(hostname: string) {
return useQuery({
queryKey: queryKeys.hostMonitor.portPolicy(hostname),
queryFn: async () => {
if (!hostname) return { port_filter_mode: 'whitelist', port_whitelist: [] as number[] };
const data = await agentApi.getPortPolicy(hostname) as { data?: { port_filter_mode?: string; port_whitelist?: number[] } };
return {
port_filter_mode: data?.data?.port_filter_mode || 'blacklist',
port_whitelist: data?.data?.port_whitelist || [],
};
},
enabled: !!hostname,
});
}
@@ -0,0 +1,70 @@
// 终端会话列表 — TanStack Query // Logan.Li
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { terminalSessionApi } from '@/services/api';
import { queryKeys } from './queryKeys';
export interface TerminalSession {
id: number;
user_id?: number;
username: string;
server_id: number;
server_name: string;
server_ip: string;
server_port: number;
client_ip: string;
status: string;
duration: number;
record_size: number;
start_time: string;
end_time?: string | null;
}
type ListParams = {
page?: number;
page_size?: number;
server_id?: number;
username?: string;
status?: string;
};
function unwrapList<T>(data: unknown): T[] {
if (Array.isArray(data)) return data;
if (data && typeof data === 'object' && 'list' in data) {
return (data as { list: T[] }).list ?? [];
}
return [];
}
export function useTerminalSessions(params: ListParams = { page: 1, page_size: 100 }) {
return useQuery({
queryKey: queryKeys.terminalSessions(params),
queryFn: async () => {
const resp = await terminalSessionApi.list(params);
return unwrapList<TerminalSession>(resp);
},
});
}
export function useTerminalSessionMutations() {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['terminal-sessions'] });
const killMutation = useMutation({
mutationFn: (id: number) => terminalSessionApi.kill(id),
onSuccess: invalidate,
});
const deleteMutation = useMutation({
mutationFn: (id: number) => terminalSessionApi.delete(id),
onSuccess: invalidate,
});
const batchDeleteMutation = useMutation({
mutationFn: (ids: number[]) => terminalSessionApi.batchDelete(ids),
onSuccess: invalidate,
});
return { killMutation, deleteMutation, batchDeleteMutation, invalidate };
}
+25
View File
@@ -0,0 +1,25 @@
// 客户端导出工具 — CSV / JSON 下载 // Logan.Li
export function exportAsCSV(filename: string, data: Record<string, unknown>[]): void {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csv = [
headers.join(','),
...data.map((row) => headers.map((h) => JSON.stringify(row[h] ?? '')).join(',')),
].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
export function exportAsJSON(filename: string, data: unknown[]): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
+67 -681
View File
@@ -1,127 +1,62 @@
// 主机监控详情 — 阿里云云监控风格 + v2.0 新增指标
// 进程TOP / Swap / FD / 上下文切换
import { useState, useEffect, useCallback, useMemo } from 'react';
// 进程TOP / Swap / FD / 上下文切换 // Logan.Li
import { useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/services/api';
import { useHostList, useHostHistory, usePortPolicy } from '@/hooks/api/useHostMonitor';
import { queryKeys } from '@/hooks/api/queryKeys';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
Cpu, HardDrive, Network, Activity, Server, Loader2,
Info, Monitor, Disc, Clock, List, HeartPulse, Plus, Trash2, RefreshCw, Edit, CheckCircle, XCircle,
Info, Monitor, Disc, Clock, List, HeartPulse, RefreshCw, XCircle,
} from 'lucide-react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Brush,
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
// 时间范围配置
const TIME_RANGES = [
{ label: '1小时', hours: 1, window: 60, buckets: 60 },
{ label: '6小时', hours: 6, window: 300, buckets: 72 },
{ label: '12小时', hours: 12, window: 600, buckets: 72 },
{ label: '1天', hours: 24, window: 1800, buckets: 96 },
{ label: '3天', hours: 72, window: 3600, buckets: 72 },
{ label: '7天', hours: 168, window: 10800, buckets: 56 },
{ label: '14天', hours: 336, window: 21600, buckets: 48 },
] as const;
type AggType = 'avg' | 'max' | 'min';
interface BucketPoint {
time: string; timestamp: number; avg: number; max: number; min: number; count: number;
}
function bucketWithStats(raw: number[], timestamps: number[], targetBuckets: number): BucketPoint[] {
if (!raw || raw.length === 0) return [];
const perBucket = Math.max(1, Math.ceil(raw.length / targetBuckets));
const result: BucketPoint[] = [];
for (let i = 0; i < raw.length; i += perBucket) {
const slice = raw.slice(i, i + perBucket);
const tsSlice = timestamps.slice(i, i + perBucket);
const sum = slice.reduce((a, b) => a + b, 0);
const avg = sum / slice.length;
const max = Math.max(...slice);
const min = Math.min(...slice);
const ts = tsSlice[Math.floor(tsSlice.length / 2)] || 0;
result.push({
time: new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
timestamp: ts, avg: Number(avg.toFixed(2)), max: Number(max.toFixed(2)), min: Number(min.toFixed(2)), count: slice.length,
});
}
return result.slice(-targetBuckets);
}
function parseJSON( raw: any): any {
if (!raw) return {};
if (typeof raw === 'object') return raw;
try { return JSON.parse(raw); } catch { return {}; }
}
function formatBPS(bps: number): string {
if (bps >= 1024 * 1024 * 1024) return `${(bps / 1024 / 1024 / 1024).toFixed(1)} GB/s`;
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
return `${bps.toFixed(1)} B/s`;
}
import { TIME_RANGES, type AggType, type HostRecord } from './host-monitor/types';
import { parseJSON, formatBPS, buildMetricsFromHistory, getCurrentVal } from './host-monitor/utils';
import { MetricChartCard } from './host-monitor/MetricChartCard';
import { DualLineChart } from './host-monitor/DualLineChart';
import { HealthProbeSection } from './host-monitor/HealthProbeSection';
import { PortPolicyDialog } from './host-monitor/PortPolicyDialog';
import { SystemInfoSection } from './host-monitor/SystemInfoSection';
export function HostMonitor() {
const qc = useQueryClient();
const [searchParams] = useSearchParams();
const urlHost = searchParams.get('host') || '';
const [hosts, setHosts] = useState<any[]>([]);
const [selectedHost, setSelectedHost] = useState(urlHost);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('basic');
const [rangeHours, setRangeHours] = useState(1);
const [aggType, setAggType] = useState<AggType>('avg');
const [history, setHistory] = useState<any[]>([]);
const [latest, setLatest] = useState<any>(null);
const [serverInfo, setServerInfo] = useState<any>(null);
// 健康探针
const [probes, setProbes] = useState<any[]>([]);
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
const [editingProbe, setEditingProbe] = useState<any>(null);
const [probeForm, setProbeForm] = useState({
name: '', hostname: '', probe_type: 'http_get', http_url: '', http_method: 'GET',
http_expect_code: 200, http_expect_body: '',
tcp_host: '', tcp_port: 3306, exec_command: '', timeout: 10, interval: 60, description: '',
});
// 加载探针
const fetchProbes = useCallback(() => {
if (!selectedHost) return;
agentApi.getHealthProbes({ hostname: selectedHost }).then(( data: any) => {
if (Array.isArray(data)) setProbes(data);
else if (data?.list) setProbes(data.list);
else setProbes([]);
}).catch(() => setProbes([]));
}, [selectedHost]);
// 端口策略 // Logan.Li
const [portPolicyMode, setPortPolicyMode] = useState('whitelist');
const [portWhitelist, setPortWhitelist] = useState<number[]>([]);
const [portPolicyDialogOpen, setPortPolicyDialogOpen] = useState(false);
const [portPolicySaving, setPortPolicySaving] = useState(false);
const fetchPortPolicy = useCallback(() => {
if (!selectedHost) return;
agentApi.getPortPolicy(selectedHost).then(( data: any) => {
if (data?.data) {
setPortPolicyMode(data.data.port_filter_mode || 'blacklist');
setPortWhitelist(data.data.port_whitelist || []);
}
}).catch(() => {});
}, [selectedHost]);
const { data: hosts = [], isLoading: hostsLoading } = useHostList();
const { data: history = [], isLoading: historyLoading } = useHostHistory(selectedHost, rangeHours);
const { data: portPolicy, refetch: refetchPortPolicy } = usePortPolicy(selectedHost);
useEffect(() => { fetchPortPolicy(); }, [fetchPortPolicy]);
useEffect(() => {
if (portPolicy) {
setPortPolicyMode(portPolicy.port_filter_mode);
setPortWhitelist(portPolicy.port_whitelist);
}
}, [portPolicy]);
useEffect(() => {
if (hosts.length > 0 && !selectedHost) {
const m = urlHost ? hosts.find((h) => h.hostname === urlHost) : null;
setSelectedHost(String(m?.hostname || hosts[0]?.hostname || ''));
}
}, [hosts, selectedHost, urlHost]);
const handleSavePortPolicy = async () => {
if (!selectedHost) return;
@@ -132,121 +67,20 @@ export function HostMonitor() {
port_whitelist: portWhitelist,
});
setPortPolicyDialogOpen(false);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.portPolicy(selectedHost) });
} catch { } finally { setPortPolicySaving(false); }
};
useEffect(() => {
fetchProbes();
}, [fetchProbes]);
const timeConfig = TIME_RANGES.find(tr => tr.hours === rangeHours) || TIME_RANGES[0];
const loading = hostsLoading || historyLoading;
const serverInfo = (hosts.find((h) => h.hostname === selectedHost) || null) as HostRecord | null;
const latest = useMemo((): HostRecord => {
const base = serverInfo || {};
if (history.length === 0) return base;
return { ...base, ...(history[history.length - 1] as HostRecord) };
}, [serverInfo, history]);
useEffect(() => {
agentApi.getLatest().then(( data: any) => {
const list = Array.isArray(data) ? data : (data?.data || data?.list || []);
setHosts(list);
if (list.length > 0 && !selectedHost) {
const m = urlHost ? list.find(( h: any) => h.hostname === urlHost) : null;
setSelectedHost(m?.hostname || list[0]?.hostname || '');
}
const cur = list.find(( h: any) => h.hostname === (selectedHost || urlHost));
if (cur) { setLatest(cur); setServerInfo(cur); }
}).catch(() => {}).finally(() => setLoading(false));
}, [selectedHost]);
useEffect(() => {
if (!selectedHost) return;
agentApi.getHistory(selectedHost, rangeHours)
.then(( data: any) => {
const list = Array.isArray(data) ? data : (data?.data || data?.list || []);
setHistory(list);
if (list.length > 0) {
const fresh = list[list.length - 1];
setLatest(( prev: any) => ({ ...prev, ...fresh }));
}
})
.catch(() => setHistory([]))
.finally(() => setLoading(false));
}, [selectedHost, rangeHours]);
// 历史指标提取
const metrics = useMemo(() => {
const rawCpu: number[] = []; const rawMem: number[] = []; const rawLoad1: number[] = [];
const rawDisk: number[] = []; const rawTcp: number[] = []; const rawTw: number[] = []; const rawCw: number[] = [];
const rawRx: number[] = []; const rawTx: number[] = [];
const rawIoR: number[] = []; const rawIoW: number[] = [];
const rawSwap: number[] = []; const rawFD: number[] = [];
// ──── v2.2 新增指标 ────
const rawIOWait: number[] = []; const rawMemAvail: number[] = [];
const rawIoUtil: number[] = []; const rawConnTotal: number[] = [];
const rawRxErr: number[] = []; const rawTxErr: number[] = [];
const rawInode: number[] = []; // 根分区 inode
const timestamps: number[] = [];
for (const r of history) {
const ts = r.report_time ? new Date(r.report_time).getTime() / 1000 : (r.created_at ? new Date(r.created_at).getTime() / 1000 : 0);
timestamps.push(ts);
rawCpu.push(r.cpu_usage || 0);
rawIOWait.push(r.cpu_iowait || 0);
rawMem.push(r.mem_usage || 0);
rawMemAvail.push(r.mem_available || 0);
rawLoad1.push(r.load1 || 0);
rawDisk.push(r.disk_usage || 0);
const diskInfoArr = parseJSON(r.disk_info);
if (Array.isArray(diskInfoArr) && diskInfoArr.length > 0) {
const rootDisk = diskInfoArr.find(( d: any) => d.mount === '/') || diskInfoArr[0];
rawInode.push(rootDisk?.inode_usage_pct || 0);
} else {
rawInode.push(0);
}
const net = parseJSON(r.net_info);
rawTcp.push(net.tcp_established || 0);
rawTw.push(net.tcp_time_wait || 0);
rawCw.push(net.tcp_close_wait || 0);
rawConnTotal.push((net.tcp_established || 0) + (net.tcp_time_wait || 0) + (net.tcp_close_wait || 0));
rawRx.push(net.net_rx_bps || 0);
rawTx.push(net.net_tx_bps || 0);
rawRxErr.push(net.net_rx_errors || 0);
rawTxErr.push(net.net_tx_errors || 0);
const dio = parseJSON(r.disk_io_info);
rawIoR.push(dio.read_bps || 0);
rawIoW.push(dio.write_bps || 0);
rawIoUtil.push(dio.util || 0);
const swap = parseJSON(r.swap_info);
rawSwap.push(swap?.usage_percent || 0);
const fd = parseJSON(r.fd_info);
rawFD.push(fd?.usage_pct || 0);
}
return {
cpu: bucketWithStats(rawCpu, timestamps, timeConfig.buckets),
mem: bucketWithStats(rawMem, timestamps, timeConfig.buckets),
load1: bucketWithStats(rawLoad1, timestamps, timeConfig.buckets),
disk: bucketWithStats(rawDisk, timestamps, timeConfig.buckets),
tcpEst: bucketWithStats(rawTcp, timestamps, timeConfig.buckets),
tcpTW: bucketWithStats(rawTw, timestamps, timeConfig.buckets),
tcpCW: bucketWithStats(rawCw, timestamps, timeConfig.buckets),
rx: bucketWithStats(rawRx.map(v => v / 1024), timestamps, timeConfig.buckets),
tx: bucketWithStats(rawTx.map(v => v / 1024), timestamps, timeConfig.buckets),
ioRead: bucketWithStats(rawIoR.map(v => v / 1024), timestamps, timeConfig.buckets),
ioWrite: bucketWithStats(rawIoW.map(v => v / 1024), timestamps, timeConfig.buckets),
swap: bucketWithStats(rawSwap, timestamps, timeConfig.buckets),
fd: bucketWithStats(rawFD, timestamps, timeConfig.buckets),
// ──── v2.2 新增指标 ────
iowait: bucketWithStats(rawIOWait, timestamps, timeConfig.buckets),
memAvail: bucketWithStats(rawMemAvail, timestamps, timeConfig.buckets),
ioUtil: bucketWithStats(rawIoUtil, timestamps, timeConfig.buckets),
connTotal: bucketWithStats(rawConnTotal, timestamps, timeConfig.buckets),
rxErr: bucketWithStats(rawRxErr, timestamps, timeConfig.buckets),
txErr: bucketWithStats(rawTxErr, timestamps, timeConfig.buckets),
inode: bucketWithStats(rawInode, timestamps, timeConfig.buckets),
};
}, [history, timeConfig]);
const metrics = useMemo(() => buildMetricsFromHistory(history, timeConfig), [history, timeConfig]);
const netInfo = parseJSON(latest?.net_info);
const portInfo: Record<string, unknown>[] = Array.isArray(latest?.port_info) ? latest.port_info : (Array.isArray(parseJSON(latest?.port_info)) ? parseJSON(latest?.port_info) : []);
@@ -257,22 +91,8 @@ export function HostMonitor() {
const csInfo = parseJSON(latest?.cs_info);
const heartbeatInfo = parseJSON(latest?.heartbeat_info);
const currentVal = (key: string, fallback = '--') => {
if (!latest) return fallback;
if (key === 'cpu') return latest.cpu_usage != null ? `${latest.cpu_usage.toFixed(1)}%` : fallback;
if (key === 'mem') return latest.mem_usage != null ? `${latest.mem_usage.toFixed(1)}%` : fallback;
if (key === 'disk') return latest.disk_usage != null ? `${latest.disk_usage.toFixed(1)}%` : fallback;
if (key === 'load1') return latest.load1 != null ? String(latest.load1) : fallback;
if (key === 'tcp') return netInfo.tcp_established != null ? String(netInfo.tcp_established) : fallback;
if (key === 'tw') return netInfo.tcp_time_wait != null ? String(netInfo.tcp_time_wait) : fallback;
if (key === 'rx') return netInfo.net_rx_bps != null ? `${(netInfo.net_rx_bps / 1024).toFixed(1)} KB/s` : fallback;
if (key === 'tx') return netInfo.net_tx_bps != null ? `${(netInfo.net_tx_bps / 1024).toFixed(1)} KB/s` : fallback;
if (key === 'ioR') return diskIOInfo.read_bps != null ? `${formatBPS(diskIOInfo.read_bps)}` : fallback;
if (key === 'ioW') return diskIOInfo.write_bps != null ? `${formatBPS(diskIOInfo.write_bps)}` : fallback;
if (key === 'swap') return swapInfo?.usage_percent != null ? `${swapInfo.usage_percent.toFixed(1)}%` : fallback;
if (key === 'fd') return fdInfo?.usage_pct != null ? `${fdInfo.usage_pct.toFixed(1)}%` : fallback;
return fallback;
};
const currentVal = (key: string, fallback = '--') =>
getCurrentVal(latest, netInfo, diskIOInfo, swapInfo, fdInfo, key, fallback);
if (loading && !history.length) {
return <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-slate-500" /></div>;
@@ -280,7 +100,6 @@ export function HostMonitor() {
return (
<div className="space-y-3">
{/* 顶栏 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-blue-400" />
@@ -289,7 +108,7 @@ export function HostMonitor() {
<SelectValue placeholder="选择主机..." />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
{hosts.map(( h: any) => (
{hosts.map((h: any) => (
<SelectItem key={h.hostname} value={h.hostname}>{h.hostname}</SelectItem>
))}
</SelectContent>
@@ -320,7 +139,6 @@ export function HostMonitor() {
</div>
</div>
{/* 分类 Tab */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="h-8 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] gap-0 overflow-x-auto">
{[
@@ -338,7 +156,6 @@ export function HostMonitor() {
))}
</TabsList>
{/* 基础监控 — 增加 Swap 卡片 */}
<TabsContent value="basic" className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricChartCard title="CPU 使用率(%)" value={currentVal('cpu')} icon={<Cpu className="h-4 w-4 text-blue-400" />}
data={metrics.cpu} color="#3b82f6" unit="%" domain={[0, 100]} aggType={aggType} />
@@ -354,14 +171,13 @@ export function HostMonitor() {
data={metrics.load1} color="#8b5cf6" unit="" domain={[0, 'auto']} aggType={aggType} />
</TabsContent>
{/* 磁盘监控 */}
<TabsContent value="disk" className="mt-3 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricChartCard title="磁盘使用率(%)" value={currentVal('disk')} icon={<HardDrive className="h-4 w-4 text-orange-400" />}
data={metrics.disk} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} />
<MetricChartCard title="Inode 使用率(%)" value={(() => {
const dArr = Array.isArray(parseJSON(latest?.disk_info)) ? parseJSON(latest?.disk_info) : [];
const root = dArr.find(( d: any) => d.mount === '/') || dArr[0];
const root = dArr.find((d: any) => d.mount === '/') || dArr[0];
return root?.inode_usage_pct != null ? `${root.inode_usage_pct.toFixed(1)}%` : '--';
})()} icon={<HardDrive className="h-4 w-4 text-purple-400" />}
data={metrics.inode} color="#8b5cf6" unit="%" domain={[0, 100]} aggType={aggType} />
@@ -374,7 +190,6 @@ export function HostMonitor() {
</div>
</TabsContent>
{/* 网络监控 — TCP连接 + 带宽 + 错误 */}
<TabsContent value="network" className="mt-3 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<MetricChartCard title="TCP EST" value={currentVal('tcp')} icon={<Network className="h-4 w-4 text-purple-400" />}
@@ -386,7 +201,6 @@ export function HostMonitor() {
<MetricChartCard title="TCP 总连接" value={netInfo ? String((netInfo.tcp_established||0)+(netInfo.tcp_time_wait||0)+(netInfo.tcp_close_wait||0)) : '--'} icon={<Network className="h-4 w-4 text-cyan-400" />}
data={metrics.connTotal} color="#22d3ee" unit="个" domain={[0, 'auto']} aggType={aggType} />
</div>
{/* 网卡带宽吞吐 — 双线 rx/tx */}
<DualLineChart
title="网卡带宽吞吐"
currentRx={netInfo?.net_rx_bps != null ? formatBPS(netInfo.net_rx_bps) : '--'}
@@ -402,7 +216,6 @@ export function HostMonitor() {
</div>
</TabsContent>
{/* ═══ 端口监控 ═══ */}
<TabsContent value="ports" className="mt-3 space-y-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
@@ -420,7 +233,7 @@ export function HostMonitor() {
</Badge>
)}
<Button variant="outline" size="sm" className="h-6 text-[10px] px-2"
onClick={() => { fetchPortPolicy(); setPortPolicyDialogOpen(true); }}>
onClick={() => { refetchPortPolicy(); setPortPolicyDialogOpen(true); }}>
策略配置
</Button>
</span>
@@ -441,7 +254,7 @@ export function HostMonitor() {
</tr>
</thead>
<tbody>
{portInfo.map(( p: any, i: number) => (
{portInfo.map((p: any, i: number) => (
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30 transition-colors">
<td className="py-1.5 font-mono text-[var(--dg-text-primary)]">{p.port}</td>
<td className="py-1.5">
@@ -465,9 +278,7 @@ export function HostMonitor() {
</Card>
</TabsContent>
{/* ═══ 进程监控 — 进程 TOP + FD 趋势 + 上下文切换 ═══ */}
<TabsContent value="process" className="mt-3 space-y-3">
{/* ── 进程 TOP 表格 ── */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
@@ -490,7 +301,7 @@ export function HostMonitor() {
</tr>
</thead>
<tbody>
{processTop.map(( p: any, i: number) => {
{processTop.map((p: any, i: number) => {
const cmd = p.cmdline || p.name || '?';
const cpup = p.cpu_pct != null ? Number(p.cpu_pct) : null;
const memp = p.mem_pct != null ? Number(p.mem_pct) : null;
@@ -498,10 +309,7 @@ export function HostMonitor() {
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30 transition-colors">
<td className="py-1.5 pr-2">
<div className="flex items-center gap-1 group/cmd">
<span
className="font-mono text-[var(--dg-text-primary)] truncate max-w-[320px] inline-block align-middle"
title={cmd}
>
<span className="font-mono text-[var(--dg-text-primary)] truncate max-w-[320px] inline-block align-middle" title={cmd}>
{cmd}
</span>
<button
@@ -512,7 +320,6 @@ export function HostMonitor() {
try {
await navigator.clipboard.writeText(cmd);
} catch {
// fallback
const ta = document.createElement('textarea');
ta.value = cmd; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
@@ -549,9 +356,7 @@ export function HostMonitor() {
</CardContent>
</Card>
{/* ── FD 文件句柄 + 上下文切换 可视化 ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* FD 卡片 + 趋势图 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
@@ -601,7 +406,6 @@ export function HostMonitor() {
</CardContent>
</Card>
{/* 上下文切换卡片 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
@@ -625,7 +429,6 @@ export function HostMonitor() {
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1 font-mono">{csInfo.irq_per_sec?.toLocaleString()} 次</div>
</div>
</div>
{/* 比例条 */}
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-emerald-400">CTX</span>
@@ -651,444 +454,27 @@ export function HostMonitor() {
</div>
</TabsContent>
{/* 系统信息 */}
<TabsContent value="system" className="mt-3 space-y-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-xs">
{[
['主机名', serverInfo?.hostname || selectedHost],
['IP 地址', serverInfo?.ip || '—'],
['操作系统', serverInfo?.os || '—'],
['内核版本', serverInfo?.kernel || '—'],
['CPU 核心数', serverInfo?.cpu_cores || '—'],
['总内存', serverInfo?.mem_total ? `${serverInfo.mem_total.toFixed(0)} GB` : '—'],
['已用内存', serverInfo?.mem_used ? `${serverInfo.mem_used.toFixed(1)} GB` : '—'],
['运行时长', serverInfo?.uptime ? `${Math.floor(serverInfo.uptime / 86400)}天 ${Math.floor((serverInfo.uptime % 86400) / 3600)}时` : '—'],
['Agent 版本', heartbeatInfo?.version || serverInfo?.agent_version || '—'],
['Agent 序列号', heartbeatInfo?.report_seq != null ? String(heartbeatInfo.report_seq) : '—'],
['监听端口数', portInfo.length > 0 ? `${portInfo.length}` : '—'],
['Agent RSS', heartbeatInfo?.self_rss_mb ? `${heartbeatInfo.self_rss_mb.toFixed(1)} MB` : '—'],
].map(([label, val]) => (
<div key={label as string}>
<span className="text-[var(--dg-text-muted)]">{label}:</span>
<span className="text-[var(--dg-text-primary)] font-mono">{val}</span>
</div>
))}
</div>
</CardContent>
</Card>
<SystemInfoSection
selectedHost={selectedHost}
serverInfo={serverInfo}
latest={latest}
heartbeatInfo={heartbeatInfo}
portInfoCount={portInfo.length}
/>
{/* Load Average */}
{latest && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">系统负载 (Load Average)</h3>
</div>
<div className="flex items-center gap-6 text-xs">
<div><span className="text-[var(--dg-text-muted)]">1min </span><span className="text-sm font-bold text-slate-200">{latest.load1 ?? '--'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">5min </span><span className="text-sm font-bold text-slate-200">{latest.load5 ?? '--'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">15min </span><span className="text-sm font-bold text-slate-200">{latest.load15 ?? '--'}</span></div>
</div>
</CardContent>
</Card>
)}
{/* 磁盘分区 + 网络接口 (保持原样) */}
{latest?.disk_info && (() => {
const disk = parseJSON(latest.disk_info);
const parts = Array.isArray(disk) ? disk : (disk.partitions || []);
if (!parts.length) return null;
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-2 flex items-center gap-2"><HardDrive className="h-4 w-4" />磁盘分区</h3>
<div className="space-y-1.5">
{parts.map(( p: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="text-[var(--dg-text-muted)] w-24 font-mono truncate">{p.mount || p.mountpoint || p.device || `disk${i}`}</span>
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.min(p.usage_percent || p.used_percent || 0, 100)}%` }} />
</div>
<span className="text-[var(--dg-text-primary)] w-28 text-right tabular-nums whitespace-nowrap">{(p.used_gb ?? 0).toFixed(1)}/{(p.total_gb ?? 0).toFixed(0)}GB</span>
<span className="text-blue-400 w-14 text-right tabular-nums whitespace-nowrap">{(p.usage_percent ?? p.used_percent ?? 0).toFixed(1)}%</span>
</div>
))}
</div>
</CardContent>
</Card>
);
})()}
</TabsContent>
{/* 健康探针 — Agent 本地执行 + 结果上报 */}
<TabsContent value="health_probes" className="mt-3 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">探针列表(Agent 在目标主机本地执行探测)</h3>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={fetchProbes}>
<RefreshCw className="w-3 h-3 mr-1" />刷新
</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={() => {
setEditingProbe(null);
setProbeForm({
name: '', hostname: selectedHost, probe_type: 'http_get', http_url: '',
http_method: 'GET', http_expect_code: 200, http_expect_body: '',
tcp_host: 'localhost', tcp_port: 3306, exec_command: '', timeout: 10, interval: 60, description: '',
});
setProbeDialogOpen(true);
}}>
<Plus className="w-3 h-3 mr-1" />添加探针
</Button>
</div>
</div>
{probes.length === 0 ? (
<Card className="bg-[var(--dg-bg-secondary)] border-dashed border-[var(--dg-border)]">
<CardContent className="p-8 text-center text-xs text-[var(--dg-text-muted)]">
暂无健康探针,点击"添加探针"创建
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{probes.map(( p: any) => (
<Card key={p.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${p.last_status === 'down' ? 'border-red-700/50' : ''}`}>
<CardContent className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{p.last_status === 'up' ? <CheckCircle className="w-4 h-4 text-emerald-400" /> :
p.last_status === 'down' ? <XCircle className="w-4 h-4 text-red-400" /> :
<Clock className="w-4 h-4 text-slate-500" />}
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{p.name}</span>
<Badge variant="outline" className="text-[10px] h-5 px-1.5 capitalize">
{p.probe_type === 'http_get' ? 'HTTP' : p.probe_type === 'tcp_socket' ? 'TCP' : 'Exec'}
</Badge>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => {
setEditingProbe(p);
setProbeForm({
name: p.name, hostname: p.hostname, probe_type: p.probe_type,
http_url: p.http_url || '', http_method: p.http_method || 'GET',
http_expect_code: p.http_expect_code || 200, http_expect_body: p.http_expect_body || '',
tcp_host: p.tcp_host || '', tcp_port: p.tcp_port || 3306,
exec_command: p.exec_command || '', timeout: p.timeout || 10,
interval: p.interval || 60, description: p.description || '',
});
setProbeDialogOpen(true);
}}>
<Edit className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400 hover:text-red-300" onClick={async () => {
if (confirm(`确定删除探针"${p.name}"?`)) {
await agentApi.deleteHealthProbe(p.id);
fetchProbes();
}
}}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="space-y-1 text-[11px] text-[var(--dg-text-muted)]">
{p.probe_type === 'http_get' && (
<div className="truncate">URL: {p.http_url}</div>
)}
{p.probe_type === 'tcp_socket' && (
<div>{p.tcp_host}:{p.tcp_port}</div>
)}
{p.probe_type === 'exec_command' && (
<div className="truncate font-mono">{p.exec_command}</div>
)}
<div className="flex items-center gap-3">
<span>状态: <span className={p.last_status === 'up' ? 'text-emerald-400' : p.last_status === 'down' ? 'text-red-400' : 'text-slate-400'}>
{p.last_status ? p.last_status.toUpperCase() : '未检测'}
</span></span>
{p.last_latency_ms > 0 && <span>延迟: {p.last_latency_ms}ms</span>}
{p.fail_count > 0 && <span className="text-red-400">连续失败: {p.fail_count}次</span>}
{p.last_checked_at && <span className="text-[10px]">{new Date(p.last_checked_at).toLocaleString('zh-CN')}</span>}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<HealthProbeSection selectedHost={selectedHost} />
</Tabs>
{/* 添加/编辑探针对话框 */}
<Dialog open={probeDialogOpen} onOpenChange={setProbeDialogOpen}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editingProbe ? '编辑探针' : '添加探针'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{editingProbe ? '修改健康探针配置' : '创建新的健康探针,Agent 在目标主机本地执行探测'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针名称 <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.name} onChange={e => setProbeForm({ ...probeForm, name: e.target.value })} placeholder="如:cex-agent 健康检查" />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">目标主机</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.hostname} onChange={e => setProbeForm({ ...probeForm, hostname: e.target.value })} />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针类型</label>
<select className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.probe_type} onChange={e => setProbeForm({ ...probeForm, probe_type: e.target.value })}>
<option value="http_get">HTTP GET</option>
<option value="tcp_socket">TCP Socket</option>
<option value="exec_command">Exec Command</option>
</select>
</div>
{probeForm.probe_type === 'http_get' && (
<>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">HTTP URL <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_url} onChange={e => setProbeForm({ ...probeForm, http_url: e.target.value })} placeholder="http://localhost:8080/health" /></div>
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望状态码</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_code} onChange={e => setProbeForm({ ...probeForm, http_expect_code: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望响应内容(可选)</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_body} onChange={e => setProbeForm({ ...probeForm, http_expect_body: e.target.value })} /></div>
</div>
</>
)}
{probeForm.probe_type === 'tcp_socket' && (
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 目标地址</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_host} onChange={e => setProbeForm({ ...probeForm, tcp_host: e.target.value })} placeholder="localhost" /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 端口</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_port} onChange={e => setProbeForm({ ...probeForm, tcp_port: Number(e.target.value) })} /></div>
</div>
)}
{probeForm.probe_type === 'exec_command' && (
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">执行命令 <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs font-mono text-[var(--dg-text-primary)]" value={probeForm.exec_command} onChange={e => setProbeForm({ ...probeForm, exec_command: e.target.value })} placeholder="pgrep -x mysqld" /></div>
)}
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">超时(秒)</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.timeout} onChange={e => setProbeForm({ ...probeForm, timeout: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">间隔(秒)</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.interval} onChange={e => setProbeForm({ ...probeForm, interval: Number(e.target.value) })} /></div>
</div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">描述</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.description} onChange={e => setProbeForm({ ...probeForm, description: e.target.value })} /></div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setProbeDialogOpen(false)}>取消</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={async () => {
if (!probeForm.name || !probeForm.hostname) return;
try {
if (editingProbe) {
await agentApi.updateHealthProbe(editingProbe.id, probeForm);
} else {
await agentApi.createHealthProbe(probeForm);
}
setProbeDialogOpen(false);
fetchProbes();
} catch { alert('保存失败'); }
}} disabled={!probeForm.name || !probeForm.hostname}>
{editingProbe ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 端口策略对话框 */}
<Dialog open={portPolicyDialogOpen} onOpenChange={setPortPolicyDialogOpen}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">端口监控策略</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">配置 Agent 端口上报过滤规则</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 模式选择 */}
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">过滤模式</label>
<div className="flex gap-2">
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'whitelist' ? 'bg-blue-500/20 border-blue-500 text-blue-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => setPortPolicyMode('whitelist')}
>
白名单 ─ 全量上报
</button>
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'blacklist' ? 'bg-amber-500/20 border-amber-500 text-amber-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => setPortPolicyMode('blacklist')}
>
黑名单 ─ 仅上报选中
</button>
</div>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1.5 leading-relaxed">
{portPolicyMode === 'whitelist'
? '所有发现的端口都上报,任意端口变化都会触发告警 + 恢复通知。'
: '仅上报选中端口的变化,其余端口变化静默忽略(不告警)。系统进程端口(sshd/rpcbind/systemd等)永远忽略。'}
</p>
</div>
{/* 端口白名单 — 仅黑名单模式可编辑 */}
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">
端口列表 <span className="text-[var(--dg-text-muted)]">(逗号分隔,如: 80,443,3306,6379)</span>
{portPolicyMode === 'whitelist' && <span className="text-blue-400 ml-1">— 白名单模式无需配置</span>}
</label>
<textarea
className={`w-full h-20 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1.5 text-xs font-mono resize-none ${
portPolicyMode === 'whitelist' ? 'text-[var(--dg-text-muted)] opacity-50 cursor-not-allowed' : 'text-[var(--dg-text-primary)]'
}`}
value={portWhitelist.join(',')}
onChange={e => {
if (portPolicyMode === 'whitelist') return;
const val = e.target.value;
const nums = val.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0 && n <= 65535);
setPortWhitelist(nums);
}}
placeholder={portPolicyMode === 'whitelist' ? '白名单模式下所有端口自动上报' : '80,443,3306,6379'}
readOnly={portPolicyMode === 'whitelist'}
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
已输入 {portWhitelist.length} 个端口
</p>
</div>
{/* 系统排除说明 */}
<div className="bg-amber-500/5 border border-amber-500/20 rounded p-2.5">
<p className="text-[10px] text-amber-300/80 leading-relaxed">
⚠️ 无论选择哪种模式,以下系统进程端口<b>永远不上报</b>:sshd、rpcbind、master、systemd、dbus-daemon、rsyslogd 等。Agent 采集时自动跳过。
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setPortPolicyDialogOpen(false)}>取消</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={handleSavePortPolicy} disabled={portPolicySaving}>
{portPolicySaving ? '保存中…' : '保存策略'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PortPolicyDialog
open={portPolicyDialogOpen}
onOpenChange={setPortPolicyDialogOpen}
portPolicyMode={portPolicyMode}
onModeChange={setPortPolicyMode}
portWhitelist={portWhitelist}
onWhitelistChange={setPortWhitelist}
onSave={handleSavePortPolicy}
saving={portPolicySaving}
/>
</div>
);
}
// ═══════ 指标图表卡片 ═══════
function MetricChartCard({
title, value, icon, data, color, unit, domain, aggType,
}: {
title: string; value: string; icon: React.ReactNode;
data: BucketPoint[]; color: string; unit: string;
domain: [number, number | 'auto']; aggType: AggType;
}) {
const chartData = data.map(d => ({ ...d, value: d[aggType] }));
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">{icon}<span className="text-xs text-[var(--dg-text-primary)] font-medium truncate">{title}</span></div>
</div>
<div className="mb-2">
<span className="text-xl font-bold text-[var(--dg-text-primary)] tabular-nums">{value}</span>
{unit && <span className="text-xs text-[var(--dg-text-muted)] ml-1">{unit}</span>}
</div>
<div className="h-48">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
<defs>
<linearGradient id={`grad-${title}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
<XAxis dataKey="time" hide />
<YAxis hide domain={domain[1] === 'auto' ? [0, (max: number) => max * 1.1] : domain as [number, number]} />
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, title.replace(/\(.+\)/, '')]}
labelFormatter={(label) => `时间: ${label}`} />
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={1.5} fill={`url(#grad-${title})`} />
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}
// ═══════ 双线图表(网卡带宽 rx+tx)═══════
function DualLineChart({
title, currentRx, currentTx, rxData, txData, aggType, unit,
}: {
title: string; currentRx: string; currentTx: string;
rxData: BucketPoint[]; txData: BucketPoint[];
aggType: AggType; unit: string;
}) {
// 合并 rx/tx 为统一时间序列
const chartData = rxData.map((d, i) => ({
time: d.time,
timestamp: d.timestamp,
rx: d[aggType] ?? 0,
tx: txData[i]?.[aggType] ?? 0,
}));
const rxGradId = `grad-rx-${title.replace(/\s/g, '')}`;
const txGradId = `grad-tx-${title.replace(/\s/g, '')}`;
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<Network className="h-4 w-4 text-blue-400" />
<span className="text-xs text-[var(--dg-text-primary)] font-medium">{title}</span>
</div>
<div className="flex items-center gap-3 text-xs">
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-blue-400 mr-1 align-middle" />接收 <b className="text-blue-300">{currentRx}</b></span>
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />发送 <b className="text-emerald-300">{currentTx}</b></span>
</div>
</div>
<div className="h-56">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
<defs>
<linearGradient id={rxGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id={txGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
<XAxis dataKey="time" hide />
<YAxis hide domain={[0, 'auto']} />
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
formatter={(v: number, name: string) => [`${v.toFixed(2)} ${unit}`, name === 'rx' ? '接收' : '发送']}
labelFormatter={(label) => `时间: ${label}`} />
<Area type="monotone" dataKey="rx" stroke="#3b82f6" strokeWidth={1.5} fill={`url(#${rxGradId})`} name="rx" />
<Area type="monotone" dataKey="tx" stroke="#10b981" strokeWidth={1.5} fill={`url(#${txGradId})`} name="tx" />
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}
}
@@ -0,0 +1,77 @@
// 双线图表(网卡带宽 rx+tx)// Logan.Li
import { Network } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Brush,
} from 'recharts';
import type { AggType, BucketPoint } from './types';
interface DualLineChartProps {
title: string;
currentRx: string;
currentTx: string;
rxData: BucketPoint[];
txData: BucketPoint[];
aggType: AggType;
unit: string;
}
export function DualLineChart({
title, currentRx, currentTx, rxData, txData, aggType, unit,
}: DualLineChartProps) {
const chartData = rxData.map((d, i) => ({
time: d.time,
timestamp: d.timestamp,
rx: d[aggType] ?? 0,
tx: txData[i]?.[aggType] ?? 0,
}));
const rxGradId = `grad-rx-${title.replace(/\s/g, '')}`;
const txGradId = `grad-tx-${title.replace(/\s/g, '')}`;
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<Network className="h-4 w-4 text-blue-400" />
<span className="text-xs text-[var(--dg-text-primary)] font-medium">{title}</span>
</div>
<div className="flex items-center gap-3 text-xs">
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-blue-400 mr-1 align-middle" />接收 <b className="text-blue-300">{currentRx}</b></span>
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-emerald-400 mr-1 align-middle" />发送 <b className="text-emerald-300">{currentTx}</b></span>
</div>
</div>
<div className="h-56">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
<defs>
<linearGradient id={rxGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id={txGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
<XAxis dataKey="time" hide />
<YAxis hide domain={[0, 'auto']} />
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
formatter={(v: number, name: string) => [`${v.toFixed(2)} ${unit}`, name === 'rx' ? '接收' : '发送']}
labelFormatter={(label) => `时间: ${label}`} />
<Area type="monotone" dataKey="rx" stroke="#3b82f6" strokeWidth={1.5} fill={`url(#${rxGradId})`} name="rx" />
<Area type="monotone" dataKey="tx" stroke="#10b981" strokeWidth={1.5} fill={`url(#${txGradId})`} name="tx" />
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,223 @@
// 健康探针 Tab + 对话框 // Logan.Li
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/services/api';
import { useHealthProbes } from '@/hooks/api/useHostMonitor';
import { queryKeys } from '@/hooks/api/queryKeys';
import { Card, CardContent } from '@/components/ui/card';
import { TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
Clock, Plus, Trash2, RefreshCw, Edit, CheckCircle, XCircle,
} from 'lucide-react';
import { DEFAULT_PROBE_FORM, type ProbeForm } from './types';
interface HealthProbeSectionProps {
selectedHost: string;
}
export function HealthProbeSection({ selectedHost }: HealthProbeSectionProps) {
const qc = useQueryClient();
const { data: probes = [], refetch } = useHealthProbes(selectedHost);
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
const [editingProbe, setEditingProbe] = useState<Record<string, unknown> | null>(null);
const [probeForm, setProbeForm] = useState<ProbeForm>(DEFAULT_PROBE_FORM());
const openCreateDialog = () => {
setEditingProbe(null);
setProbeForm(DEFAULT_PROBE_FORM(selectedHost));
setProbeDialogOpen(true);
};
const openEditDialog = (p: Record<string, unknown>) => {
setEditingProbe(p);
setProbeForm({
name: String(p.name || ''),
hostname: String(p.hostname || ''),
probe_type: String(p.probe_type || 'http_get'),
http_url: String(p.http_url || ''),
http_method: String(p.http_method || 'GET'),
http_expect_code: Number(p.http_expect_code) || 200,
http_expect_body: String(p.http_expect_body || ''),
tcp_host: String(p.tcp_host || ''),
tcp_port: Number(p.tcp_port) || 3306,
exec_command: String(p.exec_command || ''),
timeout: Number(p.timeout) || 10,
interval: Number(p.interval) || 60,
description: String(p.description || ''),
});
setProbeDialogOpen(true);
};
const handleSaveProbe = async () => {
if (!probeForm.name || !probeForm.hostname) return;
try {
if (editingProbe) {
await agentApi.updateHealthProbe(editingProbe.id as number, probeForm);
} else {
await agentApi.createHealthProbe(probeForm);
}
setProbeDialogOpen(false);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
} catch {
alert('保存失败');
}
};
const handleDeleteProbe = async (p: Record<string, unknown>) => {
if (confirm(`确定删除探针"${p.name}"?`)) {
await agentApi.deleteHealthProbe(p.id as number);
qc.invalidateQueries({ queryKey: queryKeys.hostMonitor.probes(selectedHost) });
}
};
return (
<>
<TabsContent value="health_probes" className="mt-3 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">探针列表(Agent 在目标主机本地执行探测)</h3>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => refetch()}>
<RefreshCw className="w-3 h-3 mr-1" />刷新
</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={openCreateDialog}>
<Plus className="w-3 h-3 mr-1" />添加探针
</Button>
</div>
</div>
{probes.length === 0 ? (
<Card className="bg-[var(--dg-bg-secondary)] border-dashed border-[var(--dg-border)]">
<CardContent className="p-8 text-center text-xs text-[var(--dg-text-muted)]">
暂无健康探针,点击"添加探针"创建
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{probes.map((p) => (
<Card key={String(p.id)} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${p.last_status === 'down' ? 'border-red-700/50' : ''}`}>
<CardContent className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{p.last_status === 'up' ? <CheckCircle className="w-4 h-4 text-emerald-400" /> :
p.last_status === 'down' ? <XCircle className="w-4 h-4 text-red-400" /> :
<Clock className="w-4 h-4 text-slate-500" />}
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{String(p.name)}</span>
<Badge variant="outline" className="text-[10px] h-5 px-1.5 capitalize">
{p.probe_type === 'http_get' ? 'HTTP' : p.probe_type === 'tcp_socket' ? 'TCP' : 'Exec'}
</Badge>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => openEditDialog(p)}>
<Edit className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400 hover:text-red-300" onClick={() => handleDeleteProbe(p)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="space-y-1 text-[11px] text-[var(--dg-text-muted)]">
{p.probe_type === 'http_get' && (
<div className="truncate">URL: {String(p.http_url)}</div>
)}
{p.probe_type === 'tcp_socket' && (
<div>{String(p.tcp_host)}:{String(p.tcp_port)}</div>
)}
{p.probe_type === 'exec_command' && (
<div className="truncate font-mono">{String(p.exec_command)}</div>
)}
<div className="flex items-center gap-3">
<span>状态: <span className={p.last_status === 'up' ? 'text-emerald-400' : p.last_status === 'down' ? 'text-red-400' : 'text-slate-400'}>
{p.last_status ? String(p.last_status).toUpperCase() : '未检测'}
</span></span>
{Number(p.last_latency_ms) > 0 && <span>延迟: {String(p.last_latency_ms)}ms</span>}
{Number(p.fail_count) > 0 && <span className="text-red-400">连续失败: {String(p.fail_count)}次</span>}
{p.last_checked_at && <span className="text-[10px]">{new Date(String(p.last_checked_at)).toLocaleString('zh-CN')}</span>}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<Dialog open={probeDialogOpen} onOpenChange={setProbeDialogOpen}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editingProbe ? '编辑探针' : '添加探针'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
{editingProbe ? '修改健康探针配置' : '创建新的健康探针,Agent 在目标主机本地执行探测'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针名称 <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.name} onChange={e => setProbeForm({ ...probeForm, name: e.target.value })} placeholder="如:cex-agent 健康检查" />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">目标主机</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.hostname} onChange={e => setProbeForm({ ...probeForm, hostname: e.target.value })} />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针类型</label>
<select className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.probe_type} onChange={e => setProbeForm({ ...probeForm, probe_type: e.target.value })}>
<option value="http_get">HTTP GET</option>
<option value="tcp_socket">TCP Socket</option>
<option value="exec_command">Exec Command</option>
</select>
</div>
{probeForm.probe_type === 'http_get' && (
<>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">HTTP URL <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_url} onChange={e => setProbeForm({ ...probeForm, http_url: e.target.value })} placeholder="http://localhost:8080/health" /></div>
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望状态码</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_code} onChange={e => setProbeForm({ ...probeForm, http_expect_code: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望响应内容(可选)</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_body} onChange={e => setProbeForm({ ...probeForm, http_expect_body: e.target.value })} /></div>
</div>
</>
)}
{probeForm.probe_type === 'tcp_socket' && (
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 目标地址</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_host} onChange={e => setProbeForm({ ...probeForm, tcp_host: e.target.value })} placeholder="localhost" /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 端口</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_port} onChange={e => setProbeForm({ ...probeForm, tcp_port: Number(e.target.value) })} /></div>
</div>
)}
{probeForm.probe_type === 'exec_command' && (
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">执行命令 <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs font-mono text-[var(--dg-text-primary)]" value={probeForm.exec_command} onChange={e => setProbeForm({ ...probeForm, exec_command: e.target.value })} placeholder="pgrep -x mysqld" /></div>
)}
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">超时(秒)</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.timeout} onChange={e => setProbeForm({ ...probeForm, timeout: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">间隔(秒)</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.interval} onChange={e => setProbeForm({ ...probeForm, interval: Number(e.target.value) })} /></div>
</div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">描述</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.description} onChange={e => setProbeForm({ ...probeForm, description: e.target.value })} /></div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setProbeDialogOpen(false)}>取消</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={handleSaveProbe} disabled={!probeForm.name || !probeForm.hostname}>
{editingProbe ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,63 @@
// 指标图表卡片 // Logan.Li
import type { ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Brush,
} from 'recharts';
import type { AggType, BucketPoint } from './types';
interface MetricChartCardProps {
title: string;
value: string;
icon: ReactNode;
data: BucketPoint[];
color: string;
unit: string;
domain: [number, number | 'auto'];
aggType: AggType;
}
export function MetricChartCard({
title, value, icon, data, color, unit, domain, aggType,
}: MetricChartCardProps) {
const chartData = data.map(d => ({ ...d, value: d[aggType] }));
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">{icon}<span className="text-xs text-[var(--dg-text-primary)] font-medium truncate">{title}</span></div>
</div>
<div className="mb-2">
<span className="text-xl font-bold text-[var(--dg-text-primary)] tabular-nums">{value}</span>
{unit && <span className="text-xs text-[var(--dg-text-muted)] ml-1">{unit}</span>}
</div>
<div className="h-48">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-md">暂无数据</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 2, right: 2, bottom: 0, left: -10 }}>
<defs>
<linearGradient id={`grad-${title}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" strokeOpacity={0.5} />
<XAxis dataKey="time" hide />
<YAxis hide domain={domain[1] === 'auto' ? [0, (max: number) => max * 1.1] : domain as [number, number]} />
<Tooltip contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '4px', color: '#f8fafc', fontSize: '11px', padding: '4px 8px' }}
formatter={(v: number) => [`${v.toFixed(2)} ${unit}`, title.replace(/\(.+\)/, '')]}
labelFormatter={(label) => `时间: ${label}`} />
<Area type="monotone" dataKey="value" stroke={color} strokeWidth={1.5} fill={`url(#grad-${title})`} />
<Brush dataKey="time" height={24} stroke="#334155" fill="#0f172a"
startIndex={Math.max(0, chartData.length - Math.min(20, chartData.length))} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,98 @@
// 端口策略对话框 // Logan.Li
import { Button } from '@/components/ui/button';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
interface PortPolicyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
portPolicyMode: string;
onModeChange: (mode: string) => void;
portWhitelist: number[];
onWhitelistChange: (ports: number[]) => void;
onSave: () => void;
saving: boolean;
}
export function PortPolicyDialog({
open,
onOpenChange,
portPolicyMode,
onModeChange,
portWhitelist,
onWhitelistChange,
onSave,
saving,
}: PortPolicyDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">端口监控策略</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">配置 Agent 端口上报过滤规则</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">过滤模式</label>
<div className="flex gap-2">
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'whitelist' ? 'bg-blue-500/20 border-blue-500 text-blue-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => onModeChange('whitelist')}
>
白名单 ─ 全量上报
</button>
<button
className={`flex-1 h-9 rounded text-xs font-medium border transition-colors ${portPolicyMode === 'blacklist' ? 'bg-amber-500/20 border-amber-500 text-amber-400' : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
onClick={() => onModeChange('blacklist')}
>
黑名单 ─ 仅上报选中
</button>
</div>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1.5 leading-relaxed">
{portPolicyMode === 'whitelist'
? '所有发现的端口都上报,任意端口变化都会触发告警 + 恢复通知。'
: '仅上报选中端口的变化,其余端口变化静默忽略(不告警)。系统进程端口(sshd/rpcbind/systemd等)永远忽略。'}
</p>
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1.5 block">
端口列表 <span className="text-[var(--dg-text-muted)]">(逗号分隔,如: 80,443,3306,6379)</span>
{portPolicyMode === 'whitelist' && <span className="text-blue-400 ml-1">— 白名单模式无需配置</span>}
</label>
<textarea
className={`w-full h-20 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1.5 text-xs font-mono resize-none ${
portPolicyMode === 'whitelist' ? 'text-[var(--dg-text-muted)] opacity-50 cursor-not-allowed' : 'text-[var(--dg-text-primary)]'
}`}
value={portWhitelist.join(',')}
onChange={e => {
if (portPolicyMode === 'whitelist') return;
const val = e.target.value;
const nums = val.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0 && n <= 65535);
onWhitelistChange(nums);
}}
placeholder={portPolicyMode === 'whitelist' ? '白名单模式下所有端口自动上报' : '80,443,3306,6379'}
readOnly={portPolicyMode === 'whitelist'}
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
已输入 {portWhitelist.length} 个端口
</p>
</div>
<div className="bg-amber-500/5 border border-amber-500/20 rounded p-2.5">
<p className="text-[10px] text-amber-300/80 leading-relaxed">
⚠️ 无论选择哪种模式,以下系统进程端口<b>永远不上报</b>:sshd、rpcbind、master、systemd、dbus-daemon、rsyslogd 等。Agent 采集时自动跳过。
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => onOpenChange(false)}>取消</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={onSave} disabled={saving}>
{saving ? '保存中…' : '保存策略'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,86 @@
// 系统信息 Tab 内容 // Logan.Li
import { Activity, HardDrive } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { TabsContent } from '@/components/ui/tabs';
import { parseJSON } from './utils';
interface SystemInfoSectionProps {
selectedHost: string;
serverInfo: any;
latest: any;
heartbeatInfo: any;
portInfoCount: number;
}
export function SystemInfoSection({
selectedHost, serverInfo, latest, heartbeatInfo, portInfoCount,
}: SystemInfoSectionProps) {
const disk = latest?.disk_info ? parseJSON(latest.disk_info) : null;
const parts = disk ? (Array.isArray(disk) ? disk : (disk.partitions || [])) : [];
return (
<TabsContent value="system" className="mt-3 space-y-3">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-xs">
{[
['主机名', serverInfo?.hostname || selectedHost],
['IP 地址', serverInfo?.ip || '—'],
['操作系统', serverInfo?.os || '—'],
['内核版本', serverInfo?.kernel || '—'],
['CPU 核心数', serverInfo?.cpu_cores || '—'],
['总内存', serverInfo?.mem_total ? `${serverInfo.mem_total.toFixed(0)} GB` : '—'],
['已用内存', serverInfo?.mem_used ? `${serverInfo.mem_used.toFixed(1)} GB` : '—'],
['运行时长', serverInfo?.uptime ? `${Math.floor(serverInfo.uptime / 86400)}天 ${Math.floor((serverInfo.uptime % 86400) / 3600)}时` : '—'],
['Agent 版本', heartbeatInfo?.version || serverInfo?.agent_version || '—'],
['Agent 序列号', heartbeatInfo?.report_seq != null ? String(heartbeatInfo.report_seq) : '—'],
['监听端口数', portInfoCount > 0 ? `${portInfoCount}` : '—'],
['Agent RSS', heartbeatInfo?.self_rss_mb ? `${heartbeatInfo.self_rss_mb.toFixed(1)} MB` : '—'],
].map(([label, val]) => (
<div key={label as string}>
<span className="text-[var(--dg-text-muted)]">{label}:</span>
<span className="text-[var(--dg-text-primary)] font-mono">{val}</span>
</div>
))}
</div>
</CardContent>
</Card>
{latest && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">系统负载 (Load Average)</h3>
</div>
<div className="flex items-center gap-6 text-xs">
<div><span className="text-[var(--dg-text-muted)]">1min </span><span className="text-sm font-bold text-slate-200">{latest.load1 ?? '--'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">5min </span><span className="text-sm font-bold text-slate-200">{latest.load5 ?? '--'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">15min </span><span className="text-sm font-bold text-slate-200">{latest.load15 ?? '--'}</span></div>
</div>
</CardContent>
</Card>
)}
{parts.length > 0 && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-2 flex items-center gap-2"><HardDrive className="h-4 w-4" />磁盘分区</h3>
<div className="space-y-1.5">
{parts.map((p: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="text-[var(--dg-text-muted)] w-24 font-mono truncate">{p.mount || p.mountpoint || p.device || `disk${i}`}</span>
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.min(p.usage_percent || p.used_percent || 0, 100)}%` }} />
</div>
<span className="text-[var(--dg-text-primary)] w-28 text-right tabular-nums whitespace-nowrap">{(p.used_gb ?? 0).toFixed(1)}/{(p.total_gb ?? 0).toFixed(0)}GB</span>
<span className="text-blue-400 w-14 text-right tabular-nums whitespace-nowrap">{(p.usage_percent ?? p.used_percent ?? 0).toFixed(1)}%</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
);
}
+57
View File
@@ -0,0 +1,57 @@
// 主机监控类型定义 // Logan.Li
export const TIME_RANGES = [
{ label: '1小时', hours: 1, window: 60, buckets: 60 },
{ label: '6小时', hours: 6, window: 300, buckets: 72 },
{ label: '12小时', hours: 12, window: 600, buckets: 72 },
{ label: '1天', hours: 24, window: 1800, buckets: 96 },
{ label: '3天', hours: 72, window: 3600, buckets: 72 },
{ label: '7天', hours: 168, window: 10800, buckets: 56 },
{ label: '14天', hours: 336, window: 21600, buckets: 48 },
] as const;
export type AggType = 'avg' | 'max' | 'min';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HostRecord = Record<string, any>;
export interface BucketPoint {
time: string;
timestamp: number;
avg: number;
max: number;
min: number;
count: number;
}
export interface ProbeForm {
name: string;
hostname: string;
probe_type: string;
http_url: string;
http_method: string;
http_expect_code: number;
http_expect_body: string;
tcp_host: string;
tcp_port: number;
exec_command: string;
timeout: number;
interval: number;
description: string;
}
export const DEFAULT_PROBE_FORM = (hostname = ''): ProbeForm => ({
name: '',
hostname,
probe_type: 'http_get',
http_url: '',
http_method: 'GET',
http_expect_code: 200,
http_expect_body: '',
tcp_host: 'localhost',
tcp_port: 3306,
exec_command: '',
timeout: 10,
interval: 60,
description: '',
});
+138
View File
@@ -0,0 +1,138 @@
// 主机监控工具函数 // Logan.Li
import type { BucketPoint } from './types';
export function bucketWithStats(raw: number[], timestamps: number[], targetBuckets: number): BucketPoint[] {
if (!raw || raw.length === 0) return [];
const perBucket = Math.max(1, Math.ceil(raw.length / targetBuckets));
const result: BucketPoint[] = [];
for (let i = 0; i < raw.length; i += perBucket) {
const slice = raw.slice(i, i + perBucket);
const tsSlice = timestamps.slice(i, i + perBucket);
const sum = slice.reduce((a, b) => a + b, 0);
const avg = sum / slice.length;
const max = Math.max(...slice);
const min = Math.min(...slice);
const ts = tsSlice[Math.floor(tsSlice.length / 2)] || 0;
result.push({
time: new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
timestamp: ts,
avg: Number(avg.toFixed(2)),
max: Number(max.toFixed(2)),
min: Number(min.toFixed(2)),
count: slice.length,
});
}
return result.slice(-targetBuckets);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseJSON(raw: unknown): any {
if (!raw) return {};
if (typeof raw === 'object') return raw;
try { return JSON.parse(raw as string); } catch { return {}; }
}
export function formatBPS(bps: number): string {
if (bps >= 1024 * 1024 * 1024) return `${(bps / 1024 / 1024 / 1024).toFixed(1)} GB/s`;
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
return `${bps.toFixed(1)} B/s`;
}
interface TimeConfig { buckets: number; }
export function buildMetricsFromHistory(history: any[], timeConfig: TimeConfig) {
const rawCpu: number[] = []; const rawMem: number[] = []; const rawLoad1: number[] = [];
const rawDisk: number[] = []; const rawTcp: number[] = []; const rawTw: number[] = []; const rawCw: number[] = [];
const rawRx: number[] = []; const rawTx: number[] = [];
const rawIoR: number[] = []; const rawIoW: number[] = [];
const rawSwap: number[] = []; const rawFD: number[] = [];
const rawIOWait: number[] = []; const rawMemAvail: number[] = [];
const rawIoUtil: number[] = []; const rawConnTotal: number[] = [];
const rawRxErr: number[] = []; const rawTxErr: number[] = [];
const rawInode: number[] = [];
const timestamps: number[] = [];
for (const r of history) {
const ts = r.report_time ? new Date(r.report_time).getTime() / 1000 : (r.created_at ? new Date(r.created_at).getTime() / 1000 : 0);
timestamps.push(ts);
rawCpu.push(r.cpu_usage || 0);
rawIOWait.push(r.cpu_iowait || 0);
rawMem.push(r.mem_usage || 0);
rawMemAvail.push(r.mem_available || 0);
rawLoad1.push(r.load1 || 0);
rawDisk.push(r.disk_usage || 0);
const diskInfoArr = parseJSON(r.disk_info);
if (Array.isArray(diskInfoArr) && diskInfoArr.length > 0) {
const rootDisk = diskInfoArr.find((d: any) => d.mount === '/') || diskInfoArr[0];
rawInode.push(rootDisk?.inode_usage_pct || 0);
} else {
rawInode.push(0);
}
const net = parseJSON(r.net_info);
rawTcp.push(net.tcp_established || 0);
rawTw.push(net.tcp_time_wait || 0);
rawCw.push(net.tcp_close_wait || 0);
rawConnTotal.push((net.tcp_established || 0) + (net.tcp_time_wait || 0) + (net.tcp_close_wait || 0));
rawRx.push(net.net_rx_bps || 0);
rawTx.push(net.net_tx_bps || 0);
rawRxErr.push(net.net_rx_errors || 0);
rawTxErr.push(net.net_tx_errors || 0);
const dio = parseJSON(r.disk_io_info);
rawIoR.push(dio.read_bps || 0);
rawIoW.push(dio.write_bps || 0);
rawIoUtil.push(dio.util || 0);
const swap = parseJSON(r.swap_info);
rawSwap.push(swap?.usage_percent || 0);
const fd = parseJSON(r.fd_info);
rawFD.push(fd?.usage_pct || 0);
}
return {
cpu: bucketWithStats(rawCpu, timestamps, timeConfig.buckets),
mem: bucketWithStats(rawMem, timestamps, timeConfig.buckets),
load1: bucketWithStats(rawLoad1, timestamps, timeConfig.buckets),
disk: bucketWithStats(rawDisk, timestamps, timeConfig.buckets),
tcpEst: bucketWithStats(rawTcp, timestamps, timeConfig.buckets),
tcpTW: bucketWithStats(rawTw, timestamps, timeConfig.buckets),
tcpCW: bucketWithStats(rawCw, timestamps, timeConfig.buckets),
rx: bucketWithStats(rawRx.map(v => v / 1024), timestamps, timeConfig.buckets),
tx: bucketWithStats(rawTx.map(v => v / 1024), timestamps, timeConfig.buckets),
ioRead: bucketWithStats(rawIoR.map(v => v / 1024), timestamps, timeConfig.buckets),
ioWrite: bucketWithStats(rawIoW.map(v => v / 1024), timestamps, timeConfig.buckets),
swap: bucketWithStats(rawSwap, timestamps, timeConfig.buckets),
fd: bucketWithStats(rawFD, timestamps, timeConfig.buckets),
iowait: bucketWithStats(rawIOWait, timestamps, timeConfig.buckets),
memAvail: bucketWithStats(rawMemAvail, timestamps, timeConfig.buckets),
ioUtil: bucketWithStats(rawIoUtil, timestamps, timeConfig.buckets),
connTotal: bucketWithStats(rawConnTotal, timestamps, timeConfig.buckets),
rxErr: bucketWithStats(rawRxErr, timestamps, timeConfig.buckets),
txErr: bucketWithStats(rawTxErr, timestamps, timeConfig.buckets),
inode: bucketWithStats(rawInode, timestamps, timeConfig.buckets),
};
}
export function getCurrentVal(
latest: any, netInfo: any, diskIOInfo: any, swapInfo: any, fdInfo: any,
key: string, fallback = '--',
): string {
if (!latest) return fallback;
if (key === 'cpu') return latest.cpu_usage != null ? `${latest.cpu_usage.toFixed(1)}%` : fallback;
if (key === 'mem') return latest.mem_usage != null ? `${latest.mem_usage.toFixed(1)}%` : fallback;
if (key === 'disk') return latest.disk_usage != null ? `${latest.disk_usage.toFixed(1)}%` : fallback;
if (key === 'load1') return latest.load1 != null ? String(latest.load1) : fallback;
if (key === 'tcp') return netInfo.tcp_established != null ? String(netInfo.tcp_established) : fallback;
if (key === 'tw') return netInfo.tcp_time_wait != null ? String(netInfo.tcp_time_wait) : fallback;
if (key === 'rx') return netInfo.net_rx_bps != null ? `${(netInfo.net_rx_bps / 1024).toFixed(1)} KB/s` : fallback;
if (key === 'tx') return netInfo.net_tx_bps != null ? `${(netInfo.net_tx_bps / 1024).toFixed(1)} KB/s` : fallback;
if (key === 'ioR') return diskIOInfo.read_bps != null ? `${formatBPS(diskIOInfo.read_bps)}` : fallback;
if (key === 'ioW') return diskIOInfo.write_bps != null ? `${formatBPS(diskIOInfo.write_bps)}` : fallback;
if (key === 'swap') return swapInfo?.usage_percent != null ? `${swapInfo.usage_percent.toFixed(1)}%` : fallback;
if (key === 'fd') return fdInfo?.usage_pct != null ? `${fdInfo.usage_pct.toFixed(1)}%` : fallback;
return fallback;
}
+2 -407
View File
@@ -1,408 +1,3 @@
// Kubernetes 多集群管理 (78 方法)
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
// Kubernetes API — 子模块 re-export(保持 @/services/api 导入路径不变) // Logan.Li
import { http, type ListResponse } from '../http';
import { useUserStore } from '@/store';
export const kubernetesApi = {
getClusters: async () => {
return http.get<ListResponse>('/kubernetes-clusters');
},
getClusterDetail: async (id: number | string) => {
return http.get(`/kubernetes-clusters/${id}/detail`);
},
createCluster: async ( data: any) => {
return http.post('/kubernetes-clusters', data);
},
updateCluster: async (id: number | string, data: any) => {
return http.put(`/kubernetes-clusters/${id}`, data);
},
deleteCluster: async (id: number | string) => {
return http.delete(`/kubernetes-clusters/${id}`);
},
getNodes: async (clusterId: number | string) => {
return http.get(`/kubernetes-clusters/${clusterId}/nodes`);
},
getClusterNodes: async (clusterCode: string) => {
return http.get(`/kubernetes/${clusterCode}/nodes`);
},
getNodeDetail: async (clusterCode: string, nodeName: string) => {
return http.get(`/kubernetes/${clusterCode}/nodes/${encodeURIComponent(nodeName)}`);
},
getPods: async (clusterId: number | string) => {
return http.get(`/kubernetes-clusters/${clusterId}/pods`);
},
getDeployments: async (clusterId: number | string) => {
return http.get(`/kubernetes-clusters/${clusterId}/deployments`);
},
getServices: async (clusterId: number | string) => {
return http.get(`/kubernetes-clusters/${clusterId}/services`);
},
getIngresses: async (clusterId: number | string) => {
return http.get(`/kubernetes-clusters/${clusterId}/ingresses`);
},
syncCluster: async (clusterId: number | string) => {
return http.post(`/kubernetes-clusters/${clusterId}/sync`);
},
// 🆕 批量导出集群所有命名空间资源 // Logan.Li
exportAllResources: async (clusterCode: string) => {
return http.download(`/kubernetes/${clusterCode}/export`, `cluster-${clusterCode}-resources.tar.gz`);
},
getTopology: async (clusterId: number | string) => { // Logan.Li
return http.get(`/kubernetes-clusters/${clusterId}/topology`);
},
saveTopology: async (clusterId: number | string, layoutData: string) => { // Logan.Li
return http.put(`/kubernetes-clusters/${clusterId}/topology`, { layout_data: layoutData });
},
// === 工作负载操作 (Logan.Li) - Kuboard-style routes ===
getPodLogs: async (clusterCode: string, namespace: string, podName: string, tail: number = 200, container?: string) => {
const params = new URLSearchParams({ tail: String(tail) });
if (container) params.set('container', container);
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?${params}`);
},
// 🆕 增强版 Pod 日志查询 (Logan <admin@attacker.club>)
// 支持容器选择 / 行数限制 / previous 容器日志 / sinceTime 时间范围
getPodLogsEnhanced: async (
clusterCode: string,
namespace: string,
podName: string,
opts: {
container?: string;
tailLines?: number; // 0 / undefined => 全部
previous?: boolean;
sinceTime?: string; // RFC3339,如 2024-01-01T00:00:00Z
} = {}
) => {
const params = new URLSearchParams();
if (opts.container) params.set('container', opts.container);
if (opts.tailLines && opts.tailLines > 0) params.set('tailLines', String(opts.tailLines));
if (opts.previous) params.set('previous', 'true');
if (opts.sinceTime) params.set('sinceTime', opts.sinceTime);
const qs = params.toString();
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs${qs ? `?${qs}` : ''}`);
},
// 🆕 构建日志流式 URL(tail -f 效果,fetch ReadableStream)(Logan <admin@attacker.club>)
buildPodLogsStreamUrl: (
clusterCode: string,
namespace: string,
podName: string,
opts: {
container?: string;
tailLines?: number;
previous?: boolean;
sinceTime?: string;
} = {}
) => {
const params = new URLSearchParams();
params.set('stream', 'true');
if (opts.container) params.set('container', opts.container);
if (opts.tailLines && opts.tailLines > 0) params.set('tailLines', String(opts.tailLines));
if (opts.previous) params.set('previous', 'true');
if (opts.sinceTime) params.set('sinceTime', opts.sinceTime);
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
return `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?${params}`;
},
execPod: async (clusterCode: string, namespace: string, podName: string, command: string[], container?: string) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/exec`, { command, container });
},
// 🆕 Pod 级 exec WebSocket URL (Logan <admin@attacker.club>)
// ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&command=&token=
buildPodExecWsUrl: (
clusterId: number | string,
namespace: string,
podName: string,
container?: string,
command?: string
) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`;
const wsBase = baseUrl.replace(/^http/, 'ws').replace('/api/v1', '');
const token = useUserStore.getState().token || '';
const params = new URLSearchParams();
if (container) params.set('container', container);
if (command) params.set('command', command);
if (token) params.set('token', token);
const qs = params.toString();
const url = `${wsBase}/ws/kubernetes-exec/${clusterId}/namespaces/${namespace}/pods/${podName}${qs ? `?${qs}` : ''}`;
return url;
},
// === 🆕 容器文件浏览器 (Logan <admin@attacker.club>) ===
// 后端 API 并行开发中,UI 先行;返回结构兼容 {entries:[{name,isDir,size,modTime}]} 或数组
listPodFiles: async (
clusterCode: string,
namespace: string,
podName: string,
path: string,
container?: string
) => {
return http.post(
`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/list`,
{ path, container }
);
},
// 构建文件下载 URL(带 token,供 window.open / fetch 使用)
buildPodFileDownloadUrl: (
clusterCode: string,
namespace: string,
podName: string,
path: string,
container?: string
) => {
const params = new URLSearchParams({ path });
if (container) params.set('container', container);
const token = useUserStore.getState().token || '';
if (token) params.set('token', token);
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
return `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/download?${params}`;
},
// 读取容器内文本文件(小文件预览)
readPodFileText: async (
clusterCode: string,
namespace: string,
podName: string,
path: string,
container?: string
) => {
const url = kubernetesApi.buildPodFileDownloadUrl(clusterCode, namespace, podName, path, container);
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.text();
},
// 上传文件到 Pod 容器内指定目录
uploadPodFile: async (
clusterCode: string,
namespace: string,
podName: string,
targetPath: string,
file: File,
container?: string,
) => {
const formData = new FormData();
formData.append('file', file);
formData.append('path', targetPath);
if (container) formData.append('container', container);
const token = useUserStore.getState().token || localStorage.getItem('token') || '';
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
const resp = await fetch(
`${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/upload`,
{
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
},
);
const json = await resp.json();
if (!resp.ok || json.code !== 200) throw new Error(json.message || `HTTP ${resp.status}`);
return json.data;
},
scaleDeployment: async (clusterCode: string, namespace: string, name: string, replicas: number) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/scale`, { replicas });
},
restartDeployment: async (clusterCode: string, namespace: string, name: string) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/restart`);
},
pauseDeployment: async (clusterCode: string, namespace: string, name: string) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/pause`);
},
resumeDeployment: async (clusterCode: string, namespace: string, name: string) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/resume`);
},
updateDeploymentImage: async (clusterCode: string, namespace: string, name: string, image: string, containerName?: string) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/update-image`, { image, container_name: containerName });
},
// === HPA 管理 (Logan.Li) ===
getHPAs: async (clusterCode: string, namespace?: string) => {
if (namespace) return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/hpas`);
return http.get(`/kubernetes/${clusterCode}/hpas`); // AllNS route
},
createHPA: async (clusterCode: string, data: { name: string; namespace: string; targetKind: string; targetName: string; minReplicas: number; maxReplicas: number; cpuPercent: number; memPercent: number }) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${data.namespace}/hpas`, data);
},
deleteHPA: async (clusterCode: string, namespace: string, name: string) => {
return http.delete(`/kubernetes/${clusterCode}/namespace/${namespace}/hpas/${name}`);
},
// === CronJob 管理 (Logan.Li) ===
getCronJobs: async (clusterCode: string, namespace?: string) => {
if (namespace) return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/cronjobs`);
return http.get(`/kubernetes-clusters/${clusterCode}/cronjobs`);
},
createCronJob: async (clusterCode: string, data: { name: string; namespace: string; schedule: string; image: string; command: string[] }) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${data.namespace}/cronjobs`, data);
},
suspendCronJob: async (clusterCode: string, namespace: string, name: string, suspend: boolean) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/cronjobs/${name}/suspend`, { suspend });
},
triggerCronJob: async (clusterCode: string, namespace: string, name: string) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/cronjobs/${name}/trigger`);
},
deleteCronJob: async (clusterCode: string, namespace: string, name: string) => {
return http.delete(`/kubernetes/${clusterCode}/namespace/${namespace}/cronjobs/${name}`);
},
// === Namespace-scoped resources (Kuboard-style) ===
getNamespaceDeployments: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`);
},
getNamespacePods: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pods`);
},
getNamespaceServices: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/services`);
},
getNamespaceIngresses: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/ingresses`);
},
getNamespaceConfigMaps: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/configmaps`);
},
getNamespaceSecrets: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/secrets`);
},
// === Namespace (Logan.Li) ===
getNamespaces: async (clusterCode: string) => {
return http.get(`/kubernetes/${clusterCode}/namespaces`);
},
// === All-namespace listing (Logan.Li) ===
getAllDeployments: async (clusterCode: string) => {
return http.get(`/kubernetes/${clusterCode}/deployments`);
},
getAllPods: async (clusterCode: string) => {
return http.get(`/kubernetes/${clusterCode}/pods`);
},
// === PVC (存储) ===
getPVCs: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pvcs`);
},
deletePVC: async (clusterCode: string, namespace: string, name: string) => {
return http.delete(`/kubernetes/${clusterCode}/namespace/${namespace}/pvcs/${name}`);
},
// === StatefulSet / DaemonSet ===
getStatefulSets: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets`);
},
getDaemonSets: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets`);
},
// === RBAC — ServiceAccount / Role / RoleBinding ===
getServiceAccounts: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/serviceaccounts`);
},
getRoles: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/roles`);
},
getRoleBindings: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/rolebindings`);
},
getStatefulSetDetail: async (clusterCode: string, namespace: string, name: string) => { return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets/${name}`); },
getDaemonSetDetail: async (clusterCode: string, namespace: string, name: string) => { return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets/${name}`); },
scaleStatefulSet: async (clusterCode: string, namespace: string, name: string, replicas: number) => { return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets/${name}/scale`, { replicas }); },
restartStatefulSet: async (clusterCode: string, namespace: string, name: string) => { return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets/${name}/restart`, {}); },
restartDaemonSet: async (clusterCode: string, namespace: string, name: string) => { return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets/${name}/restart`, {}); },
// === YAML ===
getResourceYaml: async (clusterCode: string, namespace: string, resource: string, name: string) => {
return http.getRaw(`/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`);
},
updateResourceYaml: async (clusterCode: string, namespace: string, resource: string, name: string, yaml: string) => {
return http.putRaw(`/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`, yaml, 'application/yaml');
},
deleteResourceYaml: async (clusterCode: string, namespace: string, resource: string, name: string) => {
return http.delete(`/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`);
},
// === Export CSV ===
exportAsCSV: (filename: string, data: any[]) => {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csv = [headers.join(','), ...data.map(( row: any) => headers.map(h => JSON.stringify(row[h] ?? '')).join(','))].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click();
},
exportAsJSON: (filename: string, data: any[]) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click();
},
// === 🆕 Pod Metrics (kubectl top pods) ===
getPodMetrics: async (clusterCode: string, namespace?: string) => {
const params = namespace ? { namespace } : undefined;
return http.get(`/kubernetes/${clusterCode}/metrics/pods`, params);
},
// === 🆕 Events ===
getEvents: async (clusterCode: string, namespace?: string, type?: string) => {
const params: Record<string, any> = {};
if (namespace) params.namespace = namespace;
if (type) params.type = type;
return http.get(`/kubernetes/${clusterCode}/events`, params);
},
getEventsSummary: async (clusterCode: string, namespace?: string, hours?: number) => {
const params: Record<string, any> = {};
if (namespace) params.namespace = namespace;
if (hours) params.hours = hours;
return http.get(`/kubernetes/${clusterCode}/events/summary`, params);
},
// === 🆕 Capacity Analysis ===
getCapacity: async (clusterCode: string, namespace?: string) => {
const params = namespace ? { namespace } : undefined;
return http.get(`/kubernetes/${clusterCode}/capacity`, params);
},
// === 🆕 M13: 多集群资源对比看板 ===
getMultiClusterComparison: async () => {
return http.get('/kubernetes/compare');
},
// === 🆕 YAML Apply ===
applyYAML: async (clusterCode: string, yaml: string) => {
return http.post(`/kubernetes/${clusterCode}/yaml/apply`, { yaml });
},
// === 🆕 ReplicaSet / Revision / Rollback (Logan.Li — Kuboard-style) ===
// 后端并行开发中,前端按约定路径预先对接;后端就绪后自动生效。
getReplicaSets: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/replicasets`);
},
getDeploymentRevisions: async (clusterCode: string, namespace: string, name: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/revisions`);
},
rollbackDeployment: async (clusterCode: string, namespace: string, name: string, revision: number) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/rollback`, { revision });
},
// 单个 Deployment 详情(如后端提供专用端点则用之,否则从列表过滤)
getDeployment: async (clusterCode: string, namespace: string, name: string) => {
try {
return await http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}`);
} catch {
// 降级:从列表中过滤
const res: Record<string, any> = await http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`);
const list = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []);
return list.find(( d: any) => d.name === name) || null;
}
},
// === 🆕 M14: Namespace 管理看板 ===
getNamespacesDetail: async (clusterCode: string) => {
return http.get(`/kubernetes/${clusterCode}/namespaces/detail`);
},
getNamespaceDetail: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/detail`);
},
createNamespace: async (clusterCode: string, name: string, labels?: Record<string, string>) => {
return http.post(`/kubernetes/${clusterCode}/namespaces`, { name, labels: labels || {} });
},
deleteNamespace: async (clusterCode: string, namespace: string) => {
return http.delete(`/kubernetes/${clusterCode}/namespaces/${namespace}`);
},
};
// ==================== 告警 API ====================
export { kubernetesApi } from './kubernetes/index';
@@ -0,0 +1,22 @@
// Kubernetes 集群管理 API // Logan.Li
import { http, type ListResponse } from '../../http';
export const clusterApi = {
getClusters: async () => http.get<ListResponse>('/kubernetes-clusters'),
getClusterDetail: async (id: number | string) => http.get(`/kubernetes-clusters/${id}/detail`),
createCluster: async (data: Record<string, unknown>) => http.post('/kubernetes-clusters', data),
updateCluster: async (id: number | string, data: Record<string, unknown>) => http.put(`/kubernetes-clusters/${id}`, data),
deleteCluster: async (id: number | string) => http.delete(`/kubernetes-clusters/${id}`),
getPods: async (clusterId: number | string) => http.get(`/kubernetes-clusters/${clusterId}/pods`),
getDeployments: async (clusterId: number | string) => http.get(`/kubernetes-clusters/${clusterId}/deployments`),
getServices: async (clusterId: number | string) => http.get(`/kubernetes-clusters/${clusterId}/services`),
getIngresses: async (clusterId: number | string) => http.get(`/kubernetes-clusters/${clusterId}/ingresses`),
syncCluster: async (clusterId: number | string) => http.post(`/kubernetes-clusters/${clusterId}/sync`),
exportAllResources: async (clusterCode: string) =>
http.download(`/kubernetes/${clusterCode}/export`, `cluster-${clusterCode}-resources.tar.gz`),
getTopology: async (clusterId: number | string) => http.get(`/kubernetes-clusters/${clusterId}/topology`),
saveTopology: async (clusterId: number | string, layoutData: string) =>
http.put(`/kubernetes-clusters/${clusterId}/topology`, { layout_data: layoutData }),
getMultiClusterComparison: async () => http.get('/kubernetes/compare'),
};
@@ -0,0 +1,94 @@
// Kubernetes API 辅助:流式 URL / WebSocket / 导出 // Logan.Li
import { buildApiUrl, getAuthToken, getWsBase, http } from '../../http';
import { exportAsCSV, exportAsJSON } from '@/lib/export-utils';
export { exportAsCSV, exportAsJSON };
export function buildPodLogsStreamUrl(
clusterCode: string,
namespace: string,
podName: string,
opts: {
container?: string;
tailLines?: number;
previous?: boolean;
sinceTime?: string;
} = {},
): string {
const params = new URLSearchParams();
params.set('stream', 'true');
if (opts.container) params.set('container', opts.container);
if (opts.tailLines && opts.tailLines > 0) params.set('tailLines', String(opts.tailLines));
if (opts.previous) params.set('previous', 'true');
if (opts.sinceTime) params.set('sinceTime', opts.sinceTime);
return buildApiUrl(
`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs`,
params,
);
}
export function buildPodExecWsUrl(
clusterId: number | string,
namespace: string,
podName: string,
container?: string,
command?: string,
): string {
const params = new URLSearchParams();
if (container) params.set('container', container);
if (command) params.set('command', command);
const token = getAuthToken();
if (token) params.set('token', token);
const qs = params.toString();
const wsBase = getWsBase();
return `${wsBase}/ws/kubernetes-exec/${clusterId}/namespaces/${namespace}/pods/${podName}${qs ? `?${qs}` : ''}`;
}
export function buildPodFileDownloadUrl(
clusterCode: string,
namespace: string,
podName: string,
path: string,
container?: string,
): string {
const params = new URLSearchParams({ path });
if (container) params.set('container', container);
const token = getAuthToken();
if (token) params.set('token', token);
return buildApiUrl(
`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/download`,
params,
);
}
export async function readPodFileText(
clusterCode: string,
namespace: string,
podName: string,
path: string,
container?: string,
): Promise<string> {
const url = buildPodFileDownloadUrl(clusterCode, namespace, podName, path, container);
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.text();
}
export async function uploadPodFile(
clusterCode: string,
namespace: string,
podName: string,
targetPath: string,
file: File,
container?: string,
): Promise<unknown> {
const formData = new FormData();
formData.append('file', file);
formData.append('path', targetPath);
if (container) formData.append('container', container);
return http.upload(
`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/upload`,
formData,
);
}
@@ -0,0 +1,20 @@
// Kubernetes API 聚合出口 // Logan.Li
import { clusterApi } from './cluster';
import { nodeApi } from './node';
import { podApi } from './pod';
import { workloadApi } from './workload';
import { namespaceApi } from './namespace';
import { resourceApi } from './resource';
import { exportAsCSV, exportAsJSON } from './helpers';
export const kubernetesApi = {
...clusterApi,
...nodeApi,
...podApi,
...workloadApi,
...namespaceApi,
...resourceApi,
exportAsCSV,
exportAsJSON,
};
@@ -0,0 +1,14 @@
// Kubernetes Namespace API // Logan.Li
import { http } from '../../http';
export const namespaceApi = {
getNamespaces: async (clusterCode: string) => http.get(`/kubernetes/${clusterCode}/namespaces`),
getNamespacesDetail: async (clusterCode: string) => http.get(`/kubernetes/${clusterCode}/namespaces/detail`),
getNamespaceDetail: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/detail`),
createNamespace: async (clusterCode: string, name: string, labels?: Record<string, string>) =>
http.post(`/kubernetes/${clusterCode}/namespaces`, { name, labels: labels || {} }),
deleteNamespace: async (clusterCode: string, namespace: string) =>
http.delete(`/kubernetes/${clusterCode}/namespaces/${namespace}`),
};
@@ -0,0 +1,10 @@
// Kubernetes 节点 API // Logan.Li
import { http } from '../../http';
export const nodeApi = {
getNodes: async (clusterId: number | string) => http.get(`/kubernetes-clusters/${clusterId}/nodes`),
getClusterNodes: async (clusterCode: string) => http.get(`/kubernetes/${clusterCode}/nodes`),
getNodeDetail: async (clusterCode: string, nodeName: string) =>
http.get(`/kubernetes/${clusterCode}/nodes/${encodeURIComponent(nodeName)}`),
};
@@ -0,0 +1,69 @@
// Kubernetes Pod API(日志 / Exec / 文件 / 指标) // Logan.Li
import { http } from '../../http';
import {
buildPodExecWsUrl,
buildPodFileDownloadUrl,
buildPodLogsStreamUrl,
readPodFileText,
uploadPodFile,
} from './helpers';
export const podApi = {
getPodLogs: async (
clusterCode: string,
namespace: string,
podName: string,
tail = 200,
container?: string,
) => {
const params = new URLSearchParams({ tail: String(tail) });
if (container) params.set('container', container);
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?${params}`);
},
getPodLogsEnhanced: async (
clusterCode: string,
namespace: string,
podName: string,
opts: {
container?: string;
tailLines?: number;
previous?: boolean;
sinceTime?: string;
} = {},
) => {
const params = new URLSearchParams();
if (opts.container) params.set('container', opts.container);
if (opts.tailLines && opts.tailLines > 0) params.set('tailLines', String(opts.tailLines));
if (opts.previous) params.set('previous', 'true');
if (opts.sinceTime) params.set('sinceTime', opts.sinceTime);
const qs = params.toString();
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs${qs ? `?${qs}` : ''}`);
},
buildPodLogsStreamUrl,
execPod: async (
clusterCode: string,
namespace: string,
podName: string,
command: string[],
container?: string,
) => http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/exec`, { command, container }),
buildPodExecWsUrl,
listPodFiles: async (
clusterCode: string,
namespace: string,
podName: string,
path: string,
container?: string,
) => http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/list`, { path, container }),
buildPodFileDownloadUrl,
readPodFileText,
uploadPodFile,
getPodMetrics: async (clusterCode: string, namespace?: string) => {
const params = namespace ? { namespace } : undefined;
return http.get(`/kubernetes/${clusterCode}/metrics/pods`, params);
},
getAllPods: async (clusterCode: string) => http.get(`/kubernetes/${clusterCode}/pods`),
getNamespacePods: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pods`),
};
@@ -0,0 +1,48 @@
// Kubernetes 命名空间资源 / YAML / 事件 / 容量 API // Logan.Li
import { http } from '../../http';
export const resourceApi = {
getNamespaceServices: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/services`),
getNamespaceIngresses: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/ingresses`),
getNamespaceConfigMaps: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/configmaps`),
getNamespaceSecrets: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/secrets`),
getPVCs: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pvcs`),
deletePVC: async (clusterCode: string, namespace: string, name: string) =>
http.delete(`/kubernetes/${clusterCode}/namespace/${namespace}/pvcs/${name}`),
getServiceAccounts: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/serviceaccounts`),
getRoles: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/roles`),
getRoleBindings: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/rolebindings`),
getResourceYaml: async (clusterCode: string, namespace: string, resource: string, name: string) =>
http.getRaw(`/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`),
updateResourceYaml: async (clusterCode: string, namespace: string, resource: string, name: string, yaml: string) =>
http.putRaw(`/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`, yaml, 'application/yaml'),
deleteResourceYaml: async (clusterCode: string, namespace: string, resource: string, name: string) =>
http.delete(`/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`),
getEvents: async (clusterCode: string, namespace?: string, type?: string) => {
const params: Record<string, string> = {};
if (namespace) params.namespace = namespace;
if (type) params.type = type;
return http.get(`/kubernetes/${clusterCode}/events`, params);
},
getEventsSummary: async (clusterCode: string, namespace?: string, hours?: number) => {
const params: Record<string, string | number> = {};
if (namespace) params.namespace = namespace;
if (hours) params.hours = hours;
return http.get(`/kubernetes/${clusterCode}/events/summary`, params);
},
getCapacity: async (clusterCode: string, namespace?: string) => {
const params = namespace ? { namespace } : undefined;
return http.get(`/kubernetes/${clusterCode}/capacity`, params);
},
applyYAML: async (clusterCode: string, yaml: string) =>
http.post(`/kubernetes/${clusterCode}/yaml/apply`, { yaml }),
};
@@ -0,0 +1,91 @@
// Kubernetes 工作负载 API(Deployment / StatefulSet / HPA / CronJob) // Logan.Li
import { http } from '../../http';
export const workloadApi = {
scaleDeployment: async (clusterCode: string, namespace: string, name: string, replicas: number) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/scale`, { replicas }),
restartDeployment: async (clusterCode: string, namespace: string, name: string) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/restart`),
pauseDeployment: async (clusterCode: string, namespace: string, name: string) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/pause`),
resumeDeployment: async (clusterCode: string, namespace: string, name: string) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/resume`),
updateDeploymentImage: async (
clusterCode: string,
namespace: string,
name: string,
image: string,
containerName?: string,
) => http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/update-image`, {
image,
container_name: containerName,
}),
getHPAs: async (clusterCode: string, namespace?: string) => {
if (namespace) return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/hpas`);
return http.get(`/kubernetes/${clusterCode}/hpas`);
},
createHPA: async (
clusterCode: string,
data: {
name: string;
namespace: string;
targetKind: string;
targetName: string;
minReplicas: number;
maxReplicas: number;
cpuPercent: number;
memPercent: number;
},
) => http.post(`/kubernetes/${clusterCode}/namespace/${data.namespace}/hpas`, data),
deleteHPA: async (clusterCode: string, namespace: string, name: string) =>
http.delete(`/kubernetes/${clusterCode}/namespace/${namespace}/hpas/${name}`),
getCronJobs: async (clusterCode: string, namespace?: string) => {
if (namespace) return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/cronjobs`);
return http.get(`/kubernetes-clusters/${clusterCode}/cronjobs`);
},
createCronJob: async (
clusterCode: string,
data: { name: string; namespace: string; schedule: string; image: string; command: string[] },
) => http.post(`/kubernetes/${clusterCode}/namespace/${data.namespace}/cronjobs`, data),
suspendCronJob: async (clusterCode: string, namespace: string, name: string, suspend: boolean) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/cronjobs/${name}/suspend`, { suspend }),
triggerCronJob: async (clusterCode: string, namespace: string, name: string) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/cronjobs/${name}/trigger`),
deleteCronJob: async (clusterCode: string, namespace: string, name: string) =>
http.delete(`/kubernetes/${clusterCode}/namespace/${namespace}/cronjobs/${name}`),
getNamespaceDeployments: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`),
getAllDeployments: async (clusterCode: string) => http.get(`/kubernetes/${clusterCode}/deployments`),
getStatefulSets: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets`),
getDaemonSets: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets`),
getStatefulSetDetail: async (clusterCode: string, namespace: string, name: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets/${name}`),
getDaemonSetDetail: async (clusterCode: string, namespace: string, name: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets/${name}`),
scaleStatefulSet: async (clusterCode: string, namespace: string, name: string, replicas: number) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets/${name}/scale`, { replicas }),
restartStatefulSet: async (clusterCode: string, namespace: string, name: string) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets/${name}/restart`, {}),
restartDaemonSet: async (clusterCode: string, namespace: string, name: string) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets/${name}/restart`, {}),
getReplicaSets: async (clusterCode: string, namespace: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/replicasets`),
getDeploymentRevisions: async (clusterCode: string, namespace: string, name: string) =>
http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/revisions`),
rollbackDeployment: async (clusterCode: string, namespace: string, name: string, revision: number) =>
http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/rollback`, { revision }),
getDeployment: async (clusterCode: string, namespace: string, name: string) => {
try {
return await http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}`);
} catch {
const res: Record<string, unknown> = await http.get(
`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`,
);
const list = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []);
return (list as { name: string }[]).find((d) => d.name === name) || null;
}
},
};
+9 -137
View File
@@ -1,138 +1,10 @@
// 审计日志 & 审批流程
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
// 系统域 API — 子模块 re-export // Logan.Li
import { http, type ListResponse } from '../http';
export const auditApi = {
getLogs: async (params?: { page?: number; page_size?: number; module?: string; action?: string; username?: string; start_time?: string; end_time?: string }) => {
return http.get<ListResponse>('/audit/logs', params as Record<string, string | number | boolean | undefined>);
},
getLogDetail: async (id: number | string) => {
return http.get(`/audit/logs/${id}`);
},
getModules: async () => {
return http.get('/audit/modules');
},
getActions: async () => {
return http.get('/audit/actions');
},
getStatistics: async () => {
return http.get('/audit/statistics');
},
exportLogs: async (params?: Record<string, string | number | boolean | undefined>) => {
// Build URL with query params for server-side CSV export
const qs = new URLSearchParams();
qs.set('format', 'csv');
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
});
}
return http.download(`/audit/logs/export?${qs.toString()}`);
},
clearLogs: async () => {
return http.post('/audit/logs/clear');
},
};
// ==================== 终端会话审计 API(WebSSH 录制) ==================== // Logan.Li
export const terminalSessionApi = {
list: async (params?: { page?: number; page_size?: number; server_id?: number; username?: string; status?: string }) => {
return http.get<ListResponse>('/terminal-sessions', params as Record<string, string | number | boolean | undefined>);
},
get: async (id: number) => {
return http.get(`/terminal-sessions/${id}`);
},
getRecord: async (id: number) => {
return http.get<string>(`/terminal-sessions/${id}/record`);
},
kill: async (id: number) => {
return http.post(`/terminal-sessions/${id}/kill`);
},
download: async (id: number) => {
return http.download(`/terminal-sessions/${id}/download`, `session-${id}.cast`);
},
delete: async (id: number) => {
return http.delete(`/terminal-sessions/${id}`);
},
batchDelete: async (ids: number[]) => {
return http.post('/terminal-sessions/batch-delete', { ids });
},
};
// ==================== 会话命令审计 API ====================
export const sessionCommandApi = {
list: async (params?: { page?: number; page_size?: number; session_id?: number; username?: string }) => {
return http.get<ListResponse>('/session-commands', params as Record<string, string | number | boolean | undefined>);
},
};
// ==================== 登录日志 API ====================
export const loginLogApi = {
list: async (params?: { page?: number; page_size?: number; username?: string; status?: string }) => {
return http.get<ListResponse>('/login-logs', params as Record<string, string | number | boolean | undefined>);
},
};
// ==================== 审批流程配置 API(工作流定义) ====================
export const approvalWorkflowApi = {
list: async (params?: { category?: string }) => {
return http.get('/approval-workflows', params);
},
get: async (id: number | string) => {
return http.get(`/approval-workflows/${id}`);
},
create: async (data: any) => {
return http.post('/approval-workflows', data);
},
update: async (id: number | string, data: any) => {
return http.put(`/approval-workflows/${id}`, data);
},
delete: async (id: number | string) => {
return http.delete(`/approval-workflows/${id}`);
},
setDefault: async (id: number | string) => {
return http.put(`/approval-workflows/${id}/default`);
},
getSteps: async (id: number | string) => {
return http.get(`/approval-workflows/${id}/steps`);
},
};
// ==================== 审批流程 API ====================
export const approvalApi = {
getTemplates: async (params?: { type?: string; category?: string }) => {
return http.get('/approvals/templates', params);
},
getTemplate: async (id: number | string) => {
return http.get(`/approvals/templates/${id}`);
},
createTemplate: async ( data: any) => {
return http.post('/approvals/templates', data);
},
updateTemplate: async (id: number | string, data: any) => {
return http.put(`/approvals/templates/${id}`, data);
},
deleteTemplate: async (id: number | string) => {
return http.delete(`/approvals/templates/${id}`);
},
getProcesses: async (params?: { page?: number; page_size?: number; status?: string; category?: string }) => {
return http.get('/approvals/processes', params);
},
createProcess: async ( data: any) => {
return http.post('/approvals/processes', data);
},
approveProcess: async (id: number | string, comment?: string) => {
return http.post(`/approvals/processes/${id}/approve`, { comment });
},
rejectProcess: async (id: number | string, comment?: string) => {
return http.post(`/approvals/processes/${id}/reject`, { comment });
},
};
// ==================== 云管理 API ====================
export {
auditApi,
terminalSessionApi,
sessionCommandApi,
loginLogApi,
approvalWorkflowApi,
approvalApi,
} from './system/index';
@@ -0,0 +1,28 @@
// 审批流程 API // Logan.Li
import { http } from '../../http';
export const approvalWorkflowApi = {
list: async (params?: { category?: string }) => http.get('/approval-workflows', params),
get: async (id: number | string) => http.get(`/approval-workflows/${id}`),
create: async (data: Record<string, unknown>) => http.post('/approval-workflows', data),
update: async (id: number | string, data: Record<string, unknown>) => http.put(`/approval-workflows/${id}`, data),
delete: async (id: number | string) => http.delete(`/approval-workflows/${id}`),
setDefault: async (id: number | string) => http.put(`/approval-workflows/${id}/default`),
getSteps: async (id: number | string) => http.get(`/approval-workflows/${id}/steps`),
};
export const approvalApi = {
getTemplates: async (params?: { type?: string; category?: string }) => http.get('/approvals/templates', params),
getTemplate: async (id: number | string) => http.get(`/approvals/templates/${id}`),
createTemplate: async (data: Record<string, unknown>) => http.post('/approvals/templates', data),
updateTemplate: async (id: number | string, data: Record<string, unknown>) => http.put(`/approvals/templates/${id}`, data),
deleteTemplate: async (id: number | string) => http.delete(`/approvals/templates/${id}`),
getProcesses: async (params?: { page?: number; page_size?: number; status?: string; category?: string }) =>
http.get('/approvals/processes', params),
createProcess: async (data: Record<string, unknown>) => http.post('/approvals/processes', data),
approveProcess: async (id: number | string, comment?: string) =>
http.post(`/approvals/processes/${id}/approve`, { comment }),
rejectProcess: async (id: number | string, comment?: string) =>
http.post(`/approvals/processes/${id}/reject`, { comment }),
};
+30
View File
@@ -0,0 +1,30 @@
// 审计日志 API // Logan.Li
import { http, type ListResponse } from '../../http';
export const auditApi = {
getLogs: async (params?: {
page?: number;
page_size?: number;
module?: string;
action?: string;
username?: string;
start_time?: string;
end_time?: string;
}) => http.get<ListResponse>('/audit/logs', params as Record<string, string | number | boolean | undefined>),
getLogDetail: async (id: number | string) => http.get(`/audit/logs/${id}`),
getModules: async () => http.get('/audit/modules'),
getActions: async () => http.get('/audit/actions'),
getStatistics: async () => http.get('/audit/statistics'),
exportLogs: async (params?: Record<string, string | number | boolean | undefined>) => {
const qs = new URLSearchParams();
qs.set('format', 'csv');
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
});
}
return http.download(`/audit/logs/export?${qs.toString()}`);
},
clearLogs: async () => http.post('/audit/logs/clear'),
};
@@ -0,0 +1,5 @@
// 系统域 API 聚合出口 // Logan.Li
export { auditApi } from './audit';
export { terminalSessionApi, sessionCommandApi, loginLogApi } from './terminal';
export { approvalWorkflowApi, approvalApi } from './approval';
@@ -0,0 +1,37 @@
// 终端会话 / 命令 / 登录日志 API // Logan.Li
import { http, type ListResponse } from '../../http';
export const terminalSessionApi = {
list: async (params?: {
page?: number;
page_size?: number;
server_id?: number;
username?: string;
status?: string;
}) => http.get<ListResponse>('/terminal-sessions', params as Record<string, string | number | boolean | undefined>),
get: async (id: number) => http.get(`/terminal-sessions/${id}`),
getRecord: async (id: number) => http.get<string>(`/terminal-sessions/${id}/record`),
kill: async (id: number) => http.post(`/terminal-sessions/${id}/kill`),
download: async (id: number) => http.download(`/terminal-sessions/${id}/download`, `session-${id}.cast`),
delete: async (id: number) => http.delete(`/terminal-sessions/${id}`),
batchDelete: async (ids: number[]) => http.post('/terminal-sessions/batch-delete', { ids }),
};
export const sessionCommandApi = {
list: async (params?: {
page?: number;
page_size?: number;
session_id?: number;
username?: string;
}) => http.get<ListResponse>('/session-commands', params as Record<string, string | number | boolean | undefined>),
};
export const loginLogApi = {
list: async (params?: {
page?: number;
page_size?: number;
username?: string;
status?: string;
}) => http.get<ListResponse>('/login-logs', params as Record<string, string | number | boolean | undefined>),
};
+22 -3
View File
@@ -27,12 +27,31 @@ export function getApiBase(): string {
return API_PREFIX;
}
function resolveApiUrl(path: string): string {
/** 拼接 REST API 完整路径(含 /api/v1 前缀) // Logan.Li */
export function buildApiUrl(path: string, params?: URLSearchParams): string {
const p = path.startsWith('/') ? path : `/${path}`;
return `${getApiBase()}${p}`;
let url = `${getApiBase()}${p}`;
const qs = params?.toString();
if (qs) url += `?${qs}`;
return url;
}
function getAuthToken(): string {
function resolveApiUrl(path: string): string {
return buildApiUrl(path);
}
/** 获取 WebSocket 根路径(不含 /api/v1) // Logan.Li */
export function getWsBase(): string {
const envBase = import.meta.env.VITE_API_BASE_URL;
if (envBase) {
const origin = String(envBase).replace(/\/$/, '').replace(/\/api\/v1$/, '');
return origin.replace(/^http/, 'ws');
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}`;
}
export function getAuthToken(): string {
return useUserStore.getState().token || localStorage.getItem('token') || '';
}