diff --git a/frontend/src/app/git-projects/repositories/page.tsx b/frontend/src/app/git-projects/repositories/page.tsx index 43aaef5..2037792 100644 --- a/frontend/src/app/git-projects/repositories/page.tsx +++ b/frontend/src/app/git-projects/repositories/page.tsx @@ -1,14 +1,37 @@ "use client"; -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { + AlertCircle, + CheckCircle2, + CircleDot, + Clock, + Copy, + ExternalLink, + GitBranch, + Loader2, + RefreshCw, + Search, + Settings, + Tags, + Webhook, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/auth/clerk"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable"; import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { getApiBaseUrl } from "@/lib/api-base"; import { getForgejoRepositories, deleteForgejoRepository, @@ -17,46 +40,420 @@ import { type ForgejoRepository, } from "@/lib/api-forgejo"; -export default function ForgejoRepositoriesPage() { - const router = useRouter(); - const auth = useAuth(); +type Notice = { + tone: "success" | "error"; + message: string; +}; +type RepositoryFilter = + | "all" + | "attention" + | "active" + | "webhooks" + | "archived"; + +const repositoryName = (repository: ForgejoRepository) => + repository.display_name || `${repository.owner}/${repository.repo}`; + +const formatTimestamp = (value: string | null) => { + if (!value) return "Never"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +}; + +const formatCompactNumber = (value: number) => + new Intl.NumberFormat(undefined, { notation: "compact" }).format(value); + +const labelColor = (color: string) => + color.startsWith("#") ? color : `#${color}`; + +function NoticeBanner({ notice }: { notice: Notice }) { + return ( +
+ {notice.tone === "success" ? ( + + ) : ( + + )} + {notice.message} +
+ ); +} + +function StatCard({ + icon, + label, + value, + caption, +}: { + icon: React.ReactNode; + label: string; + value: string; + caption: string; +}) { + return ( +
+
+
+

+ {label} +

+

{value}

+
+
+ {icon} +
+
+

{caption}

+
+ ); +} + +function RepositoryDetailsDialog({ + repository, + webhookBaseUrl, + onOpenChange, +}: { + repository: ForgejoRepository | null; + webhookBaseUrl: string; + onOpenChange: (open: boolean) => void; +}) { + const [copied, setCopied] = useState(false); + const webhookUrl = repository + ? webhookBaseUrl + ? `${webhookBaseUrl}/api/v1/forgejo/webhooks/${repository.id}` + : `.../api/v1/forgejo/webhooks/${repository.id}` + : ""; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(webhookUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard support is optional. + } + }; + + return ( + + + + + {repository ? repositoryName(repository) : "Repository details"} + + + {repository ? ( +
+
+ } + label="Open Issues" + value={String(repository.open_issues_count)} + caption="Reported upstream." + /> + } + label="Branch" + value={repository.default_branch || "Unknown"} + caption="Default branch." + /> + } + label="Webhook" + value={repository.has_webhook_secret ? "Ready" : "Missing"} + caption="Stored secret status." + /> + } + label="Synced" + value={formatTimestamp(repository.last_sync_at)} + caption="Last sync timestamp." + /> +
+ + {repository.last_sync_error ? ( +
+ + {repository.last_sync_error} +
+ ) : null} + + {repository.description ? ( +

+ {repository.description} +

+ ) : null} + +
+
+

+ Linked Boards +

+
+ {repository.linked_boards.length ? ( + repository.linked_boards.map((board) => ( + + {board.name} + + )) + ) : ( + + No linked boards. + + )} +
+
+
+

Topics

+
+ {repository.topics.length ? ( + repository.topics.map((topic) => ( + + + {topic} + + )) + ) : ( + No topics. + )} +
+
+
+ +
+

Labels

+
+ {repository.labels.length ? ( + repository.labels.map((label) => ( + + + {label.name} + + )) + ) : ( + No labels synced. + )} +
+
+ +
+
+
+

+ Webhook URL +

+ + {webhookUrl} + +
+ +
+
+
+ ) : null} +
+
+ ); +} + +export default function ForgejoRepositoriesPage() { + const auth = useAuth(); const [repositories, setRepositories] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isBulkSyncing, setIsBulkSyncing] = useState(false); const [error, setError] = useState(null); - const [notice, setNotice] = useState<{ - tone: "success" | "error"; - message: string; - } | null>(null); + const [notice, setNotice] = useState(null); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [selectedRepository, setSelectedRepository] = + useState(null); const [deleteTarget, setDeleteTarget] = useState( null, ); const [deleteError, setDeleteError] = useState(null); const [isDeleting, setIsDeleting] = useState(false); - useEffect(() => { - const fetchRepositories = async () => { - try { - setIsLoading(true); - const data = await getForgejoRepositories(); - setRepositories(data); - setError(null); - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to load repositories", - ); - } finally { - setIsLoading(false); - } - }; + const webhookBaseUrl = useMemo(() => { + try { + return getApiBaseUrl(); + } catch { + return ""; + } + }, []); + const fetchRepositories = useCallback(async () => { + try { + setIsLoading(true); + const data = await getForgejoRepositories(); + setRepositories(data); + setError(null); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load repositories", + ); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { if (auth.isSignedIn) { fetchRepositories(); } - }, [auth.isSignedIn]); + }, [auth.isSignedIn, fetchRepositories]); - const repositoryName = (repository: ForgejoRepository) => - repository.display_name || `${repository.owner}/${repository.repo}`; + useEffect(() => { + if (!notice) return; + const id = setTimeout(() => setNotice(null), 8000); + return () => clearTimeout(id); + }, [notice]); + + const activeRepositories = useMemo( + () => repositories.filter((repo) => repo.active).length, + [repositories], + ); + const totalOpenIssues = useMemo( + () => + repositories.reduce((total, repo) => total + repo.open_issues_count, 0), + [repositories], + ); + const webhookReady = useMemo( + () => + repositories.filter((repo) => repo.active && repo.has_webhook_secret) + .length, + [repositories], + ); + const syncErrors = useMemo( + () => repositories.filter((repo) => repo.last_sync_error).length, + [repositories], + ); + const attentionRepositories = useMemo( + () => + repositories.filter( + (repo) => + repo.last_sync_error || + (repo.active && !repo.has_webhook_secret) || + (repo.active && !repo.last_sync_at), + ), + [repositories], + ); + const archivedRepositories = useMemo( + () => repositories.filter((repo) => repo.is_archived).length, + [repositories], + ); + const linkedBoardCount = useMemo( + () => + repositories.reduce( + (total, repo) => total + repo.linked_boards.length, + 0, + ), + [repositories], + ); + const latestSync = useMemo(() => { + const dates = repositories + .map((repo) => repo.last_sync_at) + .filter((value): value is string => Boolean(value)) + .map((value) => new Date(value)) + .filter((date) => !Number.isNaN(date.getTime())); + if (!dates.length) return null; + return dates.reduce((latest, date) => + date.getTime() > latest.getTime() ? date : latest, + ); + }, [repositories]); + + const filteredRepositories = useMemo(() => { + const query = search.trim().toLowerCase(); + return repositories.filter((repo) => { + const matchesQuery = + !query || + [ + repositoryName(repo), + repo.owner, + repo.repo, + repo.connection?.name ?? "", + repo.connection?.base_url ?? "", + repo.default_branch, + ...repo.topics, + ...repo.linked_boards.map((board) => board.name), + ] + .join(" ") + .toLowerCase() + .includes(query); + + if (!matchesQuery) return false; + if (filter === "active") return repo.active; + if (filter === "webhooks") return repo.active && !repo.has_webhook_secret; + if (filter === "archived") return repo.is_archived; + if (filter === "attention") return attentionRepositories.includes(repo); + return true; + }); + }, [attentionRepositories, filter, repositories, search]); + + const filterOptions = useMemo( + () => [ + { key: "all" as const, label: "All", count: repositories.length }, + { + key: "attention" as const, + label: "Attention", + count: attentionRepositories.length, + }, + { key: "active" as const, label: "Active", count: activeRepositories }, + { + key: "webhooks" as const, + label: "Missing Webhooks", + count: repositories.filter( + (repo) => repo.active && !repo.has_webhook_secret, + ).length, + }, + { + key: "archived" as const, + label: "Archived", + count: archivedRepositories, + }, + ], + [ + activeRepositories, + archivedRepositories, + attentionRepositories.length, + repositories, + ], + ); const handleDelete = (repository: ForgejoRepository) => { setDeleteError(null); @@ -105,6 +502,38 @@ export default function ForgejoRepositoriesPage() { } }; + const handleSyncAttention = async () => { + const targets = attentionRepositories.filter((repo) => repo.active); + if (!targets.length) return; + setIsBulkSyncing(true); + let succeeded = 0; + let failed = 0; + await Promise.allSettled( + targets.map(async (repo) => { + try { + await syncRepository(repo.id); + succeeded++; + } catch { + failed++; + } + }), + ); + const data = await getForgejoRepositories(); + setRepositories(data); + setIsBulkSyncing(false); + setNotice( + failed === 0 + ? { + tone: "success", + message: `${succeeded} repositor${succeeded === 1 ? "y" : "ies"} synced successfully.`, + } + : { + tone: "error", + message: `Sync completed: ${succeeded} succeeded, ${failed} failed.`, + }, + ); + }; + const handleValidateRepository = async (repository: ForgejoRepository) => { try { const result = await validateRepository(repository.id); @@ -141,33 +570,140 @@ export default function ForgejoRepositoriesPage() { title="Git Project Repositories" description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked by Pipeline.`} stickyHeader - > -
- {notice ? ( -
- {notice.tone === "success" ? ( - - ) : ( - - )} - {notice.message} -
- ) : null} - -
-

Repositories

+ headerActions={ +
+ + + + + +
+ } + > +
+ {notice ? : null} + +
+
+
+ + {attentionRepositories.length + ? `${attentionRepositories.length} need review` + : "Healthy"} + +

+ Repository Operations +

+

+ Track provider reachability, webhook coverage, sync freshness, + and board usage from one management surface. +

+
+
+ + + + +
+
+
+ } + label="Repositories" + value={`${activeRepositories}/${repositories.length}`} + caption="Active tracked repositories." + /> + } + label="Open Issues" + value={formatCompactNumber(totalOpenIssues)} + caption="Reported by Forgejo." + /> + } + label="Webhooks" + value={`${webhookReady}/${activeRepositories}`} + caption="Active repositories with secrets." + /> + } + label="Latest Sync" + value={formatTimestamp(latestSync?.toISOString() ?? null)} + caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`} + /> +
+
+ +
+
+
+ + setSearch(event.target.value)} + placeholder="Search repositories, connections, topics, boards..." + className="pl-9" + /> +
+
+ {filterOptions.map((option) => ( + + ))} +
+
+
+
{error ? (
@@ -175,9 +711,10 @@ export default function ForgejoRepositoriesPage() {
) : ( @@ -185,6 +722,13 @@ export default function ForgejoRepositoriesPage() {
+ { + if (!open) setSelectedRepository(null); + }} + /> {