feat(dashboard): add model/status/account/api-key filters to usage details tab

- Add apiKey column to requestDetails (schema v2 + migration 002)
- Persist masked API key via buildRequestDetail across chatCore handlers
- Add getRequestDetails apiKey filter + distinct models/apiKeys/statuses helpers
- New /api/usage/filters endpoint returning grouped connections per provider
- Group account dropdown by provider using <optgroup>; add Status column
  with success/error badge to the details table

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-08-06 09:41:03 +07:00
parent 2a37a4085e
commit 386b25ff7f
14 changed files with 267 additions and 8 deletions

View File

@@ -114,9 +114,19 @@ export default function RequestDetailsTab() {
const [providerNameCache, setProviderNameCache] = useState(null);
const [filters, setFilters] = useState({
provider: "",
model: "",
status: "",
connectionId: "",
apiKey: "",
startDate: "",
endDate: ""
});
const [filterOptions, setFilterOptions] = useState({
models: [],
apiKeys: [],
statuses: [],
connections: []
});
const fetchProviders = useCallback(async () => {
try {
@@ -131,6 +141,21 @@ export default function RequestDetailsTab() {
}
}, []);
const fetchFilterOptions = useCallback(async () => {
try {
const res = await fetch("/api/usage/filters");
const data = await res.json();
setFilterOptions({
models: data.models || [],
apiKeys: data.apiKeys || [],
statuses: data.statuses || [],
connections: data.connections || []
});
} catch (error) {
console.error("Failed to fetch filter options:", error);
}
}, []);
const fetchDetails = useCallback(async () => {
setLoading(true);
try {
@@ -139,6 +164,10 @@ export default function RequestDetailsTab() {
pageSize: pagination.pageSize.toString()
});
if (filters.provider) params.append("provider", filters.provider);
if (filters.model) params.append("model", filters.model);
if (filters.status) params.append("status", filters.status);
if (filters.connectionId) params.append("connectionId", filters.connectionId);
if (filters.apiKey) params.append("apiKey", filters.apiKey);
if (filters.startDate) params.append("startDate", filters.startDate);
if (filters.endDate) params.append("endDate", filters.endDate);
@@ -156,7 +185,8 @@ export default function RequestDetailsTab() {
useEffect(() => {
fetchProviders();
}, [fetchProviders]);
fetchFilterOptions();
}, [fetchProviders, fetchFilterOptions]);
useEffect(() => {
fetchDetails();
@@ -176,7 +206,7 @@ export default function RequestDetailsTab() {
};
const handleClearFilters = () => {
setFilters({ provider: "", startDate: "", endDate: "" });
setFilters({ provider: "", model: "", status: "", connectionId: "", apiKey: "", startDate: "", endDate: "" });
};
return (
@@ -204,6 +234,98 @@ export default function RequestDetailsTab() {
))}
</select>
</div>
<div className="flex min-w-0 flex-col gap-2">
<label htmlFor="model-filter" className="text-sm font-medium text-text-main">Model</label>
<select
id="model-filter"
value={filters.model}
onChange={(e) => setFilters({ ...filters, model: e.target.value })}
className={cn(
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
"w-full min-w-0 cursor-pointer"
)}
style={{ colorScheme: 'auto' }}
>
<option value="">All Models</option>
{filterOptions.models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
<div className="flex min-w-0 flex-col gap-2">
<label htmlFor="status-filter" className="text-sm font-medium text-text-main">Status</label>
<select
id="status-filter"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className={cn(
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
"w-full min-w-0 cursor-pointer"
)}
style={{ colorScheme: 'auto' }}
>
<option value="">All Statuses</option>
{filterOptions.statuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
<div className="flex min-w-0 flex-col gap-2">
<label htmlFor="connection-filter" className="text-sm font-medium text-text-main">Account</label>
<select
id="connection-filter"
value={filters.connectionId}
onChange={(e) => setFilters({ ...filters, connectionId: e.target.value })}
className={cn(
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
"w-full min-w-0 cursor-pointer"
)}
style={{ colorScheme: 'auto' }}
>
<option value="">All Accounts</option>
{filterOptions.connections.map((group) => (
<optgroup key={group.provider} label={group.name}>
{group.accounts.map((conn) => (
<option key={conn.id} value={conn.id}>
{conn.label}
</option>
))}
</optgroup>
))}
</select>
</div>
<div className="flex min-w-0 flex-col gap-2">
<label htmlFor="apikey-filter" className="text-sm font-medium text-text-main">API Key</label>
<select
id="apikey-filter"
value={filters.apiKey}
onChange={(e) => setFilters({ ...filters, apiKey: e.target.value })}
className={cn(
"h-9 px-3 rounded-lg border border-black/10 dark:border-white/10 bg-surface",
"text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20",
"w-full min-w-0 cursor-pointer"
)}
style={{ colorScheme: 'auto' }}
>
<option value="">All API Keys</option>
{filterOptions.apiKeys.map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</div>
<div className="flex min-w-0 flex-col gap-2">
<label htmlFor="start-date-filter" className="text-sm font-medium text-text-main">Start Date</label>
@@ -238,7 +360,7 @@ export default function RequestDetailsTab() {
<Button
variant="ghost"
onClick={handleClearFilters}
disabled={!filters.provider && !filters.startDate && !filters.endDate}
disabled={!filters.provider && !filters.model && !filters.status && !filters.connectionId && !filters.apiKey && !filters.startDate && !filters.endDate}
className="w-full"
>
Clear Filters
@@ -255,6 +377,7 @@ export default function RequestDetailsTab() {
<th className="text-left p-4 text-sm font-semibold text-text-main">Timestamp</th>
<th className="text-left p-4 text-sm font-semibold text-text-main">Model</th>
<th className="text-left p-4 text-sm font-semibold text-text-main">Provider</th>
<th className="text-left p-4 text-sm font-semibold text-text-main">Status</th>
<th className="text-right p-4 text-sm font-semibold text-text-main">Input Tokens</th>
<th className="text-right p-4 text-sm font-semibold text-text-main">Cached</th>
<th className="text-right p-4 text-sm font-semibold text-text-main">Cache Creation</th>
@@ -296,6 +419,20 @@ export default function RequestDetailsTab() {
{getProviderName(detail.provider, providerNameCache)}
</span>
</td>
<td className="p-4 text-sm">
<span className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
detail.status === "error"
? "bg-red-500/10 text-red-600"
: "bg-green-500/10 text-green-600"
)}>
<span className={cn(
"h-1.5 w-1.5 rounded-full",
detail.status === "error" ? "bg-red-500" : "bg-green-500"
)} />
{detail.status === "error" ? "Error" : "Success"}
</span>
</td>
<td className="p-4 text-sm text-text-main text-right font-mono">
{getInputTokens(detail.tokens).toLocaleString()}
</td>

View File

@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import {
getDistinctModels,
getDistinctApiKeys,
getDistinctStatuses,
} from "@/lib/requestDetailsDb";
import { getProviderConnections, getProviderNodes } from "@/lib/localDb";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
/**
* GET /api/usage/filters
* Returns option lists for the Usage → Details filter bar:
* models: distinct model ids seen in requestDetails
* apiKeys: distinct (masked) api keys seen in requestDetails
* statuses: distinct status values (success/error)
* connections: provider connections grouped by provider (for <optgroup>)
*/
export async function GET() {
try {
const [models, apiKeys, statuses, connections, nodes] = await Promise.all([
getDistinctModels(),
getDistinctApiKeys(),
getDistinctStatuses(),
getProviderConnections(),
getProviderNodes(),
]);
const nodeNameMap = {};
for (const node of nodes || []) {
if (node.id && node.name) nodeNameMap[node.id] = node.name;
}
const byProvider = {};
for (const c of connections) {
const isCompatible =
isOpenAICompatibleProvider(c.provider) ||
isAnthropicCompatibleProvider(c.provider);
const baseName = isCompatible
? c.name || nodeNameMap[c.provider] || c.providerSpecificData?.nodeName
: c.name;
const providerName =
getProviderByAlias(c.provider)?.name || AI_PROVIDERS[c.provider]?.name || c.provider || "unknown";
const key = c.provider || "unknown";
if (!byProvider[key]) {
byProvider[key] = { provider: key, name: providerName, accounts: [] };
}
byProvider[key].accounts.push({
id: c.id,
label: baseName || providerName,
});
}
const connectionsList = Object.values(byProvider).sort((a, b) =>
a.name.localeCompare(b.name)
);
return NextResponse.json({ models, apiKeys, statuses, connections: connectionsList });
} catch (error) {
console.error("[API] Failed to get usage filters:", error);
return NextResponse.json(
{ error: "Failed to fetch usage filters" },
{ status: 500 }
);
}
}

View File

@@ -17,6 +17,7 @@ export async function GET(request) {
const model = searchParams.get("model");
const connectionId = searchParams.get("connectionId");
const status = searchParams.get("status");
const apiKey = searchParams.get("apiKey");
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
@@ -43,6 +44,7 @@ export async function GET(request) {
if (model) filter.model = model;
if (connectionId) filter.connectionId = connectionId;
if (status) filter.status = status;
if (apiKey) filter.apiKey = apiKey;
if (startDate) filter.startDate = startDate;
if (endDate) filter.endDate = endDate;

View File

@@ -65,6 +65,7 @@ export {
// Request details
export {
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
} from "./repos/requestDetailsRepo.js";
// Export/import full DB

View File

@@ -0,0 +1,14 @@
// Adds an indexed apiKey column to requestDetails so the Details tab can
// filter by (masked) API key without parsing the JSON data blob.
export default {
version: 2,
name: "request-details-apikey",
up(db) {
// Idempotent: fresh DBs already get the column from TABLES schema.
const existing = db.all(`PRAGMA table_info(requestDetails)`) || [];
if (!existing.some((c) => c.name === "apiKey")) {
db.exec(`ALTER TABLE requestDetails ADD COLUMN apiKey TEXT`);
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_rd_apikey ON requestDetails(apiKey)`);
},
};

View File

@@ -2,8 +2,9 @@
// Each migration: { version: number, name: string, up(db): void }
// Versions MUST be unique and monotonically increasing.
import m001 from "./001-initial.js";
import m002 from "./002-request-details-apikey.js";
export const MIGRATIONS = [m001].sort((a, b) => a.version - b.version);
export const MIGRATIONS = [m001, m002].sort((a, b) => a.version - b.version);
export function latestVersion() {
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;

View File

@@ -92,6 +92,7 @@ async function flushToDatabase() {
connectionId: item.connectionId || null,
timestamp: item.timestamp,
status: item.status || null,
apiKey: item.apiKey || null,
latency: item.latency || {},
tokens: item.tokens || {},
request: truncateField(item.request, config.maxJsonSize),
@@ -102,8 +103,8 @@ async function flushToDatabase() {
};
db.run(
`INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, data) VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, provider = excluded.provider, model = excluded.model, connectionId = excluded.connectionId, status = excluded.status, data = excluded.data`,
[record.id, record.timestamp, record.provider, record.model, record.connectionId, record.status, stringifyJson(record)]
`INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, apiKey, data) VALUES(?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, provider = excluded.provider, model = excluded.model, connectionId = excluded.connectionId, status = excluded.status, apiKey = excluded.apiKey, data = excluded.data`,
[record.id, record.timestamp, record.provider, record.model, record.connectionId, record.status, record.apiKey, stringifyJson(record)]
);
}
@@ -151,6 +152,7 @@ export async function getRequestDetails(filter = {}) {
if (filter.model) { conds.push("model = ?"); params.push(filter.model); }
if (filter.connectionId) { conds.push("connectionId = ?"); params.push(filter.connectionId); }
if (filter.status) { conds.push("status = ?"); params.push(filter.status); }
if (filter.apiKey) { conds.push("apiKey = ?"); params.push(filter.apiKey); }
if (filter.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); }
if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).toISOString()); }
@@ -181,6 +183,24 @@ export async function getDistinctProviders() {
return rows.map((r) => r.provider);
}
export async function getDistinctModels() {
const db = await getAdapter();
const rows = db.all(`SELECT DISTINCT model FROM requestDetails WHERE model IS NOT NULL AND model != '' ORDER BY model ASC`);
return rows.map((r) => r.model);
}
export async function getDistinctApiKeys() {
const db = await getAdapter();
const rows = db.all(`SELECT DISTINCT apiKey FROM requestDetails WHERE apiKey IS NOT NULL AND apiKey != '' ORDER BY apiKey ASC`);
return rows.map((r) => r.apiKey);
}
export async function getDistinctStatuses() {
const db = await getAdapter();
const rows = db.all(`SELECT DISTINCT status FROM requestDetails WHERE status IS NOT NULL AND status != '' ORDER BY status ASC`);
return rows.map((r) => r.status);
}
export async function getRequestDetailById(id) {
const db = await getAdapter();
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);

View File

@@ -3,7 +3,7 @@
// pre-change safety backup in migrate.js: when the stored version is lower,
// one lightweight DB backup is taken before applying schema changes. Forgetting
// to bump only skips that backup — it does NOT break the additive auto-sync.
export const SCHEMA_VERSION = 1;
export const SCHEMA_VERSION = 2;
export const PRAGMA_SQL = `
PRAGMA journal_mode = WAL;
@@ -144,6 +144,7 @@ export const TABLES = {
model: "TEXT",
connectionId: "TEXT",
status: "TEXT",
apiKey: "TEXT",
data: "TEXT NOT NULL",
},
indexes: [
@@ -151,6 +152,7 @@ export const TABLES = {
"CREATE INDEX IF NOT EXISTS idx_rd_provider ON requestDetails(provider)",
"CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)",
"CREATE INDEX IF NOT EXISTS idx_rd_conn ON requestDetails(connectionId)",
"CREATE INDEX IF NOT EXISTS idx_rd_apikey ON requestDetails(apiKey)",
],
},
};

View File

@@ -1,4 +1,5 @@
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
export {
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
} from "@/lib/db/index.js";

View File

@@ -4,4 +4,5 @@ export {
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
appendRequestLog, getRecentLogs,
saveRequestDetail, getRequestDetails, getRequestDetailById,
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
} from "@/lib/db/index.js";