Files
ocdp-go/frontend/src/features/artifact/registries/pages/ArtifactBrowserPage.tsx
Ivan087 49b92e66c3 fix: UI redesign — horizontal instance rows, proper scaling, readable tag cards
- Backend: add replicas field to InstanceResponse (extracted from values.replicaCount)
- InstanceCard: complete redesign as horizontal row layout
  - Status bar | Name+Chart | Replicas +/- | Action buttons
  - Scale controls show for deployed AND failed statuses (scale to 0)
  - Fix replicas display using new instance.replicas backend field
- InstancesManagementPage: vertical row list + onScale callback to update state
- TagCard: restore proper padding (p-4), min-width, readable button sizes
- ArtifactBrowserPage: reduce grid density (sm:1 md:2 lg:3)
- ModifyModal: simplify to YAML-only editing with current values pre-populated
  - Remove schema-based form generator
  - Keep values-diff as collapsible reference panel
2026-05-13 12:30:52 +08:00

602 lines
22 KiB
TypeScript

/**
* 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<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>("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<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 }, { 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 (
<div className="h-[calc(100vh-8rem)] -m-6 flex flex-col">
<div className="flex-shrink-0 border-b border-slate-200 bg-white 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-blue-600" />
<div>
<h1 className="text-xl font-semibold text-slate-900">Chart Browser</h1>
<p className="text-sm text-slate-500">
Select a Harbor chart and launch it into a Kubernetes cluster
</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-slate-50">
{/* Collapsible side panel */}
<aside
className={`border-r border-slate-200 bg-white flex flex-col transition-all duration-200 ${
sidebarCollapsed ? "w-12" : "w-80"
}`}
>
{sidebarCollapsed ? (
/* Collapsed state: narrow strip with just a toggle */
<div className="flex flex-col items-center pt-3 gap-4">
<button
onClick={() => setSidebarCollapsed(false)}
className="p-1.5 hover:bg-slate-100 rounded-md transition"
title="Expand sidebar"
>
<ChevronRight className="w-4 h-4 text-slate-500" />
</button>
{registryNodes.slice(0, 5).map((node) => (
<div
key={node.registry.id || node.registry.name}
className="w-7 h-7 rounded-md bg-blue-50 flex items-center justify-center"
title={node.registry.name || "Registry"}
>
<Database className="w-3.5 h-3.5 text-blue-600" />
</div>
))}
{registryNodes.length > 5 && (
<span className="text-[10px] text-slate-400">
+{registryNodes.length - 5}
</span>
)}
</div>
) : (
/* Expanded state: full sidebar */
<>
<div className="p-4 border-b border-slate-200 space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => setSidebarCollapsed(true)}
className="p-1 hover:bg-slate-100 rounded-md transition flex-shrink-0"
title="Collapse sidebar"
>
<ChevronLeft className="w-4 h-4 text-slate-500" />
</button>
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<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-white border border-slate-200 text-sm text-slate-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{repositoryError && (
<p className="text-xs text-red-400">{repositoryError}</p>
)}
<div className="flex items-center justify-between text-xs text-slate-500">
<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 Harbor registry to browse deployable charts."
/>
</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-slate-50 transition"
>
<div className="flex items-center gap-2 min-w-0">
{node.expanded ? (
<ChevronDown className="w-4 h-4 text-slate-500 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-slate-500 flex-shrink-0" />
)}
<Database className="w-4 h-4 text-blue-600 flex-shrink-0" />
<div className="text-left min-w-0">
<p className="text-sm text-slate-900 truncate">
{node.registry.name || "Unnamed"}
</p>
<p className="text-[11px] text-slate-500 truncate">
{node.registry.url}
</p>
</div>
</div>
<Badge variant="secondary" className="flex-shrink-0">
{node.repositories.length}
</Badge>
</button>
{node.expanded && (
<div className="bg-slate-50/60">
{node.repositories.length === 0 ? (
<p className="px-8 py-3 text-xs text-slate-500">
{loadingRepositories
? "Loading repositories..."
: "No chart 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-blue-50 text-blue-700"
: "hover:bg-white/80 text-slate-700"
}`}
>
<span className="truncate">{repo.name}</span>
{repo.artifactCount !== undefined && (
<span className="text-xs text-slate-500 flex-shrink-0">
{repo.artifactCount}
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
))
)}
</div>
</>
)}
</aside>
<main className="flex-1 flex flex-col bg-white overflow-hidden">
{!selectedRepository ? (
/* Placeholder when no repo is selected */
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={LayoutGrid}
title="Select a chart"
description="Select a chart from the left panel to view versions"
/>
</div>
) : (
<>
{/* Right panel header */}
<div className="flex-shrink-0 border-b border-slate-200 p-5 bg-slate-50">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="text-xs uppercase text-slate-500">Chart repository</p>
<h2 className="text-2xl font-semibold text-slate-900 truncate">
{selectedRepository.name}
</h2>
<p className="text-sm text-slate-500">
{selectedRegistryName || selectedRepository.registryId}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-slate-500" />
{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-blue-600 text-white border-blue-600"
: "border-slate-200 text-slate-700 hover:border-slate-400"
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
{/* Tag search bar */}
<div className="flex-shrink-0 px-5 pt-4 pb-2">
<div className="relative max-w-xs">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Filter tags by version..."
value={tagSearchTerm}
onChange={(e) => 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"
/>
</div>
{tagSearchTerm && (
<p className="text-xs text-slate-500 mt-1.5">
Showing {filteredArtifacts.length} of {artifacts.length} tags
</p>
)}
</div>
{/* Tag grid */}
<div className="flex-1 overflow-y-auto p-5 pt-2">
{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} tags found for this repository.`
: "This repository doesn't contain any tagged artifacts yet."
}
/>
) : filteredArtifacts.length === 0 ? (
<EmptyState
icon={Search}
title="No matching tags"
description={`No tags matching "${tagSearchTerm}" found.`}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredArtifacts.map((artifact, index) => (
<TagCard
key={`${artifact.repositoryName || "repo"}-${artifact.tag || index}`}
registryId={selectedRepository.registryId}
registryUrl={selectedRegistryUrl}
tag={artifact}
isLatest={index === 0}
/>
))}
</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 [];
}