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:
@@ -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}>
|
||||
取消
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user