diff --git a/frontend/src/app/boards/page.tsx b/frontend/src/app/boards/page.tsx index 6ab4e5c..4424cf8 100644 --- a/frontend/src/app/boards/page.tsx +++ b/frontend/src/app/boards/page.tsx @@ -4,6 +4,7 @@ export const dynamic = "force-dynamic"; import { useMemo, useState } from "react"; import Link from "next/link"; +import { Folder, Plus } from "lucide-react"; import { useAuth } from "@/auth/clerk"; import { useQueryClient } from "@tanstack/react-query"; @@ -120,17 +121,30 @@ export default function BoardsPage() { title="Boards" description={`Manage boards and task workflows. ${boards.length} board${boards.length === 1 ? "" : "s"} total.`} headerActions={ - boards.length > 0 && isAdmin ? ( +
- Create board + + Groups - ) : null + {boards.length > 0 && isAdmin ? ( + + + Create board + + ) : null} +
} stickyHeader > diff --git a/frontend/src/app/git-projects/page.tsx b/frontend/src/app/git-projects/page.tsx index c731487..b5c6b67 100644 --- a/frontend/src/app/git-projects/page.tsx +++ b/frontend/src/app/git-projects/page.tsx @@ -1,253 +1,818 @@ "use client"; -export const dynamic = "force-dynamic"; - -import { useMemo, useState, useEffect, useCallback } from "react"; - import { - type ColumnDef, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; -import { AlertCircle, CheckCircle2, GitBranch, RefreshCw } from "lucide-react"; - + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import Link from "next/link"; +import { + AlertCircle, + CheckCircle2, + CircleDot, + Clock, + Copy, + GitBranch, + Loader2, + RefreshCw, + Search, + Settings, + Tags, + Webhook, +} from "lucide-react"; -import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; -import { DataTable } from "@/components/tables/DataTable"; -import { Badge } from "@/components/ui/badge"; 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, - type ForgejoRepository, + deleteForgejoRepository, syncRepository, + validateRepository, + type ForgejoRepository, } from "@/lib/api-forgejo"; -export default function GitProjectsPage() { - const [repositories, setRepositories] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [syncingId, setSyncingId] = useState(null); - const [syncResult, setSyncResult] = useState<{ - repoName: string; - created: number; - updated: number; - open: number; - closed: number; - error?: string; - } | null>(null); +type Notice = { + tone: "success" | "error"; + message: string; +}; - const fetchRepos = useCallback(async () => { +type RepositoryFilter = + | "all" + | "attention" + | "active" + | "webhooks" + | "archived"; + +type StatTone = "blue" | "green" | "amber" | "rose" | "violet"; + +const statToneClasses: Record< + StatTone, + { card: string; icon: string; glow: string } +> = { + blue: { + card: "border-[color:rgba(96,165,250,0.34)] bg-[linear-gradient(145deg,rgba(96,165,250,0.16),rgba(15,23,36,0.94)_42%,var(--surface))]", + icon: "border-[color:rgba(96,165,250,0.3)] bg-[color:rgba(96,165,250,0.14)] text-[color:var(--accent-strong)]", + glow: "bg-[color:rgba(96,165,250,0.22)]", + }, + green: { + card: "border-[color:rgba(52,211,153,0.34)] bg-[linear-gradient(145deg,rgba(52,211,153,0.15),rgba(15,23,36,0.94)_42%,var(--surface))]", + icon: "border-[color:rgba(52,211,153,0.3)] bg-[color:rgba(52,211,153,0.14)] text-[color:var(--success)]", + glow: "bg-[color:rgba(52,211,153,0.22)]", + }, + amber: { + card: "border-[color:rgba(251,191,36,0.36)] bg-[linear-gradient(145deg,rgba(251,191,36,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]", + icon: "border-[color:rgba(251,191,36,0.32)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]", + glow: "bg-[color:rgba(251,191,36,0.2)]", + }, + rose: { + card: "border-[color:rgba(248,113,113,0.34)] bg-[linear-gradient(145deg,rgba(248,113,113,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]", + icon: "border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.13)] text-[color:var(--danger)]", + glow: "bg-[color:rgba(248,113,113,0.2)]", + }, + violet: { + card: "border-[color:rgba(168,85,247,0.32)] bg-[linear-gradient(145deg,rgba(168,85,247,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]", + icon: "border-[color:rgba(168,85,247,0.28)] bg-[color:rgba(168,85,247,0.13)] text-[color:rgb(196,181,253)]", + glow: "bg-[color:rgba(168,85,247,0.2)]", + }, +}; + +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, + tone = "blue", +}: { + icon: ReactNode; + label: string; + value: string; + caption: string; + tone?: StatTone; +}) { + const colors = statToneClasses[tone]; + 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 { - const repos = await getForgejoRepositories(); - setRepositories(repos); + 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." + tone="amber" + /> + } + label="Branch" + value={repository.default_branch || "Unknown"} + caption="Default branch." + tone="blue" + /> + } + label="Webhook" + value={repository.has_webhook_secret ? "Ready" : "Missing"} + caption="Stored secret status." + tone={repository.has_webhook_secret ? "green" : "rose"} + /> + } + label="Synced" + value={formatTimestamp(repository.last_sync_at)} + caption="Last sync timestamp." + tone={repository.last_sync_error ? "rose" : "violet"} + /> +
+ + {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(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); + + 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 - : "Pipeline could not load Git Projects.", + err instanceof Error ? err.message : "Failed to load repositories", ); } finally { - setLoading(false); + setIsLoading(false); } }, []); useEffect(() => { - fetchRepos(); - }, [fetchRepos]); + if (auth.isSignedIn) { + fetchRepositories(); + } + }, [auth.isSignedIn, fetchRepositories]); - const handleSync = useCallback( - async (repo: ForgejoRepository) => { - setSyncingId(repo.id); - setSyncResult(null); - try { - const result = await syncRepository(repo.id); - setSyncResult({ - repoName: `${repo.owner}/${repo.repo}`, - created: result.created, - updated: result.updated, - open: result.open, - closed: result.closed, - }); - await fetchRepos(); - } catch (err) { - setSyncResult({ - repoName: `${repo.owner}/${repo.repo}`, - created: 0, - updated: 0, - open: 0, - closed: 0, - error: err instanceof Error ? err.message : "Sync failed.", - }); - } finally { - setSyncingId(null); - } - }, - [fetchRepos], + 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 columns: ColumnDef[] = useMemo( + 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 }, { - accessorKey: "display_name", - header: "Repository", - cell: ({ row }) => { - const repo = row.original; - const name = repo.display_name || `${repo.owner}/${repo.repo}`; - return ( -
-
- {name} -
-
- {repo.owner}/{repo.repo} -
-
- ); - }, + 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, }, { - accessorKey: "connection", - header: "Connection", - cell: ({ row }) => { - const conn = row.original.connection; - return ( - - {conn ? conn.name : "Unassigned"} - - ); - }, - }, - { - accessorKey: "active", - header: "Status", - cell: ({ row }) => ( - - {row.original.active ? "Active" : "Inactive"} - - ), - }, - { - accessorKey: "last_sync_at", - header: "Last Synced", - cell: ({ row }) => { - const val = row.original.last_sync_at; - if (!val) return Never; - try { - return ( - - {new Date(val).toLocaleString()} - - ); - } catch { - return {val}; - } - }, - }, - { - id: "actions", - header: "", - cell: ({ row }) => { - const repo = row.original; - const isSyncing = syncingId === repo.id; - return ( - - ); - }, + key: "archived" as const, + label: "Archived", + count: archivedRepositories, }, ], - [handleSync, syncingId], + [ + activeRepositories, + archivedRepositories, + attentionRepositories.length, + repositories, + ], ); - const table = useReactTable({ - data: repositories, - columns, - getCoreRowModel: getCoreRowModel(), - }); + const handleDelete = (repository: ForgejoRepository) => { + setDeleteError(null); + setDeleteTarget(repository); + }; + + const confirmDelete = async () => { + if (!deleteTarget) return; + setIsDeleting(true); + setDeleteError(null); + try { + await deleteForgejoRepository(deleteTarget.id); + setRepositories((prev) => prev.filter((r) => r.id !== deleteTarget.id)); + setNotice({ + tone: "success", + message: `Deleted "${repositoryName(deleteTarget)}".`, + }); + setDeleteTarget(null); + } catch (err) { + setDeleteError( + err instanceof Error ? err.message : "Failed to delete repository", + ); + } finally { + setIsDeleting(false); + } + }; + + const handleSync = async (repository: ForgejoRepository) => { + try { + const result = await syncRepository(repository.id); + setNotice({ + tone: "success", + message: `${repositoryName(repository)} synced: ${result.created} created, ${result.updated} updated, ${result.open} open, ${result.closed} closed.`, + }); + // Refetch to update last_sync_at + const data = await getForgejoRepositories(); + setRepositories(data); + return result; + } catch (err) { + setNotice({ + tone: "error", + message: + err instanceof Error ? err.message : "Failed to sync repository", + }); + throw err; + } + }; + + 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); + if (result.status.ok) { + setNotice({ + tone: "success", + message: `${repositoryName(repository)} is reachable from Pipeline.`, + }); + } else { + setNotice({ + tone: "error", + message: `Repository validation failed: ${result.status.error_message || "Unknown error"}`, + }); + } + return result; + } catch (err) { + setNotice({ + tone: "error", + message: + err instanceof Error ? err.message : "Failed to validate repository", + }); + throw err; + } + }; return ( - - {syncResult && ( -
- {syncResult.error ? ( - - ) : ( - - )} -
- {syncResult.repoName}{" "} - {syncResult.error ? ( - {syncResult.error} + <> + + + + + + + + +
+ } + > +
+ {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." + tone="blue" + /> + } + label="Open Issues" + value={formatCompactNumber(totalOpenIssues)} + caption="Reported by Forgejo." + tone="amber" + /> + } + label="Webhooks" + value={`${webhookReady}/${activeRepositories}`} + caption="Active repositories with secrets." + tone={webhookReady === activeRepositories ? "green" : "rose"} + /> + } + label="Latest Sync" + value={formatTimestamp(latestSync?.toISOString() ?? null)} + caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`} + tone={syncErrors ? "rose" : "violet"} + /> +
+
+ +
+
+
+ + setSearch(event.target.value)} + placeholder="Search repositories, connections, topics, boards..." + className="pl-9" + /> +
+
+ {filterOptions.map((option) => ( + + ))} +
+
+
+ +
+ {error ? ( +
+

{error}

+
) : ( - - synced: {syncResult.created} created, {syncResult.updated}{" "} - updated, {syncResult.open} open, {syncResult.closed} closed - + )}
- )} - -
- - - - - - -
- - {error ? ( -
- {error} -
- ) : null} - -
- , - title: "No repositories tracked yet", - description: - "Connect a Git provider and add repositories so Pipeline can track issues for Git Projects.", - actionHref: "/git-projects/connections", - actionLabel: "Set up connection", - }} - /> -
- + + { + if (!open) setSelectedRepository(null); + }} + /> + { + if (!open) setDeleteTarget(null); + }} + title="Delete Git Project repository" + description={ + deleteTarget + ? `Delete "${repositoryName(deleteTarget)}" from Pipeline? Synced issue records for this repository will be removed.` + : "" + } + onConfirm={confirmDelete} + isConfirming={isDeleting} + errorMessage={deleteError} + confirmLabel="Delete Repository" + confirmingLabel="Deleting…" + confirmClassName="bg-destructive text-destructive-foreground hover:bg-destructive/90" + cancelLabel="Keep Repository" + /> + ); } diff --git a/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx b/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx index c4ee976..23d246f 100644 --- a/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx +++ b/frontend/src/app/git-projects/repositories/[repositoryId]/edit/page.tsx @@ -73,7 +73,7 @@ export default function ForgejoRepositoriesEditPage({ const handleSubmit = async (values: ForgejoRepositoryUpdate) => { await updateForgejoRepository(params.repositoryId, values); - router.push("/git-projects/repositories"); + router.push("/git-projects"); }; const handleDelete = async () => { @@ -81,7 +81,7 @@ export default function ForgejoRepositoriesEditPage({ setDeleteError(null); try { await deleteForgejoRepository(params.repositoryId); - router.push("/git-projects/repositories"); + router.push("/git-projects"); } catch (err) { setDeleteError( err instanceof Error ? err.message : "Failed to delete repository", @@ -96,8 +96,8 @@ export default function ForgejoRepositoriesEditPage({ { await createForgejoRepository(values); - router.push("/git-projects/repositories"); + router.push("/git-projects"); }; return ( diff --git a/frontend/src/app/git-projects/repositories/page.tsx b/frontend/src/app/git-projects/repositories/page.tsx index 004e74a..fd9f2f9 100644 --- a/frontend/src/app/git-projects/repositories/page.tsx +++ b/frontend/src/app/git-projects/repositories/page.tsx @@ -1,819 +1,5 @@ -"use client"; +import { redirect } from "next/navigation"; -import { - type ReactNode, - 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, - syncRepository, - validateRepository, - type ForgejoRepository, -} from "@/lib/api-forgejo"; - -type Notice = { - tone: "success" | "error"; - message: string; -}; - -type RepositoryFilter = - | "all" - | "attention" - | "active" - | "webhooks" - | "archived"; - -type StatTone = "blue" | "green" | "amber" | "rose" | "violet"; - -const statToneClasses: Record< - StatTone, - { card: string; icon: string; glow: string } -> = { - blue: { - card: "border-[color:rgba(96,165,250,0.34)] bg-[linear-gradient(145deg,rgba(96,165,250,0.16),rgba(15,23,36,0.94)_42%,var(--surface))]", - icon: "border-[color:rgba(96,165,250,0.3)] bg-[color:rgba(96,165,250,0.14)] text-[color:var(--accent-strong)]", - glow: "bg-[color:rgba(96,165,250,0.22)]", - }, - green: { - card: "border-[color:rgba(52,211,153,0.34)] bg-[linear-gradient(145deg,rgba(52,211,153,0.15),rgba(15,23,36,0.94)_42%,var(--surface))]", - icon: "border-[color:rgba(52,211,153,0.3)] bg-[color:rgba(52,211,153,0.14)] text-[color:var(--success)]", - glow: "bg-[color:rgba(52,211,153,0.22)]", - }, - amber: { - card: "border-[color:rgba(251,191,36,0.36)] bg-[linear-gradient(145deg,rgba(251,191,36,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]", - icon: "border-[color:rgba(251,191,36,0.32)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]", - glow: "bg-[color:rgba(251,191,36,0.2)]", - }, - rose: { - card: "border-[color:rgba(248,113,113,0.34)] bg-[linear-gradient(145deg,rgba(248,113,113,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]", - icon: "border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.13)] text-[color:var(--danger)]", - glow: "bg-[color:rgba(248,113,113,0.2)]", - }, - violet: { - card: "border-[color:rgba(168,85,247,0.32)] bg-[linear-gradient(145deg,rgba(168,85,247,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]", - icon: "border-[color:rgba(168,85,247,0.28)] bg-[color:rgba(168,85,247,0.13)] text-[color:rgb(196,181,253)]", - glow: "bg-[color:rgba(168,85,247,0.2)]", - }, -}; - -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, - tone = "blue", -}: { - icon: ReactNode; - label: string; - value: string; - caption: string; - tone?: StatTone; -}) { - const colors = statToneClasses[tone]; - 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." - tone="amber" - /> - } - label="Branch" - value={repository.default_branch || "Unknown"} - caption="Default branch." - tone="blue" - /> - } - label="Webhook" - value={repository.has_webhook_secret ? "Ready" : "Missing"} - caption="Stored secret status." - tone={repository.has_webhook_secret ? "green" : "rose"} - /> - } - label="Synced" - value={formatTimestamp(repository.last_sync_at)} - caption="Last sync timestamp." - tone={repository.last_sync_error ? "rose" : "violet"} - /> -
- - {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(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); - - 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, fetchRepositories]); - - 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); - setDeleteTarget(repository); - }; - - const confirmDelete = async () => { - if (!deleteTarget) return; - setIsDeleting(true); - setDeleteError(null); - try { - await deleteForgejoRepository(deleteTarget.id); - setRepositories((prev) => prev.filter((r) => r.id !== deleteTarget.id)); - setNotice({ - tone: "success", - message: `Deleted "${repositoryName(deleteTarget)}".`, - }); - setDeleteTarget(null); - } catch (err) { - setDeleteError( - err instanceof Error ? err.message : "Failed to delete repository", - ); - } finally { - setIsDeleting(false); - } - }; - - const handleSync = async (repository: ForgejoRepository) => { - try { - const result = await syncRepository(repository.id); - setNotice({ - tone: "success", - message: `${repositoryName(repository)} synced: ${result.created} created, ${result.updated} updated, ${result.open} open, ${result.closed} closed.`, - }); - // Refetch to update last_sync_at - const data = await getForgejoRepositories(); - setRepositories(data); - return result; - } catch (err) { - setNotice({ - tone: "error", - message: - err instanceof Error ? err.message : "Failed to sync repository", - }); - throw err; - } - }; - - 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); - if (result.status.ok) { - setNotice({ - tone: "success", - message: `${repositoryName(repository)} is reachable from Pipeline.`, - }); - } else { - setNotice({ - tone: "error", - message: `Repository validation failed: ${result.status.error_message || "Unknown error"}`, - }); - } - return result; - } catch (err) { - setNotice({ - tone: "error", - message: - err instanceof Error ? err.message : "Failed to validate repository", - }); - throw err; - } - }; - - return ( - <> - - - - - - - - -
- } - > -
- {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." - tone="blue" - /> - } - label="Open Issues" - value={formatCompactNumber(totalOpenIssues)} - caption="Reported by Forgejo." - tone="amber" - /> - } - label="Webhooks" - value={`${webhookReady}/${activeRepositories}`} - caption="Active repositories with secrets." - tone={webhookReady === activeRepositories ? "green" : "rose"} - /> - } - label="Latest Sync" - value={formatTimestamp(latestSync?.toISOString() ?? null)} - caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`} - tone={syncErrors ? "rose" : "violet"} - /> -
-
- -
-
-
- - setSearch(event.target.value)} - placeholder="Search repositories, connections, topics, boards..." - className="pl-9" - /> -
-
- {filterOptions.map((option) => ( - - ))} -
-
-
- -
- {error ? ( -
-

{error}

-
- ) : ( - - )} -
-
-
- { - if (!open) setSelectedRepository(null); - }} - /> - { - if (!open) setDeleteTarget(null); - }} - title="Delete Git Project repository" - description={ - deleteTarget - ? `Delete "${repositoryName(deleteTarget)}" from Pipeline? Synced issue records for this repository will be removed.` - : "" - } - onConfirm={confirmDelete} - isConfirming={isDeleting} - errorMessage={deleteError} - confirmLabel="Delete Repository" - confirmingLabel="Deleting…" - confirmClassName="bg-destructive text-destructive-foreground hover:bg-destructive/90" - cancelLabel="Keep Repository" - /> - - ); +export default function GitProjectRepositoriesRedirectPage() { + redirect("/git-projects"); } diff --git a/frontend/src/app/settings/git-projects/page.tsx b/frontend/src/app/settings/git-projects/page.tsx index b1d21c8..004d227 100644 --- a/frontend/src/app/settings/git-projects/page.tsx +++ b/frontend/src/app/settings/git-projects/page.tsx @@ -1340,7 +1340,7 @@ export default function GitProjectSettingsPage() { , and paste a secret. Store the same secret on the repository record in Pipeline via{" "} Edit Repository diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index f34ae9d..79defa6 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -11,14 +11,21 @@ import { useQueryClient } from "@tanstack/react-query"; import { ArrowRight, Bot, + Building2, + Folder, GitBranch, Globe, + KeyRound, Mail, + Network, RefreshCw, RotateCcw, Save, + SlidersHorizontal, + Tags, Trash2, User, + type LucideIcon, } from "lucide-react"; import { @@ -35,16 +42,71 @@ import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; import { Input } from "@/components/ui/input"; import SearchableSelect from "@/components/ui/searchable-select"; import { getSupportedTimezones } from "@/lib/timezones"; +import { useOrganizationMembership } from "@/lib/use-organization-membership"; type ClerkGlobal = { signOut?: (options?: { redirectUrl?: string }) => Promise | void; }; +type SettingsLink = { + href: string; + label: string; + description: string; + icon: LucideIcon; +}; + +function SettingsLinkSection({ + title, + description, + items, +}: { + title: string; + description: string; + items: SettingsLink[]; +}) { + if (items.length === 0) return null; + + return ( +
+
+

{title}

+

{description}

+
+
+ {items.map((item) => { + const Icon = item.icon; + return ( + + + + + + + {item.label} + + + {item.description} + + + + + ); + })} +
+
+ ); +} + export default function SettingsPage() { const router = useRouter(); const queryClient = useQueryClient(); const { isSignedIn } = useAuth(); const { user } = useUser(); + const { isAdmin } = useOrganizationMembership(isSignedIn); const [name, setName] = useState(""); const [timezone, setTimezone] = useState(null); @@ -56,7 +118,9 @@ export default function SettingsPage() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [forgejoSyncing, setForgejoSyncing] = useState(false); const [forgejoSyncError, setForgejoSyncError] = useState(null); - const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState(null); + const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState( + null, + ); const meQuery = useGetMeApiV1UsersMeGet< getMeApiV1UsersMeGetResponse, @@ -141,7 +205,9 @@ export default function SettingsPage() { }; const handleDisableForgejoProfile = async () => { - await updateMeMutation.mutateAsync({ data: { use_forgejo_profile: false } }); + await updateMeMutation.mutateAsync({ + data: { use_forgejo_profile: false }, + }); setForgejoSyncSuccess(null); setForgejoSyncError(null); }; @@ -175,6 +241,80 @@ export default function SettingsPage() { const isSaving = updateMeMutation.isPending; + const workspaceItems: SettingsLink[] = [ + { + href: "/organization", + label: "Organization", + description: "Manage members, invites, access, and organization details.", + icon: Building2, + }, + { + href: "/board-groups", + label: "Board groups", + description: + "Group related boards so agents and operators share context.", + icon: Folder, + }, + ...(isAdmin + ? [ + { + href: "/tags", + label: "Tags", + description: "Maintain reusable task labels used across boards.", + icon: Tags, + }, + { + href: "/custom-fields", + label: "Custom fields", + description: + "Configure organization-level task metadata and board bindings.", + icon: SlidersHorizontal, + }, + ] + : []), + ]; + + const integrationItems: SettingsLink[] = [ + { + href: "/settings/ai-providers", + label: "AI providers", + description: + "Manage model credentials, endpoints, and provider usage tracking.", + icon: KeyRound, + }, + { + href: "/settings/git-projects", + label: "Git project settings", + description: + "Review Forgejo sync health and Git Project automation settings.", + icon: GitBranch, + }, + { + href: "/git-projects/connections", + label: "Git connections", + description: "Connect Git providers used for repository and issue sync.", + icon: GitBranch, + }, + ]; + + const operationItems: SettingsLink[] = isAdmin + ? [ + { + href: "/agents", + label: "Agents", + description: + "Provision and monitor the agents available to this organization.", + icon: Bot, + }, + { + href: "/gateways", + label: "Gateways", + description: "Manage gateway connections used by boards and agents.", + icon: Network, + }, + ] + : []; + return ( <>
@@ -351,48 +491,23 @@ export default function SettingsPage() {
-
-
-
-

- - AI Providers -

-

- API keys and endpoints for Claude, Codex/OpenAI, and Ollama - (local, on-prem, or cloud). Add multiple accounts to track - usage separately. -

-
- - - -
-
+ -
-
-
-

- - Git Projects -

-

- Manage Forgejo connections, tracked repositories, and issue - sync. -

-
- - - -
-
+ + +

diff --git a/frontend/src/components/git/AssignIssueAgentDialog.tsx b/frontend/src/components/git/AssignIssueAgentDialog.tsx index 4583f9a..b7a31d5 100644 --- a/frontend/src/components/git/AssignIssueAgentDialog.tsx +++ b/frontend/src/components/git/AssignIssueAgentDialog.tsx @@ -330,7 +330,7 @@ export function AssignIssueAgentDialog({ {isLinkingRepository ? "Linking..." : "Link and continue"} Manage Git repositories diff --git a/frontend/src/components/git/ForgejoIssueMetricCards.tsx b/frontend/src/components/git/ForgejoIssueMetricCards.tsx index f990a3b..fbc8e4c 100644 --- a/frontend/src/components/git/ForgejoIssueMetricCards.tsx +++ b/frontend/src/components/git/ForgejoIssueMetricCards.tsx @@ -92,32 +92,42 @@ const repositoryLabel = (repository: ForgejoRepository): string => // ── Tone → icon badge (background + text color) ──────────────────────────── const toneIconClasses: Record = { - amber: "border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]", - success: "border-[color:rgba(16,185,129,0.35)] bg-[color:rgba(16,185,129,0.14)] text-[color:#10B981]", - danger: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.14)] text-[color:var(--danger)]", - cyan: "border-[color:rgba(6,182,212,0.35)] bg-[color:rgba(6,182,212,0.14)] text-[color:#06B6D4]", - slate: "border-[color:rgba(100,116,139,0.35)] bg-[color:rgba(100,116,139,0.12)] text-[color:#64748B]", - muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted", + amber: + "border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]", + success: + "border-[color:rgba(16,185,129,0.35)] bg-[color:rgba(16,185,129,0.14)] text-[color:#10B981]", + danger: + "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.14)] text-[color:var(--danger)]", + cyan: "border-[color:rgba(6,182,212,0.35)] bg-[color:rgba(6,182,212,0.14)] text-[color:#06B6D4]", + slate: + "border-[color:rgba(100,116,139,0.35)] bg-[color:rgba(100,116,139,0.12)] text-[color:#64748B]", + muted: + "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted", }; // ── Tone → card (full background + border + hover glow) ─────────────────── const toneCardClasses: Record = { - amber: "border-[color:rgba(245,158,11,0.28)] bg-[color:rgba(245,158,11,0.06)] hover:border-[color:rgba(245,158,11,0.55)] hover:shadow-[0_4px_24px_rgba(245,158,11,0.18)]", - success: "border-[color:rgba(16,185,129,0.28)] bg-[color:rgba(16,185,129,0.06)] hover:border-[color:rgba(16,185,129,0.55)] hover:shadow-[0_4px_24px_rgba(16,185,129,0.18)]", - danger: "border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.06)] hover:border-[color:rgba(248,113,113,0.55)] hover:shadow-[0_4px_24px_rgba(248,113,113,0.18)]", - cyan: "border-[color:rgba(6,182,212,0.28)] bg-[color:rgba(6,182,212,0.06)] hover:border-[color:rgba(6,182,212,0.55)] hover:shadow-[0_4px_24px_rgba(6,182,212,0.18)]", - slate: "border-[color:rgba(100,116,139,0.22)] bg-[color:rgba(100,116,139,0.05)] hover:border-[color:rgba(100,116,139,0.40)] hover:shadow-[0_4px_16px_rgba(100,116,139,0.12)]", - muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] hover:border-[color:var(--border-strong)] hover:shadow-sm", + amber: + "border-[color:rgba(245,158,11,0.28)] bg-[color:rgba(245,158,11,0.06)] hover:border-[color:rgba(245,158,11,0.55)] hover:shadow-[0_4px_24px_rgba(245,158,11,0.18)]", + success: + "border-[color:rgba(16,185,129,0.28)] bg-[color:rgba(16,185,129,0.06)] hover:border-[color:rgba(16,185,129,0.55)] hover:shadow-[0_4px_24px_rgba(16,185,129,0.18)]", + danger: + "border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.06)] hover:border-[color:rgba(248,113,113,0.55)] hover:shadow-[0_4px_24px_rgba(248,113,113,0.18)]", + cyan: "border-[color:rgba(6,182,212,0.28)] bg-[color:rgba(6,182,212,0.06)] hover:border-[color:rgba(6,182,212,0.55)] hover:shadow-[0_4px_24px_rgba(6,182,212,0.18)]", + slate: + "border-[color:rgba(100,116,139,0.22)] bg-[color:rgba(100,116,139,0.05)] hover:border-[color:rgba(100,116,139,0.40)] hover:shadow-[0_4px_16px_rgba(100,116,139,0.12)]", + muted: + "border-[color:var(--border)] bg-[color:var(--surface-muted)] hover:border-[color:var(--border-strong)] hover:shadow-sm", }; // ── Tone → value text color ──────────────────────────────────────────────── const toneValueClasses: Record = { - amber: "text-[color:#F59E0B]", + amber: "text-[color:#F59E0B]", success: "text-[color:#10B981]", - danger: "text-[color:var(--danger)]", - cyan: "text-[color:#06B6D4]", - slate: "text-[color:#64748B]", - muted: "text-strong", + danger: "text-[color:var(--danger)]", + cyan: "text-[color:#06B6D4]", + slate: "text-[color:#64748B]", + muted: "text-strong", }; // ── Card builder: Last Sync Health ───────────────────────────────────────── @@ -132,25 +142,28 @@ function buildSyncHealthCard( const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter( (count) => Number(count) > 0, ).length; - const metricSyncDates = Object.values(metrics?.last_sync_timestamps ?? {}).flatMap( - (v) => { const d = parseDate(v); return d ? [d] : []; }, - ); - const repositorySyncDates = repositories.flatMap( - (r) => { const d = parseDate(r.last_sync_at); return d ? [d] : []; }, - ); + const metricSyncDates = Object.values( + metrics?.last_sync_timestamps ?? {}, + ).flatMap((v) => { + const d = parseDate(v); + return d ? [d] : []; + }); + const repositorySyncDates = repositories.flatMap((r) => { + const d = parseDate(r.last_sync_at); + return d ? [d] : []; + }); const latestSync = newestDate( metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates, ); - const latestSyncAge = latestSync && nowMs != null - ? Math.max(0, nowMs - latestSync.getTime()) - : 0; + const latestSyncAge = + latestSync && nowMs != null ? Math.max(0, nowMs - latestSync.getTime()) : 0; if (repositoryCount === 0) { return { title: "Last Sync", value: "No repos", caption: "Add repositories to track sync status.", - href: "/git-projects/repositories", + href: "/git-projects", tone: "muted", icon: RefreshCw, }; @@ -160,7 +173,7 @@ function buildSyncHealthCard( title: "Last Sync", value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`, caption: "Repository sync needs attention.", - href: "/git-projects/repositories", + href: "/git-projects", tone: "danger", icon: ShieldAlert, action: { @@ -175,7 +188,7 @@ function buildSyncHealthCard( title: "Last Sync", value: "Waiting", caption: "Repositories have not synced yet.", - href: "/git-projects/repositories", + href: "/git-projects", tone: "slate", icon: Clock3, action: { @@ -190,7 +203,7 @@ function buildSyncHealthCard( title: "Last Sync", value: "Stale", caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`, - href: "/git-projects/repositories", + href: "/git-projects", tone: "amber", icon: Clock3, action: { @@ -204,7 +217,7 @@ function buildSyncHealthCard( title: "Last Sync", value: "Healthy", caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`, - href: "/git-projects/repositories", + href: "/git-projects", tone: "cyan", icon: ShieldCheck, action: { @@ -292,7 +305,10 @@ function MetricCardLink({ card }: { card: MetricCard }) { aria-label={card.action.label} > ) : ( @@ -328,16 +344,15 @@ export function ForgejoIssueMetricCards({ }, []); const activeRepositories = metricRepositories ?? repositories; - const openIssues = metrics?.open_issues ?? 0; + const openIssues = metrics?.open_issues ?? 0; const recentlyClosed = metrics?.closed_last_7_days ?? 0; - const staleOpen = metrics?.stale_open_issues ?? 0; - const repositoriesSynced = metrics?.repositories_synced ?? activeRepositories.length; + const staleOpen = metrics?.stale_open_issues ?? 0; + const repositoriesSynced = + metrics?.repositories_synced ?? activeRepositories.length; // Stale: 0 → slate, 1–5 → amber, >5 → danger const staleTone: MetricTone = - staleOpen === 0 ? "slate" : - staleOpen <= 5 ? "amber" : - "danger"; + staleOpen === 0 ? "slate" : staleOpen <= 5 ? "amber" : "danger"; const issueHref = (params: string): string => { const repositoryParam = selectedRepositoryId === ALL_REPOSITORIES_VALUE @@ -394,7 +409,8 @@ export function ForgejoIssueMetricCards({ Git Project Issue Tracking

- High-level Forgejo issue health across repositories synced into Pipeline. + High-level Forgejo issue health across repositories synced into + Pipeline.

@@ -407,7 +423,9 @@ export function ForgejoIssueMetricCards({ - All projects + + All projects + {repositories.map((repository) => ( {repositoryLabel(repository)} @@ -438,16 +456,20 @@ export function ForgejoIssueMetricCards({
{isLoading ? Array.from({ length: 4 }).map((_, i) => ) - : cards.map((card) => )} + : cards.map((card) => ( + + ))}
{!isLoading && !error && repositories.length === 0 ? (
- No Git Project repositories are configured yet. Metrics will populate after repositories are added and synced. + No Git Project repositories are configured yet. Metrics will populate + after repositories are added and synced.
) : !isLoading && !error && repositoriesSynced === 0 ? (
- Git Project repositories are configured, but Pipeline has not synced issue metrics yet. + Git Project repositories are configured, but Pipeline has not synced + issue metrics yet.
) : null}
diff --git a/frontend/src/components/git/ForgejoRepositoriesTable.tsx b/frontend/src/components/git/ForgejoRepositoriesTable.tsx index 986eed4..b3e8b69 100644 --- a/frontend/src/components/git/ForgejoRepositoriesTable.tsx +++ b/frontend/src/components/git/ForgejoRepositoriesTable.tsx @@ -11,6 +11,12 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { AlertCircle, Archive, @@ -25,7 +31,10 @@ import { RefreshCw, ShieldCheck, } from "lucide-react"; -import { DataTable } from "@/components/tables/DataTable"; +import { + DataTable, + type DataTableRowAction, +} from "@/components/tables/DataTable"; import { cn } from "@/lib/utils"; import type { @@ -45,14 +54,14 @@ const repositoryTone = (repo: ForgejoRepository) => { const toneClasses = { success: { - rail: "border-l-[color:var(--success)]", - row: "bg-[color:rgba(52,211,153,0.025)] hover:bg-[color:rgba(52,211,153,0.07)]", + rail: "border-l-transparent", + row: "hover:bg-[color:rgba(96,165,250,0.045)]", icon: "border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.13)] text-[color:var(--success)]", dot: "bg-[color:var(--success)]", }, warning: { rail: "border-l-[color:var(--warning)]", - row: "bg-[color:rgba(251,191,36,0.025)] hover:bg-[color:rgba(251,191,36,0.075)]", + row: "bg-[color:rgba(251,191,36,0.035)] hover:bg-[color:rgba(251,191,36,0.085)]", icon: "border-[color:rgba(251,191,36,0.3)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]", dot: "bg-[color:var(--warning)]", }, @@ -63,7 +72,7 @@ const toneClasses = { dot: "bg-[color:var(--danger)]", }, muted: { - rail: "border-l-[color:var(--border-strong)]", + rail: "border-l-transparent", row: "hover:bg-[color:var(--surface-muted)]", icon: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted", dot: "bg-[color:var(--text-quiet)]", @@ -74,8 +83,25 @@ const formatSyncTime = (value: string | null) => { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; + + const diffMinutes = Math.max( + 0, + Math.round((Date.now() - date.getTime()) / 60_000), + ); + const relative = + diffMinutes < 1 + ? "Just now" + : diffMinutes < 60 + ? `${diffMinutes} min ago` + : diffMinutes < 60 * 24 + ? `${Math.round(diffMinutes / 60)} hr ago` + : diffMinutes < 60 * 24 * 7 + ? `${Math.round(diffMinutes / (60 * 24))} days ago` + : date.toLocaleDateString(); + return { - date: date.toLocaleDateString(), + relative, + full: date.toLocaleString(), time: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -114,6 +140,27 @@ export function ForgejoRepositoriesTable({ }: RepositoriesTableProps) { // onEdit available for future use const _ = onEdit; + const rowActions: DataTableRowAction[] = [ + { + key: "edit", + label: "Edit", + href: (row) => `/git-projects/repositories/${row.id}/edit`, + className: + "inline-flex h-8 items-center justify-center rounded-lg px-3 text-xs font-semibold text-muted transition hover:bg-[color:var(--surface-muted)] hover:text-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]", + }, + ...(onDelete + ? [ + { + key: "delete", + label: "Delete", + onClick: onDelete, + className: + "h-8 rounded-lg px-3 text-xs font-semibold text-muted transition hover:bg-[color:rgba(248,113,113,0.1)] hover:text-[color:var(--danger)]", + } satisfies DataTableRowAction, + ] + : []), + ]; + const table = useReactTable({ data: repositories, columns: columns(onSync, onValidate, onViewDetails), @@ -133,18 +180,18 @@ export function ForgejoRepositoriesTable({ actionLabel: "Add repository", }} rowActions={{ - getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`, - onDelete: onDelete ?? undefined, + header: "Manage", + actions: rowActions, cellClassName: "px-3 py-3 align-middle md:px-5", }} tableClassName="min-w-[900px] w-full text-left text-sm" - headerClassName="bg-[linear-gradient(90deg,rgba(96,165,250,0.16),rgba(52,211,153,0.1),rgba(251,191,36,0.08))] text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]" + headerClassName="border-b border-[color:var(--border)] bg-[color:var(--surface-muted)]/80 text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]" headerCellClassName="px-3 py-3 md:px-5" cellClassName="px-3 py-4 align-middle md:px-5" rowClassName={(row) => { const tone = repositoryTone(row.original); return cn( - "border-l-4 transition-colors", + "border-l-4 transition-colors duration-150", toneClasses[tone].rail, toneClasses[tone].row, ); @@ -205,11 +252,6 @@ const columns = ( {repo.owner}/{repo.repo} - {repo.connection?.name ? ( - - / {repo.connection.name} - - ) : null} {repo.description ? ( @@ -290,7 +332,7 @@ const columns = ( Webhook ) : ( - + No secret @@ -323,11 +365,11 @@ const columns = ( className={cn( "inline-flex min-w-[94px] flex-col rounded-xl border px-3 py-2", row.original.open_issues_count > 0 - ? "border-[color:rgba(251,191,36,0.28)] bg-[color:rgba(251,191,36,0.1)]" - : "border-[color:rgba(52,211,153,0.24)] bg-[color:rgba(52,211,153,0.08)]", + ? "border-[color:rgba(251,191,36,0.24)] bg-[color:rgba(251,191,36,0.08)]" + : "border-[color:rgba(52,211,153,0.18)] bg-[color:rgba(52,211,153,0.055)]", )} > - + {row.original.open_issues_count} @@ -357,13 +399,16 @@ const columns = ( } return ( -
+
- {syncTime.date} + {syncTime.relative} {syncTime.time} {lastSyncError && ( @@ -378,6 +423,7 @@ const columns = ( }, { id: "actions", + header: "Actions", cell: ({ row }) => ( - {onSync && ( - - )} - {onValidate && ( - - )} - {onViewDetails && ( - - )} -
+ +
+ {onSync && ( + + + + + Sync issues + + )} + {onValidate && ( + + + + + Validate repository + + )} + {onViewDetails && ( + + + + + Repository details + + )} +
+
); } diff --git a/frontend/src/components/organisms/DashboardSidebar.tsx b/frontend/src/components/organisms/DashboardSidebar.tsx index cbeb4cc..ec5597a 100644 --- a/frontend/src/components/organisms/DashboardSidebar.tsx +++ b/frontend/src/components/organisms/DashboardSidebar.tsx @@ -11,14 +11,11 @@ import { Building2, CheckCircle2, CircleDot, - Folder, FolderGit, - KeyRound, LayoutGrid, Network, Settings, Store, - Tags, TerminalSquare, } from "lucide-react"; @@ -65,6 +62,14 @@ const iconClass = (active: boolean, tone: NavTone) => ); function isNavActive(pathname: string, href: string) { + if (href === "/boards") { + return ( + pathname === href || + pathname.startsWith("/boards/") || + pathname.startsWith("/board-groups") + ); + } + if (href === "/git-projects") { return ( pathname === href || @@ -76,8 +81,9 @@ function isNavActive(pathname: string, href: string) { if (href === "/settings") { return ( pathname === href || - (pathname.startsWith("/settings/") && - !pathname.startsWith("/settings/ai-providers")) + pathname.startsWith("/settings/") || + pathname.startsWith("/tags") || + pathname.startsWith("/custom-fields") ); } @@ -166,15 +172,8 @@ export function DashboardSidebar() {
-

Boards

+

Work

- } - tone="emerald" - active={isActive("/board-groups")} - /> + } + tone="emerald" + active={isActive("/approvals")} + /> - } - tone="cyan" - active={isActive("/tags")} - /> - } - tone="emerald" - active={isActive("/approvals")} - /> - {isAdmin ? ( - } - tone="rose" - active={isActive("/custom-fields")} - /> - ) : null}
@@ -250,13 +233,6 @@ export function DashboardSidebar() { active={isActive("/gateways")} /> ) : null} - } - tone="amber" - active={isActive("/settings/ai-providers")} - />