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:
@@ -538,6 +538,7 @@ export async function handleChatCore({
|
|||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
connectionId,
|
connectionId,
|
||||||
|
apiKey,
|
||||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
@@ -676,6 +677,7 @@ export async function handleChatCore({
|
|||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
connectionId,
|
connectionId,
|
||||||
|
apiKey,
|
||||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
|||||||
|
|
||||||
const totalLatency = Date.now() - requestStartTime;
|
const totalLatency = Date.now() - requestStartTime;
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
provider, model, connectionId,
|
provider, model, connectionId, apiKey,
|
||||||
latency: { ttft: totalLatency, total: totalLatency },
|
latency: { ttft: totalLatency, total: totalLatency },
|
||||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
|
|||||||
@@ -57,11 +57,21 @@ export function extractUsageFromResponse(responseBody) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mask API keys before they reach the requestDetails data blob / DB column.
|
||||||
|
// Only the prefix is kept — enough to distinguish keys without leaking them.
|
||||||
|
export function maskApiKey(key) {
|
||||||
|
if (!key || typeof key !== "string") return undefined;
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (trimmed.length <= 8) return trimmed.charAt(0) + "***";
|
||||||
|
return trimmed.slice(0, 8) + "***";
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRequestDetail(base, overrides = {}) {
|
export function buildRequestDetail(base, overrides = {}) {
|
||||||
return {
|
return {
|
||||||
provider: base.provider || "unknown",
|
provider: base.provider || "unknown",
|
||||||
model: base.model || "unknown",
|
model: base.model || "unknown",
|
||||||
connectionId: base.connectionId || undefined,
|
connectionId: base.connectionId || undefined,
|
||||||
|
apiKey: maskApiKey(base.apiKey),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
latency: base.latency || { ttft: 0, total: 0 },
|
latency: base.latency || { ttft: 0, total: 0 },
|
||||||
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export async function handleStreamingResponse({
|
|||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
connectionId,
|
connectionId,
|
||||||
|
apiKey,
|
||||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
@@ -293,6 +294,7 @@ export function buildOnStreamComplete({
|
|||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
connectionId,
|
connectionId,
|
||||||
|
apiKey,
|
||||||
latency,
|
latency,
|
||||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
|
|||||||
@@ -114,9 +114,19 @@ export default function RequestDetailsTab() {
|
|||||||
const [providerNameCache, setProviderNameCache] = useState(null);
|
const [providerNameCache, setProviderNameCache] = useState(null);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
provider: "",
|
provider: "",
|
||||||
|
model: "",
|
||||||
|
status: "",
|
||||||
|
connectionId: "",
|
||||||
|
apiKey: "",
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: ""
|
endDate: ""
|
||||||
});
|
});
|
||||||
|
const [filterOptions, setFilterOptions] = useState({
|
||||||
|
models: [],
|
||||||
|
apiKeys: [],
|
||||||
|
statuses: [],
|
||||||
|
connections: []
|
||||||
|
});
|
||||||
|
|
||||||
const fetchProviders = useCallback(async () => {
|
const fetchProviders = useCallback(async () => {
|
||||||
try {
|
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 () => {
|
const fetchDetails = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +164,10 @@ export default function RequestDetailsTab() {
|
|||||||
pageSize: pagination.pageSize.toString()
|
pageSize: pagination.pageSize.toString()
|
||||||
});
|
});
|
||||||
if (filters.provider) params.append("provider", filters.provider);
|
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.startDate) params.append("startDate", filters.startDate);
|
||||||
if (filters.endDate) params.append("endDate", filters.endDate);
|
if (filters.endDate) params.append("endDate", filters.endDate);
|
||||||
|
|
||||||
@@ -156,7 +185,8 @@ export default function RequestDetailsTab() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProviders();
|
fetchProviders();
|
||||||
}, [fetchProviders]);
|
fetchFilterOptions();
|
||||||
|
}, [fetchProviders, fetchFilterOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDetails();
|
fetchDetails();
|
||||||
@@ -176,7 +206,7 @@ export default function RequestDetailsTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
const handleClearFilters = () => {
|
||||||
setFilters({ provider: "", startDate: "", endDate: "" });
|
setFilters({ provider: "", model: "", status: "", connectionId: "", apiKey: "", startDate: "", endDate: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -204,6 +234,98 @@ export default function RequestDetailsTab() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<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>
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleClearFilters}
|
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"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Clear Filters
|
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">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">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">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">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">Cached</th>
|
||||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Cache Creation</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)}
|
{getProviderName(detail.provider, providerNameCache)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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">
|
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||||
{getInputTokens(detail.tokens).toLocaleString()}
|
{getInputTokens(detail.tokens).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
66
src/app/api/usage/filters/route.js
Normal file
66
src/app/api/usage/filters/route.js
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export async function GET(request) {
|
|||||||
const model = searchParams.get("model");
|
const model = searchParams.get("model");
|
||||||
const connectionId = searchParams.get("connectionId");
|
const connectionId = searchParams.get("connectionId");
|
||||||
const status = searchParams.get("status");
|
const status = searchParams.get("status");
|
||||||
|
const apiKey = searchParams.get("apiKey");
|
||||||
const startDate = searchParams.get("startDate");
|
const startDate = searchParams.get("startDate");
|
||||||
const endDate = searchParams.get("endDate");
|
const endDate = searchParams.get("endDate");
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export async function GET(request) {
|
|||||||
if (model) filter.model = model;
|
if (model) filter.model = model;
|
||||||
if (connectionId) filter.connectionId = connectionId;
|
if (connectionId) filter.connectionId = connectionId;
|
||||||
if (status) filter.status = status;
|
if (status) filter.status = status;
|
||||||
|
if (apiKey) filter.apiKey = apiKey;
|
||||||
if (startDate) filter.startDate = startDate;
|
if (startDate) filter.startDate = startDate;
|
||||||
if (endDate) filter.endDate = endDate;
|
if (endDate) filter.endDate = endDate;
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export {
|
|||||||
// Request details
|
// Request details
|
||||||
export {
|
export {
|
||||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||||
|
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
|
||||||
} from "./repos/requestDetailsRepo.js";
|
} from "./repos/requestDetailsRepo.js";
|
||||||
|
|
||||||
// Export/import full DB
|
// Export/import full DB
|
||||||
|
|||||||
14
src/lib/db/migrations/002-request-details-apikey.js
Normal file
14
src/lib/db/migrations/002-request-details-apikey.js
Normal 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)`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
// Each migration: { version: number, name: string, up(db): void }
|
// Each migration: { version: number, name: string, up(db): void }
|
||||||
// Versions MUST be unique and monotonically increasing.
|
// Versions MUST be unique and monotonically increasing.
|
||||||
import m001 from "./001-initial.js";
|
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() {
|
export function latestVersion() {
|
||||||
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ async function flushToDatabase() {
|
|||||||
connectionId: item.connectionId || null,
|
connectionId: item.connectionId || null,
|
||||||
timestamp: item.timestamp,
|
timestamp: item.timestamp,
|
||||||
status: item.status || null,
|
status: item.status || null,
|
||||||
|
apiKey: item.apiKey || null,
|
||||||
latency: item.latency || {},
|
latency: item.latency || {},
|
||||||
tokens: item.tokens || {},
|
tokens: item.tokens || {},
|
||||||
request: truncateField(item.request, config.maxJsonSize),
|
request: truncateField(item.request, config.maxJsonSize),
|
||||||
@@ -102,8 +103,8 @@ async function flushToDatabase() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.run(
|
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`,
|
`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, stringifyJson(record)]
|
[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.model) { conds.push("model = ?"); params.push(filter.model); }
|
||||||
if (filter.connectionId) { conds.push("connectionId = ?"); params.push(filter.connectionId); }
|
if (filter.connectionId) { conds.push("connectionId = ?"); params.push(filter.connectionId); }
|
||||||
if (filter.status) { conds.push("status = ?"); params.push(filter.status); }
|
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.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); }
|
||||||
if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).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);
|
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) {
|
export async function getRequestDetailById(id) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);
|
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
// 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.
|
// 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 = `
|
export const PRAGMA_SQL = `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
@@ -144,6 +144,7 @@ export const TABLES = {
|
|||||||
model: "TEXT",
|
model: "TEXT",
|
||||||
connectionId: "TEXT",
|
connectionId: "TEXT",
|
||||||
status: "TEXT",
|
status: "TEXT",
|
||||||
|
apiKey: "TEXT",
|
||||||
data: "TEXT NOT NULL",
|
data: "TEXT NOT NULL",
|
||||||
},
|
},
|
||||||
indexes: [
|
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_provider ON requestDetails(provider)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rd_model ON requestDetails(model)",
|
"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_conn ON requestDetails(connectionId)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_rd_apikey ON requestDetails(apiKey)",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
|
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
|
||||||
export {
|
export {
|
||||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||||
|
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
|
||||||
} from "@/lib/db/index.js";
|
} from "@/lib/db/index.js";
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export {
|
|||||||
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
|
saveRequestUsage, getUsageHistory, getUsageStats, getChartData,
|
||||||
appendRequestLog, getRecentLogs,
|
appendRequestLog, getRecentLogs,
|
||||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
||||||
|
getDistinctModels, getDistinctApiKeys, getDistinctStatuses,
|
||||||
} from "@/lib/db/index.js";
|
} from "@/lib/db/index.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user