feat: add backend image filter with debounced search
Backend: - Add filter query parameter to /api/v1/registry/image/list - Filter repositories by name using SQL LIKE query Frontend: - Add filter input field above image list table - Implement 300ms debounce to reduce backend requests - Use AbortController to cancel pending requests - Show appropriate message when no images match filter 🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
@@ -72,6 +72,7 @@ function getDisplayImageName(fullImageName: string): string {
|
|||||||
|
|
||||||
export default function RegistryImageList() {
|
export default function RegistryImageList() {
|
||||||
const [images, setImages] = useState<RegistryImage[]>([])
|
const [images, setImages] = useState<RegistryImage[]>([])
|
||||||
|
const [filterText, setFilterText] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
@@ -102,25 +103,41 @@ export default function RegistryImageList() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let abort = false
|
let abort = false
|
||||||
async function fetchImages() {
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
const fetchImages = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/registry/image/list')
|
const url = new URL('/api/v1/registry/image/list', window.location.origin)
|
||||||
|
if (filterText.trim()) {
|
||||||
|
url.searchParams.set('filter', filterText.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), { signal: controller.signal })
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
// Backend returns: {status, msg, data: {images: [...]}}
|
|
||||||
const list: RegistryImage[] = result.data?.images || []
|
const list: RegistryImage[] = result.data?.images || []
|
||||||
if (!abort) setImages(list)
|
if (!abort) {
|
||||||
|
setImages(list)
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!abort) setError(e.message)
|
if (e.name !== 'AbortError' && !abort) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!abort) setLoading(false)
|
if (!abort) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchImages()
|
|
||||||
return () => { abort = true }
|
const timeoutId = setTimeout(fetchImages, 300)
|
||||||
}, [])
|
|
||||||
|
return () => {
|
||||||
|
abort = true
|
||||||
|
controller.abort()
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [filterText])
|
||||||
|
|
||||||
const handleDownload = async (imageName: string) => {
|
const handleDownload = async (imageName: string) => {
|
||||||
setDownloadingImage(imageName)
|
setDownloadingImage(imageName)
|
||||||
@@ -351,6 +368,16 @@ export default function RegistryImageList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="筛选镜像名称..."
|
||||||
|
value={filterText}
|
||||||
|
onChange={(e) => setFilterText(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
{loading && <CircularProgress />}
|
{loading && <CircularProgress />}
|
||||||
{error && <Alert severity="error">加载失败: {error}</Alert>}
|
{error && <Alert severity="error">加载失败: {error}</Alert>}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
@@ -388,7 +415,9 @@ export default function RegistryImageList() {
|
|||||||
))}
|
))}
|
||||||
{images.length === 0 && (
|
{images.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} align="center">暂无镜像</TableCell>
|
<TableCell colSpan={5} align="center">
|
||||||
|
{filterText ? '没有匹配的镜像' : '暂无镜像'}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
// RegistryImageList returns the list of images/repositories
|
// RegistryImageList returns the list of images/repositories
|
||||||
func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
|
// Get filter parameter from query string
|
||||||
|
filter := c.Query("filter", "")
|
||||||
|
|
||||||
// Get current registry_address setting
|
// Get current registry_address setting
|
||||||
var registryConfig model.RegistryConfig
|
var registryConfig model.RegistryConfig
|
||||||
registryAddress := ""
|
registryAddress := ""
|
||||||
@@ -26,7 +29,11 @@ func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
|||||||
var repositories []model.Repository
|
var repositories []model.Repository
|
||||||
|
|
||||||
// Query all repositories from the database
|
// Query all repositories from the database
|
||||||
if err := db.Find(&repositories).Error; err != nil {
|
query := db
|
||||||
|
if filter != "" {
|
||||||
|
query = query.Where("name LIKE ?", "%"+filter+"%")
|
||||||
|
}
|
||||||
|
if err := query.Find(&repositories).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user