mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-19 12:03:54 +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.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
|
||||
|
||||
|
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 =
|
||||
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*",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
|
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,
|
||||
ConnectorIcon,
|
||||
GroupsIcon,
|
||||
BarChartIcon,
|
||||
DatabaseIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { User } from "@/lib/types";
|
||||
import {
|
||||
@@ -196,6 +198,33 @@ export async function Layout({ children }: { children: React.ReactNode }) {
|
||||
: []),
|
||||
],
|
||||
},
|
||||
...(EE_ENABLED
|
||||
? [
|
||||
{
|
||||
name: "Performance",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<BarChartIcon size={18} />
|
||||
<div className="ml-1">Anaytics</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/analytics",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<DatabaseIcon size={18} />
|
||||
<div className="ml-1">Query History</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/query-history",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Settings",
|
||||
items: [
|
||||
|
@@ -37,6 +37,8 @@ import {
|
||||
FiUploadCloud,
|
||||
FiUser,
|
||||
FiUsers,
|
||||
FiBarChart2,
|
||||
FiDatabase,
|
||||
} from "react-icons/fi";
|
||||
import { SiBookstack } from "react-icons/si";
|
||||
import Image from "next/image";
|
||||
@@ -705,3 +707,21 @@ export const WikipediaIcon = ({
|
||||
<Image src={wikipediaIcon} alt="Logo" width="96" height="96" />
|
||||
</div>
|
||||
);
|
||||
|
||||
/*
|
||||
EE Icons
|
||||
*/
|
||||
|
||||
export const BarChartIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return <FiBarChart2 size={size} className={className} />;
|
||||
};
|
||||
|
||||
export const DatabaseIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return <FiDatabase size={size} className={className} />;
|
||||
};
|
||||
|
20
web/src/lib/clickUtils.ts
Normal file
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);
|
||||
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