health stats for Open Issues and Webhook coverage

This commit is contained in:
null 2026-05-24 21:36:00 -05:00
parent 5a1caa8264
commit fc1fa41a28
2 changed files with 577 additions and 96 deletions

View File

@ -16,6 +16,7 @@ import {
GitBranch, GitBranch,
KeyRound, KeyRound,
Link2, Link2,
ListChecks,
Loader2, Loader2,
RefreshCw, RefreshCw,
Server, Server,
@ -39,6 +40,7 @@ import {
deleteForgejoConnection, deleteForgejoConnection,
deleteForgejoRepository, deleteForgejoRepository,
getForgejoConnections, getForgejoConnections,
getLinkedBoardsForRepository,
getForgejoRepositories, getForgejoRepositories,
massImportRepositories, massImportRepositories,
syncRepository, syncRepository,
@ -58,6 +60,26 @@ type DeleteTarget =
| { type: "connection"; item: ForgejoConnection } | { type: "connection"; item: ForgejoConnection }
| { type: "repository"; item: ForgejoRepository }; | { type: "repository"; item: ForgejoRepository };
type BoardLink = {
id: string;
name: string;
};
type LastMassImport = {
finishedAt: string;
result: MassImportResponse;
};
type AttentionItem = {
id: string;
title: string;
detail: string;
tone: "warning" | "danger" | "muted";
href?: string;
};
const LAST_MASS_IMPORT_STORAGE_KEY = "pipeline.gitProjects.lastMassImport";
const repositoryName = (repository: ForgejoRepository) => const repositoryName = (repository: ForgejoRepository) =>
repository.display_name || `${repository.owner}/${repository.repo}`; repository.display_name || `${repository.owner}/${repository.repo}`;
@ -68,6 +90,24 @@ const formatTimestamp = (value: string | null) => {
return date.toLocaleString(); return date.toLocaleString();
}; };
const formatCompactNumber = (value: number) =>
new Intl.NumberFormat(undefined, { notation: "compact" }).format(value);
const summarizeMassImport = (result: MassImportResponse) =>
`${result.total_created} created, ${result.total_updated} updated, ${result.total_stale_closed} closed`;
const isLastMassImport = (value: unknown): value is LastMassImport => {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<LastMassImport>;
return (
typeof candidate.finishedAt === "string" &&
typeof candidate.result?.total_created === "number" &&
typeof candidate.result?.total_updated === "number" &&
typeof candidate.result?.total_stale_closed === "number" &&
Array.isArray(candidate.result?.results)
);
};
function NoticeBanner({ notice }: { notice: Notice }) { function NoticeBanner({ notice }: { notice: Notice }) {
return ( return (
<div <div
@ -87,6 +127,33 @@ function NoticeBanner({ notice }: { notice: Notice }) {
); );
} }
function SectionHeader({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="shrink-0 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)]">
{icon}
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold text-strong">{title}</h2>
<p className="mt-1 text-sm text-muted">{description}</p>
</div>
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}
function StatCard({ function StatCard({
icon, icon,
label, label,
@ -118,6 +185,140 @@ function StatCard({
); );
} }
function AttentionPanel({ items }: { items: AttentionItem[] }) {
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<SectionHeader
icon={<ListChecks className="h-4 w-4" />}
title="Needs Attention"
description="Connection and repository signals that can block fresh issue data."
/>
{items.length === 0 ? (
<div className="mt-4 flex items-start gap-3 rounded-lg border border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.08)] p-3 text-sm">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[color:var(--success)]" />
<div>
<p className="font-medium text-strong">No action needed</p>
<p className="mt-1 text-muted">
No sync errors, missing tokens, archived repos, or webhook gaps
need attention.
</p>
</div>
</div>
) : (
<div className="mt-4 divide-y divide-[color:var(--border)] rounded-lg border border-[color:var(--border)]">
{items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 p-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0">
<p className="font-medium text-strong">{item.title}</p>
<p className="mt-1 text-sm text-muted">{item.detail}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={
item.tone === "danger"
? "text-xs font-semibold uppercase tracking-wide text-[color:var(--danger)]"
: item.tone === "warning"
? "text-xs font-semibold uppercase tracking-wide text-[color:var(--warning)]"
: "text-xs font-semibold uppercase tracking-wide text-muted"
}
>
{item.tone}
</span>
{item.href ? (
<Link href={item.href}>
<Button type="button" variant="ghost" size="sm">
Open
</Button>
</Link>
) : null}
</div>
</div>
))}
</div>
)}
</section>
);
}
function LastImportPanel({
lastImport,
onOpen,
}: {
lastImport: LastMassImport | null;
onOpen: () => void;
}) {
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<SectionHeader
icon={<Download className="h-4 w-4" />}
title="Full Import"
description="Run a complete issue pull when webhooks or scheduled sync need a reset."
action={
<Button type="button" variant="outline" size="sm" onClick={onOpen}>
<Download className="h-4 w-4" />
Open
</Button>
}
/>
<div className="mt-4 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
{lastImport ? (
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-strong">
{summarizeMassImport(lastImport.result)}
</p>
<p className="mt-1 text-xs text-muted">
Finished {formatTimestamp(lastImport.finishedAt)} across{" "}
{lastImport.result.succeeded} repositor
{lastImport.result.succeeded === 1 ? "y" : "ies"}.
{lastImport.result.failed > 0 ? (
<span className="ml-1 text-[color:var(--danger)]">
{lastImport.result.failed} failed.
</span>
) : null}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-[color:var(--surface)] px-2 py-2">
<p className="text-lg font-semibold text-strong">
{lastImport.result.total_created}
</p>
<p className="text-[11px] uppercase tracking-wide text-muted">
Created
</p>
</div>
<div className="rounded-lg bg-[color:var(--surface)] px-2 py-2">
<p className="text-lg font-semibold text-strong">
{lastImport.result.total_updated}
</p>
<p className="text-[11px] uppercase tracking-wide text-muted">
Updated
</p>
</div>
<div className="rounded-lg bg-[color:var(--surface)] px-2 py-2">
<p className="text-lg font-semibold text-strong">
{lastImport.result.total_stale_closed}
</p>
<p className="text-[11px] uppercase tracking-wide text-muted">
Closed
</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted">
No full import has run in this session. Scheduled sync still keeps
active repositories current.
</p>
)}
</div>
</section>
);
}
function CopyButton({ value, label }: { value: string; label?: string }) { function CopyButton({ value, label }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -160,6 +361,12 @@ export default function GitProjectSettingsPage() {
const [massImportOpen, setMassImportOpen] = useState(false); const [massImportOpen, setMassImportOpen] = useState(false);
const [massImportResult, setMassImportResult] = const [massImportResult, setMassImportResult] =
useState<MassImportResponse | null>(null); useState<MassImportResponse | null>(null);
const [lastMassImport, setLastMassImport] = useState<LastMassImport | null>(
null,
);
const [linkedBoardsByRepository, setLinkedBoardsByRepository] = useState<
Record<string, BoardLink[]>
>({});
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<Notice | null>(null); const [notice, setNotice] = useState<Notice | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null); const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
@ -173,6 +380,32 @@ export default function GitProjectSettingsPage() {
return () => clearTimeout(id); return () => clearTimeout(id);
}, [notice]); }, [notice]);
useEffect(() => {
try {
const stored = window.localStorage.getItem(LAST_MASS_IMPORT_STORAGE_KEY);
if (!stored) return;
const parsed: unknown = JSON.parse(stored);
if (isLastMassImport(parsed)) {
setLastMassImport(parsed);
}
} catch {
// Local storage is optional for this summary.
}
}, []);
useEffect(() => {
try {
if (lastMassImport) {
window.localStorage.setItem(
LAST_MASS_IMPORT_STORAGE_KEY,
JSON.stringify(lastMassImport),
);
}
} catch {
// Local storage is optional for this summary.
}
}, [lastMassImport]);
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -182,6 +415,25 @@ export default function GitProjectSettingsPage() {
]); ]);
setConnections(connectionsData); setConnections(connectionsData);
setRepositories(repositoriesData); setRepositories(repositoriesData);
const boardResults = await Promise.allSettled(
repositoriesData.map(async (repo) => {
const boards = await getLinkedBoardsForRepository(repo.id);
return [repo.id, boards] as const;
}),
);
setLinkedBoardsByRepository(
Object.fromEntries(
boardResults
.filter(
(
result,
): result is PromiseFulfilledResult<
readonly [string, BoardLink[]]
> => result.status === "fulfilled",
)
.map((result) => result.value),
),
);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError( setError(
@ -223,6 +475,88 @@ export default function GitProjectSettingsPage() {
() => repositories.filter((r) => r.last_sync_error).length, () => repositories.filter((r) => r.last_sync_error).length,
[repositories], [repositories],
); );
const totalOpenIssues = useMemo(
() =>
repositories.reduce((total, repo) => total + repo.open_issues_count, 0),
[repositories],
);
const activeRepositoriesWithWebhooks = useMemo(
() =>
repositories.filter((r) => r.active && r.has_webhook_secret).length,
[repositories],
);
const archivedRepositories = useMemo(
() => repositories.filter((r) => r.is_archived).length,
[repositories],
);
const attentionItems = useMemo<AttentionItem[]>(() => {
const connectionItems = connections.flatMap((connection) => {
const items: AttentionItem[] = [];
if (!connection.active) {
items.push({
id: `connection-inactive-${connection.id}`,
title: `${connection.name} is inactive`,
detail: "Repositories using this connection will not receive fresh issue data.",
tone: "muted",
href: `/git-projects/connections/${connection.id}/edit`,
});
}
if (!connection.has_token) {
items.push({
id: `connection-token-${connection.id}`,
title: `${connection.name} is missing a token`,
detail: "Validation, sync, and imports need a configured provider token.",
tone: "danger",
href: `/git-projects/connections/${connection.id}/edit`,
});
}
return items;
});
const repositoryItems = repositories.flatMap((repo) => {
const name = repositoryName(repo);
const items: AttentionItem[] = [];
if (repo.last_sync_error) {
items.push({
id: `repo-error-${repo.id}`,
title: `${name} has a sync error`,
detail: repo.last_sync_error,
tone: "danger",
href: `/git-projects/repositories/${repo.id}/edit`,
});
}
if (repo.active && !repo.has_webhook_secret) {
items.push({
id: `repo-webhook-${repo.id}`,
title: `${name} has no webhook secret`,
detail: "Webhook setup can receive events, but signature validation needs a stored secret.",
tone: "warning",
href: `/git-projects/repositories/${repo.id}/edit`,
});
}
if (repo.is_archived) {
items.push({
id: `repo-archived-${repo.id}`,
title: `${name} is archived upstream`,
detail: "Archived repositories can stay visible, but they may not need active syncing.",
tone: "muted",
href: `/git-projects/repositories/${repo.id}/edit`,
});
}
if (!repo.active) {
items.push({
id: `repo-inactive-${repo.id}`,
title: `${name} is inactive`,
detail: "Inactive repositories are excluded from Sync All and full imports.",
tone: "muted",
href: `/git-projects/repositories/${repo.id}/edit`,
});
}
return items;
});
return [...connectionItems, ...repositoryItems];
}, [connections, repositories]);
const webhookBaseUrl = useMemo(() => { const webhookBaseUrl = useMemo(() => {
try { try {
@ -310,6 +644,10 @@ export default function GitProjectSettingsPage() {
try { try {
const result = await massImportRepositories(); const result = await massImportRepositories();
setMassImportResult(result); setMassImportResult(result);
setLastMassImport({
finishedAt: new Date().toISOString(),
result,
});
await loadSettings(); await loadSettings();
} catch (err) { } catch (err) {
setNotice({ setNotice({
@ -431,7 +769,6 @@ export default function GitProjectSettingsPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
setMassImportResult(null);
setMassImportOpen(true); setMassImportOpen(true);
}} }}
disabled={isMassImporting || isLoading || activeRepositories === 0} disabled={isMassImporting || isLoading || activeRepositories === 0}
@ -465,7 +802,7 @@ export default function GitProjectSettingsPage() {
) : null} ) : null}
{/* Stats */} {/* Stats */}
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
<StatCard <StatCard
icon={<Server className="h-4 w-4" />} icon={<Server className="h-4 w-4" />}
label="Connections" label="Connections"
@ -478,6 +815,18 @@ export default function GitProjectSettingsPage() {
value={`${activeRepositories}/${repositories.length}`} value={`${activeRepositories}/${repositories.length}`}
caption="Active tracked repositories." caption="Active tracked repositories."
/> />
<StatCard
icon={<CircleDot className="h-4 w-4" />}
label="Open Issues"
value={formatCompactNumber(totalOpenIssues)}
caption="Open issues reported by tracked repositories."
/>
<StatCard
icon={<Webhook className="h-4 w-4" />}
label="Webhooks"
value={`${activeRepositoriesWithWebhooks}/${activeRepositories}`}
caption="Active repositories with webhook secrets."
/>
<StatCard <StatCard
icon={<RefreshCw className="h-4 w-4" />} icon={<RefreshCw className="h-4 w-4" />}
label="Latest Sync" label="Latest Sync"
@ -488,28 +837,33 @@ export default function GitProjectSettingsPage() {
icon={<AlertCircle className="h-4 w-4" />} icon={<AlertCircle className="h-4 w-4" />}
label="Sync Errors" label="Sync Errors"
value={String(syncErrorCount)} value={String(syncErrorCount)}
caption="Repositories with a recorded sync error." caption={`Repositories with sync errors; ${archivedRepositories} archived tracked.`}
/> />
</section> </section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
<AttentionPanel items={attentionItems} />
<LastImportPanel
lastImport={lastMassImport}
onOpen={() => setMassImportOpen(true)}
/>
</div>
{/* Connections */} {/* Connections */}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5"> <section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <SectionHeader
<div className="min-w-0"> icon={<Link2 className="h-4 w-4" />}
<h2 className="text-base font-semibold text-strong"> title="Forgejo Connections"
Forgejo Connections description="URL and token records used by tracked repositories."
</h2> action={
<p className="mt-1 text-sm text-muted">
URL and token records used by tracked repositories.
</p>
</div>
<Link href="/git-projects/connections"> <Link href="/git-projects/connections">
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">
<Link2 className="h-4 w-4" /> <Link2 className="h-4 w-4" />
Manage Connections Manage Connections
</Button> </Button>
</Link> </Link>
</div> }
/>
<div className="mt-4 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]"> <div className="mt-4 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]">
<ForgejoConnectionsTable <ForgejoConnectionsTable
connections={connections} connections={connections}
@ -524,15 +878,11 @@ export default function GitProjectSettingsPage() {
{/* Repositories */} {/* Repositories */}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5"> <section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <SectionHeader
<div className="min-w-0"> icon={<GitBranch className="h-4 w-4" />}
<h2 className="text-base font-semibold text-strong"> title="Tracked Repositories"
Tracked Repositories description="Repositories whose issues are cached and shown in Pipeline."
</h2> action={
<p className="mt-1 text-sm text-muted">
Repositories whose issues are cached and shown in Pipeline.
</p>
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link href="/git-projects/repositories/new"> <Link href="/git-projects/repositories/new">
<Button size="sm"> <Button size="sm">
@ -547,11 +897,13 @@ export default function GitProjectSettingsPage() {
</Button> </Button>
</Link> </Link>
</div> </div>
</div> }
/>
<div className="mt-4 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]"> <div className="mt-4 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]">
<ForgejoRepositoriesTable <ForgejoRepositoriesTable
repositories={repositories} repositories={repositories}
isLoading={isLoading} isLoading={isLoading}
linkedBoardsByRepository={linkedBoardsByRepository}
onDelete={(repository) => onDelete={(repository) =>
setDeleteTarget({ type: "repository", item: repository }) setDeleteTarget({ type: "repository", item: repository })
} }
@ -563,28 +915,19 @@ export default function GitProjectSettingsPage() {
{/* Webhook Setup */} {/* Webhook Setup */}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5"> <section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex items-start gap-3 mb-4"> <SectionHeader
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)] shrink-0"> icon={<Webhook className="h-4 w-4" />}
<Webhook className="h-4 w-4" /> title="Webhook Setup"
</div> description="Configure Forgejo webhooks to push issue updates to Pipeline in real time."
<div className="min-w-0"> />
<h2 className="text-base font-semibold text-strong">
Webhook Setup
</h2>
<p className="mt-1 text-sm text-muted">
Configure webhooks in Forgejo to push issue updates to
Pipeline in real time, without waiting for the scheduled sync.
</p>
</div>
</div>
{repositories.length === 0 ? ( {repositories.length === 0 ? (
<p className="text-sm text-muted"> <p className="mt-4 text-sm text-muted">
No repositories tracked yet. Add a repository to see webhook No repositories tracked yet. Add a repository to see webhook
URLs. URLs.
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="mt-4 space-y-3">
<p className="text-xs text-muted"> <p className="text-xs text-muted">
In each Forgejo repository, go to{" "} In each Forgejo repository, go to{" "}
<strong className="text-strong"> <strong className="text-strong">
@ -673,17 +1016,13 @@ export default function GitProjectSettingsPage() {
{/* Scheduled Sync */} {/* Scheduled Sync */}
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5"> <section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex items-start gap-3"> <SectionHeader
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)] shrink-0"> icon={<Clock className="h-4 w-4" />}
<Clock className="h-4 w-4" /> title="Scheduled Sync"
</div> description="Pipeline runs a background sync for all active repositories every 60 minutes."
<div className="min-w-0"> />
<h2 className="text-base font-semibold text-strong"> <div className="mt-4 pl-0 sm:pl-11">
Scheduled Sync <p className="text-sm text-muted">
</h2>
<p className="mt-1 text-sm text-muted">
Pipeline runs a background sync for all active repositories
every <strong className="text-strong">60 minutes</strong>.
This keeps issues current without manual syncing or webhooks. This keeps issues current without manual syncing or webhooks.
The interval is configured via environment variable and cannot The interval is configured via environment variable and cannot
be changed from the UI. be changed from the UI.
@ -703,7 +1042,6 @@ export default function GitProjectSettingsPage() {
</span> </span>
</div> </div>
</div> </div>
</div>
</section> </section>
</div> </div>
</DashboardPageLayout> </DashboardPageLayout>
@ -826,7 +1164,11 @@ export default function GitProjectSettingsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end gap-2">
<Button type="button" onClick={handleMassImport}>
<Download className="h-4 w-4" />
Import Again
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@ -11,7 +11,15 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CheckCircle2, GitBranch, Loader2, RefreshCw } from "lucide-react"; import {
Archive,
CheckCircle2,
GitBranch,
KeyRound,
Loader2,
RefreshCw,
Tags,
} from "lucide-react";
import { DataTable } from "@/components/tables/DataTable"; import { DataTable } from "@/components/tables/DataTable";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -28,9 +36,18 @@ type RepositorySyncResult = {
total: number; total: number;
}; };
type BoardLink = {
id: string;
name: string;
};
const labelColor = (color: string) =>
color.startsWith("#") ? color : `#${color}`;
interface RepositoriesTableProps { interface RepositoriesTableProps {
repositories: ForgejoRepository[]; repositories: ForgejoRepository[];
isLoading: boolean; isLoading: boolean;
linkedBoardsByRepository?: Record<string, BoardLink[]>;
onEdit?: (repository: ForgejoRepository) => void; onEdit?: (repository: ForgejoRepository) => void;
onDelete?: (repository: ForgejoRepository) => void; onDelete?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>; onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
@ -42,6 +59,7 @@ interface RepositoriesTableProps {
export function ForgejoRepositoriesTable({ export function ForgejoRepositoriesTable({
repositories, repositories,
isLoading, isLoading,
linkedBoardsByRepository = {},
onEdit, onEdit,
onDelete, onDelete,
onSync, onSync,
@ -51,7 +69,7 @@ export function ForgejoRepositoriesTable({
const _ = onEdit; const _ = onEdit;
const table = useReactTable({ const table = useReactTable({
data: repositories, data: repositories,
columns: columns(onSync, onValidate), columns: columns(onSync, onValidate, linkedBoardsByRepository),
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
@ -71,7 +89,7 @@ export function ForgejoRepositoriesTable({
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`, getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
onDelete: onDelete ?? undefined, onDelete: onDelete ?? undefined,
}} }}
tableClassName="min-w-[860px] w-full text-left text-sm" tableClassName="min-w-[1180px] w-full text-left text-sm"
/> />
); );
} }
@ -81,6 +99,7 @@ const columns = (
onValidate?: ( onValidate?: (
repository: ForgejoRepository, repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>, ) => Promise<ForgejoRepositoryValidationResponse>,
linkedBoardsByRepository: Record<string, BoardLink[]> = {},
): ColumnDef<ForgejoRepository>[] => [ ): ColumnDef<ForgejoRepository>[] => [
{ {
accessorKey: "displayName", accessorKey: "displayName",
@ -107,6 +126,11 @@ const columns = (
<span className="block truncate font-mono text-xs text-muted"> <span className="block truncate font-mono text-xs text-muted">
{repo.owner}/{repo.repo} {repo.connection?.name} {repo.owner}/{repo.repo} {repo.connection?.name}
</span> </span>
{repo.description ? (
<span className="mt-1 block max-w-[300px] truncate text-xs text-muted">
{repo.description}
</span>
) : null}
</div> </div>
); );
}, },
@ -132,11 +156,126 @@ const columns = (
accessorKey: "status", accessorKey: "status",
header: "Status", header: "Status",
cell: ({ row }) => { cell: ({ row }) => {
const isActive = row.original.active; const repo = row.original;
const isActive = repo.active;
return ( return (
<div className="flex flex-wrap gap-1.5">
<Badge variant={isActive ? "default" : "outline"}> <Badge variant={isActive ? "default" : "outline"}>
{isActive ? "Active" : "Inactive"} {isActive ? "Active" : "Inactive"}
</Badge> </Badge>
{repo.is_archived ? (
<Badge variant="warning" className="gap-1">
<Archive className="h-3 w-3" />
Archived
</Badge>
) : null}
{repo.has_webhook_secret ? (
<Badge variant="success" className="gap-1">
<KeyRound className="h-3 w-3" />
Webhook
</Badge>
) : (
<Badge variant="outline" className="gap-1">
<KeyRound className="h-3 w-3" />
No secret
</Badge>
)}
</div>
);
},
},
{
accessorKey: "openIssues",
header: "Issues",
cell: ({ row }) => (
<div className="min-w-[90px]">
<span className="block text-lg font-semibold text-strong">
{row.original.open_issues_count}
</span>
<span className="text-xs text-muted">open upstream</span>
</div>
),
},
{
accessorKey: "metadata",
header: "Metadata",
cell: ({ row }) => {
const repo = row.original;
const visibleTopics = repo.topics.slice(0, 2);
const hiddenTopics = Math.max(
repo.topics.length - visibleTopics.length,
0,
);
const visibleLabels = repo.labels.slice(0, 2);
const hiddenLabels = Math.max(
repo.labels.length - visibleLabels.length,
0,
);
return (
<div className="min-w-[220px] space-y-2">
<span className="block truncate font-mono text-xs text-muted">
default: {repo.default_branch || "unknown"}
</span>
<div className="flex flex-wrap gap-1.5">
{visibleTopics.map((topic) => (
<Badge key={topic} variant="accent" className="gap-1 normal-case">
<Tags className="h-3 w-3" />
{topic}
</Badge>
))}
{hiddenTopics > 0 ? (
<Badge variant="outline">+{hiddenTopics} topics</Badge>
) : null}
{visibleTopics.length === 0 ? (
<span className="text-xs text-muted">No topics</span>
) : null}
</div>
<div className="flex flex-wrap gap-1.5">
{visibleLabels.map((label) => (
<span
key={`${label.id ?? label.name}-${label.name}`}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] px-2 py-0.5 text-xs text-strong"
title={label.description || label.name}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: labelColor(label.color) }}
/>
{label.name}
</span>
))}
{hiddenLabels > 0 ? (
<span className="text-xs text-muted">+{hiddenLabels} labels</span>
) : null}
</div>
</div>
);
},
},
{
accessorKey: "boards",
header: "Boards",
cell: ({ row }) => {
const boards = linkedBoardsByRepository[row.original.id] ?? [];
const visibleBoards = boards.slice(0, 3);
const hiddenBoards = Math.max(boards.length - visibleBoards.length, 0);
if (boards.length === 0) {
return <span className="text-sm text-muted">No linked boards</span>;
}
return (
<div className="flex max-w-[240px] flex-wrap gap-1.5">
{visibleBoards.map((board) => (
<Badge key={board.id} variant="outline" className="normal-case">
{board.name}
</Badge>
))}
{hiddenBoards > 0 ? (
<Badge variant="outline">+{hiddenBoards}</Badge>
) : null}
</div>
); );
}, },
}, },