This commit is contained in:
Logan committed 2026-07-09 14:16:56 +08:00
1 parent 0690285147
commit 5618cd2738
154 files changed
+5021 -3161

No files matched your search

+53 -1
View File
@@ -1,4 +1,4 @@
import { Suspense, lazy } from 'react';
import { Suspense, lazy, useEffect, useRef } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/hooks/useTheme';
@@ -10,6 +10,7 @@ import { PageTransition } from '@/components/layout/PageTransition';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { Toaster } from '@/components/ui/sonner';
import i18n from '@/i18n';
import { toast } from 'sonner';
// Lazy load page components for code splitting
const Dashboard = lazy(() => import('@/components/dashboard/Dashboard').then(m => ({ default: m.Dashboard })));
@@ -104,12 +105,63 @@ const queryClient = new QueryClient({
},
});
function AssetVersionGuard() {
const currentVersionRef = useRef<string>('');
const notifiedRef = useRef(false);
useEffect(() => {
const readVersion = async () => {
try {
const resp = await fetch(`/version?t=${Date.now()}`, { cache: 'no-store' });
if (!resp.ok) return;
const data = await resp.json();
const nextVersion = [data.version, data.git_commit, data.build_time].filter(Boolean).join(':');
if (!nextVersion) return;
if (!currentVersionRef.current) {
currentVersionRef.current = nextVersion;
return;
}
if (currentVersionRef.current !== nextVersion && !notifiedRef.current) {
notifiedRef.current = true;
toast.info(i18n.t('common.appUpdated'), {
description: i18n.t('common.appUpdatedDesc'),
action: {
label: i18n.t('common.reloadPage'),
onClick: () => window.location.reload(),
},
duration: Infinity,
});
}
} catch {
// 版本检查失败不影响业务页面,后续定时器会重试。 // Logan.Li
}
};
void readVersion();
const timer = window.setInterval(readVersion, 60_000);
const handleFocus = () => void readVersion();
const handleVisibility = () => {
if (document.visibilityState === 'visible') void readVersion();
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.clearInterval(timer);
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, []);
return null;
}
function App() {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<ConfirmProvider>
<Toaster position="top-right" richColors closeButton />
<AssetVersionGuard />
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
+19 -16
View File
@@ -2,44 +2,47 @@
import { http } from '@/utils/request';
import { useUserStore } from '@/store';
// 会话与消息类型(与后端 model.AgentCLISession/Message 对应) // Logan.Li
// 会话与消息类型(与后端 model.AIRun/AIRunMessage 对应) // Logan.Li
export interface AgentCliSession {
id: number;
run_id: string;
parent_run_id: string;
user_id: number;
feature: string;
mode: string; // host | kubernetes
server_id: number;
server_name: string;
server_host: string;
cluster_id: number;
cluster_name: string;
namespace: string;
pod_name: string;
container: string;
subject_type: string;
subject_id: string;
subject_name: string;
subject_scope: string;
title: string;
status: string; // running/done/aborted/error
provider: string;
model: string;
safety_mode: string;
context_pack_id: string;
prompt_tokens: number;
completion_tokens: number;
rounds: number;
total_tokens: number;
duration: number;
error?: string;
started_at: string;
finished_at?: string;
created_at: string;
updated_at: string;
}
export interface AgentCliMessage {
id: number;
session_id: number;
run_id: string;
round: number;
seq: number;
type: string; // thought/tool_call/tool_result/done/error/user
role: string;
content: string;
tool_name: string;
tool_args: string;
tool_output: string;
risk: string;
success: boolean;
duration: string;
tool_call_id: string;
span_id: string;
tokens: number;
created_at: string;
}
@@ -96,4 +99,4 @@ export function buildAgentCliWsUrl(params: AgentCliWsParams | number | string):
}
return `${base}?mode=host&id=${params.serverId}&token=${token}`;
}
}
+1 -13
View File
@@ -3,18 +3,6 @@
import { http, type ListResponse } from '@/utils/request';
export const deployApi = {
// Git 仓库认证
getRepos: () => http.get<ListResponse>('/deploy/repos'),
getRepo: (id: number | string) => http.get(`/deploy/repos/${id}`),
createRepo: ( data: Record<string, unknown>) => http.post('/deploy/repos', data),
updateRepo: (id: number | string, data: Record<string, unknown>) => http.put(`/deploy/repos/${id}`, data),
deleteRepo: (id: number | string) => http.delete(`/deploy/repos/${id}`),
// 镜像仓库认证
getRegistries: () => http.get<ListResponse>('/deploy/registries'),
getRegistry: (id: number | string) => http.get(`/deploy/registries/${id}`),
createRegistry: ( data: Record<string, unknown>) => http.post('/deploy/registries', data),
updateRegistry: (id: number | string, data: Record<string, unknown>) => http.put(`/deploy/registries/${id}`, data),
deleteRegistry: (id: number | string) => http.delete(`/deploy/registries/${id}`),
// 环境管理
getEnvironments: () => http.get<ListResponse>('/deploy/environments'),
createEnvironment: ( data: Record<string, unknown>) => http.post('/deploy/environments', data),
@@ -45,4 +33,4 @@ export const deployApi = {
uninstallTool: (id: number | string) => http.post(`/deploy/tools/${id}/uninstall`),
checkTool: (id: number | string) => http.post(`/deploy/tools/${id}/check`),
getToolVersions: (id: number | string) => http.get(`/deploy/tools/${id}/versions`),
};
};
@@ -54,17 +54,17 @@ export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] {
items.push({ id: nextId(), kind: 'thought', round: m.round, content: m.content });
break;
case 'status':
items.push({ id: nextId(), kind: 'status', round: m.round, phase: m.duration || undefined, content: m.content, toolName: m.tool_name, risk: m.risk });
items.push({ id: nextId(), kind: 'status', round: m.round, content: m.content, toolName: m.tool_name });
break;
case 'tool_call': {
let command = '';
try {
const args = JSON.parse(m.tool_args || '{}') as Record<string, unknown>;
command = String(args.command || '');
const args = JSON.parse(m.content || '{}') as Record<string, unknown>;
command = String(args.command || (args.action ? `${args.action} ${args.name || ''}` : ''));
} catch { /* 忽略解析失败 */ }
items.push({
id: nextId(), kind: 'tool', round: m.round,
toolName: m.tool_name, command, risk: m.risk, pending: true,
toolName: m.tool_name, command, pending: true,
});
break;
}
@@ -73,9 +73,8 @@ export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] {
if (items[i].kind === 'tool' && items[i].pending && items[i].toolName === m.tool_name) {
items[i] = {
...items[i],
output: m.content || m.tool_output,
success: m.success,
duration: m.duration,
output: m.content,
success: true,
pending: false,
};
break;
@@ -1,149 +0,0 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
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 { Plus, Key, Globe, Container } from 'lucide-react';
import { toast } from 'sonner';
import { credentialApi, deployApi } from '@/api';
import type { JsonData } from '@/types/common';
interface Repo {
id: number; name: string; url: string; credential_id: number | null;
}
interface Registry {
id: number; name: string; registry_url: string; namespace: string; credential_id: number | null;
}
interface Credential {
id: number; name: string; secret_type: string; summary: string;
}
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
type: 'repo' | 'registry';
editId: number | null;
repos: Repo[];
registries: Registry[];
onSave: () => void;
}
export default function CredentialFormDialog({ open, onOpenChange, type, editId, repos, registries, onSave }: Props) {
const { t } = useTranslation();
const isRepo = type === 'repo';
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const [url, setUrl] = useState('');
const [namespace, setNamespace] = useState('');
const [credentialId, setCredentialId] = useState<number | string>('');
const [credentials, setCredentials] = useState<Credential[]>([]);
const [saving, setSaving] = useState(false);
// Docker 仓库 → docker 类型;Git 仓库 → api 或 docker 类型
const filteredCredentials = credentials.filter(c =>
isRepo ? true : c.secret_type === 'docker'
);
const loadCredentials = async () => {
try {
const res: JsonData = await credentialApi.list({ page_size: 100 });
setCredentials(res?.list || []);
} catch { /* silent */ }
};
useEffect(() => {
loadCredentials();
if (editId) {
if (isRepo) {
const r = repos.find(x => x.id === editId);
if (r) { setName(r.name); setDesc(''); setUrl(r.url); setCredentialId(r.credential_id || ''); }
} else {
const r = registries.find(x => x.id === editId);
if (r) { setName(r.name); setDesc(''); setUrl(r.registry_url); setNamespace(r.namespace || ''); setCredentialId(r.credential_id || ''); }
}
} else {
setName(''); setDesc(''); setUrl(''); setNamespace(''); setCredentialId('');
}
}, [editId, open, isRepo]);
const handleSave = async () => {
if (!name.trim() || !url.trim()) { toast.error(t('deploy.form.nameAddrRequired')); return; }
setSaving(true);
try {
const cid = credentialId ? Number(credentialId) : null;
const payload = isRepo
? { name: name.trim(), description: desc.trim(), url: url.trim(), credential_id: cid }
: { name: name.trim(), description: desc.trim(), registry_url: url.trim(), namespace: namespace.trim(), credential_id: cid };
if (editId) {
if (isRepo) await deployApi.updateRepo(editId, payload);
else await deployApi.updateRegistry(editId, payload);
toast.success(t('deploy.toast.updated'));
} else {
if (isRepo) await deployApi.createRepo(payload);
else await deployApi.createRegistry(payload);
toast.success(t('deploy.toast.created'));
}
onOpenChange(false);
onSave();
} catch { toast.error(t('deploy.toast.saveFailed')); }
finally { setSaving(false); }
};
const CredIcon = isRepo ? Globe : Container;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px] bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<CredIcon className="h-5 w-5 text-[var(--dg-blue)]" />
{editId ? t('common.edit') : t('common.add')} {isRepo ? t('deploy.credForm.gitRepo') : t('deploy.credForm.imageRegistry')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1"><Label>{t('deploy.form.nameStar')}</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder={isRepo ? t('deploy.credForm.repoNamePlaceholder') : t('deploy.credForm.registryNamePlaceholder')} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
<div className="space-y-1"><Label>{isRepo ? t('deploy.form.repoUrlStar') : 'Registry URL *'}</Label>
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder={isRepo ? 'http://10.10.10.72:9000/group/repo.git' : 'http://10.10.10.72'} className="font-mono text-sm bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
{!isRepo && <div className="space-y-1"><Label>{t('deploy.form.defaultNamespace')}</Label>
<Input value={namespace} onChange={e => setNamespace(e.target.value)} placeholder={t('deploy.credForm.namespacePlaceholder')} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>}
{/* 关联凭证 */}
<div className="space-y-1">
<Label>{t('deploy.cred.linkedCred')} <span className="text-xs text-[var(--dg-text-secondary)] opacity-70">{t('deploy.credForm.credHint')}</span></Label>
<Select value={String(credentialId)} onValueChange={v => setCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={isRepo ? t('deploy.cred.selectApiCred') : t('deploy.cred.selectDockerPwd')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('deploy.cred.notLinkedCred')}</SelectItem>
{filteredCredentials.map(c => (
<SelectItem key={c.id} value={String(c.id)}>
<span className="flex items-center gap-1.5">
<Key className="h-3 w-3 opacity-60" />
{c.name} {c.summary && <span className="text-[10px] opacity-50">({c.summary})</span>}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{filteredCredentials.length === 0 && (
<p className="text-xs dg-text-yellow flex items-center gap-1 mt-1">
<Plus className="h-3 w-3" />
{t('deploy.credForm.noCredHint', { type: isRepo ? '' : t('deploy.credForm.dockerPwd') })}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? t('deploy.pipeline.saving') : t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, RefreshCw, GitBranch, Container, Globe, Settings2 } from 'lucide-react';
import { Plus, Pencil, Trash2, RefreshCw, Globe, Settings2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -12,24 +12,12 @@ import { useSearchParams } from 'react-router-dom';
import BuildNodesManager from './BuildNodesManager';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { deployApi } from '@/api';
import CredentialFormDialog from './CredentialFormDialog';
import ToolConfigTab from './ToolConfigTab';
import type { JsonData } from '@/types/common';
interface Repo {
id: number; name: string; description: string; url: string;
auth_type: string; credential_id: number | null; enabled: boolean;
created_by: string; updated_at: string;
}
interface Registry {
id: number; name: string; description: string; registry_url: string; namespace: string;
auth_type: string; credential_id: number | null; enabled: boolean;
created_by: string; updated_at: string;
}
interface Environment {
id: number; name: string; display_name: string; type: string; color: string;
sort_order: number; description: string; enabled: boolean;
@@ -74,7 +62,10 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader><DialogTitle>{editData ? t('deploy.env.editEnv') : t('deploy.env.addEnv')}</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>{editData ? t('deploy.env.editEnv') : t('deploy.env.addEnv')}</DialogTitle>
<DialogDescription>{t('deploy.label.envManage')}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label>{t('deploy.env.envLabel')}</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
<div className="space-y-1"><Label>{t('deploy.table.displayName')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={t('deploy.env.prodEnv')} /></div>
@@ -102,46 +93,42 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on
);
}
const CONFIG_TABS = ['repos', 'registries', 'environments', 'tools', 'build-nodes'] as const;
const CONFIG_TABS = ['environments', 'build-nodes', 'tools'] as const;
export default function DeployCredentials() {
const { t } = useTranslation();
const _confirm = useConfirm();
const confirm = useConfirm();
const [searchParams] = useSearchParams();
const tabFromUrl = searchParams.get('tab');
const [repos, setRepos] = useState<Repo[]>([]);
const [registries, setRegistries] = useState<Registry[]>([]);
const [environments, setEnvironments] = useState<Environment[]>([]);
const [loading, setLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editType, setEditType] = useState<'repo' | 'registry'>('repo');
const [editingId, setEditingId] = useState<number | null>(null);
const [envDialogOpen, setEnvDialogOpen] = useState(false);
const [editEnv, setEditEnv] = useState<Environment | null>(null);
const [activeTab, setActiveTab] = useState(
tabFromUrl && (CONFIG_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'repos',
tabFromUrl && (CONFIG_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'environments',
);
const fetchAll = async () => {
setLoading(true);
try {
const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]);
setRepos((rR as JsonData)?.list || []);
setRegistries((rReg as JsonData)?.list || []);
setEnvironments((rEnv as JsonData)?.list || []);
const rEnv = await deployApi.getEnvironments();
setEnvironments((rEnv as { list?: Environment[] })?.list || []);
} catch { toast.error(t('deploy.toast.getDataFailed')); }
finally { setLoading(false); }
};
useEffect(() => { fetchAll(); }, []);
const handleDelete = async (type: 'repo' | 'registry' | 'env', item: JsonData) => {
const ok = await _confirm({ title: t('common.delete'), description: t('deploy.toast.deleteConfirm', { name: item.name || item.display_name }), confirmText: t('common.delete'), variant: 'destructive' });
const handleDeleteEnv = async (item: Environment) => {
const ok = await confirm({
title: t('common.delete'),
description: t('deploy.toast.deleteConfirm', { name: item.display_name || item.name }),
confirmText: t('common.delete'),
variant: 'destructive',
});
if (!ok) return;
try {
if (type === 'repo') await deployApi.deleteRepo(item.id);
else if (type === 'registry') await deployApi.deleteRegistry(item.id);
else await deployApi.deleteEnvironment(item.id);
await deployApi.deleteEnvironment(item.id);
toast.success(t('deploy.toast.deleted'));
fetchAll();
} catch { toast.error(t('deploy.toast.deleteFailed')); }
@@ -150,8 +137,6 @@ export default function DeployCredentials() {
const envTypeLabel: Record<string, string> = { production: t('deploy.env.production'), staging: t('deploy.env.staging'), testing: t('deploy.env.testing'), development: t('deploy.env.development') };
const pageTabs = [
{ id: 'repos', label: t('deploy.credForm.gitRepo'), badge: repos.length || undefined },
{ id: 'registries', label: t('deploy.credForm.imageRegistry'), badge: registries.length || undefined },
{ id: 'environments', label: t('deploy.label.envManage'), badge: environments.length || undefined },
{ id: 'build-nodes', label: t('deploy.deployNav.buildNodes') },
{ id: 'tools', label: t('deploy.tool.title') },
@@ -168,109 +153,25 @@ export default function DeployCredentials() {
activeTab={activeTab}
onTabChange={setActiveTab}
actions={activeTab === 'build-nodes' ? undefined : (
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRepo')}
</Button>
<Button size="sm" variant="secondary" onClick={() => { setEditType('registry'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRegistry')}
</Button>
</>
)}
contentClassName="space-y-4"
>
{activeTab === 'repos' && (
repos.length === 0 ? (
<PageEmpty icon={<GitBranch className="h-10 w-10" />} title={t('deploy.credForm.noRepo')} />
) : (
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('deploy.table.repoUrl')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{repos.map(r => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[300px] truncate">{r.url}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{r.enabled ? t('common.enable') : t('common.disable')}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('repo'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('repo', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ContentPanel>
)
)}
{activeTab === 'registries' && (
registries.length === 0 ? (
<PageEmpty icon={<Container className="h-10 w-10" />} title={t('deploy.credForm.noRegistry')} />
) : (
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">Registry URL</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">{t('deploy.credForm.namespace')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{registries.map(r => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[250px] truncate">{r.registry_url}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden sm:table-cell">{r.namespace || '-'}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{r.enabled ? t('common.enable') : t('common.disable')}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('registry'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('registry', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ContentPanel>
)
)}
{activeTab === 'environments' && (
<>
<div className="flex items-center justify-end mb-3">
activeTab === 'environments' ? (
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增环境
</Button>
</div>
</>
) : (
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
</Button>
)
)}
contentClassName="space-y-4"
>
{activeTab === 'environments' && (
<>
{environments.length === 0 ? (
<PageEmpty
icon={<Globe className="h-10 w-10" />}
@@ -316,7 +217,7 @@ export default function DeployCredentials() {
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditEnv(env); setEnvDialogOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('env', env)}><Trash2 className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDeleteEnv(env)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
@@ -333,11 +234,6 @@ export default function DeployCredentials() {
{activeTab === 'tools' && <ToolConfigTab />}
<CredentialFormDialog
open={editorOpen} onOpenChange={setEditorOpen} type={editType} editId={editingId}
repos={repos} registries={registries} onSave={fetchAll}
/>
<EnvFormDialog open={envDialogOpen} onClose={() => setEnvDialogOpen(false)} onSaved={fetchAll} editData={editEnv} />
</PageShell>
);
@@ -14,8 +14,6 @@ import {
Trash2,
ChevronDown,
ChevronRight,
Container,
Globe,
History,
Server,
Network,
@@ -54,8 +52,6 @@ export function PipelineCard({
const navigate = useNavigate();
const { t } = useTranslation();
const stages = parseStages(pipeline.stages);
const repo = pipeline.repo as Record<string, unknown> | undefined;
const registry = pipeline.registry as Record<string, unknown> | undefined;
const environment = pipeline.environment as Record<string, unknown> | undefined;
const code = (pipeline.code || pipeline.id) as string;
const deployTarget = String(pipeline.deploy_target || 'ssh');
@@ -151,8 +147,6 @@ export function PipelineCard({
</>
)}
</span>
{repo && <span className="flex items-center gap-1"><Globe className="h-3 w-3" />{String(repo.name)}</span>}
{registry && <span className="flex items-center gap-1"><Container className="h-3 w-3" />{String(registry.name)}</span>}
{pipeline.last_run_at && (
<span><Clock className="h-3 w-3 inline mr-0.5" />{new Date(pipeline.last_run_at as string).toLocaleString('zh-CN')}</span>
)}
@@ -260,4 +254,4 @@ export function PipelineCard({
</CardContent>
</Card>
);
}
}
@@ -15,6 +15,15 @@ interface State {
errorInfo: ErrorInfo | null;
}
function isChunkLoadError(error: Error | null) {
// 发版后旧入口引用已删除 chunk 时,浏览器会抛动态 import 加载失败。 // Logan.Li
const message = error?.message || '';
return message.includes('Failed to fetch dynamically imported module') ||
message.includes('Importing a module script failed') ||
message.includes('ChunkLoadError') ||
message.includes('Loading chunk');
}
/**
* ErrorBoundary - 全局错误边界组件
*
@@ -38,6 +47,10 @@ export class ErrorBoundary extends Component<Props, State> {
}
handleRetry = () => {
if (isChunkLoadError(this.state.error)) {
window.location.reload();
return;
}
this.setState({ hasError: false, error: null, errorInfo: null });
};
@@ -60,6 +73,7 @@ export class ErrorBoundary extends Component<Props, State> {
if (this.props.fallback) {
return this.props.fallback;
}
const chunkLoadError = isChunkLoadError(this.state.error);
return (
<div className="min-h-[60vh] flex items-center justify-center p-6">
@@ -75,7 +89,7 @@ export class ErrorBoundary extends Component<Props, State> {
{i18n.t('common.ebPageError')}
</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-2">
{i18n.t('common.ebDescription')}
{chunkLoadError ? i18n.t('common.ebChunkDescription') : i18n.t('common.ebDescription')}
</p>
</div>
@@ -116,7 +130,7 @@ export class ErrorBoundary extends Component<Props, State> {
onClick={this.handleRetry}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{i18n.t('common.reload')}
{chunkLoadError ? i18n.t('common.reloadPage') : i18n.t('common.reload')}
</Button>
<Button
variant="outline"
@@ -283,6 +283,13 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
r.code?.toLowerCase().includes(regionSearch.toLowerCase())
);
const getImportHostname = (server: any) => {
// 阿里云 HostName 常是 iZ 开头的系统名,导入预览优先展示控制台实例名称。 // Logan.Li
if (server.instance_name && server.instance_name !== server.instance_id) return server.instance_name;
if (server.hostname && server.hostname !== server.instance_id) return server.hostname;
return server.private_ip || server.public_ip || server.instance_name || server.instance_id || '-';
};
const steps = [
{ id: 1, title: t('server.cloudProvider'), icon: Cloud },
{ id: 2, title: t('server.cloudAccount'), icon: Key },
@@ -620,7 +627,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
<td className="px-3 py-2">
<Checkbox checked={selectedServers.includes(server.instance_id)} />
</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{server.instance_name || server.hostname || '-'}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{getImportHostname(server)}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">
{server.private_ip || server.public_ip || '-'}
</td>
@@ -33,6 +33,7 @@ import type { JsonData } from '@/types/common';
// 表单数据结构
export interface ServerFormData {
hostname: string;
alias: string;
ip: string;
public_ip: string;
port: string;
@@ -55,6 +56,7 @@ export interface ServerFormData {
export const EMPTY_FORM: ServerFormData = {
hostname: '',
alias: '',
ip: '',
public_ip: '',
port: '22',
@@ -183,6 +185,17 @@ export function ServerForm({
<div className="space-y-4 py-2">
{/* 基础信息 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
{t('server.formAlias')}
</Label>
<Input
placeholder={t('server.aliasPlaceholder')}
value={formData.alias}
onChange={(e) => setFormData({ ...formData, alias: 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)]">
{t('server.formHostname')} <span className="dg-text-red">*</span>
@@ -122,10 +122,13 @@ export function ServerManager() {
const servers = data?.list ?? [];
const total = data?.total ?? 0;
const getServerDisplayName = (server: JsonData) =>
server.alias || server.cloud_instance_name || server.hostname || server.ip || "";
const openTerminal = (server: JsonData) => {
setTerminalServer({
id: server.id,
name: server.hostname,
name: getServerDisplayName(server),
ip: server.ip,
hasAuth: server.has_auth ?? true,
});
@@ -133,12 +136,12 @@ export function ServerManager() {
};
const openRdp = (server: JsonData) => {
setRdpServer({ id: server.id, name: server.hostname, ip: server.ip });
setRdpServer({ id: server.id, name: getServerDisplayName(server), ip: server.ip });
setRdpOpen(true);
};
const openFileBrowser = (server: JsonData) => {
setFileBrowserServer({ id: server.id, name: server.hostname });
setFileBrowserServer({ id: server.id, name: getServerDisplayName(server) });
setFileBrowserOpen(true);
};
@@ -171,9 +174,13 @@ export function ServerManager() {
const handleDelete = async (id: string) => {
const server = servers.find((s) => String(s.id) === String(id));
const name = server ? getServerDisplayName(server) : id;
const ok = await confirm({
title: t("server.deleteTitle"),
description: `${server?.hostname || id}`,
description: t("server.deleteConfirm", {
name,
ip: server?.ip || "-",
}),
confirmText: t("common.delete"),
variant: "destructive",
});
@@ -192,7 +199,9 @@ export function ServerManager() {
if (selectedServers.length === 0) return;
const ok = await confirm({
title: t("server.batchDeleteTitle"),
description: `${selectedServers.length}`,
description: t("server.batchDeleteConfirm", {
count: selectedServers.length,
}),
confirmText: t("server.batchDelete"),
variant: "destructive",
});
@@ -224,6 +233,7 @@ export function ServerManager() {
setEditingId(server.id);
setFormData({
hostname: server.hostname || "",
alias: server.alias || "",
ip: server.ip || "",
public_ip: server.public_ip || "",
port: String(server.port || 22),
@@ -256,6 +266,7 @@ export function ServerManager() {
setFormSaving(true);
const payload: Record<string, unknown> = {
hostname: formData.hostname,
alias: formData.alias,
ip: formData.ip,
public_ip: formData.public_ip || undefined,
port: Number(formData.port) || 22,
@@ -306,6 +317,7 @@ export function ServerManager() {
return;
}
const headers = [
t("server.csv.name"),
t("server.csv.hostname"),
t("server.csv.ip"),
t("server.csv.publicIp"),
@@ -321,6 +333,7 @@ export function ServerManager() {
const memGB = s.memory ? Math.round(Number(s.memory) / 1024) : 0;
const tags = getTags(s).join(";");
return [
getServerDisplayName(s),
s.hostname || "",
s.ip || "",
s.public_ip || "",
@@ -482,7 +495,7 @@ export function ServerManager() {
/>
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("server.hostname")}
{t("server.assetName")}
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("server.ipAddress")}
@@ -52,6 +52,8 @@ export function ServerTableRow({
const tags = getTags(server);
const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
const isRdp = isRdpServer(server);
const displayName = server.alias || server.cloud_instance_name || server.hostname || server.ip;
const secondaryName = server.hostname && server.hostname !== displayName ? server.hostname : server.os;
return (
<TableRow className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
@@ -66,9 +68,12 @@ export function ServerTableRow({
<ServerIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
<div>
<p className="font-medium text-[var(--dg-text-primary)]">
{server.hostname}
{displayName}
</p>
<p className="text-xs text-[var(--dg-text-muted)]">
{secondaryName}
{server.cloud_instance_id ? ` · ${server.cloud_instance_id}` : ""}
</p>
<p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
</div>
</div>
</TableCell>
+10 -1
View File
@@ -16,6 +16,8 @@
"analyzing": "Analyzing",
"annotation": "Annotation",
"appName": "AIOps Platform",
"appUpdated": "System updated",
"appUpdatedDesc": "A new frontend version is available. Refresh the page before continuing.",
"apply": "Apply",
"applyFilter": "Apply Filter",
"approve": "Approve",
@@ -144,6 +146,7 @@
},
"ebCaughtError": "[ErrorBoundary] Caught error:",
"ebCopyError": "Copy Error",
"ebChunkDescription": "Page assets may have been updated or temporarily unavailable. Please refresh to load the latest version.",
"ebDescription": "Sorry, this page encountered an unexpected error. You can try reloading or returning to home.",
"ebErrorInfo": "[ErrorBoundary] Error info:",
"ebGoHome": "Back to Home",
@@ -759,8 +762,9 @@
"csvImport": "CSV Import",
"csvImportHint": "hostname + IP only",
"addServer": "Add Server",
"searchPlaceholder": "Search hostname or IP...",
"searchPlaceholder": "Search alias, cloud name, hostname or IP...",
"allStatus": "All Status",
"assetName": "Asset Name",
"hostname": "Hostname",
"ipAddress": "IP Address",
"publicIp": "Public IP",
@@ -774,8 +778,10 @@
"items": "items",
"totalItems": "{{count}} total",
"deleteTitle": "Delete Server",
"deleteConfirm": "Delete server \"{{name}}\" ({{ip}})? This action cannot be undone.",
"deleted": "Server deleted",
"batchDeleteTitle": "Batch Delete Servers",
"batchDeleteConfirm": "Delete the selected {{count}} servers? This action cannot be undone.",
"updated": "Server updated",
"created": "Server created",
"saveFailed": "Save failed, please retry",
@@ -785,6 +791,7 @@
"deleteFailed": "Delete failed",
"batchDeleteFailed": "Batch delete failed",
"csv": {
"name": "Asset Name",
"hostname": "Hostname",
"ip": "IP Address",
"publicIp": "Public IP",
@@ -1076,6 +1083,8 @@
"editServer": "Edit Server",
"editServerDesc": "Modify server asset information",
"addServerDesc": "Add a new server asset to CMDB",
"formAlias": "Alias",
"aliasPlaceholder": "e.g. prod-gateway, order-svc-01",
"formHostname": "Hostname",
"formIpAddress": "IP Address",
"osType": "OS Type",
+10 -1
View File
@@ -16,6 +16,8 @@
"analyzing": "分析中",
"annotation": "注解",
"appName": "AIOps平台",
"appUpdated": "系统已更新",
"appUpdatedDesc": "检测到前端资源已有新版本,建议刷新页面后继续操作。",
"apply": "应用",
"applyFilter": "应用筛选",
"approve": "通过",
@@ -144,6 +146,7 @@
},
"ebCaughtError": "[ErrorBoundary] 捕获到错误:",
"ebCopyError": "复制错误",
"ebChunkDescription": "页面资源可能已更新或短暂不可用,请刷新页面重新加载最新资源。",
"ebDescription": "抱歉,该页面遇到了意外错误。您可以尝试重新加载或返回首页。",
"ebErrorInfo": "[ErrorBoundary] 错误信息:",
"ebGoHome": "返回首页",
@@ -759,8 +762,9 @@
"csvImport": "CSV导入",
"csvImportHint": "hostname + IP 即可",
"addServer": "添加服务器",
"searchPlaceholder": "搜索主机名或IP...",
"searchPlaceholder": "搜索别名、云实例名、主机名或IP...",
"allStatus": "全部状态",
"assetName": "资产名称",
"hostname": "主机名",
"ipAddress": "IP地址",
"publicIp": "公网IP",
@@ -774,8 +778,10 @@
"items": "条",
"totalItems": "共 {{count}} 条",
"deleteTitle": "删除服务器",
"deleteConfirm": "确认删除服务器「{{name}}」({{ip}})?此操作不可撤销。",
"deleted": "服务器已删除",
"batchDeleteTitle": "批量删除服务器",
"batchDeleteConfirm": "确认删除选中的 {{count}} 台服务器?此操作不可撤销。",
"updated": "服务器已更新",
"created": "服务器已创建",
"saveFailed": "保存失败,请重试",
@@ -785,6 +791,7 @@
"deleteFailed": "删除失败",
"batchDeleteFailed": "批量删除失败",
"csv": {
"name": "资产名称",
"hostname": "主机名",
"ip": "IP地址",
"publicIp": "公网IP",
@@ -1076,6 +1083,8 @@
"editServer": "编辑服务器",
"editServerDesc": "修改服务器资产信息",
"addServerDesc": "录入新的服务器资产到 CMDB",
"formAlias": "别名",
"aliasPlaceholder": "如:生产网关、订单服务-01",
"formHostname": "主机名",
"formIpAddress": "IP地址",
"osType": "系统类型",
+5
View File
@@ -9,6 +9,11 @@ import App from './App.tsx'
// 首屏前应用主题,避免闪烁 // Logan.Li
applyThemeToDocument(getInitialTheme())
window.addEventListener('vite:preloadError', () => {
// 任一左侧子菜单懒加载资源在发版后失效时,统一整页刷新获取最新 index/chunk。 // Logan.Li
window.location.reload()
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+1 -1
View File
@@ -31,7 +31,7 @@ export interface PipelineStage {
command: string;
artifact_path: string;
image: string;
registry_id: number;
registry_credential_id: number;
dockerfile: string;
env_vars: Record<string, string>;
timeout: number;
+6
View File
@@ -5,6 +5,9 @@
export interface AssetServer {
id: number;
hostname: string;
alias: string;
cloud_instance_name: string;
cloud_instance_id: string;
ip: string;
port: number; // SSH 端口,默认 22
os: string;
@@ -36,6 +39,9 @@ export interface AssetServer {
export interface AssetServerListItem {
id: number;
hostname: string;
alias: string;
cloud_instance_name: string;
cloud_instance_id: string;
ip: string;
os: string;
status: string;