Admin Analytics/Query History dashboards (#6)

This commit is contained in:
Chris Weaver
2023-10-21 20:02:59 -07:00
parent 428f5edd21
commit e9f273d99a
24 changed files with 1151 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
import datetime
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy import case
from sqlalchemy import cast
from sqlalchemy import Date
from sqlalchemy import func
from sqlalchemy import select
from sqlalchemy.orm import Session
from danswer.configs.constants import QAFeedbackType
from danswer.db.models import QueryEvent
def fetch_query_analytics(
db_session: Session,
start: datetime.datetime,
end: datetime.datetime,
) -> Sequence[tuple[int, int, int, datetime.date]]:
stmt = (
select(
func.count(QueryEvent.id),
func.sum(case((QueryEvent.feedback == QAFeedbackType.LIKE, 1), else_=0)),
func.sum(case((QueryEvent.feedback == QAFeedbackType.DISLIKE, 1), else_=0)),
cast(QueryEvent.time_created, Date),
)
.where(
QueryEvent.time_created >= start,
)
.where(
QueryEvent.time_created <= end,
)
.group_by(cast(QueryEvent.time_created, Date))
.order_by(cast(QueryEvent.time_created, Date))
)
return db_session.execute(stmt).all() # type: ignore
def fetch_per_user_query_analytics(
db_session: Session,
start: datetime.datetime,
end: datetime.datetime,
) -> Sequence[tuple[int, int, int, datetime.date, UUID]]:
stmt = (
select(
func.count(QueryEvent.id),
func.sum(case((QueryEvent.feedback == QAFeedbackType.LIKE, 1), else_=0)),
func.sum(case((QueryEvent.feedback == QAFeedbackType.DISLIKE, 1), else_=0)),
cast(QueryEvent.time_created, Date),
QueryEvent.user_id,
)
.where(
QueryEvent.time_created >= start,
)
.where(
QueryEvent.time_created <= end,
)
.group_by(cast(QueryEvent.time_created, Date), QueryEvent.user_id)
.order_by(cast(QueryEvent.time_created, Date), QueryEvent.user_id)
)
return db_session.execute(stmt).all() # type: ignore

View File

@@ -0,0 +1,14 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session
from danswer.db.models import Document
def fetch_documents_from_ids(
db_session: Session, document_ids: list[str]
) -> Sequence[Document]:
return db_session.scalars(
select(Document).where(Document.id.in_(document_ids))
).all()

View File

@@ -0,0 +1,57 @@
import datetime
from collections.abc import Sequence
from typing import cast
from typing import Literal
from sqlalchemy import or_
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import InstrumentedAttribute
from danswer.configs.constants import QAFeedbackType
from danswer.db.models import QueryEvent
SortByOptions = Literal["time_created", "feedback"]
def fetch_query_history(
db_session: Session,
start: datetime.datetime,
end: datetime.datetime,
query: str | None = None,
feedback_type: QAFeedbackType | None = None,
sort_by_field: SortByOptions = "time_created",
sort_by_direction: Literal["asc", "desc"] = "desc",
offset: int = 0,
limit: int = 500,
) -> Sequence[QueryEvent]:
stmt = (
select(QueryEvent)
.where(
QueryEvent.time_created >= start,
)
.where(
QueryEvent.time_created <= end,
)
)
order_by_field = cast(InstrumentedAttribute, getattr(QueryEvent, sort_by_field))
if sort_by_direction == "asc":
stmt = stmt.order_by(order_by_field.asc())
else:
stmt = stmt.order_by(order_by_field.desc())
stmt = stmt.offset(offset).limit(limit)
if query:
stmt = stmt.where(
or_(
QueryEvent.llm_answer.ilike(f"%{query}%"),
QueryEvent.query.ilike(f"%{query}%"),
)
)
if feedback_type:
stmt = stmt.where(QueryEvent.feedback == feedback_type)
return db_session.scalars(stmt).all()

View File

@@ -16,6 +16,8 @@ from danswer.main import get_application
from danswer.utils.logger import setup_logger
from danswer.utils.variable_functionality import global_version
from ee.danswer.configs.app_configs import OPENID_CONFIG_URL
from ee.danswer.server.analytics.api import router as analytics_router
from ee.danswer.server.query_history.api import router as query_history_router
from ee.danswer.server.saml import router as saml_router
from ee.danswer.server.user_group.api import router as user_group_router
@@ -54,6 +56,9 @@ def get_ee_application() -> FastAPI:
# RBAC / group access control
application.include_router(user_group_router)
# analytics endpoints
application.include_router(analytics_router)
application.include_router(query_history_router)
return application

View File

@@ -0,0 +1,81 @@
import datetime
from collections import defaultdict
from fastapi import APIRouter
from fastapi import Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
import danswer.db.models as db_models
from danswer.auth.users import current_admin_user
from danswer.db.engine import get_session
from ee.danswer.db.analytics import fetch_per_user_query_analytics
from ee.danswer.db.analytics import fetch_query_analytics
router = APIRouter(prefix="/analytics")
class QueryAnalyticsResponse(BaseModel):
total_queries: int
total_likes: int
total_dislikes: int
date: datetime.date
@router.get("/admin/query")
def get_query_analytics(
start: datetime.datetime | None = None,
end: datetime.datetime | None = None,
_: db_models.User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[QueryAnalyticsResponse]:
daily_query_usage_info = fetch_query_analytics(
db_session=db_session,
start=start
or (
datetime.datetime.utcnow() - datetime.timedelta(days=30)
), # default is 30d lookback
end=end or datetime.datetime.utcnow(),
)
return [
QueryAnalyticsResponse(
total_queries=total_queries,
total_likes=total_likes,
total_dislikes=total_dislikes,
date=date,
)
for total_queries, total_likes, total_dislikes, date in daily_query_usage_info
]
class UserAnalyticsResponse(BaseModel):
total_active_users: int
date: datetime.date
@router.get("/admin/user")
def get_user_analytics(
start: datetime.datetime | None = None,
end: datetime.datetime | None = None,
_: db_models.User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[UserAnalyticsResponse]:
daily_query_usage_info_per_user = fetch_per_user_query_analytics(
db_session=db_session,
start=start
or (
datetime.datetime.utcnow() - datetime.timedelta(days=30)
), # default is 30d lookback
end=end or datetime.datetime.utcnow(),
)
user_analytics: dict[datetime.date, int] = defaultdict(int)
for __, ___, ____, date, _____ in daily_query_usage_info_per_user:
user_analytics[date] += 1
return [
UserAnalyticsResponse(
total_active_users=cnt,
date=date,
)
for date, cnt in user_analytics.items()
]

View File

@@ -0,0 +1,116 @@
from collections.abc import Iterable
from datetime import datetime
from datetime import timedelta
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import danswer.db.models as db_models
from danswer.auth.users import current_admin_user
from danswer.configs.constants import QAFeedbackType
from danswer.db.engine import get_session
from danswer.db.feedback import fetch_query_event_by_id
from danswer.db.models import Document
from ee.danswer.db.document import fetch_documents_from_ids
from ee.danswer.db.query_history import fetch_query_history
router = APIRouter()
class AbridgedSearchDoc(BaseModel):
"""A subset of the info present in `SearchDoc`"""
document_id: str
semantic_identifier: str
link: str | None
class QuerySnapshot(BaseModel):
id: int
query: str
llm_answer: str | None
retrieved_documents: list[AbridgedSearchDoc]
feedback: QAFeedbackType | None
time_created: datetime
@classmethod
def build(
cls, query_event: db_models.QueryEvent, documents: Iterable[Document]
) -> "QuerySnapshot":
return cls(
id=query_event.id,
query=query_event.query,
llm_answer=query_event.llm_answer,
retrieved_documents=[
AbridgedSearchDoc(
document_id=document.id,
semantic_identifier=document.semantic_id,
link=document.link,
)
for document in documents
],
feedback=query_event.feedback,
time_created=query_event.time_created,
)
@router.get("/admin/query-history")
def get_query_history(
feedback_type: QAFeedbackType | None = None,
start: datetime | None = None,
end: datetime | None = None,
_: db_models.User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[QuerySnapshot]:
query_history = fetch_query_history(
db_session=db_session,
start=start
or (datetime.utcnow() - timedelta(days=30)), # default is 30d lookback
end=end or datetime.utcnow(),
feedback_type=feedback_type,
)
all_relevant_document_ids: set[str] = set()
for query_event in query_history:
all_relevant_document_ids = all_relevant_document_ids.union(
query_event.retrieved_document_ids or []
)
document_id_to_document = {
document.id: document
for document in fetch_documents_from_ids(
db_session, list(all_relevant_document_ids)
)
}
query_snapshots: list[QuerySnapshot] = []
for query_event in query_history:
unique_document_ids = set(query_event.retrieved_document_ids or [])
documents = [
document_id_to_document[doc_id]
for doc_id in unique_document_ids
if doc_id in document_id_to_document
]
query_snapshots.append(
QuerySnapshot.build(query_event=query_event, documents=documents)
)
return query_snapshots
@router.get("/admin/query-history/{query_id}")
def get_query(
query_id: int,
_: db_models.User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> QuerySnapshot:
try:
query_event = fetch_query_event_by_id(query_id=query_id, db_session=db_session)
except ValueError:
raise HTTPException(400, f"Query event with id '{query_id}' does not exist.")
documents = fetch_documents_from_ids(
db_session, query_event.retrieved_document_ids or []
)
return QuerySnapshot.build(query_event=query_event, documents=documents)

View File

@@ -12,6 +12,7 @@ const nextConfig = {
const eeRedirects =
process.env.NEXT_PUBLIC_EE_ENABLED === "true"
? [
// user group pages
{
source: "/admin/groups",
destination: "/ee/admin/groups",
@@ -20,6 +21,19 @@ const nextConfig = {
source: "/admin/groups/:path*",
destination: "/ee/admin/groups/:path*",
},
// analytics / audit log pages
{
source: "/admin/performance/analytics",
destination: "/ee/admin/performance/analytics",
},
{
source: "/admin/performance/query-history",
destination: "/ee/admin/performance/query-history",
},
{
source: "/admin/performance/query-history/:path*",
destination: "/ee/admin/performance/query-history/:path*",
},
]
: [];

View File

@@ -0,0 +1,48 @@
import {
DateRangePicker,
DateRangePickerItem,
DateRangePickerValue,
} from "@tremor/react";
import { getXDaysAgo } from "./dateUtils";
export const THIRTY_DAYS = "30d";
export function DateRangeSelector({
value,
onValueChange,
}: {
value: DateRangePickerValue;
onValueChange: (value: DateRangePickerValue) => void;
}) {
return (
<div>
<div className="text-sm my-auto mr-2 font-medium text-gray-200 mb-1">
Date Range
</div>
<DateRangePicker
className="max-w-md"
value={value}
onValueChange={onValueChange}
selectPlaceholder="Select"
enableClear={false}
>
<DateRangePickerItem
key={THIRTY_DAYS}
value={THIRTY_DAYS}
from={getXDaysAgo(30)}
to={getXDaysAgo(0)}
>
Last 30 days
</DateRangePickerItem>
<DateRangePickerItem
key="today"
value="today"
from={getXDaysAgo(1)}
to={getXDaysAgo(0)}
>
Today
</DateRangePickerItem>
</DateRangePicker>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { ThreeDotsLoader } from "@/components/Loading";
import { getDatesList, useQueryAnalytics } from "../lib";
import {
AreaChart,
Card,
Title,
Text,
DateRangePickerValue,
} from "@tremor/react";
export function FeedbackChart({
timeRange,
}: {
timeRange: DateRangePickerValue;
}) {
const {
data: queryAnalyticsData,
isLoading: isQueryAnalyticsLoading,
error: queryAnalyticsError,
} = useQueryAnalytics(timeRange);
let chart;
if (isQueryAnalyticsLoading) {
chart = (
<div className="h-80 flex flex-col">
<ThreeDotsLoader />
</div>
);
} else if (!queryAnalyticsData || queryAnalyticsError) {
chart = (
<div className="h-80 text-red-600 text-bold flex flex-col">
<p className="m-auto">Failed to fetch feedback data...</p>
</div>
);
} else {
const initialDate = timeRange.from || new Date(queryAnalyticsData[0].date);
const dateRange = getDatesList(initialDate);
const dateToQueryAnalytics = new Map(
queryAnalyticsData.map((queryAnalyticsEntry) => [
queryAnalyticsEntry.date,
queryAnalyticsEntry,
])
);
chart = (
<AreaChart
className="mt-4 h-80"
data={dateRange.map((dateStr) => {
const queryAnalyticsForDate = dateToQueryAnalytics.get(dateStr);
return {
Day: dateStr,
"Positive Feedback": queryAnalyticsForDate?.total_likes || 0,
"Negative Feedback": queryAnalyticsForDate?.total_dislikes || 0,
};
})}
categories={["Positive Feedback", "Negative Feedback"]}
index="Day"
colors={["indigo", "fuchsia"]}
valueFormatter={(number: number) =>
`${Intl.NumberFormat("us").format(number).toString()}`
}
yAxisWidth={60}
/>
);
}
return (
<Card className="mt-8">
<Title>Feedback</Title>
<Text>Thumbs Up / Thumbs Down over time</Text>
{chart}
</Card>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import {
Card,
AreaChart,
Title,
Text,
DateRangePickerValue,
} from "@tremor/react";
import { getDatesList, useQueryAnalytics, useUserAnalytics } from "../lib";
import { ThreeDotsLoader } from "@/components/Loading";
export function QueryPerformanceChart({
timeRange,
}: {
timeRange: DateRangePickerValue;
}) {
const {
data: queryAnalyticsData,
isLoading: isQueryAnalyticsLoading,
error: queryAnalyticsError,
} = useQueryAnalytics(timeRange);
const {
data: userAnalyticsData,
isLoading: isUserAnalyticsLoading,
error: userAnalyticsError,
} = useUserAnalytics(timeRange);
let chart;
if (isQueryAnalyticsLoading || isUserAnalyticsLoading) {
chart = (
<div className="h-80 flex flex-col">
<ThreeDotsLoader />
</div>
);
} else if (
!queryAnalyticsData ||
!userAnalyticsData ||
queryAnalyticsError ||
userAnalyticsError
) {
chart = (
<div className="h-80 text-red-600 text-bold flex flex-col">
<p className="m-auto">Failed to fetch query data...</p>
</div>
);
} else {
const initialDate = timeRange.from || new Date(queryAnalyticsData[0].date);
const dateRange = getDatesList(initialDate);
const dateToQueryAnalytics = new Map(
queryAnalyticsData.map((queryAnalyticsEntry) => [
queryAnalyticsEntry.date,
queryAnalyticsEntry,
])
);
const dateToUserAnalytics = new Map(
userAnalyticsData.map((userAnalyticsEntry) => [
userAnalyticsEntry.date,
userAnalyticsEntry,
])
);
chart = (
<AreaChart
className="h-80"
data={dateRange.map((dateStr) => {
const queryAnalyticsForDate = dateToQueryAnalytics.get(dateStr);
const userAnalyticsForDate = dateToUserAnalytics.get(dateStr);
return {
Day: dateStr,
Queries: queryAnalyticsForDate?.total_queries || 0,
"Unique Users": userAnalyticsForDate?.total_active_users || 0,
};
})}
categories={["Queries", "Unique Users"]}
index="Day"
colors={["indigo", "fuchsia"]}
valueFormatter={(number: number) =>
`${Intl.NumberFormat("us").format(number).toString()}`
}
yAxisWidth={60}
/>
);
}
return (
<Card className="mt-8">
<Title>Usage</Title>
<Text>Usage over time</Text>
{chart}
</Card>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { DateRangeSelector } from "../DateRangeSelector";
import { FeedbackChart } from "./FeedbackChart";
import { QueryPerformanceChart } from "./QueryPerformanceChart";
import { BarChartIcon } from "@/components/icons/icons";
import { useTimeRange } from "../lib";
export default function AnalyticsPage() {
const [timeRange, setTimeRange] = useTimeRange();
return (
<main className="pt-4 mx-auto container dark">
{/* TODO: remove this `dark` once we have a mode selector */}
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<BarChartIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Analytics</h1>
</div>
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
<QueryPerformanceChart timeRange={timeRange} />
<FeedbackChart timeRange={timeRange} />
</main>
);
}

View File

@@ -0,0 +1,28 @@
import { Feedback } from "@/lib/types";
export interface QueryAnalytics {
total_queries: number;
total_likes: number;
total_dislikes: number;
date: string;
}
export interface UserAnalytics {
total_active_users: number;
date: string;
}
export interface AbridgedSearchDoc {
document_id: string;
semantic_identifier: string;
link: string | null;
}
export interface QuerySnapshot {
id: number;
query: string;
llm_answer: string;
retrieved_documents: AbridgedSearchDoc[];
time_created: string;
feedback: Feedback | null;
}

View File

@@ -0,0 +1,26 @@
export function getXDaysAgo(daysAgo: number) {
const today = new Date();
const daysAgoDate = new Date(today);
daysAgoDate.setDate(today.getDate() - daysAgo);
return daysAgoDate;
}
export function convertDateToEndOfDay(date?: Date | null) {
if (!date) {
return date;
}
const dateCopy = new Date(date);
dateCopy.setHours(23, 59, 59, 999);
return dateCopy;
}
export function convertDateToStartOfDay(date?: Date | null) {
if (!date) {
return date;
}
const dateCopy = new Date(date);
dateCopy.setHours(0, 0, 0, 0);
return dateCopy;
}

View File

@@ -0,0 +1,86 @@
import { errorHandlingFetcher } from "@/lib/fetcher";
import useSWR, { mutate } from "swr";
import {
QueryAnalytics,
QuerySnapshot,
UserAnalytics,
} from "./analytics/types";
import { useState } from "react";
import { buildApiPath } from "@/lib/urlBuilder";
import { Feedback } from "@/lib/types";
import { DateRangePickerValue } from "@tremor/react";
import {
convertDateToEndOfDay,
convertDateToStartOfDay,
getXDaysAgo,
} from "./dateUtils";
import { THIRTY_DAYS } from "./DateRangeSelector";
export const useTimeRange = () => {
return useState<DateRangePickerValue>({
to: new Date(),
from: getXDaysAgo(30),
selectValue: THIRTY_DAYS,
});
};
export const useQueryAnalytics = (timeRange: DateRangePickerValue) => {
const url = buildApiPath("/api/analytics/admin/query", {
start: convertDateToStartOfDay(timeRange.from)?.toISOString(),
end: convertDateToEndOfDay(timeRange.to)?.toISOString(),
});
const swrResponse = useSWR<QueryAnalytics[]>(url, errorHandlingFetcher);
return {
...swrResponse,
refreshQueryAnalytics: () => mutate(url),
};
};
export const useUserAnalytics = (timeRange: DateRangePickerValue) => {
const url = buildApiPath("/api/analytics/admin/user", {
start: convertDateToStartOfDay(timeRange.from)?.toISOString(),
end: convertDateToEndOfDay(timeRange.to)?.toISOString(),
});
const swrResponse = useSWR<UserAnalytics[]>(url, errorHandlingFetcher);
return {
...swrResponse,
refreshUserAnalytics: () => mutate(url),
};
};
export const useQueryHistory = () => {
const [selectedFeedbackType, setSelectedFeedbackType] =
useState<Feedback | null>(null);
const [timeRange, setTimeRange] = useTimeRange();
const url = buildApiPath("/api/admin/query-history", {
feedback_type: selectedFeedbackType,
start: convertDateToStartOfDay(timeRange.from)?.toISOString(),
end: convertDateToEndOfDay(timeRange.to)?.toISOString(),
});
const swrResponse = useSWR<QuerySnapshot[]>(url, errorHandlingFetcher);
return {
...swrResponse,
selectedFeedbackType,
setSelectedFeedbackType: (feedbackType: Feedback | "all") =>
setSelectedFeedbackType(feedbackType === "all" ? null : feedbackType),
timeRange,
setTimeRange,
refreshQueryHistory: () => mutate(url),
};
};
export function getDatesList(startDate: Date): string[] {
const datesList: string[] = [];
const endDate = new Date(); // current date
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split("T")[0]; // convert date object to 'YYYY-MM-DD' format
datesList.push(dateStr);
}
return datesList;
}

View File

@@ -0,0 +1,30 @@
import { Feedback } from "@/lib/types";
import { Badge } from "@tremor/react";
export function FeedbackBadge({ feedback }: { feedback?: Feedback | null }) {
let feedbackBadge;
switch (feedback) {
case "like":
feedbackBadge = (
<Badge color="green" className="text-sm">
Like
</Badge>
);
break;
case "dislike":
feedbackBadge = (
<Badge color="red" className="text-sm">
Dislike
</Badge>
);
break;
default:
feedbackBadge = (
<Badge color="gray" className="text-sm">
N/A
</Badge>
);
break;
}
return feedbackBadge;
}

View File

@@ -0,0 +1,172 @@
import { useQueryHistory } from "../lib";
import {
Card,
Table,
TableHead,
TableRow,
TableHeaderCell,
TableBody,
TableCell,
Text,
} from "@tremor/react";
import { Divider } from "@tremor/react";
import { Select, SelectItem } from "@tremor/react";
import { ThreeDotsLoader } from "@/components/Loading";
import { QuerySnapshot } from "../analytics/types";
import { timestampToDateString } from "@/lib/dateUtils";
import { FiBook, FiFrown, FiMinus, FiSmile } from "react-icons/fi";
import { useState } from "react";
import { Feedback } from "@/lib/types";
import { DateRangeSelector } from "../DateRangeSelector";
import { PageSelector } from "@/components/PageSelector";
import Link from "next/link";
import { FeedbackBadge } from "./FeedbackBadge";
const NUM_IN_PAGE = 20;
function QueryHistoryTableRow({
querySnapshot,
}: {
querySnapshot: QuerySnapshot;
}) {
return (
<TableRow
key={querySnapshot.id}
className="hover:bg-gradient-to-r hover:from-gray-800 hover:to-indigo-950 cursor-pointer relative"
>
<TableCell>{querySnapshot.query}</TableCell>
<TableCell>
<Text className="whitespace-normal line-clamp-5">
{querySnapshot.llm_answer}
</Text>
</TableCell>
<TableCell>
{querySnapshot.retrieved_documents.slice(0, 5).map((document) => (
<div className="flex" key={document.document_id}>
<FiBook className="my-auto mr-1" />{" "}
<p className="max-w-xs text-ellipsis overflow-hidden">
{document.semantic_identifier}
</p>
</div>
))}
</TableCell>
<TableCell>
<FeedbackBadge feedback={querySnapshot.feedback} />
</TableCell>
<TableCell>{timestampToDateString(querySnapshot.time_created)}</TableCell>
{/* Wrapping in <td> to avoid console warnings */}
<td className="w-0 p-0">
<Link
href={`/admin/performance/query-history/${querySnapshot.id}`}
className="absolute w-full h-full left-0"
></Link>
</td>
</TableRow>
);
}
function SelectFeedbackType({
value,
onValueChange,
}: {
value: Feedback | "all";
onValueChange: (value: Feedback | "all") => void;
}) {
return (
<div>
<div className="text-sm my-auto mr-2 font-medium text-gray-200 mb-1">
Feedback Type
</div>
<div className="max-w-sm space-y-6">
<Select
value={value}
onValueChange={onValueChange as (value: string) => void}
enableClear={false}
>
<SelectItem value="all" icon={FiMinus}>
Any
</SelectItem>
<SelectItem value="like" icon={FiSmile}>
Like
</SelectItem>
<SelectItem value="dislike" icon={FiFrown}>
Dislike
</SelectItem>
</Select>
</div>
</div>
);
}
export function QueryHistoryTable() {
const {
data: queryHistoryData,
selectedFeedbackType,
setSelectedFeedbackType,
timeRange,
setTimeRange,
} = useQueryHistory();
const [page, setPage] = useState(1);
return (
<Card className="mt-8">
{queryHistoryData ? (
<>
<div className="gap-y-3 flex flex-col">
<SelectFeedbackType
value={selectedFeedbackType || "all"}
onValueChange={setSelectedFeedbackType}
/>
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
</div>
<Divider />
<Table className="mt-5">
<TableHead>
<TableRow>
<TableHeaderCell>Query</TableHeaderCell>
<TableHeaderCell>LLM Answer</TableHeaderCell>
<TableHeaderCell>Retrieved Documents</TableHeaderCell>
<TableHeaderCell>Feedback</TableHeaderCell>
<TableHeaderCell>Date</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{queryHistoryData
.slice(NUM_IN_PAGE * (page - 1), NUM_IN_PAGE * page)
.map((querySnapshot) => (
<QueryHistoryTableRow
key={querySnapshot.id}
querySnapshot={querySnapshot}
/>
))}
</TableBody>
</Table>
<div className="mt-3 flex">
<div className="mx-auto">
<PageSelector
totalPages={Math.ceil(queryHistoryData.length / NUM_IN_PAGE)}
currentPage={page}
onPageChange={(newPage) => {
setPage(newPage);
window.scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
}}
/>
</div>
</div>
</>
) : (
<div className="h-80 flex flex-col">
<ThreeDotsLoader />
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
/* TODO: bring this out of EE */
import { useRouter } from "next/navigation";
import { FiChevronLeft } from "react-icons/fi";
export function BackButton() {
const router = useRouter();
return (
<div
className="my-auto flex mb-1 hover:bg-gray-800 w-fit pr-2 cursor-pointer rounded-lg"
onClick={() => router.back()}
>
<FiChevronLeft className="mr-1 my-auto" />
Back
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { Bold, Text, Card, Title, Divider } from "@tremor/react";
import { QuerySnapshot } from "../../analytics/types";
import { buildUrl } from "@/lib/utilsSS";
import { BackButton } from "./BackButton";
import { FiBook } from "react-icons/fi";
import { processCookies } from "@/lib/userSS";
import { cookies } from "next/headers";
export default async function QueryPage({
params,
}: {
params: { id: string };
}) {
const response = await fetch(buildUrl(`/admin/query-history/${params.id}`), {
next: { revalidate: 0 },
headers: {
cookie: processCookies(cookies()),
},
});
const queryEvent = (await response.json()) as QuerySnapshot;
return (
<main className="pt-4 mx-auto container dark">
<BackButton />
<Card className="mt-4">
<Title>Query Details</Title>
<Divider />
<div className="flex flex-col gap-y-3">
<div>
<Bold>Query</Bold>
<Text className="flex flex-wrap whitespace-normal mt-1">
{queryEvent.query}
</Text>
</div>
<div>
<Bold>Answer</Bold>
<Text className="flex flex-wrap whitespace-normal mt-1">
{queryEvent.llm_answer}
</Text>
</div>
<div>
<Bold>Retrieved Documents</Bold>
<div className="flex flex-col gap-y-2 mt-1">
{queryEvent.retrieved_documents?.map((document) => {
return (
<Text className="flex" key={document.document_id}>
<FiBook
className={
"my-auto mr-1" +
(document.link ? " text-blue-500" : " ")
}
/>
{document.link ? (
<a
href={document.link}
target="_blank"
className="text-blue-500"
>
{document.semantic_identifier}
</a>
) : (
document.semantic_identifier
)}
</Text>
);
})}
</div>
</div>
</div>
</Card>
</main>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import { QueryHistoryTable } from "./QueryHistoryTable";
import { DatabaseIcon } from "@/components/icons/icons";
export default function QueryHistoryPage() {
return (
<main className="pt-4 mx-auto container dark">
{/* TODO: remove this `dark` once we have a mode selector */}
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<DatabaseIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Query History</h1>
</div>
<QueryHistoryTable />
</main>
);
}

View File

@@ -9,6 +9,8 @@ import {
RobotIcon,
ConnectorIcon,
GroupsIcon,
BarChartIcon,
DatabaseIcon,
} from "@/components/icons/icons";
import { User } from "@/lib/types";
import {
@@ -196,6 +198,33 @@ export async function Layout({ children }: { children: React.ReactNode }) {
: []),
],
},
...(EE_ENABLED
? [
{
name: "Performance",
items: [
{
name: (
<div className="flex">
<BarChartIcon size={18} />
<div className="ml-1">Anaytics</div>
</div>
),
link: "/admin/performance/analytics",
},
{
name: (
<div className="flex">
<DatabaseIcon size={18} />
<div className="ml-1">Query History</div>
</div>
),
link: "/admin/performance/query-history",
},
],
},
]
: []),
{
name: "Settings",
items: [

View File

@@ -37,6 +37,8 @@ import {
FiUploadCloud,
FiUser,
FiUsers,
FiBarChart2,
FiDatabase,
} from "react-icons/fi";
import { SiBookstack } from "react-icons/si";
import Image from "next/image";
@@ -705,3 +707,21 @@ export const WikipediaIcon = ({
<Image src={wikipediaIcon} alt="Logo" width="96" height="96" />
</div>
);
/*
EE Icons
*/
export const BarChartIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <FiBarChart2 size={size} className={className} />;
};
export const DatabaseIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <FiDatabase size={size} className={className} />;
};

20
web/src/lib/clickUtils.ts Normal file
View File

@@ -0,0 +1,20 @@
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { MouseEventHandler } from "react";
export function buildRedirectClickHandler(
url: string,
router?: AppRouterInstance
) {
const redirectHandler: MouseEventHandler = (event) => {
if (event.button === 1 || event.shiftKey) {
window.open(url, "_blank");
}
if (router) {
router.push(url);
} else {
window.open(url);
}
};
return redirectHandler;
}

View File

@@ -4,3 +4,15 @@ export function getXDaysAgo(daysAgo: number) {
daysAgoDate.setDate(today.getDate() - daysAgo);
return daysAgoDate;
}
export const timestampToDateString = (timestamp: string) => {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = date.getMonth() + 1; // getMonth() is zero-based
const day = date.getDate();
const formattedDate = `${year}-${month.toString().padStart(2, "0")}-${day
.toString()
.padStart(2, "0")}`;
return formattedDate;
};

21
web/src/lib/urlBuilder.ts Normal file
View File

@@ -0,0 +1,21 @@
type QueryParams = {
[key: string]: string | number | boolean | null | undefined;
};
export function buildApiPath(base: string, params?: QueryParams): string {
let queryString = "";
if (params) {
const entries = Object.entries(params)
.filter(([key, value]) => value !== null && value !== undefined)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value!.toString())}`
);
if (entries.length > 0) {
queryString = `?${entries.join("&")}`;
}
}
return `${base}${queryString}`;
}