table polish
This commit is contained in:
parent
fb9d4a907f
commit
bd153b7a87
|
|
@ -4,6 +4,7 @@ export const dynamic = "force-dynamic";
|
|||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Folder, Plus } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -120,17 +121,30 @@ export default function BoardsPage() {
|
|||
title="Boards"
|
||||
description={`Manage boards and task workflows. ${boards.length} board${boards.length === 1 ? "" : "s"} total.`}
|
||||
headerActions={
|
||||
boards.length > 0 && isAdmin ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/boards/new"
|
||||
href="/board-groups"
|
||||
className={buttonVariants({
|
||||
size: "md",
|
||||
variant: "primary",
|
||||
variant: "outline",
|
||||
})}
|
||||
>
|
||||
Create board
|
||||
<Folder className="h-4 w-4" />
|
||||
Groups
|
||||
</Link>
|
||||
) : null
|
||||
{boards.length > 0 && isAdmin ? (
|
||||
<Link
|
||||
href="/boards/new"
|
||||
className={buttonVariants({
|
||||
size: "md",
|
||||
variant: "primary",
|
||||
})}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create board
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
stickyHeader
|
||||
>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -73,7 +73,7 @@ export default function ForgejoRepositoriesEditPage({
|
|||
|
||||
const handleSubmit = async (values: ForgejoRepositoryUpdate) => {
|
||||
await updateForgejoRepository(params.repositoryId, values);
|
||||
router.push("/git-projects/repositories");
|
||||
router.push("/git-projects");
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
|
|
@ -81,7 +81,7 @@ export default function ForgejoRepositoriesEditPage({
|
|||
setDeleteError(null);
|
||||
try {
|
||||
await deleteForgejoRepository(params.repositoryId);
|
||||
router.push("/git-projects/repositories");
|
||||
router.push("/git-projects");
|
||||
} catch (err) {
|
||||
setDeleteError(
|
||||
err instanceof Error ? err.message : "Failed to delete repository",
|
||||
|
|
@ -96,8 +96,8 @@ export default function ForgejoRepositoriesEditPage({
|
|||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
message: "Sign in to edit a tracked repository.",
|
||||
forceRedirectUrl: "/git-projects/repositories",
|
||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||
forceRedirectUrl: "/git-projects",
|
||||
signUpForceRedirectUrl: "/git-projects",
|
||||
}}
|
||||
title="Loading…"
|
||||
stickyHeader
|
||||
|
|
@ -112,8 +112,8 @@ export default function ForgejoRepositoriesEditPage({
|
|||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
message: "Sign in to edit a tracked repository.",
|
||||
forceRedirectUrl: "/git-projects/repositories",
|
||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||
forceRedirectUrl: "/git-projects",
|
||||
signUpForceRedirectUrl: "/git-projects",
|
||||
}}
|
||||
title="Error"
|
||||
stickyHeader
|
||||
|
|
@ -138,8 +138,8 @@ export default function ForgejoRepositoriesEditPage({
|
|||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
message: "Sign in to edit a tracked repository.",
|
||||
forceRedirectUrl: "/git-projects/repositories",
|
||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||
forceRedirectUrl: "/git-projects",
|
||||
signUpForceRedirectUrl: "/git-projects",
|
||||
}}
|
||||
title={`Edit Git Project Repository: ${repository.display_name || repository.repo}`}
|
||||
description="Update the repository settings Pipeline uses for Git Projects."
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default function ForgejoRepositoriesNewPage() {
|
|||
|
||||
const handleSubmit = async (values: ForgejoRepositoryCreate) => {
|
||||
await createForgejoRepository(values);
|
||||
router.push("/git-projects/repositories");
|
||||
router.push("/git-projects");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,819 +1,5 @@
|
|||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Tags,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||
import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable";
|
||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getApiBaseUrl } from "@/lib/api-base";
|
||||
import {
|
||||
getForgejoRepositories,
|
||||
deleteForgejoRepository,
|
||||
syncRepository,
|
||||
validateRepository,
|
||||
type ForgejoRepository,
|
||||
} from "@/lib/api-forgejo";
|
||||
|
||||
type Notice = {
|
||||
tone: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
type RepositoryFilter =
|
||||
| "all"
|
||||
| "attention"
|
||||
| "active"
|
||||
| "webhooks"
|
||||
| "archived";
|
||||
|
||||
type StatTone = "blue" | "green" | "amber" | "rose" | "violet";
|
||||
|
||||
const statToneClasses: Record<
|
||||
StatTone,
|
||||
{ card: string; icon: string; glow: string }
|
||||
> = {
|
||||
blue: {
|
||||
card: "border-[color:rgba(96,165,250,0.34)] bg-[linear-gradient(145deg,rgba(96,165,250,0.16),rgba(15,23,36,0.94)_42%,var(--surface))]",
|
||||
icon: "border-[color:rgba(96,165,250,0.3)] bg-[color:rgba(96,165,250,0.14)] text-[color:var(--accent-strong)]",
|
||||
glow: "bg-[color:rgba(96,165,250,0.22)]",
|
||||
},
|
||||
green: {
|
||||
card: "border-[color:rgba(52,211,153,0.34)] bg-[linear-gradient(145deg,rgba(52,211,153,0.15),rgba(15,23,36,0.94)_42%,var(--surface))]",
|
||||
icon: "border-[color:rgba(52,211,153,0.3)] bg-[color:rgba(52,211,153,0.14)] text-[color:var(--success)]",
|
||||
glow: "bg-[color:rgba(52,211,153,0.22)]",
|
||||
},
|
||||
amber: {
|
||||
card: "border-[color:rgba(251,191,36,0.36)] bg-[linear-gradient(145deg,rgba(251,191,36,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]",
|
||||
icon: "border-[color:rgba(251,191,36,0.32)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]",
|
||||
glow: "bg-[color:rgba(251,191,36,0.2)]",
|
||||
},
|
||||
rose: {
|
||||
card: "border-[color:rgba(248,113,113,0.34)] bg-[linear-gradient(145deg,rgba(248,113,113,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]",
|
||||
icon: "border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.13)] text-[color:var(--danger)]",
|
||||
glow: "bg-[color:rgba(248,113,113,0.2)]",
|
||||
},
|
||||
violet: {
|
||||
card: "border-[color:rgba(168,85,247,0.32)] bg-[linear-gradient(145deg,rgba(168,85,247,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]",
|
||||
icon: "border-[color:rgba(168,85,247,0.28)] bg-[color:rgba(168,85,247,0.13)] text-[color:rgb(196,181,253)]",
|
||||
glow: "bg-[color:rgba(168,85,247,0.2)]",
|
||||
},
|
||||
};
|
||||
|
||||
const repositoryName = (repository: ForgejoRepository) =>
|
||||
repository.display_name || `${repository.owner}/${repository.repo}`;
|
||||
|
||||
const formatTimestamp = (value: string | null) => {
|
||||
if (!value) return "Never";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatCompactNumber = (value: number) =>
|
||||
new Intl.NumberFormat(undefined, { notation: "compact" }).format(value);
|
||||
|
||||
const labelColor = (color: string) =>
|
||||
color.startsWith("#") ? color : `#${color}`;
|
||||
|
||||
function NoticeBanner({ notice }: { notice: Notice }) {
|
||||
return (
|
||||
<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,
|
||||
tone = "blue",
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
tone?: StatTone;
|
||||
}) {
|
||||
const colors = statToneClasses[tone];
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-xl border p-4 shadow-lush ${colors.card}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none absolute left-4 right-4 top-0 h-px ${colors.glow}`}
|
||||
/>
|
||||
<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 p-2 ${colors.icon}`}>{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."
|
||||
tone="amber"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<GitBranch className="h-4 w-4" />}
|
||||
label="Branch"
|
||||
value={repository.default_branch || "Unknown"}
|
||||
caption="Default branch."
|
||||
tone="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Webhook className="h-4 w-4" />}
|
||||
label="Webhook"
|
||||
value={repository.has_webhook_secret ? "Ready" : "Missing"}
|
||||
caption="Stored secret status."
|
||||
tone={repository.has_webhook_secret ? "green" : "rose"}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
label="Synced"
|
||||
value={formatTimestamp(repository.last_sync_at)}
|
||||
caption="Last sync timestamp."
|
||||
tone={repository.last_sync_error ? "rose" : "violet"}
|
||||
/>
|
||||
</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);
|
||||
|
||||
const webhookBaseUrl = useMemo(() => {
|
||||
try {
|
||||
return getApiBaseUrl();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRepositories = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await getForgejoRepositories();
|
||||
setRepositories(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load repositories",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isSignedIn) {
|
||||
fetchRepositories();
|
||||
}
|
||||
}, [auth.isSignedIn, fetchRepositories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const id = setTimeout(() => setNotice(null), 8000);
|
||||
return () => clearTimeout(id);
|
||||
}, [notice]);
|
||||
|
||||
const activeRepositories = useMemo(
|
||||
() => repositories.filter((repo) => repo.active).length,
|
||||
[repositories],
|
||||
);
|
||||
const totalOpenIssues = useMemo(
|
||||
() =>
|
||||
repositories.reduce((total, repo) => total + repo.open_issues_count, 0),
|
||||
[repositories],
|
||||
);
|
||||
const webhookReady = useMemo(
|
||||
() =>
|
||||
repositories.filter((repo) => repo.active && repo.has_webhook_secret)
|
||||
.length,
|
||||
[repositories],
|
||||
);
|
||||
const syncErrors = useMemo(
|
||||
() => repositories.filter((repo) => repo.last_sync_error).length,
|
||||
[repositories],
|
||||
);
|
||||
const attentionRepositories = useMemo(
|
||||
() =>
|
||||
repositories.filter(
|
||||
(repo) =>
|
||||
repo.last_sync_error ||
|
||||
(repo.active && !repo.has_webhook_secret) ||
|
||||
(repo.active && !repo.last_sync_at),
|
||||
),
|
||||
[repositories],
|
||||
);
|
||||
const archivedRepositories = useMemo(
|
||||
() => repositories.filter((repo) => repo.is_archived).length,
|
||||
[repositories],
|
||||
);
|
||||
const linkedBoardCount = useMemo(
|
||||
() =>
|
||||
repositories.reduce(
|
||||
(total, repo) => total + repo.linked_boards.length,
|
||||
0,
|
||||
),
|
||||
[repositories],
|
||||
);
|
||||
const latestSync = useMemo(() => {
|
||||
const dates = repositories
|
||||
.map((repo) => repo.last_sync_at)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => new Date(value))
|
||||
.filter((date) => !Number.isNaN(date.getTime()));
|
||||
if (!dates.length) return null;
|
||||
return dates.reduce((latest, date) =>
|
||||
date.getTime() > latest.getTime() ? date : latest,
|
||||
);
|
||||
}, [repositories]);
|
||||
|
||||
const filteredRepositories = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return repositories.filter((repo) => {
|
||||
const matchesQuery =
|
||||
!query ||
|
||||
[
|
||||
repositoryName(repo),
|
||||
repo.owner,
|
||||
repo.repo,
|
||||
repo.connection?.name ?? "",
|
||||
repo.connection?.base_url ?? "",
|
||||
repo.default_branch,
|
||||
...repo.topics,
|
||||
...repo.linked_boards.map((board) => board.name),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
|
||||
if (!matchesQuery) return false;
|
||||
if (filter === "active") return repo.active;
|
||||
if (filter === "webhooks") return repo.active && !repo.has_webhook_secret;
|
||||
if (filter === "archived") return repo.is_archived;
|
||||
if (filter === "attention") return attentionRepositories.includes(repo);
|
||||
return true;
|
||||
});
|
||||
}, [attentionRepositories, filter, repositories, search]);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() => [
|
||||
{ key: "all" as const, label: "All", count: repositories.length },
|
||||
{
|
||||
key: "attention" as const,
|
||||
label: "Attention",
|
||||
count: attentionRepositories.length,
|
||||
},
|
||||
{ key: "active" as const, label: "Active", count: activeRepositories },
|
||||
{
|
||||
key: "webhooks" as const,
|
||||
label: "Missing Webhooks",
|
||||
count: repositories.filter(
|
||||
(repo) => repo.active && !repo.has_webhook_secret,
|
||||
).length,
|
||||
},
|
||||
{
|
||||
key: "archived" as const,
|
||||
label: "Archived",
|
||||
count: archivedRepositories,
|
||||
},
|
||||
],
|
||||
[
|
||||
activeRepositories,
|
||||
archivedRepositories,
|
||||
attentionRepositories.length,
|
||||
repositories,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDelete = (repository: ForgejoRepository) => {
|
||||
setDeleteError(null);
|
||||
setDeleteTarget(repository);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteForgejoRepository(deleteTarget.id);
|
||||
setRepositories((prev) => prev.filter((r) => r.id !== deleteTarget.id));
|
||||
setNotice({
|
||||
tone: "success",
|
||||
message: `Deleted "${repositoryName(deleteTarget)}".`,
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
setDeleteError(
|
||||
err instanceof Error ? err.message : "Failed to delete repository",
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (repository: ForgejoRepository) => {
|
||||
try {
|
||||
const result = await syncRepository(repository.id);
|
||||
setNotice({
|
||||
tone: "success",
|
||||
message: `${repositoryName(repository)} synced: ${result.created} created, ${result.updated} updated, ${result.open} open, ${result.closed} closed.`,
|
||||
});
|
||||
// Refetch to update last_sync_at
|
||||
const data = await getForgejoRepositories();
|
||||
setRepositories(data);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setNotice({
|
||||
tone: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to sync repository",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncAttention = async () => {
|
||||
const targets = attentionRepositories.filter((repo) => repo.active);
|
||||
if (!targets.length) return;
|
||||
setIsBulkSyncing(true);
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
await Promise.allSettled(
|
||||
targets.map(async (repo) => {
|
||||
try {
|
||||
await syncRepository(repo.id);
|
||||
succeeded++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const data = await getForgejoRepositories();
|
||||
setRepositories(data);
|
||||
setIsBulkSyncing(false);
|
||||
setNotice(
|
||||
failed === 0
|
||||
? {
|
||||
tone: "success",
|
||||
message: `${succeeded} repositor${succeeded === 1 ? "y" : "ies"} synced successfully.`,
|
||||
}
|
||||
: {
|
||||
tone: "error",
|
||||
message: `Sync completed: ${succeeded} succeeded, ${failed} failed.`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleValidateRepository = async (repository: ForgejoRepository) => {
|
||||
try {
|
||||
const result = await validateRepository(repository.id);
|
||||
if (result.status.ok) {
|
||||
setNotice({
|
||||
tone: "success",
|
||||
message: `${repositoryName(repository)} is reachable from Pipeline.`,
|
||||
});
|
||||
} else {
|
||||
setNotice({
|
||||
tone: "error",
|
||||
message: `Repository validation failed: ${result.status.error_message || "Unknown error"}`,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
setNotice({
|
||||
tone: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to validate repository",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
message: "Sign in to manage Git Project repositories.",
|
||||
forceRedirectUrl: "/git-projects/repositories",
|
||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
||||
}}
|
||||
title="Git Project Repositories"
|
||||
description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked by Pipeline.`}
|
||||
stickyHeader
|
||||
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
|
||||
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="relative overflow-hidden rounded-xl border border-[color:rgba(96,165,250,0.28)] bg-[linear-gradient(135deg,rgba(96,165,250,0.16),rgba(52,211,153,0.09)_34%,rgba(251,191,36,0.08)_68%,var(--surface)_100%)] p-4 shadow-lush md:p-5">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,0.85),rgba(52,211,153,0.85),rgba(251,191,36,0))]" />
|
||||
<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 shadow-[0_0_24px_rgba(96,165,250,0.16)]"
|
||||
>
|
||||
{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."
|
||||
tone="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CircleDot className="h-4 w-4" />}
|
||||
label="Open Issues"
|
||||
value={formatCompactNumber(totalOpenIssues)}
|
||||
caption="Reported by Forgejo."
|
||||
tone="amber"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Webhook className="h-4 w-4" />}
|
||||
label="Webhooks"
|
||||
value={`${webhookReady}/${activeRepositories}`}
|
||||
caption="Active repositories with secrets."
|
||||
tone={webhookReady === activeRepositories ? "green" : "rose"}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
label="Latest Sync"
|
||||
value={formatTimestamp(latestSync?.toISOString() ?? null)}
|
||||
caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`}
|
||||
tone={syncErrors ? "rose" : "violet"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-[color:var(--border)] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0)_72%),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 ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilter(option.key)}
|
||||
className={`gap-1.5 ${
|
||||
filter === option.key
|
||||
? "shadow-[0_0_24px_rgba(96,165,250,0.2)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
<span
|
||||
className={`rounded-full px-1.5 text-xs ${
|
||||
filter === option.key
|
||||
? "bg-[color:rgba(7,11,18,0.18)] text-inherit"
|
||||
: "bg-[color:var(--surface-muted)] text-muted"
|
||||
}`}
|
||||
>
|
||||
{option.count}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-[color:rgba(96,165,250,0.18)] bg-[linear-gradient(180deg,rgba(96,165,250,0.07),rgba(15,23,36,0)_160px),var(--surface)] shadow-lush">
|
||||
{error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-[color:var(--danger)]">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ForgejoRepositoriesTable
|
||||
repositories={filteredRepositories}
|
||||
isLoading={isLoading}
|
||||
onDelete={handleDelete}
|
||||
onViewDetails={setSelectedRepository}
|
||||
onSync={handleSync}
|
||||
onValidate={handleValidateRepository}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPageLayout>
|
||||
<RepositoryDetailsDialog
|
||||
repository={selectedRepository}
|
||||
webhookBaseUrl={webhookBaseUrl}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedRepository(null);
|
||||
}}
|
||||
/>
|
||||
<ConfirmActionDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
title="Delete Git Project repository"
|
||||
description={
|
||||
deleteTarget
|
||||
? `Delete "${repositoryName(deleteTarget)}" from Pipeline? Synced issue records for this repository will be removed.`
|
||||
: ""
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
isConfirming={isDeleting}
|
||||
errorMessage={deleteError}
|
||||
confirmLabel="Delete Repository"
|
||||
confirmingLabel="Deleting…"
|
||||
confirmClassName="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
cancelLabel="Keep Repository"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
export default function GitProjectRepositoriesRedirectPage() {
|
||||
redirect("/git-projects");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1340,7 +1340,7 @@ export default function GitProjectSettingsPage() {
|
|||
, and paste a secret. Store the same secret on the repository
|
||||
record in Pipeline via{" "}
|
||||
<Link
|
||||
href="/git-projects/repositories"
|
||||
href="/git-projects"
|
||||
className="text-[color:var(--accent)] underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
Edit Repository
|
||||
|
|
|
|||
|
|
@ -11,14 +11,21 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Building2,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Mail,
|
||||
Network,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
Tags,
|
||||
Trash2,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
|
|
@ -35,16 +42,71 @@ import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import SearchableSelect from "@/components/ui/searchable-select";
|
||||
import { getSupportedTimezones } from "@/lib/timezones";
|
||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||
|
||||
type ClerkGlobal = {
|
||||
signOut?: (options?: { redirectUrl?: string }) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type SettingsLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
function SettingsLinkSection({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: SettingsLink[];
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="group flex items-center gap-4 py-4 first:pt-0 last:pb-0 focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground transition group-hover:border-[color:var(--accent)] group-hover:text-[color:var(--accent)] group-focus-visible:ring-2 group-focus-visible:ring-ring">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-[color:var(--accent)]" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { isSignedIn } = useAuth();
|
||||
const { user } = useUser();
|
||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [timezone, setTimezone] = useState<string | null>(null);
|
||||
|
|
@ -56,7 +118,9 @@ export default function SettingsPage() {
|
|||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [forgejoSyncing, setForgejoSyncing] = useState(false);
|
||||
const [forgejoSyncError, setForgejoSyncError] = useState<string | null>(null);
|
||||
const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState<string | null>(null);
|
||||
const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const meQuery = useGetMeApiV1UsersMeGet<
|
||||
getMeApiV1UsersMeGetResponse,
|
||||
|
|
@ -141,7 +205,9 @@ export default function SettingsPage() {
|
|||
};
|
||||
|
||||
const handleDisableForgejoProfile = async () => {
|
||||
await updateMeMutation.mutateAsync({ data: { use_forgejo_profile: false } });
|
||||
await updateMeMutation.mutateAsync({
|
||||
data: { use_forgejo_profile: false },
|
||||
});
|
||||
setForgejoSyncSuccess(null);
|
||||
setForgejoSyncError(null);
|
||||
};
|
||||
|
|
@ -175,6 +241,80 @@ export default function SettingsPage() {
|
|||
|
||||
const isSaving = updateMeMutation.isPending;
|
||||
|
||||
const workspaceItems: SettingsLink[] = [
|
||||
{
|
||||
href: "/organization",
|
||||
label: "Organization",
|
||||
description: "Manage members, invites, access, and organization details.",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
href: "/board-groups",
|
||||
label: "Board groups",
|
||||
description:
|
||||
"Group related boards so agents and operators share context.",
|
||||
icon: Folder,
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
href: "/tags",
|
||||
label: "Tags",
|
||||
description: "Maintain reusable task labels used across boards.",
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
href: "/custom-fields",
|
||||
label: "Custom fields",
|
||||
description:
|
||||
"Configure organization-level task metadata and board bindings.",
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const integrationItems: SettingsLink[] = [
|
||||
{
|
||||
href: "/settings/ai-providers",
|
||||
label: "AI providers",
|
||||
description:
|
||||
"Manage model credentials, endpoints, and provider usage tracking.",
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
href: "/settings/git-projects",
|
||||
label: "Git project settings",
|
||||
description:
|
||||
"Review Forgejo sync health and Git Project automation settings.",
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
href: "/git-projects/connections",
|
||||
label: "Git connections",
|
||||
description: "Connect Git providers used for repository and issue sync.",
|
||||
icon: GitBranch,
|
||||
},
|
||||
];
|
||||
|
||||
const operationItems: SettingsLink[] = isAdmin
|
||||
? [
|
||||
{
|
||||
href: "/agents",
|
||||
label: "Agents",
|
||||
description:
|
||||
"Provision and monitor the agents available to this organization.",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
href: "/gateways",
|
||||
label: "Gateways",
|
||||
description: "Manage gateway connections used by boards and agents.",
|
||||
icon: Network,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPageLayout
|
||||
|
|
@ -184,7 +324,7 @@ export default function SettingsPage() {
|
|||
signUpForceRedirectUrl: "/settings",
|
||||
}}
|
||||
title="Settings"
|
||||
description="Update your profile and account preferences."
|
||||
description="Manage your profile, workspace configuration, and integrations."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
|
|
@ -351,48 +491,23 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
AI Providers
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
API keys and endpoints for Claude, Codex/OpenAI, and Ollama
|
||||
(local, on-prem, or cloud). Add multiple accounts to track
|
||||
usage separately.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/settings/ai-providers">
|
||||
<Button type="button" variant="outline">
|
||||
Manage Providers
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<SettingsLinkSection
|
||||
title="Workspace"
|
||||
description="Set up the organization structure behind your boards."
|
||||
items={workspaceItems}
|
||||
/>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
Git Projects
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Manage Forgejo connections, tracked repositories, and issue
|
||||
sync.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/git-projects/connections">
|
||||
<Button type="button" variant="outline">
|
||||
Manage Connections
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<SettingsLinkSection
|
||||
title="Integrations"
|
||||
description="Connect external systems and configure model providers."
|
||||
items={integrationItems}
|
||||
/>
|
||||
|
||||
<SettingsLinkSection
|
||||
title="Operations"
|
||||
description="Admin tools for runtime infrastructure and agent access."
|
||||
items={operationItems}
|
||||
/>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ export function AssignIssueAgentDialog({
|
|||
{isLinkingRepository ? "Linking..." : "Link and continue"}
|
||||
</Button>
|
||||
<Link
|
||||
href="/git-projects/repositories"
|
||||
href="/git-projects"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-semibold text-strong transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
|
||||
>
|
||||
Manage Git repositories
|
||||
|
|
|
|||
|
|
@ -92,32 +92,42 @@ const repositoryLabel = (repository: ForgejoRepository): string =>
|
|||
|
||||
// ── Tone → icon badge (background + text color) ────────────────────────────
|
||||
const toneIconClasses: Record<MetricTone, string> = {
|
||||
amber: "border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]",
|
||||
success: "border-[color:rgba(16,185,129,0.35)] bg-[color:rgba(16,185,129,0.14)] text-[color:#10B981]",
|
||||
danger: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.14)] text-[color:var(--danger)]",
|
||||
cyan: "border-[color:rgba(6,182,212,0.35)] bg-[color:rgba(6,182,212,0.14)] text-[color:#06B6D4]",
|
||||
slate: "border-[color:rgba(100,116,139,0.35)] bg-[color:rgba(100,116,139,0.12)] text-[color:#64748B]",
|
||||
muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
|
||||
amber:
|
||||
"border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]",
|
||||
success:
|
||||
"border-[color:rgba(16,185,129,0.35)] bg-[color:rgba(16,185,129,0.14)] text-[color:#10B981]",
|
||||
danger:
|
||||
"border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.14)] text-[color:var(--danger)]",
|
||||
cyan: "border-[color:rgba(6,182,212,0.35)] bg-[color:rgba(6,182,212,0.14)] text-[color:#06B6D4]",
|
||||
slate:
|
||||
"border-[color:rgba(100,116,139,0.35)] bg-[color:rgba(100,116,139,0.12)] text-[color:#64748B]",
|
||||
muted:
|
||||
"border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
|
||||
};
|
||||
|
||||
// ── Tone → card (full background + border + hover glow) ───────────────────
|
||||
const toneCardClasses: Record<MetricTone, string> = {
|
||||
amber: "border-[color:rgba(245,158,11,0.28)] bg-[color:rgba(245,158,11,0.06)] hover:border-[color:rgba(245,158,11,0.55)] hover:shadow-[0_4px_24px_rgba(245,158,11,0.18)]",
|
||||
success: "border-[color:rgba(16,185,129,0.28)] bg-[color:rgba(16,185,129,0.06)] hover:border-[color:rgba(16,185,129,0.55)] hover:shadow-[0_4px_24px_rgba(16,185,129,0.18)]",
|
||||
danger: "border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.06)] hover:border-[color:rgba(248,113,113,0.55)] hover:shadow-[0_4px_24px_rgba(248,113,113,0.18)]",
|
||||
cyan: "border-[color:rgba(6,182,212,0.28)] bg-[color:rgba(6,182,212,0.06)] hover:border-[color:rgba(6,182,212,0.55)] hover:shadow-[0_4px_24px_rgba(6,182,212,0.18)]",
|
||||
slate: "border-[color:rgba(100,116,139,0.22)] bg-[color:rgba(100,116,139,0.05)] hover:border-[color:rgba(100,116,139,0.40)] hover:shadow-[0_4px_16px_rgba(100,116,139,0.12)]",
|
||||
muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] hover:border-[color:var(--border-strong)] hover:shadow-sm",
|
||||
amber:
|
||||
"border-[color:rgba(245,158,11,0.28)] bg-[color:rgba(245,158,11,0.06)] hover:border-[color:rgba(245,158,11,0.55)] hover:shadow-[0_4px_24px_rgba(245,158,11,0.18)]",
|
||||
success:
|
||||
"border-[color:rgba(16,185,129,0.28)] bg-[color:rgba(16,185,129,0.06)] hover:border-[color:rgba(16,185,129,0.55)] hover:shadow-[0_4px_24px_rgba(16,185,129,0.18)]",
|
||||
danger:
|
||||
"border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.06)] hover:border-[color:rgba(248,113,113,0.55)] hover:shadow-[0_4px_24px_rgba(248,113,113,0.18)]",
|
||||
cyan: "border-[color:rgba(6,182,212,0.28)] bg-[color:rgba(6,182,212,0.06)] hover:border-[color:rgba(6,182,212,0.55)] hover:shadow-[0_4px_24px_rgba(6,182,212,0.18)]",
|
||||
slate:
|
||||
"border-[color:rgba(100,116,139,0.22)] bg-[color:rgba(100,116,139,0.05)] hover:border-[color:rgba(100,116,139,0.40)] hover:shadow-[0_4px_16px_rgba(100,116,139,0.12)]",
|
||||
muted:
|
||||
"border-[color:var(--border)] bg-[color:var(--surface-muted)] hover:border-[color:var(--border-strong)] hover:shadow-sm",
|
||||
};
|
||||
|
||||
// ── Tone → value text color ────────────────────────────────────────────────
|
||||
const toneValueClasses: Record<MetricTone, string> = {
|
||||
amber: "text-[color:#F59E0B]",
|
||||
amber: "text-[color:#F59E0B]",
|
||||
success: "text-[color:#10B981]",
|
||||
danger: "text-[color:var(--danger)]",
|
||||
cyan: "text-[color:#06B6D4]",
|
||||
slate: "text-[color:#64748B]",
|
||||
muted: "text-strong",
|
||||
danger: "text-[color:var(--danger)]",
|
||||
cyan: "text-[color:#06B6D4]",
|
||||
slate: "text-[color:#64748B]",
|
||||
muted: "text-strong",
|
||||
};
|
||||
|
||||
// ── Card builder: Last Sync Health ─────────────────────────────────────────
|
||||
|
|
@ -132,25 +142,28 @@ function buildSyncHealthCard(
|
|||
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
|
||||
(count) => Number(count) > 0,
|
||||
).length;
|
||||
const metricSyncDates = Object.values(metrics?.last_sync_timestamps ?? {}).flatMap(
|
||||
(v) => { const d = parseDate(v); return d ? [d] : []; },
|
||||
);
|
||||
const repositorySyncDates = repositories.flatMap(
|
||||
(r) => { const d = parseDate(r.last_sync_at); return d ? [d] : []; },
|
||||
);
|
||||
const metricSyncDates = Object.values(
|
||||
metrics?.last_sync_timestamps ?? {},
|
||||
).flatMap((v) => {
|
||||
const d = parseDate(v);
|
||||
return d ? [d] : [];
|
||||
});
|
||||
const repositorySyncDates = repositories.flatMap((r) => {
|
||||
const d = parseDate(r.last_sync_at);
|
||||
return d ? [d] : [];
|
||||
});
|
||||
const latestSync = newestDate(
|
||||
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
|
||||
);
|
||||
const latestSyncAge = latestSync && nowMs != null
|
||||
? Math.max(0, nowMs - latestSync.getTime())
|
||||
: 0;
|
||||
const latestSyncAge =
|
||||
latestSync && nowMs != null ? Math.max(0, nowMs - latestSync.getTime()) : 0;
|
||||
|
||||
if (repositoryCount === 0) {
|
||||
return {
|
||||
title: "Last Sync",
|
||||
value: "No repos",
|
||||
caption: "Add repositories to track sync status.",
|
||||
href: "/git-projects/repositories",
|
||||
href: "/git-projects",
|
||||
tone: "muted",
|
||||
icon: RefreshCw,
|
||||
};
|
||||
|
|
@ -160,7 +173,7 @@ function buildSyncHealthCard(
|
|||
title: "Last Sync",
|
||||
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
|
||||
caption: "Repository sync needs attention.",
|
||||
href: "/git-projects/repositories",
|
||||
href: "/git-projects",
|
||||
tone: "danger",
|
||||
icon: ShieldAlert,
|
||||
action: {
|
||||
|
|
@ -175,7 +188,7 @@ function buildSyncHealthCard(
|
|||
title: "Last Sync",
|
||||
value: "Waiting",
|
||||
caption: "Repositories have not synced yet.",
|
||||
href: "/git-projects/repositories",
|
||||
href: "/git-projects",
|
||||
tone: "slate",
|
||||
icon: Clock3,
|
||||
action: {
|
||||
|
|
@ -190,7 +203,7 @@ function buildSyncHealthCard(
|
|||
title: "Last Sync",
|
||||
value: "Stale",
|
||||
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
||||
href: "/git-projects/repositories",
|
||||
href: "/git-projects",
|
||||
tone: "amber",
|
||||
icon: Clock3,
|
||||
action: {
|
||||
|
|
@ -204,7 +217,7 @@ function buildSyncHealthCard(
|
|||
title: "Last Sync",
|
||||
value: "Healthy",
|
||||
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
||||
href: "/git-projects/repositories",
|
||||
href: "/git-projects",
|
||||
tone: "cyan",
|
||||
icon: ShieldCheck,
|
||||
action: {
|
||||
|
|
@ -292,7 +305,10 @@ function MetricCardLink({ card }: { card: MetricCard }) {
|
|||
aria-label={card.action.label}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4 shrink-0", card.action.isLoading && "animate-spin")}
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
card.action.isLoading && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
|
|
@ -328,16 +344,15 @@ export function ForgejoIssueMetricCards({
|
|||
}, []);
|
||||
|
||||
const activeRepositories = metricRepositories ?? repositories;
|
||||
const openIssues = metrics?.open_issues ?? 0;
|
||||
const openIssues = metrics?.open_issues ?? 0;
|
||||
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
||||
const staleOpen = metrics?.stale_open_issues ?? 0;
|
||||
const repositoriesSynced = metrics?.repositories_synced ?? activeRepositories.length;
|
||||
const staleOpen = metrics?.stale_open_issues ?? 0;
|
||||
const repositoriesSynced =
|
||||
metrics?.repositories_synced ?? activeRepositories.length;
|
||||
|
||||
// Stale: 0 → slate, 1–5 → amber, >5 → danger
|
||||
const staleTone: MetricTone =
|
||||
staleOpen === 0 ? "slate" :
|
||||
staleOpen <= 5 ? "amber" :
|
||||
"danger";
|
||||
staleOpen === 0 ? "slate" : staleOpen <= 5 ? "amber" : "danger";
|
||||
const issueHref = (params: string): string => {
|
||||
const repositoryParam =
|
||||
selectedRepositoryId === ALL_REPOSITORIES_VALUE
|
||||
|
|
@ -394,7 +409,8 @@ export function ForgejoIssueMetricCards({
|
|||
Git Project Issue Tracking
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
High-level Forgejo issue health across repositories synced into Pipeline.
|
||||
High-level Forgejo issue health across repositories synced into
|
||||
Pipeline.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
|
|
@ -407,7 +423,9 @@ export function ForgejoIssueMetricCards({
|
|||
<SelectValue placeholder="All projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value={ALL_REPOSITORIES_VALUE}>All projects</SelectItem>
|
||||
<SelectItem value={ALL_REPOSITORIES_VALUE}>
|
||||
All projects
|
||||
</SelectItem>
|
||||
{repositories.map((repository) => (
|
||||
<SelectItem key={repository.id} value={repository.id}>
|
||||
{repositoryLabel(repository)}
|
||||
|
|
@ -438,16 +456,20 @@ export function ForgejoIssueMetricCards({
|
|||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 4 }).map((_, i) => <MetricSkeleton key={i} />)
|
||||
: cards.map((card) => <MetricCardLink key={card.title} card={card} />)}
|
||||
: cards.map((card) => (
|
||||
<MetricCardLink key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isLoading && !error && repositories.length === 0 ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
|
||||
No Git Project repositories are configured yet. Metrics will populate after repositories are added and synced.
|
||||
No Git Project repositories are configured yet. Metrics will populate
|
||||
after repositories are added and synced.
|
||||
</div>
|
||||
) : !isLoading && !error && repositoriesSynced === 0 ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
|
||||
Git Project repositories are configured, but Pipeline has not synced issue metrics yet.
|
||||
Git Project repositories are configured, but Pipeline has not synced
|
||||
issue metrics yet.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ import {
|
|||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
|
|
@ -25,7 +31,10 @@ import {
|
|||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { DataTable } from "@/components/tables/DataTable";
|
||||
import {
|
||||
DataTable,
|
||||
type DataTableRowAction,
|
||||
} from "@/components/tables/DataTable";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type {
|
||||
|
|
@ -45,14 +54,14 @@ const repositoryTone = (repo: ForgejoRepository) => {
|
|||
|
||||
const toneClasses = {
|
||||
success: {
|
||||
rail: "border-l-[color:var(--success)]",
|
||||
row: "bg-[color:rgba(52,211,153,0.025)] hover:bg-[color:rgba(52,211,153,0.07)]",
|
||||
rail: "border-l-transparent",
|
||||
row: "hover:bg-[color:rgba(96,165,250,0.045)]",
|
||||
icon: "border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.13)] text-[color:var(--success)]",
|
||||
dot: "bg-[color:var(--success)]",
|
||||
},
|
||||
warning: {
|
||||
rail: "border-l-[color:var(--warning)]",
|
||||
row: "bg-[color:rgba(251,191,36,0.025)] hover:bg-[color:rgba(251,191,36,0.075)]",
|
||||
row: "bg-[color:rgba(251,191,36,0.035)] hover:bg-[color:rgba(251,191,36,0.085)]",
|
||||
icon: "border-[color:rgba(251,191,36,0.3)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]",
|
||||
dot: "bg-[color:var(--warning)]",
|
||||
},
|
||||
|
|
@ -63,7 +72,7 @@ const toneClasses = {
|
|||
dot: "bg-[color:var(--danger)]",
|
||||
},
|
||||
muted: {
|
||||
rail: "border-l-[color:var(--border-strong)]",
|
||||
rail: "border-l-transparent",
|
||||
row: "hover:bg-[color:var(--surface-muted)]",
|
||||
icon: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
|
||||
dot: "bg-[color:var(--text-quiet)]",
|
||||
|
|
@ -74,8 +83,25 @@ const formatSyncTime = (value: string | null) => {
|
|||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
|
||||
const diffMinutes = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - date.getTime()) / 60_000),
|
||||
);
|
||||
const relative =
|
||||
diffMinutes < 1
|
||||
? "Just now"
|
||||
: diffMinutes < 60
|
||||
? `${diffMinutes} min ago`
|
||||
: diffMinutes < 60 * 24
|
||||
? `${Math.round(diffMinutes / 60)} hr ago`
|
||||
: diffMinutes < 60 * 24 * 7
|
||||
? `${Math.round(diffMinutes / (60 * 24))} days ago`
|
||||
: date.toLocaleDateString();
|
||||
|
||||
return {
|
||||
date: date.toLocaleDateString(),
|
||||
relative,
|
||||
full: date.toLocaleString(),
|
||||
time: date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
|
|
@ -114,6 +140,27 @@ export function ForgejoRepositoriesTable({
|
|||
}: RepositoriesTableProps) {
|
||||
// onEdit available for future use
|
||||
const _ = onEdit;
|
||||
const rowActions: DataTableRowAction<ForgejoRepository>[] = [
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
href: (row) => `/git-projects/repositories/${row.id}/edit`,
|
||||
className:
|
||||
"inline-flex h-8 items-center justify-center rounded-lg px-3 text-xs font-semibold text-muted transition hover:bg-[color:var(--surface-muted)] hover:text-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]",
|
||||
},
|
||||
...(onDelete
|
||||
? [
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
onClick: onDelete,
|
||||
className:
|
||||
"h-8 rounded-lg px-3 text-xs font-semibold text-muted transition hover:bg-[color:rgba(248,113,113,0.1)] hover:text-[color:var(--danger)]",
|
||||
} satisfies DataTableRowAction<ForgejoRepository>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: repositories,
|
||||
columns: columns(onSync, onValidate, onViewDetails),
|
||||
|
|
@ -133,18 +180,18 @@ export function ForgejoRepositoriesTable({
|
|||
actionLabel: "Add repository",
|
||||
}}
|
||||
rowActions={{
|
||||
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
|
||||
onDelete: onDelete ?? undefined,
|
||||
header: "Manage",
|
||||
actions: rowActions,
|
||||
cellClassName: "px-3 py-3 align-middle md:px-5",
|
||||
}}
|
||||
tableClassName="min-w-[900px] w-full text-left text-sm"
|
||||
headerClassName="bg-[linear-gradient(90deg,rgba(96,165,250,0.16),rgba(52,211,153,0.1),rgba(251,191,36,0.08))] text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]"
|
||||
headerClassName="border-b border-[color:var(--border)] bg-[color:var(--surface-muted)]/80 text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]"
|
||||
headerCellClassName="px-3 py-3 md:px-5"
|
||||
cellClassName="px-3 py-4 align-middle md:px-5"
|
||||
rowClassName={(row) => {
|
||||
const tone = repositoryTone(row.original);
|
||||
return cn(
|
||||
"border-l-4 transition-colors",
|
||||
"border-l-4 transition-colors duration-150",
|
||||
toneClasses[tone].rail,
|
||||
toneClasses[tone].row,
|
||||
);
|
||||
|
|
@ -205,11 +252,6 @@ const columns = (
|
|||
<span className="truncate">
|
||||
{repo.owner}/{repo.repo}
|
||||
</span>
|
||||
{repo.connection?.name ? (
|
||||
<span className="truncate text-[color:var(--text-quiet)]">
|
||||
/ {repo.connection.name}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{repo.description ? (
|
||||
<span className="mt-1 block max-w-[360px] truncate text-xs text-muted">
|
||||
|
|
@ -290,7 +332,7 @@ const columns = (
|
|||
Webhook
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Badge variant="warning" className="gap-1">
|
||||
<KeyRound className="h-3 w-3" />
|
||||
No secret
|
||||
</Badge>
|
||||
|
|
@ -323,11 +365,11 @@ const columns = (
|
|||
className={cn(
|
||||
"inline-flex min-w-[94px] flex-col rounded-xl border px-3 py-2",
|
||||
row.original.open_issues_count > 0
|
||||
? "border-[color:rgba(251,191,36,0.28)] bg-[color:rgba(251,191,36,0.1)]"
|
||||
: "border-[color:rgba(52,211,153,0.24)] bg-[color:rgba(52,211,153,0.08)]",
|
||||
? "border-[color:rgba(251,191,36,0.24)] bg-[color:rgba(251,191,36,0.08)]"
|
||||
: "border-[color:rgba(52,211,153,0.18)] bg-[color:rgba(52,211,153,0.055)]",
|
||||
)}
|
||||
>
|
||||
<span className="text-lg font-semibold leading-none text-strong">
|
||||
<span className="text-base font-semibold leading-none text-strong">
|
||||
{row.original.open_issues_count}
|
||||
</span>
|
||||
<span className="mt-1 text-[11px] uppercase tracking-wide text-muted">
|
||||
|
|
@ -357,13 +399,16 @@ const columns = (
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[150px] items-start gap-2">
|
||||
<div
|
||||
className="flex min-w-[150px] items-start gap-2"
|
||||
title={syncTime.full}
|
||||
>
|
||||
<span
|
||||
className={cn("mt-1.5 h-2 w-2 rounded-full", toneClasses[tone].dot)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-strong">
|
||||
{syncTime.date}
|
||||
{syncTime.relative}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{syncTime.time}</span>
|
||||
{lastSyncError && (
|
||||
|
|
@ -378,6 +423,7 @@ const columns = (
|
|||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => (
|
||||
<ActionsCell
|
||||
repository={row.original}
|
||||
|
|
@ -444,55 +490,69 @@ function ActionsCell({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{onSync && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSync}
|
||||
disabled={isSyncLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Sync issues"
|
||||
aria-label={`Sync issues for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
||||
>
|
||||
{isSyncLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : syncResult ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{onValidate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleValidate}
|
||||
disabled={isValidateLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Validate repository"
|
||||
aria-label={`Validate ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
||||
>
|
||||
{isValidateLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : validateResult?.ok ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{onViewDetails && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onViewDetails(repository)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Repository details"
|
||||
aria-label={`View details for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div className="inline-flex items-center rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-1 shadow-sm">
|
||||
{onSync && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSync}
|
||||
disabled={isSyncLoading}
|
||||
className="h-7 w-7 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-muted)] hover:text-strong"
|
||||
aria-label={`Sync issues for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
||||
>
|
||||
{isSyncLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : syncResult ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Sync issues</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onValidate && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleValidate}
|
||||
disabled={isValidateLoading}
|
||||
className="h-7 w-7 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-muted)] hover:text-strong"
|
||||
aria-label={`Validate ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
||||
>
|
||||
{isValidateLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : validateResult?.ok ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Validate repository</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onViewDetails && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onViewDetails(repository)}
|
||||
className="h-7 w-7 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-muted)] hover:text-strong"
|
||||
aria-label={`View details for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Repository details</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,14 +11,11 @@ import {
|
|||
Building2,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Folder,
|
||||
FolderGit,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Settings,
|
||||
Store,
|
||||
Tags,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
|
|
@ -65,6 +62,14 @@ const iconClass = (active: boolean, tone: NavTone) =>
|
|||
);
|
||||
|
||||
function isNavActive(pathname: string, href: string) {
|
||||
if (href === "/boards") {
|
||||
return (
|
||||
pathname === href ||
|
||||
pathname.startsWith("/boards/") ||
|
||||
pathname.startsWith("/board-groups")
|
||||
);
|
||||
}
|
||||
|
||||
if (href === "/git-projects") {
|
||||
return (
|
||||
pathname === href ||
|
||||
|
|
@ -76,8 +81,9 @@ function isNavActive(pathname: string, href: string) {
|
|||
if (href === "/settings") {
|
||||
return (
|
||||
pathname === href ||
|
||||
(pathname.startsWith("/settings/") &&
|
||||
!pathname.startsWith("/settings/ai-providers"))
|
||||
pathname.startsWith("/settings/") ||
|
||||
pathname.startsWith("/tags") ||
|
||||
pathname.startsWith("/custom-fields")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -166,15 +172,8 @@ export function DashboardSidebar() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<p className={sectionHeaderClass}>Boards</p>
|
||||
<p className={sectionHeaderClass}>Work</p>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<NavItem
|
||||
href="/board-groups"
|
||||
label="Board groups"
|
||||
icon={<Folder className="h-4 w-4" />}
|
||||
tone="emerald"
|
||||
active={isActive("/board-groups")}
|
||||
/>
|
||||
<NavItem
|
||||
href="/boards"
|
||||
label="Boards"
|
||||
|
|
@ -182,6 +181,13 @@ export function DashboardSidebar() {
|
|||
tone="blue"
|
||||
active={isActive("/boards")}
|
||||
/>
|
||||
<NavItem
|
||||
href="/approvals"
|
||||
label="Approvals"
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
tone="emerald"
|
||||
active={isActive("/approvals")}
|
||||
/>
|
||||
<NavItem
|
||||
href="/git-projects"
|
||||
label="Git projects"
|
||||
|
|
@ -196,29 +202,6 @@ export function DashboardSidebar() {
|
|||
tone="amber"
|
||||
active={isActive("/git-projects/issues")}
|
||||
/>
|
||||
<NavItem
|
||||
href="/tags"
|
||||
label="Tags"
|
||||
icon={<Tags className="h-4 w-4" />}
|
||||
tone="cyan"
|
||||
active={isActive("/tags")}
|
||||
/>
|
||||
<NavItem
|
||||
href="/approvals"
|
||||
label="Approvals"
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
tone="emerald"
|
||||
active={isActive("/approvals")}
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<NavItem
|
||||
href="/custom-fields"
|
||||
label="Custom fields"
|
||||
icon={<Settings className="h-4 w-4" />}
|
||||
tone="rose"
|
||||
active={isActive("/custom-fields")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -250,13 +233,6 @@ export function DashboardSidebar() {
|
|||
active={isActive("/gateways")}
|
||||
/>
|
||||
) : null}
|
||||
<NavItem
|
||||
href="/settings/ai-providers"
|
||||
label="AI Providers"
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
tone="amber"
|
||||
active={isActive("/settings/ai-providers")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue