feat: add image fetch with proxy support and registry proxy config

Backend:
- Add image fetch handler using go-containerregistry
- Support HTTP/HTTPS and SOCKS5 proxy protocols
- Pull images from remote registries (Docker Hub, etc.)
- Store fetched images as blobs and manifests
- Add timeout handling (60s for descriptor fetch)
- Add detailed logging for pull progress
- Add /api/v1/registry/image/fetch endpoint

Frontend:
- Add registry proxy configuration field
- Add "获取镜像" button and dialog
- Add proxy checkbox and input in fetch dialog
- Add LinearProgress feedback during fetch
- Add multi-stage Snackbar notifications
- Auto-refresh image list after successful fetch
- Fix download filename regex (remove trailing quote)
- Adjust button colors for better UI consistency

Dependencies:
- Add github.com/google/go-containerregistry for OCI operations
- Add golang.org/x/net/proxy for SOCKS5 support

🤖 Generated with [Qoder](https://qoder.com)
This commit is contained in:
loveuer
2025-11-10 22:23:23 +08:00
parent 9780a2b028
commit 01cfb2ede1
7 changed files with 537 additions and 5 deletions

View File

@@ -23,12 +23,15 @@ import {
DialogContent,
DialogActions,
LinearProgress,
Checkbox,
FormControlLabel,
} 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 CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import { useSettingsStore } from '../stores/settingsStore'
const SnackbarAlert = forwardRef<HTMLDivElement, AlertProps>(function SnackbarAlert(
@@ -73,20 +76,28 @@ export default function RegistryImageList() {
const [error, setError] = useState<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [uploadOpen, setUploadOpen] = useState(false)
const [fetchImageOpen, setFetchImageOpen] = 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 [fetchingImage, setFetchingImage] = useState(false)
const [fetchImageName, setFetchImageName] = useState('')
const [useProxy, setUseProxy] = useState(false)
const [fetchProxyInput, setFetchProxyInput] = useState('')
const {
registryAddress,
registryAddress,
registryProxy,
loading: configLoading,
snackbar,
fetchConfig,
setRegistryAddress,
setRegistryProxy,
hideSnackbar,
showSnackbar
} = useSettingsStore()
const [registryAddressInput, setRegistryAddressInput] = useState(registryAddress)
const [registryProxyInput, setRegistryProxyInput] = useState(registryProxy)
const [saving, setSaving] = useState(false)
useEffect(() => {
@@ -132,9 +143,9 @@ export default function RegistryImageList() {
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition')
let filename = `${imageName.replace(/\//g, '_')}-latest.tar`
let filename = `${imageName.replace(/\//g, '_')}.tar`
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/i)
if (filenameMatch) {
filename = filenameMatch[1]
}
@@ -164,6 +175,7 @@ export default function RegistryImageList() {
try {
setSaving(true)
await setRegistryAddress(registryAddressInput)
await setRegistryProxy(registryProxyInput)
setSettingsOpen(false)
} catch (error: any) {
// Error already handled in store
@@ -174,6 +186,7 @@ export default function RegistryImageList() {
const handleCloseSettings = () => {
setRegistryAddressInput(registryAddress)
setRegistryProxyInput(registryProxy)
setSettingsOpen(false)
}
@@ -181,10 +194,67 @@ export default function RegistryImageList() {
setRegistryAddressInput(registryAddress)
}, [registryAddress])
useEffect(() => {
setRegistryProxyInput(registryProxy)
}, [registryProxy])
useEffect(() => {
fetchConfig()
}, [fetchConfig])
useEffect(() => {
setFetchProxyInput(registryProxy)
}, [registryProxy])
const handleFetchImage = async () => {
if (!fetchImageName.trim()) {
showSnackbar('请输入镜像名称', 'warning')
return
}
setFetchingImage(true)
showSnackbar('开始获取镜像...', 'info')
try {
const response = await fetch('/api/v1/registry/image/fetch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
image: fetchImageName,
proxy: useProxy ? fetchProxyInput : '',
}),
})
const result = await response.json()
if (response.ok) {
showSnackbar(result.data?.message || '镜像获取成功', 'success')
setFetchImageOpen(false)
setFetchImageName('')
setUseProxy(false)
setFetchProxyInput(registryProxy)
// Refresh image list
window.location.reload()
} else {
showSnackbar(result.err || result.msg || '获取失败', 'error')
}
} catch (error: any) {
showSnackbar(`请求失败: ${error.message}`, 'error')
} finally {
setFetchingImage(false)
}
}
const handleCloseFetchImage = () => {
if (!fetchingImage) {
setFetchImageOpen(false)
setFetchImageName('')
setUseProxy(false)
setFetchProxyInput(registryProxy)
}
}
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file && file.name.endsWith('.tar')) {
@@ -258,6 +328,13 @@ export default function RegistryImageList() {
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5"></Typography>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<CloudDownloadIcon />}
onClick={() => setFetchImageOpen(true)}
>
</Button>
<Button
variant="contained"
startIcon={<UploadIcon />}
@@ -371,6 +448,69 @@ export default function RegistryImageList() {
</DialogActions>
</Dialog>
{/* Fetch Image Dialog */}
<Dialog open={fetchImageOpen} onClose={handleCloseFetchImage} maxWidth="sm" fullWidth>
<DialogTitle></DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 2 }}>
<Alert severity="info">
例如: docker.io/library/nginx:latest
</Alert>
<TextField
label="镜像名称"
placeholder="例如: docker.io/library/nginx:latest"
value={fetchImageName}
onChange={(e) => setFetchImageName(e.target.value)}
fullWidth
variant="outlined"
disabled={fetchingImage}
/>
<FormControlLabel
control={
<Checkbox
checked={useProxy}
onChange={(e) => setUseProxy(e.target.checked)}
disabled={fetchingImage}
/>
}
label="使用代理"
/>
{useProxy && (
<TextField
label="代理地址"
placeholder="例如: socks5://192.168.9.19:7890"
value={fetchProxyInput}
onChange={(e) => setFetchProxyInput(e.target.value)}
fullWidth
variant="outlined"
disabled={fetchingImage}
helperText="支持格式: http://, https://, socks5://"
/>
)}
{fetchingImage && (
<Box>
<LinearProgress />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
...
</Typography>
</Box>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseFetchImage} disabled={fetchingImage}>
</Button>
<Button
onClick={handleFetchImage}
variant="contained"
disabled={!fetchImageName.trim() || fetchingImage}
>
{fetchingImage ? '获取中...' : '获取'}
</Button>
</DialogActions>
</Dialog>
{/* Settings Drawer */}
<Drawer
anchor="right"
@@ -400,6 +540,17 @@ export default function RegistryImageList() {
disabled={configLoading || saving}
/>
<TextField
label="Registry Proxy"
placeholder="例如: http://proxy.example.com:8080"
value={registryProxyInput}
onChange={(e) => setRegistryProxyInput(e.target.value)}
fullWidth
helperText="设置镜像代理地址,格式: <scheme>://<host>[:<port>]"
variant="outlined"
disabled={configLoading || saving}
/>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button variant="outlined" onClick={handleCloseSettings} disabled={saving}>

View File

@@ -8,17 +8,20 @@ interface SnackbarMessage {
interface SettingsState {
registryAddress: string
registryProxy: string
loading: boolean
error: string | null
snackbar: SnackbarMessage
fetchConfig: () => Promise<void>
setRegistryAddress: (address: string) => Promise<void>
setRegistryProxy: (proxy: string) => Promise<void>
showSnackbar: (message: string, severity: SnackbarMessage['severity']) => void
hideSnackbar: () => void
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
registryAddress: '',
registryProxy: '',
loading: false,
error: null,
snackbar: {
@@ -36,6 +39,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
const configs = result.data?.configs || {}
set({
registryAddress: configs.registry_address || '',
registryProxy: configs.proxy || '',
loading: false
})
} catch (error: any) {
@@ -66,6 +70,29 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
}
},
setRegistryProxy: async (proxy: 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: 'proxy',
value: proxy,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
set({ registryProxy: proxy, 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: {