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: ( +
+ +
Anaytics
+
+ ), + link: "/admin/performance/analytics", + }, + { + name: ( +
+ +
Query History
+
+ ), + 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 = ({ Logo ); + +/* +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}`; +}