import { useEffect, useState, forwardRef } from 'react' import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress, Alert, Button, Drawer, TextField, Stack, IconButton, Divider, Snackbar, Dialog, DialogTitle, DialogContent, DialogActions, LinearProgress, } from '@mui/material' import MuiAlert, { AlertProps } from '@mui/material/Alert' import DownloadIcon from '@mui/icons-material/Download' import UploadIcon from '@mui/icons-material/Upload' import SettingsIcon from '@mui/icons-material/Settings' import CloseIcon from '@mui/icons-material/Close' import { useSettingsStore } from '../stores/settingsStore' const SnackbarAlert = forwardRef(function SnackbarAlert( props, ref, ) { return }) interface RegistryImage { id: number name: string upload_time: string size: number } // Format bytes to human readable format function formatSize(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` } // Remove registry address prefix from image name for display // e.g., "registry.loveuer.com/hub.yizhisec.com/external/busybox:1.37.0" // -> "hub.yizhisec.com/external/busybox:1.37.0" function getDisplayImageName(fullImageName: string): string { const firstSlashIndex = fullImageName.indexOf('/') if (firstSlashIndex > 0) { // Remove everything before the first slash (registry address) return fullImageName.substring(firstSlashIndex + 1) } // No slash found, return original name return fullImageName } export default function RegistryImageList() { const [images, setImages] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) const [uploadOpen, setUploadOpen] = useState(false) const [selectedFile, setSelectedFile] = useState(null) const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState(0) const [downloadingImage, setDownloadingImage] = useState(null) const { registryAddress, loading: configLoading, snackbar, fetchConfig, setRegistryAddress, hideSnackbar, showSnackbar } = useSettingsStore() const [registryAddressInput, setRegistryAddressInput] = useState(registryAddress) const [saving, setSaving] = useState(false) useEffect(() => { let abort = false async function fetchImages() { setLoading(true) setError(null) try { const res = await fetch('/api/v1/registry/image/list') if (!res.ok) throw new Error(`HTTP ${res.status}`) const result = await res.json() // Backend returns: {status, msg, data: {images: [...]}} const list: RegistryImage[] = result.data?.images || [] if (!abort) setImages(list) } catch (e: any) { if (!abort) setError(e.message) } finally { if (!abort) setLoading(false) } } fetchImages() return () => { abort = true } }, []) const handleDownload = async (imageName: string) => { setDownloadingImage(imageName) showSnackbar('正在准备下载,请稍候...', 'info') try { // Use the image name directly as it's already the full repository name from database // The imageName already contains the full path like: registry.loveuer.com/hub.yizhisec.com/external/busybox:1.37.0 // We should NOT modify it based on registry_address setting const fullImageName = imageName showSnackbar('正在连接服务器...', 'info') const url = `/api/v1/registry/image/download/${encodeURIComponent(fullImageName)}` const response = await fetch(url) if (!response.ok) { throw new Error(`下载失败: ${response.statusText}`) } showSnackbar('正在接收数据,请稍候...', 'info') // Get filename from Content-Disposition header or use default const contentDisposition = response.headers.get('Content-Disposition') let filename = `${imageName.replace(/\//g, '_')}-latest.tar` if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i) if (filenameMatch) { filename = filenameMatch[1] } } // Create blob and download const blob = await response.blob() const downloadUrl = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = downloadUrl link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) window.URL.revokeObjectURL(downloadUrl) showSnackbar('下载完成!', 'success') } catch (error: unknown) { const err = error as Error showSnackbar(`下载失败: ${err.message}`, 'error') } finally { setDownloadingImage(null) } } const handleSaveSettings = async () => { try { setSaving(true) await setRegistryAddress(registryAddressInput) setSettingsOpen(false) } catch (error: any) { // Error already handled in store } finally { setSaving(false) } } const handleCloseSettings = () => { setRegistryAddressInput(registryAddress) setSettingsOpen(false) } useEffect(() => { setRegistryAddressInput(registryAddress) }, [registryAddress]) useEffect(() => { fetchConfig() }, [fetchConfig]) const handleFileSelect = (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (file && file.name.endsWith('.tar')) { setSelectedFile(file) } else { showSnackbar('请选择 .tar 文件', 'error') } } const handleUpload = async () => { if (!selectedFile) { showSnackbar('请先选择文件', 'warning') return } setUploading(true) setUploadProgress(0) try { const formData = new FormData() formData.append('file', selectedFile) const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percentComplete = (e.loaded / e.total) * 100 setUploadProgress(percentComplete) } }) xhr.addEventListener('load', () => { if (xhr.status === 200) { const result = JSON.parse(xhr.responseText) showSnackbar(`上传成功: ${result.data?.repository || ''}`, 'success') setUploadOpen(false) setSelectedFile(null) setUploadProgress(0) window.location.reload() } else { const result = JSON.parse(xhr.responseText) showSnackbar(`上传失败: ${result.msg || xhr.statusText}`, 'error') } setUploading(false) }) xhr.addEventListener('error', () => { showSnackbar('上传失败: 网络错误', 'error') setUploading(false) }) xhr.open('POST', '/api/v1/registry/image/upload') xhr.send(formData) } catch (error: unknown) { const err = error as Error showSnackbar(`上传失败: ${err.message}`, 'error') setUploading(false) } } const handleCloseUpload = () => { if (!uploading) { setUploadOpen(false) setSelectedFile(null) setUploadProgress(0) } } return ( 镜像列表 {loading && } {error && 加载失败: {error}} {!loading && !error && ( ID 名称 上传时间 大小 操作 {images.map(img => ( {img.id} {getDisplayImageName(img.name)} {img.upload_time} {formatSize(img.size)} ))} {images.length === 0 && ( 暂无镜像 )}
)} {/* Upload Dialog */} 上传镜像文件 请上传 Docker 镜像 tar 文件(使用 docker save 导出的文件) {selectedFile && ( 已选择: {selectedFile.name} ({formatSize(selectedFile.size)}) )} {uploading && ( 上传中: {uploadProgress.toFixed(0)}% )} {/* Settings Drawer */} 镜像设置 setRegistryAddressInput(e.target.value)} fullWidth helperText="设置的 registry address 会作用于下载的镜像名称" variant="outlined" disabled={configLoading || saving} /> {/* Snackbar for notifications */} {snackbar.message}
) }