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:
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 <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 <密钥></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('-')) {
|
||||
|
||||
@@ -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 > 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user