feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物
主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
777d9e4e71
commit
76ec9387f0
202 files changed
+16555
-2543
No files matched your search
@@ -38,6 +38,7 @@ import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function AppReleaseManager() {
|
||||
const { t } = useTranslation();
|
||||
@@ -45,9 +46,9 @@ export function AppReleaseManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [releases, setReleases] = useState<any[]>([]);
|
||||
const [releases, setReleases] = useState<JsonData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
|
||||
const [selectedRelease, setSelectedRelease] = useState<JsonData | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -76,7 +77,7 @@ export function AppReleaseManager() {
|
||||
});
|
||||
|
||||
// 回滚发布 // Logan.Li
|
||||
const handleRollback = async ( id: any) => {
|
||||
const handleRollback = async ( id: string | number) => {
|
||||
try {
|
||||
await releaseApi.rollbackRelease(id);
|
||||
loadData();
|
||||
|
||||
@@ -41,6 +41,7 @@ import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function ReleaseManager() {
|
||||
const { t } = useTranslation();
|
||||
@@ -48,9 +49,9 @@ export function ReleaseManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [releases, setReleases] = useState<any[]>([]);
|
||||
const [releases, setReleases] = useState<JsonData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
|
||||
const [selectedRelease, setSelectedRelease] = useState<JsonData | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
|
||||
const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false);
|
||||
@@ -80,7 +81,7 @@ export function ReleaseManager() {
|
||||
return matchesSearch && matchesEnv && matchesStatus;
|
||||
});
|
||||
|
||||
const handleDeploy = async ( id: any, appName: string, version: string) => {
|
||||
const handleDeploy = async ( id: string | number, appName: string, version: string) => {
|
||||
const ok = await _confirm({
|
||||
title: '确认部署',
|
||||
description: `确定部署 ${appName} ${version} 到目标环境?`,
|
||||
@@ -95,7 +96,7 @@ export function ReleaseManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRollback = async ( id: any, appName: string, version: string) => {
|
||||
const handleRollback = async ( id: string | number, appName: string, version: string) => {
|
||||
const ok = await _confirm({
|
||||
title: '确认回滚',
|
||||
description: `确定回滚 ${appName} ${version}?此操作将恢复到上一版本。`,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,8 +10,10 @@ import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { Maximize2, Minimize2, X, FolderOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils';
|
||||
import { useUserStore } from '@/store';
|
||||
import { pipelineApi } from '@/api';
|
||||
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
interface WorkspaceTermDialogProps {
|
||||
@@ -31,7 +32,6 @@ export function WorkspaceTermDialog({
|
||||
runId,
|
||||
buildNumber,
|
||||
}: WorkspaceTermDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -39,10 +39,12 @@ export function WorkspaceTermDialog({
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [workspace, setWorkspace] = useState('');
|
||||
const [hasOutput, setHasOutput] = useState(false);
|
||||
|
||||
// 获取 workspace 路径
|
||||
useEffect(() => {
|
||||
if (!open || !runId) return;
|
||||
setHasOutput(false);
|
||||
pipelineApi.getWorkspace(runId)
|
||||
.then((data: JsonData) => setWorkspace(data?.workspace_path || ''))
|
||||
.catch(() => {});
|
||||
@@ -85,30 +87,29 @@ export function WorkspaceTermDialog({
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.status === 0 && typeof msg.data === 'string') {
|
||||
setHasOutput(true);
|
||||
term.write(msg.data);
|
||||
}
|
||||
} catch {
|
||||
setHasOutput(true);
|
||||
term.write(typeof event.data === 'string' ? event.data : '');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
if (containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
requestAnimationFrame(() => {
|
||||
try { fit.fit(); term.focus(); } catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
mountXterm(term, fit, () => containerRef.current, ws);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
term.write(`\r\n\x1b[31m${t('deploy.runDetail.connectionClosed')}\x1b[0m\r\n`);
|
||||
setHasOutput(true);
|
||||
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write(`\r\n\x1b[31m${t('deploy.runDetail.connectionError')}\x1b[0m\r\n`);
|
||||
setHasOutput(true);
|
||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
return () => {
|
||||
@@ -119,31 +120,24 @@ export function WorkspaceTermDialog({
|
||||
};
|
||||
}, [open, runId]);
|
||||
|
||||
// ResizeObserver for fit
|
||||
// 容器尺寸变化时 fit 并同步远端 PTY,避免缩放后黑边或铺不满。 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const ro = new ResizeObserver(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (fitRef.current && termRef.current) {
|
||||
try {
|
||||
fitRef.current.fit();
|
||||
termRef.current.focus();
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const { cols, rows } = termRef.current;
|
||||
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => { clearTimeout(timer); ro.disconnect(); };
|
||||
}, [open]);
|
||||
let cleanup: (() => void) | undefined;
|
||||
let retry = 0;
|
||||
const attach = () => {
|
||||
const container = containerRef.current;
|
||||
const fit = fitRef.current;
|
||||
const term = termRef.current;
|
||||
if (!container || !fit || !term) {
|
||||
if (retry++ < 30) requestAnimationFrame(attach);
|
||||
return;
|
||||
}
|
||||
cleanup = attachTerminalResize(container, fit, term, wsRef.current);
|
||||
};
|
||||
attach();
|
||||
return () => cleanup?.();
|
||||
}, [open, fullscreen]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -160,14 +154,14 @@ export function WorkspaceTermDialog({
|
||||
)}
|
||||
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">{t('deploy.pipeline.workspaceTerminal', { buildNumber })}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{t('deploy.pipeline.buildDir')}</DialogDescription>
|
||||
<DialogTitle className="sr-only">Workspace 终端 #{buildNumber}</DialogTitle>
|
||||
<DialogDescription className="sr-only">流水线构建目录终端</DialogDescription>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium truncate">
|
||||
#{buildNumber} {t('deploy.runDetail.buildDir')}
|
||||
#{buildNumber} 构建目录
|
||||
</span>
|
||||
{workspace && (
|
||||
<span className="text-[var(--dg-text-muted)] text-xs truncate hidden sm:inline">
|
||||
@@ -190,11 +184,10 @@ export function WorkspaceTermDialog({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-[#0a0e14]"
|
||||
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
||||
/>
|
||||
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}>
|
||||
<div ref={containerRef} className="xterm-terminal-host h-full" />
|
||||
<TerminalLoadingOverlay show={!hasOutput} label="正在进入工作区..." />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,11 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
const [sshPassword, setSshPassword] = useState('');
|
||||
const [sshPrivateKey, setSshPrivateKey] = useState('');
|
||||
const [sshPublicKey, setSshPublicKey] = useState('');
|
||||
// RDP 字段 // Logan.Li
|
||||
const [rdpUsername, setRdpUsername] = useState('Administrator');
|
||||
const [rdpPassword, setRdpPassword] = useState('');
|
||||
const [rdpDomain, setRdpDomain] = useState('');
|
||||
const [rdpSecurity, setRdpSecurity] = useState('nla');
|
||||
// Docker 字段
|
||||
const [dockerServer, setDockerServer] = useState('');
|
||||
const [dockerUsername, setDockerUsername] = useState('');
|
||||
@@ -75,6 +80,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
setIsActive(editData.is_active);
|
||||
// 编辑时不预填类型字段,仅修改用户填写的项 // Logan.Li
|
||||
setSshUser(''); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
|
||||
setRdpUsername(''); setRdpPassword(''); setRdpDomain(''); setRdpSecurity('nla');
|
||||
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
|
||||
setTlsCert(''); setTlsKey(''); setTlsCa('');
|
||||
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
|
||||
@@ -84,6 +90,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
} else {
|
||||
setName(''); setVarName(''); setSecretType('ssh'); setDescription(''); setTags(''); setIsActive(true);
|
||||
setSshUser('root'); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
|
||||
setRdpUsername('Administrator'); setRdpPassword(''); setRdpDomain(''); setRdpSecurity('nla');
|
||||
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
|
||||
setTlsCert(''); setTlsKey(''); setTlsCa('');
|
||||
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
|
||||
@@ -115,6 +122,13 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
if (sshPassword.trim()) payload.ssh_password = sshPassword.trim();
|
||||
if (sshPrivateKey.trim()) payload.ssh_private_key = sshPrivateKey.trim();
|
||||
if (sshPublicKey.trim()) payload.ssh_public_key = sshPublicKey.trim();
|
||||
} else if (secretType === 'rdp') {
|
||||
if (!rdpUsername.trim()) { toast.error('请填写 RDP 用户'); return; }
|
||||
if (!isEdit && !rdpPassword.trim()) { toast.error('请填写 RDP 密码'); return; }
|
||||
if (rdpUsername.trim()) payload.rdp_username = rdpUsername.trim();
|
||||
if (rdpPassword.trim()) payload.rdp_password = rdpPassword.trim();
|
||||
if (rdpDomain.trim()) payload.rdp_domain = rdpDomain.trim();
|
||||
payload.rdp_security = rdpSecurity.trim() || 'nla';
|
||||
} else if (secretType === 'docker') {
|
||||
if (dockerServer.trim()) payload.docker_server = dockerServer.trim();
|
||||
if (dockerUsername.trim()) payload.docker_username = dockerUsername.trim();
|
||||
@@ -275,6 +289,36 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
</div>
|
||||
)}
|
||||
|
||||
{secretType === 'rdp' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">Windows 用户 <span className="dg-text-red">*</span></Label>
|
||||
<Input value={rdpUsername} onChange={e => setRdpUsername(e.target.value)} placeholder="Administrator"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">安全模式</Label>
|
||||
<Select value={rdpSecurity} onValueChange={setRdpSecurity}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nla">NLA</SelectItem>
|
||||
<SelectItem value="tls">TLS</SelectItem>
|
||||
<SelectItem value="rdp">标准 RDP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">域</Label>
|
||||
<Input value={rdpDomain} onChange={e => setRdpDomain(e.target.value)} placeholder="可选"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<div><FieldWithToggle label="Windows 密码" value={rdpPassword} onChange={setRdpPassword}
|
||||
placeholder="Windows 登录密码" fieldKey="rdp_pwd" required /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{secretType === 'docker' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
@@ -394,4 +438,4 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,28 @@
|
||||
// 凭据类型配置常量 // Logan.Li
|
||||
|
||||
import {
|
||||
Key, Server, Container, Shield, Cloud, Database, FileText, Globe,
|
||||
Key, Server, Container, Shield, Cloud, Database, FileText, Globe, Monitor,
|
||||
} from 'lucide-react';
|
||||
import type { SecretType } from './types';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export const TYPE_CONFIG: Record<SecretType, { label: string; icon: typeof Key; color: string; desc: string }> = {
|
||||
ssh: { label: i18n.t('system.sshKeyLabel'), icon: Server, color: 'dg-text-yellow', desc: i18n.t('pipeline.constantsts.sedc7f9e9') },
|
||||
docker: { label: i18n.t('system.dockerCredential'), icon: Container, color: 'dg-text-cyan', desc: i18n.t('pipeline.constantsts.sbd6cf171') },
|
||||
tls: { label: i18n.t('pipeline.constantsts.sfa5a64de'), icon: Shield, color: 'dg-text-green', desc: i18n.t('pipeline.constantsts.s43f00c61') },
|
||||
aksk: { label: i18n.t('pipeline.constantsts.sb82698a1'), icon: Cloud, color: 'dg-text-purple', desc: i18n.t('pipeline.constantsts.s564e98a9') },
|
||||
database: { label: i18n.t('system.dbCredential'), icon: Database, color: 'dg-text-blue', desc: i18n.t('pipeline.constantsts.se47498d3') },
|
||||
api: { label: i18n.t('system.apiKeys'), icon: Globe, color: 'dg-text-orange', desc: i18n.t('pipeline.constantsts.sb3edf0a4') },
|
||||
generic: { label: i18n.t('pipeline.constantsts.s1d319bb7'), icon: FileText, color: 'dg-text-secondary', desc: i18n.t('pipeline.constantsts.s51b5b280') },
|
||||
ssh: { label: 'SSH 密钥', icon: Server, color: 'dg-text-yellow', desc: 'SSH 私钥/公钥/密码' },
|
||||
rdp: { label: 'Windows RDP 密码', icon: Monitor, color: 'dg-text-blue', desc: 'Windows 远程桌面用户名/密码' },
|
||||
docker: { label: 'Docker 凭证', icon: Container, color: 'dg-text-cyan', desc: 'Docker Registry 登录凭据' },
|
||||
tls: { label: 'TLS 证书', icon: Shield, color: 'dg-text-green', desc: 'TLS/SSL 证书与私钥' },
|
||||
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'dg-text-purple', desc: '云服务 AccessKey/SecretKey' },
|
||||
database: { label: '数据库凭证', icon: Database, color: 'dg-text-blue', desc: '数据库连接信息' },
|
||||
api: { label: 'API 密钥', icon: Globe, color: 'dg-text-orange', desc: 'API Token/Key + 端点' },
|
||||
generic: { label: '通用凭证', icon: FileText, color: 'dg-text-secondary', desc: '自定义 Key/Value 键值对' },
|
||||
};
|
||||
|
||||
export const TYPE_BADGE_CLASS: Record<SecretType, string> = {
|
||||
ssh: 'bg-amber-500/20 dg-text-yellow border-amber-500/30',
|
||||
rdp: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
|
||||
docker: 'bg-cyan-500/20 dg-text-cyan border-cyan-500/30',
|
||||
tls: 'bg-green-500/20 dg-text-green border-green-500/30',
|
||||
aksk: 'bg-purple-500/20 dg-text-purple border-purple-500/30',
|
||||
database: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
|
||||
api: 'bg-orange-500/20 dg-text-orange border-orange-500/30',
|
||||
generic: 'dg-badge-muted',
|
||||
};
|
||||
};
|
||||
@@ -15,4 +15,4 @@ export interface Credential {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type SecretType = 'ssh' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic';
|
||||
export type SecretType = 'ssh' | 'rdp' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic';
|
||||
@@ -56,7 +56,7 @@ export function JobRoleCreateDialog({
|
||||
const patternHint = useMemo(() => {
|
||||
if (!pattern.trim()) return t('rbac.jobRole.patternHint.empty');
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
|
||||
new RegExp(pattern);
|
||||
return preview.length > 0 ? t('rbac.jobRole.patternHint.matched', { count: preview.length }) : t('rbac.jobRole.patternHint.noMatch');
|
||||
} catch {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
type LucideIcon,
|
||||
Clock,
|
||||
Check,
|
||||
XCircle,
|
||||
@@ -28,7 +29,7 @@ const ENV_LABEL_KEY: Record<string, string> = {
|
||||
|
||||
// 发布状态徽章(审批流:待审批/已审批/已驳回/发布中/发布成功/发布失败/已回滚) // Logan.Li
|
||||
export function getStatusBadge(status: string, t: TFunction) {
|
||||
const map: Record<string, { icon: any; labelKey: string; cls: string }> = {
|
||||
const map: Record<string, { icon: LucideIcon; labelKey: string; cls: string }> = {
|
||||
pending: { icon: Clock, labelKey: 'deploy.status.pendingApproval', cls: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' },
|
||||
approved: { icon: Check, labelKey: 'deploy.status.approved', cls: 'border-purple-500/30 dg-text-purple bg-purple-500/10' },
|
||||
rejected: { icon: XCircle, labelKey: 'deploy.status.rejected', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' },
|
||||
|
||||
Reference in new issue
Block a user