1046 lines
32 KiB
TypeScript
1046 lines
32 KiB
TypeScript
"use client";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
import { type KeyboardEvent, type MouseEvent, useMemo } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
|
import { Activity, Bot, LayoutGrid, Timer } from "lucide-react";
|
|
|
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
|
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
|
|
import { ForgejoIssueMetricCards } from "@/components/git/ForgejoIssueMetricCards";
|
|
import { ForgejoHeatmap } from "@/components/git/ForgejoHeatmap";
|
|
import {
|
|
DashboardMetricCard,
|
|
DashboardInfoBlock,
|
|
DashboardEmptyState,
|
|
PendingApprovalsSection,
|
|
SessionsSection,
|
|
RecentActivitySection,
|
|
} from "@/components/dashboard";
|
|
import { ApiError } from "@/api/mutator";
|
|
import {
|
|
type dashboardMetricsApiV1MetricsDashboardGetResponse,
|
|
useDashboardMetricsApiV1MetricsDashboardGet,
|
|
} from "@/api/generated/metrics/metrics";
|
|
import { gatewaysStatusApiV1GatewaysStatusGet } from "@/api/generated/gateways/gateways";
|
|
import type { GatewaysStatusResponse } from "@/api/generated/model/gatewaysStatusResponse";
|
|
import {
|
|
type listAgentsApiV1AgentsGetResponse,
|
|
useListAgentsApiV1AgentsGet,
|
|
} from "@/api/generated/agents/agents";
|
|
import {
|
|
type listBoardsApiV1BoardsGetResponse,
|
|
useListBoardsApiV1BoardsGet,
|
|
} from "@/api/generated/boards/boards";
|
|
import {
|
|
type listActivityApiV1ActivityGetResponse,
|
|
useListActivityApiV1ActivityGet,
|
|
} from "@/api/generated/activity/activity";
|
|
import type { ActivityEventRead } from "@/api/generated/model";
|
|
import {
|
|
getForgejoHeatmap,
|
|
getForgejoMetrics,
|
|
getForgejoRepositories,
|
|
type ForgejoHeatmapDay,
|
|
type ForgejoIssueMetrics,
|
|
type ForgejoRepository,
|
|
} from "@/lib/api-forgejo";
|
|
import {
|
|
formatRelativeTimestamp,
|
|
formatTimestamp,
|
|
parseTimestamp,
|
|
} from "@/lib/formatters";
|
|
|
|
type SessionSummary = {
|
|
key: string;
|
|
title: string;
|
|
subtitle: string;
|
|
usage: string;
|
|
lastSeenAt: string | null;
|
|
isMain: boolean;
|
|
};
|
|
|
|
type SummaryRow = {
|
|
label: string;
|
|
value: string;
|
|
tone?: "default" | "success" | "warning" | "danger";
|
|
};
|
|
|
|
type GatewayTarget = {
|
|
gatewayId: string;
|
|
boardId: string;
|
|
boardName: string;
|
|
};
|
|
|
|
type GatewaySnapshot = GatewayTarget & {
|
|
connected: boolean;
|
|
gatewayUrl: string | null;
|
|
sessionsCount: number;
|
|
sessions: unknown[];
|
|
mainSession: unknown | null;
|
|
mainSessionError: string | null;
|
|
error: string | null;
|
|
requestError: string | null;
|
|
};
|
|
|
|
const DASH = "—";
|
|
const DASHBOARD_RANGE = "7d";
|
|
const DASHBOARD_RANGE_DAYS = 7;
|
|
const DASHBOARD_RANGE_LABEL = "7 days";
|
|
|
|
const numberFormatter = new Intl.NumberFormat("en-US");
|
|
const SESSION_ID_KEYS = ["key", "id", "session_key", "sessionKey", "sessionId"];
|
|
|
|
const toRecord = (value: unknown): Record<string, unknown> | null => {
|
|
if (!value || Array.isArray(value) || typeof value !== "object") return null;
|
|
return value as Record<string, unknown>;
|
|
};
|
|
|
|
const readString = (
|
|
record: Record<string, unknown> | null,
|
|
keys: string[],
|
|
): string | null => {
|
|
if (!record) return null;
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const readNumber = (
|
|
record: Record<string, unknown> | null,
|
|
keys: string[],
|
|
): number | null => {
|
|
if (!record) return null;
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string") {
|
|
const cleaned = value.replace(/[^0-9.-]/g, "");
|
|
const parsed = Number.parseFloat(cleaned);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const readStringFromRecords = (
|
|
records: Array<Record<string, unknown> | null>,
|
|
keys: string[],
|
|
): string | null => {
|
|
for (const record of records) {
|
|
const value = readString(record, keys);
|
|
if (value) return value;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const readNumberFromRecords = (
|
|
records: Array<Record<string, unknown> | null>,
|
|
keys: string[],
|
|
): number | null => {
|
|
for (const record of records) {
|
|
const value = readNumber(record, keys);
|
|
if (value !== null) return value;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const normalizeEpochMs = (value: number): number => {
|
|
if (value >= 1_000_000_000_000) return value;
|
|
if (value >= 1_000_000_000) return value * 1000;
|
|
return value;
|
|
};
|
|
|
|
const readTimestamp = (
|
|
record: Record<string, unknown> | null,
|
|
keys: string[],
|
|
): string | null => {
|
|
if (!record) return null;
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
const date = new Date(normalizeEpochMs(value));
|
|
if (!Number.isNaN(date.getTime())) return date.toISOString();
|
|
}
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) continue;
|
|
const numeric = Number.parseFloat(trimmed);
|
|
if (Number.isFinite(numeric)) {
|
|
const date = new Date(normalizeEpochMs(numeric));
|
|
if (!Number.isNaN(date.getTime())) return date.toISOString();
|
|
}
|
|
const parsed = parseTimestamp(trimmed);
|
|
if (parsed) return parsed.toISOString();
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const readTimestampFromRecords = (
|
|
records: Array<Record<string, unknown> | null>,
|
|
keys: string[],
|
|
): string | null => {
|
|
for (const record of records) {
|
|
const value = readTimestamp(record, keys);
|
|
if (value) return value;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
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[];
|
|
return [...new Set(ids)];
|
|
};
|
|
|
|
const sharesSessionIdentity = (left: string[], right: string[]): boolean =>
|
|
left.some((value) => right.includes(value));
|
|
|
|
const compactNumber = (value: number): string => {
|
|
if (!Number.isFinite(value)) return DASH;
|
|
if (Math.abs(value) >= 1_000_000) {
|
|
return `${(value / 1_000_000).toFixed(1)}m`;
|
|
}
|
|
if (Math.abs(value) >= 1_000) {
|
|
return `${(value / 1_000).toFixed(1)}k`;
|
|
}
|
|
return numberFormatter.format(value);
|
|
};
|
|
|
|
const formatCount = (value: number): string =>
|
|
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;
|
|
return `${(total / days).toFixed(1)}/day`;
|
|
};
|
|
|
|
const formatForgejoDashboardError = (error: Error | null): string | null => {
|
|
if (!error) return null;
|
|
if (error.message === "Failed to fetch") {
|
|
return "Pipeline could not load Git Project data. Check that the frontend can reach the backend API.";
|
|
}
|
|
return error.message;
|
|
};
|
|
|
|
const toSessionSummaries = (
|
|
sessions: unknown[] | null | undefined,
|
|
mainSession: unknown,
|
|
): SessionSummary[] => {
|
|
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),
|
|
);
|
|
if (!exists) sessionRecords.unshift(mainRecord);
|
|
}
|
|
|
|
const uniqueRecords: Record<string, unknown>[] = [];
|
|
const seenIdentifiers = new Set<string>();
|
|
|
|
for (const entry of sessionRecords) {
|
|
const identifiers = sessionIdentifiers(entry);
|
|
if (
|
|
identifiers.length > 0 &&
|
|
identifiers.some((value) => seenIdentifiers.has(value))
|
|
) {
|
|
continue;
|
|
}
|
|
uniqueRecords.push(entry);
|
|
identifiers.forEach((value) => seenIdentifiers.add(value));
|
|
}
|
|
|
|
return uniqueRecords.map((entry, index) => {
|
|
const usageRecord = toRecord(entry.usage);
|
|
const statsRecord = toRecord(entry.stats);
|
|
const metricsRecord = toRecord(entry.metrics);
|
|
const originRecord = toRecord(entry.origin);
|
|
const candidateRecords = [entry, usageRecord, statsRecord, metricsRecord];
|
|
|
|
const identifiers = sessionIdentifiers(entry);
|
|
const key =
|
|
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 model = readString(entry, [
|
|
"model",
|
|
"model_name",
|
|
"provider",
|
|
"engine",
|
|
]);
|
|
const modelProvider = readString(entry, [
|
|
"modelProvider",
|
|
"model_provider",
|
|
"provider",
|
|
]);
|
|
const lastSeenAt = readTimestampFromRecords(candidateRecords, [
|
|
"updated_at",
|
|
"updatedAt",
|
|
"last_updated_at",
|
|
"lastUpdatedAt",
|
|
"last_seen_at",
|
|
"lastSeen",
|
|
"last_seen",
|
|
"last_active_at",
|
|
"lastActiveAt",
|
|
"lastActivityAt",
|
|
"activityAt",
|
|
"created_at",
|
|
"createdAt",
|
|
]);
|
|
|
|
const usedTokens = readNumberFromRecords(candidateRecords, [
|
|
"used",
|
|
"used_tokens",
|
|
"tokens",
|
|
"current",
|
|
"token_count",
|
|
"tokenCount",
|
|
"totalTokens",
|
|
"total_tokens",
|
|
"inputTokens",
|
|
"input_tokens",
|
|
]);
|
|
const maxTokens = readNumberFromRecords(candidateRecords, [
|
|
"max",
|
|
"limit",
|
|
"token_limit",
|
|
"capacity",
|
|
"max_tokens",
|
|
"maxTokens",
|
|
"context_window",
|
|
"contextWindow",
|
|
"contextTokens",
|
|
"context_tokens",
|
|
"maxContextTokens",
|
|
"max_context_tokens",
|
|
]);
|
|
|
|
const pctFromPayload = readNumberFromRecords(candidateRecords, [
|
|
"pct",
|
|
"percent",
|
|
"ratio_pct",
|
|
"ratioPct",
|
|
"token_pct",
|
|
"usage_pct",
|
|
"percentUsed",
|
|
"contextPercent",
|
|
]);
|
|
const usagePct = Number.isFinite(pctFromPayload ?? NaN)
|
|
? Math.max(0, Math.min(100, Math.round(pctFromPayload ?? 0)))
|
|
: usedTokens !== null && maxTokens !== null && maxTokens > 0
|
|
? Math.max(0, Math.min(100, Math.round((usedTokens / maxTokens) * 100)))
|
|
: 0;
|
|
|
|
const usage =
|
|
usedTokens !== null && maxTokens !== null
|
|
? `${compactNumber(usedTokens)}/${compactNumber(maxTokens)} (${usagePct}%)`
|
|
: usedTokens !== null
|
|
? `${compactNumber(usedTokens)} tokens`
|
|
: DASH;
|
|
|
|
const subtitleBits = [channel, model].filter(Boolean) as string[];
|
|
const subtitle =
|
|
subtitleBits.length > 0 ? subtitleBits.join(" · ") : "Session";
|
|
const modelWithProvider =
|
|
modelProvider && model && modelProvider !== model
|
|
? `${model} · ${modelProvider}`
|
|
: model;
|
|
const subtitleWithProvider = [channel, modelWithProvider]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
|
|
return {
|
|
key,
|
|
title: label,
|
|
subtitle: subtitleWithProvider || subtitle,
|
|
usage,
|
|
lastSeenAt,
|
|
isMain:
|
|
mainIdentifiers.length > 0 &&
|
|
sharesSessionIdentity(identifiers, mainIdentifiers),
|
|
};
|
|
});
|
|
};
|
|
|
|
|
|
export default function DashboardPage() {
|
|
const router = useRouter();
|
|
const { isSignedIn } = useAuth();
|
|
|
|
const boardsQuery = useListBoardsApiV1BoardsGet<
|
|
listBoardsApiV1BoardsGetResponse,
|
|
ApiError
|
|
>(
|
|
{ limit: 200 },
|
|
{
|
|
query: {
|
|
enabled: Boolean(isSignedIn),
|
|
refetchInterval: 30_000,
|
|
refetchOnMount: "always",
|
|
},
|
|
},
|
|
);
|
|
|
|
const agentsQuery = useListAgentsApiV1AgentsGet<
|
|
listAgentsApiV1AgentsGetResponse,
|
|
ApiError
|
|
>(
|
|
{ limit: 200 },
|
|
{
|
|
query: {
|
|
enabled: Boolean(isSignedIn),
|
|
refetchInterval: 15_000,
|
|
refetchOnMount: "always",
|
|
},
|
|
},
|
|
);
|
|
|
|
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
|
|
dashboardMetricsApiV1MetricsDashboardGetResponse,
|
|
ApiError
|
|
>(
|
|
{
|
|
range_key: DASHBOARD_RANGE,
|
|
},
|
|
{
|
|
query: {
|
|
enabled: Boolean(isSignedIn),
|
|
refetchInterval: 15_000,
|
|
refetchOnMount: "always",
|
|
retry: 3,
|
|
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
|
|
},
|
|
},
|
|
);
|
|
|
|
const activityQuery = useListActivityApiV1ActivityGet<
|
|
listActivityApiV1ActivityGetResponse,
|
|
ApiError
|
|
>(
|
|
{ limit: 200 },
|
|
{
|
|
query: {
|
|
enabled: Boolean(isSignedIn),
|
|
refetchInterval: 15_000,
|
|
refetchOnMount: "always",
|
|
},
|
|
},
|
|
);
|
|
|
|
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 forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number } | null, Error>({
|
|
queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId],
|
|
enabled: Boolean(
|
|
isSignedIn &&
|
|
forgejoOrganizationId &&
|
|
!forgejoRepositoriesQuery.isLoading &&
|
|
!forgejoRepositoriesQuery.error,
|
|
),
|
|
refetchInterval: 60_000,
|
|
refetchOnMount: "always",
|
|
queryFn: () => {
|
|
if (!forgejoOrganizationId) return Promise.resolve(null);
|
|
return getForgejoHeatmap({ organization_id: forgejoOrganizationId });
|
|
},
|
|
});
|
|
|
|
const boards = useMemo(
|
|
() =>
|
|
boardsQuery.data?.status === 200
|
|
? [...(boardsQuery.data.data.items ?? [])].sort((a, b) =>
|
|
a.name.localeCompare(b.name),
|
|
)
|
|
: [],
|
|
[boardsQuery.data],
|
|
);
|
|
|
|
const agents = useMemo(
|
|
() =>
|
|
agentsQuery.data?.status === 200
|
|
? [...(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 onlineAgents = useMemo(
|
|
() =>
|
|
agents.filter((agent) => (agent.status ?? "").toLowerCase() === "online")
|
|
.length,
|
|
[agents],
|
|
);
|
|
const gatewayTargets = useMemo<GatewayTarget[]>(() => {
|
|
const byGateway = new Map<string, GatewayTarget>();
|
|
for (const board of boards) {
|
|
const gatewayId = board.gateway_id;
|
|
if (!gatewayId) continue;
|
|
if (byGateway.has(gatewayId)) continue;
|
|
byGateway.set(gatewayId, {
|
|
gatewayId,
|
|
boardId: board.id,
|
|
boardName: board.name,
|
|
});
|
|
}
|
|
return [...byGateway.values()].sort((a, b) =>
|
|
a.boardName.localeCompare(b.boardName),
|
|
);
|
|
}, [boards]);
|
|
const hasConfiguredGateways = gatewayTargets.length > 0;
|
|
|
|
const gatewayStatusesQuery = useQuery<GatewaySnapshot[], ApiError>({
|
|
queryKey: [
|
|
"dashboard",
|
|
"gateway-statuses",
|
|
gatewayTargets.map((target) => `${target.gatewayId}:${target.boardId}`),
|
|
],
|
|
enabled: Boolean(isSignedIn && hasConfiguredGateways),
|
|
refetchInterval: 15_000,
|
|
refetchOnMount: "always",
|
|
queryFn: async ({ signal }) => {
|
|
return Promise.all(
|
|
gatewayTargets.map(async (target): Promise<GatewaySnapshot> => {
|
|
try {
|
|
const response = await gatewaysStatusApiV1GatewaysStatusGet(
|
|
{ board_id: target.boardId },
|
|
{ signal },
|
|
);
|
|
if (response.status !== 200) {
|
|
return {
|
|
...target,
|
|
connected: false,
|
|
gatewayUrl: null,
|
|
sessionsCount: 0,
|
|
sessions: [],
|
|
mainSession: null,
|
|
mainSessionError: null,
|
|
error: null,
|
|
requestError: `Gateway status request failed (${response.status})`,
|
|
};
|
|
}
|
|
const payload: GatewaysStatusResponse = response.data;
|
|
return {
|
|
...target,
|
|
connected: Boolean(payload.connected),
|
|
gatewayUrl: payload.gateway_url ?? null,
|
|
sessionsCount: Number(payload.sessions_count ?? 0),
|
|
sessions: Array.isArray(payload.sessions) ? payload.sessions : [],
|
|
mainSession: payload.main_session ?? null,
|
|
mainSessionError: payload.main_session_error ?? null,
|
|
error: payload.error ?? null,
|
|
requestError: null,
|
|
};
|
|
} catch (error) {
|
|
if (signal.aborted) throw error;
|
|
return {
|
|
...target,
|
|
connected: false,
|
|
gatewayUrl: null,
|
|
sessionsCount: 0,
|
|
sessions: [],
|
|
mainSession: null,
|
|
mainSessionError: null,
|
|
error: null,
|
|
requestError:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Gateway status request failed.",
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
|
|
const gatewaySnapshots = useMemo(
|
|
() => gatewayStatusesQuery.data ?? [],
|
|
[gatewayStatusesQuery.data],
|
|
);
|
|
const sessionSummaries = useMemo(
|
|
() =>
|
|
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}`,
|
|
}),
|
|
);
|
|
}),
|
|
[gatewaySnapshots],
|
|
);
|
|
|
|
const activityEvents = useMemo(
|
|
() =>
|
|
activityQuery.data?.status === 200
|
|
? [...(activityQuery.data.data.items ?? [])]
|
|
: [],
|
|
[activityQuery.data],
|
|
);
|
|
|
|
const orderedActivityEvents = useMemo(
|
|
() =>
|
|
[...activityEvents].sort((a, b) => {
|
|
const left = parseTimestamp(a.created_at)?.getTime() ?? 0;
|
|
const right = parseTimestamp(b.created_at)?.getTime() ?? 0;
|
|
return right - left;
|
|
}),
|
|
[activityEvents],
|
|
);
|
|
|
|
const recentLogs = orderedActivityEvents.slice(0, 8);
|
|
|
|
const latestThroughputPoint =
|
|
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,
|
|
);
|
|
const completionDaysCount = (metrics?.throughput.primary.points ?? []).reduce(
|
|
(sum, point) => sum + (Number(point.value ?? 0) > 0 ? 1 : 0),
|
|
0,
|
|
);
|
|
|
|
const inboxTasksMetric = metrics?.kpis.inbox_tasks ?? 0;
|
|
const inProgressTasksMetric = metrics?.kpis.in_progress_tasks ?? 0;
|
|
const reviewTasksMetric = metrics?.kpis.review_tasks ?? 0;
|
|
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 errorRateMetric = Number(metrics?.kpis.error_rate_pct ?? 0);
|
|
const reviewBacklogRatio =
|
|
inProgressTasksMetric > 0
|
|
? reviewTasksMetric / inProgressTasksMetric
|
|
: null;
|
|
|
|
const gatewayConnectedCount = gatewaySnapshots.filter(
|
|
(snapshot) => !snapshot.requestError && snapshot.connected,
|
|
).length;
|
|
const gatewayDisconnectedCount = gatewaySnapshots.filter(
|
|
(snapshot) => !snapshot.requestError && !snapshot.connected,
|
|
).length;
|
|
const gatewayUnavailableCount = gatewaySnapshots.filter((snapshot) =>
|
|
Boolean(snapshot.requestError),
|
|
).length;
|
|
const gatewayHealthErrorCount = gatewaySnapshots.filter((snapshot) =>
|
|
Boolean(snapshot.error || snapshot.mainSessionError),
|
|
).length;
|
|
|
|
const countedSessions = gatewaySnapshots.reduce(
|
|
(sum, snapshot) => sum + Math.max(0, snapshot.sessionsCount),
|
|
0,
|
|
);
|
|
const activeSessions = Math.max(countedSessions, sessionSummaries.length);
|
|
|
|
const gatewayStatusLabel = !hasConfiguredGateways
|
|
? "Not configured"
|
|
: gatewayStatusesQuery.isLoading
|
|
? "Checking"
|
|
: gatewayConnectedCount === gatewayTargets.length
|
|
? "All connected"
|
|
: gatewayConnectedCount > 0
|
|
? "Partially connected"
|
|
: gatewayUnavailableCount === gatewayTargets.length
|
|
? "Unavailable"
|
|
: "Disconnected";
|
|
const gatewayBadgeTone: "online" | "offline" | "neutral" =
|
|
gatewayStatusLabel === "All connected"
|
|
? "online"
|
|
: gatewayStatusLabel === "Partially connected" ||
|
|
gatewayStatusLabel === "Disconnected" ||
|
|
gatewayStatusLabel === "Unavailable"
|
|
? "offline"
|
|
: "neutral";
|
|
const gatewayStatusTone: SummaryRow["tone"] =
|
|
gatewayStatusLabel === "All connected"
|
|
? "success"
|
|
: gatewayStatusLabel === "Checking" ||
|
|
gatewayStatusLabel === "Not configured"
|
|
? "default"
|
|
: gatewayStatusLabel === "Partially connected" ||
|
|
gatewayStatusLabel === "Disconnected"
|
|
? "warning"
|
|
: "danger";
|
|
|
|
const workloadRows: SummaryRow[] = [
|
|
{
|
|
label: "Total work items",
|
|
value: formatCount(tasksTotal),
|
|
},
|
|
{
|
|
label: "Inbox",
|
|
value: formatCount(inboxTasksMetric),
|
|
},
|
|
{
|
|
label: "In progress",
|
|
value: formatCount(inProgressTasksMetric),
|
|
tone: inProgressTasksMetric > 0 ? "warning" : "default",
|
|
},
|
|
{
|
|
label: "In review",
|
|
value: formatCount(reviewTasksMetric),
|
|
},
|
|
{
|
|
label: "Completed",
|
|
value: formatCount(doneTasksMetric),
|
|
tone: doneTasksMetric > 0 ? "success" : "default",
|
|
},
|
|
];
|
|
|
|
const throughputRows: SummaryRow[] = [
|
|
{
|
|
label: "Completed tasks",
|
|
value: formatCount(throughputTotal),
|
|
},
|
|
{
|
|
label: "Average throughput",
|
|
value: formatPerDay(throughputTotal, DASHBOARD_RANGE_DAYS),
|
|
},
|
|
{
|
|
label: "Error rate",
|
|
value: formatPercent(errorRateMetric),
|
|
tone: errorRateMetric > 0 ? "warning" : "success",
|
|
},
|
|
{
|
|
label: "Completion consistency",
|
|
value: `${formatCount(completionDaysCount)} active days`,
|
|
tone:
|
|
completionDaysCount >= Math.ceil(DASHBOARD_RANGE_DAYS * 0.75)
|
|
? "success"
|
|
: "default",
|
|
},
|
|
{
|
|
label: "Review backlog ratio",
|
|
value:
|
|
reviewBacklogRatio !== null
|
|
? `${reviewBacklogRatio.toFixed(2)}x`
|
|
: reviewTasksMetric > 0
|
|
? "∞"
|
|
: "0.00x",
|
|
tone:
|
|
reviewBacklogRatio !== null
|
|
? reviewBacklogRatio > 1
|
|
? "warning"
|
|
: "success"
|
|
: reviewTasksMetric > 0
|
|
? "warning"
|
|
: "success",
|
|
},
|
|
];
|
|
|
|
const gatewayRows: SummaryRow[] = [
|
|
{
|
|
label: "Gateway status",
|
|
value: gatewayStatusLabel,
|
|
tone: gatewayStatusTone,
|
|
},
|
|
{ label: "Configured gateways", value: formatCount(gatewayTargets.length) },
|
|
{
|
|
label: "Connected gateways",
|
|
value: formatCount(gatewayConnectedCount),
|
|
tone: gatewayConnectedCount > 0 ? "success" : "default",
|
|
},
|
|
{
|
|
label: "Unavailable gateways",
|
|
value: formatCount(gatewayUnavailableCount),
|
|
tone: gatewayUnavailableCount > 0 ? "danger" : "default",
|
|
},
|
|
{
|
|
label: "Gateways with issues",
|
|
value: formatCount(gatewayHealthErrorCount + gatewayDisconnectedCount),
|
|
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 =
|
|
formatForgejoDashboardError(forgejoRepositoriesQuery.error) ??
|
|
formatForgejoDashboardError(forgejoMetricsQuery.error) ??
|
|
null;
|
|
const forgejoIssueMetricsLoading =
|
|
forgejoRepositoriesQuery.isLoading || forgejoMetricsQuery.isLoading;
|
|
|
|
const shouldIgnoreRowNavigation = (target: EventTarget | null): boolean => {
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
return Boolean(target.closest("a"));
|
|
};
|
|
|
|
const buildActivityEventHref = (event: ActivityEventRead): string => {
|
|
const routeName = event.route_name ?? null;
|
|
const routeParams = event.route_params ?? {};
|
|
|
|
if (routeName === "board.approvals") {
|
|
const boardId = routeParams.boardId;
|
|
if (boardId) {
|
|
return `/boards/${encodeURIComponent(boardId)}/approvals`;
|
|
}
|
|
}
|
|
|
|
if (routeName === "board") {
|
|
const boardId = routeParams.boardId;
|
|
if (boardId) {
|
|
const params = new URLSearchParams();
|
|
Object.entries(routeParams).forEach(([key, value]) => {
|
|
if (key !== "boardId") params.set(key, value);
|
|
});
|
|
const query = params.toString();
|
|
return query
|
|
? `/boards/${encodeURIComponent(boardId)}?${query}`
|
|
: `/boards/${encodeURIComponent(boardId)}`;
|
|
}
|
|
}
|
|
|
|
const params = new URLSearchParams(
|
|
Object.keys(routeParams).length > 0
|
|
? routeParams
|
|
: {
|
|
eventId: event.id,
|
|
eventType: event.event_type,
|
|
createdAt: event.created_at,
|
|
},
|
|
);
|
|
if (event.task_id && !params.has("taskId")) {
|
|
params.set("taskId", event.task_id);
|
|
}
|
|
return `${activityFeedHref}?${params.toString()}`;
|
|
};
|
|
|
|
const navigateToActivityFeed = (href: string) => {
|
|
router.push(href);
|
|
};
|
|
|
|
const handleLogRowClick = (
|
|
event: MouseEvent<HTMLDivElement>,
|
|
href: string,
|
|
) => {
|
|
if (shouldIgnoreRowNavigation(event.target)) return;
|
|
navigateToActivityFeed(href);
|
|
};
|
|
|
|
const handleLogRowKeyDown = (
|
|
event: KeyboardEvent<HTMLDivElement>,
|
|
href: string,
|
|
) => {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
if (shouldIgnoreRowNavigation(event.target)) return;
|
|
event.preventDefault();
|
|
navigateToActivityFeed(href);
|
|
};
|
|
|
|
return (
|
|
<DashboardShell>
|
|
<SignedOut>
|
|
<SignedOutPanel
|
|
message="Sign in to access the dashboard."
|
|
forceRedirectUrl="/onboarding"
|
|
signUpForceRedirectUrl="/onboarding"
|
|
/>
|
|
</SignedOut>
|
|
<SignedIn>
|
|
<DashboardSidebar />
|
|
<main className="flex-1 overflow-y-auto bg-app">
|
|
<div className="p-4 md:p-8">
|
|
{metricsQuery.error ? (
|
|
<div className="mb-4 rounded-lg 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)]">
|
|
Load failed: {metricsQuery.error.message}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<DashboardMetricCard
|
|
title="Online Agents"
|
|
value={formatCount(activeAgentsMetric)}
|
|
secondary={`${formatCount(agents.length)} total`}
|
|
icon={<Bot className="h-4 w-4" />}
|
|
tone="accent"
|
|
/>
|
|
<DashboardMetricCard
|
|
title="Tasks In Progress"
|
|
value={formatCount(tasksInProgressMetric)}
|
|
secondary={`${formatCount(tasksTotal)} total`}
|
|
icon={<LayoutGrid className="h-4 w-4" />}
|
|
tone="success"
|
|
/>
|
|
<DashboardMetricCard
|
|
title="Error Rate"
|
|
value={formatPercent(errorRateMetric)}
|
|
secondary={`${formatCount(Number(latestThroughputPoint?.value ?? 0))} completed (latest)`}
|
|
icon={<Activity className="h-4 w-4" />}
|
|
tone="warning"
|
|
/>
|
|
<DashboardMetricCard
|
|
title="Completion Speed"
|
|
value={formatPerDay(throughputTotal, DASHBOARD_RANGE_DAYS)}
|
|
secondary={`${formatCount(throughputTotal)} completed`}
|
|
infoText={`Based on ${DASHBOARD_RANGE_LABEL}`}
|
|
icon={<Timer className="h-4 w-4" />}
|
|
tone="success"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<ForgejoIssueMetricCards
|
|
metrics={forgejoIssueMetrics}
|
|
repositories={forgejoRepositories}
|
|
isLoading={forgejoIssueMetricsLoading}
|
|
error={forgejoIssueMetricsError}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-6">
|
|
<div className="mb-4">
|
|
<h3 className="text-lg font-semibold text-strong">
|
|
Git Activity
|
|
</h3>
|
|
<p className="mt-1 text-sm text-muted">
|
|
Issue contributions across all tracked repositories in the
|
|
last 12 months.
|
|
</p>
|
|
</div>
|
|
<ForgejoHeatmap
|
|
days={forgejoHeatmapQuery.data?.days ?? []}
|
|
maxCount={forgejoHeatmapQuery.data?.max_count ?? 0}
|
|
isLoading={forgejoHeatmapQuery.isLoading}
|
|
/>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
|
<DashboardInfoBlock title="Workload" rows={workloadRows} />
|
|
<DashboardInfoBlock
|
|
title="Throughput"
|
|
infoText={`All throughput values are calculated for ${DASHBOARD_RANGE_LABEL}`}
|
|
rows={throughputRows}
|
|
/>
|
|
<DashboardInfoBlock
|
|
title="Gateway Health"
|
|
badge={{ text: gatewayStatusLabel, tone: gatewayBadgeTone }}
|
|
rows={gatewayRows}
|
|
/>
|
|
</div>
|
|
|
|
<PendingApprovalsSection
|
|
items={pendingApprovalItems}
|
|
total={pendingApprovalsTotal}
|
|
isLoading={!metrics && metricsQuery.isLoading}
|
|
hasError={!metrics && Boolean(metricsQuery.error)}
|
|
formatCount={formatCount}
|
|
formatRelative={formatRelativeTimestamp}
|
|
/>
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<SessionsSection
|
|
sessions={sessionSummaries}
|
|
activeSessions={activeSessions}
|
|
hasConfiguredGateways={hasConfiguredGateways}
|
|
isLoading={gatewayStatusesQuery.isLoading}
|
|
gatewayUnavailableCount={gatewayUnavailableCount}
|
|
gatewayTargetsCount={gatewayTargets.length}
|
|
formatCount={formatCount}
|
|
formatRelative={formatRelativeTimestamp}
|
|
dash={DASH}
|
|
/>
|
|
<RecentActivitySection
|
|
events={recentLogs}
|
|
feedHref={activityFeedHref}
|
|
onRowClick={handleLogRowClick}
|
|
onRowKeyDown={handleLogRowKeyDown}
|
|
buildHref={buildActivityEventHref}
|
|
formatRelative={formatRelativeTimestamp}
|
|
formatTimestamp={formatTimestamp}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</SignedIn>
|
|
</DashboardShell>
|
|
);
|
|
}
|