mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
* feat(dashboard): request count log per provider, per date (#4009) Some providers bill by request rather than by token, so operators need a plain per-provider, per-date request count breakdown, not just token aggregates. Adds a new getProviderDailyUsageRows() aggregation query (src/lib/db/usageAnalytics.ts), a dedicated GET /api/usage/requests-by-provider-date route (kept separate from the frozen /api/usage/analytics route to respect the file-size baseline), and a sortable, single-date-filterable table on Dashboard -> Analytics. Closes #4009 * chore(6812): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first)
This commit is contained in:
committed by
GitHub
parent
e8fce67e70
commit
28fcd418a4
@@ -23,6 +23,7 @@ import {
|
||||
ServiceTierBreakdown,
|
||||
ApiKeyFilterDropdown,
|
||||
CustomRangePicker,
|
||||
RequestCountByProviderDateTable,
|
||||
} from "./analytics";
|
||||
|
||||
// ============================================================================
|
||||
@@ -404,6 +405,9 @@ export default function UsageAnalytics() {
|
||||
{/* Provider Breakdown Table */}
|
||||
<ProviderTable byProvider={analytics?.byProvider} />
|
||||
|
||||
{/* Request Count by Provider & Date — #4009 (some providers bill per-request) */}
|
||||
<RequestCountByProviderDateTable range={range} />
|
||||
|
||||
{/* API Key Table */}
|
||||
<ApiKeyTable byApiKey={analytics?.byApiKey} />
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* RequestCountByProviderDateTable — #4009
|
||||
*
|
||||
* Some providers bill by request rather than by token, so operators need a
|
||||
* plain per-provider, per-date request count (not just token aggregates).
|
||||
* Self-contained: fetches its own data (via useProviderDailyUsage) from a
|
||||
* dedicated endpoint so the main analytics route (frozen at the file-size
|
||||
* baseline) stays untouched. Header + table markup are split into
|
||||
* RequestCountDateFilter / RequestCountTable to keep this container under
|
||||
* the max-lines-per-function complexity gate.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "../Card";
|
||||
import RequestCountDateFilter from "./RequestCountDateFilter";
|
||||
import RequestCountTable, { type RequestCountSortField } from "./RequestCountTable";
|
||||
import { useProviderDailyUsage } from "./useProviderDailyUsage";
|
||||
import { sortProviderDailyUsageRows } from "./requestCountSort";
|
||||
|
||||
export default function RequestCountByProviderDateTable({ range }: { range: string }) {
|
||||
const t = useTranslations("analytics");
|
||||
const tCommon = useTranslations("common");
|
||||
const [dateFilter, setDateFilter] = useState("");
|
||||
const [sortBy, setSortBy] = useState<RequestCountSortField>("date");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const { rows, loading, error } = useProviderDailyUsage(range, dateFilter);
|
||||
|
||||
const toggleSort = useCallback(
|
||||
(field: RequestCountSortField) => {
|
||||
if (sortBy === field) {
|
||||
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortBy(field);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
},
|
||||
[sortBy]
|
||||
);
|
||||
|
||||
const sorted = useMemo(
|
||||
() => sortProviderDailyUsageRows(rows, sortBy, sortOrder),
|
||||
[rows, sortBy, sortOrder]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<RequestCountDateFilter
|
||||
title={t("chartRequestsByProviderDate")}
|
||||
dateLabel={t("chartDate")}
|
||||
value={dateFilter}
|
||||
onChange={setDateFilter}
|
||||
/>
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<div className="text-center text-text-muted text-sm py-8">{tCommon("loading")}</div>
|
||||
) : error ? (
|
||||
<div className="text-center text-red-500 text-sm py-8">
|
||||
{tCommon("errorShort")}: {error}
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
) : (
|
||||
<RequestCountTable
|
||||
rows={sorted}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onToggleSort={toggleSort}
|
||||
dateLabel={t("chartDate")}
|
||||
providerLabel={t("chartProvider")}
|
||||
requestsLabel={t("chartRequests")}
|
||||
totalLabel={t("chartTotal")}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
41
src/shared/components/analytics/RequestCountDateFilter.tsx
Normal file
41
src/shared/components/analytics/RequestCountDateFilter.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* RequestCountDateFilter — title + single-date filter row for #4009's
|
||||
* request-count-by-provider-date table. Split out to keep the container
|
||||
* component under the max-lines-per-function complexity gate.
|
||||
*/
|
||||
|
||||
interface RequestCountDateFilterProps {
|
||||
title: string;
|
||||
dateLabel: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function RequestCountDateFilter({
|
||||
title,
|
||||
dateLabel,
|
||||
value,
|
||||
onChange,
|
||||
}: RequestCountDateFilterProps) {
|
||||
return (
|
||||
<div className="p-4 border-b border-border flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={dateLabel}
|
||||
className="px-2 py-1 rounded-md text-xs bg-black/[0.03] dark:bg-white/[0.03] border border-black/5 dark:border-white/5"
|
||||
/>
|
||||
{value && (
|
||||
<button onClick={() => onChange("")} className="text-xs text-text-muted hover:text-text-main">
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/shared/components/analytics/RequestCountTable.tsx
Normal file
104
src/shared/components/analytics/RequestCountTable.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* RequestCountTable — pure presentational table for #4009.
|
||||
* Split out of RequestCountByProviderDateTable to keep that container under
|
||||
* the max-lines-per-function complexity gate.
|
||||
*/
|
||||
|
||||
import { PROVIDER_COLORS } from "./chartColors";
|
||||
import { fmtCompact as fmt, fmtFull } from "@/shared/utils/formatting";
|
||||
import { SortIndicator } from "./charts";
|
||||
|
||||
export interface ProviderDailyUsageRow {
|
||||
date: string;
|
||||
provider: string;
|
||||
requests: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export type RequestCountSortField = "date" | "provider" | "requests" | "totalTokens";
|
||||
|
||||
interface RequestCountTableProps {
|
||||
rows: ProviderDailyUsageRow[];
|
||||
sortBy: RequestCountSortField;
|
||||
sortOrder: "asc" | "desc";
|
||||
onToggleSort: (field: RequestCountSortField) => void;
|
||||
dateLabel: string;
|
||||
providerLabel: string;
|
||||
requestsLabel: string;
|
||||
totalLabel: string;
|
||||
}
|
||||
|
||||
export default function RequestCountTable({
|
||||
rows,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onToggleSort,
|
||||
dateLabel,
|
||||
providerLabel,
|
||||
requestsLabel,
|
||||
totalLabel,
|
||||
}: RequestCountTableProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]">
|
||||
<tr>
|
||||
<th
|
||||
className="px-4 py-2.5 text-left cursor-pointer group"
|
||||
onClick={() => onToggleSort("date")}
|
||||
>
|
||||
{dateLabel} <SortIndicator active={sortBy === "date"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-left cursor-pointer group"
|
||||
onClick={() => onToggleSort("provider")}
|
||||
>
|
||||
{providerLabel} <SortIndicator active={sortBy === "provider"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => onToggleSort("requests")}
|
||||
>
|
||||
{requestsLabel} <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => onToggleSort("totalTokens")}
|
||||
>
|
||||
{totalLabel} <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={`${row.date}::${row.provider}`}
|
||||
className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2.5 font-mono text-text-muted">{row.date}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: PROVIDER_COLORS[i % PROVIDER_COLORS.length] }}
|
||||
/>
|
||||
<span className="font-medium capitalize">{row.provider}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono font-semibold">
|
||||
{fmtFull(row.requests)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
|
||||
{fmt(row.totalTokens)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,3 +26,4 @@ export { DailyTrendChart, ModelOverTimeChart } from "./rechartsUsageCharts";
|
||||
|
||||
export { default as ApiKeyFilterDropdown } from "./ApiKeyFilterDropdown";
|
||||
export { default as CustomRangePicker } from "./CustomRangePicker";
|
||||
export { default as RequestCountByProviderDateTable } from "./RequestCountByProviderDateTable";
|
||||
|
||||
24
src/shared/components/analytics/requestCountSort.ts
Normal file
24
src/shared/components/analytics/requestCountSort.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* requestCountSort — pure sort helper for #4009's request-count-by-provider-date table.
|
||||
* Split out of RequestCountByProviderDateTable to keep that container under the
|
||||
* max-lines-per-function complexity gate.
|
||||
*/
|
||||
|
||||
import type { ProviderDailyUsageRow, RequestCountSortField } from "./RequestCountTable";
|
||||
|
||||
export function sortProviderDailyUsageRows(
|
||||
rows: ProviderDailyUsageRow[],
|
||||
sortBy: RequestCountSortField,
|
||||
sortOrder: "asc" | "desc"
|
||||
): ProviderDailyUsageRow[] {
|
||||
const arr = [...rows];
|
||||
arr.sort((a, b) => {
|
||||
const va = a[sortBy];
|
||||
const vb = b[sortBy];
|
||||
if (typeof va === "string" && typeof vb === "string") {
|
||||
return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
}
|
||||
return sortOrder === "asc" ? Number(va) - Number(vb) : Number(vb) - Number(va);
|
||||
});
|
||||
return arr;
|
||||
}
|
||||
46
src/shared/components/analytics/useProviderDailyUsage.ts
Normal file
46
src/shared/components/analytics/useProviderDailyUsage.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useProviderDailyUsage — data hook for #4009's request-count-by-provider-date table.
|
||||
* Split out of RequestCountByProviderDateTable to keep that container under the
|
||||
* max-lines-per-function complexity gate.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { readFetchErrorMessage } from "@/shared/utils/fetchError";
|
||||
import type { ProviderDailyUsageRow } from "./RequestCountTable";
|
||||
|
||||
export function useProviderDailyUsage(range: string, dateFilter: string) {
|
||||
const tCommon = useTranslations("common");
|
||||
const [rows, setRows] = useState<ProviderDailyUsageRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRows = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (dateFilter) {
|
||||
params.set("date", dateFilter);
|
||||
} else {
|
||||
params.set("range", range);
|
||||
}
|
||||
const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error")));
|
||||
const data = await res.json();
|
||||
setRows(Array.isArray(data.rows) ? data.rows : []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [range, dateFilter, tCommon]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRows();
|
||||
}, [fetchRows]);
|
||||
|
||||
return { rows, loading, error };
|
||||
}
|
||||
Reference in New Issue
Block a user