Files
cluster/frontend/src/pages/RegistryImageList.tsx
loveuer 9780a2b028 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)
2025-11-10 16:28:58 +08:00

428 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}