mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 14:14:44 +08:00
@@ -1,19 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useDialog } from './Dialog'
|
||||
|
||||
export default function BrowserDialogTranslator() {
|
||||
const { t } = useLanguage()
|
||||
const { alert: showAlert } = useDialog()
|
||||
|
||||
useEffect(() => {
|
||||
const originalAlert = window.alert
|
||||
const originalConfirm = window.confirm
|
||||
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
|
||||
window.alert = (message?: unknown) => { void showAlert('提示', String(message ?? '')) }
|
||||
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
|
||||
return () => {
|
||||
window.alert = originalAlert
|
||||
window.confirm = originalConfirm
|
||||
}
|
||||
}, [t])
|
||||
}, [showAlert, t])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { CalendarClock, RefreshCw, X } from 'lucide-react'
|
||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, Template } from '../services/api'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, StorageInfo, Template } from '../services/api'
|
||||
import { useDialog } from './Dialog'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
||||
@@ -16,6 +17,7 @@ const defaultForm: CreateContainerRequest = {
|
||||
name: '',
|
||||
virtualization: 'lxc',
|
||||
template_id: '',
|
||||
storage_pool_id: '',
|
||||
vcpu: 1,
|
||||
cpu_percent: 100,
|
||||
ram_mb: 512,
|
||||
@@ -54,6 +56,7 @@ const defaultForm: CreateContainerRequest = {
|
||||
}
|
||||
|
||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
||||
const navigate = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const { language } = useLanguage()
|
||||
const networkText = createNetworkText[language]
|
||||
@@ -63,6 +66,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
|
||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||
const [hostReport, setHostReport] = useState<HostProbeReport | null>(null)
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||
const [storageLoading, setStorageLoading] = useState(true)
|
||||
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
|
||||
const [nameError, setNameError] = useState('')
|
||||
|
||||
@@ -107,8 +112,26 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
getHostReport()
|
||||
.then((res) => setHostReport(res.data.data || null))
|
||||
.catch(() => setHostReport(null))
|
||||
|
||||
}, [isOpen, form.virtualization])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
let active = true
|
||||
setStorageLoading(true)
|
||||
getStorageInfo()
|
||||
.then((res) => {
|
||||
if (active) setStorageInfo(res.data.data || null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setStorageInfo(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setStorageLoading(false)
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [isOpen])
|
||||
|
||||
const ipv6Available = !!ipv6Status?.available
|
||||
const ipv6Prefixes = ipv6Status?.prefixes || []
|
||||
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
|
||||
@@ -118,6 +141,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
|
||||
const storagePools = useMemo(() => {
|
||||
const content = form.virtualization === 'kvm' ? 'kvm' : 'lxc'
|
||||
return (storageInfo?.pools || []).filter((pool) => pool.enabled && pool.available !== false && (pool.content_types || []).includes(content))
|
||||
}, [storageInfo, form.virtualization])
|
||||
const storageReady = storagePools.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
|
||||
@@ -197,6 +225,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
}
|
||||
}
|
||||
|
||||
if (!storageReady) {
|
||||
dialog.alert('未配置存储', `请先在存储管理中为 ${form.virtualization === 'kvm' ? 'KVM 磁盘' : 'LXC 容器'}开启至少一块存储磁盘`)
|
||||
return
|
||||
}
|
||||
|
||||
const authError = validateSSHAuthInputs(form)
|
||||
if (authError) {
|
||||
dialog.alert('登录方式有误', authError)
|
||||
@@ -273,7 +306,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', allowed_image_ids: [], image_limit_configured: false }))}
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
LXC 容器
|
||||
@@ -284,7 +317,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
||||
onClick={() => {
|
||||
if (kvmAvailable) {
|
||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', allowed_image_ids: [], image_limit_configured: false }))
|
||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))
|
||||
}
|
||||
}}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
@@ -320,6 +353,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
|
||||
</Field>
|
||||
|
||||
<Field label="存储磁盘">
|
||||
{storageLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
正在检查存储配置...
|
||||
</div>
|
||||
) : storagePools.length > 0 ? (
|
||||
<select
|
||||
value={form.storage_pool_id || ''}
|
||||
onChange={(event) => setForm({ ...form, storage_pool_id: event.target.value })}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">自动选择(默认盘优先,空间不足自动切换)</option>
|
||||
{storagePools.map((pool) => (
|
||||
<option key={pool.id} value={pool.id}>
|
||||
{pool.name} · {pool.mount_point || pool.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
<span>尚未开启{form.virtualization === 'kvm' ? ' KVM 磁盘' : ' LXC 容器'}存储,当前无法创建。</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onClose(); navigate('/storage') }}
|
||||
className="shrink-0 rounded-md border border-amber-300 bg-white px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100"
|
||||
>
|
||||
去开启
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{templates.length > 0 && (
|
||||
<Field label="子用户可用镜像">
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||
@@ -821,7 +887,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
disabled={loading || storageLoading || !storageReady}
|
||||
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '创建中...' : '创建容器'}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
|
||||
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
|
||||
import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'
|
||||
import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
type DialogType = 'confirm' | 'alert'
|
||||
|
||||
interface DialogState {
|
||||
open: boolean
|
||||
type: DialogType
|
||||
title: string
|
||||
message: string
|
||||
resolve?: (value: boolean) => void
|
||||
}
|
||||
|
||||
type ToastTone = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface ToastState {
|
||||
id: number
|
||||
title: string
|
||||
message: string
|
||||
tone: ToastTone
|
||||
}
|
||||
|
||||
interface DialogContextType {
|
||||
confirm: (title: string, message: string) => Promise<boolean>
|
||||
alert: (title: string, message: string) => Promise<void>
|
||||
@@ -19,67 +25,107 @@ interface DialogContextType {
|
||||
|
||||
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||
|
||||
const toastStyles = {
|
||||
success: { icon: CheckCircle2, iconClass: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-300', borderClass: 'border-emerald-200 dark:border-emerald-800' },
|
||||
error: { icon: CircleAlert, iconClass: 'bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300', borderClass: 'border-red-200 dark:border-red-800' },
|
||||
warning: { icon: AlertTriangle, iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300', borderClass: 'border-amber-200 dark:border-amber-800' },
|
||||
info: { icon: Info, iconClass: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300', borderClass: 'border-gray-200 dark:border-gray-700' },
|
||||
}
|
||||
|
||||
function toastTone(title: string): ToastTone {
|
||||
if (/失败|错误|异常|不可用|failed|error/i.test(title)) return 'error'
|
||||
if (/提示|警告|未配置|格式|配额|封禁|warning/i.test(title)) return 'warning'
|
||||
if (/完成|成功|已保存|success/i.test(title)) return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, title: '', message: '' })
|
||||
const [toasts, setToasts] = useState<ToastState[]>([])
|
||||
const toastID = useRef(0)
|
||||
const toastTimers = useRef(new Map<number, number>())
|
||||
const { t } = useLanguage()
|
||||
|
||||
const confirm = useCallback((title: string, message: string) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setDialog({ open: true, type: 'confirm', title, message, resolve })
|
||||
setDialog({ open: true, title, message, resolve })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id))
|
||||
const timer = toastTimers.current.get(id)
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
toastTimers.current.delete(id)
|
||||
}, [])
|
||||
|
||||
const alert = useCallback((title: string, message: string) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
|
||||
})
|
||||
const id = ++toastID.current
|
||||
setToasts((current) => [...current, { id, title, message, tone: toastTone(title) }].slice(-4))
|
||||
const timer = window.setTimeout(() => dismissToast(id), 4200)
|
||||
toastTimers.current.set(id, timer)
|
||||
return Promise.resolve()
|
||||
}, [dismissToast])
|
||||
|
||||
useEffect(() => () => {
|
||||
toastTimers.current.forEach((timer) => window.clearTimeout(timer))
|
||||
toastTimers.current.clear()
|
||||
}, [])
|
||||
|
||||
const close = (result: boolean) => {
|
||||
dialog.resolve?.(result)
|
||||
setDialog({ open: false, type: 'alert', title: '', message: '' })
|
||||
setDialog({ open: false, title: '', message: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ confirm, alert }}>
|
||||
{children}
|
||||
{dialog.open && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
|
||||
{dialog.type === 'alert' && (
|
||||
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
|
||||
<X className="w-4 h-4" />
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-[120] flex w-[calc(100vw-2rem)] max-w-sm flex-col gap-2" aria-live="polite" aria-atomic="true">
|
||||
{toasts.map((toast) => {
|
||||
const style = toastStyles[toast.tone]
|
||||
const ToastIcon = style.icon
|
||||
return (
|
||||
<div key={toast.id} className={`pointer-events-auto rounded-lg border bg-white shadow-lg dark:bg-gray-900 dark:shadow-black/40 ${style.borderClass}`} role="status">
|
||||
<div className="flex items-start gap-3 p-3.5">
|
||||
<div className={`mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${style.iconClass}`}>
|
||||
<ToastIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{t(toast.title)}</div>
|
||||
<div className="mt-0.5 break-words text-sm leading-5 text-gray-600 dark:text-gray-300">{t(toast.message)}</div>
|
||||
</div>
|
||||
<button onClick={() => dismissToast(toast.id)} className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white" title={t('关闭')}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{dialog.open && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 dark:bg-black/70">
|
||||
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3 border-b border-gray-100 px-5 py-4 dark:border-gray-700">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="flex-1 text-sm font-semibold text-black dark:text-white">{t(dialog.title)}</h3>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{t(dialog.message)}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
|
||||
{dialog.type === 'confirm' && (
|
||||
<button
|
||||
onClick={() => close(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
{t('取消')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={() => close(false)}
|
||||
className="rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('取消')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => close(true)}
|
||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${
|
||||
dialog.type === 'confirm'
|
||||
? 'bg-black text-white hover:bg-gray-800'
|
||||
: 'bg-black text-white hover:bg-gray-800'
|
||||
}`}
|
||||
className="rounded-md bg-black px-4 py-2 text-sm text-white transition-colors hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
|
||||
>
|
||||
{dialog.type === 'confirm' ? t('确认') : t('确定')}
|
||||
{t('确认')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Code2,
|
||||
Cpu,
|
||||
Camera,
|
||||
HardDrive,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Moon,
|
||||
@@ -83,6 +84,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||
const isStoragePage = location.pathname.startsWith('/storage')
|
||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||
const isHostReportPage = location.pathname.startsWith('/host-report')
|
||||
@@ -201,6 +203,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
{!collapsed && <span>路由管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/storage')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isStoragePage
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="w-4 h-4" />
|
||||
{!collapsed && <span>存储管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/audit-logs')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
|
||||
Reference in New Issue
Block a user