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"> <Link href="/git-projects/connections">
URL and token records used by tracked repositories. <Button size="sm" variant="outline">
</p> <Link2 className="h-4 w-4" />
</div> Manage Connections
<Link href="/git-projects/connections"> </Button>
<Button size="sm" variant="outline"> </Link>
<Link2 className="h-4 w-4" /> }
Manage Connections />
</Button>
</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,34 +878,32 @@ 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"> <div className="flex flex-wrap gap-2">
Repositories whose issues are cached and shown in Pipeline. <Link href="/git-projects/repositories/new">
</p> <Button size="sm">
</div> <GitBranch className="h-4 w-4" />
<div className="flex flex-wrap gap-2"> Add Repository
<Link href="/git-projects/repositories/new"> </Button>
<Button size="sm"> </Link>
<GitBranch className="h-4 w-4" /> <Link href="/git-projects">
Add Repository <Button variant="outline" size="sm">
</Button> <ExternalLink className="h-4 w-4" />
</Link> Git Projects
<Link href="/git-projects"> </Button>
<Button variant="outline" size="sm"> </Link>
<ExternalLink className="h-4 w-4" /> </div>
Git Projects }
</Button> />
</Link>
</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,35 +1016,30 @@ 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> This keeps issues current without manual syncing or webhooks.
<p className="mt-1 text-sm text-muted"> The interval is configured via environment variable and cannot
Pipeline runs a background sync for all active repositories be changed from the UI.
every <strong className="text-strong">60 minutes</strong>. </p>
This keeps issues current without manual syncing or webhooks. <div className="mt-3 flex flex-wrap gap-4 text-xs text-muted">
The interval is configured via environment variable and cannot <span>
be changed from the UI. Env:{" "}
</p> <code className="rounded bg-[color:var(--surface-muted)] px-1 py-0.5 font-mono text-strong">
<div className="mt-3 flex flex-wrap gap-4 text-xs text-muted"> FORGEJO_SYNC_ENABLED
<span> </code>
Env:{" "} </span>
<code className="rounded bg-[color:var(--surface-muted)] px-1 py-0.5 font-mono text-strong"> <span>
FORGEJO_SYNC_ENABLED Env:{" "}
</code> <code className="rounded bg-[color:var(--surface-muted)] px-1 py-0.5 font-mono text-strong">
</span> FORGEJO_SYNC_INTERVAL_SECONDS
<span> </code>
Env:{" "} </span>
<code className="rounded bg-[color:var(--surface-muted)] px-1 py-0.5 font-mono text-strong">
FORGEJO_SYNC_INTERVAL_SECONDS
</code>
</span>
</div>
</div> </div>
</div> </div>
</section> </section>
@ -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 (
<Badge variant={isActive ? "default" : "outline"}> <div className="flex flex-wrap gap-1.5">
{isActive ? "Active" : "Inactive"} <Badge variant={isActive ? "default" : "outline"}>
</Badge> {isActive ? "Active" : "Inactive"}
</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>
); );
}, },
}, },