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 { Markdown } from "@/components/atoms/Markdown";
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
import { ForgejoIssueMetricCards } from "@/components/git/ForgejoIssueMetricCards";
import { ApiError } from "@/api/mutator";
import {
type dashboardMetricsApiV1MetricsDashboardGetResponse,
useDashboardMetricsApiV1MetricsDashboardGet,
} from "@/api/generated/metrics/metrics";
import {
gatewaysStatusApiV1GatewaysStatusGet,
} from "@/api/generated/gateways/gateways";
import { gatewaysStatusApiV1GatewaysStatusGet } from "@/api/generated/gateways/gateways";
import type { GatewaysStatusResponse } from "@/api/generated/model/gatewaysStatusResponse";
import {
type listAgentsApiV1AgentsGetResponse,
@ -44,6 +43,12 @@ import {
useListActivityApiV1ActivityGet,
} from "@/api/generated/activity/activity";
import type { ActivityEventRead } from "@/api/generated/model";
import {
getForgejoMetrics,
getForgejoRepositories,
type ForgejoIssueMetrics,
type ForgejoRepository,
} from "@/lib/api-forgejo";
import {
formatRelativeTimestamp,
formatTimestamp,
@ -189,9 +194,13 @@ const readTimestampFromRecords = (
return null;
};
const sessionIdentifiers = (record: Record<string, unknown> | null): string[] => {
const sessionIdentifiers = (
record: Record<string, unknown> | null,
): string[] => {
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)];
};
@ -210,13 +219,16 @@ const compactNumber = (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 =>
Number.isFinite(value) ? `${value.toFixed(1)}%` : DASH;
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`;
};
@ -224,15 +236,15 @@ const toSessionSummaries = (
sessions: unknown[] | null | undefined,
mainSession: unknown,
): SessionSummary[] => {
const sessionRecords = (sessions ?? []).map(toRecord).filter(Boolean) as Array<
Record<string, unknown>
>;
const sessionRecords = (sessions ?? [])
.map(toRecord)
.filter(Boolean) as Array<Record<string, unknown>>;
const mainRecord = toRecord(mainSession);
const mainIdentifiers = sessionIdentifiers(mainRecord);
if (mainRecord && mainIdentifiers.length > 0) {
const exists = sessionRecords.some(
(entry) => sharesSessionIdentity(sessionIdentifiers(entry), mainIdentifiers),
const exists = sessionRecords.some((entry) =>
sharesSessionIdentity(sessionIdentifiers(entry), mainIdentifiers),
);
if (!exists) sessionRecords.unshift(mainRecord);
}
@ -242,7 +254,10 @@ const toSessionSummaries = (
for (const entry of sessionRecords) {
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;
}
uniqueRecords.push(entry);
@ -258,17 +273,29 @@ const toSessionSummaries = (
const identifiers = sessionIdentifiers(entry);
const key =
readString(entry, ["key", "session_key", "sessionKey", "id", "sessionId"]) ??
`session-${index}`;
readString(entry, [
"key",
"session_key",
"sessionKey",
"id",
"sessionId",
]) ?? `session-${index}`;
const label = readString(entry, ["label", "name", "title"]) ?? key;
const channel = readStringFromRecords([entry, originRecord], [
"channel",
"source",
"kind",
"chatType",
const channel = readStringFromRecords(
[entry, originRecord],
["channel", "source", "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, [
"updated_at",
"updatedAt",
@ -336,10 +363,15 @@ const toSessionSummaries = (
: DASH;
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 =
modelProvider && model && modelProvider !== model ? `${model} · ${modelProvider}` : model;
const subtitleWithProvider = [channel, modelWithProvider].filter(Boolean).join(" · ");
modelProvider && model && modelProvider !== model
? `${model} · ${modelProvider}`
: model;
const subtitleWithProvider = [channel, modelWithProvider]
.filter(Boolean)
.join(" · ");
return {
key,
@ -397,15 +429,15 @@ function TopMetricCard({
) : null}
</div>
<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 ? (
<p className="pb-1 text-xs text-slate-500">{secondary}</p>
) : null}
</div>
</div>
<div className={`rounded-lg p-2 ${iconTone}`}>
{icon}
</div>
<div className={`rounded-lg p-2 ${iconTone}`}>{icon}</div>
</div>
</section>
);
@ -453,7 +485,10 @@ function InfoBlock({
</div>
<div className="divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white">
{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={`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 { isSignedIn } = useAuth();
const boardsQuery = useListBoardsApiV1BoardsGet<listBoardsApiV1BoardsGetResponse, ApiError>(
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
@ -490,7 +528,10 @@ export default function DashboardPage() {
},
);
const agentsQuery = useListAgentsApiV1AgentsGet<listAgentsApiV1AgentsGetResponse, ApiError>(
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
@ -519,7 +560,10 @@ export default function DashboardPage() {
},
);
const activityQuery = useListActivityApiV1ActivityGet<listActivityApiV1ActivityGetResponse, ApiError>(
const activityQuery = useListActivityApiV1ActivityGet<
listActivityApiV1ActivityGetResponse,
ApiError
>(
{ limit: 200 },
{
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(
() =>
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],
);
@ -541,15 +621,20 @@ export default function DashboardPage() {
const agents = useMemo(
() =>
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],
);
const metrics = metricsQuery.data?.status === 200 ? metricsQuery.data.data : null;
const metrics =
metricsQuery.data?.status === 200 ? metricsQuery.data.data : null;
const onlineAgents = useMemo(
() => agents.filter((agent) => (agent.status ?? "").toLowerCase() === "online").length,
() =>
agents.filter((agent) => (agent.status ?? "").toLowerCase() === "online")
.length,
[agents],
);
const gatewayTargets = useMemo<GatewayTarget[]>(() => {
@ -564,7 +649,9 @@ export default function DashboardPage() {
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]);
const hasConfiguredGateways = gatewayTargets.length > 0;
@ -622,7 +709,9 @@ export default function DashboardPage() {
mainSessionError: null,
error: null,
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) => {
if (snapshot.requestError) return [];
const sourceLabel = snapshot.gatewayUrl || snapshot.boardName;
return toSessionSummaries(snapshot.sessions, snapshot.mainSession).map((session) => ({
return toSessionSummaries(snapshot.sessions, snapshot.mainSession).map(
(session) => ({
...session,
key: `${snapshot.gatewayId}:${session.key}`,
subtitle: `${sourceLabel} · ${session.subtitle}`,
}));
}),
);
}),
[gatewaySnapshots],
);
@ -669,7 +760,9 @@ export default function DashboardPage() {
const recentLogs = orderedActivityEvents.slice(0, 8);
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(
(sum, point) => sum + Number(point.value ?? 0),
0,
@ -685,11 +778,18 @@ export default function DashboardPage() {
const doneTasksMetric = metrics?.kpis.done_tasks ?? 0;
const activeAgentsMetric = onlineAgents;
const tasksTotal = inboxTasksMetric + inProgressTasksMetric + reviewTasksMetric + doneTasksMetric;
const tasksInProgressMetric = metrics?.kpis.tasks_in_progress ?? inProgressTasksMetric;
const tasksTotal =
inboxTasksMetric +
inProgressTasksMetric +
reviewTasksMetric +
doneTasksMetric;
const tasksInProgressMetric =
metrics?.kpis.tasks_in_progress ?? inProgressTasksMetric;
const errorRateMetric = Number(metrics?.kpis.error_rate_pct ?? 0);
const reviewBacklogRatio =
inProgressTasksMetric > 0 ? reviewTasksMetric / inProgressTasksMetric : null;
inProgressTasksMetric > 0
? reviewTasksMetric / inProgressTasksMetric
: null;
const gatewayConnectedCount = gatewaySnapshots.filter(
(snapshot) => !snapshot.requestError && snapshot.connected,
@ -697,11 +797,11 @@ export default function DashboardPage() {
const gatewayDisconnectedCount = gatewaySnapshots.filter(
(snapshot) => !snapshot.requestError && !snapshot.connected,
).length;
const gatewayUnavailableCount = gatewaySnapshots.filter(
(snapshot) => Boolean(snapshot.requestError),
const gatewayUnavailableCount = gatewaySnapshots.filter((snapshot) =>
Boolean(snapshot.requestError),
).length;
const gatewayHealthErrorCount = gatewaySnapshots.filter(
(snapshot) => Boolean(snapshot.error || snapshot.mainSessionError),
const gatewayHealthErrorCount = gatewaySnapshots.filter((snapshot) =>
Boolean(snapshot.error || snapshot.mainSessionError),
).length;
const countedSessions = gatewaySnapshots.reduce(
@ -732,9 +832,11 @@ export default function DashboardPage() {
const gatewayStatusTone: SummaryRow["tone"] =
gatewayStatusLabel === "All connected"
? "success"
: gatewayStatusLabel === "Checking" || gatewayStatusLabel === "Not configured"
: gatewayStatusLabel === "Checking" ||
gatewayStatusLabel === "Not configured"
? "default"
: gatewayStatusLabel === "Partially connected" || gatewayStatusLabel === "Disconnected"
: gatewayStatusLabel === "Partially connected" ||
gatewayStatusLabel === "Disconnected"
? "warning"
: "danger";
@ -768,7 +870,10 @@ export default function DashboardPage() {
label: "Completed tasks",
value: formatCount(throughputTotal),
},
{ label: "Average throughput", value: formatPerDay(throughputTotal, DASHBOARD_RANGE_DAYS) },
{
label: "Average throughput",
value: formatPerDay(throughputTotal, DASHBOARD_RANGE_DAYS),
},
{
label: "Error rate",
value: formatPercent(errorRateMetric),
@ -777,7 +882,10 @@ export default function DashboardPage() {
{
label: "Completion consistency",
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",
@ -799,7 +907,11 @@ export default function DashboardPage() {
];
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: "Connected gateways",
@ -814,13 +926,23 @@ export default function DashboardPage() {
{
label: "Gateways with issues",
value: formatCount(gatewayHealthErrorCount + gatewayDisconnectedCount),
tone: gatewayHealthErrorCount + gatewayDisconnectedCount > 0 ? "warning" : "success",
tone:
gatewayHealthErrorCount + gatewayDisconnectedCount > 0
? "warning"
: "success",
},
];
const pendingApprovalItems = metrics?.pending_approvals.items ?? [];
const pendingApprovalsTotal = metrics?.pending_approvals.total ?? 0;
const hasPendingApprovals = pendingApprovalItems.length > 0;
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 => {
if (!(target instanceof HTMLElement)) return false;
@ -940,11 +1062,17 @@ export default function DashboardPage() {
/>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<InfoBlock
title="Workload"
rows={workloadRows}
<div className="mt-4">
<ForgejoIssueMetricCards
metrics={forgejoIssueMetrics}
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
title="Throughput"
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">
<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
href="/approvals"
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>
{pendingApprovalsTotal > pendingApprovalItems.length ? (
<p className="text-xs text-slate-500">
Showing latest {formatCount(pendingApprovalItems.length)} of{" "}
{formatCount(pendingApprovalsTotal)} pending approvals.
Showing latest {formatCount(pendingApprovalItems.length)}{" "}
of {formatCount(pendingApprovalsTotal)} pending approvals.
</p>
) : null}
</div>
@ -1020,8 +1150,12 @@ export default function DashboardPage() {
<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">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-slate-900">Sessions</h3>
<span className="text-xs text-slate-500">{formatCount(activeSessions)}</span>
<h3 className="text-lg font-semibold text-slate-900">
Sessions
</h3>
<span className="text-xs text-slate-500">
{formatCount(activeSessions)}
</span>
</div>
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{!hasConfiguredGateways ? (
@ -1037,8 +1171,8 @@ export default function DashboardPage() {
{gatewayUnavailableCount > 0 ? (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
{formatCount(gatewayUnavailableCount)} gateway
{gatewayUnavailableCount === 1 ? "" : "s"} unavailable; showing sessions
from reachable gateways.
{gatewayUnavailableCount === 1 ? "" : "s"}{" "}
unavailable; showing sessions from reachable gateways.
</div>
) : null}
{sessionSummaries.map((session) => (
@ -1051,16 +1185,22 @@ export default function DashboardPage() {
<p className="truncate text-sm font-medium text-slate-900">
<span
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}
</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 className="min-w-0 max-w-[45%] text-right">
<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 className="text-[11px] text-slate-500">
{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">
<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
href={activityFeedHref}
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="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
content={event.message?.trim() || event.event_type}
content={
event.message?.trim() || event.event_type
}
variant="comment"
/>
</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">
<Shield className="mb-2 h-5 w-5 text-slate-400" />
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>

View File

@ -2,7 +2,8 @@
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 { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
@ -16,16 +17,67 @@ import {
import { ForgejoIssueFilters } from "@/components/git/ForgejoIssueFilters";
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() {
const searchParams = useSearchParams();
const initialStaleOnly = searchParams.get("stale") === "1";
const initialRecentClosedOnly =
!initialStaleOnly && searchParams.get("recent") === "7d";
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
const [repos, setRepos] = useState<ForgejoRepository[]>([]);
const [total, setTotal] = useState(0);
const [isLoadingIssues, setIsLoadingIssues] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stateFilter, setStateFilter] = useState<string>("open");
const [repoFilter, setRepoFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [stateFilter, setStateFilter] = useState<string>(() =>
initialStaleOnly
? "open"
: 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;
useEffect(() => {
@ -45,11 +97,17 @@ export default function GitIssuesPage() {
try {
setIsLoadingIssues(true);
const result = await getForgejoIssues({
state: stateFilter !== "all" ? stateFilter : undefined,
state: staleOnly
? "open"
: recentClosedOnly
? "closed"
: stateFilter !== "all"
? stateFilter
: undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined,
page,
limit,
page: staleOnly || recentClosedOnly ? 1 : page,
limit: staleOnly || recentClosedOnly ? 100 : limit,
});
setIssues(result.items);
setTotal(result.total);
@ -66,17 +124,23 @@ export default function GitIssuesPage() {
}
})();
return () => controller.abort();
}, [stateFilter, repoFilter, search, page]);
}, [stateFilter, repoFilter, search, staleOnly, recentClosedOnly, page]);
const handleRefresh = async () => {
try {
setIsLoadingIssues(true);
const result = await getForgejoIssues({
state: stateFilter !== "all" ? stateFilter : undefined,
state: staleOnly
? "open"
: recentClosedOnly
? "closed"
: stateFilter !== "all"
? stateFilter
: undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined,
page,
limit,
page: staleOnly || recentClosedOnly ? 1 : page,
limit: staleOnly || recentClosedOnly ? 100 : limit,
});
setIssues(result.items);
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 (
<DashboardPageLayout
@ -102,13 +187,15 @@ export default function GitIssuesPage() {
signUpForceRedirectUrl: "/git-projects/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
>
<ForgejoIssueFilters
stateFilter={stateFilter}
onStateChange={(v) => {
setStateFilter(v);
setStaleOnly(false);
setRecentClosedOnly(false);
setPage(1);
}}
repoFilter={repoFilter}
@ -124,6 +211,46 @@ export default function GitIssuesPage() {
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 ? (
<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" />
@ -132,7 +259,7 @@ export default function GitIssuesPage() {
) : null}
<ForgejoIssuesTable
issues={issues}
issues={visibleIssues}
isLoading={isLoadingIssues}
onRefresh={handleRefresh}
/>
@ -140,7 +267,7 @@ export default function GitIssuesPage() {
{totalPages > 1 && (
<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">
Page {page} of {totalPages} ({total} total)
Page {page} of {totalPages} ({visibleTotal} total)
</span>
<div className="flex gap-2">
<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 {
open_issues: number;
closed_issues: number;
recently_closed: number;
stale_open: number;
total_issues: number;
repositories_health: RepositorySyncHealth[];
closed_last_7_days: number;
closed_last_30_days: number;
stale_open_issues: number;
repositories_synced: number;
last_sync_timestamps: Record<string, string>;
sync_error_counts: Record<string, number>;
}
// Forgejo Metrics API
export async function getForgejoMetrics(params?: {
organization_id?: string;
board_id?: string;
repository_id?: string;
}): Promise<ForgejoIssueMetrics> {
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?.repository_id)
searchParams.set("repository_id", params.repository_id);