From e9f273d99adc895799cab21864060c4538dc2391 Mon Sep 17 00:00:00 2001
From: Chris Weaver <25087905+Weves@users.noreply.github.com>
Date: Sat, 21 Oct 2023 20:02:59 -0700
Subject: [PATCH] Admin Analytics/Query History dashboards (#6)
---
backend/ee/danswer/db/analytics.py | 64 +++++++
backend/ee/danswer/db/document.py | 14 ++
backend/ee/danswer/db/query_history.py | 57 ++++++
backend/ee/danswer/main.py | 5 +
backend/ee/danswer/server/analytics/api.py | 81 +++++++++
.../ee/danswer/server/query_history/api.py | 116 ++++++++++++
web/next.config.js | 14 ++
.../admin/performance/DateRangeSelector.tsx | 48 +++++
.../performance/analytics/FeedbackChart.tsx | 75 ++++++++
.../analytics/QueryPerformanceChart.tsx | 94 ++++++++++
.../ee/admin/performance/analytics/page.tsx | 26 +++
.../ee/admin/performance/analytics/types.ts | 28 +++
web/src/app/ee/admin/performance/dateUtils.ts | 26 +++
web/src/app/ee/admin/performance/lib.ts | 86 +++++++++
.../query-history/FeedbackBadge.tsx | 30 +++
.../query-history/QueryHistoryTable.tsx | 172 ++++++++++++++++++
.../query-history/[id]/BackButton.tsx | 18 ++
.../performance/query-history/[id]/page.tsx | 78 ++++++++
.../admin/performance/query-history/page.tsx | 17 ++
web/src/components/admin/Layout.tsx | 29 +++
web/src/components/icons/icons.tsx | 20 ++
web/src/lib/clickUtils.ts | 20 ++
web/src/lib/dateUtils.ts | 12 ++
web/src/lib/urlBuilder.ts | 21 +++
24 files changed, 1151 insertions(+)
create mode 100644 backend/ee/danswer/db/analytics.py
create mode 100644 backend/ee/danswer/db/document.py
create mode 100644 backend/ee/danswer/db/query_history.py
create mode 100644 backend/ee/danswer/server/analytics/api.py
create mode 100644 backend/ee/danswer/server/query_history/api.py
create mode 100644 web/src/app/ee/admin/performance/DateRangeSelector.tsx
create mode 100644 web/src/app/ee/admin/performance/analytics/FeedbackChart.tsx
create mode 100644 web/src/app/ee/admin/performance/analytics/QueryPerformanceChart.tsx
create mode 100644 web/src/app/ee/admin/performance/analytics/page.tsx
create mode 100644 web/src/app/ee/admin/performance/analytics/types.ts
create mode 100644 web/src/app/ee/admin/performance/dateUtils.ts
create mode 100644 web/src/app/ee/admin/performance/lib.ts
create mode 100644 web/src/app/ee/admin/performance/query-history/FeedbackBadge.tsx
create mode 100644 web/src/app/ee/admin/performance/query-history/QueryHistoryTable.tsx
create mode 100644 web/src/app/ee/admin/performance/query-history/[id]/BackButton.tsx
create mode 100644 web/src/app/ee/admin/performance/query-history/[id]/page.tsx
create mode 100644 web/src/app/ee/admin/performance/query-history/page.tsx
create mode 100644 web/src/lib/clickUtils.ts
create mode 100644 web/src/lib/urlBuilder.ts
diff --git a/backend/ee/danswer/db/analytics.py b/backend/ee/danswer/db/analytics.py
new file mode 100644
index 000000000000..1eba26a8585e
--- /dev/null
+++ b/backend/ee/danswer/db/analytics.py
@@ -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
diff --git a/backend/ee/danswer/db/document.py b/backend/ee/danswer/db/document.py
new file mode 100644
index 000000000000..5a368ea170e2
--- /dev/null
+++ b/backend/ee/danswer/db/document.py
@@ -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()
diff --git a/backend/ee/danswer/db/query_history.py b/backend/ee/danswer/db/query_history.py
new file mode 100644
index 000000000000..5badca06bd70
--- /dev/null
+++ b/backend/ee/danswer/db/query_history.py
@@ -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()
diff --git a/backend/ee/danswer/main.py b/backend/ee/danswer/main.py
index 666a69f3a154..2318716b8ebf 100644
--- a/backend/ee/danswer/main.py
+++ b/backend/ee/danswer/main.py
@@ -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
diff --git a/backend/ee/danswer/server/analytics/api.py b/backend/ee/danswer/server/analytics/api.py
new file mode 100644
index 000000000000..72910446edc4
--- /dev/null
+++ b/backend/ee/danswer/server/analytics/api.py
@@ -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()
+ ]
diff --git a/backend/ee/danswer/server/query_history/api.py b/backend/ee/danswer/server/query_history/api.py
new file mode 100644
index 000000000000..c1176fbe351b
--- /dev/null
+++ b/backend/ee/danswer/server/query_history/api.py
@@ -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)
diff --git a/web/next.config.js b/web/next.config.js
index 5c1b92c7f11a..05f872c235bc 100644
--- a/web/next.config.js
+++ b/web/next.config.js
@@ -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*",
+ },
]
: [];
diff --git a/web/src/app/ee/admin/performance/DateRangeSelector.tsx b/web/src/app/ee/admin/performance/DateRangeSelector.tsx
new file mode 100644
index 000000000000..63164a5bcd47
--- /dev/null
+++ b/web/src/app/ee/admin/performance/DateRangeSelector.tsx
@@ -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 (
+
+
+ Date Range
+
+
+
+ Last 30 days
+
+
+ Today
+
+
+
+ );
+}
diff --git a/web/src/app/ee/admin/performance/analytics/FeedbackChart.tsx b/web/src/app/ee/admin/performance/analytics/FeedbackChart.tsx
new file mode 100644
index 000000000000..a466d866e060
--- /dev/null
+++ b/web/src/app/ee/admin/performance/analytics/FeedbackChart.tsx
@@ -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 = (
+
+
+
+ );
+ } else if (!queryAnalyticsData || queryAnalyticsError) {
+ chart = (
+
+
Failed to fetch feedback data...
+
+ );
+ } 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 = (
+ {
+ 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 (
+
+ Feedback
+ Thumbs Up / Thumbs Down over time
+ {chart}
+
+ );
+}
diff --git a/web/src/app/ee/admin/performance/analytics/QueryPerformanceChart.tsx b/web/src/app/ee/admin/performance/analytics/QueryPerformanceChart.tsx
new file mode 100644
index 000000000000..b16e80bf698c
--- /dev/null
+++ b/web/src/app/ee/admin/performance/analytics/QueryPerformanceChart.tsx
@@ -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 = (
+
+
+
+ );
+ } else if (
+ !queryAnalyticsData ||
+ !userAnalyticsData ||
+ queryAnalyticsError ||
+ userAnalyticsError
+ ) {
+ chart = (
+
+
Failed to fetch query data...
+
+ );
+ } 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 = (
+ {
+ 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 (
+
+ Usage
+ Usage over time
+ {chart}
+
+ );
+}
diff --git a/web/src/app/ee/admin/performance/analytics/page.tsx b/web/src/app/ee/admin/performance/analytics/page.tsx
new file mode 100644
index 000000000000..e0cfe86ba9eb
--- /dev/null
+++ b/web/src/app/ee/admin/performance/analytics/page.tsx
@@ -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 (
+
+ {/* TODO: remove this `dark` once we have a mode selector */}
+
+
+
Analytics
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/ee/admin/performance/analytics/types.ts b/web/src/app/ee/admin/performance/analytics/types.ts
new file mode 100644
index 000000000000..6e50f466e408
--- /dev/null
+++ b/web/src/app/ee/admin/performance/analytics/types.ts
@@ -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;
+}
diff --git a/web/src/app/ee/admin/performance/dateUtils.ts b/web/src/app/ee/admin/performance/dateUtils.ts
new file mode 100644
index 000000000000..caffdd999ab3
--- /dev/null
+++ b/web/src/app/ee/admin/performance/dateUtils.ts
@@ -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;
+}
diff --git a/web/src/app/ee/admin/performance/lib.ts b/web/src/app/ee/admin/performance/lib.ts
new file mode 100644
index 000000000000..ee5a9923e73d
--- /dev/null
+++ b/web/src/app/ee/admin/performance/lib.ts
@@ -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({
+ 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(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(url, errorHandlingFetcher);
+
+ return {
+ ...swrResponse,
+ refreshUserAnalytics: () => mutate(url),
+ };
+};
+
+export const useQueryHistory = () => {
+ const [selectedFeedbackType, setSelectedFeedbackType] =
+ useState(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(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;
+}
diff --git a/web/src/app/ee/admin/performance/query-history/FeedbackBadge.tsx b/web/src/app/ee/admin/performance/query-history/FeedbackBadge.tsx
new file mode 100644
index 000000000000..5000f301dc85
--- /dev/null
+++ b/web/src/app/ee/admin/performance/query-history/FeedbackBadge.tsx
@@ -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 = (
+
+ Like
+
+ );
+ break;
+ case "dislike":
+ feedbackBadge = (
+
+ Dislike
+
+ );
+ break;
+ default:
+ feedbackBadge = (
+
+ N/A
+
+ );
+ break;
+ }
+ return feedbackBadge;
+}
diff --git a/web/src/app/ee/admin/performance/query-history/QueryHistoryTable.tsx b/web/src/app/ee/admin/performance/query-history/QueryHistoryTable.tsx
new file mode 100644
index 000000000000..cf7dc092d9c2
--- /dev/null
+++ b/web/src/app/ee/admin/performance/query-history/QueryHistoryTable.tsx
@@ -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 (
+
+ {querySnapshot.query}
+
+
+ {querySnapshot.llm_answer}
+
+
+
+ {querySnapshot.retrieved_documents.slice(0, 5).map((document) => (
+
+
{" "}
+
+ {document.semantic_identifier}
+
+
+ ))}
+
+
+
+
+ {timestampToDateString(querySnapshot.time_created)}
+ {/* Wrapping in to avoid console warnings */}
+ |
+
+ |
+
+ );
+}
+
+function SelectFeedbackType({
+ value,
+ onValueChange,
+}: {
+ value: Feedback | "all";
+ onValueChange: (value: Feedback | "all") => void;
+}) {
+ return (
+
+
+ Feedback Type
+
+
+
+
+
+ );
+}
+
+export function QueryHistoryTable() {
+ const {
+ data: queryHistoryData,
+ selectedFeedbackType,
+ setSelectedFeedbackType,
+ timeRange,
+ setTimeRange,
+ } = useQueryHistory();
+
+ const [page, setPage] = useState(1);
+
+ return (
+
+ {queryHistoryData ? (
+ <>
+
+
+
+
+
+
+
+
+
+ Query
+ LLM Answer
+ Retrieved Documents
+ Feedback
+ Date
+
+
+
+ {queryHistoryData
+ .slice(NUM_IN_PAGE * (page - 1), NUM_IN_PAGE * page)
+ .map((querySnapshot) => (
+
+ ))}
+
+
+
+
+
+
{
+ setPage(newPage);
+ window.scrollTo({
+ top: 0,
+ left: 0,
+ behavior: "smooth",
+ });
+ }}
+ />
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/app/ee/admin/performance/query-history/[id]/BackButton.tsx b/web/src/app/ee/admin/performance/query-history/[id]/BackButton.tsx
new file mode 100644
index 000000000000..2089a071b638
--- /dev/null
+++ b/web/src/app/ee/admin/performance/query-history/[id]/BackButton.tsx
@@ -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 (
+ router.back()}
+ >
+
+ Back
+
+ );
+}
diff --git a/web/src/app/ee/admin/performance/query-history/[id]/page.tsx b/web/src/app/ee/admin/performance/query-history/[id]/page.tsx
new file mode 100644
index 000000000000..614c124bf1f0
--- /dev/null
+++ b/web/src/app/ee/admin/performance/query-history/[id]/page.tsx
@@ -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 (
+
+
+
+
+ Query Details
+
+
+
+
+
+ Query
+
+ {queryEvent.query}
+
+
+
+
+ Answer
+
+ {queryEvent.llm_answer}
+
+
+
+
+
Retrieved Documents
+
+ {queryEvent.retrieved_documents?.map((document) => {
+ return (
+
+
+ {document.link ? (
+
+ {document.semantic_identifier}
+
+ ) : (
+ document.semantic_identifier
+ )}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/ee/admin/performance/query-history/page.tsx b/web/src/app/ee/admin/performance/query-history/page.tsx
new file mode 100644
index 000000000000..4144912e21d0
--- /dev/null
+++ b/web/src/app/ee/admin/performance/query-history/page.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { QueryHistoryTable } from "./QueryHistoryTable";
+import { DatabaseIcon } from "@/components/icons/icons";
+
+export default function QueryHistoryPage() {
+ return (
+
+ {/* TODO: remove this `dark` once we have a mode selector */}
+
+
+
Query History
+
+
+
+ );
+}
diff --git a/web/src/components/admin/Layout.tsx b/web/src/components/admin/Layout.tsx
index ab2d08d1e64a..4bfd527d580d 100644
--- a/web/src/components/admin/Layout.tsx
+++ b/web/src/components/admin/Layout.tsx
@@ -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: (
+
+ ),
+ link: "/admin/performance/analytics",
+ },
+ {
+ name: (
+
+ ),
+ link: "/admin/performance/query-history",
+ },
+ ],
+ },
+ ]
+ : []),
{
name: "Settings",
items: [
diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx
index 1e99fadab7ed..99c3589106f8 100644
--- a/web/src/components/icons/icons.tsx
+++ b/web/src/components/icons/icons.tsx
@@ -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 = ({
);
+
+/*
+EE Icons
+*/
+
+export const BarChartIcon = ({
+ size = 16,
+ className = defaultTailwindCSS,
+}: IconProps) => {
+ return ;
+};
+
+export const DatabaseIcon = ({
+ size = 16,
+ className = defaultTailwindCSS,
+}: IconProps) => {
+ return ;
+};
diff --git a/web/src/lib/clickUtils.ts b/web/src/lib/clickUtils.ts
new file mode 100644
index 000000000000..bde09c4b104f
--- /dev/null
+++ b/web/src/lib/clickUtils.ts
@@ -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;
+}
diff --git a/web/src/lib/dateUtils.ts b/web/src/lib/dateUtils.ts
index 0c0e1fd238e3..5693d0d422e2 100644
--- a/web/src/lib/dateUtils.ts
+++ b/web/src/lib/dateUtils.ts
@@ -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;
+};
diff --git a/web/src/lib/urlBuilder.ts b/web/src/lib/urlBuilder.ts
new file mode 100644
index 000000000000..73aec4997ad2
--- /dev/null
+++ b/web/src/lib/urlBuilder.ts
@@ -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}`;
+}