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:
loveuer
2025-11-10 16:28:58 +08:00
parent 29088a6b54
commit 9780a2b028
35 changed files with 3065 additions and 91 deletions

View File

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

View File

@@ -0,0 +1,88 @@
import { create } from 'zustand'
interface SnackbarMessage {
open: boolean
message: string
severity: 'success' | 'error' | 'info' | 'warning'
}
interface SettingsState {
registryAddress: string
loading: boolean
error: string | null
snackbar: SnackbarMessage
fetchConfig: () => Promise<void>
setRegistryAddress: (address: string) => Promise<void>
showSnackbar: (message: string, severity: SnackbarMessage['severity']) => void
hideSnackbar: () => void
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
registryAddress: '',
loading: false,
error: null,
snackbar: {
open: false,
message: '',
severity: 'info'
},
fetchConfig: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/registry/config')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const result = await res.json()
const configs = result.data?.configs || {}
set({
registryAddress: configs.registry_address || '',
loading: false
})
} catch (error: any) {
set({ error: error.message, loading: false })
}
},
setRegistryAddress: async (address: string) => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/registry/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: 'registry_address',
value: address,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
set({ registryAddress: address, loading: false })
get().showSnackbar('保存成功', 'success')
} catch (error: any) {
set({ error: error.message, loading: false })
get().showSnackbar(`保存失败: ${error.message}`, 'error')
throw error
}
},
showSnackbar: (message: string, severity: SnackbarMessage['severity']) => {
set({
snackbar: {
open: true,
message,
severity
}
})
},
hideSnackbar: () => {
set({
snackbar: {
open: false,
message: '',
severity: 'info'
}
})
},
}))