mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-25 19:37:29 +02:00
Admin Analytics/Query History dashboards (#6)
This commit is contained in:
64
backend/ee/danswer/db/analytics.py
Normal file
64
backend/ee/danswer/db/analytics.py
Normal 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
|
14
backend/ee/danswer/db/document.py
Normal file
14
backend/ee/danswer/db/document.py
Normal 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()
|
57
backend/ee/danswer/db/query_history.py
Normal file
57
backend/ee/danswer/db/query_history.py
Normal 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()
|
@@ -16,6 +16,8 @@ from danswer.main import get_application
|
|||||||
from danswer.utils.logger import setup_logger
|
from danswer.utils.logger import setup_logger
|
||||||
from danswer.utils.variable_functionality import global_version
|
from danswer.utils.variable_functionality import global_version
|
||||||
from ee.danswer.configs.app_configs import OPENID_CONFIG_URL
|
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.saml import router as saml_router
|
||||||
from ee.danswer.server.user_group.api import router as user_group_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
|
# RBAC / group access control
|
||||||
application.include_router(user_group_router)
|
application.include_router(user_group_router)
|
||||||
|
# analytics endpoints
|
||||||
|
application.include_router(analytics_router)
|
||||||
|
application.include_router(query_history_router)
|
||||||
|
|
||||||
return application
|
return application
|
||||||
|
|
||||||
|
81
backend/ee/danswer/server/analytics/api.py
Normal file
81
backend/ee/danswer/server/analytics/api.py
Normal 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()
|
||||||
|
]
|
116
backend/ee/danswer/server/query_history/api.py
Normal file
116
backend/ee/danswer/server/query_history/api.py
Normal 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)
|
@@ -12,6 +12,7 @@ const nextConfig = {
|
|||||||
const eeRedirects =
|
const eeRedirects =
|
||||||
process.env.NEXT_PUBLIC_EE_ENABLED === "true"
|
process.env.NEXT_PUBLIC_EE_ENABLED === "true"
|
||||||
? [
|
? [
|
||||||
|
// user group pages
|
||||||
{
|
{
|
||||||
source: "/admin/groups",
|
source: "/admin/groups",
|
||||||
destination: "/ee/admin/groups",
|
destination: "/ee/admin/groups",
|
||||||
@@ -20,6 +21,19 @@ const nextConfig = {
|
|||||||
source: "/admin/groups/:path*",
|
source: "/admin/groups/:path*",
|
||||||
destination: "/ee/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*",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
48
web/src/app/ee/admin/performance/DateRangeSelector.tsx
Normal file
48
web/src/app/ee/admin/performance/DateRangeSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
75
web/src/app/ee/admin/performance/analytics/FeedbackChart.tsx
Normal file
75
web/src/app/ee/admin/performance/analytics/FeedbackChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
26
web/src/app/ee/admin/performance/analytics/page.tsx
Normal file
26
web/src/app/ee/admin/performance/analytics/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
28
web/src/app/ee/admin/performance/analytics/types.ts
Normal file
28
web/src/app/ee/admin/performance/analytics/types.ts
Normal 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;
|
||||||
|
}
|
26
web/src/app/ee/admin/performance/dateUtils.ts
Normal file
26
web/src/app/ee/admin/performance/dateUtils.ts
Normal 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;
|
||||||
|
}
|
86
web/src/app/ee/admin/performance/lib.ts
Normal file
86
web/src/app/ee/admin/performance/lib.ts
Normal 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;
|
||||||
|
}
|
@@ -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;
|
||||||
|
}
|
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
78
web/src/app/ee/admin/performance/query-history/[id]/page.tsx
Normal file
78
web/src/app/ee/admin/performance/query-history/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
17
web/src/app/ee/admin/performance/query-history/page.tsx
Normal file
17
web/src/app/ee/admin/performance/query-history/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -9,6 +9,8 @@ import {
|
|||||||
RobotIcon,
|
RobotIcon,
|
||||||
ConnectorIcon,
|
ConnectorIcon,
|
||||||
GroupsIcon,
|
GroupsIcon,
|
||||||
|
BarChartIcon,
|
||||||
|
DatabaseIcon,
|
||||||
} from "@/components/icons/icons";
|
} from "@/components/icons/icons";
|
||||||
import { User } from "@/lib/types";
|
import { User } from "@/lib/types";
|
||||||
import {
|
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",
|
name: "Settings",
|
||||||
items: [
|
items: [
|
||||||
|
@@ -37,6 +37,8 @@ import {
|
|||||||
FiUploadCloud,
|
FiUploadCloud,
|
||||||
FiUser,
|
FiUser,
|
||||||
FiUsers,
|
FiUsers,
|
||||||
|
FiBarChart2,
|
||||||
|
FiDatabase,
|
||||||
} from "react-icons/fi";
|
} from "react-icons/fi";
|
||||||
import { SiBookstack } from "react-icons/si";
|
import { SiBookstack } from "react-icons/si";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -705,3 +707,21 @@ export const WikipediaIcon = ({
|
|||||||
<Image src={wikipediaIcon} alt="Logo" width="96" height="96" />
|
<Image src={wikipediaIcon} alt="Logo" width="96" height="96" />
|
||||||
</div>
|
</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
20
web/src/lib/clickUtils.ts
Normal 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;
|
||||||
|
}
|
@@ -4,3 +4,15 @@ export function getXDaysAgo(daysAgo: number) {
|
|||||||
daysAgoDate.setDate(today.getDate() - daysAgo);
|
daysAgoDate.setDate(today.getDate() - daysAgo);
|
||||||
return daysAgoDate;
|
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
21
web/src/lib/urlBuilder.ts
Normal 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}`;
|
||||||
|
}
|
Reference in New Issue
Block a user