ocdp v1
This commit is contained in:
@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Artifact Browser Page
|
||||
* 左侧 Registry/Repository 树(自动加载并展开),右侧 Artifact 卡片。
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Package,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
Search,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} 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: undefined, label: "All" },
|
||||
{ value: "chart", label: "Charts" },
|
||||
{ value: "image", label: "Images" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
const ArtifactBrowserPage: React.FC = () => {
|
||||
const { info: toastInfo, success: toastSuccess, error: toastError } = useToast();
|
||||
|
||||
const [registryNodes, setRegistryNodes] = useState<RegistryNode[]>([]);
|
||||
const [loadingRegistries, setLoadingRegistries] = useState(true);
|
||||
const [loadingRepositories, setLoadingRepositories] = useState(true);
|
||||
const [repositoryError, setRepositoryError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const [selectedRepository, setSelectedRepository] = useState<RepositoryNode | null>(null);
|
||||
const [artifacts, setArtifacts] = useState<ArtifactListItem[]>([]);
|
||||
const [loadingArtifacts, setLoadingArtifacts] = useState(false);
|
||||
const [artifactError, setArtifactError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<ListArtifactsFilter | undefined>(undefined);
|
||||
|
||||
const [searchTerm, setSearchTerm] = 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<ArtifactListItem[]>("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<RegistryResponse[]>("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<RepositoryNode[]>("repositories", registryId);
|
||||
if (!repoNodes) {
|
||||
const response = await listRepositories({ registryId });
|
||||
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 selectedRegistryName = selectedRepository
|
||||
? registryNodes.find((node) => node.registry.id === selectedRepository.registryId)?.registry
|
||||
.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-8rem)] -m-6 flex flex-col">
|
||||
<div className="flex-shrink-0 border-b border-dark-border bg-dark-card px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="w-6 h-6 text-purple-400" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">Artifact Browser</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
Browse registries, repositories, and artifacts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
spinIcon
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex overflow-hidden bg-dark-bg">
|
||||
<aside className="w-80 border-r border-dark-border bg-gradient-to-b from-gray-900 via-gray-950 to-gray-900 flex flex-col">
|
||||
<div className="p-4 border-b border-dark-border space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search registries / repositories..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg bg-gray-900/70 border border-gray-700 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
{repositoryError && (
|
||||
<p className="text-xs text-red-400">{repositoryError}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||
<span>Registries</span>
|
||||
<Badge variant="secondary">{registryNodes.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{loadingRegistries ? (
|
||||
<div className="p-4">
|
||||
<LoadingState message="Loading registries..." />
|
||||
</div>
|
||||
) : filteredNodes.length === 0 ? (
|
||||
<div className="p-4">
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No registries"
|
||||
description="Add a registry to get started."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
filteredNodes.map((node) => (
|
||||
<div key={node.registry.id || node.registry.name}>
|
||||
<button
|
||||
onClick={() => toggleRegistry(node.registry.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-800/60 transition"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{node.expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<Database className="w-4 h-4 text-purple-400" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm text-white">{node.registry.name || "Unnamed"}</p>
|
||||
<p className="text-[11px] text-gray-500 truncate">
|
||||
{node.registry.url}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">{node.repositories.length}</Badge>
|
||||
</button>
|
||||
{node.expanded && (
|
||||
<div className="bg-gray-900/60">
|
||||
{node.repositories.length === 0 ? (
|
||||
<p className="px-8 py-3 text-xs text-gray-500">
|
||||
{loadingRepositories
|
||||
? "Loading repositories..."
|
||||
: "No repositories found."}
|
||||
</p>
|
||||
) : (
|
||||
node.repositories.map((repo) => {
|
||||
const isSelected =
|
||||
selectedRepository?.registryId === repo.registryId &&
|
||||
selectedRepository?.name === repo.name;
|
||||
return (
|
||||
<button
|
||||
key={`${repo.registryId}-${repo.name}`}
|
||||
onClick={() => handleRepositoryClick(repo)}
|
||||
className={`w-full text-left px-8 py-2 flex items-center justify-between text-sm transition ${
|
||||
isSelected
|
||||
? "bg-purple-600/20 text-white"
|
||||
: "hover:bg-gray-800/80 text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{repo.name}</span>
|
||||
{repo.artifactCount !== undefined && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{repo.artifactCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 flex flex-col bg-dark-card overflow-hidden">
|
||||
{!selectedRepository ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Select a repository"
|
||||
description="Choose a repository from the left panel to view artifacts."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-shrink-0 border-b border-dark-border p-5 bg-gradient-to-r from-gray-900 to-gray-850">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">Repository</p>
|
||||
<h2 className="text-2xl font-semibold text-white">
|
||||
{selectedRepository.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{selectedRegistryName || selectedRepository.registryId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => setFilter(option.value)}
|
||||
className={`px-3 py-1.5 text-xs rounded-full border transition ${
|
||||
filter === option.value
|
||||
? "bg-purple-600 text-white border-purple-500"
|
||||
: "border-gray-700 text-gray-300 hover:border-gray-500"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{artifactError && (
|
||||
<p className="text-sm text-red-400 mb-3">{artifactError}</p>
|
||||
)}
|
||||
{loadingArtifacts ? (
|
||||
<LoadingState message="Loading artifacts..." />
|
||||
) : artifacts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No artifacts"
|
||||
description={
|
||||
filter
|
||||
? `No ${filter} artifacts found for this repository.`
|
||||
: "This repository doesn't contain any artifacts yet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{artifacts.map((artifact, index) => (
|
||||
<TagCard
|
||||
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
|
||||
registryId={selectedRepository.registryId}
|
||||
tag={artifact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 [];
|
||||
}
|
||||
Reference in New Issue
Block a user