feat(dashboard): issue tracking widgets (#27)

This commit is contained in:
null 2026-05-19 20:31:05 -05:00
parent 8e012a2197
commit 21dadc8724
4 changed files with 661 additions and 96 deletions

View File

@ -22,14 +22,13 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
import { Markdown } from "@/components/atoms/Markdown"; import { Markdown } from "@/components/atoms/Markdown";
import { SignedOutPanel } from "@/components/auth/SignedOutPanel"; import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
import { ForgejoIssueMetricCards } from "@/components/git/ForgejoIssueMetricCards";
import { ApiError } from "@/api/mutator"; import { ApiError } from "@/api/mutator";
import { import {
type dashboardMetricsApiV1MetricsDashboardGetResponse, type dashboardMetricsApiV1MetricsDashboardGetResponse,
useDashboardMetricsApiV1MetricsDashboardGet, useDashboardMetricsApiV1MetricsDashboardGet,
} from "@/api/generated/metrics/metrics"; } from "@/api/generated/metrics/metrics";
import { import { gatewaysStatusApiV1GatewaysStatusGet } from "@/api/generated/gateways/gateways";
gatewaysStatusApiV1GatewaysStatusGet,
} from "@/api/generated/gateways/gateways";
import type { GatewaysStatusResponse } from "@/api/generated/model/gatewaysStatusResponse"; import type { GatewaysStatusResponse } from "@/api/generated/model/gatewaysStatusResponse";
import { import {
type listAgentsApiV1AgentsGetResponse, type listAgentsApiV1AgentsGetResponse,
@ -44,6 +43,12 @@ import {
useListActivityApiV1ActivityGet, useListActivityApiV1ActivityGet,
} from "@/api/generated/activity/activity"; } from "@/api/generated/activity/activity";
import type { ActivityEventRead } from "@/api/generated/model"; import type { ActivityEventRead } from "@/api/generated/model";
import {
getForgejoMetrics,
getForgejoRepositories,
type ForgejoIssueMetrics,
type ForgejoRepository,
} from "@/lib/api-forgejo";
import { import {
formatRelativeTimestamp, formatRelativeTimestamp,
formatTimestamp, formatTimestamp,
@ -189,9 +194,13 @@ const readTimestampFromRecords = (
return null; return null;
}; };
const sessionIdentifiers = (record: Record<string, unknown> | null): string[] => { const sessionIdentifiers = (
record: Record<string, unknown> | null,
): string[] => {
if (!record) return []; if (!record) return [];
const ids = SESSION_ID_KEYS.map((key) => readString(record, [key])).filter(Boolean) as string[]; const ids = SESSION_ID_KEYS.map((key) => readString(record, [key])).filter(
Boolean,
) as string[];
return [...new Set(ids)]; return [...new Set(ids)];
}; };
@ -210,13 +219,16 @@ const compactNumber = (value: number): string => {
}; };
const formatCount = (value: number): string => const formatCount = (value: number): string =>
Number.isFinite(value) ? numberFormatter.format(Math.max(0, Math.round(value))) : "0"; Number.isFinite(value)
? numberFormatter.format(Math.max(0, Math.round(value)))
: "0";
const formatPercent = (value: number): string => const formatPercent = (value: number): string =>
Number.isFinite(value) ? `${value.toFixed(1)}%` : DASH; Number.isFinite(value) ? `${value.toFixed(1)}%` : DASH;
const formatPerDay = (total: number, days: number): string => { const formatPerDay = (total: number, days: number): string => {
if (!Number.isFinite(total) || !Number.isFinite(days) || days <= 0) return DASH; if (!Number.isFinite(total) || !Number.isFinite(days) || days <= 0)
return DASH;
return `${(total / days).toFixed(1)}/day`; return `${(total / days).toFixed(1)}/day`;
}; };
@ -224,15 +236,15 @@ const toSessionSummaries = (
sessions: unknown[] | null | undefined, sessions: unknown[] | null | undefined,
mainSession: unknown, mainSession: unknown,
): SessionSummary[] => { ): SessionSummary[] => {
const sessionRecords = (sessions ?? []).map(toRecord).filter(Boolean) as Array< const sessionRecords = (sessions ?? [])
Record<string, unknown> .map(toRecord)
>; .filter(Boolean) as Array<Record<string, unknown>>;
const mainRecord = toRecord(mainSession); const mainRecord = toRecord(mainSession);
const mainIdentifiers = sessionIdentifiers(mainRecord); const mainIdentifiers = sessionIdentifiers(mainRecord);
if (mainRecord && mainIdentifiers.length > 0) { if (mainRecord && mainIdentifiers.length > 0) {
const exists = sessionRecords.some( const exists = sessionRecords.some((entry) =>
(entry) => sharesSessionIdentity(sessionIdentifiers(entry), mainIdentifiers), sharesSessionIdentity(sessionIdentifiers(entry), mainIdentifiers),
); );
if (!exists) sessionRecords.unshift(mainRecord); if (!exists) sessionRecords.unshift(mainRecord);
} }
@ -242,7 +254,10 @@ const toSessionSummaries = (
for (const entry of sessionRecords) { for (const entry of sessionRecords) {
const identifiers = sessionIdentifiers(entry); const identifiers = sessionIdentifiers(entry);
if (identifiers.length > 0 && identifiers.some((value) => seenIdentifiers.has(value))) { if (
identifiers.length > 0 &&
identifiers.some((value) => seenIdentifiers.has(value))
) {
continue; continue;
} }
uniqueRecords.push(entry); uniqueRecords.push(entry);
@ -258,17 +273,29 @@ const toSessionSummaries = (
const identifiers = sessionIdentifiers(entry); const identifiers = sessionIdentifiers(entry);
const key = const key =
readString(entry, ["key", "session_key", "sessionKey", "id", "sessionId"]) ?? readString(entry, [
`session-${index}`; "key",
"session_key",
"sessionKey",
"id",
"sessionId",
]) ?? `session-${index}`;
const label = readString(entry, ["label", "name", "title"]) ?? key; const label = readString(entry, ["label", "name", "title"]) ?? key;
const channel = readStringFromRecords([entry, originRecord], [ const channel = readStringFromRecords(
"channel", [entry, originRecord],
"source", ["channel", "source", "kind", "chatType"],
"kind", );
"chatType", const model = readString(entry, [
"model",
"model_name",
"provider",
"engine",
]);
const modelProvider = readString(entry, [
"modelProvider",
"model_provider",
"provider",
]); ]);
const model = readString(entry, ["model", "model_name", "provider", "engine"]);
const modelProvider = readString(entry, ["modelProvider", "model_provider", "provider"]);
const lastSeenAt = readTimestampFromRecords(candidateRecords, [ const lastSeenAt = readTimestampFromRecords(candidateRecords, [
"updated_at", "updated_at",
"updatedAt", "updatedAt",
@ -336,10 +363,15 @@ const toSessionSummaries = (
: DASH; : DASH;
const subtitleBits = [channel, model].filter(Boolean) as string[]; const subtitleBits = [channel, model].filter(Boolean) as string[];
const subtitle = subtitleBits.length > 0 ? subtitleBits.join(" · ") : "Session"; const subtitle =
subtitleBits.length > 0 ? subtitleBits.join(" · ") : "Session";
const modelWithProvider = const modelWithProvider =
modelProvider && model && modelProvider !== model ? `${model} · ${modelProvider}` : model; modelProvider && model && modelProvider !== model
const subtitleWithProvider = [channel, modelWithProvider].filter(Boolean).join(" · "); ? `${model} · ${modelProvider}`
: model;
const subtitleWithProvider = [channel, modelWithProvider]
.filter(Boolean)
.join(" · ");
return { return {
key, key,
@ -397,15 +429,15 @@ function TopMetricCard({
) : null} ) : null}
</div> </div>
<div className="mt-2 flex items-end gap-2"> <div className="mt-2 flex items-end gap-2">
<p className="font-heading text-4xl font-bold text-slate-900">{value}</p> <p className="font-heading text-4xl font-bold text-slate-900">
{value}
</p>
{secondary ? ( {secondary ? (
<p className="pb-1 text-xs text-slate-500">{secondary}</p> <p className="pb-1 text-xs text-slate-500">{secondary}</p>
) : null} ) : null}
</div> </div>
</div> </div>
<div className={`rounded-lg p-2 ${iconTone}`}> <div className={`rounded-lg p-2 ${iconTone}`}>{icon}</div>
{icon}
</div>
</div> </div>
</section> </section>
); );
@ -453,7 +485,10 @@ function InfoBlock({
</div> </div>
<div className="divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white"> <div className="divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white">
{rows.map((row) => ( {rows.map((row) => (
<div key={`${row.label}-${row.value}`} className="flex items-start justify-between gap-3 px-3 py-2"> <div
key={`${row.label}-${row.value}`}
className="flex items-start justify-between gap-3 px-3 py-2"
>
<span className="min-w-0 text-sm text-slate-500">{row.label}</span> <span className="min-w-0 text-sm text-slate-500">{row.label}</span>
<span <span
className={`max-w-[65%] break-words text-right text-sm font-medium leading-5 ${ className={`max-w-[65%] break-words text-right text-sm font-medium leading-5 ${
@ -479,7 +514,10 @@ export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const boardsQuery = useListBoardsApiV1BoardsGet<listBoardsApiV1BoardsGetResponse, ApiError>( const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(
{ limit: 200 }, { limit: 200 },
{ {
query: { query: {
@ -490,7 +528,10 @@ export default function DashboardPage() {
}, },
); );
const agentsQuery = useListAgentsApiV1AgentsGet<listAgentsApiV1AgentsGetResponse, ApiError>( const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>(
{ limit: 200 }, { limit: 200 },
{ {
query: { query: {
@ -519,7 +560,10 @@ export default function DashboardPage() {
}, },
); );
const activityQuery = useListActivityApiV1ActivityGet<listActivityApiV1ActivityGetResponse, ApiError>( const activityQuery = useListActivityApiV1ActivityGet<
listActivityApiV1ActivityGetResponse,
ApiError
>(
{ limit: 200 }, { limit: 200 },
{ {
query: { query: {
@ -530,10 +574,46 @@ export default function DashboardPage() {
}, },
); );
const forgejoRepositoriesQuery = useQuery<ForgejoRepository[], Error>({
queryKey: ["dashboard", "forgejo", "repositories"],
enabled: Boolean(isSignedIn),
refetchInterval: 60_000,
refetchOnMount: "always",
queryFn: () => getForgejoRepositories(),
});
const forgejoRepositories = useMemo(
() => forgejoRepositoriesQuery.data ?? [],
[forgejoRepositoriesQuery.data],
);
const forgejoOrganizationId = useMemo(
() =>
forgejoRepositories.find((repository) => repository.organization_id)
?.organization_id ?? null,
[forgejoRepositories],
);
const forgejoMetricsQuery = useQuery<ForgejoIssueMetrics | null, Error>({
queryKey: ["dashboard", "forgejo", "metrics", forgejoOrganizationId],
enabled: Boolean(
isSignedIn &&
!forgejoRepositoriesQuery.isLoading &&
!forgejoRepositoriesQuery.error,
),
refetchInterval: 60_000,
refetchOnMount: "always",
queryFn: () => {
if (!forgejoOrganizationId) return Promise.resolve(null);
return getForgejoMetrics({ organization_id: forgejoOrganizationId });
},
});
const boards = useMemo( const boards = useMemo(
() => () =>
boardsQuery.data?.status === 200 boardsQuery.data?.status === 200
? [...(boardsQuery.data.data.items ?? [])].sort((a, b) => a.name.localeCompare(b.name)) ? [...(boardsQuery.data.data.items ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
)
: [], : [],
[boardsQuery.data], [boardsQuery.data],
); );
@ -541,15 +621,20 @@ export default function DashboardPage() {
const agents = useMemo( const agents = useMemo(
() => () =>
agentsQuery.data?.status === 200 agentsQuery.data?.status === 200
? [...(agentsQuery.data.data.items ?? [])].sort((a, b) => a.name.localeCompare(b.name)) ? [...(agentsQuery.data.data.items ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
)
: [], : [],
[agentsQuery.data], [agentsQuery.data],
); );
const metrics = metricsQuery.data?.status === 200 ? metricsQuery.data.data : null; const metrics =
metricsQuery.data?.status === 200 ? metricsQuery.data.data : null;
const onlineAgents = useMemo( const onlineAgents = useMemo(
() => agents.filter((agent) => (agent.status ?? "").toLowerCase() === "online").length, () =>
agents.filter((agent) => (agent.status ?? "").toLowerCase() === "online")
.length,
[agents], [agents],
); );
const gatewayTargets = useMemo<GatewayTarget[]>(() => { const gatewayTargets = useMemo<GatewayTarget[]>(() => {
@ -564,7 +649,9 @@ export default function DashboardPage() {
boardName: board.name, boardName: board.name,
}); });
} }
return [...byGateway.values()].sort((a, b) => a.boardName.localeCompare(b.boardName)); return [...byGateway.values()].sort((a, b) =>
a.boardName.localeCompare(b.boardName),
);
}, [boards]); }, [boards]);
const hasConfiguredGateways = gatewayTargets.length > 0; const hasConfiguredGateways = gatewayTargets.length > 0;
@ -622,7 +709,9 @@ export default function DashboardPage() {
mainSessionError: null, mainSessionError: null,
error: null, error: null,
requestError: requestError:
error instanceof Error ? error.message : "Gateway status request failed.", error instanceof Error
? error.message
: "Gateway status request failed.",
}; };
} }
}), }),
@ -639,11 +728,13 @@ export default function DashboardPage() {
gatewaySnapshots.flatMap((snapshot) => { gatewaySnapshots.flatMap((snapshot) => {
if (snapshot.requestError) return []; if (snapshot.requestError) return [];
const sourceLabel = snapshot.gatewayUrl || snapshot.boardName; const sourceLabel = snapshot.gatewayUrl || snapshot.boardName;
return toSessionSummaries(snapshot.sessions, snapshot.mainSession).map((session) => ({ return toSessionSummaries(snapshot.sessions, snapshot.mainSession).map(
(session) => ({
...session, ...session,
key: `${snapshot.gatewayId}:${session.key}`, key: `${snapshot.gatewayId}:${session.key}`,
subtitle: `${sourceLabel} · ${session.subtitle}`, subtitle: `${sourceLabel} · ${session.subtitle}`,
})); }),
);
}), }),
[gatewaySnapshots], [gatewaySnapshots],
); );
@ -669,7 +760,9 @@ export default function DashboardPage() {
const recentLogs = orderedActivityEvents.slice(0, 8); const recentLogs = orderedActivityEvents.slice(0, 8);
const latestThroughputPoint = const latestThroughputPoint =
metrics?.throughput.primary.points?.[metrics.throughput.primary.points.length - 1] ?? null; metrics?.throughput.primary.points?.[
metrics.throughput.primary.points.length - 1
] ?? null;
const throughputTotal = (metrics?.throughput.primary.points ?? []).reduce( const throughputTotal = (metrics?.throughput.primary.points ?? []).reduce(
(sum, point) => sum + Number(point.value ?? 0), (sum, point) => sum + Number(point.value ?? 0),
0, 0,
@ -685,11 +778,18 @@ export default function DashboardPage() {
const doneTasksMetric = metrics?.kpis.done_tasks ?? 0; const doneTasksMetric = metrics?.kpis.done_tasks ?? 0;
const activeAgentsMetric = onlineAgents; const activeAgentsMetric = onlineAgents;
const tasksTotal = inboxTasksMetric + inProgressTasksMetric + reviewTasksMetric + doneTasksMetric; const tasksTotal =
const tasksInProgressMetric = metrics?.kpis.tasks_in_progress ?? inProgressTasksMetric; inboxTasksMetric +
inProgressTasksMetric +
reviewTasksMetric +
doneTasksMetric;
const tasksInProgressMetric =
metrics?.kpis.tasks_in_progress ?? inProgressTasksMetric;
const errorRateMetric = Number(metrics?.kpis.error_rate_pct ?? 0); const errorRateMetric = Number(metrics?.kpis.error_rate_pct ?? 0);
const reviewBacklogRatio = const reviewBacklogRatio =
inProgressTasksMetric > 0 ? reviewTasksMetric / inProgressTasksMetric : null; inProgressTasksMetric > 0
? reviewTasksMetric / inProgressTasksMetric
: null;
const gatewayConnectedCount = gatewaySnapshots.filter( const gatewayConnectedCount = gatewaySnapshots.filter(
(snapshot) => !snapshot.requestError && snapshot.connected, (snapshot) => !snapshot.requestError && snapshot.connected,
@ -697,11 +797,11 @@ export default function DashboardPage() {
const gatewayDisconnectedCount = gatewaySnapshots.filter( const gatewayDisconnectedCount = gatewaySnapshots.filter(
(snapshot) => !snapshot.requestError && !snapshot.connected, (snapshot) => !snapshot.requestError && !snapshot.connected,
).length; ).length;
const gatewayUnavailableCount = gatewaySnapshots.filter( const gatewayUnavailableCount = gatewaySnapshots.filter((snapshot) =>
(snapshot) => Boolean(snapshot.requestError), Boolean(snapshot.requestError),
).length; ).length;
const gatewayHealthErrorCount = gatewaySnapshots.filter( const gatewayHealthErrorCount = gatewaySnapshots.filter((snapshot) =>
(snapshot) => Boolean(snapshot.error || snapshot.mainSessionError), Boolean(snapshot.error || snapshot.mainSessionError),
).length; ).length;
const countedSessions = gatewaySnapshots.reduce( const countedSessions = gatewaySnapshots.reduce(
@ -732,9 +832,11 @@ export default function DashboardPage() {
const gatewayStatusTone: SummaryRow["tone"] = const gatewayStatusTone: SummaryRow["tone"] =
gatewayStatusLabel === "All connected" gatewayStatusLabel === "All connected"
? "success" ? "success"
: gatewayStatusLabel === "Checking" || gatewayStatusLabel === "Not configured" : gatewayStatusLabel === "Checking" ||
gatewayStatusLabel === "Not configured"
? "default" ? "default"
: gatewayStatusLabel === "Partially connected" || gatewayStatusLabel === "Disconnected" : gatewayStatusLabel === "Partially connected" ||
gatewayStatusLabel === "Disconnected"
? "warning" ? "warning"
: "danger"; : "danger";
@ -768,7 +870,10 @@ export default function DashboardPage() {
label: "Completed tasks", label: "Completed tasks",
value: formatCount(throughputTotal), value: formatCount(throughputTotal),
}, },
{ label: "Average throughput", value: formatPerDay(throughputTotal, DASHBOARD_RANGE_DAYS) }, {
label: "Average throughput",
value: formatPerDay(throughputTotal, DASHBOARD_RANGE_DAYS),
},
{ {
label: "Error rate", label: "Error rate",
value: formatPercent(errorRateMetric), value: formatPercent(errorRateMetric),
@ -777,7 +882,10 @@ export default function DashboardPage() {
{ {
label: "Completion consistency", label: "Completion consistency",
value: `${formatCount(completionDaysCount)} active days`, value: `${formatCount(completionDaysCount)} active days`,
tone: completionDaysCount >= Math.ceil(DASHBOARD_RANGE_DAYS * 0.75) ? "success" : "default", tone:
completionDaysCount >= Math.ceil(DASHBOARD_RANGE_DAYS * 0.75)
? "success"
: "default",
}, },
{ {
label: "Review backlog ratio", label: "Review backlog ratio",
@ -799,7 +907,11 @@ export default function DashboardPage() {
]; ];
const gatewayRows: SummaryRow[] = [ const gatewayRows: SummaryRow[] = [
{ label: "Gateway status", value: gatewayStatusLabel, tone: gatewayStatusTone }, {
label: "Gateway status",
value: gatewayStatusLabel,
tone: gatewayStatusTone,
},
{ label: "Configured gateways", value: formatCount(gatewayTargets.length) }, { label: "Configured gateways", value: formatCount(gatewayTargets.length) },
{ {
label: "Connected gateways", label: "Connected gateways",
@ -814,13 +926,23 @@ export default function DashboardPage() {
{ {
label: "Gateways with issues", label: "Gateways with issues",
value: formatCount(gatewayHealthErrorCount + gatewayDisconnectedCount), value: formatCount(gatewayHealthErrorCount + gatewayDisconnectedCount),
tone: gatewayHealthErrorCount + gatewayDisconnectedCount > 0 ? "warning" : "success", tone:
gatewayHealthErrorCount + gatewayDisconnectedCount > 0
? "warning"
: "success",
}, },
]; ];
const pendingApprovalItems = metrics?.pending_approvals.items ?? []; const pendingApprovalItems = metrics?.pending_approvals.items ?? [];
const pendingApprovalsTotal = metrics?.pending_approvals.total ?? 0; const pendingApprovalsTotal = metrics?.pending_approvals.total ?? 0;
const hasPendingApprovals = pendingApprovalItems.length > 0; const hasPendingApprovals = pendingApprovalItems.length > 0;
const activityFeedHref = "/activity"; const activityFeedHref = "/activity";
const forgejoIssueMetrics = forgejoMetricsQuery.data ?? null;
const forgejoIssueMetricsError =
forgejoRepositoriesQuery.error?.message ??
forgejoMetricsQuery.error?.message ??
null;
const forgejoIssueMetricsLoading =
forgejoRepositoriesQuery.isLoading || forgejoMetricsQuery.isLoading;
const shouldIgnoreRowNavigation = (target: EventTarget | null): boolean => { const shouldIgnoreRowNavigation = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) return false; if (!(target instanceof HTMLElement)) return false;
@ -940,11 +1062,17 @@ export default function DashboardPage() {
/> />
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3"> <div className="mt-4">
<InfoBlock <ForgejoIssueMetricCards
title="Workload" metrics={forgejoIssueMetrics}
rows={workloadRows} repositories={forgejoRepositories}
isLoading={forgejoIssueMetricsLoading}
error={forgejoIssueMetricsError}
/> />
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<InfoBlock title="Workload" rows={workloadRows} />
<InfoBlock <InfoBlock
title="Throughput" title="Throughput"
infoText={`All throughput values are calculated for ${DASHBOARD_RANGE_LABEL}`} infoText={`All throughput values are calculated for ${DASHBOARD_RANGE_LABEL}`}
@ -962,7 +1090,9 @@ export default function DashboardPage() {
<section className="mt-4 rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm"> <section className="mt-4 rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-slate-900">Pending Approvals</h3> <h3 className="text-lg font-semibold text-slate-900">
Pending Approvals
</h3>
<Link <Link
href="/approvals" href="/approvals"
className="inline-flex items-center gap-1 text-xs text-slate-500 transition hover:text-slate-700" className="inline-flex items-center gap-1 text-xs text-slate-500 transition hover:text-slate-700"
@ -1005,8 +1135,8 @@ export default function DashboardPage() {
</div> </div>
{pendingApprovalsTotal > pendingApprovalItems.length ? ( {pendingApprovalsTotal > pendingApprovalItems.length ? (
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Showing latest {formatCount(pendingApprovalItems.length)} of{" "} Showing latest {formatCount(pendingApprovalItems.length)}{" "}
{formatCount(pendingApprovalsTotal)} pending approvals. of {formatCount(pendingApprovalsTotal)} pending approvals.
</p> </p>
) : null} ) : null}
</div> </div>
@ -1020,8 +1150,12 @@ export default function DashboardPage() {
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<section className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm"> <section className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-slate-900">Sessions</h3> <h3 className="text-lg font-semibold text-slate-900">
<span className="text-xs text-slate-500">{formatCount(activeSessions)}</span> Sessions
</h3>
<span className="text-xs text-slate-500">
{formatCount(activeSessions)}
</span>
</div> </div>
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1"> <div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{!hasConfiguredGateways ? ( {!hasConfiguredGateways ? (
@ -1037,8 +1171,8 @@ export default function DashboardPage() {
{gatewayUnavailableCount > 0 ? ( {gatewayUnavailableCount > 0 ? (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"> <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
{formatCount(gatewayUnavailableCount)} gateway {formatCount(gatewayUnavailableCount)} gateway
{gatewayUnavailableCount === 1 ? "" : "s"} unavailable; showing sessions {gatewayUnavailableCount === 1 ? "" : "s"}{" "}
from reachable gateways. unavailable; showing sessions from reachable gateways.
</div> </div>
) : null} ) : null}
{sessionSummaries.map((session) => ( {sessionSummaries.map((session) => (
@ -1051,16 +1185,22 @@ export default function DashboardPage() {
<p className="truncate text-sm font-medium text-slate-900"> <p className="truncate text-sm font-medium text-slate-900">
<span <span
className={`mr-2 inline-block h-2 w-2 rounded-full ${ className={`mr-2 inline-block h-2 w-2 rounded-full ${
session.isMain ? "bg-emerald-500" : "bg-slate-400" session.isMain
? "bg-emerald-500"
: "bg-slate-400"
}`} }`}
/> />
{session.title} {session.title}
</p> </p>
<p className="mt-0.5 truncate text-xs text-slate-500">{session.subtitle}</p> <p className="mt-0.5 truncate text-xs text-slate-500">
{session.subtitle}
</p>
</div> </div>
<div className="min-w-0 max-w-[45%] text-right"> <div className="min-w-0 max-w-[45%] text-right">
<p className="truncate text-xs font-medium text-slate-700"> <p className="truncate text-xs font-medium text-slate-700">
{session.usage === DASH ? "Usage unavailable" : session.usage} {session.usage === DASH
? "Usage unavailable"
: session.usage}
</p> </p>
<p className="text-[11px] text-slate-500"> <p className="text-[11px] text-slate-500">
{session.lastSeenAt {session.lastSeenAt
@ -1086,7 +1226,9 @@ export default function DashboardPage() {
<section className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm"> <section className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white p-4 md:p-6 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-slate-900">Recent Activity</h3> <h3 className="text-lg font-semibold text-slate-900">
Recent Activity
</h3>
<Link <Link
href={activityFeedHref} href={activityFeedHref}
className="inline-flex items-center gap-1 text-xs text-slate-500 transition hover:text-slate-700" className="inline-flex items-center gap-1 text-xs text-slate-500 transition hover:text-slate-700"
@ -1117,7 +1259,9 @@ export default function DashboardPage() {
<div className="min-w-0 flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<div className="break-words text-sm font-medium text-slate-900 [&_ol]:mb-0 [&_p]:mb-0 [&_pre]:my-1 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_ul]:mb-0"> <div className="break-words text-sm font-medium text-slate-900 [&_ol]:mb-0 [&_p]:mb-0 [&_pre]:my-1 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_ul]:mb-0">
<Markdown <Markdown
content={event.message?.trim() || event.event_type} content={
event.message?.trim() || event.event_type
}
variant="comment" variant="comment"
/> />
</div> </div>
@ -1137,7 +1281,9 @@ export default function DashboardPage() {
<div className="flex h-[240px] flex-col items-center justify-center rounded-lg border border-slate-200 bg-white text-sm text-slate-500"> <div className="flex h-[240px] flex-col items-center justify-center rounded-lg border border-slate-200 bg-white text-sm text-slate-500">
<Shield className="mb-2 h-5 w-5 text-slate-400" /> <Shield className="mb-2 h-5 w-5 text-slate-400" />
No activity yet No activity yet
<p className="mt-1 text-xs text-slate-500">Activity appears here when events are emitted.</p> <p className="mt-1 text-xs text-slate-500">
Activity appears here when events are emitted.
</p>
</div> </div>
)} )}
</div> </div>

View File

@ -2,7 +2,8 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
@ -16,16 +17,67 @@ import {
import { ForgejoIssueFilters } from "@/components/git/ForgejoIssueFilters"; import { ForgejoIssueFilters } from "@/components/git/ForgejoIssueFilters";
import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable"; import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable";
const STALE_ISSUE_DAYS = 14;
const STALE_ISSUE_MS = STALE_ISSUE_DAYS * 24 * 60 * 60 * 1000;
const RECENT_CLOSED_DAYS = 7;
const RECENT_CLOSED_MS = RECENT_CLOSED_DAYS * 24 * 60 * 60 * 1000;
const ISSUE_STATES = new Set(["all", "open", "closed"]);
const normalizeStateFilter = (value: string | null): string =>
value && ISSUE_STATES.has(value) ? value : "open";
const parsePositiveInteger = (value: string | null): number => {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
};
const isStaleOpenIssue = (issue: ForgejoIssue, cutoffMs: number): boolean => {
if (issue.state !== "open") return false;
const updatedAt = new Date(issue.forgejo_updated_at || issue.updated_at);
if (Number.isNaN(updatedAt.getTime())) return false;
return updatedAt.getTime() < cutoffMs;
};
const isRecentlyClosedIssue = (
issue: ForgejoIssue,
cutoffMs: number,
): boolean => {
if (issue.state !== "closed") return false;
const closedAt = new Date(
issue.forgejo_closed_at || issue.forgejo_updated_at || issue.updated_at,
);
if (Number.isNaN(closedAt.getTime())) return false;
return closedAt.getTime() >= cutoffMs;
};
export default function GitIssuesPage() { export default function GitIssuesPage() {
const searchParams = useSearchParams();
const initialStaleOnly = searchParams.get("stale") === "1";
const initialRecentClosedOnly =
!initialStaleOnly && searchParams.get("recent") === "7d";
const [issues, setIssues] = useState<ForgejoIssue[]>([]); const [issues, setIssues] = useState<ForgejoIssue[]>([]);
const [repos, setRepos] = useState<ForgejoRepository[]>([]); const [repos, setRepos] = useState<ForgejoRepository[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [isLoadingIssues, setIsLoadingIssues] = useState(true); const [isLoadingIssues, setIsLoadingIssues] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [stateFilter, setStateFilter] = useState<string>("open"); const [stateFilter, setStateFilter] = useState<string>(() =>
const [repoFilter, setRepoFilter] = useState<string>("all"); initialStaleOnly
const [search, setSearch] = useState(""); ? "open"
const [page, setPage] = useState(1); : initialRecentClosedOnly
? "closed"
: normalizeStateFilter(searchParams.get("state")),
);
const [repoFilter, setRepoFilter] = useState<string>(
() => searchParams.get("repository_id") || "all",
);
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
const [staleOnly, setStaleOnly] = useState(initialStaleOnly);
const [recentClosedOnly, setRecentClosedOnly] = useState(
initialRecentClosedOnly,
);
const [page, setPage] = useState(() =>
parsePositiveInteger(searchParams.get("page")),
);
const limit = 30; const limit = 30;
useEffect(() => { useEffect(() => {
@ -45,11 +97,17 @@ export default function GitIssuesPage() {
try { try {
setIsLoadingIssues(true); setIsLoadingIssues(true);
const result = await getForgejoIssues({ const result = await getForgejoIssues({
state: stateFilter !== "all" ? stateFilter : undefined, state: staleOnly
? "open"
: recentClosedOnly
? "closed"
: stateFilter !== "all"
? stateFilter
: undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined, repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined, search: search || undefined,
page, page: staleOnly || recentClosedOnly ? 1 : page,
limit, limit: staleOnly || recentClosedOnly ? 100 : limit,
}); });
setIssues(result.items); setIssues(result.items);
setTotal(result.total); setTotal(result.total);
@ -66,17 +124,23 @@ export default function GitIssuesPage() {
} }
})(); })();
return () => controller.abort(); return () => controller.abort();
}, [stateFilter, repoFilter, search, page]); }, [stateFilter, repoFilter, search, staleOnly, recentClosedOnly, page]);
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
setIsLoadingIssues(true); setIsLoadingIssues(true);
const result = await getForgejoIssues({ const result = await getForgejoIssues({
state: stateFilter !== "all" ? stateFilter : undefined, state: staleOnly
? "open"
: recentClosedOnly
? "closed"
: stateFilter !== "all"
? stateFilter
: undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined, repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined, search: search || undefined,
page, page: staleOnly || recentClosedOnly ? 1 : page,
limit, limit: staleOnly || recentClosedOnly ? 100 : limit,
}); });
setIssues(result.items); setIssues(result.items);
setTotal(result.total); setTotal(result.total);
@ -92,7 +156,28 @@ export default function GitIssuesPage() {
} }
}; };
const totalPages = Math.ceil(total / limit); const staleCutoffMs = useMemo(() => Date.now() - STALE_ISSUE_MS, []);
const recentClosedCutoffMs = useMemo(() => Date.now() - RECENT_CLOSED_MS, []);
const visibleIssues = useMemo(() => {
if (staleOnly) {
return issues.filter((issue) => isStaleOpenIssue(issue, staleCutoffMs));
}
if (recentClosedOnly) {
return issues.filter((issue) =>
isRecentlyClosedIssue(issue, recentClosedCutoffMs),
);
}
return issues;
}, [
issues,
recentClosedCutoffMs,
recentClosedOnly,
staleCutoffMs,
staleOnly,
]);
const isClientFiltered = staleOnly || recentClosedOnly;
const visibleTotal = isClientFiltered ? visibleIssues.length : total;
const totalPages = isClientFiltered ? 1 : Math.ceil(total / limit);
return ( return (
<DashboardPageLayout <DashboardPageLayout
@ -102,13 +187,15 @@ export default function GitIssuesPage() {
signUpForceRedirectUrl: "/git-projects/issues", signUpForceRedirectUrl: "/git-projects/issues",
}} }}
title="Git Project Issues" title="Git Project Issues"
description={`${total} issue${total === 1 ? "" : "s"} from repositories tracked by Pipeline.`} description={`${visibleTotal} issue${visibleTotal === 1 ? "" : "s"} from repositories tracked by Pipeline.`}
stickyHeader stickyHeader
> >
<ForgejoIssueFilters <ForgejoIssueFilters
stateFilter={stateFilter} stateFilter={stateFilter}
onStateChange={(v) => { onStateChange={(v) => {
setStateFilter(v); setStateFilter(v);
setStaleOnly(false);
setRecentClosedOnly(false);
setPage(1); setPage(1);
}} }}
repoFilter={repoFilter} repoFilter={repoFilter}
@ -124,6 +211,46 @@ export default function GitIssuesPage() {
repos={repos} repos={repos}
/> />
{staleOnly ? (
<div className="mb-4 flex flex-col gap-3 rounded-xl border border-[color:var(--warning)]/35 bg-[color:rgba(251,191,36,0.12)] p-3 text-sm text-[color:var(--warning)] sm:flex-row sm:items-center sm:justify-between">
<span>
Showing open issues not updated in {STALE_ISSUE_DAYS}+ days.
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full text-[color:var(--warning)] hover:bg-[color:rgba(251,191,36,0.14)] sm:w-auto"
onClick={() => {
setStaleOnly(false);
setPage(1);
}}
>
Show All Open Issues
</Button>
</div>
) : null}
{recentClosedOnly ? (
<div className="mb-4 flex flex-col gap-3 rounded-xl border border-[color:var(--success)]/35 bg-[color:rgba(52,211,153,0.12)] p-3 text-sm text-[color:var(--success)] sm:flex-row sm:items-center sm:justify-between">
<span>
Showing issues closed in the last {RECENT_CLOSED_DAYS} days.
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full text-[color:var(--success)] hover:bg-[color:rgba(52,211,153,0.14)] sm:w-auto"
onClick={() => {
setRecentClosedOnly(false);
setPage(1);
}}
>
Show All Closed Issues
</Button>
</div>
) : null}
{error ? ( {error ? (
<div className="mb-4 flex items-start gap-3 rounded-xl 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)]"> <div className="mb-4 flex items-start gap-3 rounded-xl 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" /> <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
@ -132,7 +259,7 @@ export default function GitIssuesPage() {
) : null} ) : null}
<ForgejoIssuesTable <ForgejoIssuesTable
issues={issues} issues={visibleIssues}
isLoading={isLoadingIssues} isLoading={isLoadingIssues}
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />
@ -140,7 +267,7 @@ export default function GitIssuesPage() {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="mt-4 flex flex-col gap-3 text-sm text-muted sm:flex-row sm:items-center sm:justify-between"> <div className="mt-4 flex flex-col gap-3 text-sm text-muted sm:flex-row sm:items-center sm:justify-between">
<span className="break-words"> <span className="break-words">
Page {page} of {totalPages} ({total} total) Page {page} of {totalPages} ({visibleTotal} total)
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button

View File

@ -0,0 +1,287 @@
"use client";
import type { ComponentType } from "react";
import Link from "next/link";
import {
AlertTriangle,
ArrowUpRight,
CheckCircle2,
CircleDot,
Clock3,
RefreshCw,
} from "lucide-react";
import type { ForgejoIssueMetrics, ForgejoRepository } from "@/lib/api-forgejo";
import { formatRelativeTimestamp } from "@/lib/formatters";
import { cn } from "@/lib/utils";
type ForgejoIssueMetricCardsProps = {
metrics: ForgejoIssueMetrics | null;
repositories: ForgejoRepository[];
isLoading?: boolean;
error?: string | null;
};
type MetricTone = "accent" | "success" | "warning" | "danger" | "muted";
type MetricCard = {
title: string;
value: string;
caption: string;
href: string;
tone: MetricTone;
icon: ComponentType<{ className?: string }>;
};
const numberFormatter = new Intl.NumberFormat("en-US");
const STALE_SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000;
const formatCount = (value: number | null | undefined): string =>
numberFormatter.format(Math.max(0, Math.round(Number(value ?? 0))));
const parseDate = (value: string | null | undefined): Date | null => {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
};
const toneClasses: Record<MetricTone, string> = {
accent:
"border-[color:var(--accent)]/30 bg-[color:var(--accent-soft)] text-[color:var(--accent)]",
success:
"border-[color:var(--success)]/30 bg-[color:rgba(52,211,153,0.12)] text-[color:var(--success)]",
warning:
"border-[color:var(--warning)]/30 bg-[color:rgba(251,191,36,0.12)] text-[color:var(--warning)]",
danger:
"border-[color:var(--danger)]/30 bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
muted:
"border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
};
const newestDate = (dates: Date[]): Date | null => {
if (dates.length === 0) return null;
return dates.reduce((latest, date) =>
date.getTime() > latest.getTime() ? date : latest,
);
};
function buildSyncHealthCard(
metrics: ForgejoIssueMetrics | null,
repositories: ForgejoRepository[],
): MetricCard {
const repositoryCount = repositories.length;
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
(count) => Number(count) > 0,
).length;
const metricSyncDates = Object.values(
metrics?.last_sync_timestamps ?? {},
).flatMap((value) => {
const date = parseDate(value);
return date ? [date] : [];
});
const repositorySyncDates = repositories.flatMap((repository) => {
const date = parseDate(repository.last_sync_at);
return date ? [date] : [];
});
const latestSync = newestDate(
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
);
const latestSyncAge = latestSync
? Date.now() - latestSync.getTime()
: Number.POSITIVE_INFINITY;
if (repositoryCount === 0) {
return {
title: "Last Sync Health",
value: "No repos",
caption: "Add repositories to track sync status.",
href: "/git-projects/repositories",
tone: "muted",
icon: RefreshCw,
};
}
if (syncErrorCount > 0) {
return {
title: "Last Sync Health",
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
caption: "Repository sync needs attention.",
href: "/git-projects/repositories",
tone: "danger",
icon: AlertTriangle,
};
}
if (!latestSync) {
return {
title: "Last Sync Health",
value: "Waiting",
caption: "Repositories have not synced yet.",
href: "/git-projects/repositories",
tone: "warning",
icon: Clock3,
};
}
if (latestSyncAge > STALE_SYNC_THRESHOLD_MS) {
return {
title: "Last Sync Health",
value: "Stale",
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
href: "/git-projects/repositories",
tone: "warning",
icon: Clock3,
};
}
return {
title: "Last Sync Health",
value: "Healthy",
caption: `Last sync ${formatRelativeTimestamp(latestSync.toISOString())}.`,
href: "/git-projects/repositories",
tone: "success",
icon: CheckCircle2,
};
}
function MetricSkeleton() {
return (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
<div className="h-4 w-28 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="mt-4 h-8 w-16 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="mt-3 h-3 w-36 animate-pulse rounded bg-[color:var(--surface-strong)]" />
</div>
);
}
function MetricCardLink({ card }: { card: MetricCard }) {
const Icon = card.icon;
return (
<Link
href={card.href}
className="group flex min-w-0 flex-col justify-between rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 transition hover:-translate-y-0.5 hover:border-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
{card.title}
</p>
<p className="mt-3 break-words font-heading text-3xl font-semibold text-strong">
{card.value}
</p>
</div>
<div
className={cn(
"shrink-0 rounded-lg border p-2 transition group-hover:scale-105",
toneClasses[card.tone],
)}
>
<Icon className="h-4 w-4" />
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<p className="min-w-0 text-sm text-muted">{card.caption}</p>
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted transition group-hover:text-[color:var(--accent)]" />
</div>
</Link>
);
}
export function ForgejoIssueMetricCards({
metrics,
repositories,
isLoading = false,
error,
}: ForgejoIssueMetricCardsProps) {
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 ?? repositories.length;
const cards: MetricCard[] = [
{
title: "Open Issues",
value: formatCount(openIssues),
caption:
openIssues === 0
? "No open Git Project issues."
: "Review open issues across Git Projects.",
href: "/git-projects/issues?state=open",
tone: openIssues > 0 ? "accent" : "muted",
icon: CircleDot,
},
{
title: "Recently Closed",
value: formatCount(recentlyClosed),
caption: "Closed in the last 7 days.",
href: "/git-projects/issues?state=closed&recent=7d",
tone: recentlyClosed > 0 ? "success" : "muted",
icon: CheckCircle2,
},
{
title: "Stale Open Issues",
value: formatCount(staleOpen),
caption:
staleOpen === 0
? "No stale open issues detected."
: "Open issues without recent movement.",
href: "/git-projects/issues?state=open&stale=1",
tone: staleOpen > 0 ? "warning" : "success",
icon: Clock3,
},
buildSyncHealthCard(metrics, repositories),
];
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-6">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="text-lg font-semibold text-strong">
Git Project Issue Tracking
</h3>
<p className="mt-1 text-sm text-muted">
High-level Forgejo issue health across repositories synced into
Pipeline.
</p>
</div>
<Link
href="/git-projects/issues"
className="inline-flex w-fit items-center gap-1 text-sm font-medium text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
>
Open issues
<ArrowUpRight className="h-4 w-4" />
</Link>
</div>
{error ? (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-[color:var(--warning)]/35 bg-[color:rgba(251,191,36,0.12)] p-3 text-sm text-[color:var(--warning)]">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{isLoading
? Array.from({ length: 4 }).map((_, index) => (
<MetricSkeleton key={index} />
))
: cards.map((card) => (
<MetricCardLink key={card.title} card={card} />
))}
</div>
{!isLoading && 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.
</div>
) : !isLoading && 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.
</div>
) : null}
</section>
);
}

View File

@ -344,18 +344,23 @@ export interface RepositorySyncHealth {
export interface ForgejoIssueMetrics { export interface ForgejoIssueMetrics {
open_issues: number; open_issues: number;
closed_issues: number; closed_issues: number;
recently_closed: number; closed_last_7_days: number;
stale_open: number; closed_last_30_days: number;
total_issues: number; stale_open_issues: number;
repositories_health: RepositorySyncHealth[]; repositories_synced: number;
last_sync_timestamps: Record<string, string>;
sync_error_counts: Record<string, number>;
} }
// Forgejo Metrics API // Forgejo Metrics API
export async function getForgejoMetrics(params?: { export async function getForgejoMetrics(params?: {
organization_id?: string;
board_id?: string; board_id?: string;
repository_id?: string; repository_id?: string;
}): Promise<ForgejoIssueMetrics> { }): Promise<ForgejoIssueMetrics> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params?.organization_id)
searchParams.set("organization_id", params.organization_id);
if (params?.board_id) searchParams.set("board_id", params.board_id); if (params?.board_id) searchParams.set("board_id", params.board_id);
if (params?.repository_id) if (params?.repository_id)
searchParams.set("repository_id", params.repository_id); searchParams.set("repository_id", params.repository_id);