|
|
@@ -1,13 +1,13 @@
|
|
|
"use client";
|
|
|
|
|
|
+import { useInfiniteQuery } from "@tanstack/react-query";
|
|
|
import { Check, ChevronDown, Filter, Loader2, RefreshCw, ScrollText, X } from "lucide-react";
|
|
|
import { useTranslations } from "next-intl";
|
|
|
-import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
|
|
+import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
import {
|
|
|
getMyAvailableEndpoints,
|
|
|
getMyAvailableModels,
|
|
|
- getMyUsageLogs,
|
|
|
- type MyUsageLogsResult,
|
|
|
+ getMyUsageLogsBatch,
|
|
|
} from "@/actions/my-usage";
|
|
|
import { LogsDateRangePicker } from "@/app/[locale]/dashboard/logs/_components/logs-date-range-picker";
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
@@ -25,9 +25,9 @@ import {
|
|
|
import { cn } from "@/lib/utils";
|
|
|
import { UsageLogsTable } from "./usage-logs-table";
|
|
|
|
|
|
+const BATCH_SIZE = 20;
|
|
|
+
|
|
|
interface UsageLogsSectionProps {
|
|
|
- initialData?: MyUsageLogsResult | null;
|
|
|
- loading?: boolean;
|
|
|
autoRefreshSeconds?: number;
|
|
|
defaultOpen?: boolean;
|
|
|
serverTimeZone?: string;
|
|
|
@@ -41,12 +41,9 @@ interface Filters {
|
|
|
excludeStatusCode200?: boolean;
|
|
|
endpoint?: string;
|
|
|
minRetryCount?: number;
|
|
|
- page?: number;
|
|
|
}
|
|
|
|
|
|
export function UsageLogsSection({
|
|
|
- initialData = null,
|
|
|
- loading = false,
|
|
|
autoRefreshSeconds,
|
|
|
defaultOpen = false,
|
|
|
serverTimeZone,
|
|
|
@@ -60,14 +57,70 @@ export function UsageLogsSection({
|
|
|
const [endpoints, setEndpoints] = useState<string[]>([]);
|
|
|
const [isModelsLoading, setIsModelsLoading] = useState(true);
|
|
|
const [isEndpointsLoading, setIsEndpointsLoading] = useState(true);
|
|
|
- const [draftFilters, setDraftFilters] = useState<Filters>({ page: 1 });
|
|
|
- const [appliedFilters, setAppliedFilters] = useState<Filters>({ page: 1 });
|
|
|
- const [data, setData] = useState<MyUsageLogsResult | null>(initialData);
|
|
|
- const [isPending, startTransition] = useTransition();
|
|
|
- const [error, setError] = useState<string | null>(null);
|
|
|
+ const [draftFilters, setDraftFilters] = useState<Filters>({});
|
|
|
+ const [appliedFilters, setAppliedFilters] = useState<Filters>({});
|
|
|
+ const [isBrowsingHistory, setIsBrowsingHistory] = useState(false);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ setIsModelsLoading(true);
|
|
|
+ setIsEndpointsLoading(true);
|
|
|
+
|
|
|
+ void getMyAvailableModels()
|
|
|
+ .then((modelsResult) => {
|
|
|
+ if (modelsResult.ok && modelsResult.data) {
|
|
|
+ setModels(modelsResult.data);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .finally(() => setIsModelsLoading(false));
|
|
|
|
|
|
- // Compute metrics for header summary
|
|
|
- const logs = data?.logs ?? [];
|
|
|
+ void getMyAvailableEndpoints()
|
|
|
+ .then((endpointsResult) => {
|
|
|
+ if (endpointsResult.ok && endpointsResult.data) {
|
|
|
+ setEndpoints(endpointsResult.data);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .finally(() => setIsEndpointsLoading(false));
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const query = useInfiniteQuery({
|
|
|
+ queryKey: ["my-usage-logs-batch", appliedFilters],
|
|
|
+ queryFn: async ({ pageParam }) => {
|
|
|
+ const result = await getMyUsageLogsBatch({
|
|
|
+ ...appliedFilters,
|
|
|
+ cursor: pageParam,
|
|
|
+ limit: BATCH_SIZE,
|
|
|
+ });
|
|
|
+ if (!result.ok) {
|
|
|
+ throw new Error(result.error);
|
|
|
+ }
|
|
|
+ return result.data;
|
|
|
+ },
|
|
|
+ initialPageParam: undefined as { createdAt: string; id: number } | undefined,
|
|
|
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
|
+ staleTime: 30000,
|
|
|
+ refetchOnWindowFocus: false,
|
|
|
+ refetchInterval: autoRefreshSeconds
|
|
|
+ ? (query) => {
|
|
|
+ if (isBrowsingHistory) return false;
|
|
|
+ if (query.state.fetchStatus !== "idle") return false;
|
|
|
+ return autoRefreshSeconds * 1000;
|
|
|
+ }
|
|
|
+ : false,
|
|
|
+ });
|
|
|
+ const {
|
|
|
+ data,
|
|
|
+ fetchNextPage,
|
|
|
+ hasNextPage = false,
|
|
|
+ isFetchingNextPage,
|
|
|
+ isLoading,
|
|
|
+ isError,
|
|
|
+ error,
|
|
|
+ isRefetching = false,
|
|
|
+ } = query;
|
|
|
+ const refetch = query.refetch ?? (async (): Promise<unknown> => undefined);
|
|
|
+
|
|
|
+ const logs = useMemo(() => data?.pages.flatMap((page) => page.logs) ?? [], [data]);
|
|
|
+ const latestPage = data?.pages[0];
|
|
|
|
|
|
const activeFiltersCount = useMemo(() => {
|
|
|
let count = 0;
|
|
|
@@ -79,10 +132,7 @@ export function UsageLogsSection({
|
|
|
return count;
|
|
|
}, [appliedFilters]);
|
|
|
|
|
|
- const lastLog = useMemo(() => {
|
|
|
- if (!logs || logs.length === 0) return null;
|
|
|
- return logs[0]; // First log is the most recent (sorted by createdAt DESC)
|
|
|
- }, [logs]);
|
|
|
+ const lastLog = logs[0] ?? null;
|
|
|
|
|
|
const lastStatusText = useMemo(() => {
|
|
|
if (!lastLog?.createdAt) return null;
|
|
|
@@ -99,7 +149,7 @@ export function UsageLogsSection({
|
|
|
}, [lastLog]);
|
|
|
|
|
|
const successRate = useMemo(() => {
|
|
|
- if (!logs || logs.length === 0) return null;
|
|
|
+ if (logs.length === 0) return null;
|
|
|
const successCount = logs.filter((log) => log.statusCode && log.statusCode < 400).length;
|
|
|
return Math.round((successCount / logs.length) * 100);
|
|
|
}, [logs]);
|
|
|
@@ -111,133 +161,38 @@ export function UsageLogsSection({
|
|
|
return "";
|
|
|
}, [lastLog]);
|
|
|
|
|
|
- // Sync initialData from parent when it becomes available
|
|
|
- // (useState only uses initialData on first mount, not on subsequent updates)
|
|
|
- useEffect(() => {
|
|
|
- if (initialData && !data) {
|
|
|
- setData(initialData);
|
|
|
- }
|
|
|
- }, [initialData, data]);
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- setIsModelsLoading(true);
|
|
|
- setIsEndpointsLoading(true);
|
|
|
-
|
|
|
- void getMyAvailableModels()
|
|
|
- .then((modelsResult) => {
|
|
|
- if (modelsResult.ok && modelsResult.data) {
|
|
|
- setModels(modelsResult.data);
|
|
|
- }
|
|
|
- })
|
|
|
- .finally(() => setIsModelsLoading(false));
|
|
|
-
|
|
|
- void getMyAvailableEndpoints()
|
|
|
- .then((endpointsResult) => {
|
|
|
- if (endpointsResult.ok && endpointsResult.data) {
|
|
|
- setEndpoints(endpointsResult.data);
|
|
|
- }
|
|
|
- })
|
|
|
- .finally(() => setIsEndpointsLoading(false));
|
|
|
- }, []);
|
|
|
-
|
|
|
- const loadLogs = useCallback(
|
|
|
- (nextFilters: Filters) => {
|
|
|
- startTransition(async () => {
|
|
|
- const result = await getMyUsageLogs(nextFilters);
|
|
|
- if (result.ok && result.data) {
|
|
|
- setData(result.data);
|
|
|
- setAppliedFilters(nextFilters);
|
|
|
- setError(null);
|
|
|
- } else {
|
|
|
- setError(!result.ok && "error" in result ? result.error : t("loadFailed"));
|
|
|
- }
|
|
|
- });
|
|
|
- },
|
|
|
- [t]
|
|
|
- );
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- // initial load if not provided
|
|
|
- if (data) return;
|
|
|
- if (!initialData && !loading) {
|
|
|
- loadLogs({ page: 1 });
|
|
|
- }
|
|
|
- }, [data, initialData, loading, loadLogs]);
|
|
|
-
|
|
|
- // Auto-refresh polling (only when on page 1 to avoid disrupting history browsing)
|
|
|
- const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- if (!autoRefreshSeconds || autoRefreshSeconds <= 0) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const pollIntervalMs = autoRefreshSeconds * 1000;
|
|
|
-
|
|
|
- const startPolling = () => {
|
|
|
- if (intervalRef.current) {
|
|
|
- clearInterval(intervalRef.current);
|
|
|
- }
|
|
|
-
|
|
|
- intervalRef.current = setInterval(() => {
|
|
|
- // Only auto-refresh when on page 1
|
|
|
- if ((appliedFilters.page ?? 1) === 1) {
|
|
|
- loadLogs(appliedFilters);
|
|
|
- }
|
|
|
- }, pollIntervalMs);
|
|
|
- };
|
|
|
-
|
|
|
- const stopPolling = () => {
|
|
|
- if (intervalRef.current) {
|
|
|
- clearInterval(intervalRef.current);
|
|
|
- intervalRef.current = null;
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- const handleVisibilityChange = () => {
|
|
|
- if (document.hidden) {
|
|
|
- stopPolling();
|
|
|
- } else {
|
|
|
- // Refresh immediately when tab becomes visible (only if on page 1)
|
|
|
- if ((appliedFilters.page ?? 1) === 1) {
|
|
|
- loadLogs(appliedFilters);
|
|
|
- }
|
|
|
- startPolling();
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- startPolling();
|
|
|
- document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
-
|
|
|
- return () => {
|
|
|
- stopPolling();
|
|
|
- document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
|
- };
|
|
|
- }, [autoRefreshSeconds, appliedFilters, loadLogs]);
|
|
|
-
|
|
|
const handleFilterChange = (changes: Partial<Filters>) => {
|
|
|
- setDraftFilters((prev) => ({ ...prev, ...changes, page: 1 }));
|
|
|
+ setDraftFilters((prev) => ({ ...prev, ...changes }));
|
|
|
};
|
|
|
|
|
|
const handleApply = () => {
|
|
|
- loadLogs({ ...draftFilters, page: 1 });
|
|
|
+ const nextFilters = { ...draftFilters };
|
|
|
+ if (JSON.stringify(nextFilters) === JSON.stringify(appliedFilters)) {
|
|
|
+ void refetch();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setAppliedFilters(nextFilters);
|
|
|
};
|
|
|
|
|
|
const handleReset = () => {
|
|
|
- setDraftFilters({ page: 1 });
|
|
|
- loadLogs({ page: 1 });
|
|
|
+ setDraftFilters({});
|
|
|
+ if (Object.keys(appliedFilters).length === 0) {
|
|
|
+ void refetch();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setAppliedFilters({});
|
|
|
};
|
|
|
|
|
|
const handleDateRangeChange = (range: { startDate?: string; endDate?: string }) => {
|
|
|
handleFilterChange(range);
|
|
|
};
|
|
|
|
|
|
- const handlePageChange = (page: number) => {
|
|
|
- loadLogs({ ...appliedFilters, page });
|
|
|
- };
|
|
|
+ const handleLoadMore = useCallback(() => {
|
|
|
+ void fetchNextPage();
|
|
|
+ }, [fetchNextPage]);
|
|
|
|
|
|
- const isInitialLoading = loading || (!data && isPending);
|
|
|
- const isRefreshing = isPending && Boolean(data);
|
|
|
+ const isRefreshing = isRefetching && !isFetchingNextPage && logs.length > 0;
|
|
|
+ const errorMessage = isError ? (error instanceof Error ? error.message : t("loadFailed")) : null;
|
|
|
|
|
|
return (
|
|
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
|
@@ -250,7 +205,6 @@ export function UsageLogsSection({
|
|
|
isOpen && "border-b"
|
|
|
)}
|
|
|
>
|
|
|
- {/* Icon + Title */}
|
|
|
<div className="flex items-center gap-3">
|
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
|
|
<ScrollText className="h-4 w-4" />
|
|
|
@@ -258,11 +212,8 @@ export function UsageLogsSection({
|
|
|
<span className="text-sm font-semibold">{tCollapsible("title")}</span>
|
|
|
</div>
|
|
|
|
|
|
- {/* Header Summary */}
|
|
|
<div className="flex items-center gap-3">
|
|
|
- {/* Desktop Summary */}
|
|
|
<div className="hidden sm:flex items-center gap-2 text-sm">
|
|
|
- {/* Last Status */}
|
|
|
{lastLog ? (
|
|
|
<span className={cn("font-mono", lastStatusColor)}>
|
|
|
{tCollapsible("lastStatus", {
|
|
|
@@ -276,7 +227,6 @@ export function UsageLogsSection({
|
|
|
|
|
|
<span className="text-muted-foreground">|</span>
|
|
|
|
|
|
- {/* Success Rate */}
|
|
|
{successRate !== null ? (
|
|
|
<span
|
|
|
className={cn(
|
|
|
@@ -291,7 +241,6 @@ export function UsageLogsSection({
|
|
|
</span>
|
|
|
) : null}
|
|
|
|
|
|
- {/* Active Filters Badge */}
|
|
|
{activeFiltersCount > 0 && (
|
|
|
<>
|
|
|
<span className="text-muted-foreground">|</span>
|
|
|
@@ -302,7 +251,6 @@ export function UsageLogsSection({
|
|
|
</>
|
|
|
)}
|
|
|
|
|
|
- {/* Auto-refresh */}
|
|
|
{autoRefreshSeconds && (
|
|
|
<>
|
|
|
<span className="text-muted-foreground">|</span>
|
|
|
@@ -312,9 +260,7 @@ export function UsageLogsSection({
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
- {/* Mobile Summary */}
|
|
|
<div className="flex items-center gap-1.5 text-xs sm:hidden">
|
|
|
- {/* Last Status - compact */}
|
|
|
{lastLog ? (
|
|
|
<span className={cn("font-mono", lastStatusColor)}>
|
|
|
{lastLog.statusCode ?? "-"} ({lastStatusText ?? "-"})
|
|
|
@@ -325,7 +271,6 @@ export function UsageLogsSection({
|
|
|
|
|
|
<span className="text-muted-foreground">|</span>
|
|
|
|
|
|
- {/* Success Rate - compact */}
|
|
|
{successRate !== null ? (
|
|
|
<span
|
|
|
className={cn(
|
|
|
@@ -338,7 +283,6 @@ export function UsageLogsSection({
|
|
|
</span>
|
|
|
) : null}
|
|
|
|
|
|
- {/* Filters + Refresh */}
|
|
|
{activeFiltersCount > 0 && (
|
|
|
<>
|
|
|
<span className="text-muted-foreground">|</span>
|
|
|
@@ -355,7 +299,6 @@ export function UsageLogsSection({
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
- {/* Chevron */}
|
|
|
<ChevronDown
|
|
|
className={cn(
|
|
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
|
@@ -385,9 +328,7 @@ export function UsageLogsSection({
|
|
|
<Select
|
|
|
value={draftFilters.model ?? "__all__"}
|
|
|
onValueChange={(value) =>
|
|
|
- handleFilterChange({
|
|
|
- model: value === "__all__" ? undefined : value,
|
|
|
- })
|
|
|
+ handleFilterChange({ model: value === "__all__" ? undefined : value })
|
|
|
}
|
|
|
disabled={isModelsLoading}
|
|
|
>
|
|
|
@@ -411,9 +352,7 @@ export function UsageLogsSection({
|
|
|
<Select
|
|
|
value={draftFilters.endpoint ?? "__all__"}
|
|
|
onValueChange={(value) =>
|
|
|
- handleFilterChange({
|
|
|
- endpoint: value === "__all__" ? undefined : value,
|
|
|
- })
|
|
|
+ handleFilterChange({ endpoint: value === "__all__" ? undefined : value })
|
|
|
}
|
|
|
disabled={isEndpointsLoading}
|
|
|
>
|
|
|
@@ -449,7 +388,9 @@ export function UsageLogsSection({
|
|
|
onValueChange={(value) =>
|
|
|
handleFilterChange({
|
|
|
statusCode:
|
|
|
- value === "__all__" || value === "!200" ? undefined : parseInt(value, 10),
|
|
|
+ value === "__all__" || value === "!200"
|
|
|
+ ? undefined
|
|
|
+ : Number.parseInt(value, 10),
|
|
|
excludeStatusCode200: value === "!200",
|
|
|
})
|
|
|
}
|
|
|
@@ -478,7 +419,9 @@ export function UsageLogsSection({
|
|
|
placeholder={tDashboard("logs.filters.minRetryCountPlaceholder")}
|
|
|
onChange={(e) =>
|
|
|
handleFilterChange({
|
|
|
- minRetryCount: e.target.value ? parseInt(e.target.value, 10) : undefined,
|
|
|
+ minRetryCount: e.target.value
|
|
|
+ ? Number.parseInt(e.target.value, 10)
|
|
|
+ : undefined,
|
|
|
})
|
|
|
}
|
|
|
/>
|
|
|
@@ -486,21 +429,14 @@ export function UsageLogsSection({
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
- <Button size="sm" onClick={handleApply} disabled={isPending || loading}>
|
|
|
+ <Button size="sm" onClick={handleApply} disabled={isLoading}>
|
|
|
{t("filters.apply")}
|
|
|
</Button>
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- variant="outline"
|
|
|
- onClick={handleReset}
|
|
|
- disabled={isPending || loading}
|
|
|
- >
|
|
|
+ <Button size="sm" variant="outline" onClick={handleReset} disabled={isLoading}>
|
|
|
{t("filters.reset")}
|
|
|
</Button>
|
|
|
</div>
|
|
|
|
|
|
- {error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
|
-
|
|
|
{isRefreshing ? (
|
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
|
@@ -509,14 +445,16 @@ export function UsageLogsSection({
|
|
|
) : null}
|
|
|
|
|
|
<UsageLogsTable
|
|
|
- logs={data?.logs ?? []}
|
|
|
- total={data?.total ?? 0}
|
|
|
- page={appliedFilters.page ?? 1}
|
|
|
- pageSize={data?.pageSize ?? 20}
|
|
|
- onPageChange={handlePageChange}
|
|
|
- currencyCode={data?.currencyCode}
|
|
|
- loading={isInitialLoading}
|
|
|
+ logs={logs}
|
|
|
+ hasNextPage={hasNextPage}
|
|
|
+ isFetchingNextPage={isFetchingNextPage}
|
|
|
+ currencyCode={latestPage?.currencyCode}
|
|
|
+ loading={isLoading}
|
|
|
loadingLabel={tCommon("loading")}
|
|
|
+ errorMessage={errorMessage}
|
|
|
+ onLoadMore={handleLoadMore}
|
|
|
+ resetScrollKey={appliedFilters}
|
|
|
+ onHistoryBrowsingChange={setIsBrowsingHistory}
|
|
|
/>
|
|
|
</div>
|
|
|
</CollapsibleContent>
|