small visual adjustments and chat button

This commit is contained in:
Owen
2026-08-11 15:57:57 -04:00
parent e3ccc4f8d4
commit e734cc93a1
6 changed files with 113 additions and 50 deletions
+2
View File
@@ -3405,6 +3405,8 @@
"aiSessionNoData": "No data captured", "aiSessionNoData": "No data captured",
"aiSessionCouldNotParse": "(raw, could not parse transcript)", "aiSessionCouldNotParse": "(raw, could not parse transcript)",
"aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.", "aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.",
"aiSessionViewRaw": "View Raw JSON",
"aiSessionViewChat": "View Chat",
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
"logRetentionRequestLabel": "HTTP Request Log Retention", "logRetentionRequestLabel": "HTTP Request Log Retention",
"logRetentionRequestDescription": "How long to retain request logs", "logRetentionRequestDescription": "How long to retain request logs",
+17 -2
View File
@@ -66,6 +66,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
.pipe(z.int().positive()) .pipe(z.int().positive())
.optional(), .optional(),
actor: z.string().optional(), actor: z.string().optional(),
model: z.string().optional(),
isStream: z isStream: z
.union([z.boolean(), z.string()]) .union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val)) .transform((val) => (typeof val === "string" ? val === "true" : val))
@@ -123,6 +124,9 @@ function getWhere(data: Q) {
) )
: undefined, : undefined,
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined, data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
data.model
? eq(aiSessionLog.requestedModel, data.model)
: undefined,
data.isStream !== undefined data.isStream !== undefined
? eq(aiSessionLog.isStream, data.isStream) ? eq(aiSessionLog.isStream, data.isStream)
: undefined : undefined
@@ -333,7 +337,8 @@ async function queryUniqueFilterAttributes(
uniqueProviders, uniqueProviders,
uniqueUsers, uniqueUsers,
uniqueResources, uniqueResources,
uniqueSiteResources uniqueSiteResources,
uniqueModels
] = await Promise.all([ ] = await Promise.all([
logsDb logsDb
.selectDistinct({ id: aiSessionLog.providerId }) .selectDistinct({ id: aiSessionLog.providerId })
@@ -354,9 +359,18 @@ async function queryUniqueFilterAttributes(
.selectDistinct({ id: aiSessionLog.siteResourceId }) .selectDistinct({ id: aiSessionLog.siteResourceId })
.from(aiSessionLog) .from(aiSessionLog)
.where(and(baseConditions, isNull(aiSessionLog.resourceId))) .where(and(baseConditions, isNull(aiSessionLog.resourceId)))
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ model: aiSessionLog.requestedModel })
.from(aiSessionLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1) .limit(DISTINCT_LIMIT + 1)
]); ]);
const models = uniqueModels
.map((row) => row.model)
.filter((model): model is string => model !== null);
const providerIds = uniqueProviders const providerIds = uniqueProviders
.map((row) => row.id) .map((row) => row.id)
.filter((id): id is number => id !== null); .filter((id): id is number => id !== null);
@@ -440,7 +454,8 @@ async function queryUniqueFilterAttributes(
return { return {
providers: sortNamedFilterOptions(providers), providers: sortNamedFilterOptions(providers),
resources: sortNamedFilterOptions(resourcesWithNames), resources: sortNamedFilterOptions(resourcesWithNames),
users: userList users: userList,
models: models.sort()
}; };
} }
+1
View File
@@ -138,6 +138,7 @@ export type QueryAiSessionLogResponse = {
id: string; id: string;
email: string | null; email: string | null;
}[]; }[];
models: string[];
}; };
}; };
+24 -21
View File
@@ -50,12 +50,14 @@ export default function AiSessionLogsPage() {
capability?: string; capability?: string;
resourceId?: string; resourceId?: string;
actor?: string; actor?: string;
model?: string;
isStream?: string; isStream?: string;
}>({ }>({
providerId: searchParams.get("providerId") || undefined, providerId: searchParams.get("providerId") || undefined,
capability: searchParams.get("capability") || undefined, capability: searchParams.get("capability") || undefined,
resourceId: searchParams.get("resourceId") || undefined, resourceId: searchParams.get("resourceId") || undefined,
actor: searchParams.get("actor") || undefined, actor: searchParams.get("actor") || undefined,
model: searchParams.get("model") || undefined,
isStream: searchParams.get("isStream") || undefined isStream: searchParams.get("isStream") || undefined
}); });
@@ -132,7 +134,8 @@ export default function AiSessionLogsPage() {
const filterAttributes = data?.filterAttributes ?? { const filterAttributes = data?.filterAttributes ?? {
providers: [], providers: [],
resources: [], resources: [],
users: [] users: [],
models: []
}; };
const handleDateRangeChange = ( const handleDateRangeChange = (
@@ -309,15 +312,27 @@ export default function AiSessionLogsPage() {
}, },
{ {
accessorKey: "requestedModel", accessorKey: "requestedModel",
header: ({ column }) => ( header: ({ column }) => {
<span className="px-2">{t("model")}</span>
),
cell: ({ row }) => {
return ( return (
<span className="text-xs text-muted-foreground"> <div className="flex items-center gap-2 px-2">
{row.original.requestedModel || "-"} <ColumnFilterButton
</span> options={filterAttributes.models.map((model) => ({
value: model,
label: model
}))}
selectedValue={filters.model}
onValueChange={(value) =>
handleFilterChange("model", value)
}
label={t("model")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
); );
},
cell: ({ row }) => {
return <span>{row.original.requestedModel || "-"}</span>;
} }
}, },
{ {
@@ -444,7 +459,7 @@ export default function AiSessionLogsPage() {
const renderExpandedRow = (row: any) => { const renderExpandedRow = (row: any) => {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs"> <div className="grid grid-cols-2 gap-4 text-xs">
<div> <div>
<strong>{t("aiSessionId")}</strong> <strong>{t("aiSessionId")}</strong>
<p className="text-muted-foreground mt-1 break-all"> <p className="text-muted-foreground mt-1 break-all">
@@ -457,18 +472,6 @@ export default function AiSessionLogsPage() {
{row.statusCode ?? "N/A"} {row.statusCode ?? "N/A"}
</p> </p>
</div> </div>
<div>
<strong>{t("model")}</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.requestedModel || "N/A"}
</p>
</div>
<div>
<strong>{t("capability")}</strong>
<p className="text-muted-foreground mt-1 break-all">
{capabilityLabels[row.capability] || row.capability}
</p>
</div>
</div> </div>
<AiSessionChatView <AiSessionChatView
normalizedRequest={row.normalizedRequest} normalizedRequest={row.normalizedRequest}
+68 -27
View File
@@ -1,8 +1,17 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { AlertTriangle, Bot, Terminal, User as UserIcon, Wrench } from "lucide-react"; import {
AlertTriangle,
Bot,
Code,
MessagesSquare,
Terminal,
User as UserIcon,
Wrench
} from "lucide-react";
import { Button } from "@app/components/ui/button";
import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization"; import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization";
type AiSessionChatViewProps = { type AiSessionChatViewProps = {
@@ -95,14 +104,14 @@ function RawFallbackBlock({
label: string; label: string;
raw: string | null; raw: string | null;
noDataLabel: string; noDataLabel: string;
unparsedLabel: string; unparsedLabel?: string;
}) { }) {
const pretty = prettyRaw(raw); const pretty = prettyRaw(raw);
return ( return (
<div className="rounded-md border bg-muted/30 p-3"> <div className="rounded-md border bg-muted/30 p-3">
<div className="mb-1 text-xs font-medium text-muted-foreground"> <div className="mb-1 text-xs font-medium text-muted-foreground">
{label} {label}
{pretty && ( {pretty && unparsedLabel && (
<span className="ml-2 font-normal italic opacity-70"> <span className="ml-2 font-normal italic opacity-70">
{unparsedLabel} {unparsedLabel}
</span> </span>
@@ -123,6 +132,7 @@ export function AiSessionChatView({
truncated truncated
}: AiSessionChatViewProps) { }: AiSessionChatViewProps) {
const t = useTranslations(); const t = useTranslations();
const [rawMode, setRawMode] = useState(false);
const requestMessages = useMemo( const requestMessages = useMemo(
() => parseMessages(normalizedRequest), () => parseMessages(normalizedRequest),
@@ -139,38 +149,69 @@ export function AiSessionChatView({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{truncated && ( <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-500"> {truncated ? (
<AlertTriangle className="h-3.5 w-3.5 flex-none" /> <div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-500">
{t("aiSessionLogTruncated")} <AlertTriangle className="h-3.5 w-3.5 flex-none" />
</div> {t("aiSessionLogTruncated")}
)} </div>
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
{hasRequestMessages ? (
requestMessages!.map((message, i) => (
<MessageBubble key={`req-${i}`} message={message} />
))
) : ( ) : (
<div />
)}
<Button
variant="outline"
size="sm"
onClick={() => setRawMode((prev) => !prev)}
>
{rawMode ? (
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
) : (
<Code className="mr-2 h-3.5 w-3.5" />
)}
{rawMode ? t("aiSessionViewChat") : t("aiSessionViewRaw")}
</Button>
</div>
{rawMode ? (
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
<RawFallbackBlock <RawFallbackBlock
label={t("aiSessionRequest")} label={t("aiSessionRequest")}
raw={requestBody} raw={normalizedRequest}
noDataLabel={t("aiSessionNoData")} noDataLabel={t("aiSessionNoData")}
unparsedLabel={t("aiSessionCouldNotParse")}
/> />
)}
{hasResponseMessages ? (
responseMessages!.map((message, i) => (
<MessageBubble key={`res-${i}`} message={message} />
))
) : (
<RawFallbackBlock <RawFallbackBlock
label={t("aiSessionResponse")} label={t("aiSessionResponse")}
raw={responseBody} raw={normalizedResponse}
noDataLabel={t("aiSessionNoData")} noDataLabel={t("aiSessionNoData")}
unparsedLabel={t("aiSessionCouldNotParse")}
/> />
)} </div>
</div> ) : (
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
{hasRequestMessages ? (
requestMessages!.map((message, i) => (
<MessageBubble key={`req-${i}`} message={message} />
))
) : (
<RawFallbackBlock
label={t("aiSessionRequest")}
raw={requestBody}
noDataLabel={t("aiSessionNoData")}
unparsedLabel={t("aiSessionCouldNotParse")}
/>
)}
{hasResponseMessages ? (
responseMessages!.map((message, i) => (
<MessageBubble key={`res-${i}`} message={message} />
))
) : (
<RawFallbackBlock
label={t("aiSessionResponse")}
raw={responseBody}
noDataLabel={t("aiSessionNoData")}
unparsedLabel={t("aiSessionCouldNotParse")}
/>
)}
</div>
)}
</div> </div>
); );
} }
+1
View File
@@ -1053,6 +1053,7 @@ export const aiSessionLogsFiltersSchema = z.object({
capability: z.string().optional().catch(undefined), capability: z.string().optional().catch(undefined),
resourceId: z.string().optional().catch(undefined), resourceId: z.string().optional().catch(undefined),
actor: z.string().optional().catch(undefined), actor: z.string().optional().catch(undefined),
model: z.string().optional().catch(undefined),
isStream: z.string().optional().catch(undefined) isStream: z.string().optional().catch(undefined)
}); });