feat: 新增日志中心、功能优化与代码重构

- 新增 log_datasource.go / log_proxy.go / user_api_token.go 后端控制器
- 新增 log_datasource.go model
- 新增 monitor/log-center.ts API 模块
- 新增 LogCenter 前端组件及页面
- 重构 deployment 相关组件 (HPDA/UpdateImageModal/bundle-yaml 等)
- 新增 ResetPasswordDialog / code-snippet-block / copy-button 等 UI 组件
- 重构多个 k8s 相关组件 (TopologyView/ResourceVisualEditor/PodFileBrowser 等)
- 更新多个 API 和工具函数 (auth.ts/monitor/index.ts/request.ts 等)
- 更新静态资源 (index.html + 新 JS/CSS bundle)
- 删除废弃静态资源文件
This commit is contained in:
Logan committed 2026-06-26 23:18:05 +08:00
1 parent 5ca0e6d83a
commit 8d46e8f352
197 files changed
+5014 -815

No files matched your search

@@ -7,6 +7,7 @@
// 作者: Logan <admin@attacker.club>
import { useParams, useNavigate } from 'react-router-dom';
import { DeploymentWorkload } from './DeploymentWorkload';
import { isCreateDeployment } from './deployment-constants';
export type WorkloadMode = 'view' | 'edit' | 'yaml';
@@ -23,7 +24,9 @@ export default function DeploymentDetailPage() {
const clusterCode = params.clusterCode || '';
const namespace = params.namespace || '';
const deploymentName = params.name || '';
const currentMode: WorkloadMode = (params.mode === 'edit' || params.mode === 'yaml') ? params.mode : 'view';
const currentMode: WorkloadMode = isCreateDeployment(deploymentName)
? 'edit'
: ((params.mode === 'edit' || params.mode === 'yaml') ? params.mode : 'view');
// 模式切换:在 view/edit/yaml 路由间跳转
const handleModeChange = (m: WorkloadMode) => {
@@ -18,6 +18,7 @@ interface DeploymentEditBasicProps {
manifest: any | null;
onSaved: (updated?: any) => void;
onCancel: () => void;
isCreate?: boolean;
}
/** 将 metadata.labels 对象转为 key-value 数组 */
@@ -36,11 +37,12 @@ function kvToObj(arr: Array<{ key: string; value: string }>): Record<string, str
return out;
}
export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditBasicProps) {
export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel, isCreate = false }: DeploymentEditBasicProps) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [replicas, setReplicas] = useState(1);
const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]);
const [annotations, setAnnotations] = useState<Array<{ key: string; value: string }>>([]);
@@ -51,6 +53,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
const m = manifest;
if (!m) { setLoading(false); return; }
setRawManifest(m);
setResName(m?.metadata?.name || '');
setReplicas(m?.spec?.replicas ?? 1);
setLabels(objToKv(m?.metadata?.labels));
setAnnotations(objToKv(m?.metadata?.annotations));
@@ -101,6 +104,12 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
base.spec.replicas = replicas;
const labelObj = kvToObj(labels);
base.metadata = base.metadata || {};
if (isCreate) {
const trimmed = resName.trim();
if (!trimmed) throw new Error('名称不能为空');
base.metadata.name = trimmed;
base.metadata.namespace = namespace;
}
base.metadata.labels = Object.keys(labelObj).length > 0 ? labelObj : undefined;
const annObj = kvToObj(annotations);
base.metadata.annotations = Object.keys(annObj).length > 0 ? annObj : undefined;
@@ -117,8 +126,8 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
} else {
delete base.spec.template.metadata.labels;
}
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: '基本信息已保存', description: `${deploymentName} 更新已提交` });
const savedName = await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: isCreate ? '创建成功' : '基本信息已保存', description: isCreate ? `${savedName} 已创建` : `${savedName} 更新已提交` });
onSaved(base);
} catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
@@ -170,19 +179,23 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</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">
<Lock className="h-4 w-4 text-slate-500" />
基本标识(只读)
{isCreate ? '基本标识' : '基本标识(只读)'}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-400 text-xs">名称 (name)</Label>
<Input value={deploymentName} readOnly
className="mt-1 bg-slate-950 border-slate-800 text-slate-500 font-mono text-sm cursor-not-allowed" />
<Input
value={isCreate ? resName : deploymentName}
readOnly={!isCreate}
onChange={e => isCreate && setResName(e.target.value)}
className={`mt-1 font-mono text-sm ${isCreate ? 'bg-slate-800 border-slate-700 text-slate-200' : 'bg-slate-950 border-slate-800 text-slate-500 cursor-not-allowed'}`}
/>
</div>
<div>
<Label className="text-slate-400 text-xs">命名空间 (namespace)</Label>
@@ -198,7 +198,7 @@ export function DeploymentView(props: DeploymentViewProps) {
)}
<InfoItem label="创建时间" value={formatK8sTime(deployment?.created_at || deployment?.creationTimestamp)} />
</div>
{pods[0] && (
{pods.length === 1 && (
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
<PodConditionsRow pod={pods[0]} />
</div>
@@ -249,7 +249,7 @@ export function DeploymentView(props: DeploymentViewProps) {
</div>
{/* Tab Content */}
{tab === 'pods' && <PodsTab {...props} expandedPod={expandedPod} setExpandedPod={setExpandedPod} />}
{tab === 'pods' && <PodsTab {...props} onDeletePod={props.onDeletePod} expandedPod={expandedPod} setExpandedPod={setExpandedPod} />}
{tab === 'services' && <ServicesTab services={services} ingresses={ingresses} loading={tabLoading} namespace={namespace} />}
{tab === 'events' && <EventsTab events={events} loading={tabLoading} />}
{tab === 'scale' && (
@@ -2,7 +2,7 @@
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器)
// 作者: Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -26,6 +26,7 @@ import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { useUserStore } from '@/store';
import { YamlViewer } from './YamlViewer';
import { loadDeploymentBundleYaml, saveDeploymentBundleYaml } from './deployment-bundle-yaml';
import { DeploymentView } from './DeploymentView';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
@@ -34,11 +35,15 @@ import { CICDWebhookContent } from './deployment-workload/CICDWebhookContent';
import { ModalShell } from './deployment-workload/ModalShell';
import { PodLogsModal } from './deployment-workload/PodLogsModal';
import { ActionModals } from './deployment-workload/ActionModals';
import { createEmptyManifest } from './resource-create-templates';
import { CREATE_DEPLOYMENT_SLUG, isCreateDeployment } from './deployment-constants';
import { extractContainers } from './deployment-detail/helpers';
import type { ActionModal, DeploymentWorkloadProps } from './deployment-workload/types';
export type { DeploymentWorkloadProps, WorkloadMode } from './deployment-workload/types';
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
const isCreate = isCreateDeployment(deploymentName);
const navigate = useNavigate();
const { toast } = useToast();
const confirm = useConfirm();
@@ -58,9 +63,30 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
const [fileBrowserPod, setFileBrowserPod] = useState<string | null>(null);
const clusterId = deployment?.cluster_id || deployment?.clusterId || clusterCode;
// CI/CD curl 示例:镜像与主容器名与当前 Deployment 对齐 // Logan.Li
const cicdContainerName = useMemo(() => {
const fromManifest = manifest?.spec?.template?.spec?.containers?.[0]?.name;
if (fromManifest) return fromManifest as string;
if (pods.length > 0) {
const names = extractContainers(pods[0]);
if (names.length) return names[0];
}
return '';
}, [manifest, pods]);
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
const loadData = useCallback(async () => {
if (isCreate) {
const empty = createEmptyManifest('deployments', namespace, 'my-app');
setDeployment({ name: 'my-app', namespace, replicas: 1 });
setManifest(empty);
setPods([]);
setReplicaSets([]);
setRevisions([]);
setLoading(false);
return;
}
setLoading(true);
try {
const [dep, podRes, rsRes, revRes] = await Promise.all([
@@ -94,10 +120,11 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
} finally {
setLoading(false);
}
}, [clusterCode, namespace, deploymentName]);
}, [clusterCode, namespace, deploymentName, isCreate]);
// 加载 manifest(用于 edit 模式 + YAML 编辑)
const loadManifest = useCallback(async () => {
if (isCreate) return;
try {
const yamlText = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName);
const { parseEditableManifest } = await import('./k8s-manifest-utils');
@@ -109,8 +136,21 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
}
}, [clusterCode, namespace, deploymentName, deployment]);
const loadBundleYaml = useCallback(
() => loadDeploymentBundleYaml(clusterCode, namespace, deploymentName),
[clusterCode, namespace, deploymentName],
);
const saveBundleYaml = useCallback(
(yaml: string) => saveDeploymentBundleYaml(clusterCode, namespace, yaml),
[clusterCode, namespace],
);
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => { if (mode === 'edit' && !manifest) loadManifest(); }, [mode, manifest, loadManifest]);
useEffect(() => {
if (isCreate || mode !== 'edit' || manifest) return;
loadManifest();
}, [mode, manifest, loadManifest, isCreate]);
// ============ 操作 ============
const _handleScale = async (delta: number) => {
@@ -178,8 +218,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
} finally { setActionLoading(''); }
};
const handleCICD = () => {
// 打开 Webhook 弹窗(不跳转流水线)
const handleCICD = async () => {
// 打开 Webhook 弹窗前拉取 manifest,以获取准确容器名 // Logan.Li
if (!isCreate && !manifest) await loadManifest();
setModal({ kind: 'cicd' });
};
@@ -187,7 +228,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
if (!newImage.trim()) return;
setActionLoading('image');
try {
await kubernetesApi.updateDeploymentImage(clusterCode, namespace, deploymentName, newImage);
await kubernetesApi.updateDeploymentImage(
clusterCode, namespace, deploymentName, newImage, cicdContainerName || undefined,
);
toast({ title: '镜像更新成功', description: `正在滚动更新为 ${newImage}` });
setModal(null);
loadData();
@@ -223,6 +266,22 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
setFileBrowserPod(pod.name);
};
const handleDeletePod = async (pod: any) => {
const ok = await confirm({
title: '删除 Pod',
description: `确认删除 Pod "${pod.name}"?操作不可恢复。`,
confirmText: '删除',
});
if (!ok) return;
try {
await kubernetesApi.deletePod(clusterCode, namespace, pod.name);
toast({ title: 'Pod 已删除' });
loadData();
} catch (e) {
toast({ title: '删除失败', description: (e as any)?.message || String(e), variant: 'destructive' });
}
};
// ============ 渲染 ============
if (loading) {
return (
@@ -235,7 +294,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
);
}
if (!deployment) {
if (!deployment && !isCreate) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-12 text-center">
@@ -247,6 +306,37 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
);
}
if (isCreate) {
const handleCreated = (updated?: any) => {
const name = updated?.metadata?.name;
if (name) {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${name}`);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
<ArrowLeft className="h-4 w-4 mr-1" />返回
</Button>
<div>
<h1 className="text-xl font-bold text-slate-100">新建 Deployment</h1>
<p className="text-xs text-slate-500 mt-0.5">{namespace} · {clusterCode}</p>
</div>
</div>
<EditModeContainer
clusterCode={clusterCode}
namespace={namespace}
deploymentName={CREATE_DEPLOYMENT_SLUG}
manifest={manifest}
isCreate
onSaved={handleCreated}
onCancel={onBack}
/>
</div>
);
}
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
const total = deployment.replicas || 0;
const isHealthy = ready === total && total > 0;
@@ -314,7 +404,15 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
{/* 操作按钮行 */}
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => setModal({ kind: 'image' })}>
<Button
variant="outline"
size="sm"
className="border-slate-700 text-slate-300"
onClick={() => {
setNewImage(deployment?.image || '');
setModal({ kind: 'image' });
}}
>
<ImageIcon className="h-3.5 w-3.5 mr-1" />调整镜像
</Button>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={handleRestart}>
@@ -355,6 +453,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
onShowPodTerminal={handleShowPodTerminal}
onShowPodYaml={handleShowPodYaml}
onShowPodFileBrowser={handleShowPodFileBrowser}
onDeletePod={handleDeletePod}
/>
) : mode === 'yaml' ? (
/* YAML 编辑模式 — 内联 CodeMirror 编辑器 */
@@ -364,6 +463,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
resource="deployments"
name={deploymentName}
inline
resourceHint="含关联 Service"
loadYamlFn={loadBundleYaml}
saveYamlFn={saveBundleYaml}
onSaved={loadData}
onClose={() => onModeChange('view')}
/>
@@ -386,6 +488,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
namespace={namespace}
resource="deployments"
name={deploymentName}
resourceHint="含关联 Service"
loadYamlFn={loadBundleYaml}
saveYamlFn={saveBundleYaml}
onClose={() => setModal(null)}
onSaved={loadData}
/>
@@ -409,8 +514,14 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
{/* ============ CI/CD Webhook 弹窗 ============ */}
{modal?.kind === 'cicd' && (
<ModalShell title="CI/CD Webhook" onClose={() => setModal(null)}>
<CICDWebhookContent clusterCode={clusterCode} namespace={namespace} deploymentName={deploymentName} />
<ModalShell title="CI/CD Webhook" onClose={() => setModal(null)} size="lg">
<CICDWebhookContent
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
image={deployment?.image || newImage || ''}
containerName={cicdContainerName}
/>
</ModalShell>
)}
@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Cloud, XCircle } from 'lucide-react';
import { Cloud, XCircle, RefreshCw } from 'lucide-react';
import { kubernetesApi } from '@/api';
import {
AreaChart,
@@ -15,8 +15,9 @@ import {
ResponsiveContainer,
} from 'recharts';
// 资源使用趋势数据 — 从节点 metrics 实时计算(不再用硬编码 mock) // Logan.Li
// 由于当前无 time-series 存储,用当前快照展示一个合理的汇总值
// 资源使用趋势数据
// 由于当前无持久化时序存储,这里基于当前节点 metrics 构造一个简短的模拟趋势(最近几分钟变化)
// 真正历史数据需要后端记录指标快照后才能实现 // Logan.Li
export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; onClose: () => void }) {
const [nodes, setNodes] = useState<any[]>([]);
@@ -50,6 +51,21 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on
};
}, [nodes]);
// 构造趋势数据(多点),避免单点导致“光点”效果 // Logan.Li
const trendData = useMemo(() => {
const cpu = Math.max(0, Math.min(100, Number(clusterMetrics.cpuPct.toFixed(1))));
const mem = Math.max(0, Math.min(100, Number(clusterMetrics.memPct.toFixed(1))));
// 模拟最近 5 个时间点的趋势(基于当前值轻微回溯)
return [
{ time: '10m前', cpu: Math.max(0, cpu * 0.72), memory: Math.max(0, mem * 0.78) },
{ time: '7m前', cpu: Math.max(0, cpu * 0.82), memory: Math.max(0, mem * 0.85) },
{ time: '4m前', cpu: Math.max(0, cpu * 0.90), memory: Math.max(0, mem * 0.93) },
{ time: '2m前', cpu: Math.max(0, cpu * 0.96), memory: Math.max(0, mem * 0.97) },
{ time: '现在', cpu, memory: mem },
];
}, [clusterMetrics]);
const loadClusterDetail = async (clusterId: string) => {
try {
// 后端 ListNodes/ListPods/etc 返回 data 直接为数组(非 {list,total})
@@ -132,49 +148,63 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-slate-800/50 border-slate-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-slate-400">资源使用趋势</CardTitle>
<div className="flex items-center justify-between w-full">
<CardTitle className="text-sm text-slate-400">资源使用趋势(模拟最近变化)</CardTitle>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-slate-400 hover:text-slate-200"
onClick={() => cluster?.id && loadClusterDetail(cluster.id)}
title="刷新节点指标"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={[
{ time: 'CPU', cpu: Number(clusterMetrics.cpuPct.toFixed(1)), memory: Number(clusterMetrics.memPct.toFixed(1)) },
]}>
<AreaChart data={trendData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.35} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="colorMemory" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
<stop offset="5%" stopColor="#10b981" stopOpacity={0.35} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={12} />
<YAxis stroke="#475569" fontSize={12} domain={[0, 100]} />
<CartesianGrid strokeDasharray="2 2" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={11} tickLine={false} />
<YAxis stroke="#475569" fontSize={11} domain={[0, 100]} unit="%" tickLine={false} />
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '6px',
color: '#f8fafc',
fontSize: '12px',
}}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="#3b82f6"
fillOpacity={1}
strokeWidth={2}
fill="url(#colorCpu)"
dot={false}
activeDot={{ r: 3, fill: '#3b82f6' }}
name="CPU(%)"
/>
<Area
type="monotone"
dataKey="memory"
stroke="#10b981"
fillOpacity={1}
strokeWidth={2}
fill="url(#colorMemory)"
dot={false}
activeDot={{ r: 3, fill: '#10b981' }}
name="内存(%)"
/>
</AreaChart>
@@ -20,6 +20,7 @@ import {
AlertTriangle,
XCircle,
RefreshCw,
Loader2,
Settings,
Eye,
Trash2,
@@ -317,21 +318,21 @@ export function KubernetesClusters() {
<CardContent className="pt-0">
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.node_count || 0}</div>
<div className="text-xs text-slate-500">节点</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.pod_count || 0}</div>
<div className="text-xs text-slate-500">Pod</div>
<div className="text-lg font-semibold text-slate-200">{cluster.ingress_count || 0}</div>
<div className="text-xs text-slate-500">Ingress</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.service_count || 0}</div>
<div className="text-xs text-slate-500">Service</div>
<div className="text-xs text-slate-500">SVC</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.deployment_count || 0}</div>
<div className="text-xs text-slate-500">Deployment</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.pod_count || 0}</div>
<div className="text-xs text-slate-500">Pod</div>
</div>
</div>
<div className="space-y-2">
{hasMetrics ? (
@@ -364,7 +365,9 @@ export function KubernetesClusters() {
</div>
</>
) : (
<div className="text-xs text-slate-600 text-center">已接入 {cluster.node_count || 0} 节点 · {cluster.pod_count || 0} Pod · 资源使用率待采集</div>
<div className="text-xs text-slate-600 text-center">
Ingress {cluster.ingress_count || 0} · SVC {cluster.service_count || 0} · Deployment {cluster.deployment_count || 0} · Pod {cluster.pod_count || 0} · 资源使用率待采集
</div>
)}
</div>
</CardContent>
@@ -400,20 +403,39 @@ export function KubernetesClusters() {
/>
)}
{/* 导出资源弹窗 */}
{/* 导出资源弹窗 — 导出中展示 loading,禁止误关 // Logan.Li */}
{exportCluster && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setExportCluster(null)}>
<div className="bg-slate-900 border border-slate-700 rounded-lg p-5 w-96 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={() => { if (!exporting) setExportCluster(null); }}
>
<div className="bg-slate-900 border border-slate-700 rounded-lg p-5 w-[420px] shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold text-slate-100 mb-1">导出集群资源</h3>
<p className="text-xs text-slate-400 mb-4">
导出 {exportCluster.name} 所有命名空间的 Deployment、Service、Ingress、ConfigMap、Secret 为 tar.gz
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setExportCluster(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleExport} disabled={exporting}>
{exporting ? '导出中...' : '确认导出'}
</Button>
</div>
{exporting ? (
<div className="flex flex-col items-center py-6 gap-3">
<Loader2 className="h-9 w-9 animate-spin text-blue-400" />
<p className="text-sm text-slate-200">正在导出 {exportCluster.name}</p>
<p className="text-xs text-slate-500 text-center leading-relaxed px-2">
正在拉取所有命名空间的 Deployment、Service、Ingress、ConfigMap、Secret 并打包为 tar.gz
<br />
资源较多时可能需要 1~3 分钟,请稍候勿关闭页面
</p>
</div>
) : (
<>
<p className="text-xs text-slate-400 mb-4 leading-relaxed">
导出 <span className="text-slate-300">{exportCluster.name}</span> 所有命名空间的
Deployment、Service、Ingress、ConfigMap、Secret 为 tar.gz
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setExportCluster(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleExport}>
<Download className="h-4 w-4 mr-1.5" />
确认导出
</Button>
</div>
</>
)}
</div>
</div>
)}
@@ -31,8 +31,12 @@ type ResourceType =
// 支持可视化编辑/新建的资源类型 // Logan.Li
const VISUAL_RESOURCE_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
/** Deployment 列表:新建 + YAML 导入,不做批量导出 // Logan.Li */
const DEPLOYMENT_LIST_TYPES: ResourceType[] = ['deployments'];
/** 支持命名空间 YAML 批量导出(对齐集群导出)// Logan.Li */
const EXPORT_YAML_TYPES: ResourceType[] = ['deployments', 'services', 'ingresses', 'configmaps', 'secrets'];
const EXPORT_YAML_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets'];
/** 列表页保留 CSV/JSON 导出的配置类资源 // Logan.Li */
const BULK_EXPORT_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
const VisualActions = ({ name, opts }: { name: string; opts: { onYaml?: (n: string) => void; onEdit?: (n: string) => void } }) => (
<div className="flex items-center gap-0.5">
@@ -52,7 +56,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
}> = {
deployments: {
title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes,
columns: ['名称', '命名空间', '副本', '镜像', '操作'],
columns: ['名称', '命名空间', '副本', '镜像'],
renderRow: (d, opts) => (
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
@@ -72,10 +76,6 @@ const RESOURCE_CONFIG: Record<ResourceType, {
})()}
</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看 YAML"
onClick={() => opts.onYaml?.(d.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</TableCell>
</TableRow>
),
},
@@ -95,13 +95,13 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<TableCell className={(p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500')}>{p.restart_count || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10" title="终端"
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5 mr-1" />终端</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10" title="文件浏览器"
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看YAML"
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-emerald-400" title="终端"
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-amber-400" title="文件"
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-blue-400" title="YAML"
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
@@ -414,7 +414,13 @@ export default function NamespaceResourcePage() {
setYamlItem({ resource: type, name, editMode: true });
}
};
const handleCreate = () => setVisualItem({ resource: type, name: '', isCreate: true });
const handleCreate = () => {
if (type === 'deployments') {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/edit/Deployment/_create`);
return;
}
setVisualItem({ resource: type, name: '', isCreate: true });
};
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
const handleExportYaml = async () => {
@@ -484,18 +490,22 @@ export default function NamespaceResourcePage() {
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadData} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportCSV} title="导出CSV">
<Download className="h-3 w-3 mr-1" />CSV
</Button>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON">
<Download className="h-3 w-3 mr-1" />JSON
</Button>
{BULK_EXPORT_TYPES.includes(type) && (
<>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportCSV} title="导出CSV">
<Download className="h-3 w-3 mr-1" />CSV
</Button>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON">
<Download className="h-3 w-3 mr-1" />JSON
</Button>
</>
)}
{EXPORT_YAML_TYPES.includes(type) && (
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportYaml} title="导出命名空间 YAML(tar.gz)">
<Download className="h-3 w-3 mr-1" />导出YAML
</Button>
)}
{VISUAL_RESOURCE_TYPES.includes(type) && (
{(VISUAL_RESOURCE_TYPES.includes(type) || DEPLOYMENT_LIST_TYPES.includes(type)) && (
<>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={() => setShowYamlImport(true)}>
<Upload className="h-3 w-3 mr-1" />导入
@@ -166,11 +166,11 @@ export function PodFileBrowser({
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-slate-700 shrink-0">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-amber-400" />
<span className="text-sm font-semibold text-slate-200">文件浏览器: {podName}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{namespace}</Badge>
<div className="flex items-center justify-between p-3 border-b border-slate-700 shrink-0 gap-3">
<div className="flex items-center gap-2 min-w-0 flex-1">
<HardDrive className="h-4 w-4 shrink-0 text-amber-400" />
<span className="text-sm font-semibold text-slate-200 truncate" title={podName}>文件: {podName}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px] shrink-0">{namespace}</Badge>
</div>
<div className="flex items-center gap-2">
{containers.length > 0 && (
@@ -171,16 +171,23 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[90vw] max-w-3xl h-[85vh] flex flex-col shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2">
<MetaIcon className={`h-5 w-5 ${meta.color}`} />
<div>
<h2 className="text-lg font-bold text-[var(--dg-text-primary)]">{isCreate ? `新建 ${meta.title}` : `${meta.title} 编辑`}</h2>
<p className="text-xs text-[var(--dg-text-muted)]">{isCreate ? namespace : name} · {namespace} · {clusterCode}</p>
{/* Header:长名称/域名截断,避免挤压模式切换按钮 // Logan.Li */}
<div className="flex items-center justify-between p-4 border-b border-[var(--dg-border)] gap-3 shrink-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<MetaIcon className={`h-5 w-5 shrink-0 ${meta.color}`} />
<div className="min-w-0">
<h2 className="text-lg font-bold text-[var(--dg-text-primary)] truncate">
{isCreate ? `新建 ${meta.title}` : `${meta.title} 编辑`}
</h2>
<p
className="text-xs text-[var(--dg-text-muted)] truncate"
title={isCreate ? `${namespace} · ${clusterCode}` : `${name} · ${namespace} · ${clusterCode}`}
>
{isCreate ? `${namespace} · ${clusterCode}` : `${name} · ${namespace} · ${clusterCode}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{/* Mode switcher */}
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden">
<button
@@ -198,7 +205,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
</div>
{/* Body */}
<div className={`flex-1 min-h-0 p-4 ${mode === 'yaml' ? 'flex flex-col overflow-hidden' : 'overflow-auto'}`}>
<div className={`flex-1 min-h-0 p-4 ${mode === 'yaml' ? 'flex flex-col overflow-hidden' : 'flex flex-col overflow-hidden'}`}>
{loading ? (
<div className="flex items-center justify-center h-full py-20"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>
) : mode === 'yaml' ? (
@@ -241,19 +248,21 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
/>
</div>
) : manifest ? (
<>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{resource === 'configmaps' && <ConfigMapEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'secrets' && <SecretEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'services' && <ServiceEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'ingresses' && <IngressEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'ingresses' && <IngressEditor manifest={manifest} clusterCode={clusterCode} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'pvcs' && <PvcEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{!['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(resource) && (
<div className="text-center py-12 text-[var(--dg-text-muted)]">
<p>该资源类型暂不支持可视化编辑</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}>切换到 YAML 编辑</Button>
<div className="flex-1 flex items-center justify-center text-center text-[var(--dg-text-muted)]">
<div>
<p>该资源类型暂不支持可视化编辑</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}>切换到 YAML 编辑</Button>
</div>
</div>
)}
</>
</div>
) : (
<div className="text-center py-12 text-[var(--dg-text-muted)]">
<p>无法解析资源结构</p>
@@ -275,6 +284,7 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
const [entries, setEntries] = useState<{ key: string; value: string }[]>(
Object.entries(data).map(([key, value]) => ({ key, value: String(value) }))
);
const [expandAll, setExpandAll] = useState(false);
const update = (i: number, field: 'key' | 'value', val: string) => {
const next = [...entries];
@@ -294,19 +304,25 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
};
return (
<div className="space-y-4">
<div>
<div className="flex flex-col h-full">
<div className="shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-h-0 flex flex-col mt-3">
<div className="flex items-center justify-between mb-2 shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-sm">配置数据 (键值对)</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
{expandAll ? '折叠全部' : '展开全部'}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
</div>
</div>
<div className="space-y-2 max-h-[45vh] overflow-y-auto pr-1">
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-2 border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)] p-2">
{entries.map((entry, i) => (
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<Input
value={entry.key}
onChange={e => update(i, 'key', e.target.value)}
@@ -317,7 +333,8 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
value={entry.value}
onChange={e => update(i, 'value', e.target.value)}
placeholder="值"
className="flex-[2] font-mono text-xs min-h-[36px] max-h-[120px] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
rows={expandAll || entry.value.length > 300 ? 10 : (entry.value.length > 100 ? 6 : 3)}
className="flex-[2] font-mono text-xs min-h-[70px] max-h-[420px] resize-y bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] leading-relaxed"
/>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400 hover:text-red-300 shrink-0" onClick={() => removeRow(i)}>
<Trash2 className="h-3.5 w-3.5" />
@@ -329,7 +346,8 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存配置
@@ -345,6 +363,7 @@ function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any
function SecretEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const rawData = manifest?.data || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [expandAll, setExpandAll] = useState(false);
// Decode base64 values for editing
const [entries, setEntries] = useState<{ key: string; value: string; encoded: boolean }[]>(
Object.entries(rawData).map(([key, value]) => {
@@ -380,34 +399,43 @@ function SecretEditor({ manifest, onSave, saving, isCreate }: { manifest: any; o
};
return (
<div className="space-y-4">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">类型</Label>
<Input value={manifest?.type || 'Opaque'} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">密钥数据 (已自动解码 Base64)</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
<div className="flex flex-col h-full">
<div className="shrink-0 grid grid-cols-2 gap-4">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div className="space-y-2 max-h-[45vh] overflow-y-auto pr-1">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">类型</Label>
<Input value={manifest?.type || 'Opaque'} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col mt-3">
<div className="flex items-center justify-between mb-2 shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-sm">密钥数据 (已自动解码 Base64)</Label>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
{expandAll ? '折叠全部' : '展开全部'}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-2 border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)] p-2">
{entries.map((entry, i) => (
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<Input
value={entry.key}
onChange={e => update(i, 'key', e.target.value)}
placeholder="键名 (如 password)"
className="flex-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
<Input
<Textarea
value={entry.value}
onChange={e => update(i, 'value', e.target.value)}
placeholder="明文值"
className="flex-[2] font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
rows={expandAll || entry.value.length > 300 ? 10 : (entry.value.length > 100 ? 6 : 3)}
className="flex-[2] font-mono text-xs min-h-[70px] max-h-[420px] resize-y bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] leading-relaxed"
/>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400 hover:text-red-300 shrink-0" onClick={() => removeRow(i)}>
<Trash2 className="h-3.5 w-3.5" />
@@ -419,7 +447,8 @@ function SecretEditor({ manifest, onSave, saving, isCreate }: { manifest: any; o
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存密钥
@@ -491,12 +520,12 @@ function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
];
return (
<div className="space-y-5">
<div>
<div className="flex flex-col h-full">
<div className="shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">服务名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<div className="shrink-0 mt-3">
<Label className="text-[var(--dg-text-secondary)] text-xs mb-2 block">服务类型</Label>
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden w-fit">
{svcTypes.map((t) => (
@@ -512,15 +541,17 @@ function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
</div>
</div>
{/* Ports */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">端口映射</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addPort}><Plus className="h-3 w-3 mr-1" />添加端口</Button>
</div>
<div className="space-y-2">
{/* 端口 + 选择器:统一滚动,避免条目过多时底部被裁切 // Logan.Li */}
<div className="flex-1 min-h-0 overflow-y-auto mt-3 pr-1 space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">端口映射</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addPort}><Plus className="h-3 w-3 mr-1" />添加端口</Button>
</div>
<div className="space-y-2 border border-[var(--dg-border)] rounded-lg p-2 bg-[var(--dg-bg-secondary)]">
{ports.map((p, i) => (
<div key={i} className="grid grid-cols-[1fr_80px_80px_80px_80px_32px] gap-2 items-center">
<div key={i} className="overflow-x-auto">
<div className="grid grid-cols-[minmax(80px,1fr)_80px_80px_80px_80px_32px] gap-2 items-center min-w-[480px]">
<Input value={p.name} onChange={e => updatePort(i, 'name', e.target.value)} placeholder="名称" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.port} onChange={e => updatePort(i, 'port', e.target.value)} placeholder="端口" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.targetPort} onChange={e => updatePort(i, 'targetPort', e.target.value)} placeholder="目标" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
@@ -533,31 +564,33 @@ function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
) : <div />}
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removePort(i)}><Trash2 className="h-3 w-3" /></Button>
</div>
))}
{ports.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无端口,点击「添加端口」</div>}
</div>
</div>
{/* Selector */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">选择器 (Selector)</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addSelector}><Plus className="h-3 w-3 mr-1" />添加标签</Button>
</div>
<div className="space-y-2">
{selector.map((s, i) => (
<div key={i} className="flex items-center gap-2">
<Input value={s.key} onChange={e => updateSelector(i, 'key', e.target.value)} placeholder="键 (如 app)" className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<span className="text-[var(--dg-text-muted)] text-xs">=</span>
<Input value={s.value} onChange={e => updateSelector(i, 'value', e.target.value)} placeholder="值 (如 nginx)" className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removeSelector(i)}><Trash2 className="h-3 w-3" /></Button>
</div>
))}
{selector.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无选择器</div>}
{ports.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无端口,点击「添加端口」</div>}
</div>
</div>
{/* Selector */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">选择器 (Selector)</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addSelector}><Plus className="h-3 w-3 mr-1" />添加标签</Button>
</div>
<div className="space-y-2">
{selector.map((s, i) => (
<div key={i} className="flex items-center gap-2 min-w-0">
<Input value={s.key} onChange={e => updateSelector(i, 'key', e.target.value)} placeholder="键 (如 app)" className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<span className="text-[var(--dg-text-muted)] text-xs shrink-0">=</span>
<Input value={s.value} onChange={e => updateSelector(i, 'value', e.target.value)} placeholder="值 (如 nginx)" className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-red-400 hover:text-red-300" onClick={() => removeSelector(i)}><Trash2 className="h-3 w-3" /></Button>
</div>
))}
{selector.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无选择器</div>}
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存 Service
@@ -570,10 +603,29 @@ function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
// ══════════════════════════════════════════════
// Ingress 编辑器 — Host/Path 路由规则
// ══════════════════════════════════════════════
function IngressEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
function IngressEditor({ manifest, clusterCode, onSave, saving, isCreate }: { manifest: any; clusterCode: string; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const spec = manifest?.spec || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [ingressClass, setIngressClass] = useState(spec.ingressClassName || 'nginx');
// IngressClass 列表(关联集群中的 IngressController)
const [ingressClasses, setIngressClasses] = useState<Array<{ name: string; controller: string }>>([]);
const [loadingClasses, setLoadingClasses] = useState(false);
// 加载集群 IngressClass 列表
useEffect(() => {
if (!clusterCode) return;
setLoadingClasses(true);
kubernetesApi.getIngressClasses(clusterCode).then((res: any) => {
const list = Array.isArray(res) ? res : (res?.list || res || []);
setIngressClasses(list);
// 新建时如果还没指定且有列表,尝试默认选第一个
if (isCreate && (!spec.ingressClassName || spec.ingressClassName === 'nginx') && list.length > 0) {
// 优先选 nginx 或第一个
const prefer = list.find((c: any) => c.name?.toLowerCase().includes('nginx')) || list[0];
if (prefer?.name) setIngressClass(prefer.name);
}
}).catch(() => {}).finally(() => setLoadingClasses(false));
}, [clusterCode]);
const [rules, setRules] = useState<{ host: string; paths: { path: string; pathType: string; serviceName: string; servicePort: string }[] }[]>(
(spec.rules || []).map(( r: any) => ({
host: r.host || '',
@@ -632,44 +684,103 @@ function IngressEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex flex-col h-full min-h-0">
<div className="shrink-0 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="min-w-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">Ingress Class</Label>
<Input value={ingressClass} onChange={(e) => setIngressClass(e.target.value)} placeholder="nginx / alb" className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)]" />
<div className="min-w-0">
<Label className="text-[var(--dg-text-secondary)] text-xs flex items-center gap-1">
Ingress Class
<span className="text-[10px] text-[var(--dg-text-muted)]">(可选)</span>
</Label>
{/* IngressClass 下拉 + 手工输入支持 // Logan.Li */}
<div className="space-y-1 mt-1">
<Select
value={ingressClass || ''}
onValueChange={(val) => {
if (val && val !== '__manual__') {
setIngressClass(val);
}
}}
>
<SelectTrigger className="h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={loadingClasses ? '加载中...' : '选择或输入 IngressClass'} />
</SelectTrigger>
<SelectContent>
{ingressClasses.length > 0 ? (
ingressClasses.map((ic) => (
<SelectItem key={ic.name} value={ic.name}>
{ic.name} {ic.controller ? <span className="text-[var(--dg-text-muted)] text-[10px]">({ic.controller})</span> : ''}
</SelectItem>
))
) : (
!loadingClasses && <div className="px-2 py-1.5 text-xs text-[var(--dg-text-muted)]">未发现 IngressClass,请手动输入</div>
)}
{/* 当前自定义值作为选项,确保选中态 */}
{ingressClass && !ingressClasses.some((ic) => ic.name === ingressClass) && (
<SelectItem value={ingressClass}>{ingressClass} (手动输入)</SelectItem>
)}
<SelectItem value="__manual__">—— 继续手动输入 ——</SelectItem>
</SelectContent>
</Select>
{/* 总是显示输入框支持手工输入或修改 */}
<div className="text-[10px] text-[var(--dg-text-muted)] flex items-center gap-1">
手工输入:
<Input
value={ingressClass}
onChange={(e) => setIngressClass(e.target.value)}
placeholder="alb / nginx / my-ingress-class"
className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
{/* 帮助提示 */}
<div className="text-[10px] text-[var(--dg-text-muted)] leading-tight">
您的集群中可能有多个 IngressController,您可在此指定当前 Ingress 对应哪个 IngressController。<br />
从 IngressClass 列表选择,或手工输入名称。
</div>
{/* 常见示例 */}
{(ingressClasses.length === 0 || ingressClass) && (
<div className="text-[10px] text-[var(--dg-text-muted)]">
常见:<span className="font-mono">alb</span> → ingress.k8s.alibabacloud/alb &nbsp;&nbsp; <span className="font-mono">nginx</span> → k8s.io/ingress-nginx
</div>
)}
</div>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-h-0 flex flex-col mt-3">
<div className="flex items-center justify-between mb-2 shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-sm">路由规则</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRule}><Plus className="h-3 w-3 mr-1" />添加 Host</Button>
</div>
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-3 border border-[var(--dg-border)] rounded-lg p-2 bg-[var(--dg-bg-secondary)]">
{rules.map((rule, ri) => (
<div key={ri} className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] space-y-2">
<div className="flex items-center gap-2">
<div key={ri} className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] space-y-2 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<Input
value={rule.host}
onChange={e => updateRule(ri, e.target.value)}
placeholder="域名 (如 api.example.com)"
className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
title={rule.host}
/>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removeRule(ri)}><Trash2 className="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-red-400 hover:text-red-300" onClick={() => removeRule(ri)}><Trash2 className="h-3 w-3" /></Button>
</div>
{rule.paths.map((p, pi) => (
<div key={pi} className="grid grid-cols-[1fr_100px_1fr_60px_28px] gap-1.5 items-center pl-4">
<Input value={p.path} onChange={e => updatePath(ri, pi, 'path', e.target.value)} placeholder="路径 /" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Select value={p.pathType} onValueChange={v => updatePath(ri, pi, 'pathType', v)}>
<SelectTrigger className="h-7 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="Prefix">Prefix</SelectItem><SelectItem value="Exact">Exact</SelectItem></SelectContent>
</Select>
<Input value={p.serviceName} onChange={e => updatePath(ri, pi, 'serviceName', e.target.value)} placeholder="Service" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.servicePort} onChange={e => updatePath(ri, pi, 'servicePort', e.target.value)} placeholder="端口" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removePath(ri, pi)}><Trash2 className="h-3 w-3" /></Button>
<div key={pi} className="overflow-x-auto pl-4">
<div className="grid grid-cols-[minmax(80px,1fr)_100px_minmax(80px,1fr)_60px_28px] gap-1.5 items-center min-w-[420px]">
<Input value={p.path} onChange={e => updatePath(ri, pi, 'path', e.target.value)} placeholder="路径 /" className="font-mono text-xs h-7 min-w-0 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Select value={p.pathType} onValueChange={v => updatePath(ri, pi, 'pathType', v)}>
<SelectTrigger className="h-7 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="Prefix">Prefix</SelectItem><SelectItem value="Exact">Exact</SelectItem></SelectContent>
</Select>
<Input value={p.serviceName} onChange={e => updatePath(ri, pi, 'serviceName', e.target.value)} placeholder="Service" className="font-mono text-xs h-7 min-w-0 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.servicePort} onChange={e => updatePath(ri, pi, 'servicePort', e.target.value)} placeholder="端口" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => removePath(ri, pi)}><Trash2 className="h-3 w-3" /></Button>
</div>
</div>
))}
<Button variant="ghost" size="sm" className="h-6 text-xs text-[var(--dg-text-muted)]" onClick={() => addPath(ri)}><Plus className="h-3 w-3 mr-1" />添加路径</Button>
@@ -678,7 +789,7 @@ function IngressEditor({ manifest, onSave, saving, isCreate }: { manifest: any;
{rules.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无路由规则,点击「添加 Host」</div>}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存 Ingress
@@ -713,12 +824,12 @@ function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSa
};
return (
<div className="space-y-4">
<div>
<div className="flex flex-col h-full">
<div className="shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 mt-3 shrink-0">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">存储大小</Label>
<Input
@@ -743,7 +854,7 @@ function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSa
</Select>
</div>
</div>
<div>
<div className="shrink-0 mt-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">StorageClass</Label>
<Input
value={storageClass}
@@ -752,7 +863,10 @@ function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSa
className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<div className="flex-1" />
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存 PVC
@@ -95,14 +95,15 @@ export function TopologyView() {
setLoading(true);
try {
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || []));
const [nodesRes, svcRes, depRes, ingRes] = await Promise.all([
const [nodesRes, svcRes, depRes, ingRes, podRes] = await Promise.all([
kubernetesApi.getNodes(selectedClusterId).catch(() => []),
kubernetesApi.getServices(selectedClusterId).catch(() => []),
kubernetesApi.getDeployments(selectedClusterId).catch(() => []),
kubernetesApi.getIngresses(selectedClusterId).catch(() => []),
kubernetesApi.getPods(selectedClusterId).catch(() => []),
]);
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {};
const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes));
const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes), toArray(podRes));
// 合并已保存布局
const layout = await loadSavedLayout(selectedClusterId);
@@ -389,6 +390,19 @@ export function TopologyView() {
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadTopology} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="sm"
className="h-9 text-xs border-blue-600/40 text-blue-400"
onClick={async () => {
setSavedLayout(null);
await loadTopology();
}}
disabled={loading}
title="忽略保存的布局,重新根据 K8s 资源自动生成关联 (Ingress路径→Svc→Deploy→Pod)"
>
<RotateCcw className="h-3.5 w-3.5 mr-1" /> 重新自动生成
</Button>
</div>
</div>
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { ModalOverlay } from '@/components/ui/modal-overlay';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { parseEditableManifest, prepareYamlForSave } from './k8s-manifest-utils';
import { parseEditableManifest, parseEditableManifests, prepareYamlForSave } from './k8s-manifest-utils';
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
import { YamlEditorToolbar } from './YamlEditorToolbar';
import { YamlCodeMirror } from './YamlCodeMirror';
@@ -23,11 +23,18 @@ interface YamlViewerProps {
initialYaml?: string;
/** 覆盖资源配置的只读行为(Pod 默认只读)// Logan.Li */
readOnly?: boolean;
/** 自定义加载(如 Deployment + Service 多文档)// Logan.Li */
loadYamlFn?: () => Promise<string>;
/** 自定义保存(如 Deployment + Service 多文档)// Logan.Li */
saveYamlFn?: (yaml: string) => Promise<void>;
/** 资源描述补充(如 "含关联 Service")// Logan.Li */
resourceHint?: string;
}
export function YamlViewer({
clusterCode, namespace, resource, name, onClose, onSaved,
autoEdit = false, inline = false, initialYaml, readOnly: readOnlyProp,
loadYamlFn, saveYamlFn, resourceHint,
}: YamlViewerProps) {
const { toast } = useToast();
const resCfg = getYamlResourceConfig(resource);
@@ -42,7 +49,9 @@ export function YamlViewer({
const [showRaw, setShowRaw] = useState(false);
const applyStripped = useCallback(async (text: string) => {
const { yaml: stripped } = await parseEditableManifest(text);
const { yaml: stripped } = text.includes('\n---')
? await parseEditableManifests(text)
: await parseEditableManifest(text);
setDisplayYaml(stripped);
setEditYaml(stripped);
return stripped;
@@ -57,7 +66,9 @@ export function YamlViewer({
}
setLoading(true);
try {
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
const text = loadYamlFn
? await loadYamlFn()
: await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
setRawYaml(text);
await applyStripped(text);
} catch (e) {
@@ -67,7 +78,7 @@ export function YamlViewer({
setDisplayYaml(err);
setEditYaml(err);
} finally { setLoading(false); }
}, [clusterCode, namespace, resource, name, initialYaml, applyStripped, toast]);
}, [clusterCode, namespace, resource, name, initialYaml, loadYamlFn, applyStripped, toast]);
useEffect(() => { loadYaml(); }, [loadYaml]);
@@ -86,8 +97,12 @@ export function YamlViewer({
setSaving(true);
try {
const yamlStr = await prepareYamlForSave(editYaml);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
toast({ title: '保存成功', description: `${resource}/${name} 已更新` });
if (saveYamlFn) {
await saveYamlFn(yamlStr);
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
}
toast({ title: '保存成功', description: saveYamlFn ? '多文档 YAML 已应用' : `${resource}/${name} 已更新` });
setEditing(false);
setRawYaml(yamlStr);
setDisplayYaml(yamlStr);
@@ -149,7 +164,9 @@ export function YamlViewer({
<div className="flex flex-col gap-3 min-h-[400px] h-[calc(100vh-220px)] max-h-[720px]">
<div className="flex items-center justify-between gap-3 flex-wrap shrink-0">
<p className="text-xs text-slate-500">
<code className="text-slate-400">{resource}/{name}</code> · {namespace}
<code className="text-slate-400">{resource}/{name}</code>
{resourceHint ? <span className="text-slate-600"> · {resourceHint}</span> : null}
{' · '}{namespace}
</p>
{toolbar}
</div>
@@ -165,12 +182,12 @@ export function YamlViewer({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-slate-700 gap-3 shrink-0">
<div className="min-w-0">
<h2 className="text-lg font-bold text-slate-100 truncate">
<div className="min-w-0 flex-1">
<h2 className="text-lg font-bold text-slate-100 truncate" title={`${resCfg.label} YAML — ${name}`}>
{resCfg.label} YAML — {name}
{readOnly && <span className="ml-2 text-xs font-normal text-slate-500">只读</span>}
</h2>
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
<p className="text-xs text-slate-500 truncate" title={`${namespace} · ${clusterCode}`}>{namespace} · {clusterCode}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{toolbar}
@@ -0,0 +1,100 @@
// Deployment 多文档 YAML(Deployment + 关联 Service)加载与保存 // Logan.Li
import { kubernetesApi } from '@/api';
import {
manifestToEditableYaml,
parseEditableManifest,
parseEditableManifests,
prepareManifestForSave,
} from './k8s-manifest-utils';
const toArray = (v: unknown) => (Array.isArray(v) ? v : ((v as Record<string, any>)?.list || (v as Record<string, any>)?.items || (v as Record<string, any>)?.data || []));
/** Service selector 是否匹配 Deployment 标签 // Logan.Li */
export function selectorMatches(
depSelector: Record<string, string>,
svcSelector: Record<string, string>,
): boolean {
if (Object.keys(svcSelector).length === 0) return false;
return Object.entries(svcSelector).every(([k, v]) => depSelector[k] === v);
}
/** 查找与 Deployment 关联的 Service 名称 // Logan.Li */
export function findMatchedServiceNames(
deploymentName: string,
depSelector: Record<string, string>,
services: any[],
): string[] {
const names = new Set<string>();
for (const svc of services) {
const svcName = svc.name || svc.metadata?.name || '';
if (!svcName) continue;
const svcSelector = svc.spec?.selector || svc.selector || {};
if (svcName === deploymentName || selectorMatches(depSelector, svcSelector)) {
names.add(svcName);
}
}
return [...names].sort();
}
/** 加载 Deployment + 关联 Service 的多文档 YAML // Logan.Li */
export async function loadDeploymentBundleYaml(
clusterCode: string,
namespace: string,
deploymentName: string,
): Promise<string> {
const depYaml = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName);
const { manifest: depManifest } = await parseEditableManifest(depYaml);
const depSelector = depManifest?.spec?.selector?.matchLabels
|| depManifest?.spec?.template?.metadata?.labels
|| {};
const svcListRes = await kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []);
const matchedSvcNames = findMatchedServiceNames(deploymentName, depSelector, toArray(svcListRes));
const docs: string[] = [];
if (depManifest) {
docs.push(await manifestToEditableYaml(depManifest));
} else {
const { yaml: stripped } = await parseEditableManifest(depYaml);
if (stripped.trim()) docs.push(stripped);
}
for (const svcName of matchedSvcNames) {
try {
const svcYaml = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'services', svcName);
const { yaml: stripped } = await parseEditableManifest(svcYaml);
if (stripped.trim()) docs.push(stripped);
} catch {
// 单个 Service 拉取失败时跳过 // Logan.Li
}
}
return docs.join('\n---\n');
}
/** 保存多文档 YAML(Deployment + Service 等)// Logan.Li */
export async function saveDeploymentBundleYaml(
clusterCode: string,
namespace: string,
yamlText: string,
): Promise<void> {
const { manifests } = await parseEditableManifests(yamlText);
if (manifests.length === 0) throw new Error('YAML 内容为空或解析失败');
for (const manifest of manifests) {
const kind = String(manifest.kind || '').toLowerCase();
const name = String(manifest.metadata?.name || '').trim();
if (!kind || !name) throw new Error('每个 YAML 文档必须包含 kind 和 metadata.name');
const docYaml = await prepareManifestForSave(manifest);
if (kind === 'deployment') {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', name, docYaml);
continue;
}
if (kind === 'service') {
await kubernetesApi.applyYAML(clusterCode, docYaml);
continue;
}
throw new Error(`不支持的资源类型: ${manifest.kind}(仅支持 Deployment / Service)`);
}
}
@@ -0,0 +1,6 @@
// Deployment 路由/新建占位符 // Logan.Li
export const CREATE_DEPLOYMENT_SLUG = '_create';
export function isCreateDeployment(name: string): boolean {
return name === CREATE_DEPLOYMENT_SLUG;
}
@@ -58,10 +58,10 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
<Plus className="h-3.5 w-3.5 mr-1" />创建 HPA
</Button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setOpen(false)}>
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[480px] max-h-[80vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-bold text-slate-100 mb-4">创建 HPA</h2>
<div className="space-y-3">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setOpen(false)}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[480px] max-w-[92vw] max-h-[85vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-bold text-slate-100 px-6 pt-6 pb-4 shrink-0">创建 HPA</h2>
<div className="flex-1 min-h-0 overflow-y-auto px-6 space-y-3">
<div>
<Label className="text-slate-400 text-sm">名称</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
@@ -70,7 +70,7 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
</div>
<div>
<Label className="text-slate-400 text-sm">目标</Label>
<p className="text-slate-300 text-sm mt-1 bg-slate-800 rounded px-3 py-2">
<p className="text-slate-300 text-sm mt-1 bg-slate-800 rounded px-3 py-2 truncate" title={`Deployment: ${deploymentName} · 命名空间: ${namespace}`}>
Deployment: {deploymentName} · 命名空间: {namespace}
</p>
</div>
@@ -103,7 +103,7 @@ export function CreateHPADialog({ clusterCode, namespace, deploymentName, onCrea
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<div className="flex justify-end gap-2 px-6 py-4 border-t border-slate-700 shrink-0">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setOpen(false)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={loading} onClick={handleCreate}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : '创建'}
@@ -1,13 +1,21 @@
// Deployment manifest 统一保存 // Logan.Li
import { kubernetesApi } from '@/api';
import { prepareManifestForSave } from './k8s-manifest-utils';
import { isCreateDeployment } from './deployment-constants';
export async function saveDeploymentManifest(
clusterCode: string,
namespace: string,
deploymentName: string,
manifest: Record<string, any>,
): Promise<void> {
): Promise<string> {
const resName = String(manifest?.metadata?.name || '').trim();
if (!resName) throw new Error('Deployment 名称不能为空');
const yaml = await prepareManifestForSave(manifest);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, yaml);
if (isCreateDeployment(deploymentName)) {
await kubernetesApi.applyYAML(clusterCode, yaml);
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', resName, yaml);
}
return resName;
}
@@ -3,89 +3,166 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
ChevronDown, ChevronRight, Terminal, FileText, HardDrive, FileCode,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ChevronDown, ChevronRight, Terminal, HardDrive, MoreVertical, FileText, FileCode, Trash2,
} from 'lucide-react';
import { formatK8sTime, statusColor } from '../k8s-yaml-utils';
import { getPodTimeline, PodConditionsRow } from './helpers';
import { statusColor } from '../k8s-yaml-utils';
import { PodConditionsRow } from './helpers';
import type { DeploymentViewProps } from './types';
export function PodsTab({
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
onDeletePod,
expandedPod, setExpandedPod,
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
if (pods.length === 0) {
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>;
}
return (
<div className="space-y-2">
{pods.map(pod => {
const tl = getPodTimeline(pod);
const expanded = expandedPod === pod.name;
const nodeName = pod.node_name || pod.node;
return (
<Card key={pod.name} className="dg-card">
<CardContent className="p-4">
{/* Pod header row */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-6 w-6 text-[var(--dg-text-muted)]" onClick={() => setExpandedPod(expanded ? null : pod.name)}>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<span className="font-mono text-sm text-[var(--dg-text-primary)]">{pod.name}</span>
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
{pod.status || 'Unknown'}
</Badge>
<span className="text-xs text-[var(--dg-text-muted)]">
<button className="text-[var(--dg-blue)] hover:underline mr-2" onClick={() => window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}>
{nodeName || '-'}
<Card className="dg-card overflow-hidden">
<CardContent className="p-0 overflow-x-auto">
{/* 表头:操作列固定宽度,避免更多按钮溢出卡片 // Logan.Li */}
<div className="grid grid-cols-[28px_minmax(140px,1fr)_72px_100px_96px_48px_56px_88px] gap-2 min-w-[740px] px-4 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 text-[11px] font-medium text-[var(--dg-text-muted)] uppercase tracking-wide">
<span />
<span>名称</span>
<span>状态</span>
<span>节点</span>
<span>IP</span>
<span className="text-center">重启</span>
<span>Age</span>
<span className="text-right">操作</span>
</div>
<div className="divide-y divide-[var(--dg-border)]">
{pods.map(pod => {
const expanded = expandedPod === pod.name;
const nodeName = pod.node_name || pod.node;
const sc = statusColor(pod.status);
const containers = (pod as Record<string, any>).containers as any[] | undefined;
return (
<div key={pod.name}>
{/* 列表行:紧凑信息,不重复展示完整时间线 // Logan.Li */}
<div
className={`grid grid-cols-[28px_minmax(140px,1fr)_72px_100px_96px_48px_56px_88px] gap-2 min-w-[740px] items-center px-4 py-3 transition-colors ${
expanded ? 'bg-[var(--dg-bg-tertiary)]/30' : 'hover:bg-[var(--dg-bg-tertiary)]/20'
}`}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[var(--dg-text-muted)]"
onClick={() => setExpandedPod(expanded ? null : pod.name)}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<span className="font-mono text-sm text-[var(--dg-text-primary)] truncate" title={pod.name}>
{pod.name}
</span>
<Badge
className="w-fit text-[11px] font-normal"
style={{ background: `${sc}18`, color: sc, border: `1px solid ${sc}33` }}
>
{pod.status || 'Unknown'}
</Badge>
<button
className="text-xs text-[var(--dg-blue)] hover:underline truncate text-left"
title={nodeName}
onClick={() => nodeName && window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}
>
{nodeName || '—'}
</button>
</span>
<span className="text-xs font-mono text-[var(--dg-text-muted)]">{pod.ip || pod.pod_ip || '-'}</span>
<span className="text-xs text-[var(--dg-text-muted)]">重启 {pod.restart_count || 0} 次</span>
<span className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(tl.startTime || pod.created_at)}</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 text-xs text-[var(--dg-blue)]" onClick={() => onShowPodLogs(pod)}>
<FileText className="h-3.5 w-3.5 mr-1" />日志
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-emerald-400" onClick={() => onShowPodTerminal(pod)}>
<Terminal className="h-3.5 w-3.5 mr-1" />终端
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-amber-400" onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5 mr-1" />文件
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-slate-400 hover:text-blue-400" onClick={() => onShowPodYaml(pod)}>
<FileCode className="h-3.5 w-3.5 mr-1" />YAML
</Button>
</div>
{/* Expanded: timeline */}
{expanded && (
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
<PodConditionsRow pod={pod} />
{/* Container details */}
{(pod as Record<string, any>).containers && ((pod as Record<string, any>).containers as any[]).length > 0 && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
{((pod as Record<string, any>).containers as any[]).map((c: any, idx: number) => (
<div key={idx} className="p-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
<div className="flex items-center justify-between">
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>{c.ready ? 'Ready' : 'Pending'}</span>
</div>
<div className="text-[var(--dg-text-muted)] mt-1">{c.image}</div>
{c.resources && (
<div className="mt-1 flex gap-3">
<span>CPU: {c.resources?.requests?.cpu || '0'}/{c.resources?.limits?.cpu || '-'} m</span>
<span>内存: {c.resources?.requests?.memory || '0'}/{c.resources?.limits?.memory || '-'}</span>
</div>
)}
</div>
))}
</div>
)}
<span className="text-xs font-mono text-[var(--dg-text-muted)] truncate">
{pod.ip || pod.pod_ip || '—'}
</span>
<span className={`text-xs text-center font-mono ${(pod.restart_count || 0) > 0 ? 'text-amber-400' : 'text-[var(--dg-text-muted)]'}`}>
{pod.restart_count || 0}
</span>
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
{pod.age || '—'}
</span>
<div className="flex items-center justify-end gap-0.5 shrink-0 w-[88px]">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-emerald-400" title="终端" onClick={() => onShowPodTerminal(pod)}>
<Terminal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-amber-400" title="文件" onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-[var(--dg-text-muted)] hover:text-[var(--dg-blue)]"
title="更多"
aria-label="更多"
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DropdownMenuItem onClick={() => onShowPodLogs(pod)} className="text-[var(--dg-text-primary)]">
<FileText className="h-3.5 w-3.5 mr-2" /> 日志
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]">
<FileCode className="h-3.5 w-3.5 mr-2" /> YAML
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDeletePod?.(pod)} className="text-red-400 focus:text-red-400">
<Trash2 className="h-3.5 w-3.5 mr-2" /> 删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{/* 展开详情:时间线与容器信息仅在此处展示 // Logan.Li */}
{expanded && (
<div className="px-4 pb-4 pt-1 space-y-3 bg-[var(--dg-bg-tertiary)]/20">
<PodConditionsRow pod={pod} />
{containers && containers.length > 0 && (
<div>
<p className="text-xs text-[var(--dg-text-muted)] mb-2">容器</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{containers.map((c: any, idx: number) => (
<div key={idx} className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>
{c.ready ? 'Ready' : 'Pending'}
</span>
</div>
<p className="text-[var(--dg-text-muted)] mt-1 truncate" title={c.image}>{c.image}</p>
{c.resources && (
<div className="mt-1.5 flex gap-4 text-[var(--dg-text-muted)]">
<span>CPU {c.resources?.requests?.cpu || '0'} / {c.resources?.limits?.cpu || '—'}</span>
<span>内存 {c.resources?.requests?.memory || '0'} / {c.resources?.limits?.memory || '—'}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
@@ -5,6 +5,12 @@ import {
} from '../k8s-yaml-utils';
import type { PodItem } from './types';
export interface PodStage {
label: string;
ts?: string;
color: string;
}
// Pod 条件时间线提取 // Logan.Li
export function getPodTimeline(pod: PodItem) {
const cond: Record<string, any>[] = (pod as Record<string, any>).conditions || (pod as Record<string, any>).pod_conditions || [];
@@ -19,6 +25,17 @@ export function getPodTimeline(pod: PodItem) {
};
}
// 启动阶段列表(列表/详情共用) // Logan.Li
export function getPodStages(pod: PodItem): PodStage[] {
const tl = getPodTimeline(pod);
return [
{ label: '已调度', ts: tl.scheduled, color: STATUS_COLORS.blue },
{ label: '已初始化', ts: tl.initialized, color: STATUS_COLORS.orange },
{ label: '容器已就绪', ts: tl.containersReady, color: STATUS_COLORS.green },
{ label: '容器组已就绪', ts: tl.ready, color: STATUS_COLORS.green },
];
}
// 信息面板单项 // Logan.Li
export function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
@@ -29,36 +46,99 @@ export function InfoItem({ label, value }: { label: string; value: React.ReactNo
);
}
// Pod 条件进度条 // Logan.Li
export function PodConditionsRow({ pod }: { pod: PodItem }) {
const tl = getPodTimeline(pod);
const stages = [
{ label: '已调度', ts: tl.scheduled, color: STATUS_COLORS.blue },
{ label: '已初始化', ts: tl.initialized, color: STATUS_COLORS.orange },
{ label: '容器已就绪', ts: tl.containersReady, color: STATUS_COLORS.green },
{ label: '容器组已就绪', ts: tl.ready, color: STATUS_COLORS.green },
];
// 列表行紧凑就绪指示(无时间戳,避免视觉疲劳) // Logan.Li
export function PodStatusCompact({ pod }: { pod: PodItem }) {
const stages = getPodStages(pod);
const done = stages.filter(s => s.ts).length;
const allReady = done === stages.length;
const pending = stages.find(s => !s.ts);
return (
<div className="flex items-center gap-2 overflow-x-auto">
{stages.map((s, i) => {
const prev = stages[i - 1];
const dur = elapsedSeconds(prev?.ts, s.ts);
return (
<div className="flex items-center gap-2 min-w-[88px]">
<div className="flex items-center gap-0.5" aria-label="启动进度">
{stages.map((s, i) => (
<Fragment key={s.label}>
<div className="flex flex-col items-center min-w-[80px]">
<div className="h-2 w-2 rounded-full" style={{ background: s.ts ? s.color : '#334155' }} />
<span className="text-[10px] mt-1 font-medium" style={{ color: s.ts ? s.color : '#475569' }}>{s.label}</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">{s.ts ? formatK8sTime(s.ts) : '-'}</span>
</div>
<span
className="inline-block h-1.5 w-1.5 rounded-full shrink-0"
style={{ background: s.ts ? s.color : 'var(--dg-border)' }}
title={`${s.label}${s.ts ? ` · ${formatK8sTime(s.ts)}` : ''}`}
/>
{i < stages.length - 1 && (
<div className="flex flex-col items-center mx-0.5 min-w-[30px]">
<div className="h-0.5 w-8 bg-[var(--dg-border)]" />
<span className="text-[9px] text-[var(--dg-text-muted)] mt-0.5">{dur != null ? formatDuration(dur) : ''}</span>
</div>
<span
className="inline-block h-px w-2 shrink-0"
style={{ background: s.ts && stages[i + 1]?.ts ? s.color : 'var(--dg-border)' }}
/>
)}
</Fragment>
);
})}
))}
</div>
<span
className="text-[11px] whitespace-nowrap"
style={{ color: allReady ? STATUS_COLORS.green : 'var(--dg-text-muted)' }}
>
{allReady ? '就绪' : pending?.label || `${done}/${stages.length}`}
</span>
</div>
);
}
// 详情区完整启动时间线(仅展开或概览面板使用) // Logan.Li
export function PodConditionsRow({ pod }: { pod: PodItem }) {
const stages = getPodStages(pod);
const totalBoot = elapsedSeconds(stages[0]?.ts, stages[stages.length - 1]?.ts);
return (
<div className="rounded-lg bg-[var(--dg-bg-tertiary)]/60 px-4 py-3">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-[var(--dg-text-muted)]">启动时间线</span>
{totalBoot != null && (
<span className="text-[11px] text-[var(--dg-text-muted)]">
总耗时 <span className="font-mono text-[var(--dg-text-secondary)]">{formatDuration(totalBoot)}</span>
</span>
)}
</div>
<div className="flex items-start gap-0 overflow-x-auto">
{stages.map((s, i) => {
const prev = stages[i - 1];
const dur = elapsedSeconds(prev?.ts, s.ts);
const active = !!s.ts;
return (
<Fragment key={s.label}>
<div className="flex flex-col min-w-[108px]">
<div className="flex items-center gap-2">
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold"
style={{
background: active ? `${s.color}22` : 'var(--dg-bg-secondary)',
color: active ? s.color : 'var(--dg-text-muted)',
border: `1px solid ${active ? `${s.color}55` : 'var(--dg-border)'}`,
}}
>
{i + 1}
</span>
<span className="text-xs font-medium" style={{ color: active ? s.color : 'var(--dg-text-muted)' }}>
{s.label}
</span>
</div>
<span className="mt-1.5 pl-7 text-[11px] font-mono text-[var(--dg-text-muted)]">
{s.ts ? formatK8sTime(s.ts) : '—'}
</span>
</div>
{i < stages.length - 1 && (
<div className="flex flex-col items-center justify-center px-1 pt-2.5 min-w-[48px]">
<div
className="h-px w-full"
style={{ background: active && stages[i + 1]?.ts ? s.color : 'var(--dg-border)' }}
/>
<span className="mt-1 text-[10px] text-[var(--dg-text-muted)]">
{dur != null ? formatDuration(dur) : ''}
</span>
</div>
)}
</Fragment>
);
})}
</div>
</div>
);
}
@@ -29,4 +29,5 @@ export interface DeploymentViewProps {
onShowPodTerminal: (pod: PodItem) => void;
onShowPodYaml: (pod: PodItem) => void;
onShowPodFileBrowser: (pod: PodItem) => void;
onDeletePod?: (pod: PodItem) => void;
}
@@ -2,9 +2,9 @@
// 作者: Logan.Li
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertTriangle, Loader2, Minus, Plus, Trash2 } from 'lucide-react';
import { ModalShell } from './ModalShell';
import { UpdateImageModal } from './UpdateImageModal';
import type { ActionModal } from './types';
export interface ActionModalsProps {
@@ -42,26 +42,14 @@ export function ActionModals({
<>
{/* ============ 弹窗:调整镜像版本 ============ */}
{modal?.kind === 'image' && (
<ModalShell title="调整镜像版本" onClose={onClose}>
<div className="space-y-3">
<div>
<Label className="text-slate-400 text-sm">当前镜像</Label>
<p className="text-xs text-slate-500 font-mono mt-1 bg-slate-800 rounded px-3 py-2">{deployment.image || '-'}</p>
</div>
<div>
<Label className="text-slate-400 text-sm">新镜像地址</Label>
<Input value={newImage} onChange={e => setNewImage(e.target.value)}
placeholder="harbor.example.com/app:v2.0"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onClose}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'image' || !newImage.trim()} onClick={onUpdateImage}>
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
</Button>
</div>
</div>
</ModalShell>
<UpdateImageModal
deployment={deployment}
newImage={newImage}
setNewImage={setNewImage}
actionLoading={actionLoading}
onClose={onClose}
onUpdateImage={onUpdateImage}
/>
)}
{/* ============ 弹窗:伸缩 ============ */}
@@ -1,49 +1,135 @@
// CICDWebhookContent.tsx — CI/CD Webhook 弹窗内容
// 作者: Logan.Li
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store';
// CICDWebhookContent.tsx — CI/CD Webhook 弹窗内容 // Logan.Li
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { CodeSnippetBlock } from '@/components/ui/code-snippet-block';
import { userApi } from '@/api';
import { getPublicApiBase } from '@/utils/request';
import { getSessionApiAccessKey } from '@/utils/storage';
export interface CICDWebhookContentProps {
clusterCode: string;
namespace: string;
deploymentName: string;
/** 当前 Deployment 镜像 // Logan.Li */
image?: string;
/** 主容器名(可选,多容器时建议填写)// Logan.Li */
containerName?: string;
}
export function CICDWebhookContent({ clusterCode, namespace, deploymentName }: CICDWebhookContentProps) {
const { toast } = useToast();
const token = useUserStore(( s: any) => s.token) || localStorage.getItem('token') || '';
const webhookUrl = `${window.location.origin}/api/v1/clusters/${clusterCode}/namespaces/${namespace}/deployments/${deploymentName}/update-image`;
const curlCmd = `curl -X POST '${webhookUrl}' \\
-H 'Authorization: Bearer ${token ? token.substring(0, 16) + '...' : 'YOUR_TOKEN'}' \\
-H 'Content-Type: application/json' \\
-d '{"image": "harbor.example.com/app:v1.0", "container": "your-container-name"}'`;
export function CICDWebhookContent({
clusterCode,
namespace,
deploymentName,
image = '',
containerName = '',
}: CICDWebhookContentProps) {
const apiBase = getPublicApiBase();
const [keyLoading, setKeyLoading] = useState(true);
const [hasKey, setHasKey] = useState(false);
const [keyExpired, setKeyExpired] = useState(false);
const [accessKey, setAccessKey] = useState('');
const copy = (text: string) => {
navigator.clipboard.writeText(text).then(() => toast({ title: '已复制' })).catch(() => {});
};
useEffect(() => {
let cancelled = false;
(async () => {
setKeyLoading(true);
const cached = getSessionApiAccessKey();
if (cached) setAccessKey(cached);
try {
const meta = await userApi.getApiAccessKey();
if (!cancelled) {
setHasKey(!!meta?.has_token);
setKeyExpired(!!meta?.expired);
}
} catch {
if (!cancelled) setHasKey(!!cached);
} finally {
if (!cancelled) setKeyLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
const updateUrl = `${apiBase}/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}/update-image`;
const restartUrl = `${apiBase}/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}/restart`;
const updateBody = useMemo(() => {
const body: Record<string, string> = { image: image || '<新镜像地址>' };
if (containerName) body.container_name = containerName;
return JSON.stringify(body);
}, [image, containerName]);
const updateCurl = accessKey
? `curl -X POST '${updateUrl}' \\
-H 'X-AIOps-Access-Key: ${accessKey}' \\
-H 'Content-Type: application/json' \\
-d '${updateBody}'`
: `curl -X POST '${updateUrl}' \\
-H 'X-AIOps-Access-Key: <个人中心生成的访问密钥>' \\
-H 'Content-Type: application/json' \\
-d '${updateBody}'`;
const restartCurl = accessKey
? `curl -X POST '${restartUrl}' \\
-H 'X-AIOps-Access-Key: ${accessKey}'`
: `curl -X POST '${restartUrl}' \\
-H 'X-AIOps-Access-Key: <个人中心生成的访问密钥>'`;
const canCopy = !!accessKey && !keyExpired;
return (
<div className="space-y-4">
<div>
<Label className="text-slate-400 text-sm">Webhook URL</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs text-slate-300 bg-slate-800 rounded px-3 py-2 break-all font-mono">{webhookUrl}</code>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 shrink-0" onClick={() => copy(webhookUrl)}>复制</Button>
</div>
<div className="rounded-md bg-[var(--dg-bg-tertiary)]/50 px-3 py-2 text-xs text-[var(--dg-text-muted)] leading-relaxed">
{keyLoading ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />正在读取 API 密钥…
</span>
) : keyExpired ? (
<>
访问凭证已过期,请前往
<Link to="/profile" className="text-[var(--dg-blue)] hover:underline mx-1">个人中心</Link>
调整有效期或轮换凭证后再复制 curl。
</>
) : accessKey ? (
<>已填入本机保存的访问凭证,复制 curl 后可直接在终端执行触发更新。</>
) : hasKey ? (
<>
已检测到访问凭证,但本机未缓存明文。请前往
<Link to="/profile" className="text-[var(--dg-blue)] hover:underline mx-1">个人中心</Link>
轮换凭证一次,之后 curl 将自动填入。
</>
) : (
<>
请先在
<Link to="/profile" className="text-[var(--dg-blue)] hover:underline mx-1">个人中心</Link>
签发 CI/CD 访问凭证,curl 将自动填入。
</>
)}
</div>
<div>
<Label className="text-slate-400 text-sm">curl 示例</Label>
<div className="relative mt-1">
<pre className="text-xs text-slate-300 bg-slate-800 rounded p-3 overflow-x-auto font-mono whitespace-pre-wrap">{curlCmd}</pre>
<Button variant="outline" size="sm" className="absolute top-2 right-2 border-slate-700 text-slate-400" onClick={() => copy(curlCmd)}>复制</Button>
</div>
</div>
<div className="text-xs text-slate-500 space-y-1">
<p>• 使用 Bearer Token 认证(JWT 登录令牌)</p>
<p>• <code className="text-slate-400">image</code> 必填,指定新镜像地址</p>
<p>• <code className="text-slate-400">container</code> 可选,不填则更新第一个容器</p>
<CodeSnippetBlock
label="更新镜像"
code={updateCurl}
copyLabel="curl 已复制,可直接粘贴到终端执行"
copyDisabled={!canCopy}
copyDisabledTitle="请先在个人中心签发访问凭证"
/>
<CodeSnippetBlock
label="滚动重启"
code={restartCurl}
copyLabel="curl 已复制,可直接粘贴到终端执行"
copyDisabled={!canCopy}
copyDisabledTitle="请先在个人中心签发访问凭证"
/>
<div className="text-xs text-[var(--dg-text-muted)] space-y-1">
<p>• 示例镜像:<code className="text-[var(--dg-text-secondary)]">{image || '—'}</code>
{containerName ? <> · 容器 <code className="text-[var(--dg-text-secondary)]">{containerName}</code></> : null}
</p>
<p>• 使用长期 API 密钥,不会随登录 JWT 过期</p>
<p>• 修改 <code className="text-[var(--dg-text-secondary)]">image</code> 字段后执行 curl 即可触发滚动更新</p>
<p>• 也可用 <code className="text-[var(--dg-text-secondary)]">Authorization: Bearer &lt;密钥&gt;</code></p>
</div>
</div>
);
@@ -12,11 +12,13 @@ export interface EditModeContainerProps {
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onSaved: (updated?: any) => void;
onCancel: () => void;
/** 新建模式:名称可编辑,保存走 apply // Logan.Li */
isCreate?: boolean;
}
export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: EditModeContainerProps) {
export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel, isCreate = false }: EditModeContainerProps) {
type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service';
const [activeTab, setActiveTab] = useState<EditTab>('basic');
const [editManifest, setEditManifest] = useState<any>(manifest);
@@ -27,7 +29,7 @@ export function EditModeContainer({ clusterCode, namespace, deploymentName, mani
const handleSaved = (updated?: any) => {
if (updated) setEditManifest(updated);
onSaved();
onSaved(updated);
};
const tabs: Array<{ key: EditTab; label: string; icon: typeof Settings }> = [
@@ -45,6 +47,7 @@ export function EditModeContainer({ clusterCode, namespace, deploymentName, mani
manifest: editManifest,
onSaved: handleSaved,
onCancel,
isCreate,
};
return (
@@ -1,5 +1,4 @@
// ModalShell.tsx — 通用 Modal 外壳
// 作者: Logan.Li
// ModalShell.tsx — 通用 Modal 外壳(固定标题 + 内容区滚动)// Logan.Li
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
@@ -7,17 +6,25 @@ export interface ModalShellProps {
title: string;
children: React.ReactNode;
onClose: () => void;
/** lg:宽弹窗;xl:镜像地址等长文本场景 // Logan.Li */
size?: 'default' | 'lg' | 'xl';
}
export function ModalShell({ title, children, onClose }: ModalShellProps) {
export function ModalShell({ title, children, onClose, size = 'default' }: ModalShellProps) {
const widthClass =
size === 'xl' ? 'w-[780px]' : size === 'lg' ? 'w-[680px]' : 'w-[460px]';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[460px] max-w-[90vw] shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-slate-100">{title}</h2>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className={`bg-slate-900 border border-slate-700 rounded-xl ${widthClass} max-w-[92vw] max-h-[85vh] flex flex-col shadow-2xl`}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 px-6 pt-6 pb-4 shrink-0">
<h2 className="text-base font-bold text-slate-100 truncate min-w-0" title={title}>{title}</h2>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
{children}
<div className="flex-1 min-h-0 overflow-y-auto px-6 pb-6">{children}</div>
</div>
</div>
);
@@ -63,13 +63,13 @@ export function PodLogsModal({ clusterCode, namespace, podName, containers, onCl
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-3 border-b border-slate-700">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-slate-200">{podName}</span>
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{container}</Badge>}
<div className="flex items-center justify-between p-3 border-b border-slate-700 gap-3 shrink-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText className="h-4 w-4 shrink-0 text-blue-400" />
<span className="text-sm font-semibold text-slate-200 truncate" title={podName}>{podName}</span>
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px] shrink-0">{container}</Badge>}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
{containers.length > 0 && (
<select value={container} onChange={e => setContainer(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2 py-1">
@@ -0,0 +1,154 @@
// UpdateImageModal.tsx — 调整镜像版本弹窗(地址 + Tag 分栏)// Logan.Li
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ArrowRight, Loader2 } from 'lucide-react';
import { joinImageRef, parseImageRef } from '../image-ref-utils';
import { ModalShell } from './ModalShell';
export interface UpdateImageModalProps {
deployment: any;
newImage: string;
setNewImage: (value: string) => void;
actionLoading: string;
onClose: () => void;
onUpdateImage: () => void;
}
export function UpdateImageModal({
deployment,
newImage,
setNewImage,
actionLoading,
onClose,
onUpdateImage,
}: UpdateImageModalProps) {
const currentImage = deployment?.image || '';
const current = useMemo(() => parseImageRef(currentImage), [currentImage]);
const [newRepo, setNewRepo] = useState('');
const [newTag, setNewTag] = useState('');
useEffect(() => {
const parsed = parseImageRef(newImage || currentImage);
setNewRepo(parsed.repository);
setNewTag(parsed.tag);
}, [newImage, currentImage]);
const previewImage = useMemo(() => joinImageRef(newRepo, newTag), [newRepo, newTag]);
const tagChanged = newTag.trim() !== current.tag;
const repoChanged = newRepo.trim() !== current.repository;
const hasChanges = previewImage !== currentImage;
const syncImage = (repo: string, tag: string) => {
setNewImage(joinImageRef(repo, tag));
};
return (
<ModalShell title="调整镜像版本" onClose={onClose} size="xl">
<div className="space-y-5">
{/* 当前镜像:只读展示完整地址与 Tag // Logan.Li */}
<div className="rounded-lg border border-slate-700/80 bg-slate-800/40 p-4 space-y-3">
<p className="text-xs font-medium text-slate-400">当前镜像</p>
<div className="grid grid-cols-[1fr_180px] gap-3">
<div>
<Label className="text-slate-500 text-xs">镜像地址</Label>
<p
className="mt-1.5 text-sm text-slate-200 font-mono break-all leading-relaxed bg-slate-900/60 rounded-md px-3 py-2.5 min-h-[42px]"
title={current.repository || '-'}
>
{current.repository || '-'}
</p>
</div>
<div>
<Label className="text-slate-500 text-xs">当前 Tag</Label>
<p
className="mt-1.5 text-sm text-cyan-300 font-mono break-all leading-relaxed bg-slate-900/60 rounded-md px-3 py-2.5 min-h-[42px]"
title={current.tag || '-'}
>
{current.tag || '-'}
</p>
</div>
</div>
<p className="text-[11px] text-slate-500 font-mono break-all" title={currentImage}>
完整引用:<span className="text-slate-400">{currentImage || '-'}</span>
</p>
</div>
<div className="flex items-center justify-center text-slate-600">
<ArrowRight className="h-4 w-4" />
</div>
{/* 新镜像:地址与 Tag 分栏编辑 // Logan.Li */}
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 space-y-3">
<p className="text-xs font-medium text-blue-300/90">新镜像</p>
<div className="grid grid-cols-[1fr_180px] gap-3">
<div>
<Label className="text-slate-400 text-xs">镜像地址</Label>
<Input
value={newRepo}
onChange={e => {
const v = e.target.value;
setNewRepo(v);
syncImage(v, newTag);
}}
placeholder="harbor.example.com/project/app"
className="mt-1.5 h-10 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono"
/>
</div>
<div>
<Label className="text-slate-400 text-xs">新版本 Tag</Label>
<Input
value={newTag}
onChange={e => {
const v = e.target.value;
setNewTag(v);
syncImage(newRepo, v);
}}
placeholder="v2.0.0"
className="mt-1.5 h-10 bg-slate-800 border-slate-700 text-cyan-200 text-sm font-mono"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-slate-500">Tag 变更:</span>
<code className="px-2 py-0.5 rounded bg-slate-800 text-slate-400 font-mono">{current.tag}</code>
<ArrowRight className="h-3 w-3 text-slate-600" />
<code
className={`px-2 py-0.5 rounded font-mono ${
tagChanged ? 'bg-cyan-500/15 text-cyan-300' : 'bg-slate-800 text-slate-400'
}`}
>
{newTag.trim() || '-'}
</code>
{!tagChanged && !repoChanged && (
<span className="text-slate-600">(未修改)</span>
)}
{repoChanged && !tagChanged && (
<span className="text-amber-400/80">地址已变更</span>
)}
</div>
<p className="text-[11px] text-slate-500 font-mono break-all">
更新预览:<span className="text-slate-300">{previewImage || '-'}</span>
</p>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onClose}>
取消
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700"
disabled={actionLoading === 'image' || !previewImage.trim() || !hasChanges}
onClick={onUpdateImage}
>
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
</Button>
</div>
</div>
</ModalShell>
);
}
@@ -0,0 +1,44 @@
// image-ref-utils.ts — 容器镜像引用解析与拼接 // Logan.Li
export interface ImageRefParts {
repository: string;
tag: string;
/** digest 形式时存在,如 sha256:abc... // Logan.Li */
digest?: string;
}
/** 解析镜像引用为仓库地址 + tag(兼容 registry:port 与 digest)// Logan.Li */
export function parseImageRef(image: string): ImageRefParts {
const raw = (image || '').trim();
if (!raw) return { repository: '', tag: 'latest' };
if (raw.includes('@')) {
const at = raw.lastIndexOf('@');
return {
repository: raw.slice(0, at),
tag: raw.slice(at + 1),
digest: raw.slice(at + 1),
};
}
const slashIdx = raw.lastIndexOf('/');
const colonIdx = raw.lastIndexOf(':');
if (colonIdx > slashIdx) {
return {
repository: raw.slice(0, colonIdx),
tag: raw.slice(colonIdx + 1) || 'latest',
};
}
return { repository: raw, tag: 'latest' };
}
/** 拼接完整镜像引用 // Logan.Li */
export function joinImageRef(repository: string, tag: string): string {
const repo = repository.trim();
const t = tag.trim();
if (!repo) return '';
if (!t) return repo;
if (t.startsWith('sha256:')) return `${repo}@${t}`;
return `${repo}:${t}`;
}
@@ -261,13 +261,22 @@ export function stripRuntimeFields(manifest: Record<string, any>): Record<string
}
export async function parseManifest(text: string): Promise<Record<string, any> | null> {
if (!text?.trim()) return null;
try { return JSON.parse(text); } catch { /* fallthrough */ }
const docs = await parseManifests(text);
return docs[0] || null;
}
/** 解析多文档 YAML(--- 分隔)// Logan.Li */
export async function parseManifests(text: string): Promise<Record<string, any>[]> {
if (!text?.trim()) return [];
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === 'object' ? [parsed] : [];
} catch { /* fallthrough */ }
try {
const jsyaml: Record<string, any> = await import('js-yaml');
const parsed = jsyaml.load(text);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch { return null; }
const docs = jsyaml.loadAll(text) as unknown[];
return docs.filter((doc): doc is Record<string, any> => !!doc && typeof doc === 'object');
} catch { return []; }
}
export async function dumpManifest(manifest: Record<string, any>): Promise<string> {
@@ -282,16 +291,26 @@ export async function manifestToEditableYaml(manifest: Record<string, any>): Pro
return cleanYAML(dumped);
}
/** 多文档 YAML → 可编辑 manifests + 精简 YAML // Logan.Li */
export async function parseEditableManifests(text: string): Promise<{
manifests: Record<string, any>[];
yaml: string;
}> {
const parsed = await parseManifests(text);
if (parsed.length === 0) return { manifests: [], yaml: text };
const stripped = parsed.map((doc) => stripRuntimeFields(doc));
const parts: string[] = [];
for (const doc of stripped) parts.push(await manifestToEditableYaml(doc));
return { manifests: stripped, yaml: parts.join('\n---\n') };
}
/** 原始 YAML → 可编辑 manifest + 精简 YAML // Logan.Li */
export async function parseEditableManifest(text: string): Promise<{
manifest: Record<string, any> | null;
yaml: string;
}> {
const parsed = await parseManifest(text);
if (!parsed) return { manifest: null, yaml: text };
const stripped = stripRuntimeFields(parsed);
const yaml = await manifestToEditableYaml(stripped);
return { manifest: stripped, yaml };
const { manifests, yaml } = await parseEditableManifests(text);
return { manifest: manifests[0] || null, yaml };
}
/** 保存前:剥离运行时字段 + 清理默认值 // Logan.Li */
@@ -301,7 +320,10 @@ export async function prepareManifestForSave(manifest: Record<string, any>): Pro
/** 从 YAML 文本保存(解析失败时仅做文本清理)// Logan.Li */
export async function prepareYamlForSave(text: string): Promise<string> {
const parsed = await parseManifest(text);
if (parsed) return prepareManifestForSave(parsed);
return cleanYAML(text);
const parsed = await parseManifests(text);
if (parsed.length === 0) return cleanYAML(text);
if (parsed.length === 1) return prepareManifestForSave(parsed[0]);
const parts: string[] = [];
for (const doc of parsed) parts.push(await prepareManifestForSave(doc));
return parts.join('\n---\n');
}
@@ -57,6 +57,30 @@ export function createEmptyManifest(resource: string, namespace: string, name: s
resources: { requests: { storage: '1Gi' } },
},
};
case 'deployments':
return {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: { name: name || 'my-app', namespace, labels: { app: name || 'my-app' } },
spec: {
replicas: 1,
selector: { matchLabels: { app: name || 'my-app' } },
template: {
metadata: { labels: { app: name || 'my-app' } },
spec: {
containers: [{
name: 'main',
image: 'nginx:latest',
ports: [{ containerPort: 80, protocol: 'TCP' }],
resources: {
requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
}],
},
},
},
};
default:
return { apiVersion: 'v1', kind: 'Unknown', metadata: { name, namespace } };
}
@@ -9,6 +9,7 @@ export const NODE_TYPE_LABELS: Record<string, string> = {
loadbalancer: 'Ingress',
service: 'Service',
app: 'Deployment',
pod: 'Pod',
server: 'Node',
database: '数据库',
external: '外部服务',
@@ -33,6 +34,7 @@ export function getNodeIcon(type: string, className = 'h-5 w-5') {
case 'cluster': return <Cloud className={className} />;
case 'app': return <Boxes className={className} />;
case 'service': return <Network className={className} />;
case 'pod': return <Box className={className} />;
case 'database': return <Database className={className} />;
case 'external': return <Zap className={className} />;
case 'custom': return <Box className={className} />;
@@ -48,6 +50,7 @@ export function getNodeColor(type: string) {
case 'cluster': return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30';
case 'app': return 'bg-rose-500/20 text-rose-400 border-rose-500/30';
case 'service': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
case 'pod': return 'bg-teal-500/20 text-teal-400 border-teal-500/30';
case 'database': return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'external': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'custom': return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
@@ -56,18 +59,19 @@ export function getNodeColor(type: string) {
}
/**
* buildTopology — 从真实 K8s 资源构建分层拓扑图
* 层级: Cluster → Ingress → Service → Deployment → Node
* buildTopology — 从真实 K8s 资源构建分层拓扑图(自动关联)
* 推荐层级: Cluster → Ingress(域名+路径) → Service → Deployment → Pod
* 支持基于 Ingress rules、Service selector、Pod 标签的自动连线
*/
export function buildTopology( cluster: any, ingresses: any[], services: any[], deployments: any[], nodes: any[],
export function buildTopology( cluster: any, ingresses: any[], services: any[], deployments: any[], nodes: any[], pods: any[] = [],
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
const tNodes: TopologyNode[] = [];
const tEdges: TopologyEdge[] = [];
const MAX_PER_LAYER = 12;
const maxLayerCount = Math.max(1, ingresses.length, services.length, deployments.length, nodes.length);
const canvasW = Math.max(900, Math.min(maxLayerCount * 130, 2400));
const layerY = [60, 180, 320, 480, 640];
const maxLayerCount = Math.max(1, ingresses.length, services.length, deployments.length, pods.length || nodes.length);
const canvasW = Math.max(900, Math.min(maxLayerCount * 140, 2600));
const layerY = [50, 160, 280, 410, 540, 680]; // cluster, ingress, svc, deploy, pod, node (可选)
const distributeX = (count: number): number[] => {
const cap = Math.min(count, MAX_PER_LAYER);
@@ -85,12 +89,24 @@ export function buildTopology( cluster: any, ingresses: any[], services: any[],
data: { kind: 'cluster', version: cluster.version, status: cluster.status, api_server: cluster.api_server, node_count: cluster.node_count, pod_count: cluster.pod_count },
});
// Layer 1: Ingress
// Layer 1: Ingress (优先显示 域名 + 路径)
const ingCap = ingresses.slice(0, MAX_PER_LAYER);
const ingXs = distributeX(ingCap.length);
ingCap.forEach((ing, i) => {
const id = `ingress-${ing.id}`;
tNodes.push({ id, name: ing.name, type: 'loadbalancer', x: ingXs[i], y: layerY[1], data: { kind: 'ingress', namespace: ing.namespace, backend: ing.backend, tls: ing.tls, hosts: ing.hosts } });
const hosts = Array.isArray(ing.hosts) ? ing.hosts : (ing.hosts ? JSON.parse(ing.hosts) : []);
const hostLabel = hosts.length ? hosts.join(',') : (ing.name || '');
// 尝试显示路径 (如果数据中有 paths 或 backend)
const pathLabel = ing.paths ? ing.paths : (ing.backend ? `→${ing.backend}` : '');
const displayName = pathLabel ? `${hostLabel}\n${pathLabel}` : hostLabel;
tNodes.push({
id,
name: displayName,
type: 'loadbalancer',
x: ingXs[i],
y: layerY[1],
data: { kind: 'ingress', namespace: ing.namespace, backend: ing.backend, tls: ing.tls, hosts }
});
tEdges.push({ id: `e-${clusterId}-${id}`, source: clusterId, target: id, label: 'route' });
});
@@ -124,7 +140,41 @@ export function buildTopology( cluster: any, ingresses: any[], services: any[],
tNodes.push({ id, name: dep.name, type: 'app', x: depXs[i], y: layerY[3], data: { kind: 'deployment', namespace: dep.namespace, replicas: dep.replicas, ready_replicas: dep.ready_replicas, available_replicas: dep.available_replicas, image: dep.image } });
});
// Service → Deployment edges
// Layer 4: Pod (自动关联 Deployment)
const podCap = (pods || []).slice(0, MAX_PER_LAYER);
const podXs = distributeX(podCap.length);
podCap.forEach((pod, i) => {
const id = `pod-${pod.id || pod.name}`;
tNodes.push({
id,
name: pod.name || 'pod',
type: 'pod', // 需要在类型中支持
x: podXs[i],
y: layerY[4],
data: { kind: 'pod', namespace: pod.namespace, node: pod.node_name || pod.node, status: pod.status, ip: pod.ip }
});
});
// Deployment → Pod 连线 (基于 deployment 名称匹配或 namespace)
if (depCap.length > 0 && podCap.length > 0) {
depCap.forEach((dep) => {
const depNodeId = `deploy-${dep.id}`;
let matchedPods = podCap.filter((p) => p.namespace === dep.namespace);
// 简单匹配:pod 名称包含 deploy 名称 或 labels
matchedPods = matchedPods.filter((p) =>
(p.name || '').includes(dep.name) ||
(dep.name && (p.name || '').startsWith(dep.name))
);
if (matchedPods.length === 0) matchedPods = podCap.filter((p) => p.namespace === dep.namespace).slice(0, 3);
if (matchedPods.length === 0) matchedPods = podCap.slice(0, 2);
matchedPods.slice(0, 3).forEach((p) => {
const pid = `pod-${p.id || p.name}`;
tEdges.push({ id: `e-${depNodeId}-${pid}`, source: depNodeId, target: pid, label: 'owns' });
});
});
}
// Service → Deployment edges (基于 selector 自动匹配)
if (svcCap.length > 0 && depCap.length > 0) {
svcCap.forEach((svc) => {
const svcNodeId = `service-${svc.id}`;
@@ -147,18 +197,23 @@ export function buildTopology( cluster: any, ingresses: any[], services: any[],
});
}
// Layer 4: Node
// Layer 5: Node (宿主机,可选)
const nodeCap = nodes.slice(0, MAX_PER_LAYER);
const nodeXs = distributeX(nodeCap.length);
nodeCap.forEach((n, i) => {
const id = `k8snode-${n.id}`;
tNodes.push({ id, name: n.name, type: 'server', x: nodeXs[i], y: layerY[4], data: { kind: 'node', ip: n.ip, role: n.role, status: n.status, os: n.os, cpu: n.cpu, memory: n.memory, cpu_usage: n.cpu_usage, memory_usage: n.memory_usage, pod_count: n.pod_count } });
tNodes.push({ id, name: n.name, type: 'server', x: nodeXs[i], y: layerY[5], data: { kind: 'node', ip: n.ip, role: n.role, status: n.status, os: n.os, cpu: n.cpu, memory: n.memory, cpu_usage: n.cpu_usage, memory_usage: n.memory_usage, pod_count: n.pod_count } });
});
// Deployment → Node edges
// Deployment / Pod → Node (宿主机) 连线
const workerNodes = nodeCap.filter((n) => (n.role || '').toLowerCase() === 'worker');
const schedulableNodes = workerNodes.length > 0 ? workerNodes : nodeCap;
if (depCap.length > 0 && schedulableNodes.length > 0) {
if (podCap.length > 0 && schedulableNodes.length > 0) {
podCap.forEach((p) => {
const pid = `pod-${p.id || p.name}`;
schedulableNodes.slice(0, 2).forEach((n) => tEdges.push({ id: `e-${pid}-k8snode-${n.id}`, source: pid, target: `k8snode-${n.id}`, label: 'on' }));
});
} else if (depCap.length > 0 && schedulableNodes.length > 0) {
depCap.forEach((dep) => {
const depNodeId = `deploy-${dep.id}`;
schedulableNodes.slice(0, 3).forEach((n) => tEdges.push({ id: `e-${depNodeId}-k8snode-${n.id}`, source: depNodeId, target: `k8snode-${n.id}`, label: 'schedule' }));
@@ -40,6 +40,10 @@ const ROUTE_NAME_MAP: Record<string, string> = {
'groups': '用户组',
'rbac': '权限管理',
'monitor-center': '资源监控',
'log-center': '日志中心',
'loki': 'Loki',
'es': 'ES',
'logs': '日志中心',
'trace': '链路追踪',
'alert-center': '告警中心',
'datasources': '数据源',
@@ -96,6 +100,11 @@ export function Breadcrumb() {
const knownName = ROUTE_NAME_MAP[segment];
let displayName = knownName;
// 日志中心子路由 alerts 与监控告警路由区分 // Logan.Li
if (segment === 'alerts' && paths[index - 1] === 'log-center') {
displayName = '日志告警';
}
if (!displayName) {
// Dynamic parameter - could be cluster code, namespace, resource name, etc.
if (segment.length > 20 || segment.includes('-')) {
+1 -1
View File
@@ -284,7 +284,7 @@ export function Header() {
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => navigate('/users')}
onClick={() => navigate('/profile')}
>
<User className="h-4 w-4 mr-2.5" />
个人中心
@@ -39,6 +39,7 @@ import {
ShieldCheck,
FileSearch,
LogIn,
ScrollText,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -120,6 +121,15 @@ const menuItems: MenuItem[] = [
{ id: 'datasources', name: '数据源', icon: Database, path: '/datasources', permission: 'monitor:alert' },
],
},
// 日志中心:与监控中心平级,三个子菜单 // Logan.Li
{
id: 'log-center', name: '日志中心', icon: ScrollText, permission: 'monitor:alert',
children: [
{ id: 'log-loki', name: 'Loki', icon: FileSearch, path: '/log-center/loki', permission: 'monitor:alert' },
{ id: 'log-es', name: 'ES', icon: Database, path: '/log-center/es', permission: 'monitor:alert' },
{ id: 'log-alerts', name: '日志告警', icon: AlertTriangle, path: '/log-center/alerts', permission: 'monitor:alert' },
],
},
{
id: 'automation', name: '自动化运维', icon: PlayCircle, permission: 'auto:cron',
children: [
@@ -186,6 +196,7 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
(item.id === 'k8s-workload' && location.pathname.startsWith('/workloads')) ||
(item.id === 'k8s-network' && (location.pathname.includes('/services') || location.pathname.includes('/ingresses'))) ||
(item.id === 'k8s-config' && (location.pathname.includes('/configmaps') || location.pathname.includes('/secrets'))) ||
(item.id === 'log-center' && location.pathname.startsWith('/log-center')) ||
activeMenu === item.id;
const isActiveParent = hasChildren && item.children?.some((child) => {
@@ -33,12 +33,15 @@ export function AlertRuleDialog({
editingRule,
notifyChannels,
onSaved,
defaultType = 'metric',
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editingRule: any;
notifyChannels: any[];
onSaved: () => void;
/** 新建时默认规则类型 // Logan.Li */
defaultType?: 'metric' | 'log' | 'event';
}) {
const [ruleForm, setRuleForm] = useState({ ...emptyRuleForm });
const [ruleSaving, setRuleSaving] = useState(false);
@@ -58,9 +61,13 @@ export function AlertRuleDialog({
notify_channels: parseChannels(editingRule.notify_channels),
});
} else {
setRuleForm({ ...emptyRuleForm });
const base = { ...emptyRuleForm, type: defaultType };
if (defaultType === 'log') {
base.source = 'loki';
}
setRuleForm(base);
}
}, [open, editingRule]);
}, [open, editingRule, defaultType]);
const toggleNotifyChannel = (channel: string) => {
setRuleForm((prev) => ({
@@ -138,7 +145,11 @@ export function AlertRuleDialog({
<Label className="text-slate-300">规则类型</Label>
<select
value={ruleForm.type}
onChange={(e) => setRuleForm({ ...ruleForm, type: e.target.value })}
onChange={(e) => {
const type = e.target.value;
const source = type === 'log' ? 'loki' : 'prometheus';
setRuleForm({ ...ruleForm, type, source });
}}
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="metric">指标监控</option>
@@ -153,9 +164,18 @@ export function AlertRuleDialog({
onChange={(e) => setRuleForm({ ...ruleForm, source: e.target.value })}
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="prometheus">Prometheus</option>
<option value="zabbix">Zabbix</option>
<option value="custom">自定义</option>
{ruleForm.type === 'log' ? (
<>
<option value="loki">Loki</option>
<option value="elasticsearch">Elasticsearch</option>
</>
) : (
<>
<option value="prometheus">Prometheus</option>
<option value="zabbix">Zabbix</option>
<option value="custom">自定义</option>
</>
)}
</select>
</div>
</div>
@@ -165,10 +185,14 @@ export function AlertRuleDialog({
<Input
value={ruleForm.expression}
onChange={(e) => setRuleForm({ ...ruleForm, expression: e.target.value })}
placeholder="如:cpu_usage > 80"
placeholder={ruleForm.type === 'log' ? '{app="api"} |= "error"' : 'cpu_usage > 80'}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 font-mono text-sm"
/>
<p className="text-xs text-slate-600">PromQL 或指标表达式,例如 cpu_usage &gt; 80</p>
<p className="text-xs text-slate-600">
{ruleForm.type === 'log'
? (ruleForm.source === 'loki' ? 'LogQL 查询或 count_over_time 表达式' : 'Elasticsearch Lucene 查询')
: 'PromQL 或指标表达式,例如 cpu_usage > 80'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
@@ -0,0 +1,154 @@
// EsQueryPanel — Elasticsearch 查询 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Play, Plus, RefreshCw, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { logCenterApi, type LogDataSource, type LogQueryEntry } from '@/api/monitor/log-center';
import { LogDatasourceDialog } from './LogDatasourceDialog';
import { LogResultsTable } from './LogResultsTable';
import { useLogQueryContext } from './LogQueryContext';
const TIME_PRESETS = [
{ label: '15 分钟', value: '15m' },
{ label: '1 小时', value: '1h' },
{ label: '6 小时', value: '6h' },
{ label: '24 小时', value: '24h' },
];
function presetToStart(preset: string): string {
const m = preset.match(/^(\d+)(m|h)$/);
if (!m) return new Date(Date.now() - 3600000).toISOString();
const n = parseInt(m[1], 10);
const ms = m[2] === 'h' ? n * 3600000 : n * 60000;
return new Date(Date.now() - ms).toISOString();
}
export function EsQueryPanel() {
const { toast } = useToast();
const { setContext } = useLogQueryContext();
const [sources, setSources] = useState<LogDataSource[]>([]);
const [dsId, setDsId] = useState<string>('');
const [index, setIndex] = useState('logstash-*');
const [query, setQuery] = useState('level:error AND message:*timeout*');
const [timePreset, setTimePreset] = useState('1h');
const [limit, setLimit] = useState(200);
const [entries, setEntries] = useState<LogQueryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [dsDialog, setDsDialog] = useState(false);
const loadSources = useCallback(async () => {
const list = await logCenterApi.listDatasources('elasticsearch').catch(() => []);
const arr = Array.isArray(list) ? list : [];
setSources(arr);
if (!dsId && arr.length) {
const def = arr.find(s => s.is_default) || arr[0];
setDsId(String(def.id));
if (def.default_index) setIndex(def.default_index);
}
}, [dsId]);
useEffect(() => { loadSources(); }, [loadSources]);
const syncContext = useCallback((q: string, id: string, idx: string) => {
const ds = sources.find(s => String(s.id) === id);
setContext({
engine: 'elasticsearch',
datasourceId: ds ? ds.id : undefined,
datasourceName: ds?.name,
query: q,
index: idx,
timeRange: timePreset,
});
}, [sources, timePreset, setContext]);
useEffect(() => {
syncContext(query, dsId, index);
}, [query, dsId, index, syncContext]);
const runQuery = async () => {
if (!dsId) return;
setLoading(true);
try {
const res = await logCenterApi.queryElasticsearch({
datasource_id: Number(dsId),
index: index.trim() || undefined,
query: query.trim() || '*',
size: limit,
start: presetToStart(timePreset),
end: new Date().toISOString(),
});
setEntries(res?.entries || []);
toast({ title: '查询完成', description: `共 ${res?.total ?? 0} 条` });
} catch (e: any) {
toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' });
setEntries([]);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[200px] flex-1">
<Label className="text-xs text-[var(--dg-text-muted)]">数据源</Label>
<div className="flex gap-2 mt-1">
<Select value={dsId} onValueChange={setDsId}>
<SelectTrigger className="h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectValue placeholder="选择 ES" />
</SelectTrigger>
<SelectContent>
{sources.map(s => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}{s.is_default ? ' (默认)' : ''}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={() => setDsDialog(true)}>
<Plus className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={loadSources}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="w-[140px]">
<Label className="text-xs text-[var(--dg-text-muted)]">索引</Label>
<Input value={index} onChange={e => setIndex(e.target.value)} className="h-9 mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<div className="w-[120px]">
<Label className="text-xs text-[var(--dg-text-muted)]">时间范围</Label>
<Select value={timePreset} onValueChange={setTimePreset}>
<SelectTrigger className="h-9 mt-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button className="h-9 bg-[var(--dg-blue)] hover:opacity-90" onClick={runQuery} disabled={loading || !dsId}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Play className="h-4 w-4 mr-1" />}
查询
</Button>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">Lucene 查询</Label>
<Input
value={query}
onChange={e => setQuery(e.target.value)}
className="mt-1 font-mono text-xs h-10 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
placeholder='status:500 AND path:"/api/*"'
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">支持 Lucene 语法;留空则匹配全部 · 单次最多 500 条,不落库</p>
</div>
<LogResultsTable entries={entries} loading={loading} />
<LogDatasourceDialog open={dsDialog} onOpenChange={setDsDialog} engine="elasticsearch" onSaved={loadSources} />
</div>
);
}
@@ -0,0 +1,136 @@
// LogAlertFromQueryDialog — 叮当:将查询转为日志告警规则 // Logan.Li
import { useState, useEffect } from 'react';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
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 { Loader2, BellRing } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { alertApi } from '@/api';
import type { LogQueryContext } from './log-center-types';
export function LogAlertFromQueryDialog({
open, onOpenChange, ctx, onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
ctx: LogQueryContext | null;
onSaved?: () => void;
}) {
const { toast } = useToast();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [expression, setExpression] = useState('');
const [severity, setSeverity] = useState('warning');
const [duration, setDuration] = useState(300);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open || !ctx) return;
const engineLabel = ctx.engine === 'loki' ? 'Loki' : 'ES';
setName(`${engineLabel} 日志告警`);
setDescription(
`数据源: ${ctx.datasourceName || '—'} · 范围: ${ctx.timeRange || '—'}${ctx.index ? ` · 索引: ${ctx.index}` : ''}`,
);
if (ctx.engine === 'loki') {
setExpression(`count_over_time(${wrapLogQL(ctx.query)}[5m]) > 0`);
} else {
setExpression(ctx.query || '*');
}
}, [open, ctx]);
const handleSave = async () => {
if (!name.trim() || !expression.trim() || !ctx) return;
setSaving(true);
try {
await alertApi.createRule({
name: name.trim(),
description: description.trim(),
type: 'log',
source: ctx.engine === 'loki' ? 'loki' : 'elasticsearch',
expression: expression.trim(),
severity,
duration,
notify_channels: [],
});
toast({ title: '告警规则已创建', description: '可在「日志告警」Tab 中继续配置通知渠道' });
onOpenChange(false);
onSaved?.();
} catch (e: any) {
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<BellRing className="h-5 w-5 text-amber-400" />
叮当 · 添加告警
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
将当前查询条件保存为日志告警规则,命中时触发通知
</DialogDescription>
</DialogHeader>
{!ctx?.query?.trim() ? (
<p className="text-sm text-amber-400/90 py-4">请先在 Loki 或 ES 查询页输入查询语句</p>
) : (
<div className="space-y-3">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">规则名称</Label>
<Input value={name} onChange={e => setName(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">描述</Label>
<Textarea value={description} onChange={e => setDescription(e.target.value)} rows={2} className="mt-1 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] resize-none" />
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">触发条件</Label>
<Textarea value={expression} onChange={e => setExpression(e.target.value)} rows={4} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
{ctx.engine === 'loki' ? 'LogQL 告警表达式,默认 count_over_time' : 'ES Lucene 查询,匹配到日志即告警'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">级别</Label>
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full h-9 mt-1 px-2 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm">
<option value="info">提示</option>
<option value="warning">警告</option>
<option value="critical">严重</option>
</select>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">持续(秒)</Label>
<Input type="number" value={duration} onChange={e => setDuration(Number(e.target.value) || 300)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
</div>
<div className="rounded-md bg-[var(--dg-bg-tertiary)]/60 p-2 text-[10px] font-mono text-[var(--dg-text-secondary)] break-all">
原始查询: {ctx.query}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving || !ctx?.query?.trim()}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
创建规则
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** 为 count_over_time 包裹 selector // Logan.Li */
function wrapLogQL(q: string): string {
const t = q.trim();
if (t.startsWith('{')) return t;
return `{job=~".+"} |= "${t.replace(/"/g, '\\"')}"`;
}
@@ -0,0 +1,118 @@
// LogAlertsPanel — 日志告警规则列表 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Plus, Pencil, Trash2, RefreshCw, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertApi } from '@/api';
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
export function LogAlertsPanel() {
const { toast } = useToast();
const confirm = useConfirm();
const [rules, setRules] = useState<any[]>([]);
const [channels, setChannels] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const [rulesRes, chRes] = await Promise.all([
alertApi.getRules(),
alertApi.getNotifyChannels(),
]);
const all = (rulesRes as any)?.list || (Array.isArray(rulesRes) ? rulesRes : []);
setRules(all.filter((r: any) => r.type === 'log' || r.source === 'loki' || r.source === 'elasticsearch'));
setChannels((chRes as any)?.list || []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleDelete = async (rule: any) => {
const ok = await confirm({ title: '删除规则', description: `删除「${rule.name}」?`, variant: 'destructive', confirmText: '删除' });
if (!ok) return;
try {
await alertApi.deleteRule(rule.id);
toast({ title: '已删除' });
load();
} catch (e: any) {
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
}
};
const severityColor: Record<string, string> = {
critical: 'bg-red-500/15 text-red-400',
warning: 'bg-amber-500/15 text-amber-400',
info: 'bg-blue-500/15 text-blue-400',
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-[var(--dg-text-muted)]">仅保存告警策略(LogQL / Lucene),日志数据仍在外部 Loki / ES</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-1" />新建
</Button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>
) : rules.length === 0 ? (
<Card className="dg-card">
<CardContent className="py-12 text-center text-[var(--dg-text-muted)] text-sm">
暂无日志告警。在 Loki/ES 查询页点击右下角「叮当」可从当前查询快速创建。
</CardContent>
</Card>
) : (
<div className="space-y-2">
{rules.map(rule => (
<Card key={rule.id} className="dg-card">
<CardContent className="py-3 px-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px]">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-[var(--dg-text-primary)]">{rule.name}</span>
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)]">{rule.source || 'log'}</Badge>
<Badge className={`text-[10px] ${severityColor[rule.severity] || ''}`}>{rule.severity}</Badge>
{!rule.enabled && rule.enabled !== undefined && (
<Badge variant="outline" className="text-[10px] text-[var(--dg-text-muted)]">已禁用</Badge>
)}
</div>
<p className="text-[11px] font-mono text-[var(--dg-text-muted)] mt-1 truncate" title={rule.expression}>{rule.expression}</p>
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setEditing(rule); setDialogOpen(true); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400" onClick={() => handleDelete(rule)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<AlertRuleDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingRule={editing}
notifyChannels={channels}
onSaved={load}
defaultType="log"
/>
</div>
);
}
@@ -0,0 +1,54 @@
// LogCenter — 日志中心(侧边栏子菜单驱动:Loki / ES / 日志告警)// Logan.Li
import { useState } from 'react';
import { Navigate, useParams } from 'react-router-dom';
import { ScrollText } from 'lucide-react';
import { LogQueryProvider } from './LogQueryContext';
import { LokiQueryPanel } from './LokiQueryPanel';
import { EsQueryPanel } from './EsQueryPanel';
import { LogAlertsPanel } from './LogAlertsPanel';
import { LogDingAssistant } from './LogDingAssistant';
import { LogCenterArchBanner } from './LogCenterArchBanner';
import type { LogCenterTab } from './log-center-types';
const SECTION_META: Record<LogCenterTab, { title: string; description: string }> = {
loki: { title: 'Loki 查询', description: '对接外部 Loki · LogQL 实时检索' },
es: { title: 'ES 查询', description: '对接 Elasticsearch · 索引日志检索' },
alerts: { title: '日志告警', description: '基于查询条件的日志告警策略' },
};
export default function LogCenter() {
const { section } = useParams<{ section: string }>();
const tab: LogCenterTab | null =
section && ['loki', 'es', 'alerts'].includes(section) ? (section as LogCenterTab) : null;
const [alertReloadKey, setAlertReloadKey] = useState(0);
if (!tab) return <Navigate to="/log-center/loki" replace />;
const meta = SECTION_META[tab];
return (
<LogQueryProvider>
<div className="space-y-4 pb-20">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-cyan-500/20 to-blue-500/20 flex items-center justify-center border border-cyan-500/20">
<ScrollText className="h-5 w-5 text-cyan-400" />
</div>
<div>
<h1 className="text-xl font-bold text-[var(--dg-text-primary)]">{meta.title}</h1>
<p className="text-sm text-[var(--dg-text-muted)]">{meta.description}</p>
</div>
</div>
{tab !== 'alerts' && <LogCenterArchBanner />}
{tab === 'loki' && <LokiQueryPanel />}
{tab === 'es' && <EsQueryPanel />}
{tab === 'alerts' && <LogAlertsPanel key={alertReloadKey} />}
{tab !== 'alerts' && (
<LogDingAssistant onRuleCreated={() => setAlertReloadKey(k => k + 1)} />
)}
</div>
</LogQueryProvider>
);
}
@@ -0,0 +1,17 @@
// LogCenterArchBanner — 架构说明:平台不落库海量日志 // Logan.Li
import { Info } from 'lucide-react';
export function LogCenterArchBanner() {
return (
<div className="flex gap-3 rounded-lg border border-cyan-500/20 bg-cyan-500/5 px-4 py-3 text-xs text-[var(--dg-text-secondary)] leading-relaxed">
<Info className="h-4 w-4 shrink-0 text-cyan-400 mt-0.5" />
<div>
<p className="text-[var(--dg-text-primary)] font-medium mb-1">无状态查询网关</p>
<p>
AIOps 不存储 Loki / Elasticsearch 中的海量日志,仅保存<strong className="text-[var(--dg-text-primary)] font-normal">数据源连接</strong>与
<strong className="text-[var(--dg-text-primary)] font-normal">告警策略</strong>。查询时实时转发至外部集群,结果仅在当前页面展示,刷新即清空。
</p>
</div>
</div>
);
}
@@ -0,0 +1,160 @@
// LogDatasourceDialog — 添加/编辑 Loki / ES 数据源 // Logan.Li
import { useState, useEffect } from 'react';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Loader2, Plug } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center';
import type { LogEngine } from './log-center-types';
export function LogDatasourceDialog({
open, onOpenChange, engine, editing, onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
engine: LogEngine;
editing?: LogDataSource | null;
onSaved: () => void;
}) {
const { toast } = useToast();
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [defaultIndex, setDefaultIndex] = useState('logstash-*');
const [basicAuth, setBasicAuth] = useState(false);
const [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [skipTLS, setSkipTLS] = useState(false);
const [isDefault, setIsDefault] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
if (!open) return;
if (editing) {
setName(editing.name);
setUrl(editing.url);
setDefaultIndex(editing.default_index || 'logstash-*');
setBasicAuth(!!editing.basic_auth);
setUser(editing.basic_auth_user || '');
setPass('');
setSkipTLS(!!editing.skip_tls_verify);
setIsDefault(!!editing.is_default);
} else {
setName(engine === 'loki' ? 'Loki' : 'Elasticsearch');
setUrl(engine === 'loki' ? 'http://localhost:3100' : 'http://localhost:9200');
setDefaultIndex('logstash-*');
setBasicAuth(false);
setUser('');
setPass('');
setSkipTLS(false);
setIsDefault(false);
}
}, [open, editing, engine]);
const payload = () => ({
name: name.trim(),
type: engine,
url: url.trim(),
basic_auth: basicAuth,
basic_auth_user: user,
basic_auth_pass: pass,
skip_tls_verify: skipTLS,
default_index: engine === 'elasticsearch' ? defaultIndex : undefined,
is_default: isDefault,
});
const handleTest = async () => {
setTesting(true);
try {
await logCenterApi.testDatasource(payload());
toast({ title: '连接成功' });
} catch (e: any) {
toast({ title: '连接失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setTesting(false);
}
};
const handleSave = async () => {
if (!name.trim() || !url.trim()) return;
setSaving(true);
try {
if (editing) {
await logCenterApi.updateDatasource(editing.id, payload());
} else {
await logCenterApi.createDatasource(payload());
}
toast({ title: '已保存' });
onOpenChange(false);
onSaved();
} catch (e: any) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{editing ? '编辑' : '添加'}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
仅保存连接信息,日志本体存储在您的 Loki / ES 集群
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-1">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">名称</Label>
<Input value={name} onChange={e => setName(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">地址</Label>
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder="http://host:port" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
{engine === 'elasticsearch' && (
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">默认索引</Label>
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
)}
<div className="flex items-center justify-between">
<Label className="text-xs text-[var(--dg-text-muted)]">基础认证</Label>
<Switch checked={basicAuth} onCheckedChange={setBasicAuth} />
</div>
{basicAuth && (
<div className="grid grid-cols-2 gap-2">
<Input value={user} onChange={e => setUser(e.target.value)} placeholder="用户名" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Input type="password" value={pass} onChange={e => setPass(e.target.value)} placeholder="密码" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
)}
<div className="flex items-center justify-between">
<Label className="text-xs text-[var(--dg-text-muted)]">跳过 TLS 校验</Label>
<Switch checked={skipTLS} onCheckedChange={setSkipTLS} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-[var(--dg-text-muted)]">设为默认</Label>
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={handleTest} disabled={testing}>
{testing ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plug className="h-4 w-4 mr-1" />}
测试
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,36 @@
// LogDingAssistant — 小叮当浮动助手:查询转告警 // Logan.Li
import { useState } from 'react';
import { Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useLogQueryContext } from './LogQueryContext';
import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog';
export function LogDingAssistant({ onRuleCreated }: { onRuleCreated?: () => void }) {
const { context } = useLogQueryContext();
const [open, setOpen] = useState(false);
return (
<>
<div className="fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2">
<div className="hidden sm:block rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] px-3 py-1.5 text-[11px] text-[var(--dg-text-muted)] shadow-lg opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
将当前查询添加为告警
</div>
<Button
size="lg"
className="h-12 rounded-full shadow-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-400 hover:to-orange-400 text-white gap-2 px-5"
onClick={() => setOpen(true)}
title="叮当 · 添加告警"
>
<Sparkles className="h-5 w-5" />
<span className="font-medium">叮当</span>
</Button>
</div>
<LogAlertFromQueryDialog
open={open}
onOpenChange={setOpen}
ctx={context}
onSaved={onRuleCreated}
/>
</>
);
}
@@ -0,0 +1,26 @@
// LogQueryContext — 查询上下文(供叮当助手读取)// Logan.Li
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import type { LogQueryContext as LogQueryCtx } from './log-center-types';
interface LogQueryContextValue {
context: LogQueryCtx | null;
setContext: (ctx: LogQueryCtx | null) => void;
}
const Ctx = createContext<LogQueryContextValue | null>(null);
export function LogQueryProvider({ children }: { children: ReactNode }) {
const [context, setContext] = useState<LogQueryCtx | null>(null);
const stableSet = useCallback((ctx: LogQueryCtx | null) => setContext(ctx), []);
return (
<Ctx.Provider value={{ context, setContext: stableSet }}>
{children}
</Ctx.Provider>
);
}
export function useLogQueryContext() {
const v = useContext(Ctx);
if (!v) throw new Error('useLogQueryContext must be used within LogQueryProvider');
return v;
}
@@ -0,0 +1,42 @@
// LogResultsTable — 日志查询结果表格 // Logan.Li
import { Badge } from '@/components/ui/badge';
import type { LogQueryEntry } from '@/api/monitor/log-center';
const levelColor: Record<string, string> = {
error: 'bg-red-500/15 text-red-400 border-red-500/30',
warn: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
warning: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
info: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
debug: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
};
export function LogResultsTable({ entries, loading }: { entries: LogQueryEntry[]; loading?: boolean }) {
if (loading) {
return <div className="text-center py-16 text-[var(--dg-text-muted)] text-sm">查询中...</div>;
}
if (entries.length === 0) {
return <div className="text-center py-16 text-[var(--dg-text-muted)] text-sm">暂无日志,调整查询条件后重试</div>;
}
return (
<div className="border border-[var(--dg-border)] rounded-lg overflow-hidden">
<div className="grid grid-cols-[160px_72px_1fr] gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)]/50 text-[11px] font-medium text-[var(--dg-text-muted)] uppercase">
<span>时间</span>
<span>级别</span>
<span>内容</span>
</div>
<div className="max-h-[480px] overflow-y-auto divide-y divide-[var(--dg-border)]">
{entries.map((e, i) => (
<div key={i} className="grid grid-cols-[160px_72px_1fr] gap-2 px-3 py-2 text-xs hover:bg-[var(--dg-bg-tertiary)]/30">
<span className="font-mono text-[var(--dg-text-muted)] truncate" title={e.timestamp}>{e.timestamp}</span>
<span>
{e.level ? (
<Badge variant="outline" className={`text-[10px] ${levelColor[e.level.toLowerCase()] || 'border-[var(--dg-border)]'}`}>{e.level}</Badge>
) : '—'}
</span>
<pre className="font-mono text-[var(--dg-text-secondary)] whitespace-pre-wrap break-all leading-relaxed">{e.message}</pre>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,154 @@
// LokiQueryPanel — LogQL 查询 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
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 { Play, Plus, RefreshCw, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { logCenterApi, type LogDataSource, type LogQueryEntry } from '@/api/monitor/log-center';
import { LogDatasourceDialog } from './LogDatasourceDialog';
import { LogResultsTable } from './LogResultsTable';
import { useLogQueryContext } from './LogQueryContext';
const TIME_PRESETS = [
{ label: '15 分钟', value: '15m' },
{ label: '1 小时', value: '1h' },
{ label: '6 小时', value: '6h' },
{ label: '24 小时', value: '24h' },
];
function presetToStart(preset: string): string {
const m = preset.match(/^(\d+)(m|h)$/);
if (!m) return new Date(Date.now() - 3600000).toISOString();
const n = parseInt(m[1], 10);
const ms = m[2] === 'h' ? n * 3600000 : n * 60000;
return new Date(Date.now() - ms).toISOString();
}
export function LokiQueryPanel() {
const { toast } = useToast();
const { setContext } = useLogQueryContext();
const [sources, setSources] = useState<LogDataSource[]>([]);
const [dsId, setDsId] = useState<string>('');
const [query, setQuery] = useState('{namespace="default"} |= "error"');
const [timePreset, setTimePreset] = useState('1h');
const [limit, setLimit] = useState(200);
const [entries, setEntries] = useState<LogQueryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [dsDialog, setDsDialog] = useState(false);
const loadSources = useCallback(async () => {
const list = await logCenterApi.listDatasources('loki').catch(() => []);
const arr = Array.isArray(list) ? list : [];
setSources(arr);
if (!dsId && arr.length) {
const def = arr.find(s => s.is_default) || arr[0];
setDsId(String(def.id));
}
}, [dsId]);
useEffect(() => { loadSources(); }, [loadSources]);
const syncContext = useCallback((q: string, id: string) => {
const ds = sources.find(s => String(s.id) === id);
setContext({
engine: 'loki',
datasourceId: ds ? ds.id : undefined,
datasourceName: ds?.name,
query: q,
timeRange: timePreset,
});
}, [sources, timePreset, setContext]);
useEffect(() => {
syncContext(query, dsId);
}, [query, dsId, syncContext]);
const runQuery = async () => {
if (!dsId || !query.trim()) return;
setLoading(true);
try {
const res = await logCenterApi.queryLoki({
datasource_id: Number(dsId),
query: query.trim(),
limit,
start: presetToStart(timePreset),
end: new Date().toISOString(),
});
setEntries(res?.entries || []);
toast({ title: '查询完成', description: `共 ${res?.total ?? 0} 条` });
} catch (e: any) {
toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' });
setEntries([]);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[200px] flex-1">
<Label className="text-xs text-[var(--dg-text-muted)]">数据源</Label>
<div className="flex gap-2 mt-1">
<Select value={dsId} onValueChange={setDsId}>
<SelectTrigger className="h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectValue placeholder="选择 Loki" />
</SelectTrigger>
<SelectContent>
{sources.map(s => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}{s.is_default ? ' (默认)' : ''}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={() => setDsDialog(true)} title="添加数据源">
<Plus className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={loadSources} title="刷新">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="w-[120px]">
<Label className="text-xs text-[var(--dg-text-muted)]">时间范围</Label>
<Select value={timePreset} onValueChange={setTimePreset}>
<SelectTrigger className="h-9 mt-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="w-[88px]">
<Label className="text-xs text-[var(--dg-text-muted)]">条数</Label>
<Input type="number" value={limit} onChange={e => setLimit(Number(e.target.value) || 200)} className="h-9 mt-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<Button className="h-9 bg-[var(--dg-blue)] hover:opacity-90" onClick={runQuery} disabled={loading || !dsId}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Play className="h-4 w-4 mr-1" />}
查询
</Button>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">LogQL</Label>
<Textarea
value={query}
onChange={e => setQuery(e.target.value)}
rows={3}
className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] resize-y"
placeholder='{app="nginx"} |= "error"'
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
示例:{'{job="varlogs"}'} | json | level="error" · 单次最多 500 条,不落库
</p>
</div>
<LogResultsTable entries={entries} loading={loading} />
<LogDatasourceDialog open={dsDialog} onOpenChange={setDsDialog} engine="loki" onSaved={loadSources} />
</div>
);
}
@@ -0,0 +1,15 @@
// log-center-types.ts — 日志中心类型(无状态:仅存连接与告警策略)// Logan.Li
export type LogCenterTab = 'loki' | 'es' | 'alerts';
export type LogEngine = 'loki' | 'elasticsearch';
/** 叮当助手可读取的当前查询上下文 // Logan.Li */
export interface LogQueryContext {
engine: LogEngine;
datasourceId?: number;
datasourceName?: string;
query: string;
index?: string;
timeRange?: string;
}
+31 -55
View File
@@ -25,6 +25,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Server, ChevronDown, Loader2, Save, Key, Shield } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { credentialApi } from '@/api';
// 表单数据结构
@@ -96,8 +97,15 @@ export function ServerForm({
onSave,
formSaving,
}: ServerFormProps) {
const navigate = useNavigate();
const [credentials, setCredentials] = useState<Credential[]>([]);
// 只显示 SSH 相关的凭证(ssh key / ssh password)
const sshCredentials = credentials.filter((cred: any) => {
const at = (cred.auth_type || cred.type || cred.secret_type || '').toLowerCase();
return at === 'key' || at === 'password' || at === 'ssh_key' || at === 'ssh_password' || at.includes('ssh');
});
// 加载凭证列表
useEffect(() => {
if (open) {
@@ -110,11 +118,10 @@ export function ServerForm({
}
}, [open]);
// 选择凭证时自动填充
// 选择凭证时自动填充(登录用户和认证方式由凭证提供,不再手动)
const handleCredentialSelect = (credId: string) => {
const id = Number(credId);
if (!id) {
// 手动输入模式
setFormData({ ...formData, credential_id: null });
return;
}
@@ -125,7 +132,7 @@ export function ServerForm({
credential_id: id,
username: cred.username || 'root',
auth_type: cred.auth_type || 'password',
password: '', // 已关联凭证,不需要重复输入
password: '', // 已关联凭证
ssh_key: '',
});
}
@@ -194,7 +201,7 @@ export function ServerForm({
</div>
</div>
{/* 凭证选择 — 下拉关联已有凭证 */}
{/* SSH 凭证关联 — 只允许 SSH 密钥 / SSH 密码 类型 */}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)] flex items-center gap-1.5">
<Key className="h-3.5 w-3.5" /> SSH 凭证
@@ -205,71 +212,40 @@ export function ServerForm({
onValueChange={handleCredentialSelect}
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder="选择已有凭证..." />
<SelectValue placeholder="选择 SSH 凭证..." />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="__manual">手动输入(不关联凭证)</SelectItem>
{credentials.map((cred) => (
{sshCredentials.map((cred) => (
<SelectItem key={cred.id} value={String(cred.id)}>
<span className="flex items-center gap-2">
<Shield className="h-3 w-3 text-blue-400" />
{cred.name} ({cred.username}@{cred.auth_type === 'key' ? '密钥' : '密码'})
{cred.name} ({cred.username}@{ (cred.auth_type || '').toLowerCase() === 'key' ? '密钥' : '密码'})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{sshCredentials.length === 0 && (
<p className="text-xs text-[var(--dg-text-muted)]">
暂无可用的 SSH 凭证(仅支持 SSH 密钥 和 SSH 密码 类型)。
<span
className="text-[var(--dg-blue)] hover:underline cursor-pointer ml-1"
onClick={() => {
onOpenChange(false);
navigate('/credentials');
}}
>
去系统管理 - 凭证配置创建
</span>
</p>
)}
{formData.credential_id && (
<p className="text-[10px] text-green-400/70">已关联凭证,登录信息从凭证自动获取</p>
<p className="text-[10px] text-green-400/70">已关联凭证,登录信息从凭证自动获取(登录用户和认证方式由凭证决定)</p>
)}
</div>
{/* 手动输入模式 — 仅当未选凭证时显示 */}
{!formData.credential_id && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">登录用户</Label>
<Input
placeholder="root"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">认证方式</Label>
<Select
value={formData.auth_type}
onValueChange={(v) => setFormData({ ...formData, auth_type: v })}
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="password">密码认证</SelectItem>
<SelectItem value="key">密钥认证</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
{formData.auth_type === 'key' ? 'SSH 私钥' : '登录密码'}
{!editingId && <span className="text-red-400 ml-1">*</span>}
{editingId && <span className="text-[var(--dg-text-muted)] ml-1 text-xs">(留空则不修改)</span>}
</Label>
<Textarea
placeholder={formData.auth_type === 'key' ? '粘贴 SSH 私钥内容...' : '输入登录密码...'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[50px] font-mono text-sm"
/>
</div>
</>
)}
{/* 更多详情 */}
<Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
<CollapsibleTrigger asChild>
@@ -0,0 +1,95 @@
// 管理员重置用户密码弹窗 // Logan.Li
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Key, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { userApi } from '@/api';
interface ResetPasswordDialogProps {
user: { id: number | string; username?: string; nickname?: string; authSource?: string } | null;
onClose: () => void;
}
export function ResetPasswordDialog({ user, onClose }: ResetPasswordDialogProps) {
const { toast } = useToast();
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [saving, setSaving] = useState(false);
const isLdap = user?.authSource === 'ldap';
const canSubmit = password.length >= 6 && password === confirm && !isLdap;
const handleReset = async () => {
if (!user || !canSubmit) return;
setSaving(true);
try {
await userApi.resetUserPassword(user.id, password);
toast({ title: '密码已重置', description: `用户 ${user.nickname || user.username} 的新密码已生效` });
setPassword('');
setConfirm('');
onClose();
} catch (e: any) {
toast({ title: '重置失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setSaving(false);
}
};
return (
<Dialog open={!!user} onOpenChange={(open) => { if (!open) { setPassword(''); setConfirm(''); onClose(); } }}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Key className="h-5 w-5 text-[var(--dg-blue)]" />
重置密码
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
{user ? `为「${user.nickname || user.username}」设置新密码` : ''}
</DialogDescription>
</DialogHeader>
{isLdap ? (
<p className="text-sm text-amber-400/90 py-2">LDAP 用户请在 LDAP 目录中管理密码,不支持本地重置。</p>
) : (
<div className="space-y-3 py-1">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">新密码</Label>
<Input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="至少 6 位"
className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">确认密码</Label>
<Input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
placeholder="再次输入新密码"
className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" className="border-[var(--dg-border)]" onClick={onClose}>取消</Button>
{!isLdap && (
<Button onClick={handleReset} disabled={!canSubmit || saving} className="bg-[var(--dg-blue)] hover:opacity-90">
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
确认重置
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -9,6 +9,7 @@ import type { Role } from '@/types';
import { SystemUserTab } from './SystemUserTab';
import { SystemRolesTab } from './SystemRolesTab';
import { SystemSettingsTab } from './SystemSettingsTab';
import { ResetPasswordDialog } from './ResetPasswordDialog';
import { useLdapManagement } from './useLdapManagement';
import type { UserFormData } from './UserFormDialog';
@@ -56,6 +57,7 @@ export function SystemManager() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUser, setSelectedUser] = useState<any | null>(null);
const [resetPasswordUser, setResetPasswordUser] = useState<any | null>(null);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
// 创建/编辑用户弹窗
@@ -274,6 +276,7 @@ export function SystemManager() {
openCreate={openCreate}
openEdit={openEdit}
handleDelete={handleDelete}
onResetPassword={(u) => { setSelectedUser(null); setResetPasswordUser(u); }}
importDialogOpen={importDialogOpen}
setImportDialogOpen={setImportDialogOpen}
importPreview={importPreview}
@@ -307,6 +310,8 @@ export function SystemManager() {
<SystemSettingsTab {...ldap.settingsTabProps} />
</TabsContent>
</Tabs>
<ResetPasswordDialog user={resetPasswordUser} onClose={() => setResetPasswordUser(null)} />
</div>
);
}
@@ -48,6 +48,7 @@ interface SystemUserTabProps {
openCreate: () => void;
openEdit: ( user: any) => void;
handleDelete: ( user: any) => void;
onResetPassword?: (user: any) => void;
// CSV import
importDialogOpen: 'csv' | 'ldap' | null;
setImportDialogOpen: (v: 'csv' | 'ldap' | null) => void;
@@ -89,6 +90,7 @@ export function SystemUserTab({
openCreate,
openEdit,
handleDelete,
onResetPassword,
importDialogOpen,
setImportDialogOpen,
importPreview,
@@ -277,6 +279,7 @@ export function SystemUserTab({
setSelectedUser={setSelectedUser}
getUserStatusBadge={getUserStatusBadge}
openEdit={openEdit}
onResetPassword={onResetPassword}
/>
<UserFormDialog
@@ -16,6 +16,7 @@ interface UserDetailDialogProps {
setSelectedUser: (u: any | null) => void;
getUserStatusBadge: (status: 'active' | 'inactive') => React.ReactNode;
openEdit: ( user: any) => void;
onResetPassword?: (user: any) => void;
}
// UserDetailDialog 仅展示选中用户的基本信息和角色列表 // Logan.Li
@@ -24,6 +25,7 @@ export function UserDetailDialog({
setSelectedUser,
getUserStatusBadge,
openEdit,
onResetPassword,
}: UserDetailDialogProps) {
return (
<Dialog open={!!selectedUser} onOpenChange={() => setSelectedUser(null)}>
@@ -81,7 +83,12 @@ export function UserDetailDialog({
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Button
variant="outline"
className="border-slate-700 text-slate-300"
disabled={selectedUser?.authSource === 'ldap'}
onClick={() => selectedUser && onResetPassword?.(selectedUser)}
>
<Key className="h-4 w-4 mr-2" />
重置密码
</Button>
@@ -0,0 +1,43 @@
// code-snippet-block.tsx — 代码片段展示 + 外侧复制按钮 // Logan.Li
import { CopyButton } from '@/components/ui/copy-button';
import { cn } from '@/lib/utils';
export interface CodeSnippetBlockProps {
label?: string;
code: string;
copyLabel?: string;
className?: string;
/** 无密钥等场景禁用复制 // Logan.Li */
copyDisabled?: boolean;
copyDisabledTitle?: string;
}
export function CodeSnippetBlock({
label, code, copyLabel = '已复制', className, copyDisabled, copyDisabledTitle,
}: CodeSnippetBlockProps) {
return (
<div className={cn('space-y-1.5', className)}>
<div className="flex items-center justify-between gap-2">
{label ? (
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
) : (
<span />
)}
<CopyButton
text={code}
label={copyLabel}
variant="outline"
size="sm"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] h-7 px-2"
disabled={copyDisabled}
title={copyDisabled ? (copyDisabledTitle || '不可复制') : '复制'}
>
复制
</CopyButton>
</div>
<pre className="text-[11px] font-mono text-[var(--dg-text-secondary)] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md p-3 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{code}
</pre>
</div>
);
}
@@ -0,0 +1,74 @@
// copy-button.tsx — 统一复制按钮(图标 + 文案)// Logan.Li
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import { copyToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils';
export interface CopyButtonProps {
text: string;
/** 复制成功 toast 标题 */
label?: string;
/** 按钮展示文案,默认仅图标 */
children?: React.ReactNode;
className?: string;
size?: 'default' | 'sm' | 'lg' | 'icon';
variant?: 'default' | 'outline' | 'ghost' | 'secondary';
disabled?: boolean;
title?: string;
}
export function CopyButton({
text,
label = '已复制',
children,
className,
size = 'sm',
variant = 'outline',
disabled,
title = '复制',
}: CopyButtonProps) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
const ok = await copyToClipboard(text);
if (ok) {
setCopied(true);
toast({ title: label });
setTimeout(() => setCopied(false), 2000);
} else {
toast({
title: '复制失败',
description: '请手动选中内容后复制',
variant: 'destructive',
});
}
};
const isIconOnly = !children && size === 'icon';
return (
<Button
type="button"
variant={variant}
size={size}
className={cn(
isIconOnly ? 'h-8 w-8 shrink-0' : 'shrink-0',
className,
)}
onClick={handleCopy}
disabled={disabled || !text}
title={title}
aria-label={title}
>
{copied ? (
<Check className={cn('text-emerald-400', children ? 'h-3.5 w-3.5 mr-1' : 'h-3.5 w-3.5')} />
) : (
<Copy className={cn(children ? 'h-3.5 w-3.5 mr-1' : 'h-3.5 w-3.5')} />
)}
{children}
</Button>
);
}
@@ -0,0 +1,443 @@
// ApiAccessKeySection — 单条 CI/CD 访问凭证(凭证卡片布局)// Logan.Li
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { CodeSnippetBlock } from '@/components/ui/code-snippet-block';
import {
KeyRound, Plus, Settings2, RotateCw, Ban, Loader2, Rocket, Eye, EyeOff, Clock, Activity,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { userApi } from '@/api';
import { getPublicApiBase } from '@/utils/request';
import { getSessionApiAccessKey, setSessionApiAccessKey } from '@/utils/storage';
import { cn } from '@/lib/utils';
export type ApiKeyMeta = {
has_token: boolean;
key_id?: string;
prefix?: string;
access_key_mask?: string;
description?: string;
created_at?: string;
expires_at?: string;
last_used_at?: string;
status?: 'active' | 'expired';
expired?: boolean;
days_left?: number;
};
function formatDateTime(value?: string) {
if (!value) return '—';
const d = new Date(value);
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString();
}
function defaultExpiresDate(months = 12) {
const d = new Date();
d.setMonth(d.getMonth() + months);
return d.toISOString().slice(0, 10);
}
function toDateInputValue(value?: string) {
if (!value) return defaultExpiresDate();
const d = new Date(value);
return Number.isNaN(d.getTime()) ? defaultExpiresDate() : d.toISOString().slice(0, 10);
}
/** 根据创建/到期时间计算有效期进度(0-100)// Logan.Li */
function expiryProgress(createdAt?: string, expiresAt?: string) {
if (!createdAt || !expiresAt) return { pct: 0, daysLeft: null as number | null };
const start = new Date(createdAt).getTime();
const end = new Date(expiresAt).getTime();
const now = Date.now();
if (end <= start) return { pct: 0, daysLeft: 0 };
const daysLeft = Math.max(0, Math.ceil((end - now) / 86400000));
const pct = Math.min(100, Math.max(0, ((now - start) / (end - start)) * 100));
return { pct, daysLeft };
}
function shortKeyId(id?: string) {
if (!id) return '—';
if (id.length <= 10) return id;
return `${id.slice(0, 6)}…${id.slice(-4)}`;
}
export function ApiAccessKeySection() {
const { toast } = useToast();
const confirm = useConfirm();
const apiBase = getPublicApiBase();
const [keyMeta, setKeyMeta] = useState<ApiKeyMeta | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [showToken, setShowToken] = useState(false);
const [plainToken, setPlainToken] = useState('');
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'create' | 'edit'>('create');
const [formDescription, setFormDescription] = useState('');
const [formExpiresAt, setFormExpiresAt] = useState(defaultExpiresDate());
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const [revealedToken, setRevealedToken] = useState('');
const loadKey = useCallback(async () => {
setLoading(true);
try {
const cached = getSessionApiAccessKey();
if (cached) setPlainToken(cached);
const meta = await userApi.getApiAccessKey().catch(() => ({ has_token: false }));
setKeyMeta(meta);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadKey(); }, [loadKey]);
const copyableToken = plainToken || getSessionApiAccessKey();
const mask = keyMeta?.access_key_mask || (keyMeta?.prefix ? `${keyMeta.prefix}.•••••••••••••••••••••••••••••••••` : '');
const { pct: expiryPct, daysLeft: calcDaysLeft } = useMemo(
() => expiryProgress(keyMeta?.created_at, keyMeta?.expires_at),
[keyMeta?.created_at, keyMeta?.expires_at],
);
const daysLeft = keyMeta?.days_left ?? calcDaysLeft;
const isExpired = !!keyMeta?.expired || keyMeta?.status === 'expired' || (daysLeft != null && daysLeft <= 0);
const openCreate = () => {
setFormMode('create');
setFormDescription('');
setFormExpiresAt(defaultExpiresDate());
setFormOpen(true);
};
const openEdit = () => {
setFormMode('edit');
setFormDescription(keyMeta?.description || '');
setFormExpiresAt(toDateInputValue(keyMeta?.expires_at));
setFormOpen(true);
};
const handleFormSubmit = async () => {
if (formMode === 'edit') {
setActionLoading(true);
try {
const res = await userApi.updateApiAccessKey({
description: formDescription,
expires_at: formExpiresAt,
});
setKeyMeta(res);
setFormOpen(false);
toast({ title: '凭证信息已更新' });
} catch (e: any) {
toast({ title: '更新失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setActionLoading(false);
}
return;
}
const hasKey = !!keyMeta?.has_token;
const ok = await confirm({
title: hasKey ? '轮换凭证' : '签发凭证',
description: hasKey ? '旧凭证将立即作废,是否继续?' : '凭证明文仅显示一次,请妥善保存。',
confirmText: hasKey ? '轮换' : '签发',
variant: hasKey ? 'destructive' : 'default',
});
if (!ok) return;
setActionLoading(true);
try {
const res = await userApi.regenerateApiAccessKey({
description: formDescription,
expires_at: formExpiresAt,
});
setPlainToken(res.token);
setSessionApiAccessKey(res.token);
setRevealedToken(res.token);
setTokenDialogOpen(true);
setFormOpen(false);
setKeyMeta({
has_token: true,
key_id: res.key_id,
prefix: res.prefix,
access_key_mask: `${res.prefix}.•••••••••••••••••••••••••••••••••`,
description: res.description,
created_at: res.created_at,
expires_at: res.expires_at,
status: 'active',
expired: false,
days_left: Math.ceil((new Date(res.expires_at).getTime() - Date.now()) / 86400000),
});
toast({ title: '凭证已签发', description: '已保存至本浏览器,请立即复制' });
} catch (e: any) {
toast({ title: '签发失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setActionLoading(false);
}
};
const handleRevoke = async () => {
const ok = await confirm({
title: '吊销凭证',
description: '吊销后 Pipeline / curl 调用将全部失败,是否继续?',
confirmText: '吊销',
variant: 'destructive',
});
if (!ok) return;
setActionLoading(true);
try {
await userApi.revokeApiAccessKey();
setPlainToken('');
setSessionApiAccessKey('');
setKeyMeta({ has_token: false });
toast({ title: '凭证已吊销' });
} catch (e: any) {
toast({ title: '吊销失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setActionLoading(false);
}
};
const authHeader = copyableToken ? `X-AIOps-Access-Key: ${copyableToken}` : 'X-AIOps-Access-Key: <你的访问凭证>';
const sampleCluster = 'test-k8s';
const sampleNs = 'default';
const sampleDep = 'demo-jar';
const updateCurl = `curl -X POST '${apiBase}/kubernetes/${sampleCluster}/namespace/${sampleNs}/deployments/${sampleDep}/update-image' \\
-H '${authHeader}' \\
-H 'Content-Type: application/json' \\
-d '{"image":"harbor.example.com/app:v1.0","container_name":"main"}'`;
const restartCurl = `curl -X POST '${apiBase}/kubernetes/${sampleCluster}/namespace/${sampleNs}/deployments/${sampleDep}/restart' \\
-H '${authHeader}'`;
const progressColor = isExpired
? 'bg-red-500'
: daysLeft != null && daysLeft <= 30
? 'bg-amber-400'
: 'bg-[var(--dg-blue)]';
return (
<>
<Card className="dg-card border-[var(--dg-blue)]/20">
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<div>
<CardTitle className="text-base flex items-center gap-2 text-[var(--dg-text-primary)]">
<KeyRound className="h-4 w-4 text-[var(--dg-blue)]" />
CI/CD 访问凭证
<Badge className="text-[10px] bg-[var(--dg-blue)]/15 text-[var(--dg-blue)] border-[var(--dg-blue)]/30">长期有效</Badge>
</CardTitle>
<CardDescription className="text-xs mt-1">
每人唯一凭证,Deployment 发布脚本自动引用,不随登录会话过期。
</CardDescription>
</div>
{!loading && !keyMeta?.has_token && (
<Button size="sm" onClick={openCreate} disabled={actionLoading}>
<Plus className="h-4 w-4 mr-1" />签发凭证
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="flex items-center gap-2 py-8 text-sm text-[var(--dg-text-muted)] justify-center">
<Loader2 className="h-4 w-4 animate-spin" />加载中…
</div>
) : keyMeta?.has_token ? (
<div className="rounded-xl border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 overflow-hidden">
{/* 凭证主体:左侧色条 + 内容区 // Logan.Li */}
<div className="flex">
<div className={cn(
'w-1 shrink-0',
isExpired ? 'bg-red-500' : daysLeft != null && daysLeft <= 30 ? 'bg-amber-400' : 'bg-gradient-to-b from-[var(--dg-blue)] to-[var(--dg-purple)]',
)} />
<div className="flex-1 p-4 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1 min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-wider text-[var(--dg-text-muted)]">凭证字符串</p>
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md px-3 py-2 truncate block">
{showToken && copyableToken ? copyableToken : mask}
</code>
{copyableToken && (
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0 border-[var(--dg-border)]"
onClick={() => setShowToken(!showToken)}
title={showToken ? '隐藏' : '显示'}
>
{showToken ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
)}
</div>
</div>
<div className="flex flex-wrap gap-1.5 shrink-0">
<Button variant="outline" size="sm" className="h-8 text-xs border-[var(--dg-border)]" onClick={openEdit} disabled={actionLoading}>
<Settings2 className="h-3 w-3 mr-1" />调整
</Button>
<Button variant="outline" size="sm" className="h-8 text-xs border-[var(--dg-border)]" onClick={openCreate} disabled={actionLoading}>
<RotateCw className="h-3 w-3 mr-1" />轮换
</Button>
<Button variant="outline" size="sm" className="h-8 text-xs border-red-500/30 text-red-400" onClick={handleRevoke} disabled={actionLoading}>
<Ban className="h-3 w-3 mr-1" />吊销
</Button>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-3 text-xs">
<div>
<p className="text-[var(--dg-text-muted)] mb-0.5">用途说明</p>
<p className="text-[var(--dg-text-secondary)]">{keyMeta.description || '未填写'}</p>
</div>
<div>
<p className="text-[var(--dg-text-muted)] mb-0.5">凭证标识</p>
<p className="font-mono text-[var(--dg-text-secondary)]" title={keyMeta.key_id}>{shortKeyId(keyMeta.key_id)}</p>
</div>
<div>
<p className="text-[var(--dg-text-muted)] mb-0.5">签发时间</p>
<p className="text-[var(--dg-text-secondary)]">{formatDateTime(keyMeta.created_at)}</p>
</div>
</div>
{/* 有效期进度条 // Logan.Li */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-[11px]">
<span className="flex items-center gap-1 text-[var(--dg-text-muted)]">
<Clock className="h-3 w-3" />
{isExpired ? '已过期' : daysLeft != null ? `剩余 ${daysLeft} 天` : '有效期'}
</span>
<span className="text-[var(--dg-text-muted)]">
有效至 {formatDateTime(keyMeta.expires_at)}
</span>
</div>
<div className="h-1.5 rounded-full bg-[var(--dg-bg-secondary)] overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', progressColor)}
style={{ width: `${isExpired ? 100 : 100 - expiryPct}%` }}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-[var(--dg-text-muted)] pt-1 border-t border-[var(--dg-border)]/60">
<span className="inline-flex items-center gap-1">
<Activity className="h-3 w-3" />
最近调用 {formatDateTime(keyMeta.last_used_at)}
</span>
{keyMeta.prefix && (
<span>前缀 <code className="text-[var(--dg-text-secondary)]">{keyMeta.prefix}</code></span>
)}
{isExpired && (
<Badge variant="outline" className="text-[10px] border-red-500/40 text-red-400">需续期或轮换</Badge>
)}
</div>
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-dashed border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/30 px-6 py-12 text-center">
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-[var(--dg-blue)]/10 flex items-center justify-center">
<KeyRound className="h-6 w-6 text-[var(--dg-blue)]" />
</div>
<p className="text-sm text-[var(--dg-text-secondary)]">尚未签发 CI/CD 访问凭证</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1 mb-4">签发后可用于 Pipeline 触发镜像更新、滚动重启等操作</p>
<Button size="sm" onClick={openCreate} disabled={actionLoading}>
<Plus className="h-4 w-4 mr-1" />立即签发
</Button>
</div>
)}
{keyMeta?.has_token && !copyableToken && (
<p className="text-[11px] text-amber-400/90">本机未缓存凭证明文,请点击「轮换」重新签发一次;签发后将自动填入 Deployment 发布 curl。</p>
)}
<div className="rounded-lg bg-[var(--dg-bg-tertiary)]/60 p-4 space-y-3">
<p className="text-xs font-medium text-[var(--dg-text-secondary)] flex items-center gap-1.5">
<Rocket className="h-3.5 w-3.5 text-purple-400" />调用示例
</p>
<CodeSnippetBlock
label="更新镜像"
code={updateCurl}
copyLabel="curl 已复制"
copyDisabled={!copyableToken}
copyDisabledTitle="请先签发访问凭证"
/>
<CodeSnippetBlock
label="滚动重启"
code={restartCurl}
copyLabel="curl 已复制"
copyDisabled={!copyableToken}
copyDisabledTitle="请先签发访问凭证"
/>
</div>
</CardContent>
</Card>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">
{formMode === 'edit' ? '调整凭证' : keyMeta?.has_token ? '轮换凭证' : '签发凭证'}
</DialogTitle>
<DialogDescription className="text-xs text-[var(--dg-text-muted)]">
{formMode === 'edit' ? '修改用途说明与有效期限,凭证字符串不变。' : '填写用途并设定有效期,签发后请立即保存明文。'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">用途说明</Label>
<Input
value={formDescription}
onChange={e => setFormDescription(e.target.value)}
placeholder="如 Jenkins 流水线、GitLab CI"
className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">有效至</Label>
<Input
type="date"
value={formExpiresAt}
onChange={e => setFormExpiresAt(e.target.value)}
className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFormOpen(false)} className="border-[var(--dg-border)]">取消</Button>
<Button onClick={handleFormSubmit} disabled={actionLoading || !formExpiresAt}>
{actionLoading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
{formMode === 'edit' ? '保存' : keyMeta?.has_token ? '轮换' : '签发'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">请保存凭证明文</DialogTitle>
<DialogDescription className="text-xs text-[var(--dg-text-muted)]">
仅此一次机会查看完整字符串,关闭后无法再次获取。
</DialogDescription>
</DialogHeader>
<CodeSnippetBlock
label="访问凭证"
code={revealedToken}
copyLabel="凭证已复制"
className="mt-2"
/>
<DialogFooter>
<Button onClick={() => setTokenDialogOpen(false)}>我已保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,126 @@
// ProfilePage — 个人中心(基本信息 / 安全 / API 访问密钥)// Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { User, Shield, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { userApi } from '@/api';
import { useUserStore } from '@/store';
import { ApiAccessKeySection } from './ApiAccessKeySection';
export default function ProfilePage() {
const { toast } = useToast();
const storeUser = useUserStore(s => s.user);
const [profile, setProfile] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [oldPwd, setOldPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [pwdSaving, setPwdSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const p = await userApi.getProfile();
setProfile(p);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const displayName = profile?.real_name || profile?.nickname || storeUser?.nickname || storeUser?.username || '用户';
const username = profile?.username || storeUser?.username || '';
const handleChangePassword = async () => {
if (!oldPwd || newPwd.length < 6) return;
setPwdSaving(true);
try {
await userApi.changePassword({ old_password: oldPwd, new_password: newPwd });
toast({ title: '密码已修改' });
setOldPwd('');
setNewPwd('');
} catch (e: any) {
toast({ title: '修改失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setPwdSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-24 text-[var(--dg-text-muted)]">
<Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...
</div>
);
}
return (
<div className="max-w-5xl mx-auto space-y-6 pb-8">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-[var(--dg-blue)] to-[var(--dg-purple)] flex items-center justify-center text-white text-2xl font-bold shadow-lg">
{displayName.charAt(0).toUpperCase()}
</div>
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{displayName}</h1>
<p className="text-sm text-[var(--dg-text-muted)]">@{username} · {profile?.email || '—'}</p>
<div className="flex gap-2 mt-2">
{(profile?.roles || []).map((r: any) => (
<Badge key={r.id || r.name} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs">
{r.name || r}
</Badge>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2 text-[var(--dg-text-primary)]">
<User className="h-4 w-4 text-[var(--dg-blue)]" />基本信息
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">用户名</span><span className="font-mono">{username}</span></div>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">邮箱</span><span>{profile?.email || '—'}</span></div>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">手机</span><span>{profile?.phone || '—'}</span></div>
<div className="flex justify-between"><span className="text-[var(--dg-text-muted)]">账号来源</span>
<Badge variant="outline" className="text-xs">{profile?.auth_source === 'ldap' ? 'LDAP' : '本地'}</Badge>
</div>
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2 text-[var(--dg-text-primary)]">
<Shield className="h-4 w-4 text-emerald-400" />安全设置
</CardTitle>
<CardDescription className="text-xs">修改登录密码</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">当前密码</Label>
<Input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<div>
<Label className="text-xs text-[var(--dg-text-muted)]">新密码</Label>
<Input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
</div>
<Button size="sm" onClick={handleChangePassword} disabled={pwdSaving || !oldPwd || newPwd.length < 6}>
{pwdSaving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
保存密码
</Button>
</CardContent>
</Card>
</div>
<ApiAccessKeySection />
</div>
);
}