Backend: - Add registry_address configuration API (GET/POST) - Add tar image upload with OCI and Docker format support - Add image download with streaming optimization - Fix blob download using c.Send (Fiber v3 SendStream bug) - Add registry_address prefix stripping for all OCI v2 endpoints - Add AGENTS.md for project documentation Frontend: - Add settings store with Snackbar notifications - Add image upload dialog with progress bar - Add download state tracking with multi-stage feedback - Replace alert() with MUI Snackbar messages - Display image names without registry_address prefix 🤖 Generated with [Qoder](https://qoder.com)
428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
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<HTMLDivElement, AlertProps>(function SnackbarAlert(
|
||
props,
|
||
ref,
|
||
) {
|
||
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
|
||
})
|
||
|
||
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<RegistryImage[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||
const [uploadOpen, setUploadOpen] = useState(false)
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||
const [uploading, setUploading] = useState(false)
|
||
const [uploadProgress, setUploadProgress] = useState(0)
|
||
const [downloadingImage, setDownloadingImage] = useState<string | null>(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<HTMLInputElement>) => {
|
||
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 (
|
||
<Box>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||
<Typography variant="h5">镜像列表</Typography>
|
||
<Stack direction="row" spacing={2}>
|
||
<Button
|
||
variant="contained"
|
||
startIcon={<UploadIcon />}
|
||
onClick={() => setUploadOpen(true)}
|
||
>
|
||
上传镜像
|
||
</Button>
|
||
<Button
|
||
variant="outlined"
|
||
startIcon={<SettingsIcon />}
|
||
onClick={() => setSettingsOpen(true)}
|
||
>
|
||
设置
|
||
</Button>
|
||
</Stack>
|
||
</Box>
|
||
{loading && <CircularProgress />}
|
||
{error && <Alert severity="error">加载失败: {error}</Alert>}
|
||
{!loading && !error && (
|
||
<Paper>
|
||
<TableContainer>
|
||
<Table size="small">
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableCell>ID</TableCell>
|
||
<TableCell>名称</TableCell>
|
||
<TableCell>上传时间</TableCell>
|
||
<TableCell>大小</TableCell>
|
||
<TableCell>操作</TableCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{images.map(img => (
|
||
<TableRow key={img.id} hover>
|
||
<TableCell>{img.id}</TableCell>
|
||
<TableCell>{getDisplayImageName(img.name)}</TableCell>
|
||
<TableCell>{img.upload_time}</TableCell>
|
||
<TableCell>{formatSize(img.size)}</TableCell>
|
||
<TableCell>
|
||
<Button
|
||
variant="outlined"
|
||
size="small"
|
||
startIcon={downloadingImage === img.name ? <CircularProgress size={16} /> : <DownloadIcon />}
|
||
onClick={() => handleDownload(img.name)}
|
||
disabled={downloadingImage === img.name}
|
||
>
|
||
{downloadingImage === img.name ? '下载中...' : '下载'}
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{images.length === 0 && (
|
||
<TableRow>
|
||
<TableCell colSpan={5} align="center">暂无镜像</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Paper>
|
||
)}
|
||
|
||
{/* Upload Dialog */}
|
||
<Dialog open={uploadOpen} onClose={handleCloseUpload} maxWidth="sm" fullWidth>
|
||
<DialogTitle>上传镜像文件</DialogTitle>
|
||
<DialogContent>
|
||
<Stack spacing={3} sx={{ mt: 2 }}>
|
||
<Alert severity="info">
|
||
请上传 Docker 镜像 tar 文件(使用 docker save 导出的文件)
|
||
</Alert>
|
||
<Button
|
||
variant="outlined"
|
||
component="label"
|
||
fullWidth
|
||
disabled={uploading}
|
||
>
|
||
选择文件
|
||
<input
|
||
type="file"
|
||
accept=".tar"
|
||
hidden
|
||
onChange={handleFileSelect}
|
||
/>
|
||
</Button>
|
||
{selectedFile && (
|
||
<Typography variant="body2" color="text.secondary">
|
||
已选择: {selectedFile.name} ({formatSize(selectedFile.size)})
|
||
</Typography>
|
||
)}
|
||
{uploading && (
|
||
<Box>
|
||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||
上传中: {uploadProgress.toFixed(0)}%
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
</Stack>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={handleCloseUpload} disabled={uploading}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleUpload}
|
||
variant="contained"
|
||
disabled={!selectedFile || uploading}
|
||
>
|
||
{uploading ? '上传中...' : '开始上传'}
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
{/* Settings Drawer */}
|
||
<Drawer
|
||
anchor="right"
|
||
open={settingsOpen}
|
||
onClose={handleCloseSettings}
|
||
PaperProps={{
|
||
sx: { width: 400, p: 3 }
|
||
}}
|
||
>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||
<Typography variant="h6">镜像设置</Typography>
|
||
<IconButton onClick={handleCloseSettings}>
|
||
<CloseIcon />
|
||
</IconButton>
|
||
</Box>
|
||
<Divider sx={{ mb: 3 }} />
|
||
|
||
<Stack spacing={3}>
|
||
<TextField
|
||
label="Registry Address"
|
||
placeholder="例如: my-registry.com"
|
||
value={registryAddressInput}
|
||
onChange={(e) => setRegistryAddressInput(e.target.value)}
|
||
fullWidth
|
||
helperText="设置的 registry address 会作用于下载的镜像名称"
|
||
variant="outlined"
|
||
disabled={configLoading || saving}
|
||
/>
|
||
|
||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||
<Button variant="outlined" onClick={handleCloseSettings} disabled={saving}>
|
||
取消
|
||
</Button>
|
||
<Button variant="contained" onClick={handleSaveSettings} disabled={saving || configLoading}>
|
||
{saving ? '保存中...' : '保存'}
|
||
</Button>
|
||
</Box>
|
||
</Stack>
|
||
</Drawer>
|
||
|
||
{/* Snackbar for notifications */}
|
||
<Snackbar
|
||
open={snackbar.open}
|
||
autoHideDuration={3000}
|
||
onClose={hideSnackbar}
|
||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||
>
|
||
<SnackbarAlert onClose={hideSnackbar} severity={snackbar.severity}>
|
||
{snackbar.message}
|
||
</SnackbarAlert>
|
||
</Snackbar>
|
||
</Box>
|
||
)
|
||
}
|