/** * Artifact Browser Page * 左侧 Registry/Repository 树(自动加载并展开),右侧 Artifact 卡片。 */ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Package, Database, RefreshCw, Filter, Search, ChevronRight, ChevronDown, ChevronLeft, LayoutGrid, } from "lucide-react"; import { useToast } from "@/shared"; import { Button, LoadingState, EmptyState, Badge, } from "@/shared/components"; import { listRegistries, listRepositories, listArtifacts, } from "@/api"; import type { RegistryResponse, ListRepositories200Item, ArtifactListItem, ListArtifactsFilter, } from "@/api"; import { TagCard } from "../components/TagCard"; import { globalCache } from "@/shared/services/artifact-cache"; import { RegistryErrors, SuccessMessages, formatApiError } from "@/shared/utils"; interface RepositoryNode { name: string; registryId: string; registryName: string; artifactCount?: number; } interface RegistryNode { registry: RegistryResponse; repositories: RepositoryNode[]; expanded: boolean; } const FILTER_OPTIONS: Array<{ value: ListArtifactsFilter | undefined; label: string }> = [ { value: "chart", label: "Charts" }, { value: undefined, label: "All tags" }, ]; const ArtifactBrowserPage: React.FC = () => { const { info: toastInfo, success: toastSuccess, error: toastError } = useToast(); const [registryNodes, setRegistryNodes] = useState([]); const [loadingRegistries, setLoadingRegistries] = useState(true); const [loadingRepositories, setLoadingRepositories] = useState(true); const [repositoryError, setRepositoryError] = useState(null); const [refreshing, setRefreshing] = useState(false); const [selectedRepository, setSelectedRepository] = useState(null); const [artifacts, setArtifacts] = useState([]); const [loadingArtifacts, setLoadingArtifacts] = useState(false); const [artifactError, setArtifactError] = useState(null); const [filter, setFilter] = useState("chart"); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [tagSearchTerm, setTagSearchTerm] = useState(""); const loadArtifacts = useCallback( async ( registryId: string, repositoryName: string, skipCache = false, isRefresh = false ) => { if (!isRefresh) { setLoadingArtifacts(true); } setArtifactError(null); const filterKey = filter ?? "all"; try { let data: ArtifactListItem[] | null = null; if (!skipCache) { data = globalCache.get("tags", registryId, repositoryName, filterKey); } if (!data) { data = await listArtifacts( { registryId, repositoryName }, filter ? { filter } : undefined ); globalCache.set("tags", data, registryId, repositoryName, filterKey); } setArtifacts(data ?? []); } catch (err) { const errorMsg = formatApiError(err) || "Failed to load artifacts"; setArtifactError(errorMsg); toastError(errorMsg); } finally { if (!isRefresh) { setLoadingArtifacts(false); } } }, [filter, toastError] ); const fetchRegsAndRepos = useCallback(async (isRefresh = false) => { if (!isRefresh) { setLoadingRegistries(true); setLoadingRepositories(true); } setRepositoryError(null); let succeeded = false; try { let registries = globalCache.get("registries"); if (!registries) { registries = await listRegistries(); globalCache.set("registries", registries); } const baseNodes: RegistryNode[] = registries.map((registry) => ({ registry, repositories: [], expanded: true, })); setRegistryNodes(baseNodes); if (!isRefresh) { setLoadingRegistries(false); } const repoMap = await Promise.all( registries.map(async (registry) => { const registryId = registry.id; if (!registryId) { return { id: registryId, repos: [] }; } try { let repoNodes = globalCache.get("repositories", registryId); if (!repoNodes) { const response = await listRepositories({ registryId }, { artifactType: "chart" }); repoNodes = normalizeRepositories(registry, response); globalCache.set("repositories", repoNodes, registryId); } return { id: registryId, repos: repoNodes }; } catch (err) { console.error(`Failed to load repositories for ${registry.name}`, err); return { id: registryId, repos: [] }; } }) ); setRegistryNodes((prev) => prev.map((node) => { const mapping = repoMap.find((entry) => entry.id === node.registry.id); if (!mapping) return node; return { ...node, repositories: mapping.repos }; }) ); const firstRepo = repoMap.find((entry) => entry.repos.length > 0)?.repos[0]; if (firstRepo) { setSelectedRepository(firstRepo); await loadArtifacts(firstRepo.registryId, firstRepo.name, false, isRefresh); } else { setSelectedRepository(null); setArtifacts([]); } succeeded = true; } catch (err) { const msg = formatApiError(err) || RegistryErrors.LOAD_FAILED; toastError(msg); setRepositoryError(msg); } finally { if (!isRefresh) { setLoadingRepositories(false); } } return succeeded; }, [toastError, loadArtifacts]); const handleRefresh = async () => { if (refreshing) return; toastInfo("Refreshing registries & repositories...", { title: "Artifact Browser Refresh", durationMs: 1800, mergeKey: "artifact-browser-refresh", }); setRefreshing(true); globalCache.clearAll(); try { const refreshed = await fetchRegsAndRepos(true); if (refreshed) { toastSuccess(SuccessMessages.DATA_REFRESHED); } } finally { setRefreshing(false); } }; const handleRepositoryClick = (repo: RepositoryNode) => { setSelectedRepository(repo); loadArtifacts(repo.registryId, repo.name, true); }; const toggleRegistry = (registryId?: string) => { if (!registryId) return; setRegistryNodes((prev) => prev.map((node) => node.registry.id === registryId ? { ...node, expanded: !node.expanded } : node ) ); }; const hasInitialized = React.useRef(false); useEffect(() => { if (hasInitialized.current) { return; } hasInitialized.current = true; fetchRegsAndRepos(); }, [fetchRegsAndRepos]); useEffect(() => { if (selectedRepository) { loadArtifacts(selectedRepository.registryId, selectedRepository.name); } }, [filter, selectedRepository, loadArtifacts]); const filteredNodes = useMemo(() => { const term = searchTerm.toLowerCase(); if (!term) return registryNodes; return registryNodes .map((node) => ({ ...node, repositories: node.repositories.filter((repo) => { const registryMatch = node.registry.name?.toLowerCase().includes(term); const repoMatch = repo.name.toLowerCase().includes(term); return registryMatch || repoMatch; }), })) .filter( (node) => node.repositories.length > 0 || node.registry.name?.toLowerCase().includes(term) ); }, [registryNodes, searchTerm]); const filteredArtifacts = useMemo(() => { const term = tagSearchTerm.trim().toLowerCase(); if (!term) return artifacts; return artifacts.filter((a) => (a.tag || "").toLowerCase().includes(term)); }, [artifacts, tagSearchTerm]); const selectedRegistryName = selectedRepository ? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry .name : null; const selectedRegistryUrl = selectedRepository ? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry .url : undefined; return (

Chart Browser

Select a Harbor chart and launch it into a Kubernetes cluster

{/* Collapsible side panel */}
{!selectedRepository ? ( /* Placeholder when no repo is selected */
) : ( <> {/* Right panel header */}

Chart repository

{selectedRepository.name}

{selectedRegistryName || selectedRepository.registryId}

{FILTER_OPTIONS.map((option) => ( ))}
{/* Tag search bar */}
setTagSearchTerm(e.target.value)} className="w-full pl-8 pr-3 py-1.5 rounded-lg bg-white border border-slate-200 text-sm text-slate-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{tagSearchTerm && (

Showing {filteredArtifacts.length} of {artifacts.length} tags

)}
{/* Tag grid */}
{artifactError && (

{artifactError}

)} {loadingArtifacts ? ( ) : artifacts.length === 0 ? ( ) : filteredArtifacts.length === 0 ? ( ) : (
{filteredArtifacts.map((artifact, index) => ( ))}
)}
)}
); }; export default ArtifactBrowserPage; function normalizeRepositories( registry: RegistryResponse, payload: ListRepositories200Item[] | { repositories?: string[] } | null | undefined ): RepositoryNode[] { const registryId = registry.id || ""; const registryName = registry.name || registryId; if (Array.isArray(payload)) { return payload .map((repo): RepositoryNode | null => { if (typeof repo === "string") { return { name: repo, registryId, registryName, artifactCount: undefined, }; } const name = repo.name || ""; if (!name) { return null; } return { name, registryId, registryName, artifactCount: (repo as any).artifact_count ?? (repo as any).artifactCount, }; }) .filter((repo): repo is RepositoryNode => Boolean(repo)); } if (payload && typeof payload === "object" && Array.isArray((payload as any).repositories)) { const repoEntries = (payload as any).repositories.map( (name: unknown): RepositoryNode | null => typeof name === "string" ? { name, registryId, registryName, artifactCount: undefined, } : null ); return repoEntries.filter( (repo: RepositoryNode | null): repo is RepositoryNode => Boolean(repo) ); } return []; }