feat: add registry config, image upload/download, and OCI format support
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)
This commit is contained in:
@@ -1,5 +1,42 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress, Alert } from '@mui/material'
|
||||
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
|
||||
@@ -17,10 +54,40 @@ function formatSize(bytes: number): string {
|
||||
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
|
||||
@@ -44,9 +111,169 @@ export default function RegistryImageList() {
|
||||
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>
|
||||
<Typography variant="h5" gutterBottom>镜像列表</Typography>
|
||||
<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 && (
|
||||
@@ -59,20 +286,32 @@ export default function RegistryImageList() {
|
||||
<TableCell>名称</TableCell>
|
||||
<TableCell>上传时间</TableCell>
|
||||
<TableCell>大小</TableCell>
|
||||
<TableCell>操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{images.map(img => (
|
||||
<TableRow key={img.id} hover>
|
||||
<TableCell>{img.id}</TableCell>
|
||||
<TableCell>{img.name}</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={4} align="center">暂无镜像</TableCell>
|
||||
<TableCell colSpan={5} align="center">暂无镜像</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
@@ -80,6 +319,109 @@ export default function RegistryImageList() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user