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:
1 parent
7fb2236b4d
commit
0b404b4611
226 files changed
+3040
-2596
No files matched your search
Generated
+1
-1
@@ -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" />
|
||||
刷新
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '',
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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>),
|
||||
};
|
||||
@@ -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') || '';
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user