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