Modernized git-projects/repositories/
This commit is contained in:
parent
1c1bade3ca
commit
19a6b8fda8
|
|
@ -1,14 +1,37 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import Link from "next/link";
|
||||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
CircleDot,
|
||||||
|
Clock,
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
GitBranch,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Tags,
|
||||||
|
Webhook,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable";
|
import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable";
|
||||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
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 {
|
import {
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
deleteForgejoRepository,
|
deleteForgejoRepository,
|
||||||
|
|
@ -17,46 +40,420 @@ import {
|
||||||
type ForgejoRepository,
|
type ForgejoRepository,
|
||||||
} from "@/lib/api-forgejo";
|
} from "@/lib/api-forgejo";
|
||||||
|
|
||||||
export default function ForgejoRepositoriesPage() {
|
type Notice = {
|
||||||
const router = useRouter();
|
tone: "success" | "error";
|
||||||
const auth = useAuth();
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RepositoryFilter =
|
||||||
|
| "all"
|
||||||
|
| "attention"
|
||||||
|
| "active"
|
||||||
|
| "webhooks"
|
||||||
|
| "archived";
|
||||||
|
|
||||||
|
const repositoryName = (repository: ForgejoRepository) =>
|
||||||
|
repository.display_name || `${repository.owner}/${repository.repo}`;
|
||||||
|
|
||||||
|
const formatTimestamp = (value: string | null) => {
|
||||||
|
if (!value) return "Never";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCompactNumber = (value: number) =>
|
||||||
|
new Intl.NumberFormat(undefined, { notation: "compact" }).format(value);
|
||||||
|
|
||||||
|
const labelColor = (color: string) =>
|
||||||
|
color.startsWith("#") ? color : `#${color}`;
|
||||||
|
|
||||||
|
function NoticeBanner({ notice }: { notice: Notice }) {
|
||||||
|
return (
|
||||||
|
<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 [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isBulkSyncing, setIsBulkSyncing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<{
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
tone: "success" | "error";
|
const [search, setSearch] = useState("");
|
||||||
message: string;
|
const [filter, setFilter] = useState<RepositoryFilter>("all");
|
||||||
} | null>(null);
|
const [selectedRepository, setSelectedRepository] =
|
||||||
|
useState<ForgejoRepository | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ForgejoRepository | null>(
|
const [deleteTarget, setDeleteTarget] = useState<ForgejoRepository | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const webhookBaseUrl = useMemo(() => {
|
||||||
const fetchRepositories = async () => {
|
try {
|
||||||
try {
|
return getApiBaseUrl();
|
||||||
setIsLoading(true);
|
} catch {
|
||||||
const data = await getForgejoRepositories();
|
return "";
|
||||||
setRepositories(data);
|
}
|
||||||
setError(null);
|
}, []);
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err.message : "Failed to load repositories",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
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) {
|
if (auth.isSignedIn) {
|
||||||
fetchRepositories();
|
fetchRepositories();
|
||||||
}
|
}
|
||||||
}, [auth.isSignedIn]);
|
}, [auth.isSignedIn, fetchRepositories]);
|
||||||
|
|
||||||
const repositoryName = (repository: ForgejoRepository) =>
|
useEffect(() => {
|
||||||
repository.display_name || `${repository.owner}/${repository.repo}`;
|
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) => {
|
const handleDelete = (repository: ForgejoRepository) => {
|
||||||
setDeleteError(null);
|
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) => {
|
const handleValidateRepository = async (repository: ForgejoRepository) => {
|
||||||
try {
|
try {
|
||||||
const result = await validateRepository(repository.id);
|
const result = await validateRepository(repository.id);
|
||||||
|
|
@ -141,33 +570,140 @@ export default function ForgejoRepositoriesPage() {
|
||||||
title="Git Project Repositories"
|
title="Git Project Repositories"
|
||||||
description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked by Pipeline.`}
|
description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked by Pipeline.`}
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
headerActions={
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-wrap gap-2">
|
||||||
{notice ? (
|
<Link href="/settings/git-projects">
|
||||||
<div
|
<Button variant="outline" size="sm">
|
||||||
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${
|
<Settings className="h-4 w-4" />
|
||||||
notice.tone === "success"
|
Settings
|
||||||
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
|
</Button>
|
||||||
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"
|
</Link>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push("/git-projects/repositories/new")}
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchRepositories}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
Add Repository
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
<Link href="/git-projects/repositories/new">
|
||||||
|
<Button size="sm">
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
Add Repository
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</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">
|
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
|
|
@ -175,9 +711,10 @@ export default function ForgejoRepositoriesPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ForgejoRepositoriesTable
|
<ForgejoRepositoriesTable
|
||||||
repositories={repositories}
|
repositories={filteredRepositories}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onViewDetails={setSelectedRepository}
|
||||||
onSync={handleSync}
|
onSync={handleSync}
|
||||||
onValidate={handleValidateRepository}
|
onValidate={handleValidateRepository}
|
||||||
/>
|
/>
|
||||||
|
|
@ -185,6 +722,13 @@ export default function ForgejoRepositoriesPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
|
<RepositoryDetailsDialog
|
||||||
|
repository={selectedRepository}
|
||||||
|
webhookBaseUrl={webhookBaseUrl}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSelectedRepository(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ConfirmActionDialog
|
<ConfirmActionDialog
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue