feat(dashboard): issue tracking widgets (#27)
This commit is contained in:
parent
8e012a2197
commit
21dadc8724
|
|
@ -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) => ({
|
||||
...session,
|
||||
key: `${snapshot.gatewayId}:${session.key}`,
|
||||
subtitle: `${sourceLabel} · ${session.subtitle}`,
|
||||
}));
|
||||
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"
|
||||
|
|
@ -1104,7 +1246,7 @@ export default function DashboardPage() {
|
|||
key={event.id}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`Open related context for ${event.event_type} activity`}
|
||||
aria-label={`Open related context for ${event.event_type} activity`}
|
||||
onClick={(interactionEvent) =>
|
||||
handleLogRowClick(interactionEvent, eventHref)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue