Skip to content

feat(admin): add history graphs, cost charts, logs - #1800

Merged
smakosh merged 6 commits into
mainfrom
feat/admin-dashboard-enhancements
Mar 7, 2026
Merged

smakosh merged 6 commits into
mainfrom
feat/admin-dashboard-enhancements

Conversation

@smakosh

@smakosh smakosh commented Mar 7, 2026

Copy link
Copy Markdown
Member

Summary

  • Add expandable history charts (uptime, latency, requests, tokens) for providers and models with configurable time windows (1m–24h)
  • Add cost-by-model bar charts on the main dashboard (global) and org detail pages, showing top 20 models by cost/requests/tokens
  • Replace simple log rows with rich expandable log cards (routing info, cost breakdown, error details) matching the UI app pattern
  • Add "Total Processed" (Stripe gross revenue) metric to the main dashboard

Test plan

  • Verify provider/model rows expand to show history charts with correct data
  • Verify time window selector updates history charts
  • Verify cost-by-model charts render on dashboard and org pages
  • Verify log cards expand with full details (routing, cost breakdown, errors)
  • Verify "Total Processed" metric card appears on dashboard
  • Run pnpm build for api and admin apps

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Cost-by-model analytics (global & per-organization) and history endpoints powering charts
    • New expandable Models and Providers pages with per-row history views
    • Rich Log view with detailed metadata and expandable details
    • New Total Processed revenue metric on the dashboard
    • Organization-level cost-by-model and project logs sections
  • UI/UX Improvements

    • Streamlined, interactive tables with sortable headers and per-row expansion
    • Interactive history and cost charts with selectable time windows and metric views

- Add provider/model/mapping history API endpoints with
  1m-24h time windows querying from history tables
- Add global and org-level cost-by-model API endpoints
- Add totalProcessed metric (Stripe gross revenue)
- Expand project logs API to return full request details
- Add expandable history charts on provider/model pages
- Add cost-by-model bar charts on dashboard and org page
- Replace simple log rows with rich expandable log cards
  with routing info, cost breakdown, and copyable IDs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@smakosh smakosh self-assigned this Mar 7, 2026
@coderabbitai

coderabbitai Bot commented Mar 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (3)
  • apps/code/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • apps/playground/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • apps/ui/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a1f3d3ce-7c6e-42d8-9729-6dd3ac49d455

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds enterprise admin UI under ee/admin, new history and cost-by-model server routes and schemas, client components (charts, tables, log cards), centralized admin types and a server API client, and updates infra/build targets to reference the EE admin bundle.

Changes

Cohort / File(s) Summary
Admin API & Schemas
apps/api/src/routes/admin.ts
Added history and cost-by-model endpoints, new response schemas (historyWindow, historyDataPoint, historyResponse, costByModelEntry/response), and totalProcessed metric computation.
EE Admin Pages
ee/admin/src/app/models/page.tsx, ee/admin/src/app/providers/page.tsx, ee/admin/src/app/page.tsx, ee/admin/src/app/organizations/.../page.tsx, ee/admin/src/app/organizations/[orgId]/projects/[projectId]/page.tsx, ee/admin/src/app/organizations/[orgId]/discounts/page.tsx, ee/admin/src/app/organizations/[orgId]/org-cost-by-model.tsx
New/updated server pages: models/providers pages (search/sort/pagination), dashboard metric card additions, OrgCostByModel wiring, and migration to createServerApiClient-based API calls.
Client Components (Charts / Tables / UI)
ee/admin/src/components/history-chart.tsx, ee/admin/src/components/cost-by-model-chart.tsx, ee/admin/src/components/dashboard-cost-by-model.tsx, ee/admin/src/components/models-table.tsx, ee/admin/src/components/providers-table.tsx, ee/admin/src/components/log-card.tsx, ee/admin/src/components/cost-by-model-chart.tsx
New interactive components: HistoryChart, CostByModelChart, DashboardCostByModel, ModelsTable, ProvidersTable, and LogCard with per-row expansion and chart integration.
Server-side Admin Utilities
ee/admin/src/lib/admin-history.ts, ee/admin/src/lib/admin-organizations.ts, ee/admin/src/lib/admin-discounts.ts, ee/admin/src/lib/server-api.ts
New server helpers and actions: history/cost fetchers, org/project metrics/logs actions, discount management, and createServerApiClient (session cookie extraction + openapi client).
Types Consolidation
ee/admin/src/lib/types.ts
Centralized TypeScript admin types mapping API paths to response shapes (GetJsonResponse helper and many exported aliases).
Migration of apps/admin UI
apps/admin/src/app/models/page.tsx, apps/admin/src/app/providers/page.tsx, apps/admin/src/app/organizations/.../project-logs.tsx
Replaced inline table/log renderers with ModelsTable/ProvidersTable/LogCard usage and removed custom table scaffolding in apps/admin pages.
Organization Project Logs (client)
ee/admin/src/app/organizations/[orgId]/projects/[projectId]/project-logs.tsx
Added ProjectLogsSection client component with paginated loading and LogCard rendering.
Infra & Packaging
ee/admin/package.json, ee/admin/src/app/*, infra/split.dockerfile, infra/supervisord.conf, AGENTS.md
Switched build/runtime paths from apps/admin to ee/admin, adjusted package scripts and license path, updated runtime port and supervisord entry.
DB Seed Adjustment
packages/db/src/seed.ts
Expanded seed history: deduplicated mapping history per provider up to 50 mappings and increased seeded models from 15 to 50.
Import / Type Path Updates
multiple ee/admin/src/... files
Updated many local imports to use centralized @/lib/types and replaced ad-hoc admin helpers with createServerApiClient calls.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Browser as Browser
participant EEAdmin as ee/admin (UI)
participant AdminAPI as apps/api (Admin routes)
participant DB as Database
Browser->>EEAdmin: Navigate /models or request chart (window)
EEAdmin->>AdminAPI: GET /admin/models or /models/{id}/history (via createServerApiClient)
AdminAPI->>DB: Query history/cost tables (per-minute aggregates)
DB-->>AdminAPI: Aggregated timeseries or cost-by-model data
AdminAPI-->>EEAdmin: JSON response (history/cost schemas)
EEAdmin-->>Browser: Render ModelsTable / HistoryChart with returned data

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • steebchen
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(admin): add history graphs, cost charts, logs' accurately summarizes the main changes: adding history visualization components, cost-by-model charts, and log card enhancements to the admin dashboard.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/admin-dashboard-enhancements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
apps/admin/src/components/providers-table.tsx (1)

55-57: Consider adding keyboard accessibility for row expansion.

The row is clickable via onClick but may not be keyboard-accessible. Consider adding tabIndex={0} and onKeyDown handler for Enter/Space keys to improve accessibility.

🔧 Optional accessibility enhancement
 			<TableRow
 				className="cursor-pointer hover:bg-muted/50"
 				onClick={() => setExpanded(!expanded)}
+				tabIndex={0}
+				onKeyDown={(e) => {
+					if (e.key === "Enter" || e.key === " ") {
+						e.preventDefault();
+						setExpanded(!expanded);
+					}
+				}}
 			>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/components/providers-table.tsx` around lines 55 - 57, The
TableRow currently toggles expansion only via onClick; make it
keyboard-accessible by adding tabIndex={0} and an onKeyDown handler that listens
for Enter (key === "Enter") and Space (key === " ") to call
setExpanded(!expanded) (or the same toggle function used by onClick); also add
appropriate ARIA attributes like role="button" and aria-expanded={expanded} on
the TableRow to expose state to assistive tech.
apps/admin/src/components/cost-by-model-chart.tsx (1)

89-91: Consider replacing console.error with a structured logging approach.

ESLint flags this console.error statement. While error logging here is reasonable for debugging, consider using a dedicated logger or removing it in production builds if the admin app has a logging utility.

🔧 Optional: suppress or use logger
 			} catch (error) {
-				console.error("Failed to load cost by model:", error);
+				// eslint-disable-next-line no-console
+				console.error("Failed to load cost by model:", error);
 				setData(null);

Or, if a logger is available:

-				console.error("Failed to load cost by model:", error);
+				logger.error("Failed to load cost by model:", error);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/components/cost-by-model-chart.tsx` around lines 89 - 91, The
catch block in the cost-by-model-chart component currently uses console.error in
the error handler (inside the async data load in CostByModelChart / the
fetch/useEffect block); replace that with your app's logging utility or remove
it for production: import and call the shared logger (e.g., logger.error("Failed
to load cost by model", error)) or guard with an environment check before
logging, and keep the existing setData(null) behavior so the UI still clears on
failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/admin/src/components/history-chart.tsx`:
- Around line 105-116: The loadData callback currently treats a null result from
fetchData (which indicates API/auth failures) the same as an empty result, and
logs errors with console.error; update loadData (and the analogous block at the
other occurrence) to: detect when fetchData returns null and treat that as a
fetch failure (do not convert to []), set a dedicated error state (e.g.,
setError) with a descriptive message, avoid using console.error (use an existing
logger or set the error state instead), and only setData(result) when result is
a real array; ensure setLoading is still cleared in finally. Also check/follow
the same pattern where fetchServerData is used so failures surface as errors
rather than "No data".
- Around line 128-139: The current summaryStats.avgTtft re-averages per-bucket
avgTtft equally; change it to a weighted average using each bucket's non-cached
request volume (logsCount - cachedCount) as the weight: iterate data filtering
buckets with avgTtft !== null and weight > 0, accumulate sum += (d.avgTtft *
weight) and totalWeight += weight, then set avgTtft = totalWeight > 0 ?
Math.round(sum / totalWeight) : null; update the calculation referenced as
summaryStats and the avgTtft computation to use these names (data, logsCount,
cachedCount, avgTtft) and guard against negative weights or division by zero.

In `@apps/admin/src/components/log-card.tsx`:
- Around line 126-141: The expander and icon-only copy buttons in the LogCard
component lack accessible names and state; update the Button that toggles
isExpanded (the one rendering ChevronUp/ChevronDown) to include
aria-expanded={isExpanded} and a descriptive aria-label (e.g., "Toggle log
details"), and add meaningful aria-labels to the icon-only copy buttons (e.g.,
"Copy request body" / "Copy response body" or include dynamic context). Locate
the Button instances that render ChevronUp/ChevronDown and the copy icon Buttons
in log-card.tsx and add the appropriate aria attributes so screen readers get
both the control purpose and the expanded state.

In `@apps/admin/src/components/models-table.tsx`:
- Around line 129-143: The headers in models-table.tsx were made static but the
parent page still maintains sortBy/sortOrder, so restore clickable sortable
headers: update TableHead cells (e.g., the Model, Requests, Errors, Error Rate,
Last Updated columns) to call a passed-in onSort prop (or dispatch an event)
that toggles sortOrder and sets sortBy to the corresponding key; use the
existing sortBy/sortOrder props from apps/admin/src/app/models/page.tsx to
render an active sort indicator in the relevant TableHead and ensure the handler
signature matches the page’s state updater so admins can pivot by errors,
requests, updatedAt, etc.

---

Nitpick comments:
In `@apps/admin/src/components/cost-by-model-chart.tsx`:
- Around line 89-91: The catch block in the cost-by-model-chart component
currently uses console.error in the error handler (inside the async data load in
CostByModelChart / the fetch/useEffect block); replace that with your app's
logging utility or remove it for production: import and call the shared logger
(e.g., logger.error("Failed to load cost by model", error)) or guard with an
environment check before logging, and keep the existing setData(null) behavior
so the UI still clears on failure.

In `@apps/admin/src/components/providers-table.tsx`:
- Around line 55-57: The TableRow currently toggles expansion only via onClick;
make it keyboard-accessible by adding tabIndex={0} and an onKeyDown handler that
listens for Enter (key === "Enter") and Space (key === " ") to call
setExpanded(!expanded) (or the same toggle function used by onClick); also add
appropriate ARIA attributes like role="button" and aria-expanded={expanded} on
the TableRow to expose state to assistive tech.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 7bb5d4dd-db5e-4479-8c44-56fe22b6bf5e

📥 Commits

Reviewing files that changed from the base of the PR and between 80ca210 and c3d2fae.

⛔ Files ignored due to path filters (4)
  • apps/admin/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • apps/code/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • apps/playground/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • apps/ui/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
📒 Files selected for processing (16)
  • apps/admin/src/app/models/page.tsx
  • apps/admin/src/app/organizations/[orgId]/org-cost-by-model.tsx
  • apps/admin/src/app/organizations/[orgId]/page.tsx
  • apps/admin/src/app/organizations/[orgId]/projects/[projectId]/project-logs.tsx
  • apps/admin/src/app/page.tsx
  • apps/admin/src/app/providers/page.tsx
  • apps/admin/src/components/cost-by-model-chart.tsx
  • apps/admin/src/components/dashboard-cost-by-model.tsx
  • apps/admin/src/components/history-chart.tsx
  • apps/admin/src/components/log-card.tsx
  • apps/admin/src/components/models-table.tsx
  • apps/admin/src/components/providers-table.tsx
  • apps/admin/src/lib/admin-history.ts
  • apps/admin/src/lib/admin-metrics.ts
  • apps/admin/src/lib/admin-organizations.ts
  • apps/api/src/routes/admin.ts

Comment on lines +105 to +116
const loadData = useCallback(
async (w: HistoryWindow) => {
setLoading(true);
try {
const result = await fetchData(w);
setData(result ?? []);
} catch (error) {
console.error("Failed to load history:", error);
setData([]);
} finally {
setLoading(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t render fetch failures as “No data”.

fetchServerData() resolves null on API/auth failures, and this path converts that into [], so admins get the same “No data for this time window” message for both a real empty window and a failed request. That hides outages/auth problems as zero traffic, and Line 112 is also already tripping the current no-console rule.

🛠️ Suggested fix
 	const [data, setData] = useState<HistoryDataPoint[]>([]);
 	const [loading, setLoading] = useState(true);
+	const [loadError, setLoadError] = useState(false);
 	const [window, setWindow] = useState<HistoryWindow>("4h");
 	const [activeMetric, setActiveMetric] = useState<ActiveMetric>("requests");
 
 	const loadData = useCallback(
 		async (w: HistoryWindow) => {
 			setLoading(true);
+			setLoadError(false);
 			try {
 				const result = await fetchData(w);
-				setData(result ?? []);
-			} catch (error) {
-				console.error("Failed to load history:", error);
+				if (result === null) {
+					setLoadError(true);
+					setData([]);
+					return;
+				}
+				setData(result);
+			} catch {
+				setLoadError(true);
 				setData([]);
 			} finally {
 				setLoading(false);
 			}
@@
 				{loading ? (
 					<div className="flex h-[200px] items-center justify-center text-sm text-muted-foreground">
 						Loading...
 					</div>
+				) : loadError ? (
+					<div className="flex h-[200px] items-center justify-center text-sm text-muted-foreground">
+						Failed to load history for this time window
+					</div>
 				) : data.length === 0 ? (
 					<div className="flex h-[200px] items-center justify-center text-sm text-muted-foreground">
 						No data for this time window

Also applies to: 213-220

🧰 Tools
🪛 ESLint

[error] 112-112: Unexpected console statement.

(no-console)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/components/history-chart.tsx` around lines 105 - 116, The
loadData callback currently treats a null result from fetchData (which indicates
API/auth failures) the same as an empty result, and logs errors with
console.error; update loadData (and the analogous block at the other occurrence)
to: detect when fetchData returns null and treat that as a fetch failure (do not
convert to []), set a dedicated error state (e.g., setError) with a descriptive
message, avoid using console.error (use an existing logger or set the error
state instead), and only setData(result) when result is a real array; ensure
setLoading is still cleared in finally. Also check/follow the same pattern where
fetchServerData is used so failures surface as errors rather than "No data".

Comment on lines +128 to +139
const summaryStats = {
totalRequests: data.reduce((sum, d) => sum + d.logsCount, 0),
totalErrors: data.reduce((sum, d) => sum + d.errorsCount, 0),
avgTtft:
data.filter((d) => d.avgTtft !== null).length > 0
? Math.round(
data
.filter((d) => d.avgTtft !== null)
.reduce((sum, d) => sum + (d.avgTtft ?? 0), 0) /
data.filter((d) => d.avgTtft !== null).length,
)
: null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Weight the TTFT summary by non-cached request volume.

avgTtft is already a per-bucket average from the history API. Re-averaging those bucket means equally here makes a 1-request minute count the same as a 1,000-request minute, so the header can be badly skewed. Use each bucket’s logsCount - cachedCount as the weight.

📊 Suggested fix
 	const summaryStats = {
 		totalRequests: data.reduce((sum, d) => sum + d.logsCount, 0),
 		totalErrors: data.reduce((sum, d) => sum + d.errorsCount, 0),
-		avgTtft:
-			data.filter((d) => d.avgTtft !== null).length > 0
-				? Math.round(
-						data
-							.filter((d) => d.avgTtft !== null)
-							.reduce((sum, d) => sum + (d.avgTtft ?? 0), 0) /
-							data.filter((d) => d.avgTtft !== null).length,
-					)
-				: null,
+		avgTtft: (() => {
+			const ttftBuckets = data.filter((d) => d.avgTtft !== null);
+			const nonCachedRequests = ttftBuckets.reduce(
+				(sum, d) => sum + Math.max(d.logsCount - d.cachedCount, 0),
+				0,
+			);
+			if (nonCachedRequests === 0) {
+				return null;
+			}
+			const weightedTtft = ttftBuckets.reduce(
+				(sum, d) =>
+					sum + (d.avgTtft ?? 0) * Math.max(d.logsCount - d.cachedCount, 0),
+				0,
+			);
+			return Math.round(weightedTtft / nonCachedRequests);
+		})(),
 		errorRate:
 			data.reduce((sum, d) => sum + d.logsCount, 0) > 0
 				? (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/components/history-chart.tsx` around lines 128 - 139, The
current summaryStats.avgTtft re-averages per-bucket avgTtft equally; change it
to a weighted average using each bucket's non-cached request volume (logsCount -
cachedCount) as the weight: iterate data filtering buckets with avgTtft !== null
and weight > 0, accumulate sum += (d.avgTtft * weight) and totalWeight +=
weight, then set avgTtft = totalWeight > 0 ? Math.round(sum / totalWeight) :
null; update the calculation referenced as summaryStats and the avgTtft
computation to use these names (data, logsCount, cachedCount, avgTtft) and guard
against negative weights or division by zero.

Comment on lines +126 to +141
<Button
variant="ghost"
size="sm"
className="h-8 w-8 shrink-0 p-0"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>

{isExpanded && (
<div className="space-y-4 p-4">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Give the icon-only controls accessible names and state.

The expander and copy buttons have no accessible label, and the toggle does not expose aria-expanded. Screen-reader users will only hear a generic “button”, which makes the new log details hard to operate.

♿ Suggested fix
 export function LogCard({ log }: { log: ProjectLogEntry }) {
 	const [isExpanded, setIsExpanded] = useState(false);
+	const detailsId = `log-details-${log.id}`;
 
 	let StatusIcon = CheckCircle2;
 	let color = "text-green-500";
 	let bgColor = "bg-green-100";
@@
 				<Button
 					variant="ghost"
 					size="sm"
+					type="button"
 					className="h-8 w-8 shrink-0 p-0"
+					aria-expanded={isExpanded}
+					aria-controls={detailsId}
+					aria-label={isExpanded ? "Collapse log details" : "Expand log details"}
 					onClick={() => setIsExpanded(!isExpanded)}
 				>
@@
-			{isExpanded && (
-				<div className="space-y-4 p-4">
+			{isExpanded && (
+				<div id={detailsId} className="space-y-4 p-4">
@@
 									{log.requestId && (
 										<button
+											type="button"
+											aria-label="Copy request ID"
 											className="text-muted-foreground hover:text-foreground"
 											onClick={() => copyToClipboard(log.requestId!)}
 										>
@@
 									<button
+										type="button"
+										aria-label="Copy log ID"
 										className="text-muted-foreground hover:text-foreground"
 										onClick={() => copyToClipboard(log.id)}
 									>

Also applies to: 149-166

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/components/log-card.tsx` around lines 126 - 141, The expander
and icon-only copy buttons in the LogCard component lack accessible names and
state; update the Button that toggles isExpanded (the one rendering
ChevronUp/ChevronDown) to include aria-expanded={isExpanded} and a descriptive
aria-label (e.g., "Toggle log details"), and add meaningful aria-labels to the
icon-only copy buttons (e.g., "Copy request body" / "Copy response body" or
include dynamic context). Locate the Button instances that render
ChevronUp/ChevronDown and the copy icon Buttons in log-card.tsx and add the
appropriate aria attributes so screen readers get both the control purpose and
the expanded state.

Comment thread ee/admin/src/components/models-table.tsx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (10)
ee/admin/src/components/history-chart.tsx (1)

82-89: Remove dead code: both branches return the same format.

The formatTimestamp function has identical return values for both branches ("HH:mm"), making the conditional logic pointless. Either differentiate the formats (e.g., include date for 24h window) or simplify to a single return.

♻️ Option 1: Simplify if formats are intentionally the same
 function formatTimestamp(ts: string, window: HistoryWindow): string {
 	const date = new Date(ts);
-	const minuteWindows = new Set(["1m", "2m", "5m", "30m", "1h", "2h"]);
-	if (minuteWindows.has(window)) {
-		return format(date, "HH:mm");
-	}
 	return format(date, "HH:mm");
 }
♻️ Option 2: Differentiate formats for longer windows
 function formatTimestamp(ts: string, window: HistoryWindow): string {
 	const date = new Date(ts);
 	const minuteWindows = new Set(["1m", "2m", "5m", "30m", "1h", "2h"]);
 	if (minuteWindows.has(window)) {
 		return format(date, "HH:mm");
 	}
-	return format(date, "HH:mm");
+	return format(date, "MMM d HH:mm");
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/history-chart.tsx` around lines 82 - 89, The
conditional in formatTimestamp is dead: both branches return format(date,
"HH:mm"); remove the minuteWindows check and return a single format, or if
longer windows should show more context, change the non-minute branch to a
different format (e.g., include date) instead of "HH:mm". Update the function
formatTimestamp (and the minuteWindows Set/HistoryWindow usage) accordingly so
there is no redundant branch.
ee/admin/src/components/providers-table.tsx (1)

23-35: Consider extracting shared formatting utilities.

formatNumber and formatDate appear to be duplicated across components (similar patterns exist in the codebase). Consider extracting these to a shared utility file (e.g., @/lib/format) to reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/providers-table.tsx` around lines 23 - 35,
formatNumber and formatDate are duplicated utilities; extract them into a shared
module (e.g., a new "@/lib/format" file) and replace the local functions in
providers-table.tsx with imports. Move the Intl.NumberFormat and Date formatting
logic into exported functions (formatNumber, formatDate) in the new module,
update providers-table.tsx to import and use those exports, and update any other
components that duplicate the same logic to import from the shared module
instead.
ee/admin/src/app/providers/page.tsx (1)

9-9: Consider importing SortOrder from the source of truth.

SortOrder is redeclared locally here but is already exported from admin-providers.ts. Importing it ensures consistency if the type definition changes.

-import type { ProviderSortBy } from "@/lib/admin-providers";
-
-type SortOrder = "asc" | "desc";
+import type { ProviderSortBy, SortOrder } from "@/lib/admin-providers";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/providers/page.tsx` at line 9, The local type alias
SortOrder in page.tsx duplicates a type already exported from
admin-providers.ts; remove the local declaration and import SortOrder from
admin-providers.ts instead to keep a single source of truth (update the import
list at the top of ee/admin/src/app/providers/page.tsx to include SortOrder and
delete the local `type SortOrder = "asc" | "desc";` declaration).
ee/admin/src/app/models/page.tsx (2)

11-31: Consider extracting SignInPrompt to a shared component.

SignInPrompt is duplicated across multiple pages (providers, models, and likely others). Extracting it to a shared component under @/components would reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/models/page.tsx` around lines 11 - 31, The SignInPrompt
component is duplicated; extract it into a shared component (e.g., create a new
component named SignInPrompt in the shared components folder) and replace the
inline SignInPrompt definitions with a single import and usage; ensure the new
SignInPrompt exports the same component signature and retains use of Button and
Link (preserve className and props such as size or asChild if needed), update
all pages that defined their own SignInPrompt (e.g., models and providers pages)
to import SignInPrompt and remove the local duplicates so there’s one
centralized component to maintain.

11-11: Import SortOrder from the shared module.

-import type { ModelSortBy } from "@/lib/admin-models";
-
-type SortOrder = "asc" | "desc";
+import type { ModelSortBy, SortOrder } from "@/lib/admin-models";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/models/page.tsx` at line 11, The file declares a local type
alias `SortOrder = "asc" | "desc"` but the review asks to import the shared
definition instead; remove the local `type SortOrder` declaration and import
`SortOrder` from the shared module where the canonical type lives, then update
any uses in this file (e.g., props, interfaces, or function signatures in
page.tsx) to reference the imported `SortOrder` symbol so the file uses the
centralized type.
ee/admin/src/components/log-card.tsx (1)

31-33: Consider providing user feedback after copying.

copyToClipboard silently writes to the clipboard with no visual confirmation. A brief toast or icon change would improve UX.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/log-card.tsx` around lines 31 - 33, The
copyToClipboard function currently writes to the clipboard silently; update it
to surface success/failure feedback by awaiting
navigator.clipboard.writeText(text) and then triggering a UI notification (e.g.,
a toast) or toggling a copied state used by LogCard to show a transient icon
change; specifically modify copyToClipboard(text: string) to return a Promise or
accept a callback, handle errors and call the app's existing toast/snackbar
utility or set a local copied boolean in the surrounding component (LogCard) so
the user sees a success or error message and the copied state clears after a
short timeout.
ee/admin/src/lib/admin-history.ts (2)

26-45: Inconsistent URL encoding: providerId is not encoded.

getModelHistory (line 57) and getMappingHistory (line 79) use encodeURIComponent() for modelId, but providerId is never encoded. If provider IDs can contain special characters, this could cause request failures.

For consistency and safety, consider encoding providerId as well:

♻️ Proposed fix
 export async function getProviderHistory(
 	providerId: string,
 	window: HistoryWindow,
 ): Promise<HistoryDataPoint[] | null> {
 	if (!(await hasSession())) {
 		return null;
 	}

 	const data = await fetchServerData<HistoryResponse>(
 		"GET",
-		`/admin/providers/${providerId}/history` as "/admin/providers",
+		`/admin/providers/${encodeURIComponent(providerId)}/history` as "/admin/providers",
 		{
 			params: {
 				query: { window },
 			},
 		},
 	);

 	return data?.data ?? null;
 }

Similarly for getMappingHistory at line 79:

-		`/admin/providers/${providerId}/models/${encodeURIComponent(modelId)}/history` as "/admin/providers",
+		`/admin/providers/${encodeURIComponent(providerId)}/models/${encodeURIComponent(modelId)}/history` as "/admin/providers",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/lib/admin-history.ts` around lines 26 - 45, The
getProviderHistory function constructs the request URL without encoding
providerId; update getProviderHistory to use encodeURIComponent(providerId) when
building the path passed to fetchServerData (same pattern as getModelHistory and
getMappingHistory) so special characters are percent-encoded and the GET to
`/admin/providers/${...}/history` is safe and consistent.

14-20: Consider extracting hasSession() to a shared utility.

This function duplicates the hasSession() implementation in admin-organizations.ts (lines 131-141). Consider extracting it to a shared module (e.g., server-api.ts or a new auth-utils.ts) to adhere to DRY principles.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/lib/admin-history.ts` around lines 14 - 20, The hasSession()
implementation is duplicated; extract it into a shared utility (e.g., create and
export async function hasSession() from a new auth-utils module or add it to
server-api) and replace the duplicate implementations in admin-history.ts and
admin-organizations.ts with an import of that shared hasSession; keep the same
async signature and behavior (using cookies(), key "better-auth.session_token"
and "__Secure-better-auth.session_token") and update both files to call the
imported hasSession to eliminate duplication.
ee/admin/src/components/cost-by-model-chart.tsx (2)

83-97: Replace console.error with structured error handling or silent fallback.

The static analysis tool flagged the console.error statement. Consider either:

  1. Removing it since the UI already shows a "No data" state
  2. Using a structured logging utility if one exists in the project
♻️ Proposed fix
 	const loadData = useCallback(
 		async (w: TokenWindow) => {
 			setLoading(true);
 			try {
 				const result = await fetchData(w);
 				setData(result);
-			} catch (error) {
-				console.error("Failed to load cost by model:", error);
+			} catch {
 				setData(null);
 			} finally {
 				setLoading(false);
 			}
 		},
 		[fetchData],
 	);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/cost-by-model-chart.tsx` around lines 83 - 97, The
console.error in the loadData async callback should be removed or replaced with
the project's structured logger; update the loadData function to avoid using
console.error by either omitting the log (since setData(null) drives the "No
data" UI) or calling the centralized logger (e.g., useLogger or processLogger)
with a clear message and the caught error; ensure you still setData(null) and
setLoading(false) in the catch/finally blocks and keep references to fetchData,
setData, and setLoading unchanged.

150-165: Add type="button" to prevent accidental form submission.

The tab buttons lack explicit type="button". While unlikely to cause issues in this context, it's a good practice to prevent accidental form submission if this component is ever used inside a form.

♻️ Proposed fix
 				{viewTabs.map((tab) => (
 					<button
 						key={tab.key}
+						type="button"
 						className={cn(
 							"rounded-md px-3 py-1 text-xs font-medium transition-colors",
 							activeView === tab.key
 								? "bg-primary text-primary-foreground"
 								: "text-muted-foreground hover:text-foreground",
 						)}
 						onClick={() => setActiveView(tab.key)}
 					>
 						{tab.label}
 					</button>
 				))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/cost-by-model-chart.tsx` around lines 150 - 165, The
tab buttons in the JSX mapping (rendering viewTabs) are missing an explicit type
and can accidentally submit a surrounding form; update the button elements
created in the viewTabs.map (the buttons that call setActiveView and use
activeView and cn) to include type="button" so they do not act as submit buttons
if this component is used inside a form.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ee/admin/src/app/models/page.tsx`:
- Around line 43-47: The code currently casts untrusted URL params into
ModelSortBy/SortOrder directly (see params, sortBy, sortOrder) — validate the
incoming params against the allowed values before casting and fallback to
defaults; implement a small whitelist check (or helper like isValidModelSortBy
and isValidSortOrder) that verifies params?.sortBy is one of ModelSortBy values
and params?.sortOrder is one of SortOrder values, then assign sortBy =
validValue ?? "logsCount" and sortOrder = validValue ?? "desc" to avoid unsafe
casts.

In
`@ee/admin/src/app/organizations/`[orgId]/projects/[projectId]/project-logs.tsx:
- Around line 47-48: The catch block in project-logs.tsx currently calls
console.error("Failed to load project logs:", error); instead add proper error
handling by storing the error in component state (e.g., useState like
[loadError, setLoadError]) and call setLoadError(error) inside the catch;
optionally invoke a user-visible toast (e.g., toast.error) with a friendly
message. Then update the component render to show a user-facing error state or
component when loadError is set (or rethrow to an ErrorBoundary) so users see
the failure instead of only logging to the console.

In `@ee/admin/src/app/providers/page.tsx`:
- Around line 39-41: params?.sortBy and params?.sortOrder are being cast
directly to ProviderSortBy and SortOrder which bypasses validation; update the
logic around params, sortBy, and sortOrder in providers/page.tsx to explicitly
validate the string values against the allowed ProviderSortBy and SortOrder
enums (or arrays of allowed values) before casting — if the value is not in the
allowed set, fall back to the safe defaults ("logsCount" for sortBy and "desc"
for sortOrder) and only pass validated values into getProviders; implement a
small validator/helper that checks membership and use it where sortBy and
sortOrder are derived.

---

Nitpick comments:
In `@ee/admin/src/app/models/page.tsx`:
- Around line 11-31: The SignInPrompt component is duplicated; extract it into a
shared component (e.g., create a new component named SignInPrompt in the shared
components folder) and replace the inline SignInPrompt definitions with a single
import and usage; ensure the new SignInPrompt exports the same component
signature and retains use of Button and Link (preserve className and props such
as size or asChild if needed), update all pages that defined their own
SignInPrompt (e.g., models and providers pages) to import SignInPrompt and
remove the local duplicates so there’s one centralized component to maintain.
- Line 11: The file declares a local type alias `SortOrder = "asc" | "desc"` but
the review asks to import the shared definition instead; remove the local `type
SortOrder` declaration and import `SortOrder` from the shared module where the
canonical type lives, then update any uses in this file (e.g., props,
interfaces, or function signatures in page.tsx) to reference the imported
`SortOrder` symbol so the file uses the centralized type.

In `@ee/admin/src/app/providers/page.tsx`:
- Line 9: The local type alias SortOrder in page.tsx duplicates a type already
exported from admin-providers.ts; remove the local declaration and import
SortOrder from admin-providers.ts instead to keep a single source of truth
(update the import list at the top of ee/admin/src/app/providers/page.tsx to
include SortOrder and delete the local `type SortOrder = "asc" | "desc";`
declaration).

In `@ee/admin/src/components/cost-by-model-chart.tsx`:
- Around line 83-97: The console.error in the loadData async callback should be
removed or replaced with the project's structured logger; update the loadData
function to avoid using console.error by either omitting the log (since
setData(null) drives the "No data" UI) or calling the centralized logger (e.g.,
useLogger or processLogger) with a clear message and the caught error; ensure
you still setData(null) and setLoading(false) in the catch/finally blocks and
keep references to fetchData, setData, and setLoading unchanged.
- Around line 150-165: The tab buttons in the JSX mapping (rendering viewTabs)
are missing an explicit type and can accidentally submit a surrounding form;
update the button elements created in the viewTabs.map (the buttons that call
setActiveView and use activeView and cn) to include type="button" so they do not
act as submit buttons if this component is used inside a form.

In `@ee/admin/src/components/history-chart.tsx`:
- Around line 82-89: The conditional in formatTimestamp is dead: both branches
return format(date, "HH:mm"); remove the minuteWindows check and return a single
format, or if longer windows should show more context, change the non-minute
branch to a different format (e.g., include date) instead of "HH:mm". Update the
function formatTimestamp (and the minuteWindows Set/HistoryWindow usage)
accordingly so there is no redundant branch.

In `@ee/admin/src/components/log-card.tsx`:
- Around line 31-33: The copyToClipboard function currently writes to the
clipboard silently; update it to surface success/failure feedback by awaiting
navigator.clipboard.writeText(text) and then triggering a UI notification (e.g.,
a toast) or toggling a copied state used by LogCard to show a transient icon
change; specifically modify copyToClipboard(text: string) to return a Promise or
accept a callback, handle errors and call the app's existing toast/snackbar
utility or set a local copied boolean in the surrounding component (LogCard) so
the user sees a success or error message and the copied state clears after a
short timeout.

In `@ee/admin/src/components/providers-table.tsx`:
- Around line 23-35: formatNumber and formatDate are duplicated utilities;
extract them into a shared module (e.g., a new "@/lib/format" file) and replace
the local functions in providers-table.tsx with imports. Move the
Intl.NumberFormat and Date formatting logic into exported functions
(formatNumber, formatDate) in the new module, update providers-table.tsx to
import and use those exports, and update any other components that duplicate the
same logic to import from the shared module instead.

In `@ee/admin/src/lib/admin-history.ts`:
- Around line 26-45: The getProviderHistory function constructs the request URL
without encoding providerId; update getProviderHistory to use
encodeURIComponent(providerId) when building the path passed to fetchServerData
(same pattern as getModelHistory and getMappingHistory) so special characters
are percent-encoded and the GET to `/admin/providers/${...}/history` is safe and
consistent.
- Around line 14-20: The hasSession() implementation is duplicated; extract it
into a shared utility (e.g., create and export async function hasSession() from
a new auth-utils module or add it to server-api) and replace the duplicate
implementations in admin-history.ts and admin-organizations.ts with an import of
that shared hasSession; keep the same async signature and behavior (using
cookies(), key "better-auth.session_token" and
"__Secure-better-auth.session_token") and update both files to call the imported
hasSession to eliminate duplication.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ba8d58bc-daeb-4679-98d6-737efd6abafa

📥 Commits

Reviewing files that changed from the base of the PR and between c3d2fae and aec108b.

⛔ Files ignored due to path filters (9)
  • ee/admin/public/favicon/android-chrome-192x192.png is excluded by !**/*.png
  • ee/admin/public/favicon/android-chrome-512x512.png is excluded by !**/*.png
  • ee/admin/public/favicon/apple-touch-icon.png is excluded by !**/*.png
  • ee/admin/public/favicon/favicon-16x16.png is excluded by !**/*.png
  • ee/admin/public/favicon/favicon-32x32.png is excluded by !**/*.png
  • ee/admin/public/favicon/favicon.ico is excluded by !**/*.ico
  • ee/admin/public/opengraph.png is excluded by !**/*.png
  • ee/admin/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (98)
  • AGENTS.md
  • ee/admin/.gitignore
  • ee/admin/.lintstagedrc.json
  • ee/admin/.prettierignore
  • ee/admin/README.md
  • ee/admin/components.json
  • ee/admin/eslint.config.mjs
  • ee/admin/next.config.ts
  • ee/admin/package.json
  • ee/admin/postcss.config.mjs
  • ee/admin/proxy.ts
  • ee/admin/public/favicon/site.webmanifest
  • ee/admin/src/app/api/health/route.ts
  • ee/admin/src/app/discounts/page.tsx
  • ee/admin/src/app/globals.css
  • ee/admin/src/app/layout.tsx
  • ee/admin/src/app/login/page.tsx
  • ee/admin/src/app/models/page.tsx
  • ee/admin/src/app/organizations/[orgId]/discounts/page.tsx
  • ee/admin/src/app/organizations/[orgId]/gift-credits-dialog.tsx
  • ee/admin/src/app/organizations/[orgId]/org-cost-by-model.tsx
  • ee/admin/src/app/organizations/[orgId]/org-metrics.tsx
  • ee/admin/src/app/organizations/[orgId]/page.tsx
  • ee/admin/src/app/organizations/[orgId]/projects/[projectId]/page.tsx
  • ee/admin/src/app/organizations/[orgId]/projects/[projectId]/project-logs.tsx
  • ee/admin/src/app/organizations/[orgId]/projects/[projectId]/project-metrics.tsx
  • ee/admin/src/app/organizations/page.tsx
  • ee/admin/src/app/page.tsx
  • ee/admin/src/app/providers/page.tsx
  • ee/admin/src/components/admin-shell.tsx
  • ee/admin/src/components/auth/user-provider.tsx
  • ee/admin/src/components/cost-by-model-chart.tsx
  • ee/admin/src/components/dashboard-cost-by-model.tsx
  • ee/admin/src/components/delete-user-button.tsx
  • ee/admin/src/components/discount-form.tsx
  • ee/admin/src/components/history-chart.tsx
  • ee/admin/src/components/landing/theme-toggle.tsx
  • ee/admin/src/components/log-card.tsx
  • ee/admin/src/components/models-table.tsx
  • ee/admin/src/components/providers-table.tsx
  • ee/admin/src/components/revenue-chart.tsx
  • ee/admin/src/components/server-data-wrapper.tsx
  • ee/admin/src/components/signups-chart.tsx
  • ee/admin/src/components/time-range-picker.tsx
  • ee/admin/src/components/token-time-range-toggle.tsx
  • ee/admin/src/components/ui/alert.tsx
  • ee/admin/src/components/ui/avatar.tsx
  • ee/admin/src/components/ui/badge.tsx
  • ee/admin/src/components/ui/button.tsx
  • ee/admin/src/components/ui/card.tsx
  • ee/admin/src/components/ui/carousel.tsx
  • ee/admin/src/components/ui/chart.tsx
  • ee/admin/src/components/ui/checkbox.tsx
  • ee/admin/src/components/ui/collapsible.tsx
  • ee/admin/src/components/ui/command.tsx
  • ee/admin/src/components/ui/dialog.tsx
  • ee/admin/src/components/ui/dropdown-menu.tsx
  • ee/admin/src/components/ui/form.tsx
  • ee/admin/src/components/ui/hover-card.tsx
  • ee/admin/src/components/ui/input-group.tsx
  • ee/admin/src/components/ui/input.tsx
  • ee/admin/src/components/ui/label.tsx
  • ee/admin/src/components/ui/logo.tsx
  • ee/admin/src/components/ui/popover.tsx
  • ee/admin/src/components/ui/progress.tsx
  • ee/admin/src/components/ui/scroll-area.tsx
  • ee/admin/src/components/ui/select.tsx
  • ee/admin/src/components/ui/separator.tsx
  • ee/admin/src/components/ui/sheet.tsx
  • ee/admin/src/components/ui/sidebar.tsx
  • ee/admin/src/components/ui/skeleton.tsx
  • ee/admin/src/components/ui/sonner.tsx
  • ee/admin/src/components/ui/table.tsx
  • ee/admin/src/components/ui/tabs.tsx
  • ee/admin/src/components/ui/textarea.tsx
  • ee/admin/src/components/ui/tooltip.tsx
  • ee/admin/src/hooks/use-mobile.ts
  • ee/admin/src/hooks/useUser.ts
  • ee/admin/src/lib/admin-discounts.ts
  • ee/admin/src/lib/admin-history.ts
  • ee/admin/src/lib/admin-metrics.ts
  • ee/admin/src/lib/admin-models.ts
  • ee/admin/src/lib/admin-organizations.ts
  • ee/admin/src/lib/admin-providers.ts
  • ee/admin/src/lib/auth-client.ts
  • ee/admin/src/lib/config-server.ts
  • ee/admin/src/lib/config.tsx
  • ee/admin/src/lib/fetch-client.ts
  • ee/admin/src/lib/getUser.ts
  • ee/admin/src/lib/providers.tsx
  • ee/admin/src/lib/server-api.ts
  • ee/admin/src/lib/stripe.ts
  • ee/admin/src/lib/types.ts
  • ee/admin/src/lib/utils.ts
  • ee/admin/src/types/next-themes.d.ts
  • ee/admin/tsconfig.json
  • infra/split.dockerfile
  • infra/supervisord.conf

Comment on lines +43 to +47
const params = await searchParams;
const page = Math.max(1, parseInt(params?.page ?? "1", 10));
const search = params?.search ?? "";
const sortBy = (params?.sortBy as ModelSortBy) ?? "logsCount";
const sortOrder = (params?.sortOrder as SortOrder) || "desc";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate URL parameters before casting to ModelSortBy.

Same issue as the providers page—casting untrusted sortBy and sortOrder from URL parameters without validation.

🛡️ Proposed fix
+const validSortByValues = new Set<string>([
+	"name",
+	"logsCount",
+	"errorsCount",
+	"cachedCount",
+	"avgTimeToFirstToken",
+	"providerCount",
+]);
+const validSortOrderValues = new Set<string>(["asc", "desc"]);
+
 export default async function ModelsPage({
 	searchParams,
 }: {
 	searchParams?: Promise<{
 		page?: string;
 		search?: string;
 		sortBy?: string;
 		sortOrder?: string;
 	}>;
 }) {
 	const params = await searchParams;
 	const page = Math.max(1, parseInt(params?.page ?? "1", 10));
 	const search = params?.search ?? "";
-	const sortBy = (params?.sortBy as ModelSortBy) ?? "logsCount";
-	const sortOrder = (params?.sortOrder as SortOrder) || "desc";
+	const sortBy: ModelSortBy = validSortByValues.has(params?.sortBy ?? "")
+		? (params!.sortBy as ModelSortBy)
+		: "logsCount";
+	const sortOrder: SortOrder = validSortOrderValues.has(params?.sortOrder ?? "")
+		? (params!.sortOrder as SortOrder)
+		: "desc";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const params = await searchParams;
const page = Math.max(1, parseInt(params?.page ?? "1", 10));
const search = params?.search ?? "";
const sortBy = (params?.sortBy as ModelSortBy) ?? "logsCount";
const sortOrder = (params?.sortOrder as SortOrder) || "desc";
const validSortByValues = new Set<string>([
"name",
"logsCount",
"errorsCount",
"cachedCount",
"avgTimeToFirstToken",
"providerCount",
]);
const validSortOrderValues = new Set<string>(["asc", "desc"]);
export default async function ModelsPage({
searchParams,
}: {
searchParams?: Promise<{
page?: string;
search?: string;
sortBy?: string;
sortOrder?: string;
}>;
}) {
const params = await searchParams;
const page = Math.max(1, parseInt(params?.page ?? "1", 10));
const search = params?.search ?? "";
const sortBy: ModelSortBy = validSortByValues.has(params?.sortBy ?? "")
? (params!.sortBy as ModelSortBy)
: "logsCount";
const sortOrder: SortOrder = validSortOrderValues.has(params?.sortOrder ?? "")
? (params!.sortOrder as SortOrder)
: "desc";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/models/page.tsx` around lines 43 - 47, The code currently
casts untrusted URL params into ModelSortBy/SortOrder directly (see params,
sortBy, sortOrder) — validate the incoming params against the allowed values
before casting and fallback to defaults; implement a small whitelist check (or
helper like isValidModelSortBy and isValidSortOrder) that verifies
params?.sortBy is one of ModelSortBy values and params?.sortOrder is one of
SortOrder values, then assign sortBy = validValue ?? "logsCount" and sortOrder =
validValue ?? "desc" to avoid unsafe casts.

Comment on lines +47 to +48
} catch (error) {
console.error("Failed to load project logs:", error);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace console.error with proper error handling.

Static analysis flags this console.error statement. Consider showing a user-facing error state or using a toast notification so users know the load failed.

💡 Suggested approach
+	const [error, setError] = useState<string | null>(null);
+
 	const loadLogs = useCallback(
 		async (cursor?: string) => {
+			setError(null);
 			if (cursor) {
 				setLoadingMore(true);
 			} else {
 				setLoading(true);
 			}

 			try {
 				const data = await loadProjectLogsAction(orgId, projectId, cursor);
 				// ...
 			} catch (error) {
-				console.error("Failed to load project logs:", error);
+				setError("Failed to load project logs. Please try again.");
 			} finally {
 				// ...
 			}
 		},
 		[orgId, projectId],
 	);

Then render an error message in the UI when error is set.

🧰 Tools
🪛 ESLint

[error] 48-48: Unexpected console statement.

(no-console)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/organizations/`[orgId]/projects/[projectId]/project-logs.tsx
around lines 47 - 48, The catch block in project-logs.tsx currently calls
console.error("Failed to load project logs:", error); instead add proper error
handling by storing the error in component state (e.g., useState like
[loadError, setLoadError]) and call setLoadError(error) inside the catch;
optionally invoke a user-visible toast (e.g., toast.error) with a friendly
message. Then update the component render to show a user-facing error state or
component when loadError is set (or rethrow to an ErrorBoundary) so users see
the failure instead of only logging to the console.

Comment on lines +39 to +41
const params = await searchParams;
const sortBy = (params?.sortBy as ProviderSortBy) ?? "logsCount";
const sortOrder = (params?.sortOrder as SortOrder) || "desc";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate URL parameters before casting to strict union types.

Casting params?.sortBy directly to ProviderSortBy bypasses TypeScript's type safety. If a user manipulates the URL with an invalid value (e.g., ?sortBy=malicious), the invalid string is passed to getProviders and sent to the server. While the server may validate, the client loses compile-time guarantees.

🛡️ Proposed fix to validate before casting
+const validSortByValues = new Set<string>([
+	"name",
+	"logsCount",
+	"errorsCount",
+	"cachedCount",
+	"avgTimeToFirstToken",
+	"modelCount",
+]);
+const validSortOrderValues = new Set<string>(["asc", "desc"]);
+
 export default async function ProvidersPage({
 	searchParams,
 }: {
 	searchParams?: Promise<{
 		sortBy?: string;
 		sortOrder?: string;
 	}>;
 }) {
 	const params = await searchParams;
-	const sortBy = (params?.sortBy as ProviderSortBy) ?? "logsCount";
-	const sortOrder = (params?.sortOrder as SortOrder) || "desc";
+	const sortBy: ProviderSortBy = validSortByValues.has(params?.sortBy ?? "")
+		? (params!.sortBy as ProviderSortBy)
+		: "logsCount";
+	const sortOrder: SortOrder = validSortOrderValues.has(params?.sortOrder ?? "")
+		? (params!.sortOrder as SortOrder)
+		: "desc";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/providers/page.tsx` around lines 39 - 41, params?.sortBy and
params?.sortOrder are being cast directly to ProviderSortBy and SortOrder which
bypasses validation; update the logic around params, sortBy, and sortOrder in
providers/page.tsx to explicitly validate the string values against the allowed
ProviderSortBy and SortOrder enums (or arrays of allowed values) before casting
— if the value is not in the allowed set, fall back to the safe defaults
("logsCount" for sortBy and "desc" for sortOrder) and only pass validated values
into getProviders; implement a small validator/helper that checks membership and
use it where sortBy and sortOrder are derived.

Replace fetchServerData wrapper with direct createServerApiClient()
calls. Derive all types from OpenAPI spec using GetJsonResponse helper.
Remove admin-metrics.ts, admin-models.ts, admin-providers.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ee/admin/src/app/organizations/[orgId]/projects/[projectId]/page.tsx (1)

47-55: ⚠️ Potential issue | 🟠 Major

Handle non-2xx responses before showing SignInPrompt.

openapi-fetch returns { data, error, response } for all calls—non-2xx responses do not throw. The current code only destructures data and ignores error, so 401 auth failures, 404 missing organizations, and 5xx backend errors all trigger the sign-in prompt.

Check the error property and handle 404/5xx cases appropriately:

  • 404: Show notFound()
  • 5xx: Re-throw or show error page
  • 401: Then show sign-in prompt
Current code
	const { data: projectsData } = await $api.GET(
		"/admin/organizations/{orgId}/projects",
		{ params: { path: { orgId } } },
	);

	if (!projectsData) {
		return <SignInPrompt />;
	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/organizations/`[orgId]/projects/[projectId]/page.tsx around
lines 47 - 55, The current call using createServerApiClient and $api.GET only
reads data (projectsData) and treats falsy data as an auth failure; instead
destructure and inspect error and response from $api.GET (e.g., const { data:
projectsData, error, response } = await $api.GET(...)), then handle by: if
response?.status === 401 return <SignInPrompt />; if response?.status === 404
call notFound(); if response?.status && response.status >= 500 re-throw or
surface an error page (or throw error) ; otherwise proceed when projectsData is
present; ensure you reference createServerApiClient, $api.GET, projectsData,
error, response, SignInPrompt and notFound() when making the changes.
♻️ Duplicate comments (3)
ee/admin/src/components/models-table.tsx (1)

129-143: ⚠️ Potential issue | 🟠 Major

Static headers remove sorting capability.

The parent page (models/page.tsx) still maintains sortBy/sortOrder state, but these headers are now static. Admins can no longer change the sort order from the UI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/models-table.tsx` around lines 129 - 143, The table
headers in models-table.tsx (TableHeader/TableHead) are static which prevents
changing the page-level sort state (sortBy/sortOrder in models/page.tsx); update
ModelsTable to render sortable headers by wiring each TableHead to the existing
sort change handler (prop or callback from models/page.tsx), toggling sortOrder
for the clicked sort key (e.g., "Model", "Family", "Status", "Requests",
"Errors", "Error Rate", "Avg TTFT", "Last Updated"), and render a visual/ARIA
sort indicator for the active sort column; ensure the component uses the sortBy
and sortOrder values passed from models/page.tsx and calls the onSortChange (or
similarly named) function with the new sort key and order when a header is
clicked.
ee/admin/src/app/organizations/[orgId]/projects/[projectId]/project-logs.tsx (1)

45-46: ⚠️ Potential issue | 🟡 Minor

Replace console.error with user-facing error state.

The static analysis correctly flags this console statement. Users won't see any feedback when log loading fails. Consider adding error state and displaying it in the UI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/organizations/`[orgId]/projects/[projectId]/project-logs.tsx
around lines 45 - 46, Replace the console.error in the catch block with a
user-facing error state: add a React state like error/setError in the component
that owns the fetch (in project-logs.tsx), set setError(error) inside the catch
(error) branch, and clear it when retries/start loading; then render a visible
error UI (banner/message) in the component's JSX to inform the user that logs
failed to load and optionally include the error message and a retry button.
Ensure you reference the same catch (error) block and the component
(project-logs.tsx) when making the change so the UI surfaces failures instead of
logging to console.
ee/admin/src/app/models/page.tsx (1)

49-50: ⚠️ Potential issue | 🟠 Major

Validate URL parameters before casting.

Untrusted sortBy and sortOrder from URL query parameters are cast directly to ModelSortBy and SortOrder without validation. This could allow invalid values to reach the API.

🛡️ Suggested validation
+const validSortByValues = new Set([
+	"name",
+	"family",
+	"logsCount",
+	"errorsCount",
+	"cachedCount",
+	"avgTimeToFirstToken",
+	"providerCount",
+]);
+
 const params = await searchParams;
 const page = Math.max(1, parseInt(params?.page ?? "1", 10));
 const search = params?.search ?? "";
-const sortBy = (params?.sortBy as ModelSortBy) ?? "logsCount";
-const sortOrder = (params?.sortOrder as SortOrder) || "desc";
+const sortBy: ModelSortBy = validSortByValues.has(params?.sortBy ?? "")
+	? (params!.sortBy as ModelSortBy)
+	: "logsCount";
+const sortOrder: SortOrder =
+	params?.sortOrder === "asc" || params?.sortOrder === "desc"
+		? params.sortOrder
+		: "desc";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/models/page.tsx` around lines 49 - 50, Untrusted query
values params?.sortBy and params?.sortOrder are being cast directly to
ModelSortBy and SortOrder; validate them against allowed enum/whitelist values
before casting to prevent invalid values reaching the API. Update the code that
sets sortBy and sortOrder to check params.sortBy against the set of valid
ModelSortBy members (and params.sortOrder against valid SortOrder values like
"asc"/"desc"), and only assign the cast if the value is valid, otherwise fall
back to the defaults ("logsCount" and "desc").
🧹 Nitpick comments (4)
ee/admin/src/lib/types.ts (1)

19-83: Drop the section-header comments or split the barrel.

The export names already provide the grouping, so the // Metrics, // Organizations, etc. headers mostly add noise. If the grouping matters, consider re-exporting from feature-specific type modules instead.

As per coding guidelines "No unnecessary code comments".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/lib/types.ts` around lines 19 - 83, Remove the section-header
comments (e.g., "// Metrics", "// Organizations", "// Projects", etc.) from this
barrel types file and either delete them or move the related type groups into
feature-specific modules and re-export them; for example extract
AdminDashboardMetrics/TimeseriesRange,
Organization/OrganizationsListResponse/TokenWindow,
Project/ProjectsListResponse/ProjectMetrics/ProjectLogsResponse,
ApiKey/ApiKeysListResponse, Member/MembersListResponse,
Discount/DiscountsListResponse/DiscountOptions,
ProviderStats/ProvidersListResponse, ModelStats/ModelsListResponse,
HistoryResponse/HistoryDataPoint, CostByModelResponse, etc., into per-feature
files and then export those modules from this file so the grouping is preserved
without inline comments. Ensure exported type names and imports/exports remain
unchanged so consumers (e.g., AdminDashboardMetrics, Organization, Project,
ApiKey, Member, DiscountOptions, ProviderModelMapping, ProviderStats,
ModelStats, HistoryDataPoint) keep working.
ee/admin/src/components/models-table.tsx (1)

21-33: Consider extracting shared formatting helpers.

formatNumber and formatDate are duplicated verbatim in providers-table.tsx. Consider extracting these to a shared utility module (e.g., @/lib/format-utils.ts).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/models-table.tsx` around lines 21 - 33, formatNumber
and formatDate are duplicated in models-table.tsx and providers-table.tsx;
extract them into a shared utility (e.g., create functions formatNumber and
formatDate in a new module like "@/lib/format-utils.ts") and replace the local
implementations by importing those helpers in both ModelsTable
(models-table.tsx) and ProvidersTable (providers-table.tsx); ensure you export
the functions from the new module and update the files to import { formatNumber,
formatDate } from the shared module so both components use the same
implementation.
ee/admin/src/components/cost-by-model-chart.tsx (1)

24-36: Consider importing CostByModelData from shared types.

The ee/admin/src/lib/types.ts file defines CostByModelResponse derived from the OpenAPI schema. Importing that type instead of redefining CostByModelData locally would ensure consistency with the API contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/cost-by-model-chart.tsx` around lines 24 - 36,
Replace the locally defined CostByModelData/CostByModelEntry types with the
canonical type from shared types: import CostByModelResponse (or the exact
exported name) from the shared types module and use it in place of
CostByModelData; remove the local interfaces (CostByModelData and
CostByModelEntry) and update any references in this component (e.g., props,
state, or variables used by the cost-by-model-chart component) to the imported
CostByModelResponse (use a type alias if names differ, e.g., type
CostByModelData = CostByModelResponse) to ensure the component uses the
OpenAPI-derived schema.
ee/admin/src/app/page.tsx (1)

116-128: Consider handling API errors explicitly.

The code destructures data from API responses but doesn't check for error. If the API returns an error, data will be undefined and the page will show SignInPrompt, which may be misleading when the actual issue is an API error rather than authentication.

This is a minor concern since the current fallback behavior is reasonable, but for better UX you might want to distinguish between auth failures and API errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/app/page.tsx` around lines 116 - 128, Check the responses from
createServerApiClient and the two $api.GET calls (metricsRes and timeseriesRes)
for error fields before using metrics/timeseries; instead of only testing if
metrics is falsy and returning SignInPrompt, detect an authentication error
(e.g., metricsRes.error || timeseriesRes.error with an auth flag/status) and
return <SignInPrompt /> only for auth failures, otherwise surface or throw a
clearer API error (render an error message or throw a new Error including
metricsRes.error/timeseriesRes.error) so callers of metrics and timeseries see
the actual API failure rather than a misleading SignInPrompt.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ee/admin/src/components/cost-by-model-chart.tsx`:
- Around line 89-91: The catch block in the cost-by-model-chart component
currently logs errors to the console and calls setData(null); replace that with
a user-facing error state: add a state variable (e.g., error / setError) in the
component, update the catch in the load/fetch function to call
setError(error?.message || String(error)) and setData(null) instead of
console.error, and ensure the render path for the component (where you currently
handle "No data") also renders a clear error UI when error is present; also
reset/clear error (setError(null)) at the start of the load/fetch function (or
on retries) so transient retries don't show stale messages.

---

Outside diff comments:
In `@ee/admin/src/app/organizations/`[orgId]/projects/[projectId]/page.tsx:
- Around line 47-55: The current call using createServerApiClient and $api.GET
only reads data (projectsData) and treats falsy data as an auth failure; instead
destructure and inspect error and response from $api.GET (e.g., const { data:
projectsData, error, response } = await $api.GET(...)), then handle by: if
response?.status === 401 return <SignInPrompt />; if response?.status === 404
call notFound(); if response?.status && response.status >= 500 re-throw or
surface an error page (or throw error) ; otherwise proceed when projectsData is
present; ensure you reference createServerApiClient, $api.GET, projectsData,
error, response, SignInPrompt and notFound() when making the changes.

---

Duplicate comments:
In `@ee/admin/src/app/models/page.tsx`:
- Around line 49-50: Untrusted query values params?.sortBy and params?.sortOrder
are being cast directly to ModelSortBy and SortOrder; validate them against
allowed enum/whitelist values before casting to prevent invalid values reaching
the API. Update the code that sets sortBy and sortOrder to check params.sortBy
against the set of valid ModelSortBy members (and params.sortOrder against valid
SortOrder values like "asc"/"desc"), and only assign the cast if the value is
valid, otherwise fall back to the defaults ("logsCount" and "desc").

In
`@ee/admin/src/app/organizations/`[orgId]/projects/[projectId]/project-logs.tsx:
- Around line 45-46: Replace the console.error in the catch block with a
user-facing error state: add a React state like error/setError in the component
that owns the fetch (in project-logs.tsx), set setError(error) inside the catch
(error) branch, and clear it when retries/start loading; then render a visible
error UI (banner/message) in the component's JSX to inform the user that logs
failed to load and optionally include the error message and a retry button.
Ensure you reference the same catch (error) block and the component
(project-logs.tsx) when making the change so the UI surfaces failures instead of
logging to console.

In `@ee/admin/src/components/models-table.tsx`:
- Around line 129-143: The table headers in models-table.tsx
(TableHeader/TableHead) are static which prevents changing the page-level sort
state (sortBy/sortOrder in models/page.tsx); update ModelsTable to render
sortable headers by wiring each TableHead to the existing sort change handler
(prop or callback from models/page.tsx), toggling sortOrder for the clicked sort
key (e.g., "Model", "Family", "Status", "Requests", "Errors", "Error Rate", "Avg
TTFT", "Last Updated"), and render a visual/ARIA sort indicator for the active
sort column; ensure the component uses the sortBy and sortOrder values passed
from models/page.tsx and calls the onSortChange (or similarly named) function
with the new sort key and order when a header is clicked.

---

Nitpick comments:
In `@ee/admin/src/app/page.tsx`:
- Around line 116-128: Check the responses from createServerApiClient and the
two $api.GET calls (metricsRes and timeseriesRes) for error fields before using
metrics/timeseries; instead of only testing if metrics is falsy and returning
SignInPrompt, detect an authentication error (e.g., metricsRes.error ||
timeseriesRes.error with an auth flag/status) and return <SignInPrompt /> only
for auth failures, otherwise surface or throw a clearer API error (render an
error message or throw a new Error including
metricsRes.error/timeseriesRes.error) so callers of metrics and timeseries see
the actual API failure rather than a misleading SignInPrompt.

In `@ee/admin/src/components/cost-by-model-chart.tsx`:
- Around line 24-36: Replace the locally defined
CostByModelData/CostByModelEntry types with the canonical type from shared
types: import CostByModelResponse (or the exact exported name) from the shared
types module and use it in place of CostByModelData; remove the local interfaces
(CostByModelData and CostByModelEntry) and update any references in this
component (e.g., props, state, or variables used by the cost-by-model-chart
component) to the imported CostByModelResponse (use a type alias if names
differ, e.g., type CostByModelData = CostByModelResponse) to ensure the
component uses the OpenAPI-derived schema.

In `@ee/admin/src/components/models-table.tsx`:
- Around line 21-33: formatNumber and formatDate are duplicated in
models-table.tsx and providers-table.tsx; extract them into a shared utility
(e.g., create functions formatNumber and formatDate in a new module like
"@/lib/format-utils.ts") and replace the local implementations by importing
those helpers in both ModelsTable (models-table.tsx) and ProvidersTable
(providers-table.tsx); ensure you export the functions from the new module and
update the files to import { formatNumber, formatDate } from the shared module
so both components use the same implementation.

In `@ee/admin/src/lib/types.ts`:
- Around line 19-83: Remove the section-header comments (e.g., "// Metrics", "//
Organizations", "// Projects", etc.) from this barrel types file and either
delete them or move the related type groups into feature-specific modules and
re-export them; for example extract AdminDashboardMetrics/TimeseriesRange,
Organization/OrganizationsListResponse/TokenWindow,
Project/ProjectsListResponse/ProjectMetrics/ProjectLogsResponse,
ApiKey/ApiKeysListResponse, Member/MembersListResponse,
Discount/DiscountsListResponse/DiscountOptions,
ProviderStats/ProvidersListResponse, ModelStats/ModelsListResponse,
HistoryResponse/HistoryDataPoint, CostByModelResponse, etc., into per-feature
files and then export those modules from this file so the grouping is preserved
without inline comments. Ensure exported type names and imports/exports remain
unchanged so consumers (e.g., AdminDashboardMetrics, Organization, Project,
ApiKey, Member, DiscountOptions, ProviderModelMapping, ProviderStats,
ModelStats, HistoryDataPoint) keep working.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d0e869af-7eb4-470e-b6fc-6cd3ac54e7d7

📥 Commits

Reviewing files that changed from the base of the PR and between aec108b and 90c2f30.

📒 Files selected for processing (26)
  • ee/admin/src/app/models/page.tsx
  • ee/admin/src/app/organizations/[orgId]/discounts/page.tsx
  • ee/admin/src/app/organizations/[orgId]/org-cost-by-model.tsx
  • ee/admin/src/app/organizations/[orgId]/org-metrics.tsx
  • ee/admin/src/app/organizations/[orgId]/page.tsx
  • ee/admin/src/app/organizations/[orgId]/projects/[projectId]/page.tsx
  • ee/admin/src/app/organizations/[orgId]/projects/[projectId]/project-logs.tsx
  • ee/admin/src/app/organizations/[orgId]/projects/[projectId]/project-metrics.tsx
  • ee/admin/src/app/organizations/page.tsx
  • ee/admin/src/app/page.tsx
  • ee/admin/src/app/providers/page.tsx
  • ee/admin/src/components/cost-by-model-chart.tsx
  • ee/admin/src/components/dashboard-cost-by-model.tsx
  • ee/admin/src/components/discount-form.tsx
  • ee/admin/src/components/log-card.tsx
  • ee/admin/src/components/models-table.tsx
  • ee/admin/src/components/providers-table.tsx
  • ee/admin/src/components/revenue-chart.tsx
  • ee/admin/src/components/signups-chart.tsx
  • ee/admin/src/components/time-range-picker.tsx
  • ee/admin/src/components/token-time-range-toggle.tsx
  • ee/admin/src/lib/admin-discounts.ts
  • ee/admin/src/lib/admin-history.ts
  • ee/admin/src/lib/admin-organizations.ts
  • ee/admin/src/lib/server-api.ts
  • ee/admin/src/lib/types.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • ee/admin/src/app/providers/page.tsx
  • ee/admin/src/app/organizations/[orgId]/org-cost-by-model.tsx
  • ee/admin/src/components/log-card.tsx

Comment on lines +89 to +91
} catch (error) {
console.error("Failed to load cost by model:", error);
setData(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace console.error with user-facing feedback.

ESLint flags this console statement. While setData(null) triggers the "No data" UI state, users won't know if it's genuinely empty or if an error occurred. Consider distinguishing error states.

💡 Suggested approach
+const [error, setError] = useState<string | null>(null);

 const loadData = useCallback(
 	async (w: TokenWindow) => {
 		setLoading(true);
+		setError(null);
 		try {
 			const result = await fetchData(w);
 			setData(result);
 		} catch (error) {
-			console.error("Failed to load cost by model:", error);
+			setError("Failed to load cost data. Please try again.");
 			setData(null);
 		} finally {
 			setLoading(false);
 		}
 	},
 	[fetchData],
 );

Then render the error message when error is set.

🧰 Tools
🪛 ESLint

[error] 90-90: Unexpected console statement.

(no-console)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/cost-by-model-chart.tsx` around lines 89 - 91, The
catch block in the cost-by-model-chart component currently logs errors to the
console and calls setData(null); replace that with a user-facing error state:
add a state variable (e.g., error / setError) in the component, update the catch
in the load/fetch function to call setError(error?.message || String(error)) and
setData(null) instead of console.error, and ensure the render path for the
component (where you currently handle "No data") also renders a clear error UI
when error is present; also reset/clear error (setError(null)) at the start of
the load/fetch function (or on retries) so transient retries don't show stale
messages.

smakosh and others added 2 commits March 7, 2026 15:33
- Fix history chart showing string concatenation instead of numbers
  by adding Number() coercion in mapHistoryRows for PostgreSQL bigint
- Add sortable column headers to models and providers tables using
  Link-based SortableHeader pattern matching organizations page
- Add status, free, updatedAt sort options to models API
- Add status, updatedAt sort options to providers API
- Make tables responsive with horizontal scroll via min-w-0
- Expand seed to generate history for all providers and 50 models

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@smakosh
smakosh enabled auto-merge March 7, 2026 14:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/routes/admin.ts (1)

1645-1666: ⚠️ Potential issue | 🟠 Major

Reject cursors that do not belong to this project.

The cursor lookup only filters by log.id. If a cursor from another project is passed in, pagination will be anchored to the wrong timestamp and can skip or duplicate rows for this project.

🐛 Proposed fix
 	if (cursor) {
 		const cursorLog = await db
 			.select({ createdAt: tables.log.createdAt })
 			.from(tables.log)
-			.where(eq(tables.log.id, cursor))
+			.where(
+				and(
+					eq(tables.log.id, cursor),
+					eq(tables.log.projectId, projectId),
+				),
+			)
 			.limit(1);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/admin.ts` around lines 1645 - 1666, The cursor lookup
currently queries by log.id only (see db.select -> tables.log and cursorLog)
which allows cursors from other projects; update the lookup to also filter by
the current project (tables.log.projectId) so the fetched cursor row belongs to
this project, then use that cursorCreatedAt to build whereConditions; ensure the
same project constraint (tables.log.projectId === projectId) is applied when
validating the cursor and when composing the pagination where clause alongside
tables.log.createdAt and tables.log.id.
🧹 Nitpick comments (2)
packages/db/src/seed.ts (1)

1101-1101: Reuse the existing collection type instead of redeclaring Record<string, any>.

The new topMappings declaration unnecessarily reiterates the Record<string, any> type. Use const topMappings: typeof mappings = []; to keep both variables in sync and avoid duplicate type declarations, which also aligns with the guideline to minimize any usage in TypeScript.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/seed.ts` at line 1101, Replace the explicit Record<string,
any> annotation on topMappings with the existing mappings' type so they stay in
sync; change the declaration of topMappings to use typeof mappings (e.g., const
topMappings: typeof mappings = []) rather than redeclaring Record<string, any>,
ensuring both variables share the same inferred shape and reducing use of any.
ee/admin/src/components/providers-table.tsx (1)

26-36: Share the sort unions instead of redefining them locally.

ee/admin/src/app/providers/page.tsx already depends on the same ProviderSortBy / SortOrder shape when reading search params. Keeping a second copy here makes the page and table easy to drift the next time a sortable column is added. Export these unions from a shared admin types module and import them in both places.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/providers-table.tsx` around lines 26 - 36, The local
duplicate type unions ProviderSortBy and SortOrder should be removed and
replaced with imports from a single shared admin types module: create or use a
central exported type (exporting ProviderSortBy and SortOrder) and update the
component to import those types instead of redefining them; specifically, delete
the local definitions for ProviderSortBy and SortOrder in providers-table.tsx
and update the import statements in this file and in the page that reads search
params (which also uses ProviderSortBy/SortOrder) to reference the shared
exported types so both files use the same canonical definitions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/src/routes/admin.ts`:
- Around line 3046-3081: The totals are being computed from the already-limited
top-20 result set (rows), so totalCost and totalRequests undercount when >20
models exist; fix by running a separate aggregate query against
projectHourlyModelStats (with the same where
gte(projectHourlyModelStats.hourTimestamp, startDate)) that computes SUM(cost)
and SUM(requestCount) for the full window and use those results for
totalCost/totalRequests, then keep the existing select/groupBy/orderBy/limit
query to populate rows/models. Update references to totalCost and totalRequests
to use the aggregate query results instead of reducing the limited rows.
- Around line 1717-1723: The current mapping uses truthy checks which turn valid
0 token counts into null; update each token field expression in the
paginatedLogs.map callback (promptTokens, completionTokens, totalTokens,
reasoningTokens, cachedTokens) to check for null/undefined instead of
falsiness—e.g. replace "l.promptTokens ? String(l.promptTokens) : null" with
"l.promptTokens != null ? String(l.promptTokens) : null" (do the same for
completionTokens, totalTokens, reasoningTokens, cachedTokens) so zero values are
preserved as "0".
- Around line 1571-1582: Replace the loose z.any() uses for errorDetails and
routingMetadata with the concrete Zod schemas: import errorDetails from
`@llmgateway/db` and import the routingMetadata schema from logs.ts (the same
symbol used there), then change the fields in the response schema from
errorDetails: z.any().nullable() and routingMetadata: z.any().nullable() to use
the imported errorDetails and routingMetadata schemas (preserving .nullable() if
needed); also add the corresponding imports at the top of the file so the types
are reused rather than any-typed.

In `@ee/admin/src/components/providers-table.tsx`:
- Line 18: The import of getProviderHistory from
ee/admin/src/lib/admin-history.ts breaks Next.js server/client boundaries
because admin-history.ts uses "use server" and createServerApiClient (which
calls next/headers.cookies()), so update ProvidersTable to avoid importing that
server-only module: either convert the ProvidersTable component into a server
component (move/rename it out of "use client" and remove any client-only
directives so it can safely import getProviderHistory), or keep ProvidersTable
as a client component and implement a Server Action (e.g., a new server-exported
async function in admin-history.ts or a separate server-actions file) that wraps
the history fetch and call that action from the client component; reference
ProvidersTable, getProviderHistory, admin-history.ts, and createServerApiClient
when making the change.
- Around line 80-88: The formatDate function uses toLocaleDateString without an
explicit timeZone which can cause SSR/hydration mismatches; update formatDate
(and any other direct toLocaleDateString calls) to pass a fixed timeZone (e.g.,
timeZone: "UTC") in the options so the server and client render the same value,
e.g., add timeZone: "UTC" to the options object in the formatDate function and
mirror the same change for other toLocaleDateString usages in this file.

In `@packages/db/src/seed.ts`:
- Around line 1099-1110: The current per-provider selection loop (using
seenProviders and topMappings over mappings) ensures one mapping per provider
but can yield fewer than 50 entries; to fix, after that loop add a second pass
over mappings and append additional mappings to topMappings until
topMappings.length === 50 (skipping ones already in seenProviders or already
present) so you preserve provider coverage first and then fill remaining slots;
update the logic that builds topMappings to perform the initial per-provider
pass then a follow-up pass that fills to 50 while honoring uniqueness and the
existing seenProviders set.

---

Outside diff comments:
In `@apps/api/src/routes/admin.ts`:
- Around line 1645-1666: The cursor lookup currently queries by log.id only (see
db.select -> tables.log and cursorLog) which allows cursors from other projects;
update the lookup to also filter by the current project (tables.log.projectId)
so the fetched cursor row belongs to this project, then use that cursorCreatedAt
to build whereConditions; ensure the same project constraint
(tables.log.projectId === projectId) is applied when validating the cursor and
when composing the pagination where clause alongside tables.log.createdAt and
tables.log.id.

---

Nitpick comments:
In `@ee/admin/src/components/providers-table.tsx`:
- Around line 26-36: The local duplicate type unions ProviderSortBy and
SortOrder should be removed and replaced with imports from a single shared admin
types module: create or use a central exported type (exporting ProviderSortBy
and SortOrder) and update the component to import those types instead of
redefining them; specifically, delete the local definitions for ProviderSortBy
and SortOrder in providers-table.tsx and update the import statements in this
file and in the page that reads search params (which also uses
ProviderSortBy/SortOrder) to reference the shared exported types so both files
use the same canonical definitions.

In `@packages/db/src/seed.ts`:
- Line 1101: Replace the explicit Record<string, any> annotation on topMappings
with the existing mappings' type so they stay in sync; change the declaration of
topMappings to use typeof mappings (e.g., const topMappings: typeof mappings =
[]) rather than redeclaring Record<string, any>, ensuring both variables share
the same inferred shape and reducing use of any.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2997dd90-84a6-47c3-8d35-6c6d753c7505

📥 Commits

Reviewing files that changed from the base of the PR and between 90c2f30 and 8c604ef.

⛔ Files ignored due to path filters (1)
  • ee/admin/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
📒 Files selected for processing (7)
  • apps/api/src/routes/admin.ts
  • ee/admin/src/app/models/page.tsx
  • ee/admin/src/app/providers/page.tsx
  • ee/admin/src/components/models-table.tsx
  • ee/admin/src/components/providers-table.tsx
  • ee/admin/src/components/ui/sidebar.tsx
  • packages/db/src/seed.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • ee/admin/src/components/models-table.tsx
  • ee/admin/src/app/providers/page.tsx
  • ee/admin/src/app/models/page.tsx

Comment on lines +1571 to +1582
errorDetails: z.any().nullable(),
finishReason: z.string().nullable(),
unifiedFinishReason: z.string().nullable(),
cached: z.boolean().nullable(),
cachedTokens: z.string().nullable(),
streamed: z.boolean().nullable(),
source: z.string().nullable(),
content: z.string().nullable(),
usedMode: z.string(),
discount: z.number().nullable(),
timeToFirstToken: z.number().nullable(),
responseSize: z.number().nullable(),
routingMetadata: z.any().nullable(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Schema and route usage:"
rg -n -C2 '\b(errorDetails|routingMetadata)\b' apps packages ee

echo
echo "Log table field definitions:"
rg -n -C3 '\b(errorDetails|routingMetadata)\b' packages/db/src/schema.ts

Repository: theopenco/llmgateway

Length of output: 50376


🏁 Script executed:

#!/bin/bash
# Get the errorDetails and routingMetadata definitions from logs.ts
head -n 100 apps/api/src/routes/logs.ts | tail -n 50

Repository: theopenco/llmgateway

Length of output: 1753


🏁 Script executed:

#!/bin/bash
# Get the complete routingMetadata and errorDetails schema definitions
rg -n -A 30 'routingMetadata: z' apps/api/src/routes/logs.ts | head -n 60

Repository: theopenco/llmgateway

Length of output: 890


🏁 Script executed:

#!/bin/bash
# Check the imports at the top of logs.ts to see where errorDetails comes from
head -n 30 apps/api/src/routes/logs.ts

Repository: theopenco/llmgateway

Length of output: 544


🏁 Script executed:

#!/bin/bash
# Check what's exported from packages/db and verify the errorDetails definition
cat packages/db/src/types.ts | head -n 20

Repository: theopenco/llmgateway

Length of output: 567


🏁 Script executed:

#!/bin/bash
# Check imports in admin.ts to see what's currently imported from db
head -n 50 apps/api/src/routes/admin.ts | grep -A 30 "from.*db"

Repository: theopenco/llmgateway

Length of output: 722


🏁 Script executed:

#!/bin/bash
# Get the complete import from `@llmgateway/db` in admin.ts
head -n 50 apps/api/src/routes/admin.ts | grep -B 50 "from.*@llmgateway/db"

Repository: theopenco/llmgateway

Length of output: 523


🏁 Script executed:

#!/bin/bash
# Check if routingMetadata is exported from db or defined elsewhere
rg -n "export.*routingMetadata" packages/db/src/

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

#!/bin/bash
# Check what's exported from the db package
rg -n "^export" packages/db/src/index.ts | head -n 30

Repository: theopenco/llmgateway

Length of output: 426


🏁 Script executed:

#!/bin/bash
# Verify what the routingMetadata schema looks like in logs.ts is complete
rg -n -A 50 'routingMetadata: z' apps/api/src/routes/logs.ts | head -n 70

Repository: theopenco/llmgateway

Length of output: 1384


🏁 Script executed:

#!/bin/bash
# Get the context around lines 1571-1582 in admin.ts to see the full schema
sed -n '1560,1590p' apps/api/src/routes/admin.ts

Repository: theopenco/llmgateway

Length of output: 1082


Replace z.any() with properly typed schemas for errorDetails and routingMetadata.

Both fields have stable, well-defined schemas defined in the codebase:

  • errorDetails is exported from @llmgateway/db and used in logs.ts
  • routingMetadata is explicitly modeled in logs.ts with all properties

Import and reuse these schemas instead of using z.any():

♻️ Proposed fix
-	errorDetails: z.any().nullable(),
+	errorDetails: errorDetails.nullable(),
 	finishReason: z.string().nullable(),
 	unifiedFinishReason: z.string().nullable(),
 	cached: z.boolean().nullable(),
@@ -1579,7 +1579,35 @@
 	timeToFirstToken: z.number().nullable(),
 	responseSize: z.number().nullable(),
-	routingMetadata: z.any().nullable(),
+	routingMetadata: z
+		.object({
+			availableProviders: z.array(z.string()).optional(),
+			selectedProvider: z.string().optional(),
+			selectionReason: z.string().optional(),
+			providerScores: z
+				.array(
+					z.object({
+						providerId: z.string(),
+						score: z.number(),
+						uptime: z.number().optional(),
+						latency: z.number().optional(),
+						price: z.number().optional(),
+					}),
+				)
+				.optional(),
+			routing: z
+				.array(
+					z.object({
+						provider: z.string(),
+						model: z.string(),
+						status_code: z.number(),
+						error_type: z.string(),
+						succeeded: z.boolean(),
+					}),
+				)
+				.optional(),
+			noFallback: z.boolean().optional(),
+		})
+		.nullable()
+		.optional(),

Add to imports: errorDetails from @llmgateway/db

Per coding guidelines: "**/*.{ts,tsx}: Never use any or as any in TypeScript unless absolutely necessary`".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/admin.ts` around lines 1571 - 1582, Replace the loose
z.any() uses for errorDetails and routingMetadata with the concrete Zod schemas:
import errorDetails from `@llmgateway/db` and import the routingMetadata schema
from logs.ts (the same symbol used there), then change the fields in the
response schema from errorDetails: z.any().nullable() and routingMetadata:
z.any().nullable() to use the imported errorDetails and routingMetadata schemas
(preserving .nullable() if needed); also add the corresponding imports at the
top of the file so the types are reused rather than any-typed.

Comment on lines 1717 to 1723
logs: paginatedLogs.map((l) => ({
...l,
promptTokens: l.promptTokens ? String(l.promptTokens) : null,
completionTokens: l.completionTokens ? String(l.completionTokens) : null,
totalTokens: l.totalTokens ? String(l.totalTokens) : null,
reasoningTokens: l.reasoningTokens ? String(l.reasoningTokens) : null,
cachedTokens: l.cachedTokens ? String(l.cachedTokens) : null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve zero token counts when serializing logs.

These truthy checks convert valid 0 values into null, so the new log cards will drop token counts for empty/error/cached responses.

🐛 Proposed fix
-			promptTokens: l.promptTokens ? String(l.promptTokens) : null,
-			completionTokens: l.completionTokens ? String(l.completionTokens) : null,
-			totalTokens: l.totalTokens ? String(l.totalTokens) : null,
-			reasoningTokens: l.reasoningTokens ? String(l.reasoningTokens) : null,
-			cachedTokens: l.cachedTokens ? String(l.cachedTokens) : null,
+			promptTokens: l.promptTokens != null ? String(l.promptTokens) : null,
+			completionTokens:
+				l.completionTokens != null ? String(l.completionTokens) : null,
+			totalTokens: l.totalTokens != null ? String(l.totalTokens) : null,
+			reasoningTokens:
+				l.reasoningTokens != null ? String(l.reasoningTokens) : null,
+			cachedTokens: l.cachedTokens != null ? String(l.cachedTokens) : null,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logs: paginatedLogs.map((l) => ({
...l,
promptTokens: l.promptTokens ? String(l.promptTokens) : null,
completionTokens: l.completionTokens ? String(l.completionTokens) : null,
totalTokens: l.totalTokens ? String(l.totalTokens) : null,
reasoningTokens: l.reasoningTokens ? String(l.reasoningTokens) : null,
cachedTokens: l.cachedTokens ? String(l.cachedTokens) : null,
logs: paginatedLogs.map((l) => ({
...l,
promptTokens: l.promptTokens != null ? String(l.promptTokens) : null,
completionTokens:
l.completionTokens != null ? String(l.completionTokens) : null,
totalTokens: l.totalTokens != null ? String(l.totalTokens) : null,
reasoningTokens:
l.reasoningTokens != null ? String(l.reasoningTokens) : null,
cachedTokens: l.cachedTokens != null ? String(l.cachedTokens) : null,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/admin.ts` around lines 1717 - 1723, The current mapping
uses truthy checks which turn valid 0 token counts into null; update each token
field expression in the paginatedLogs.map callback (promptTokens,
completionTokens, totalTokens, reasoningTokens, cachedTokens) to check for
null/undefined instead of falsiness—e.g. replace "l.promptTokens ?
String(l.promptTokens) : null" with "l.promptTokens != null ?
String(l.promptTokens) : null" (do the same for completionTokens, totalTokens,
reasoningTokens, cachedTokens) so zero values are preserved as "0".

Comment on lines +3046 to +3081
const rows = await db
.select({
usedModel: projectHourlyModelStats.usedModel,
cost: sql<number>`SUM(${projectHourlyModelStats.cost})`.as("cost"),
requestCount:
sql<number>`SUM(${projectHourlyModelStats.requestCount})`.as(
"request_count",
),
totalTokens:
sql<number>`SUM(CAST(${projectHourlyModelStats.totalTokens} AS NUMERIC))`.as(
"total_tokens",
),
})
.from(projectHourlyModelStats)
.where(gte(projectHourlyModelStats.hourTimestamp, startDate))
.groupBy(projectHourlyModelStats.usedModel)
.orderBy(desc(sql`SUM(${projectHourlyModelStats.cost})`))
.limit(20);

const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0);
const totalRequests = rows.reduce(
(sum, r) => sum + Number(r.requestCount),
0,
);

return c.json({
window,
models: rows.map((r) => ({
model: r.usedModel,
cost: Number(r.cost),
requestCount: Number(r.requestCount),
totalTokens: Number(r.totalTokens),
})),
totalCost,
totalRequests,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Compute totals before applying the top-20 limit.

Both cost-by-model endpoints derive totalCost and totalRequests from the already-limited top 20 rows. Once an org or the global dataset has more than 20 active models, these totals will underreport the real window totals.

🐛 Proposed fix pattern
+	const [totals] = await db
+		.select({
+			totalCost: sql<number>`COALESCE(SUM(${projectHourlyModelStats.cost}), 0)`.as(
+				"total_cost",
+			),
+			totalRequests:
+				sql<number>`COALESCE(SUM(${projectHourlyModelStats.requestCount}), 0)`.as(
+					"total_requests",
+				),
+		})
+		.from(projectHourlyModelStats)
+		.where(/* same filtered predicate, but no GROUP BY / LIMIT */);
+
 	const rows = await db
 		.select({
 			usedModel: projectHourlyModelStats.usedModel,
 			cost: sql<number>`SUM(${projectHourlyModelStats.cost})`.as("cost"),
 			requestCount:
 				sql<number>`SUM(${projectHourlyModelStats.requestCount})`.as(
 					"request_count",
 				),
 			totalTokens:
 				sql<number>`SUM(CAST(${projectHourlyModelStats.totalTokens} AS NUMERIC))`.as(
 					"total_tokens",
 				),
 		})
 		.from(projectHourlyModelStats)
 		.where(/* same filtered predicate */)
 		.groupBy(projectHourlyModelStats.usedModel)
 		.orderBy(desc(sql`SUM(${projectHourlyModelStats.cost})`))
 		.limit(20);
-
-	const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0);
-	const totalRequests = rows.reduce(
-		(sum, r) => sum + Number(r.requestCount),
-		0,
-	);

 	return c.json({
 		window,
 		models: rows.map((r) => ({
 			model: r.usedModel,
 			cost: Number(r.cost),
 			requestCount: Number(r.requestCount),
 			totalTokens: Number(r.totalTokens),
 		})),
-		totalCost,
-		totalRequests,
+		totalCost: Number(totals?.totalCost ?? 0),
+		totalRequests: Number(totals?.totalRequests ?? 0),
 	});

Also applies to: 3139-3179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/admin.ts` around lines 3046 - 3081, The totals are being
computed from the already-limited top-20 result set (rows), so totalCost and
totalRequests undercount when >20 models exist; fix by running a separate
aggregate query against projectHourlyModelStats (with the same where
gte(projectHourlyModelStats.hourTimestamp, startDate)) that computes SUM(cost)
and SUM(requestCount) for the full window and use those results for
totalCost/totalRequests, then keep the existing select/groupBy/orderBy/limit
query to populate rows/models. Update references to totalCost and totalRequests
to use the aggregate query results instead of reducing the limited rows.

TableHeader,
TableRow,
} from "@/components/ui/table";
import { getProviderHistory } from "@/lib/admin-history";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ee/admin/src/lib/admin-history.ts =="
sed -n '1,120p' ee/admin/src/lib/admin-history.ts

echo
echo "== ee/admin/src/lib/server-api.ts =="
sed -n '1,240p' ee/admin/src/lib/server-api.ts

echo
echo "== server-only indicators =="
rg -n -C2 'server-only|next/headers|cookies\(|headers\(|createServerApiClient' ee/admin/src/lib

Repository: theopenco/llmgateway

Length of output: 11234


Remove or restructure this import—it violates Next.js server/client boundaries.

ee/admin/src/lib/admin-history.ts is marked "use server" and calls createServerApiClient(), which directly imports and uses next/headers.cookies() (a server-only API). A "use client" component cannot import or call server-only functions. This will fail at runtime. Either move ProvidersTable to a server component, or wrap the history fetch in a Server Action that you call from the client.

Also applies to: 99-103

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/providers-table.tsx` at line 18, The import of
getProviderHistory from ee/admin/src/lib/admin-history.ts breaks Next.js
server/client boundaries because admin-history.ts uses "use server" and
createServerApiClient (which calls next/headers.cookies()), so update
ProvidersTable to avoid importing that server-only module: either convert the
ProvidersTable component into a server component (move/rename it out of "use
client" and remove any client-only directives so it can safely import
getProviderHistory), or keep ProvidersTable as a client component and implement
a Server Action (e.g., a new server-exported async function in admin-history.ts
or a separate server-actions file) that wraps the history fetch and call that
action from the client component; reference ProvidersTable, getProviderHistory,
admin-history.ts, and createServerApiClient when making the change.

Comment on lines +80 to +88
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n ee/admin/src/components/providers-table.tsx | head -100

Repository: theopenco/llmgateway

Length of output: 3116


🏁 Script executed:

cat -n ee/admin/src/components/providers-table.tsx | sed -n '140,150p'

Repository: theopenco/llmgateway

Length of output: 447


🏁 Script executed:

cat -n ee/admin/src/components/providers-table.tsx | sed -n '75,95p' | cat -A

Repository: theopenco/llmgateway

Length of output: 808


🏁 Script executed:

cat -n ee/admin/src/components/providers-table.tsx | head -30

Repository: theopenco/llmgateway

Length of output: 1035


Stabilize the updatedAt timezone to prevent hydration mismatches.

Since this is a Client Component that renders server-side during SSR, toLocaleDateString without an explicit timeZone will use the server's timezone during initial render and the browser's timezone during hydration. If they differ, this causes hydration mismatches and potential off-by-hours displays.

Suggested fix
+const updatedAtFormatter = new Intl.DateTimeFormat("en-US", {
+	timeZone: "UTC",
+	year: "numeric",
+	month: "short",
+	day: "numeric",
+	hour: "2-digit",
+	minute: "2-digit",
+});
+
 function formatDate(dateString: string) {
-	return new Date(dateString).toLocaleDateString("en-US", {
-		year: "numeric",
-		month: "short",
-		day: "numeric",
-		hour: "2-digit",
-		minute: "2-digit",
-	});
+	return updatedAtFormatter.format(new Date(dateString));
 }

Also applies to: 144-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/providers-table.tsx` around lines 80 - 88, The
formatDate function uses toLocaleDateString without an explicit timeZone which
can cause SSR/hydration mismatches; update formatDate (and any other direct
toLocaleDateString calls) to pass a fixed timeZone (e.g., timeZone: "UTC") in
the options so the server and client render the same value, e.g., add timeZone:
"UTC" to the options object in the formatDate function and mirror the same
change for other toLocaleDateString usages in this file.

Comment thread packages/db/src/seed.ts
Comment on lines +1099 to +1110
// Pick one mapping per provider to ensure all providers have history data
const seenProviders = new Set<string>();
const topMappings: Array<Record<string, any>> = [];
for (const m of mappings) {
if (!seenProviders.has(m.providerId)) {
seenProviders.add(m.providerId);
topMappings.push(m);
}
if (topMappings.length >= 50) {
break;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fill the remaining mapping slots after the per-provider pass.

This guarantees provider coverage, but it no longer guarantees 50 seeded mapping histories. If there are fewer than 50 distinct providers, topMappings stops at provider count, so most model/provider rows still won't have history and the admin charts stay much sparser than this PR intends.

💡 Proposed fix
-	// Pick one mapping per provider to ensure all providers have history data
-	const seenProviders = new Set<string>();
-	const topMappings: Array<Record<string, any>> = [];
-	for (const m of mappings) {
-		if (!seenProviders.has(m.providerId)) {
-			seenProviders.add(m.providerId);
-			topMappings.push(m);
-		}
-		if (topMappings.length >= 50) {
-			break;
-		}
-	}
+	// First ensure every provider is represented, then fill the remaining slots up to 50.
+	const seenProviders = new Set<string>();
+	const selectedMappingIds = new Set<string>();
+	const topMappings: typeof mappings = [];
+	for (const m of mappings) {
+		if (seenProviders.has(m.providerId)) {
+			continue;
+		}
+		seenProviders.add(m.providerId);
+		selectedMappingIds.add(m.id);
+		topMappings.push(m);
+	}
+	for (const m of mappings) {
+		if (topMappings.length >= 50) {
+			break;
+		}
+		if (selectedMappingIds.has(m.id)) {
+			continue;
+		}
+		selectedMappingIds.add(m.id);
+		topMappings.push(m);
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Pick one mapping per provider to ensure all providers have history data
const seenProviders = new Set<string>();
const topMappings: Array<Record<string, any>> = [];
for (const m of mappings) {
if (!seenProviders.has(m.providerId)) {
seenProviders.add(m.providerId);
topMappings.push(m);
}
if (topMappings.length >= 50) {
break;
}
}
// First ensure every provider is represented, then fill the remaining slots up to 50.
const seenProviders = new Set<string>();
const selectedMappingIds = new Set<string>();
const topMappings: typeof mappings = [];
for (const m of mappings) {
if (seenProviders.has(m.providerId)) {
continue;
}
seenProviders.add(m.providerId);
selectedMappingIds.add(m.id);
topMappings.push(m);
}
for (const m of mappings) {
if (topMappings.length >= 50) {
break;
}
if (selectedMappingIds.has(m.id)) {
continue;
}
selectedMappingIds.add(m.id);
topMappings.push(m);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/seed.ts` around lines 1099 - 1110, The current per-provider
selection loop (using seenProviders and topMappings over mappings) ensures one
mapping per provider but can yield fewer than 50 entries; to fix, after that
loop add a second pass over mappings and append additional mappings to
topMappings until topMappings.length === 50 (skipping ones already in
seenProviders or already present) so you preserve provider coverage first and
then fill remaining slots; update the logic that builds topMappings to perform
the initial per-provider pass then a follow-up pass that fills to 50 while
honoring uniqueness and the existing seenProviders set.

@smakosh
smakosh added this pull request to the merge queue Mar 7, 2026
Merged via the queue into main with commit 7c958ab Mar 7, 2026
23 checks passed
@smakosh
smakosh deleted the feat/admin-dashboard-enhancements branch March 7, 2026 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants