mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 23:40:40 +02:00
show the sessions in the ui
This commit is contained in:
@@ -3388,6 +3388,23 @@
|
|||||||
"logRetention": "Log Retention",
|
"logRetention": "Log Retention",
|
||||||
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
|
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
|
||||||
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
|
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
|
||||||
|
"aiSessionLogs": "AI Gateway Session Logs",
|
||||||
|
"aiSessionLogsDescription": "View prompt and response transcripts for AI gateway requests in this organization",
|
||||||
|
"sidebarLogsAi": "AI Session Logs",
|
||||||
|
"commandLogsAi": "AI Session Logs",
|
||||||
|
"provider": "Provider",
|
||||||
|
"capability": "Capability",
|
||||||
|
"model": "Model",
|
||||||
|
"stream": "Stream",
|
||||||
|
"streaming": "Streaming",
|
||||||
|
"nonStreaming": "Non-streaming",
|
||||||
|
"statusCode": "Status Code",
|
||||||
|
"aiSessionId": "Session ID",
|
||||||
|
"aiSessionRequest": "Request",
|
||||||
|
"aiSessionResponse": "Response",
|
||||||
|
"aiSessionNoData": "No data captured",
|
||||||
|
"aiSessionCouldNotParse": "(raw, could not parse transcript)",
|
||||||
|
"aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { registry } from "@server/openApi";
|
||||||
|
import { NextFunction } from "express";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { OpenAPITags } from "@server/openApi";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { z } from "zod";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import {
|
||||||
|
queryAiSessionLogsQuery,
|
||||||
|
queryAiSessionLogsParams,
|
||||||
|
queryAiSession,
|
||||||
|
countAiSessionQuery
|
||||||
|
} from "./queryAiSessionLog";
|
||||||
|
import { generateCSV } from "./generateCSV";
|
||||||
|
|
||||||
|
const MAX_EXPORT_LIMIT = 50_000;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/logs/ai/export",
|
||||||
|
description: "Export the AI gateway session log for an organization as CSV",
|
||||||
|
tags: [OpenAPITags.Logs],
|
||||||
|
request: {
|
||||||
|
query: queryAiSessionLogsQuery.omit({
|
||||||
|
limit: true,
|
||||||
|
offset: true
|
||||||
|
}),
|
||||||
|
params: queryAiSessionLogsParams
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function exportAiSessionLogs(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = queryAiSessionLogsQuery.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = queryAiSessionLogsParams.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
|
const [{ count }] = await countAiSessionQuery(data);
|
||||||
|
if (count > MAX_EXPORT_LIMIT) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryAiSession(data);
|
||||||
|
|
||||||
|
const log = await baseQuery.limit(MAX_EXPORT_LIMIT);
|
||||||
|
|
||||||
|
const csvData = generateCSV(log);
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/csv");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="ai-session-logs-${data.orgId}-${Date.now()}.csv"`
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.send(csvData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from "./queryRequestAuditLog";
|
export * from "./queryRequestAuditLog";
|
||||||
export * from "./queryRequestAnalytics";
|
export * from "./queryRequestAnalytics";
|
||||||
export * from "./exportRequestAuditLog";
|
export * from "./exportRequestAuditLog";
|
||||||
|
export * from "./queryAiSessionLog";
|
||||||
|
export * from "./exportAiSessionLog";
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import {
|
||||||
|
logsDb,
|
||||||
|
aiSessionLog,
|
||||||
|
aiProviders,
|
||||||
|
resources,
|
||||||
|
siteResources,
|
||||||
|
users,
|
||||||
|
db,
|
||||||
|
primaryDb
|
||||||
|
} from "@server/db";
|
||||||
|
import { registry } from "@server/openApi";
|
||||||
|
import { NextFunction } from "express";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
|
||||||
|
import { OpenAPITags } from "@server/openApi";
|
||||||
|
import { z } from "zod";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { QueryAiSessionLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
|
||||||
|
export const queryAiSessionLogsQuery = z.strictObject({
|
||||||
|
// iso string just validate its a parseable date
|
||||||
|
timeStart: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeStart must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.transform((val) => new Date(val).getTime())
|
||||||
|
.prefault(() => getSevenDaysAgo().toISOString())
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
description:
|
||||||
|
"Start time as ISO date string (defaults to 7 days ago)"
|
||||||
|
}),
|
||||||
|
timeEnd: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeEnd must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.transform((val) => new Date(val).getTime())
|
||||||
|
.optional()
|
||||||
|
.prefault(() => new Date().toISOString())
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
description:
|
||||||
|
"End time as ISO date string (defaults to current time)"
|
||||||
|
}),
|
||||||
|
providerId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive())
|
||||||
|
.optional(),
|
||||||
|
capability: z.enum(AI_CAPABILITIES).optional(),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive())
|
||||||
|
.optional(),
|
||||||
|
actor: z.string().optional(),
|
||||||
|
isStream: z
|
||||||
|
.union([z.boolean(), z.string()])
|
||||||
|
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
||||||
|
.optional(),
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const queryAiSessionLogsParams = z.object({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const queryAiSessionLogsCombined = queryAiSessionLogsQuery.merge(
|
||||||
|
queryAiSessionLogsParams
|
||||||
|
);
|
||||||
|
type Q = z.infer<typeof queryAiSessionLogsCombined>;
|
||||||
|
|
||||||
|
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||||
|
items: T[]
|
||||||
|
): T[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const nameA = a.name ?? "";
|
||||||
|
const nameB = b.name ?? "";
|
||||||
|
|
||||||
|
if (nameA < nameB) return -1;
|
||||||
|
if (nameA > nameB) return 1;
|
||||||
|
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWhere(data: Q) {
|
||||||
|
return and(
|
||||||
|
gt(aiSessionLog.createdAt, data.timeStart),
|
||||||
|
lt(aiSessionLog.createdAt, data.timeEnd),
|
||||||
|
eq(aiSessionLog.orgId, data.orgId),
|
||||||
|
data.providerId
|
||||||
|
? eq(aiSessionLog.providerId, data.providerId)
|
||||||
|
: undefined,
|
||||||
|
data.capability ? eq(aiSessionLog.capability, data.capability) : undefined,
|
||||||
|
data.resourceId
|
||||||
|
? or(
|
||||||
|
eq(aiSessionLog.resourceId, data.resourceId),
|
||||||
|
eq(aiSessionLog.siteResourceId, data.resourceId)
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
|
||||||
|
data.isStream !== undefined
|
||||||
|
? eq(aiSessionLog.isStream, data.isStream)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryAiSession(data: Q) {
|
||||||
|
return logsDb
|
||||||
|
.select({
|
||||||
|
id: aiSessionLog.id,
|
||||||
|
sessionId: aiSessionLog.sessionId,
|
||||||
|
orgId: aiSessionLog.orgId,
|
||||||
|
providerId: aiSessionLog.providerId,
|
||||||
|
capability: aiSessionLog.capability,
|
||||||
|
resourceId: aiSessionLog.resourceId,
|
||||||
|
siteResourceId: aiSessionLog.siteResourceId,
|
||||||
|
userId: aiSessionLog.userId,
|
||||||
|
requestedModel: aiSessionLog.requestedModel,
|
||||||
|
isStream: aiSessionLog.isStream,
|
||||||
|
requestBody: aiSessionLog.requestBody,
|
||||||
|
responseBody: aiSessionLog.responseBody,
|
||||||
|
normalizedRequest: aiSessionLog.normalizedRequest,
|
||||||
|
normalizedResponse: aiSessionLog.normalizedResponse,
|
||||||
|
truncated: aiSessionLog.truncated,
|
||||||
|
statusCode: aiSessionLog.statusCode,
|
||||||
|
createdAt: aiSessionLog.createdAt
|
||||||
|
})
|
||||||
|
.from(aiSessionLog)
|
||||||
|
.where(getWhere(data))
|
||||||
|
.orderBy(desc(aiSessionLog.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichWithDetails(
|
||||||
|
logs: Awaited<ReturnType<typeof queryAiSession>>
|
||||||
|
) {
|
||||||
|
const providerIds = [...new Set(logs.map((log) => log.providerId))];
|
||||||
|
|
||||||
|
const resourceIds = logs
|
||||||
|
.map((log) => log.resourceId)
|
||||||
|
.filter((id): id is number => id !== null && id !== undefined);
|
||||||
|
|
||||||
|
const siteResourceIds = logs
|
||||||
|
.filter((log) => log.resourceId == null && log.siteResourceId != null)
|
||||||
|
.map((log) => log.siteResourceId)
|
||||||
|
.filter((id): id is number => id !== null && id !== undefined);
|
||||||
|
|
||||||
|
const userIds = [
|
||||||
|
...new Set(
|
||||||
|
logs
|
||||||
|
.map((log) => log.userId)
|
||||||
|
.filter((id): id is string => id !== null && id !== undefined)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
const providerMap = new Map<
|
||||||
|
number,
|
||||||
|
{ name: string | null; type: string | null }
|
||||||
|
>();
|
||||||
|
if (providerIds.length > 0) {
|
||||||
|
const providerDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
providerId: aiProviders.providerId,
|
||||||
|
name: aiProviders.name,
|
||||||
|
type: aiProviders.type
|
||||||
|
})
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(inArray(aiProviders.providerId, providerIds));
|
||||||
|
|
||||||
|
for (const p of providerDetails) {
|
||||||
|
providerMap.set(p.providerId, { name: p.name, type: p.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceMap = new Map<
|
||||||
|
number,
|
||||||
|
{ name: string | null; niceId: string | null }
|
||||||
|
>();
|
||||||
|
if (resourceIds.length > 0) {
|
||||||
|
const resourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
name: resources.name,
|
||||||
|
niceId: resources.niceId
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
.where(inArray(resources.resourceId, resourceIds));
|
||||||
|
|
||||||
|
for (const r of resourceDetails) {
|
||||||
|
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteResourceMap = new Map<
|
||||||
|
number,
|
||||||
|
{ name: string | null; niceId: string | null }
|
||||||
|
>();
|
||||||
|
if (siteResourceIds.length > 0) {
|
||||||
|
const siteResourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name,
|
||||||
|
niceId: siteResources.niceId
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||||
|
|
||||||
|
for (const r of siteResourceDetails) {
|
||||||
|
siteResourceMap.set(r.siteResourceId, {
|
||||||
|
name: r.name,
|
||||||
|
niceId: r.niceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMap = new Map<string, string | null>();
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
const userDetails = await db
|
||||||
|
.select({ userId: users.userId, email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.userId, userIds));
|
||||||
|
|
||||||
|
for (const u of userDetails) {
|
||||||
|
userMap.set(u.userId, u.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs.map((log) => {
|
||||||
|
const provider = providerMap.get(log.providerId);
|
||||||
|
|
||||||
|
let resourceId = log.resourceId;
|
||||||
|
let resourceName: string | null = null;
|
||||||
|
let resourceNiceId: string | null = null;
|
||||||
|
let resourceType: "public" | "site" | null = null;
|
||||||
|
if (log.resourceId != null) {
|
||||||
|
const details = resourceMap.get(log.resourceId);
|
||||||
|
resourceName = details?.name ?? null;
|
||||||
|
resourceNiceId = details?.niceId ?? null;
|
||||||
|
resourceType = "public";
|
||||||
|
} else if (log.siteResourceId != null) {
|
||||||
|
const details = siteResourceMap.get(log.siteResourceId);
|
||||||
|
resourceId = log.siteResourceId;
|
||||||
|
resourceName = details?.name ?? null;
|
||||||
|
resourceNiceId = details?.niceId ?? null;
|
||||||
|
resourceType = "site";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...log,
|
||||||
|
resourceId,
|
||||||
|
resourceType,
|
||||||
|
providerName: provider?.name ?? null,
|
||||||
|
providerType: provider?.type ?? null,
|
||||||
|
resourceName,
|
||||||
|
resourceNiceId,
|
||||||
|
userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countAiSessionQuery(data: Q) {
|
||||||
|
return logsDb
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(aiSessionLog)
|
||||||
|
.where(getWhere(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/logs/ai",
|
||||||
|
description: "Query the AI gateway session log for an organization",
|
||||||
|
tags: [OpenAPITags.Logs],
|
||||||
|
request: {
|
||||||
|
query: queryAiSessionLogsQuery,
|
||||||
|
params: queryAiSessionLogsParams
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function queryUniqueFilterAttributes(
|
||||||
|
timeStart: number,
|
||||||
|
timeEnd: number,
|
||||||
|
orgId: string
|
||||||
|
) {
|
||||||
|
const baseConditions = and(
|
||||||
|
gt(aiSessionLog.createdAt, timeStart),
|
||||||
|
lt(aiSessionLog.createdAt, timeEnd),
|
||||||
|
eq(aiSessionLog.orgId, orgId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const DISTINCT_LIMIT = 500;
|
||||||
|
|
||||||
|
const [
|
||||||
|
uniqueProviders,
|
||||||
|
uniqueUsers,
|
||||||
|
uniqueResources,
|
||||||
|
uniqueSiteResources
|
||||||
|
] = await Promise.all([
|
||||||
|
logsDb
|
||||||
|
.selectDistinct({ id: aiSessionLog.providerId })
|
||||||
|
.from(aiSessionLog)
|
||||||
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
|
logsDb
|
||||||
|
.selectDistinct({ userId: aiSessionLog.userId })
|
||||||
|
.from(aiSessionLog)
|
||||||
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
|
logsDb
|
||||||
|
.selectDistinct({ id: aiSessionLog.resourceId })
|
||||||
|
.from(aiSessionLog)
|
||||||
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
|
logsDb
|
||||||
|
.selectDistinct({ id: aiSessionLog.siteResourceId })
|
||||||
|
.from(aiSessionLog)
|
||||||
|
.where(and(baseConditions, isNull(aiSessionLog.resourceId)))
|
||||||
|
.limit(DISTINCT_LIMIT + 1)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const providerIds = uniqueProviders
|
||||||
|
.map((row) => row.id)
|
||||||
|
.filter((id): id is number => id !== null);
|
||||||
|
|
||||||
|
let providers: Array<{ id: number; name: string | null }> = [];
|
||||||
|
if (providerIds.length > 0) {
|
||||||
|
const providerDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
providerId: aiProviders.providerId,
|
||||||
|
name: aiProviders.name
|
||||||
|
})
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(inArray(aiProviders.providerId, providerIds));
|
||||||
|
|
||||||
|
providers = providerDetails.map((p) => ({
|
||||||
|
id: p.providerId,
|
||||||
|
name: p.name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = uniqueUsers
|
||||||
|
.map((row) => row.userId)
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
|
||||||
|
let userList: Array<{ id: string; email: string | null }> = [];
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
const userDetails = await db
|
||||||
|
.select({ userId: users.userId, email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.userId, userIds));
|
||||||
|
|
||||||
|
userList = userDetails.map((u) => ({ id: u.userId, email: u.email }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceIds = uniqueResources
|
||||||
|
.map((row) => row.id)
|
||||||
|
.filter((id): id is number => id !== null);
|
||||||
|
|
||||||
|
const siteResourceIds = uniqueSiteResources
|
||||||
|
.map((row) => row.id)
|
||||||
|
.filter((id): id is number => id !== null);
|
||||||
|
|
||||||
|
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
||||||
|
|
||||||
|
if (resourceIds.length > 0) {
|
||||||
|
const resourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
name: resources.name
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
.where(inArray(resources.resourceId, resourceIds));
|
||||||
|
|
||||||
|
resourcesWithNames = [
|
||||||
|
...resourcesWithNames,
|
||||||
|
...resourceDetails.map((r) => ({
|
||||||
|
id: r.resourceId,
|
||||||
|
name: r.name
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteResourceIds.length > 0) {
|
||||||
|
const siteResourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||||
|
|
||||||
|
resourcesWithNames = [
|
||||||
|
...resourcesWithNames,
|
||||||
|
...siteResourceDetails.map((r) => ({
|
||||||
|
id: r.siteResourceId,
|
||||||
|
name: r.name
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers: sortNamedFilterOptions(providers),
|
||||||
|
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||||
|
users: userList
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryAiSessionLogs(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = queryAiSessionLogsQuery.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = queryAiSessionLogsParams.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
|
const baseQuery = queryAiSession(data);
|
||||||
|
|
||||||
|
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
|
const log = await enrichWithDetails(logsRaw);
|
||||||
|
|
||||||
|
const totalCountResult = await countAiSessionQuery(data);
|
||||||
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
const filterAttributes = await queryUniqueFilterAttributes(
|
||||||
|
data.timeStart,
|
||||||
|
data.timeEnd,
|
||||||
|
data.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<QueryAiSessionLogResponse>(res, {
|
||||||
|
data: {
|
||||||
|
log,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
limit: data.limit,
|
||||||
|
offset: data.offset
|
||||||
|
},
|
||||||
|
filterAttributes
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "AI session logs retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,53 @@ export type QueryAccessAuditLogResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QueryAiSessionLogResponse = {
|
||||||
|
log: {
|
||||||
|
id: number;
|
||||||
|
sessionId: string;
|
||||||
|
orgId: string | null;
|
||||||
|
providerId: number;
|
||||||
|
providerName: string | null;
|
||||||
|
providerType: string | null;
|
||||||
|
capability: string;
|
||||||
|
resourceId: number | null;
|
||||||
|
siteResourceId: number | null;
|
||||||
|
resourceName: string | null;
|
||||||
|
resourceNiceId: string | null;
|
||||||
|
resourceType: "public" | "site" | null;
|
||||||
|
userId: string | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
requestedModel: string | null;
|
||||||
|
isStream: boolean;
|
||||||
|
requestBody: string | null;
|
||||||
|
responseBody: string | null;
|
||||||
|
normalizedRequest: string | null;
|
||||||
|
normalizedResponse: string | null;
|
||||||
|
truncated: boolean;
|
||||||
|
statusCode: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
}[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
filterAttributes: {
|
||||||
|
providers: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
resources: {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}[];
|
||||||
|
users: {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type QueryConnectionAuditLogResponse = {
|
export type QueryConnectionAuditLogResponse = {
|
||||||
log: {
|
log: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|||||||
@@ -1487,6 +1487,21 @@ authenticated.get(
|
|||||||
logs.exportRequestAuditLogs
|
logs.exportRequestAuditLogs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/ai",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||||
|
logs.queryAiSessionLogs
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/ai/export",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||||
|
logActionAudit(ActionsEnum.exportLogs),
|
||||||
|
logs.exportAiSessionLogs
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/blueprints",
|
"/org/:orgId/blueprints",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -1530,6 +1530,21 @@ authenticated.get(
|
|||||||
logs.exportRequestAuditLogs
|
logs.exportRequestAuditLogs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/ai",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.viewLogs),
|
||||||
|
logs.queryAiSessionLogs
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/ai/export",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||||
|
logActionAudit(ActionsEnum.exportLogs),
|
||||||
|
logs.exportAiSessionLogs
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/analytics",
|
"/org/:orgId/logs/analytics",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "AI Session Logs"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
"use client";
|
||||||
|
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||||
|
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||||
|
import { LogDataTable } from "@app/components/LogDataTable";
|
||||||
|
import { AiSessionChatView } from "@app/components/AiSessionChatView";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { ArrowUpRight, Bot, Waves, User } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
|
import type { QueryAiSessionLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
|
||||||
|
const capabilityLabels: Record<string, string> = {
|
||||||
|
openai_chat: "OpenAI Chat Completions",
|
||||||
|
openai_responses: "OpenAI Responses",
|
||||||
|
anthropic_messages: "Anthropic Messages",
|
||||||
|
gemini_generate_content: "Gemini",
|
||||||
|
google_generate_content: "Vertex AI (Generate Content)",
|
||||||
|
google_raw_predict: "Vertex AI (Raw Predict)",
|
||||||
|
bedrock_model_invoke: "Bedrock (Invoke Model)",
|
||||||
|
bedrock_converse: "Bedrock (Converse)"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AiSessionLogsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const t = useTranslations();
|
||||||
|
const { orgId } = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
const [pageSize, setPageSize] = useStoredPageSize("ai-session-logs", 20);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<{
|
||||||
|
providerId?: string;
|
||||||
|
capability?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
actor?: string;
|
||||||
|
isStream?: string;
|
||||||
|
}>({
|
||||||
|
providerId: searchParams.get("providerId") || undefined,
|
||||||
|
capability: searchParams.get("capability") || undefined,
|
||||||
|
resourceId: searchParams.get("resourceId") || undefined,
|
||||||
|
actor: searchParams.get("actor") || undefined,
|
||||||
|
isStream: searchParams.get("isStream") || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDefaultDateRange = () => {
|
||||||
|
const startParam = searchParams.get("start");
|
||||||
|
const endParam = searchParams.get("end");
|
||||||
|
if (startParam && endParam) {
|
||||||
|
return {
|
||||||
|
startDate: { date: new Date(startParam) },
|
||||||
|
endDate: { date: new Date(endParam) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
startDate: { date: getSevenDaysAgo() },
|
||||||
|
endDate: { date: new Date() }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const [dateRange, setDateRange] = useState<{
|
||||||
|
startDate: DateTimeValue;
|
||||||
|
endDate: DateTimeValue;
|
||||||
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
|
const queryFilters = useMemo(() => {
|
||||||
|
let timeStart: string | undefined;
|
||||||
|
let timeEnd: string | undefined;
|
||||||
|
|
||||||
|
if (dateRange.startDate?.date) {
|
||||||
|
const dt = new Date(dateRange.startDate.date);
|
||||||
|
if (dateRange.startDate.time) {
|
||||||
|
const [h, m, s] = dateRange.startDate.time
|
||||||
|
.split(":")
|
||||||
|
.map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
}
|
||||||
|
timeStart = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateRange.endDate?.date) {
|
||||||
|
const dt = new Date(dateRange.endDate.date);
|
||||||
|
if (dateRange.endDate.time) {
|
||||||
|
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||||
|
dt.setHours(h, m, s || 0);
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
dt.setHours(
|
||||||
|
now.getHours(),
|
||||||
|
now.getMinutes(),
|
||||||
|
now.getSeconds(),
|
||||||
|
now.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timeEnd = dt.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...filters
|
||||||
|
};
|
||||||
|
}, [dateRange, currentPage, pageSize, filters]);
|
||||||
|
|
||||||
|
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||||
|
...logQueries.aiSessions({
|
||||||
|
orgId: orgId as string,
|
||||||
|
filters: queryFilters
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
|
||||||
|
const totalCount = data?.pagination?.total ?? 0;
|
||||||
|
const filterAttributes = data?.filterAttributes ?? {
|
||||||
|
providers: [],
|
||||||
|
resources: [],
|
||||||
|
users: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateRangeChange = (
|
||||||
|
startDate: DateTimeValue,
|
||||||
|
endDate: DateTimeValue
|
||||||
|
) => {
|
||||||
|
setDateRange({ startDate, endDate });
|
||||||
|
setCurrentPage(0);
|
||||||
|
updateUrlParamsForAllFilters({
|
||||||
|
start: startDate.date?.toISOString() || "",
|
||||||
|
end: endDate.date?.toISOString() || ""
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
|
setPageSize(newPageSize);
|
||||||
|
setCurrentPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (
|
||||||
|
filterType: keyof typeof filters,
|
||||||
|
value: string | undefined
|
||||||
|
) => {
|
||||||
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(0);
|
||||||
|
updateUrlParamsForAllFilters(newFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUrlParamsForAllFilters = (
|
||||||
|
newFilters:
|
||||||
|
| typeof filters
|
||||||
|
| {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
Object.entries(newFilters).forEach(([key, value]) => {
|
||||||
|
if (value) {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportData = async () => {
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
timeStart: dateRange.startDate?.date
|
||||||
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
timeEnd: dateRange.endDate?.date
|
||||||
|
? new Date(dateRange.endDate.date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
...filters
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await api.get(`/org/${orgId}/logs/ai/export`, {
|
||||||
|
responseType: "blob",
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
const epoch = Math.floor(Date.now() / 1000);
|
||||||
|
link.setAttribute(
|
||||||
|
"download",
|
||||||
|
`ai-session-logs-${orgId}-${epoch}.csv`
|
||||||
|
);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.parentNode?.removeChild(link);
|
||||||
|
} catch (error) {
|
||||||
|
let apiErrorMessage: string | null = null;
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
const data = error.response.data;
|
||||||
|
|
||||||
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
|
const text = await data.text();
|
||||||
|
const errorData = JSON.parse(text);
|
||||||
|
apiErrorMessage = errorData.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: apiErrorMessage ?? t("exportError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<any>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<span className="px-2">{t("timestamp")}</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{new Date(row.original.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "providerName",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2">
|
||||||
|
<ColumnFilterButton
|
||||||
|
options={filterAttributes.providers.map(
|
||||||
|
(provider) => ({
|
||||||
|
value: provider.id.toString(),
|
||||||
|
label: provider.name || "Unnamed Provider"
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
selectedValue={filters.providerId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("providerId", value)
|
||||||
|
}
|
||||||
|
label={t("provider")}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
{row.original.providerName || "-"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "capability",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2">
|
||||||
|
<ColumnFilterButton
|
||||||
|
options={Object.entries(capabilityLabels).map(
|
||||||
|
([value, label]) => ({ value, label })
|
||||||
|
)}
|
||||||
|
selectedValue={filters.capability}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("capability", value)
|
||||||
|
}
|
||||||
|
label={t("capability")}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-xs">
|
||||||
|
{capabilityLabels[row.original.capability] ||
|
||||||
|
row.original.capability}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "requestedModel",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<span className="px-2">{t("model")}</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.original.requestedModel || "-"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "resourceName",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2">
|
||||||
|
<ColumnFilterButton
|
||||||
|
options={filterAttributes.resources.map((res) => ({
|
||||||
|
value: res.id.toString(),
|
||||||
|
label: res.name || "Unnamed Resource"
|
||||||
|
}))}
|
||||||
|
selectedValue={filters.resourceId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("resourceId", value)
|
||||||
|
}
|
||||||
|
label={t("resource")}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.resourceNiceId) {
|
||||||
|
return <span className="text-xs text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
row.original.resourceType === "site"
|
||||||
|
? getPrivateResourceSettingsHref(
|
||||||
|
row.original.orgId,
|
||||||
|
row.original.resourceNiceId
|
||||||
|
)
|
||||||
|
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||||
|
}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{row.original.resourceName}
|
||||||
|
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "isStream",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2">
|
||||||
|
<ColumnFilterButton
|
||||||
|
options={[
|
||||||
|
{ value: "true", label: t("streaming") },
|
||||||
|
{ value: "false", label: t("nonStreaming") }
|
||||||
|
]}
|
||||||
|
label={t("stream")}
|
||||||
|
selectedValue={filters.isStream}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("isStream", value)
|
||||||
|
}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{row.original.isStream ? (
|
||||||
|
<>
|
||||||
|
<Waves className="h-4 w-4" />
|
||||||
|
{t("streaming")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{t("nonStreaming")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "userEmail",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2">
|
||||||
|
<ColumnFilterButton
|
||||||
|
options={filterAttributes.users.map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.email || user.id
|
||||||
|
}))}
|
||||||
|
selectedValue={filters.actor}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("actor", value)
|
||||||
|
}
|
||||||
|
label={t("actor")}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{row.original.userEmail ? (
|
||||||
|
<>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
{row.original.userEmail}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>-</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderExpandedRow = (row: any) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<strong>{t("aiSessionId")}</strong>
|
||||||
|
<p className="text-muted-foreground mt-1 break-all">
|
||||||
|
{row.sessionId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{t("statusCode")}</strong>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{row.statusCode ?? "N/A"}
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
<AiSessionChatView
|
||||||
|
normalizedRequest={row.normalizedRequest}
|
||||||
|
normalizedResponse={row.normalizedResponse}
|
||||||
|
requestBody={row.requestBody}
|
||||||
|
responseBody={row.responseBody}
|
||||||
|
truncated={row.truncated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("aiSessionLogs")}
|
||||||
|
description={t("aiSessionLogsDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LogDataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
title={t("aiSessionLogs")}
|
||||||
|
searchPlaceholder={t("searchLogs")}
|
||||||
|
searchColumn="providerName"
|
||||||
|
onRefresh={() => refetch()}
|
||||||
|
isRefreshing={isFetching}
|
||||||
|
onExport={() => startTransition(exportData)}
|
||||||
|
isExporting={isExporting}
|
||||||
|
onDateRangeChange={handleDateRangeChange}
|
||||||
|
dateRange={{
|
||||||
|
start: dateRange.startDate,
|
||||||
|
end: dateRange.endDate
|
||||||
|
}}
|
||||||
|
defaultSort={{
|
||||||
|
id: "createdAt",
|
||||||
|
desc: true
|
||||||
|
}}
|
||||||
|
totalCount={totalCount}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pageSize={pageSize}
|
||||||
|
expandable={true}
|
||||||
|
renderExpandedRow={renderExpandedRow}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
|
||||||
|
const capabilities = Object.keys(capabilityLabels);
|
||||||
|
const providers = [
|
||||||
|
{ id: 1, name: "OpenAI Production" },
|
||||||
|
{ id: 2, name: "Anthropic Default" },
|
||||||
|
{ id: 3, name: "Vertex AI" }
|
||||||
|
];
|
||||||
|
const resourcesSample = [
|
||||||
|
{ id: 1, niceId: "resource-1", name: "Resource 1" },
|
||||||
|
{ id: 2, niceId: "resource-2", name: "Resource 2" }
|
||||||
|
];
|
||||||
|
const actors = ["alice@example.com", "bob@example.com", null];
|
||||||
|
const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"];
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const provider = providers[Math.floor(Math.random() * providers.length)];
|
||||||
|
const resource =
|
||||||
|
resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
|
||||||
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: i,
|
||||||
|
sessionId: `sample-session-${i}`,
|
||||||
|
orgId: "sample-org",
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
providerType: "openai",
|
||||||
|
capability:
|
||||||
|
capabilities[Math.floor(Math.random() * capabilities.length)],
|
||||||
|
resourceId: resource.id,
|
||||||
|
siteResourceId: null,
|
||||||
|
resourceName: resource.name,
|
||||||
|
resourceNiceId: resource.niceId,
|
||||||
|
resourceType: "public",
|
||||||
|
userId: actor ? `user-${i}` : null,
|
||||||
|
userEmail: actor,
|
||||||
|
requestedModel: models[Math.floor(Math.random() * models.length)],
|
||||||
|
isStream: Math.random() > 0.5,
|
||||||
|
requestBody: null,
|
||||||
|
responseBody: null,
|
||||||
|
normalizedRequest: null,
|
||||||
|
normalizedResponse: null,
|
||||||
|
truncated: false,
|
||||||
|
statusCode: 200,
|
||||||
|
createdAt: Math.floor(
|
||||||
|
sevenDaysAgoMs + Math.random() * (now - sevenDaysAgoMs)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Env } from "@app/lib/types/env";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
BellRing,
|
BellRing,
|
||||||
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
Cable,
|
Cable,
|
||||||
@@ -227,6 +228,11 @@ export const orgNavSections = (
|
|||||||
<SquareMousePointer className="size-4 flex-none" />
|
<SquareMousePointer className="size-4 flex-none" />
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "sidebarLogsAi",
|
||||||
|
href: "/{orgId}/settings/logs/ai",
|
||||||
|
icon: <Bot className="size-4 flex-none" />
|
||||||
|
},
|
||||||
...(!env?.flags.disableEnterpriseFeatures
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -521,6 +527,11 @@ export const commandBarNavSections = (
|
|||||||
href: "/{orgId}/settings/logs/request",
|
href: "/{orgId}/settings/logs/request",
|
||||||
icon: <SquareMousePointer className="size-4 flex-none" />
|
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "commandLogsAi",
|
||||||
|
href: "/{orgId}/settings/logs/ai",
|
||||||
|
icon: <Bot className="size-4 flex-none" />
|
||||||
|
},
|
||||||
...(!env?.flags.disableEnterpriseFeatures
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { AlertTriangle, Bot, Terminal, User as UserIcon, Wrench } from "lucide-react";
|
||||||
|
import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization";
|
||||||
|
|
||||||
|
type AiSessionChatViewProps = {
|
||||||
|
normalizedRequest: string | null;
|
||||||
|
normalizedResponse: string | null;
|
||||||
|
requestBody: string | null;
|
||||||
|
responseBody: string | null;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseMessages(json: string | null): NormalizedAiMessage[] | null {
|
||||||
|
if (!json) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
return Array.isArray(parsed) ? (parsed as NormalizedAiMessage[]) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyRaw(raw: string | null): string | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ message }: { message: NormalizedAiMessage }) {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
const isSystem = message.role === "system";
|
||||||
|
const isTool = message.role === "tool";
|
||||||
|
|
||||||
|
if (isSystem) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<Terminal className="h-3.5 w-3.5 mt-0.5 flex-none" />
|
||||||
|
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||||
|
{message.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-2 ${isUser ? "flex-row-reverse" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-7 w-7 flex-none items-center justify-center rounded-full ${
|
||||||
|
isUser
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: isTool
|
||||||
|
? "bg-amber-100 dark:bg-amber-900/40"
|
||||||
|
: "bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isUser ? (
|
||||||
|
<UserIcon className="h-4 w-4" />
|
||||||
|
) : isTool ? (
|
||||||
|
<Wrench className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||||
|
isUser
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: isTool
|
||||||
|
? "bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 font-mono text-xs"
|
||||||
|
: "bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.content || (
|
||||||
|
<span className="italic opacity-60"> </span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RawFallbackBlock({
|
||||||
|
label,
|
||||||
|
raw,
|
||||||
|
noDataLabel,
|
||||||
|
unparsedLabel
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
raw: string | null;
|
||||||
|
noDataLabel: string;
|
||||||
|
unparsedLabel: string;
|
||||||
|
}) {
|
||||||
|
const pretty = prettyRaw(raw);
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-muted/30 p-3">
|
||||||
|
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
{pretty && (
|
||||||
|
<span className="ml-2 font-normal italic opacity-70">
|
||||||
|
{unparsedLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||||
|
{pretty ?? noDataLabel}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiSessionChatView({
|
||||||
|
normalizedRequest,
|
||||||
|
normalizedResponse,
|
||||||
|
requestBody,
|
||||||
|
responseBody,
|
||||||
|
truncated
|
||||||
|
}: AiSessionChatViewProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const requestMessages = useMemo(
|
||||||
|
() => parseMessages(normalizedRequest),
|
||||||
|
[normalizedRequest]
|
||||||
|
);
|
||||||
|
const responseMessages = useMemo(
|
||||||
|
() => parseMessages(normalizedResponse),
|
||||||
|
[normalizedResponse]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasRequestMessages = !!requestMessages && requestMessages.length > 0;
|
||||||
|
const hasResponseMessages =
|
||||||
|
!!responseMessages && responseMessages.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{truncated && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-500">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 flex-none" />
|
||||||
|
{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} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
|||||||
import type {
|
import type {
|
||||||
QueryAccessAuditLogResponse,
|
QueryAccessAuditLogResponse,
|
||||||
QueryActionAuditLogResponse,
|
QueryActionAuditLogResponse,
|
||||||
|
QueryAiSessionLogResponse,
|
||||||
QueryConnectionAuditLogResponse,
|
QueryConnectionAuditLogResponse,
|
||||||
QueryRequestAuditLogResponse
|
QueryRequestAuditLogResponse
|
||||||
} from "@server/routers/auditLogs/types";
|
} from "@server/routers/auditLogs/types";
|
||||||
@@ -1031,6 +1032,32 @@ export const connectionLogsFiltersSchema = z.object({
|
|||||||
|
|
||||||
export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>;
|
export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>;
|
||||||
|
|
||||||
|
export const aiSessionLogsFiltersSchema = z.object({
|
||||||
|
timeStart: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeStart must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
timeEnd: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => !isNaN(Date.parse(val)), {
|
||||||
|
error: "timeEnd must be a valid ISO date string"
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
page: z.coerce.number().optional().catch(0).default(0),
|
||||||
|
pageSize: z.coerce.number().optional().catch(20).default(20),
|
||||||
|
providerId: z.string().optional().catch(undefined),
|
||||||
|
capability: z.string().optional().catch(undefined),
|
||||||
|
resourceId: z.string().optional().catch(undefined),
|
||||||
|
actor: z.string().optional().catch(undefined),
|
||||||
|
isStream: z.string().optional().catch(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AiSessionLogFilters = z.output<typeof aiSessionLogsFiltersSchema>;
|
||||||
|
|
||||||
export const logQueries = {
|
export const logQueries = {
|
||||||
requestAnalytics: ({
|
requestAnalytics: ({
|
||||||
orgId,
|
orgId,
|
||||||
@@ -1180,6 +1207,37 @@ export const logQueries = {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
aiSessions: ({
|
||||||
|
orgId,
|
||||||
|
filters
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
filters: AiSessionLogFilters;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["AI_SESSION_LOGS", orgId, "ALL", filters] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const { page, pageSize, ...rest } = filters;
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<QueryAiSessionLogResponse>
|
||||||
|
>(`/org/${orgId}/logs/ai`, {
|
||||||
|
params: {
|
||||||
|
...rest,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: page * pageSize
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
if (query.state.data) {
|
||||||
|
return durationToMs(30, "seconds");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user