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