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

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

No files matched your search

@@ -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>
);
}