Modernized git-projects/repositories/
This commit is contained in:
parent
1c1bade3ca
commit
19a6b8fda8
|
|
@ -1,14 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Tags,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||
import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable";
|
||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
import {
|
||||
getForgejoRepositories,
|
||||
deleteForgejoRepository,
|
||||
|
|
@ -17,25 +40,284 @@ import {
|
|||
type ForgejoRepository,
|
||||
} from "@/lib/api-forgejo";
|
||||
|
||||
export default function ForgejoRepositoriesPage() {
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
|
||||
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<{
|
||||
type Notice = {
|
||||
tone: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
};
|
||||
|
||||
type RepositoryFilter =
|
||||
| "all"
|
||||
| "attention"
|
||||
| "active"
|
||||
| "webhooks"
|
||||
| "archived";
|
||||
|
||||
const repositoryName = (repository: ForgejoRepository) =>
|
||||
repository.display_name || `${repository.owner}/${repository.repo}`;
|
||||
|
||||
const formatTimestamp = (value: string | null) => {
|
||||
if (!value) return "Never";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatCompactNumber = (value: number) =>
|
||||
new Intl.NumberFormat(undefined, { notation: "compact" }).format(value);
|
||||
|
||||
const labelColor = (color: string) =>
|
||||
color.startsWith("#") ? color : `#${color}`;
|
||||
|
||||
function NoticeBanner({ notice }: { notice: Notice }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${
|
||||
notice.tone === "success"
|
||||
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
|
||||
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"
|
||||
}`}
|
||||
>
|
||||
{notice.tone === "success" ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span>{notice.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-strong">{value}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-2 text-[color:var(--accent)]">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted">{caption}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={Boolean(repository)} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{repository ? repositoryName(repository) : "Repository details"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{repository ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<CircleDot className="h-4 w-4" />}
|
||||
label="Open Issues"
|
||||
value={String(repository.open_issues_count)}
|
||||
caption="Reported upstream."
|
||||
/>
|
||||
<StatCard
|
||||
icon={<GitBranch className="h-4 w-4" />}
|
||||
label="Branch"
|
||||
value={repository.default_branch || "Unknown"}
|
||||
caption="Default branch."
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Webhook className="h-4 w-4" />}
|
||||
label="Webhook"
|
||||
value={repository.has_webhook_secret ? "Ready" : "Missing"}
|
||||
caption="Stored secret status."
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
label="Synced"
|
||||
value={formatTimestamp(repository.last_sync_at)}
|
||||
caption="Last sync timestamp."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{repository.last_sync_error ? (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{repository.last_sync_error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{repository.description ? (
|
||||
<p className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
|
||||
{repository.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-strong">
|
||||
Linked Boards
|
||||
</h3>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{repository.linked_boards.length ? (
|
||||
repository.linked_boards.map((board) => (
|
||||
<Badge
|
||||
key={board.id}
|
||||
variant="outline"
|
||||
className="normal-case"
|
||||
>
|
||||
{board.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted">
|
||||
No linked boards.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-strong">Topics</h3>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{repository.topics.length ? (
|
||||
repository.topics.map((topic) => (
|
||||
<Badge
|
||||
key={topic}
|
||||
variant="accent"
|
||||
className="gap-1 normal-case"
|
||||
>
|
||||
<Tags className="h-3 w-3" />
|
||||
{topic}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted">No topics.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-strong">Labels</h3>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{repository.labels.length ? (
|
||||
repository.labels.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-3 py-1 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>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted">No labels synced.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-strong">
|
||||
Webhook URL
|
||||
</p>
|
||||
<code className="mt-1 block truncate font-mono text-xs text-muted">
|
||||
{webhookUrl}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ForgejoRepositoriesPage() {
|
||||
const auth = useAuth();
|
||||
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isBulkSyncing, setIsBulkSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<RepositoryFilter>("all");
|
||||
const [selectedRepository, setSelectedRepository] =
|
||||
useState<ForgejoRepository | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ForgejoRepository | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRepositories = async () => {
|
||||
const webhookBaseUrl = useMemo(() => {
|
||||
try {
|
||||
return getApiBaseUrl();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRepositories = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await getForgejoRepositories();
|
||||
|
|
@ -48,15 +330,130 @@ export default function ForgejoRepositoriesPage() {
|
|||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isSignedIn) {
|
||||
fetchRepositories();
|
||||
}
|
||||
}, [auth.isSignedIn]);
|
||||
}, [auth.isSignedIn, fetchRepositories]);
|
||||
|
||||
const repositoryName = (repository: ForgejoRepository) =>
|
||||
repository.display_name || `${repository.owner}/${repository.repo}`;
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const id = setTimeout(() => setNotice(null), 8000);
|
||||
return () => clearTimeout(id);
|
||||
}, [notice]);
|
||||
|
||||
const activeRepositories = useMemo(
|
||||
() => repositories.filter((repo) => repo.active).length,
|
||||
[repositories],
|
||||
);
|
||||
const totalOpenIssues = useMemo(
|
||||
() =>
|
||||
repositories.reduce((total, repo) => total + repo.open_issues_count, 0),
|
||||
[repositories],
|
||||
);
|
||||
const webhookReady = useMemo(
|
||||
() =>
|
||||
repositories.filter((repo) => repo.active && repo.has_webhook_secret)
|
||||
.length,
|
||||
[repositories],
|
||||
);
|
||||
const syncErrors = useMemo(
|
||||
() => repositories.filter((repo) => repo.last_sync_error).length,
|
||||
[repositories],
|
||||
);
|
||||
const attentionRepositories = useMemo(
|
||||
() =>
|
||||
repositories.filter(
|
||||
(repo) =>
|
||||
repo.last_sync_error ||
|
||||
(repo.active && !repo.has_webhook_secret) ||
|
||||
(repo.active && !repo.last_sync_at),
|
||||
),
|
||||
[repositories],
|
||||
);
|
||||
const archivedRepositories = useMemo(
|
||||
() => repositories.filter((repo) => repo.is_archived).length,
|
||||
[repositories],
|
||||
);
|
||||
const linkedBoardCount = useMemo(
|
||||
() =>
|
||||
repositories.reduce(
|
||||
(total, repo) => total + repo.linked_boards.length,
|
||||
0,
|
||||
),
|
||||
[repositories],
|
||||
);
|
||||
const latestSync = useMemo(() => {
|
||||
const dates = repositories
|
||||
.map((repo) => repo.last_sync_at)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => new Date(value))
|
||||
.filter((date) => !Number.isNaN(date.getTime()));
|
||||
if (!dates.length) return null;
|
||||
return dates.reduce((latest, date) =>
|
||||
date.getTime() > latest.getTime() ? date : latest,
|
||||
);
|
||||
}, [repositories]);
|
||||
|
||||
const filteredRepositories = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return repositories.filter((repo) => {
|
||||
const matchesQuery =
|
||||
!query ||
|
||||
[
|
||||
repositoryName(repo),
|
||||
repo.owner,
|
||||
repo.repo,
|
||||
repo.connection?.name ?? "",
|
||||
repo.connection?.base_url ?? "",
|
||||
repo.default_branch,
|
||||
...repo.topics,
|
||||
...repo.linked_boards.map((board) => board.name),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
|
||||
if (!matchesQuery) return false;
|
||||
if (filter === "active") return repo.active;
|
||||
if (filter === "webhooks") return repo.active && !repo.has_webhook_secret;
|
||||
if (filter === "archived") return repo.is_archived;
|
||||
if (filter === "attention") return attentionRepositories.includes(repo);
|
||||
return true;
|
||||
});
|
||||
}, [attentionRepositories, filter, repositories, search]);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() => [
|
||||
{ key: "all" as const, label: "All", count: repositories.length },
|
||||
{
|
||||
key: "attention" as const,
|
||||
label: "Attention",
|
||||
count: attentionRepositories.length,
|
||||
},
|
||||
{ key: "active" as const, label: "Active", count: activeRepositories },
|
||||
{
|
||||
key: "webhooks" as const,
|
||||
label: "Missing Webhooks",
|
||||
count: repositories.filter(
|
||||
(repo) => repo.active && !repo.has_webhook_secret,
|
||||
).length,
|
||||
},
|
||||
{
|
||||
key: "archived" as const,
|
||||
label: "Archived",
|
||||
count: archivedRepositories,
|
||||
},
|
||||
],
|
||||
[
|
||||
activeRepositories,
|
||||
archivedRepositories,
|
||||
attentionRepositories.length,
|
||||
repositories,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDelete = (repository: ForgejoRepository) => {
|
||||
setDeleteError(null);
|
||||
|
|
@ -105,6 +502,38 @@ export default function ForgejoRepositoriesPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSyncAttention = async () => {
|
||||
const targets = attentionRepositories.filter((repo) => repo.active);
|
||||
if (!targets.length) return;
|
||||
setIsBulkSyncing(true);
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
await Promise.allSettled(
|
||||
targets.map(async (repo) => {
|
||||
try {
|
||||
await syncRepository(repo.id);
|
||||
succeeded++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const data = await getForgejoRepositories();
|
||||
setRepositories(data);
|
||||
setIsBulkSyncing(false);
|
||||
setNotice(
|
||||
failed === 0
|
||||
? {
|
||||
tone: "success",
|
||||
message: `${succeeded} repositor${succeeded === 1 ? "y" : "ies"} synced successfully.`,
|
||||
}
|
||||
: {
|
||||
tone: "error",
|
||||
message: `Sync completed: ${succeeded} succeeded, ${failed} failed.`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleValidateRepository = async (repository: ForgejoRepository) => {
|
||||
try {
|
||||
const result = await validateRepository(repository.id);
|
||||
|
|
@ -141,33 +570,140 @@ export default function ForgejoRepositoriesPage() {
|
|||
title="Git Project Repositories"
|
||||
description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked by Pipeline.`}
|
||||
stickyHeader
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{notice ? (
|
||||
<div
|
||||
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${
|
||||
notice.tone === "success"
|
||||
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
|
||||
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"
|
||||
}`}
|
||||
>
|
||||
{notice.tone === "success" ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span>{notice.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-medium text-muted">Repositories</h2>
|
||||
headerActions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/settings/git-projects">
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={() => router.push("/git-projects/repositories/new")}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchRepositories}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Link href="/git-projects/repositories/new">
|
||||
<Button size="sm">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Add Repository
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{notice ? <NoticeBanner notice={notice} /> : null}
|
||||
|
||||
<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-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Badge
|
||||
variant={attentionRepositories.length ? "warning" : "success"}
|
||||
className="w-fit"
|
||||
>
|
||||
{attentionRepositories.length
|
||||
? `${attentionRepositories.length} need review`
|
||||
: "Healthy"}
|
||||
</Badge>
|
||||
<h2 className="mt-3 font-heading text-2xl font-semibold text-strong">
|
||||
Repository Operations
|
||||
</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
||||
Track provider reachability, webhook coverage, sync freshness,
|
||||
and board usage from one management surface.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSyncAttention}
|
||||
disabled={
|
||||
isBulkSyncing ||
|
||||
attentionRepositories.filter((repo) => repo.active)
|
||||
.length === 0
|
||||
}
|
||||
>
|
||||
{isBulkSyncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
Sync Attention
|
||||
</Button>
|
||||
<Link href="/git-projects">
|
||||
<Button type="button" variant="outline">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Git Projects
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<GitBranch className="h-4 w-4" />}
|
||||
label="Repositories"
|
||||
value={`${activeRepositories}/${repositories.length}`}
|
||||
caption="Active tracked repositories."
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CircleDot className="h-4 w-4" />}
|
||||
label="Open Issues"
|
||||
value={formatCompactNumber(totalOpenIssues)}
|
||||
caption="Reported by Forgejo."
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Webhook className="h-4 w-4" />}
|
||||
label="Webhooks"
|
||||
value={`${webhookReady}/${activeRepositories}`}
|
||||
caption="Active repositories with secrets."
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
label="Latest Sync"
|
||||
value={formatTimestamp(latestSync?.toISOString() ?? null)}
|
||||
caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search repositories, connections, topics, boards..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filterOptions.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
type="button"
|
||||
variant={filter === option.key ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilter(option.key)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{option.label}
|
||||
<span className="text-xs text-muted">{option.count}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
|
||||
{error ? (
|
||||
<div className="p-8 text-center">
|
||||
|
|
@ -175,9 +711,10 @@ export default function ForgejoRepositoriesPage() {
|
|||
</div>
|
||||
) : (
|
||||
<ForgejoRepositoriesTable
|
||||
repositories={repositories}
|
||||
repositories={filteredRepositories}
|
||||
isLoading={isLoading}
|
||||
onDelete={handleDelete}
|
||||
onViewDetails={setSelectedRepository}
|
||||
onSync={handleSync}
|
||||
onValidate={handleValidateRepository}
|
||||
/>
|
||||
|
|
@ -185,6 +722,13 @@ export default function ForgejoRepositoriesPage() {
|
|||
</div>
|
||||
</div>
|
||||
</DashboardPageLayout>
|
||||
<RepositoryDetailsDialog
|
||||
repository={selectedRepository}
|
||||
webhookBaseUrl={webhookBaseUrl}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedRepository(null);
|
||||
}}
|
||||
/>
|
||||
<ConfirmActionDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue