💡 feat: add DB-backed admin insights (#14898)

* feat: add Mongo-backed admin insights

* feat: gate insights with environment variable

* fix: tighten insights access and activity metrics

* fix: preserve insights date selections

* perf: parallelize insights search aggregation

* test: wait for MCP conflict recovery

* test: satisfy strict MCP recovery typing

* fix: disable insights pagination while loading

* fix: localize insights range shortcuts

* fix: bound insights search input
This commit is contained in:
Ravi Kumar L 2026-08-18 13:51:51 +02:00 committed by GitHub
parent 389cfebea1
commit 006e421cd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 3447 additions and 51 deletions

View file

@ -63,6 +63,9 @@ ADMIN_PANEL_SESSION_SECRET=
# In deploy-compose the panel is served at http://admin.localhost via nginx.
# ADMIN_PANEL_PORT=3000
# Enable the admin-only MongoDB Insights dashboard.
ENABLE_INSIGHTS=false
NO_INDEX=true
# Use the address that is at most n number of hops away from the Express application.
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.

View file

@ -456,6 +456,7 @@ if (cluster.isMaster) {
/** Routes */
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
app.use('/api/admin/insights', routes.insights);
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/skills', routes.adminSkills);
app.use('/api/actions', routes.actions);

View file

@ -308,6 +308,7 @@ const startServer = async () => {
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
/* API Endpoints */
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
app.use('/api/admin/insights', routes.insights);
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/config', routes.adminConfig);
app.use('/api/admin/langfuse', routes.adminLangfuse);

View file

@ -104,6 +104,7 @@ afterEach(() => {
delete process.env.SAML_SESSION_SECRET;
delete process.env.ALLOW_ACCOUNT_DELETION;
delete process.env.ADMIN_PANEL_URL;
delete process.env.ENABLE_INSIGHTS;
delete process.env.ANALYTICS_GTM_ID;
delete process.env.CUSTOM_FOOTER;
delete process.env.HELP_AND_FAQ_URL;
@ -174,6 +175,7 @@ describe('GET /api/config', () => {
expect(response.body).not.toHaveProperty('sharePointPickerGraphScope');
expect(response.body).not.toHaveProperty('sharePointPickerSharePointScope');
expect(response.body).not.toHaveProperty('conversationImportMaxFileSize');
expect(response.body).not.toHaveProperty('insightsEnabled');
});
it('should strip authenticated-only informational fields from unauthenticated response (#12688)', async () => {
@ -398,6 +400,18 @@ describe('GET /api/config', () => {
expect(response.body.conversationImportMaxFileSize).toBe(5000000);
});
it('should advertise Insights only when ENABLE_INSIGHTS is enabled', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
const app = createApp(mockUser);
let response = await request(app).get('/api/config');
expect(response.body.insightsEnabled).toBe(false);
process.env.ENABLE_INSIGHTS = 'true';
response = await request(app).get('/api/config');
expect(response.body.insightsEnabled).toBe(true);
});
it('should advertise Langfuse fanout only when the toggle and collector URL are configured', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockHasCapability.mockResolvedValue(true);

View file

@ -0,0 +1,93 @@
const express = require('express');
const request = require('supertest');
const mockCreateInsightsAccessHandler = jest.fn(() => (_req, res) => res.json({ access: true }));
const mockCreateInsightsHandler = jest.fn(() => (_req, res) => res.json({ summary: {} }));
const mockGrantedCapabilities = new Set(['access:admin', 'read:insights']);
const mockGetInsights = jest.fn();
let mockUser = { id: 'admin-id', role: 'ADMIN' };
jest.mock('@librechat/api', () => ({
createInsightsAccessHandler: (...args) => mockCreateInsightsAccessHandler(...args),
createInsightsHandler: (...args) => mockCreateInsightsHandler(...args),
isEnabled: (value) => value === 'true',
}));
jest.mock('@librechat/data-schemas', () => ({
SystemCapabilities: {
ACCESS_ADMIN: 'access:admin',
READ_INSIGHTS: 'read:insights',
},
}));
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, _res, next) => {
req.user = mockUser;
next();
},
checkAdmin: (req, res, next) => {
if (req.user.role !== 'ADMIN') {
return res.status(403).json({ message: 'Forbidden' });
}
next();
},
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
requireCapability: (capability) => (_req, res, next) => {
if (!mockGrantedCapabilities.has(capability)) {
return res.status(403).json({ message: 'Forbidden' });
}
next();
},
}));
jest.mock('~/models', () => ({
getInsights: (...args) => mockGetInsights(...args),
}));
const insightsRouter = require('../insights');
function createApp() {
const app = express();
app.use('/api/admin/insights', insightsRouter);
return app;
}
describe('Insights routes', () => {
beforeEach(() => {
mockUser = { id: 'admin-id', role: 'ADMIN' };
mockGrantedCapabilities.clear();
mockGrantedCapabilities.add('access:admin');
mockGrantedCapabilities.add('read:insights');
});
it('requires the ADMIN role', async () => {
mockUser = { id: 'delegated-admin-id', role: 'DELEGATED_ADMIN' };
const response = await request(createApp()).get('/api/admin/insights');
expect(response.status).toBe(403);
});
it('serves the access probe and dashboard', async () => {
const app = createApp();
await expect(request(app).get('/api/admin/insights/access')).resolves.toMatchObject({
status: 200,
body: { access: true },
});
await expect(request(app).get('/api/admin/insights')).resolves.toMatchObject({
status: 200,
body: { summary: {} },
});
});
it.each(['access:admin', 'read:insights'])('requires %s', async (capability) => {
mockGrantedCapabilities.delete(capability);
const response = await request(createApp()).get('/api/admin/insights');
expect(response.status).toBe(403);
});
});

View file

@ -304,6 +304,7 @@ router.get('/', async function (req, res) {
: 0,
langfuseFanoutEnabled,
langfuseConnectionAccess,
insightsEnabled: isEnabled(process.env.ENABLE_INSIGHTS),
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
fileUploadSseEnabled: isEnabled(process.env.FILE_UPLOAD_SSE_ENABLED),

View file

@ -37,8 +37,10 @@ const keys = require('./keys');
const user = require('./user');
const mcp = require('./mcp');
const rum = require('./rum');
const insights = require('./insights');
module.exports = {
insights,
rum,
mcp,
auth,

View file

@ -0,0 +1,17 @@
const express = require('express');
const { createInsightsAccessHandler, createInsightsHandler, isEnabled } = require('@librechat/api');
const { SystemCapabilities } = require('@librechat/data-schemas');
const { requireJwtAuth, checkAdmin } = require('~/server/middleware');
const { requireCapability } = require('~/server/middleware/roles/capabilities');
const db = require('~/models');
const router = express.Router();
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
const requireInsightsAccess = requireCapability(SystemCapabilities.READ_INSIGHTS);
const isInsightsEnabled = () => isEnabled(process.env.ENABLE_INSIGHTS);
router.use(requireJwtAuth, checkAdmin, requireAdminAccess, requireInsightsAccess);
router.get('/access', createInsightsAccessHandler({ isInsightsEnabled }));
router.get('/', createInsightsHandler({ isInsightsEnabled, getInsights: db.getInsights }));
module.exports = router;

View file

@ -0,0 +1,746 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertCircle, Info, Search } from 'lucide-react';
import { Button, Input, Spinner, TooltipAnchor, useMediaQuery } from '@librechat/client';
import {
INSIGHTS_MAX_RANGE_DAYS,
INSIGHTS_SEARCH_MAX_LENGTH,
INSIGHTS_SEARCH_MIN_LENGTH,
SystemRoles,
} from 'librechat-data-provider';
import type {
InsightsRange,
TInsightsChurnedUser,
TInsightsConversation,
TInsightsParams,
TInsightsUser,
} from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import { useGetStartupConfig, useInsightsAccessQuery, useInsightsQuery } from '~/data-provider';
import { useAuthContext, useDocumentTitle, useLocalize } from '~/hooks';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import { LocalizedDateRangePicker } from '~/components/ui';
import { getRollingDateRange } from './dateRange';
import { cn } from '~/utils';
type ShortcutRange = Exclude<InsightsRange, 'custom'>;
type Localize = ReturnType<typeof useLocalize>;
type SparklinePoint = { date: string; value: number };
type CustomDateRange = { startDate: Date; endDate: Date };
type KpiCardData = {
id: 'conversations' | 'users' | 'messages' | 'tokens';
title: string;
value: number;
sparkline: SparklinePoint[];
};
const ranges: Array<{ value: ShortcutRange; labelKey: TranslationKeys; days: number }> = [
{ value: '24h', labelKey: 'com_insights_range_24_hours', days: 1 },
{ value: '7d', labelKey: 'com_insights_range_7_days', days: 7 },
{ value: '30d', labelKey: 'com_insights_range_30_days', days: 30 },
];
const dateRangeSelectionDelayMs = 350;
const searchDelayMs = 350;
function formatValue(value: number, locale: string) {
return new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value);
}
function formatExactValue(value: number, locale: string) {
return new Intl.NumberFormat(locale).format(value);
}
function formatDate(value: string, locale: string) {
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(value));
}
function formatRecentChatDate(value: string, locale: string) {
const date = new Date(value);
const now = new Date();
const elapsedMinutes = Math.floor((now.getTime() - date.getTime()) / 60_000);
const relativeTime = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' });
if (elapsedMinutes < 1) {
return relativeTime.format(0, 'minute');
}
if (elapsedMinutes < 60) {
return relativeTime.format(-elapsedMinutes, 'minute');
}
if (elapsedMinutes < 24 * 60) {
return relativeTime.format(-Math.floor(elapsedMinutes / 60), 'hour');
}
if (elapsedMinutes < 7 * 24 * 60) {
return relativeTime.format(-Math.floor(elapsedMinutes / (24 * 60)), 'day');
}
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
}).format(date);
}
function displayUserName(name: string, localize: Localize) {
return name || localize('com_insights_unknown_user');
}
function responseStatus(error: unknown) {
return (error as { response?: { status?: number } } | undefined)?.response?.status;
}
function getShortcutDateRange(range: ShortcutRange) {
const days = ranges.find((item) => item.value === range)?.days ?? 7;
return getRollingDateRange(new Date(), days);
}
function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<section
className={cn(
'min-w-0 rounded-lg border border-border-light bg-surface-primary p-5',
className,
)}
>
{children}
</section>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex min-h-40 items-center justify-center text-sm text-text-secondary">
{message}
</div>
);
}
function LoadingState({ message }: { message: string }) {
return (
<Panel className="flex min-h-52 flex-col items-center justify-center gap-3">
<Spinner className="size-7 text-text-secondary" />
<span className="text-sm text-text-secondary">{message}</span>
</Panel>
);
}
function Sparkline({
values,
label,
locale,
}: {
values: SparklinePoint[];
label: string;
locale: string;
}) {
const patternId = useId().replace(/:/g, '');
const [activeIndex, setActiveIndex] = useState<number>();
const points = useMemo(() => {
if (values.length < 2) {
return [];
}
const maximum = Math.max(1, ...values.map((point) => point.value));
return values.map((point, index) => ({
...point,
x: (index / (values.length - 1)) * 300,
y: 52 - (point.value / maximum) * 46,
}));
}, [values]);
if (points.length < 2) {
return <div className="h-14" />;
}
const line = points.map((point) => `${point.x},${point.y}`).join(' ');
const area = `M 0 56 L ${points.map((point) => `${point.x} ${point.y}`).join(' L ')} L 300 56 Z`;
const activePoint = activeIndex == null ? undefined : points[activeIndex];
const formatter = new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
return (
<div
className="relative mt-2 h-14 w-full text-status-info outline-none"
role="img"
tabIndex={0}
aria-label={label}
onFocus={() => setActiveIndex(points.length - 1)}
onBlur={() => setActiveIndex(undefined)}
onMouseLeave={() => setActiveIndex(undefined)}
onMouseMove={(event) => {
const bounds = event.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width));
setActiveIndex(Math.round(ratio * (points.length - 1)));
}}
>
<svg
className="h-full w-full overflow-visible"
viewBox="0 0 300 56"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<pattern id={patternId} width="6" height="6" patternUnits="userSpaceOnUse">
<path
d="M-1 1 L1 -1 M0 6 L6 0 M5 7 L7 5"
stroke="currentColor"
strokeWidth="0.8"
opacity="0.22"
/>
</pattern>
</defs>
<path d={area} fill={`url(#${patternId})`} />
<polyline
points={line}
fill="none"
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
{activePoint && (
<>
<span
className="pointer-events-none absolute top-0 h-full w-px bg-border-medium"
style={{ left: `${(activePoint.x / 300) * 100}%` }}
/>
<span
className="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-status-info ring-2 ring-surface-primary"
style={{
left: `${(activePoint.x / 300) * 100}%`,
top: `${(activePoint.y / 56) * 100}%`,
}}
/>
<span
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-light bg-surface-primary px-2.5 py-1.5 text-xs text-text-primary shadow-lg"
style={{ left: `${Math.max(14, Math.min(86, (activePoint.x / 300) * 100))}%` }}
>
{formatter.format(new Date(activePoint.date))}{' '}
<strong>{formatExactValue(activePoint.value, locale)}</strong>
</span>
</>
)}
</div>
);
}
function KpiCard({ card, locale }: { card: KpiCardData; locale: string }) {
const localize = useLocalize();
return (
<Panel>
<h2 className="text-lg font-normal text-text-secondary">{card.title}</h2>
<div className="mt-3 text-4xl font-semibold tabular-nums leading-none text-text-primary">
{formatValue(card.value, locale)}
</div>
<Sparkline
values={card.sparkline}
label={localize('com_insights_sparkline_accessibility', { label: card.title })}
locale={locale}
/>
</Panel>
);
}
function TablePanel({ title, children }: { title: React.ReactNode; children: React.ReactNode }) {
return (
<Panel className="overflow-hidden">
<h2 className="mb-3 text-base font-semibold text-text-primary">{title}</h2>
{children}
</Panel>
);
}
function UserCell({ name, email, localize }: { name: string; email: string; localize: Localize }) {
return (
<div className="min-w-0">
<div className="truncate text-text-primary">{displayUserName(name, localize)}</div>
<div className="truncate text-xs text-text-secondary">{email}</div>
</div>
);
}
function TopUsersTable({
rows,
localize,
locale,
}: {
rows: TInsightsUser[];
localize: Localize;
locale: string;
}) {
return (
<TablePanel title={localize('com_insights_top_users')}>
{rows.length === 0 ? (
<EmptyState message={localize('com_insights_no_data')} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[420px] text-left text-sm">
<thead className="border-b border-border-medium text-xs text-text-secondary">
<tr>
<th className="px-2 py-2 font-medium">{localize('com_insights_user')}</th>
<th className="px-2 py-2 text-right font-medium">
{localize('com_insights_messages')}
</th>
<th className="px-2 py-2 text-right font-medium">
{localize('com_insights_chats')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{rows.map((entry) => (
<tr key={entry.userId} className="hover:bg-surface-hover">
<td className="px-2 py-3">
<UserCell {...entry} localize={localize} />
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(entry.messages, locale)}
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(entry.conversations, locale)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TablePanel>
);
}
function ChurnedUsersTable({
rows,
localize,
locale,
}: {
rows: TInsightsChurnedUser[];
localize: Localize;
locale: string;
}) {
const definition = localize('com_insights_churned_users_definition');
return (
<TablePanel
title={
<span className="inline-flex items-center gap-1.5">
{localize('com_insights_churned_users')}
<TooltipAnchor
description={definition}
render={
<button
type="button"
aria-label={definition}
className="text-text-secondary hover:text-text-primary"
>
<Info className="size-4" aria-hidden="true" />
</button>
}
/>
</span>
}
>
{rows.length === 0 ? (
<EmptyState message={localize('com_insights_no_data')} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] table-fixed text-left text-sm">
<thead className="border-b border-border-medium text-xs text-text-secondary">
<tr>
<th className="w-[34%] px-2 py-2 font-medium">{localize('com_insights_user')}</th>
<th className="w-[12%] px-2 py-2 text-right font-medium">
{localize('com_insights_messages')}
</th>
<th className="w-[12%] px-2 py-2 text-right font-medium">
{localize('com_insights_chats')}
</th>
<th className="w-[21%] px-2 py-2 text-right font-medium">
{localize('com_insights_first_seen')}
</th>
<th className="w-[21%] px-2 py-2 text-right font-medium">
{localize('com_insights_last_seen')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{rows.map((entry) => (
<tr key={entry.userId} className="hover:bg-surface-hover">
<td className="overflow-hidden px-2 py-3">
<UserCell {...entry} localize={localize} />
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(entry.messages, locale)}
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(entry.conversations, locale)}
</td>
<td className="whitespace-nowrap px-2 py-3 text-right text-text-secondary">
{formatDate(entry.firstSeen, locale)}
</td>
<td className="whitespace-nowrap px-2 py-3 text-right text-text-secondary">
{formatDate(entry.lastSeen, locale)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TablePanel>
);
}
function LatestConversations({
rows,
search,
activeSearch,
page,
pages,
isFetching,
setPage,
setSearch,
localize,
locale,
}: {
rows: TInsightsConversation[];
search: string;
activeSearch: string;
page: number;
pages: number;
isFetching: boolean;
setPage: React.Dispatch<React.SetStateAction<number>>;
setSearch: React.Dispatch<React.SetStateAction<string>>;
localize: Localize;
locale: string;
}) {
return (
<Panel className="overflow-hidden">
<div className="mb-3 flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">
{localize('com_insights_latest_conversations')}
</h2>
{isFetching && <Spinner className="size-4 text-text-secondary" />}
</div>
<div className="relative w-full sm:max-w-md">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-text-secondary"
aria-hidden="true"
/>
<Input
aria-label={localize('com_insights_search_placeholder')}
className="pl-9"
maxLength={INSIGHTS_SEARCH_MAX_LENGTH}
onChange={(event) => setSearch(event.target.value)}
placeholder={localize('com_insights_search_placeholder')}
value={search}
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] table-fixed text-left text-sm">
<thead className="border-b border-border-medium text-xs text-text-secondary">
<tr>
<th className="w-[120px] px-2 py-2 font-medium">{localize('com_insights_date')}</th>
<th className="w-[192px] px-2 py-2 font-medium">{localize('com_insights_user')}</th>
<th className="px-2 py-2 font-medium">{localize('com_insights_first_message')}</th>
<th className="w-20 px-2 py-2 text-right font-medium">
{localize('com_insights_messages')}
</th>
<th className="w-20 px-2 py-2 text-right font-medium">
{localize('com_insights_total_tokens')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{rows.map((conversation) => (
<tr
key={`${conversation.conversationId}:${conversation.userId}`}
className="hover:bg-surface-hover"
>
<td className="whitespace-nowrap px-2 py-3 text-text-secondary">
{formatRecentChatDate(conversation.date, locale)}
</td>
<td className="px-2 py-3">
<UserCell {...conversation} localize={localize} />
</td>
<td className="max-w-xl px-2 py-3">
<span className="line-clamp-2">
{conversation.firstMessage || localize('com_insights_no_message')}
</span>
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatExactValue(conversation.messages, locale)}
</td>
<td className="px-2 py-3 text-right tabular-nums">
{formatValue(conversation.totalTokens, locale)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{rows.length === 0 && (
<EmptyState
message={
activeSearch
? localize('com_insights_no_search_results')
: localize('com_insights_no_data')
}
/>
)}
<div className="mt-3 flex items-center justify-between gap-3 border-t border-border-light pt-3 text-sm text-text-secondary">
<span>{localize('com_insights_page_of', { page, pages })}</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={isFetching || page <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
>
{localize('com_ui_prev')}
</Button>
<Button
variant="outline"
size="sm"
disabled={isFetching || page >= pages}
onClick={() => setPage((value) => value + 1)}
>
{localize('com_ui_next')}
</Button>
</div>
</div>
</Panel>
);
}
export default function InsightsView() {
const localize = useLocalize();
const { i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en';
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const { user } = useAuthContext();
const { data: startupConfig, isLoading: configLoading } = useGetStartupConfig();
const [range, setRange] = useState<ShortcutRange>('7d');
const [customDateRange, setCustomDateRange] = useState<CustomDateRange>();
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const dateRangeSelectionTimeout = useRef<number>();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const insightsFeatureEnabled = startupConfig?.insightsEnabled === true;
const shouldCheckAccess = user?.role === SystemRoles.ADMIN && insightsFeatureEnabled;
const access = useInsightsAccessQuery(user?.id, {
enabled: shouldCheckAccess,
});
const isAllowed = insightsFeatureEnabled && access.data?.access === true;
const insightsParams = useMemo<TInsightsParams>(() => {
const params: TInsightsParams = { page, pageSize: 10, search, timeZone };
if (customDateRange) {
return {
...params,
range: 'custom',
fromTimestamp: customDateRange.startDate.toISOString(),
toTimestamp: customDateRange.endDate.toISOString(),
};
}
return { ...params, range };
}, [customDateRange, page, range, search, timeZone]);
const displayDateRange = useMemo(
() => customDateRange ?? getShortcutDateRange(range),
[customDateRange, range],
);
const insights = useInsightsQuery(insightsParams, { enabled: isAllowed });
const data = insights.data;
useDocumentTitle(`${localize('com_insights_title')} | LibreChat`);
useEffect(
() => () => {
if (dateRangeSelectionTimeout.current != null) {
window.clearTimeout(dateRangeSelectionTimeout.current);
}
},
[],
);
useEffect(() => {
const trimmedSearch = searchInput.trim().slice(0, INSIGHTS_SEARCH_MAX_LENGTH);
const nextSearch = trimmedSearch.length >= INSIGHTS_SEARCH_MIN_LENGTH ? trimmedSearch : '';
if (nextSearch === search) {
return;
}
const timeout = window.setTimeout(() => {
setSearch(nextSearch);
setPage(1);
}, searchDelayMs);
return () => window.clearTimeout(timeout);
}, [search, searchInput]);
const kpiCards = useMemo<KpiCardData[]>(() => {
if (!data) {
return [];
}
return [
{
id: 'conversations',
title: localize('com_insights_total_conversations'),
value: data.summary.totalConversations,
sparkline: data.daily.map((row) => ({ date: row.date, value: row.conversations })),
},
{
id: 'users',
title: localize('com_insights_total_users'),
value: data.summary.totalUsers,
sparkline: data.daily.map((row) => ({ date: row.date, value: row.users })),
},
{
id: 'messages',
title: localize('com_insights_messages'),
value: data.summary.totalMessages,
sparkline: data.daily.map((row) => ({ date: row.date, value: row.messages })),
},
{
id: 'tokens',
title: localize('com_insights_total_tokens'),
value: data.summary.totalTokens,
sparkline: data.daily.map((row) => ({ date: row.date, value: row.totalTokens })),
},
];
}, [data, localize]);
const handleSelectDateRange = (startDate: Date, endDate: Date) => {
if (dateRangeSelectionTimeout.current != null) {
window.clearTimeout(dateRangeSelectionTimeout.current);
}
dateRangeSelectionTimeout.current = window.setTimeout(() => {
setCustomDateRange({ startDate: new Date(startDate), endDate: new Date(endDate) });
setPage(1);
dateRangeSelectionTimeout.current = undefined;
}, dateRangeSelectionDelayMs);
};
if (configLoading || (shouldCheckAccess && access.isLoading)) {
return (
<div className="h-full w-full bg-presentation p-4">
<LoadingState message={localize('com_insights_loading')} />
</div>
);
}
const accessStatus = responseStatus(access.error);
if (access.isError && accessStatus !== 403 && accessStatus !== 404) {
return (
<div className="h-full w-full bg-presentation p-4">
<Panel className="flex items-center gap-2">
<AlertCircle className="size-4 text-status-error" />
<span className="text-sm">{localize('com_insights_load_error')}</span>
</Panel>
</div>
);
}
if (!isAllowed) {
return <Navigate to="/c/new" replace />;
}
return (
<div className="flex h-full w-full min-w-0 flex-col bg-presentation text-text-primary">
<header className="z-20 flex min-h-14 w-full flex-shrink-0 flex-col gap-3 border-b border-border-light bg-presentation px-4 py-3 sm:px-5 md:flex-row md:items-center md:justify-between md:px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-3">
{isSmallScreen && <OpenSidebar />}
<h1 className="text-base font-semibold">{localize('com_insights_title')}</h1>
</div>
<div className="flex max-w-full flex-wrap items-center gap-2 md:flex-nowrap">
<div className="inline-flex rounded-lg border border-border-light p-0.5">
{ranges.map((item) => (
<Button
key={item.value}
size="sm"
variant="ghost"
aria-pressed={!customDateRange && range === item.value}
className={cn(
'h-8 rounded-md px-3',
!customDateRange && range === item.value && 'bg-surface-active-alt',
)}
onClick={() => {
if (dateRangeSelectionTimeout.current != null) {
window.clearTimeout(dateRangeSelectionTimeout.current);
dateRangeSelectionTimeout.current = undefined;
}
setRange(item.value);
setCustomDateRange(undefined);
setPage(1);
}}
>
{localize(item.labelKey)}
</Button>
))}
</div>
<div className="w-full min-w-0 sm:w-[340px]">
<LocalizedDateRangePicker
endDate={displayDateRange.endDate}
futureDatesDisabled
labels={{
apply: localize('com_ui_done'),
cancel: localize('com_ui_cancel'),
endDate: localize('com_insights_end_date'),
invalidRange: localize('com_insights_invalid_date_range', {
days: INSIGHTS_MAX_RANGE_DAYS,
}),
startDate: localize('com_insights_start_date'),
}}
locale={locale}
maxRangeLength={INSIGHTS_MAX_RANGE_DAYS}
onSelectDateRange={handleSelectDateRange}
placeholder={localize('com_insights_date_range_placeholder')}
startDate={displayDateRange.startDate}
/>
</div>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="flex w-full min-w-0 flex-col gap-5 px-4 py-4 sm:px-5 md:px-6 lg:px-8">
{insights.isLoading && <LoadingState message={localize('com_insights_loading')} />}
{insights.isError && (
<Panel className="flex items-center gap-2">
<AlertCircle className="size-4 text-status-error" />
<span className="text-sm">{localize('com_insights_load_error')}</span>
</Panel>
)}
{data && (
<>
<div className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3">
{kpiCards.map((card) => (
<KpiCard key={card.id} card={card} locale={locale} />
))}
</div>
<div className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,580px),1fr))] gap-3">
<TopUsersTable rows={data.topUsers} localize={localize} locale={locale} />
<ChurnedUsersTable rows={data.churnedUsers} localize={localize} locale={locale} />
</div>
<LatestConversations
rows={data.latest.conversations}
search={searchInput}
activeSearch={search}
page={data.latest.page}
pages={data.latest.pages}
isFetching={insights.isFetching}
setPage={setPage}
setSearch={setSearchInput}
localize={localize}
locale={locale}
/>
</>
)}
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,12 @@
import { getRollingDateRange } from './dateRange';
describe('Insights date ranges', () => {
it('builds shortcut bounds from the same rolling duration as the query', () => {
const endDate = new Date(2026, 7, 31, 15, 30);
const range = getRollingDateRange(endDate, 30);
expect(range.endDate.getTime() - range.startDate.getTime()).toBe(30 * 24 * 60 * 60 * 1000);
expect(range.endDate).toEqual(endDate);
});
});

View file

@ -0,0 +1,7 @@
const millisecondsPerDay = 24 * 60 * 60 * 1000;
export function getRollingDateRange(endDate: Date, days: number) {
const rangeEnd = new Date(endDate);
const rangeStart = new Date(endDate.getTime() - days * millisecondsPerDay);
return { startDate: rangeStart, endDate: rangeEnd };
}

View file

@ -0,0 +1 @@
export { default } from './InsightsView';

View file

@ -1,6 +1,7 @@
import { memo, useCallback, lazy, Suspense } from 'react';
import { useRecoilValue } from 'recoil';
import { SquarePen } from 'lucide-react';
import { useLocation } from 'react-router-dom';
import { Skeleton, Sidebar, Button, TooltipAnchor } from '@librechat/client';
import type { NavLink } from '~/common';
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
@ -58,6 +59,8 @@ const NavIconButton = memo(function NavIconButton({
setActive,
onExpand,
onCollapse,
onNavigate,
onLeaveInsights,
}: {
link: NavLink;
isActive: boolean;
@ -65,6 +68,8 @@ const NavIconButton = memo(function NavIconButton({
setActive: (id: string) => void;
onExpand?: () => void;
onCollapse?: () => void;
onNavigate?: () => void;
onLeaveInsights?: () => void;
}) {
const localize = useLocalize();
@ -72,6 +77,7 @@ const NavIconButton = memo(function NavIconButton({
(e: React.MouseEvent<HTMLButtonElement>) => {
if (link.onClick) {
link.onClick(e);
onNavigate?.();
return;
}
if (isActive && expanded) {
@ -83,9 +89,11 @@ const NavIconButton = memo(function NavIconButton({
}
if (!expanded) {
onExpand?.();
} else {
onLeaveInsights?.();
}
},
[link, isActive, setActive, expanded, onExpand, onCollapse],
[link, isActive, setActive, expanded, onExpand, onCollapse, onNavigate, onLeaveInsights],
);
return (
@ -117,15 +125,21 @@ function ExpandedPanel({
expanded = true,
onCollapse,
onExpand,
onNavigate,
onLeaveInsights,
}: {
links: NavLink[];
expanded?: boolean;
onCollapse?: () => void;
onExpand?: () => void;
onNavigate?: () => void;
onLeaveInsights?: () => void;
}) {
const localize = useLocalize();
const location = useLocation();
const { active, setActive } = useActivePanel();
const effectiveActive = resolveActivePanel(active, links);
const isInsightsRoute = location.pathname.startsWith('/insights');
const toggleLabel = expanded ? 'com_nav_close_sidebar' : 'com_nav_open_sidebar';
const toggleClick = expanded ? onCollapse : onExpand;
@ -160,11 +174,17 @@ function ExpandedPanel({
<NavIconButton
key={link.id}
link={link}
isActive={link.id === effectiveActive}
isActive={
link.id === 'insights'
? isInsightsRoute
: !isInsightsRoute && link.id === effectiveActive
}
expanded={expanded ?? true}
setActive={setActive}
onExpand={onExpand}
onCollapse={onCollapse}
onNavigate={onNavigate}
onLeaveInsights={isInsightsRoute ? onLeaveInsights : undefined}
/>
))}
</div>

View file

@ -9,6 +9,7 @@ function Sidebar({
expanded,
onCollapse,
onExpand,
onLeaveInsights,
onResizeStart,
onResizeKeyboard,
}: {
@ -16,6 +17,7 @@ function Sidebar({
expanded: boolean;
onCollapse: () => void;
onExpand: () => void;
onLeaveInsights: () => void;
onResizeStart: (e: React.MouseEvent) => void;
onResizeKeyboard: (direction: 'shrink' | 'grow') => void;
}) {
@ -27,6 +29,7 @@ function Sidebar({
expanded={expanded}
onCollapse={onCollapse}
onExpand={onExpand}
onLeaveInsights={onLeaveInsights}
/>
<nav
className={cn(

View file

@ -1,5 +1,6 @@
import { useCallback, useState, useEffect, useRef, memo } from 'react';
import { useForm } from 'react-hook-form';
import { useLocation, useNavigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import type { ChatFormValues } from '~/common';
import {
@ -44,6 +45,8 @@ function SidebarChatProvider({ children }: { children: ReactNode }) {
function UnifiedSidebar() {
const localize = useLocalize();
const location = useLocation();
const navigate = useNavigate();
const { isSmallScreen, expanded } = useSidebarState();
const { setSidebarOpen } = useSidebarToggle();
const [sidebarWidth, setSidebarWidth] = useState(getInitialWidth);
@ -51,6 +54,8 @@ function UnifiedSidebar() {
const resizeHandlers = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
const links = useUnifiedSidebarLinks();
const isInsightsRoute = location.pathname.startsWith('/insights');
const panelExpanded = expanded && !isInsightsRoute;
const handleCollapse = useCallback(
(afterSlide?: () => void) => {
@ -63,6 +68,17 @@ function UnifiedSidebar() {
setSidebarOpen(true);
}, [setSidebarOpen]);
const handleLeaveInsights = useCallback(() => {
navigate('/c/new');
}, [navigate]);
const handlePanelExpand = useCallback(() => {
if (isInsightsRoute) {
handleLeaveInsights();
}
handleExpand();
}, [handleExpand, handleLeaveInsights, isInsightsRoute]);
const handleResizeStart = useCallback(() => {
setIsResizing(true);
document.body.style.userSelect = 'none';
@ -163,14 +179,24 @@ function UnifiedSidebar() {
>
<SidebarChatProvider>
<ActivePanelProvider>
<MobileHeader links={links} expanded={expanded} onClose={handleCollapse} />
<MobileHeader
links={links}
expanded={expanded}
onClose={handleCollapse}
onLeaveInsights={handleLeaveInsights}
routeActiveId={isInsightsRoute ? 'insights' : undefined}
/>
<nav
id="chat-history-nav"
className="min-h-0 flex-1 overflow-hidden bg-surface-primary-alt"
>
<SidePanelNav links={links} />
</nav>
<MobileShortcutTargets links={links} />
<MobileShortcutTargets
links={links}
onLeaveInsights={handleLeaveInsights}
routeActiveId={isInsightsRoute ? 'insights' : undefined}
/>
<MobileBottomBar links={links} onNewChat={handleCollapse} />
</ActivePanelProvider>
</SidebarChatProvider>
@ -184,9 +210,9 @@ function UnifiedSidebar() {
<aside
className="relative flex h-full flex-shrink-0 overflow-hidden"
style={{
width: expanded ? sidebarWidth : COLLAPSED_WIDTH,
minWidth: expanded ? EXPANDED_MIN : COLLAPSED_WIDTH,
maxWidth: expanded ? '40%' : COLLAPSED_WIDTH,
width: panelExpanded ? sidebarWidth : COLLAPSED_WIDTH,
minWidth: panelExpanded ? EXPANDED_MIN : COLLAPSED_WIDTH,
maxWidth: panelExpanded ? '40%' : COLLAPSED_WIDTH,
transition: isResizing
? 'none'
: `width ${TRANSITION_MS}ms ${EASING}, min-width ${TRANSITION_MS}ms ${EASING}, max-width ${TRANSITION_MS}ms ${EASING}`,
@ -195,9 +221,10 @@ function UnifiedSidebar() {
>
<Sidebar
links={links}
expanded={expanded}
expanded={panelExpanded}
onCollapse={handleCollapse}
onExpand={handleExpand}
onExpand={handlePanelExpand}
onLeaveInsights={handleLeaveInsights}
onResizeStart={handleResizeStart}
onResizeKeyboard={handleResizeKeyboard}
/>

View file

@ -1,11 +1,13 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import '@testing-library/jest-dom/extend-expect';
import { MemoryRouter } from 'react-router-dom';
import { MessagesSquare, NotebookPen } from 'lucide-react';
import { render, fireEvent, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { MutableSnapshot } from 'recoil';
import { ActivePanelProvider, DEFAULT_PANEL } from '~/Providers/ActivePanelContext';
import type { NavLink } from '~/common';
import { ActivePanelProvider, DEFAULT_PANEL } from '~/Providers';
const mockNewConversation = jest.fn();
const mockClearMessagesCache = jest.fn();
@ -86,7 +88,7 @@ jest.mock('~/components/Nav/AccountSettings', () => ({
import ExpandedPanel from '../ExpandedPanel';
import store from '~/store';
const createLinks = () => [
const createLinks = (): NavLink[] => [
{
title: 'com_ui_chat_history' as const,
icon: MessagesSquare,
@ -103,14 +105,18 @@ const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { r
function renderPanel({
expanded = true,
links = createLinks(),
onCollapse = jest.fn(),
onExpand = jest.fn(),
onNavigate,
initialPanel = DEFAULT_PANEL,
initializeState,
}: {
expanded?: boolean;
links?: NavLink[];
onCollapse?: jest.Mock;
onExpand?: jest.Mock;
onNavigate?: jest.Mock;
initialPanel?: string;
initializeState?: (snapshot: MutableSnapshot) => void;
} = {}) {
@ -119,18 +125,21 @@ function renderPanel({
}
const result = render(
<QueryClientProvider client={createQueryClient()}>
<RecoilRoot initializeState={initializeState}>
<ActivePanelProvider>
<ExpandedPanel
links={createLinks()}
expanded={expanded}
onCollapse={onCollapse}
onExpand={onExpand}
/>
</ActivePanelProvider>
</RecoilRoot>
</QueryClientProvider>,
<MemoryRouter>
<QueryClientProvider client={createQueryClient()}>
<RecoilRoot initializeState={initializeState}>
<ActivePanelProvider>
<ExpandedPanel
links={links}
expanded={expanded}
onCollapse={onCollapse}
onExpand={onExpand}
onNavigate={onNavigate}
/>
</ActivePanelProvider>
</RecoilRoot>
</QueryClientProvider>
</MemoryRouter>,
);
return { ...result, onCollapse, onExpand };
@ -172,6 +181,26 @@ describe('ExpandedPanel', () => {
expect(onExpand).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('side:active-panel')).toBe('prompts');
});
it('notifies mobile navigation after a route link is selected', () => {
const onClick = jest.fn();
const onNavigate = jest.fn();
const links = [
...createLinks(),
{
title: 'com_insights_navigation' as const,
icon: NotebookPen,
id: 'insights',
onClick,
},
];
renderPanel({ links, onNavigate });
fireEvent.click(screen.getByRole('button', { name: 'com_insights_navigation' }));
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledTimes(1);
});
});
describe('NewChatButton panel switch', () => {

View file

@ -20,10 +20,14 @@ function Header({
links,
expanded,
onClose,
onLeaveInsights,
routeActiveId,
}: {
links: NavLink[];
expanded: boolean;
onClose: () => void;
onLeaveInsights?: () => void;
routeActiveId?: string;
}) {
const localize = useLocalize();
const toggleSidebarAriaKey = useShortcutAriaKey('toggleSidebar');
@ -64,7 +68,12 @@ function Header({
>
<Sidebar className="icon-md" aria-hidden="true" />
</Button>
<Switcher links={links} />
<Switcher
links={links}
onLeaveInsights={onLeaveInsights}
onNavigate={onClose}
routeActiveId={routeActiveId}
/>
<Suspense fallback={<Skeleton className="size-9 rounded-lg" />}>
<AccountSettings collapsed />
</Suspense>

View file

@ -16,9 +16,17 @@ import { useActivePanel, resolveActivePanel } from '~/Providers';
* Only available links render, so a shortcut for a panel this endpoint does not
* offer still correctly does nothing.
*/
function ShortcutTargets({ links }: { links: NavLink[] }) {
function ShortcutTargets({
links,
onLeaveInsights,
routeActiveId,
}: {
links: NavLink[];
onLeaveInsights?: () => void;
routeActiveId?: string;
}) {
const { active, setActive } = useActivePanel();
const activeId = resolveActivePanel(active, links);
const activeId = routeActiveId ?? resolveActivePanel(active, links);
return (
<>
@ -30,7 +38,16 @@ function ShortcutTargets({ links }: { links: NavLink[] }) {
tabIndex={-1}
data-testid={`nav-panel-${link.id}`}
aria-pressed={link.id === activeId}
onClick={() => setActive(link.id)}
onClick={() => {
if (link.onClick) {
link.onClick();
return;
}
setActive(link.id);
if (routeActiveId) {
onLeaveInsights?.();
}
}}
/>
))}
</>

View file

@ -13,13 +13,23 @@ import { cn } from '~/utils';
* you leave it. Replaces the icon rail's ten unlabelled glyphs with labelled
* rows, and costs no standing width.
*/
function Switcher({ links }: { links: NavLink[] }) {
function Switcher({
links,
onLeaveInsights,
onNavigate,
routeActiveId,
}: {
links: NavLink[];
onLeaveInsights?: () => void;
onNavigate?: () => void;
routeActiveId?: string;
}) {
const localize = useLocalize();
const menuId = useId();
const [isOpen, setIsOpen] = useState(false);
const { active, setActive } = useActivePanel();
const activeId = resolveActivePanel(active, links);
const activeId = routeActiveId ?? resolveActivePanel(active, links);
const activeLink = links.find((link) => link.id === activeId);
const items = useMemo<t.MenuItemProps[]>(
@ -30,9 +40,19 @@ function Switcher({ links }: { links: NavLink[] }) {
ariaChecked: link.id === activeId,
className: link.id === activeId ? 'bg-surface-active-alt' : undefined,
icon: <link.icon className="size-5 text-text-primary" aria-hidden="true" />,
onClick: () => setActive(link.id),
onClick: () => {
if (link.onClick) {
link.onClick();
onNavigate?.();
return;
}
setActive(link.id);
if (routeActiveId) {
onLeaveInsights?.();
}
},
})),
[links, activeId, localize, setActive],
[links, activeId, localize, onLeaveInsights, onNavigate, routeActiveId, setActive],
);
if (!activeLink) {

View file

@ -1,5 +1,5 @@
import { MessagesSquare, NotebookPen } from 'lucide-react';
import { render, screen, fireEvent } from '@testing-library/react';
import { BarChart3, MessagesSquare, NotebookPen } from 'lucide-react';
import type { NavLink } from '~/common';
import { ActivePanelProvider } from '~/Providers/ActivePanelContext';
import ShortcutTargets from '../ShortcutTargets';
@ -9,10 +9,22 @@ const links = [
{ id: 'prompts', title: 'com_ui_prompts', icon: NotebookPen, Component: () => null },
] as unknown as NavLink[];
const renderTargets = () =>
const renderTargets = ({
targetLinks = links,
onLeaveInsights,
routeActiveId,
}: {
targetLinks?: NavLink[];
onLeaveInsights?: () => void;
routeActiveId?: string;
} = {}) =>
render(
<ActivePanelProvider>
<ShortcutTargets links={links} />
<ShortcutTargets
links={targetLinks}
onLeaveInsights={onLeaveInsights}
routeActiveId={routeActiveId}
/>
</ActivePanelProvider>,
);
@ -47,6 +59,35 @@ describe('ShortcutTargets', () => {
expect(localStorage.getItem('side:active-panel')).toBe('prompts');
});
it('uses a route link callback without changing the active panel', () => {
const onClick = jest.fn();
const targetLinks = [
...links,
{
id: 'insights',
title: 'com_insights_navigation',
icon: BarChart3,
onClick,
},
] as unknown as NavLink[];
renderTargets({ targetLinks });
fireEvent.click(screen.getByTestId('nav-panel-insights'));
expect(onClick).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('side:active-panel')).toBeNull();
});
it('leaves a route when a panel target is selected', () => {
const onLeaveInsights = jest.fn();
renderTargets({ onLeaveInsights, routeActiveId: 'insights' });
fireEvent.click(screen.getByTestId('nav-panel-prompts'));
expect(onLeaveInsights).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('side:active-panel')).toBe('prompts');
});
it('offers no target for a panel this endpoint does not have', () => {
renderTargets();

View file

@ -0,0 +1,207 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@librechat/client';
import * as Popover from '@radix-ui/react-popover';
import { CalendarDays, ChevronDown } from 'lucide-react';
type DateRangePickerLabels = {
apply: string;
cancel: string;
endDate: string;
invalidRange: string;
startDate: string;
};
type LocalizedDateRangePickerProps = {
endDate?: Date;
futureDatesDisabled?: boolean;
labels: DateRangePickerLabels;
locale: string;
maxRangeLength?: number;
onSelectDateRange: (startDate: Date, endDate: Date) => void;
placeholder: string;
startDate?: Date;
};
const millisecondsPerDay = 24 * 60 * 60 * 1000;
function toDateInputValue(date?: Date) {
if (!date) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function parseDateInput(value: string, endOfDay = false) {
const [year, month, day] = value.split('-').map(Number);
if (!year || !month || !day) {
return undefined;
}
return endOfDay
? new Date(year, month - 1, day, 23, 59, 59, 999)
: new Date(year, month - 1, day);
}
function addCalendarDays(value: string, days: number) {
const date = parseDateInput(value);
if (!date) {
return '';
}
date.setDate(date.getDate() + days);
return toDateInputValue(date);
}
function calendarDayDifference(startDate: Date, endDate: Date) {
const start = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const end = Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
return Math.round((end - start) / millisecondsPerDay);
}
function earliestDate(...dates: Array<string | undefined>) {
return dates.filter((date): date is string => Boolean(date)).sort()[0];
}
export default function LocalizedDateRangePicker({
endDate,
futureDatesDisabled = false,
labels,
locale,
maxRangeLength = -1,
onSelectDateRange,
placeholder,
startDate,
}: LocalizedDateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [pendingStartDate, setPendingStartDate] = useState(() => toDateInputValue(startDate));
const [pendingEndDate, setPendingEndDate] = useState(() => toDateInputValue(endDate));
const today = toDateInputValue(new Date());
useEffect(() => {
if (!isOpen) {
setPendingStartDate(toDateInputValue(startDate));
setPendingEndDate(toDateInputValue(endDate));
}
}, [endDate, isOpen, startDate]);
const formattedRange = useMemo(() => {
if (!startDate || !endDate) {
return placeholder;
}
const formatter = new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return `${formatter.format(startDate)} - ${formatter.format(endDate)}`;
}, [endDate, locale, placeholder, startDate]);
const parsedStartDate = parseDateInput(pendingStartDate);
const parsedEndDate = parseDateInput(pendingEndDate, true);
const rangeLength =
parsedStartDate && parsedEndDate
? calendarDayDifference(parsedStartDate, parsedEndDate)
: undefined;
const matchesSelectedRange =
pendingStartDate === toDateInputValue(startDate) &&
pendingEndDate === toDateInputValue(endDate);
const selectedRangeDuration =
startDate && endDate ? endDate.getTime() - startDate.getTime() : undefined;
const selectedRangeIsValid =
matchesSelectedRange &&
selectedRangeDuration != null &&
selectedRangeDuration >= 0 &&
(maxRangeLength < 0 || selectedRangeDuration <= maxRangeLength * millisecondsPerDay);
const isValidRange =
rangeLength != null &&
rangeLength >= 0 &&
(maxRangeLength < 0 || rangeLength < maxRangeLength || selectedRangeIsValid) &&
(!futureDatesDisabled || pendingEndDate <= today);
const latestEndDate =
maxRangeLength > 0 && pendingStartDate
? addCalendarDays(pendingStartDate, maxRangeLength - 1)
: undefined;
const endDateMax = earliestDate(latestEndDate, futureDatesDisabled ? today : undefined);
const handleOpenChange = (open: boolean) => {
if (open) {
setPendingStartDate(toDateInputValue(startDate));
setPendingEndDate(toDateInputValue(endDate));
}
setIsOpen(open);
};
const handleApply = () => {
if (!parsedStartDate || !parsedEndDate || !isValidRange) {
return;
}
if (matchesSelectedRange) {
setIsOpen(false);
return;
}
onSelectDateRange(parsedStartDate, parsedEndDate);
setIsOpen(false);
};
return (
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
<Button variant="outline" className="w-full min-w-0 justify-start px-3">
<CalendarDays className="size-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-left">{formattedRange}</span>
<ChevronDown className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
</Button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="end"
sideOffset={6}
className="z-50 w-[320px] max-w-[calc(100vw-2rem)] rounded-lg border border-border-light bg-surface-primary p-4 text-text-primary shadow-lg"
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm">{labels.startDate}</span>
<input
type="date"
lang={locale}
max={futureDatesDisabled ? today : undefined}
value={pendingStartDate}
onChange={(event) => setPendingStartDate(event.target.value)}
className="h-9 min-w-0 rounded-md border border-border-medium bg-surface-primary px-2 text-sm text-text-primary outline-none focus:border-border-heavy"
/>
</label>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm">{labels.endDate}</span>
<input
type="date"
lang={locale}
min={pendingStartDate || undefined}
max={endDateMax}
value={pendingEndDate}
onChange={(event) => setPendingEndDate(event.target.value)}
className="h-9 min-w-0 rounded-md border border-border-medium bg-surface-primary px-2 text-sm text-text-primary outline-none focus:border-border-heavy"
/>
</label>
</div>
{!isValidRange && pendingStartDate && pendingEndDate && (
<span className="text-sm text-status-error">{labels.invalidRange}</span>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
{labels.cancel}
</Button>
<Button size="sm" disabled={!isValidRange} onClick={handleApply}>
{labels.apply}
</Button>
</div>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View file

@ -0,0 +1,92 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import LocalizedDateRangePicker from '../LocalizedDateRangePicker';
const labels = {
apply: 'Apply',
cancel: 'Cancel',
endDate: 'End date',
invalidRange: 'Invalid range',
startDate: 'Start date',
};
const germanDateFormatter = new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
const triggerName = `${germanDateFormatter.format(new Date(2026, 2, 4))} - ${germanDateFormatter.format(new Date(2026, 2, 9))}`;
function renderPicker(onSelectDateRange = jest.fn()) {
render(
<LocalizedDateRangePicker
endDate={new Date(2026, 2, 9, 17)}
futureDatesDisabled={false}
labels={labels}
locale="de-DE"
maxRangeLength={10}
onSelectDateRange={onSelectDateRange}
placeholder="Select dates"
startDate={new Date(2026, 2, 4, 9)}
/>,
);
}
describe('LocalizedDateRangePicker', () => {
it('formats the range with the active locale and exposes localized labels', () => {
renderPicker();
fireEvent.click(screen.getByRole('button', { name: triggerName }));
expect(screen.getByLabelText(labels.startDate)).toHaveAttribute('lang', 'de-DE');
expect(screen.getByLabelText(labels.endDate)).toHaveAttribute('lang', 'de-DE');
expect(screen.getByRole('button', { name: labels.apply })).toBeInTheDocument();
expect(screen.getByRole('button', { name: labels.cancel })).toBeInTheDocument();
});
it('validates the range and returns complete local calendar days', () => {
const onSelectDateRange = jest.fn();
renderPicker(onSelectDateRange);
fireEvent.click(screen.getByRole('button', { name: triggerName }));
fireEvent.change(screen.getByLabelText(labels.endDate), { target: { value: '2026-03-14' } });
expect(screen.getByText(labels.invalidRange)).toBeInTheDocument();
expect(screen.getByRole('button', { name: labels.apply })).toBeDisabled();
fireEvent.change(screen.getByLabelText(labels.endDate), { target: { value: '2026-03-10' } });
fireEvent.click(screen.getByRole('button', { name: labels.apply }));
const [startDate, endDate] = onSelectDateRange.mock.calls[0] as [Date, Date];
expect([startDate.getFullYear(), startDate.getMonth(), startDate.getDate()]).toEqual([
2026, 2, 4,
]);
expect([startDate.getHours(), startDate.getMinutes()]).toEqual([0, 0]);
expect([endDate.getFullYear(), endDate.getMonth(), endDate.getDate()]).toEqual([2026, 2, 10]);
expect([endDate.getHours(), endDate.getMinutes(), endDate.getSeconds()]).toEqual([23, 59, 59]);
});
it('does not convert an unchanged rolling shortcut to a custom range', () => {
const onSelectDateRange = jest.fn();
const startDate = new Date(2026, 7, 1, 15, 30);
const endDate = new Date(2026, 7, 31, 15, 30);
const rangeName = `${germanDateFormatter.format(startDate)} - ${germanDateFormatter.format(endDate)}`;
render(
<LocalizedDateRangePicker
endDate={endDate}
futureDatesDisabled={false}
labels={labels}
locale="de-DE"
maxRangeLength={30}
onSelectDateRange={onSelectDateRange}
placeholder="Select dates"
startDate={startDate}
/>,
);
fireEvent.click(screen.getByRole('button', { name: rangeName }));
fireEvent.click(screen.getByRole('button', { name: labels.apply }));
expect(onSelectDateRange).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: labels.apply })).not.toBeInTheDocument();
});
});

View file

@ -4,4 +4,5 @@ export { default as PanelFooter } from './PanelFooter';
export { default as PanelContent } from './PanelContent';
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
export { default as LocalizedDateRangePicker } from './LocalizedDateRangePicker';
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';

View file

@ -0,0 +1 @@
export * from './queries';

View file

@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type {
TInsightsAccessResponse,
TInsightsParams,
TInsightsResponse,
} from 'librechat-data-provider';
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
export const useInsightsQuery = (
params: TInsightsParams,
config?: UseQueryOptions<TInsightsResponse>,
): QueryObserverResult<TInsightsResponse> =>
useQuery<TInsightsResponse>([QueryKeys.insights, params], () => dataService.getInsights(params), {
keepPreviousData: true,
refetchOnWindowFocus: false,
...config,
});
export const useInsightsAccessQuery = (
userId?: string,
config?: UseQueryOptions<TInsightsAccessResponse>,
): QueryObserverResult<TInsightsAccessResponse> =>
useQuery<TInsightsAccessResponse>(
[QueryKeys.insightsAccess, userId ?? 'anonymous'],
() => dataService.getInsightsAccess(),
{
retry: false,
staleTime: 60_000,
refetchOnWindowFocus: false,
...config,
},
);

View file

@ -4,6 +4,7 @@ export * from './Endpoints';
export * from './Skills';
export * from './Files';
export * from './Langfuse';
export * from './Insights';
/* Memories */
export * from './Memories';
export * from './Messages';

View file

@ -1,18 +1,23 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { MessagesSquare } from 'lucide-react';
import { BarChart3, MessagesSquare } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import { getConfigDefaults, getEndpointField } from 'librechat-data-provider';
import { getConfigDefaults, getEndpointField, SystemRoles } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
import type { NavLink } from '~/common';
import { useGetEndpointsQuery, useGetStartupConfig, useInsightsAccessQuery } from '~/data-provider';
import ConversationsSection from '~/components/UnifiedSidebar/ConversationsSection';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
import { useAuthContext } from '~/hooks';
import store from '~/store';
const defaultInterface = getConfigDefaults().interface;
export default function useUnifiedSidebarLinks() {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthContext();
/** Selector instead of the full conversation atom: the links only depend on
* the endpoint, so parameter edits and other conversation writes stay out. */
const endpoint = useRecoilValue(store.conversationEndpointByIndex(0)) ?? undefined;
@ -23,6 +28,10 @@ export default function useUnifiedSidebarLinks() {
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
const insightsFeatureEnabled = startupConfig?.insightsEnabled === true;
const { data: insightsAccess } = useInsightsAccessQuery(user?.id, {
enabled: user?.role === SystemRoles.ADMIN && insightsFeatureEnabled,
});
const endpointType = useMemo(
() => getEndpointField(endpointsConfig, endpoint, 'type'),
@ -59,8 +68,27 @@ export default function useUnifiedSidebarLinks() {
Component: ConversationsSection,
};
return [conversationLink, ...sideNavLinks];
}, [sideNavLinks]);
if (!insightsFeatureEnabled || insightsAccess?.access !== true) {
return [conversationLink, ...sideNavLinks];
}
const insightsLink: NavLink = {
title: 'com_insights_navigation',
label: '',
icon: BarChart3,
id: 'insights',
onClick: () => {
if (!location.pathname.startsWith('/insights')) {
navigate('/insights');
}
},
};
const mcpIndex = sideNavLinks.findIndex((link) => link.id === 'mcp-builder');
const nextLinks = [...sideNavLinks];
nextLinks.splice(mcpIndex >= 0 ? mcpIndex + 1 : nextLinks.length, 0, insightsLink);
return [conversationLink, ...nextLinks];
}, [insightsAccess?.access, insightsFeatureEnabled, location.pathname, navigate, sideNavLinks]);
return links;
}

View file

@ -2307,5 +2307,37 @@
"com_ui_zoom_in": "Zoom in",
"com_ui_zoom_level": "Zoom level",
"com_ui_zoom_out": "Zoom out",
"com_user_message": "You"
"com_user_message": "You",
"com_insights_title": "Insights",
"com_insights_navigation": "Insights",
"com_insights_loading": "Loading insights",
"com_insights_load_error": "Insights could not be loaded. Try again.",
"com_insights_total_users": "Unique users",
"com_insights_total_conversations": "Conversations",
"com_insights_churned_users": "Churned users",
"com_insights_churned_users_definition": "Users whose latest message sent to an assistant reached 28 days old during the selected date range.",
"com_insights_top_users": "Top users",
"com_insights_no_data": "No data yet",
"com_insights_user": "User",
"com_insights_unknown_user": "Unknown user",
"com_insights_chats": "Chats",
"com_insights_search_placeholder": "Search by chat id or user",
"com_insights_no_search_results": "No chats match this search.",
"com_insights_latest_conversations": "Latest conversations",
"com_insights_date": "Date",
"com_insights_first_message": "First message",
"com_insights_first_seen": "First seen",
"com_insights_last_seen": "Last seen",
"com_insights_messages": "Messages",
"com_insights_total_tokens": "Tokens",
"com_insights_no_message": "No message",
"com_insights_page_of": "Page {{page}} of {{pages}}",
"com_insights_range_24_hours": "24h",
"com_insights_range_7_days": "7d",
"com_insights_range_30_days": "30d",
"com_insights_date_range_placeholder": "Select dates",
"com_insights_start_date": "Start date",
"com_insights_end_date": "End date",
"com_insights_invalid_date_range": "Choose an end date after the start date, within {{days}} days.",
"com_insights_sparkline_accessibility": "{{label}} over time"
}

View file

@ -41,6 +41,11 @@ const loadSkillsView = () =>
Component: m.default,
}));
const loadInsightsView = () =>
import('~/components/Insights').then((m) => ({
Component: m.default,
}));
const loadProjectsView = () =>
import('~/components/Projects').then((m) => ({
Component: m.ProjectsView,
@ -151,6 +156,10 @@ export const router = createBrowserRouter(
path: 'skills',
lazy: loadSkillsView,
},
{
path: 'insights',
lazy: loadInsightsView,
},
{
path: 'skills/new',
lazy: loadSkillsView,

View file

@ -76,6 +76,8 @@ export * from './tools';
export * from './web';
/* Langfuse */
export * from './langfuse';
/* Insights */
export * from './insights';
/* Cache */
export * from './cache';
/* Shared Links */

View file

@ -0,0 +1,164 @@
import type { TInsightsResponse } from 'librechat-data-provider';
import type { Response } from 'express';
import type { ServerRequest } from '~/types';
import { createInsightsAccessHandler, createInsightsHandler } from './handlers';
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn() },
}));
const emptyInsights: TInsightsResponse = {
summary: {
totalUsers: 0,
totalConversations: 0,
totalMessages: 0,
totalTokens: 0,
},
daily: [],
topUsers: [],
churnedUsers: [],
latest: { conversations: [], page: 1, pageSize: 10, pages: 1 },
};
const insightsEnabled = jest.fn(() => true);
const insightsDisabled = jest.fn(() => false);
const createResponse = () => {
const status = jest.fn();
const json = jest.fn();
status.mockReturnValue({ json });
return {
response: { status, json } as Partial<Response> as Response,
status,
json,
};
};
const createRequest = (query: ServerRequest['query'] = {}): ServerRequest =>
({
query,
user: { id: 'admin-id', role: 'ADMIN', tenantId: 'tenant-a' },
}) as ServerRequest;
describe('Insights handlers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns access when Insights is enabled', async () => {
const handler = createInsightsAccessHandler({ isInsightsEnabled: insightsEnabled });
const { response, json } = createResponse();
await handler(createRequest(), response);
expect(insightsEnabled).toHaveBeenCalledTimes(1);
expect(json).toHaveBeenCalledWith({ access: true });
});
it('returns 404 from the access endpoint when Insights is disabled', async () => {
const handler = createInsightsAccessHandler({ isInsightsEnabled: insightsDisabled });
const { response, status, json } = createResponse();
await handler(createRequest(), response);
expect(status).toHaveBeenCalledWith(404);
expect(json).toHaveBeenCalledWith({ message: 'Not found' });
});
it('returns 404 when Insights is disabled', async () => {
const getInsights = jest.fn();
const handler = createInsightsHandler({ isInsightsEnabled: insightsDisabled, getInsights });
const { response, status, json } = createResponse();
await handler(createRequest(), response);
expect(status).toHaveBeenCalledWith(404);
expect(json).toHaveBeenCalledWith({ message: 'Not found' });
expect(getInsights).not.toHaveBeenCalled();
});
it('loads a bounded page for the authenticated tenant', async () => {
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
const { response, json } = createResponse();
await handler(
createRequest({ page: '3', pageSize: '500', tenantId: 'forged-tenant', range: '30d' }),
response,
);
expect(getInsights).toHaveBeenCalledWith({
page: 3,
pageSize: 50,
tenantId: 'tenant-a',
search: undefined,
range: '30d',
fromTimestamp: undefined,
toTimestamp: undefined,
timeZone: undefined,
});
expect(json).toHaveBeenCalledWith(emptyInsights);
});
it('passes custom dates and a valid timezone to the data layer', async () => {
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
const { response } = createResponse();
await handler(
createRequest({
range: 'custom',
fromTimestamp: '2026-06-01T00:00:00.000Z',
toTimestamp: '2026-06-10T00:00:00.000Z',
timeZone: 'America/Los_Angeles',
}),
response,
);
expect(getInsights).toHaveBeenCalledWith(
expect.objectContaining({
range: 'custom',
fromTimestamp: '2026-06-01T00:00:00.000Z',
toTimestamp: '2026-06-10T00:00:00.000Z',
timeZone: 'America/Los_Angeles',
}),
);
});
it('uses pagination defaults and discards an invalid timezone', async () => {
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
const { response } = createResponse();
await handler(
createRequest({ page: '-1', pageSize: 'invalid', timeZone: 'Not/A-Timezone' }),
response,
);
expect(getInsights).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, pageSize: 10, timeZone: undefined }),
);
});
it('normalizes and bounds search input', async () => {
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
const { response } = createResponse();
await handler(createRequest({ search: ` ${'message'.repeat(40)} ` }), response);
expect(getInsights).toHaveBeenCalledWith(
expect.objectContaining({ search: 'message'.repeat(40).slice(0, 200) }),
);
});
it('does not run searches shorter than the minimum length', async () => {
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
const { response } = createResponse();
await handler(createRequest({ search: 'ab' }), response);
expect(getInsights).toHaveBeenCalledWith(expect.objectContaining({ search: undefined }));
});
});

View file

@ -0,0 +1,103 @@
import { logger } from '@librechat/data-schemas';
import {
INSIGHTS_SEARCH_MAX_LENGTH,
INSIGHTS_SEARCH_MIN_LENGTH,
type TInsightsParams,
} from 'librechat-data-provider';
import type { InsightsMethods } from '@librechat/data-schemas';
import type { Response } from 'express';
import type { ServerRequest } from '~/types';
type InsightsHandlerDeps = {
isInsightsEnabled: () => boolean;
getInsights: InsightsMethods['getInsights'];
};
type InsightsAccessHandlerDeps = Pick<InsightsHandlerDeps, 'isInsightsEnabled'>;
const firstQueryValue = (value: unknown): string | undefined => {
if (Array.isArray(value)) {
return firstQueryValue(value[0]);
}
return typeof value === 'string' ? value : undefined;
};
const positiveInteger = (value: unknown, fallback: number): number => {
const parsed = Number.parseInt(firstQueryValue(value) ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const stringValue = (input: unknown): string | undefined => {
const value = firstQueryValue(input)?.trim();
return value ? value : undefined;
};
const validRanges = new Set<TInsightsParams['range']>(['24h', '7d', '30d', 'custom']);
const insightsRange = (value: unknown): TInsightsParams['range'] | undefined => {
const range = stringValue(value) as TInsightsParams['range'] | undefined;
return range && validRanges.has(range) ? range : undefined;
};
const timeZoneValue = (value: unknown): string | undefined => {
const timeZone = stringValue(value)?.slice(0, 100);
if (!timeZone) {
return undefined;
}
try {
new Intl.DateTimeFormat('en', { timeZone }).format();
return timeZone;
} catch {
return undefined;
}
};
export function createInsightsAccessHandler({ isInsightsEnabled }: InsightsAccessHandlerDeps) {
return async (_req: ServerRequest, res: Response): Promise<void> => {
try {
if (!isInsightsEnabled()) {
res.status(404).json({ message: 'Not found' });
return;
}
res.json({ access: true });
} catch (error) {
logger.error('[Insights] Failed to load access status', error);
res.status(500).json({ message: 'Failed to load insights access' });
}
};
}
export function createInsightsHandler({ isInsightsEnabled, getInsights }: InsightsHandlerDeps) {
return async (req: ServerRequest, res: Response): Promise<void> => {
try {
if (!isInsightsEnabled()) {
res.status(404).json({ message: 'Not found' });
return;
}
const page = positiveInteger(req.query.page, 1);
const pageSize = Math.min(50, Math.max(5, positiveInteger(req.query.pageSize, 10)));
const tenantId = stringValue(req.user?.tenantId);
const requestedSearch = stringValue(req.query.search)?.slice(0, INSIGHTS_SEARCH_MAX_LENGTH);
const search =
requestedSearch && requestedSearch.length >= INSIGHTS_SEARCH_MIN_LENGTH
? requestedSearch
: undefined;
const insights = await getInsights({
page,
pageSize,
tenantId,
search,
range: insightsRange(req.query.range),
fromTimestamp: stringValue(req.query.fromTimestamp),
toTimestamp: stringValue(req.query.toTimestamp),
timeZone: timeZoneValue(req.query.timeZone),
});
res.json(insights);
} catch (error) {
logger.error('[Insights] Failed to load dashboard', error);
res.status(500).json({ message: 'Failed to load insights' });
}
};
}

View file

@ -0,0 +1 @@
export * from './handlers';

View file

@ -155,12 +155,12 @@ async function createConflictingServer(): Promise<ConflictTestServer> {
}
async function waitForCondition(
predicate: () => boolean,
predicate: () => boolean | Promise<boolean>,
timeoutMs = 10000,
intervalMs = 25,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
while (!(await predicate())) {
if (Date.now() > deadline) {
throw new Error('Timed out waiting for condition');
}
@ -250,23 +250,24 @@ describe('MCPConnection standalone SSE stream conflict', () => {
it('rebuilds the session so the connection recovers from the conflict', async () => {
const srv = await createConflictingServer();
server = srv;
conn = new MCPConnection({
const connection = new MCPConnection({
serverName: 'test',
serverConfig: { type: 'streamable-http', url: srv.url },
useSSRFProtection: false,
});
conn = connection;
await conn.connect();
const firstSessionId = (conn as unknown as { transport?: { sessionId?: string } }).transport
?.sessionId;
await connection.connect();
const firstSessionId = (connection as unknown as { transport?: { sessionId?: string } })
.transport?.sessionId;
expect(firstSessionId).toBeTruthy();
await waitForCondition(() => {
const current = (conn as unknown as { transport?: { sessionId?: string } }).transport
await waitForCondition(async () => {
const current = (connection as unknown as { transport?: { sessionId?: string } }).transport
?.sessionId;
return Boolean(current) && current !== firstSessionId;
return Boolean(current) && current !== firstSessionId && (await connection.isConnected());
}, 15000);
expect(await conn.isConnected()).toBe(true);
expect(await connection.isConnected()).toBe(true);
}, 25000);
});

View file

@ -420,6 +420,9 @@ export const skillFiles = (id: string) => `${getSkill(id)}/files`;
export const skillFile = (id: string, relativePath: string) =>
`${skillFiles(id)}/${encodeURIComponent(relativePath)}`;
export const insights = () => `${BASE_URL}/api/admin/insights`;
export const insightsAccess = () => `${insights()}/access`;
export const adminSkillsSync = () => `${BASE_URL}/api/admin/skills/sync`;
export const adminSkillsSyncStatus = () => `${adminSkillsSync()}/status`;
export const adminSkillsSyncRun = () => `${adminSkillsSync()}/run`;

View file

@ -1655,6 +1655,7 @@ export type TStartupConfig = {
socialLogins?: string[];
langfuseFanoutEnabled?: boolean;
langfuseConnectionAccess?: boolean;
insightsEnabled?: boolean;
interface?: TInterfaceConfig;
turnstile?: TTurnstileConfig;
balance?: TBalanceConfig;

View file

@ -1,4 +1,5 @@
import type { AxiosResponse } from 'axios';
import type { TInsightsAccessResponse, TInsightsParams, TInsightsResponse } from './types/insights';
import type { TFileConfig } from './file-config';
import type * as t from './types';
import * as permissions from './accessPermissions';
@ -16,6 +17,21 @@ import request from './request';
import * as s from './schemas';
import * as r from './roles';
export function getInsights(params: TInsightsParams = {}): Promise<TInsightsResponse> {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value));
}
}
const suffix = query.toString() ? `?${query.toString()}` : '';
return request.get(`${endpoints.insights()}${suffix}`);
}
export function getInsightsAccess(): Promise<TInsightsAccessResponse> {
return request.get(endpoints.insightsAccess());
}
export function getLangfuseConnection(): Promise<t.TLangfuseConnectionStatus> {
return request.get(endpoints.adminLangfuseConnection());
}

View file

@ -32,6 +32,7 @@ export * from './types/skills';
export * from './types/runs';
export * from './types/web';
export * from './types/graph';
export * from './types/insights';
/* access permissions */
export * from './accessPermissions';
/* query/mutation keys */

View file

@ -22,6 +22,8 @@ export enum QueryKeys {
tokenCount = 'tokenCount',
availablePlugins = 'availablePlugins',
startupConfig = 'startupConfig',
insights = 'insights',
insightsAccess = 'insightsAccess',
assistants = 'assistants',
assistant = 'assistant',
agents = 'agents',

View file

@ -0,0 +1,70 @@
export type InsightsRange = '24h' | '7d' | '30d' | 'custom';
export const INSIGHTS_MAX_RANGE_DAYS = 30;
export const INSIGHTS_SEARCH_MIN_LENGTH = 3;
export const INSIGHTS_SEARCH_MAX_LENGTH = 200;
export type TInsightsParams = {
range?: InsightsRange;
fromTimestamp?: string;
toTimestamp?: string;
timeZone?: string;
search?: string;
page?: number;
pageSize?: number;
};
export type TInsightsDailyPoint = {
date: string;
conversations: number;
users: number;
messages: number;
totalTokens: number;
};
export type TInsightsUser = {
userId: string;
name: string;
email: string;
conversations: number;
messages: number;
};
export type TInsightsChurnedUser = TInsightsUser & {
firstSeen: string;
lastSeen: string;
};
export type TInsightsConversation = {
conversationId: string;
date: string;
userId: string;
name: string;
email: string;
firstMessage: string;
messages: number;
totalTokens: number;
};
export type TInsightsSummary = {
totalUsers: number;
totalConversations: number;
totalMessages: number;
totalTokens: number;
};
export type TInsightsResponse = {
summary: TInsightsSummary;
daily: TInsightsDailyPoint[];
topUsers: TInsightsUser[];
churnedUsers: TInsightsChurnedUser[];
latest: {
conversations: TInsightsConversation[];
page: number;
pageSize: number;
pages: number;
};
};
export type TInsightsAccessResponse = {
access: boolean;
};

View file

@ -25,6 +25,7 @@ export const SystemCapabilities = {
MANAGE_CONFIGS: 'manage:configs',
ASSIGN_CONFIGS: 'assign:configs',
READ_USAGE: 'read:usage',
READ_INSIGHTS: 'read:insights',
READ_AGENTS: 'read:agents',
MANAGE_AGENTS: 'manage:agents',
MANAGE_MCP_SERVERS: 'manage:mcpservers',
@ -261,6 +262,7 @@ export const CAPABILITY_CATEGORIES: CapabilityCategory[] = [
capabilities: [
SystemCapabilities.ACCESS_ADMIN,
SystemCapabilities.READ_USAGE,
SystemCapabilities.READ_INSIGHTS,
SystemCapabilities.READ_AUDIT_LOG,
],
},

View file

@ -122,6 +122,8 @@ import {
type MCPAuthorityConfigSourceDocument,
type MCPAuthorityCredentialSourceDocument,
} from './mcpAuthority';
/* Insights */
import { createInsightsMethods, type InsightsMethods } from './insights';
export {
RoleConflictError,
@ -191,7 +193,8 @@ export type AllMethods = UserMethods &
AgentTriggerDeliveryMethods &
AgentMethods &
ConfigMethods &
MCPAuthorityMethods;
MCPAuthorityMethods &
InsightsMethods;
/** Dependencies injected from the api layer into createMethods */
export interface CreateMethodsDeps {
@ -332,6 +335,8 @@ export function createMethods(
...createConfigMethods(mongoose),
/* MCP authority proofs */
...createMCPAuthorityMethods(mongoose),
/* Insights */
...createInsightsMethods(mongoose),
};
}
@ -390,4 +395,5 @@ export type {
MCPAuthorityMethodHooks,
MCPAuthorityConfigSourceDocument,
MCPAuthorityCredentialSourceDocument,
InsightsMethods,
};

View file

@ -0,0 +1,807 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createConversationModel } from '../models/convo';
import { createMessageModel } from '../models/message';
import { createInsightsMethods } from './insights';
import { createUserModel } from '../models/user';
let mongoServer: MongoMemoryServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
const models = [
createConversationModel(mongoose),
createMessageModel(mongoose),
createUserModel(mongoose),
];
await Promise.all(models.map((model) => model.init()));
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Promise.all([
mongoose.models.Conversation.deleteMany({}),
mongoose.models.Message.deleteMany({}),
mongoose.models.User.deleteMany({}),
]);
});
describe('Insights methods', () => {
it('counts active users from prompts and all messages for top users within the tenant', async () => {
const now = new Date();
const activeAt = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const oldConversationAt = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000);
const activeUserId = new mongoose.Types.ObjectId();
const newUserId = new mongoose.Types.ObjectId();
const otherTenantUserId = new mongoose.Types.ObjectId();
await mongoose.models.User.collection.insertMany([
{
_id: activeUserId,
tenantId: 'tenant-a',
name: 'Active Existing User',
email: 'active@example.com',
},
{
_id: newUserId,
tenantId: 'tenant-a',
name: 'New User',
email: 'new@example.com',
},
{
_id: otherTenantUserId,
tenantId: 'tenant-b',
name: 'Other Tenant User',
email: 'other@example.com',
},
]);
await mongoose.models.Conversation.collection.insertMany([
{
conversationId: 'old-conversation',
tenantId: 'tenant-a',
user: activeUserId.toString(),
createdAt: oldConversationAt,
updatedAt: activeAt,
isTemporary: false,
},
{
conversationId: 'new-conversation',
tenantId: 'tenant-a',
user: newUserId.toString(),
createdAt: activeAt,
updatedAt: activeAt,
isTemporary: false,
},
{
conversationId: 'other-conversation',
tenantId: 'tenant-b',
user: otherTenantUserId.toString(),
createdAt: activeAt,
updatedAt: activeAt,
isTemporary: false,
},
]);
const messages = [
['old-user', 'old-conversation', activeUserId, true],
['old-assistant', 'old-conversation', activeUserId, false],
['new-user', 'new-conversation', newUserId, true],
['new-assistant', 'new-conversation', newUserId, false],
['other-user', 'other-conversation', otherTenantUserId, true],
['other-assistant', 'other-conversation', otherTenantUserId, false],
] as const;
await mongoose.models.Message.collection.insertMany([
...messages.map(([messageId, conversationId, userId, isCreatedByUser]) => ({
messageId,
conversationId,
tenantId: conversationId === 'other-conversation' ? 'tenant-b' : 'tenant-a',
user: userId.toString(),
createdAt: activeAt,
updatedAt: activeAt,
isCreatedByUser,
isTemporary: false,
text: messageId,
tokenCount: 10,
})),
{
messageId: 'unattributed-null',
conversationId: 'old-conversation',
tenantId: 'tenant-a',
user: null,
createdAt: activeAt,
updatedAt: activeAt,
isCreatedByUser: false,
isTemporary: false,
text: 'Unattributed message',
tokenCount: 10,
},
{
messageId: 'unattributed-empty',
conversationId: 'old-conversation',
tenantId: 'tenant-a',
user: '',
createdAt: activeAt,
updatedAt: activeAt,
isCreatedByUser: false,
isTemporary: false,
text: 'Unattributed message',
tokenCount: 10,
},
]);
const result = await createInsightsMethods(mongoose).getInsights({
tenantId: 'tenant-a',
range: '7d',
});
expect(result.summary).toEqual(
expect.objectContaining({
totalUsers: 2,
totalConversations: 1,
totalMessages: 4,
totalTokens: 40,
}),
);
expect(result.daily).toContainEqual(
expect.objectContaining({ conversations: 1, users: 2, messages: 4, totalTokens: 40 }),
);
expect(result.topUsers).toEqual(
expect.arrayContaining([
expect.objectContaining({
userId: activeUserId.toString(),
messages: 2,
conversations: 1,
}),
expect.objectContaining({
userId: newUserId.toString(),
messages: 2,
conversations: 1,
}),
]),
);
expect(result.topUsers).toHaveLength(2);
});
it('lists users whose 28-day inactivity threshold falls within the selected range', async () => {
const now = Date.now();
const daysAgo = (days: number) => new Date(now - days * 24 * 60 * 60 * 1000);
const churnedUserId = new mongoose.Types.ObjectId();
const previouslyChurnedUserId = new mongoose.Types.ObjectId();
const activeUserId = new mongoose.Types.ObjectId();
const otherTenantUserId = new mongoose.Types.ObjectId();
await mongoose.models.User.collection.insertMany([
{
_id: churnedUserId,
tenantId: 'tenant-a',
name: 'Churned User',
email: 'churned@example.com',
},
{
_id: previouslyChurnedUserId,
tenantId: 'tenant-a',
name: 'Previously Churned User',
email: 'previously-churned@example.com',
},
{
_id: activeUserId,
tenantId: 'tenant-a',
name: 'Active User',
email: 'active@example.com',
},
{
_id: otherTenantUserId,
tenantId: 'tenant-b',
name: 'Other Tenant User',
email: 'other@example.com',
},
]);
await mongoose.models.Message.collection.insertMany([
{
messageId: 'churned-first-prompt',
conversationId: 'churned-conversation-1',
tenantId: 'tenant-a',
user: churnedUserId.toString(),
createdAt: daysAgo(60),
updatedAt: daysAgo(60),
isCreatedByUser: true,
isTemporary: false,
},
{
messageId: 'churned-second-prompt',
conversationId: 'churned-conversation-1',
tenantId: 'tenant-a',
user: churnedUserId.toString(),
createdAt: daysAgo(45),
updatedAt: daysAgo(45),
isCreatedByUser: true,
isTemporary: false,
},
{
messageId: 'churned-last-prompt',
conversationId: 'churned-conversation-2',
tenantId: 'tenant-a',
user: churnedUserId.toString(),
createdAt: daysAgo(29),
updatedAt: daysAgo(29),
isCreatedByUser: true,
isTemporary: false,
},
{
messageId: 'recent-assistant-response',
conversationId: 'churned-conversation-2',
tenantId: 'tenant-a',
user: churnedUserId.toString(),
createdAt: daysAgo(1),
updatedAt: daysAgo(1),
isCreatedByUser: false,
isTemporary: false,
},
{
messageId: 'previously-churned-prompt',
conversationId: 'previously-churned-conversation',
tenantId: 'tenant-a',
user: previouslyChurnedUserId.toString(),
createdAt: daysAgo(40),
updatedAt: daysAgo(40),
isCreatedByUser: true,
isTemporary: false,
},
{
messageId: 'active-prompt',
conversationId: 'active-conversation',
tenantId: 'tenant-a',
user: activeUserId.toString(),
createdAt: daysAgo(27),
updatedAt: daysAgo(27),
isCreatedByUser: true,
isTemporary: false,
},
{
messageId: 'other-tenant-old-prompt',
conversationId: 'other-conversation',
tenantId: 'tenant-b',
user: otherTenantUserId.toString(),
createdAt: daysAgo(35),
updatedAt: daysAgo(35),
isCreatedByUser: true,
isTemporary: false,
},
]);
const result = await createInsightsMethods(mongoose).getInsights({
tenantId: 'tenant-a',
range: '7d',
});
expect(result.churnedUsers).toEqual([
{
userId: churnedUserId.toString(),
name: 'Churned User',
email: 'churned@example.com',
conversations: 2,
messages: 4,
firstSeen: daysAgo(60).toISOString(),
lastSeen: daysAgo(29).toISOString(),
},
]);
expect(result.summary).not.toHaveProperty('churnedUsers');
});
it('keeps duplicate conversation IDs isolated by owner', async () => {
const now = new Date();
const firstUserId = new mongoose.Types.ObjectId();
const secondUserId = new mongoose.Types.ObjectId();
await mongoose.models.User.collection.insertMany([
{
_id: firstUserId,
tenantId: 'tenant-a',
name: 'First User',
email: 'first@example.com',
},
{
_id: secondUserId,
tenantId: 'tenant-a',
name: 'Second User',
email: 'second@example.com',
},
]);
await mongoose.models.Conversation.collection.insertMany([
{
conversationId: 'shared-client-id',
tenantId: 'tenant-a',
user: firstUserId.toString(),
title: 'First conversation',
createdAt: now,
updatedAt: now,
isTemporary: false,
},
{
conversationId: 'shared-client-id',
tenantId: 'tenant-a',
user: secondUserId.toString(),
title: 'Second conversation',
createdAt: new Date(now.getTime() - 1),
updatedAt: now,
isTemporary: false,
},
]);
await mongoose.models.Message.collection.insertMany([
{
messageId: 'first-prompt',
conversationId: 'shared-client-id',
tenantId: 'tenant-a',
user: firstUserId.toString(),
createdAt: now,
updatedAt: now,
isCreatedByUser: true,
isTemporary: false,
text: 'First owner private prompt',
tokenCount: 10,
},
{
messageId: 'first-response',
conversationId: 'shared-client-id',
tenantId: 'tenant-a',
user: firstUserId.toString(),
createdAt: now,
updatedAt: now,
isCreatedByUser: false,
isTemporary: false,
endpoint: 'agents',
sender: 'First Agent',
text: 'First response',
tokenCount: 20,
},
{
messageId: 'second-prompt',
conversationId: 'shared-client-id',
tenantId: 'tenant-a',
user: secondUserId.toString(),
createdAt: now,
updatedAt: now,
isCreatedByUser: true,
isTemporary: false,
text: 'Second owner private prompt',
tokenCount: 30,
},
{
messageId: 'second-response',
conversationId: 'shared-client-id',
tenantId: 'tenant-a',
user: secondUserId.toString(),
createdAt: now,
updatedAt: now,
isCreatedByUser: false,
isTemporary: false,
endpoint: 'agents',
sender: 'Second Agent',
text: 'Second response',
tokenCount: 40,
},
]);
const methods = createInsightsMethods(mongoose);
const insights = await methods.getInsights({ tenantId: 'tenant-a', range: '7d' });
const byUser = new Map(insights.latest.conversations.map((row) => [row.userId, row]));
expect(byUser.get(firstUserId.toString())).toEqual(
expect.objectContaining({
firstMessage: 'First owner private prompt',
messages: 2,
totalTokens: 30,
}),
);
expect(byUser.get(secondUserId.toString())).toEqual(
expect.objectContaining({
firstMessage: 'Second owner private prompt',
messages: 2,
totalTokens: 70,
}),
);
const search = await methods.getInsights({
tenantId: 'tenant-a',
range: '7d',
search: 'first@example.com',
});
expect(search.latest.conversations).toEqual([
expect.objectContaining({ userId: firstUserId.toString() }),
]);
});
it('bounds latest conversation message totals to a custom range', async () => {
const from = new Date('2026-01-01T00:00:00.000Z');
const to = new Date('2026-01-07T23:59:59.999Z');
const inRange = new Date('2026-01-02T12:00:00.000Z');
const temporaryInRange = new Date('2026-01-02T11:59:00.000Z');
const afterRange = new Date('2026-01-08T12:00:00.000Z');
const userId = new mongoose.Types.ObjectId();
await mongoose.models.User.collection.insertOne({
_id: userId,
tenantId: 'tenant-a',
name: 'Historical User',
email: 'historical@example.com',
});
await mongoose.models.Conversation.collection.insertOne({
conversationId: 'historical-conversation',
tenantId: 'tenant-a',
user: userId.toString(),
title: 'Historical conversation',
createdAt: inRange,
updatedAt: afterRange,
isTemporary: false,
});
await mongoose.models.Message.collection.insertMany([
{
messageId: 'temporary-prompt',
conversationId: 'historical-conversation',
tenantId: 'tenant-a',
user: userId.toString(),
createdAt: temporaryInRange,
updatedAt: temporaryInRange,
isCreatedByUser: true,
isTemporary: true,
text: 'Temporary prompt',
tokenCount: 100,
},
{
messageId: 'historical-prompt',
conversationId: 'historical-conversation',
tenantId: 'tenant-a',
user: userId.toString(),
createdAt: inRange,
updatedAt: inRange,
isCreatedByUser: true,
isTemporary: false,
text: 'Historical prompt',
tokenCount: 10,
},
{
messageId: 'historical-response',
conversationId: 'historical-conversation',
tenantId: 'tenant-a',
user: userId.toString(),
createdAt: inRange,
updatedAt: inRange,
isCreatedByUser: false,
isTemporary: false,
text: 'Historical response',
tokenCount: 20,
},
{
messageId: 'later-response',
conversationId: 'historical-conversation',
tenantId: 'tenant-a',
user: userId.toString(),
createdAt: afterRange,
updatedAt: afterRange,
isCreatedByUser: false,
isTemporary: false,
text: 'Later response',
tokenCount: 30,
},
]);
const result = await createInsightsMethods(mongoose).getInsights({
tenantId: 'tenant-a',
range: 'custom',
fromTimestamp: from.toISOString(),
toTimestamp: to.toISOString(),
});
expect(result.latest.conversations).toEqual([
expect.objectContaining({
conversationId: 'historical-conversation',
firstMessage: 'Historical prompt',
messages: 2,
totalTokens: 30,
}),
]);
});
it('uses the selected timezone for daily buckets and fills inactive dates', async () => {
const userId = new mongoose.Types.ObjectId();
const activity = [new Date('2026-08-01T12:00:00.000Z'), new Date('2026-08-03T12:00:00.000Z')];
await mongoose.models.User.collection.insertOne({
_id: userId,
tenantId: 'tenant-a',
name: 'Pacific User',
email: 'pacific@example.com',
});
await mongoose.models.Conversation.collection.insertMany(
activity.map((createdAt, index) => ({
conversationId: `pacific-${index}`,
tenantId: 'tenant-a',
user: userId.toString(),
createdAt,
updatedAt: createdAt,
isTemporary: false,
})),
);
await mongoose.models.Message.collection.insertMany(
activity.map((createdAt, index) => ({
messageId: `pacific-message-${index}`,
conversationId: `pacific-${index}`,
tenantId: 'tenant-a',
user: userId.toString(),
createdAt,
updatedAt: createdAt,
isCreatedByUser: true,
isTemporary: false,
text: `Message ${index}`,
})),
);
const result = await createInsightsMethods(mongoose).getInsights({
tenantId: 'tenant-a',
range: 'custom',
fromTimestamp: '2026-08-01T07:00:00.000Z',
toTimestamp: '2026-08-04T06:59:59.999Z',
timeZone: 'America/Los_Angeles',
});
expect(result.daily).toEqual([
{ date: '2026-08-01', conversations: 1, messages: 1, totalTokens: 0, users: 1 },
{ date: '2026-08-02', conversations: 0, messages: 0, totalTokens: 0, users: 0 },
{ date: '2026-08-03', conversations: 1, messages: 1, totalTokens: 0, users: 1 },
]);
});
it('preserves a 30-date custom range across a daylight saving time change', async () => {
const userId = new mongoose.Types.ObjectId();
const activity = new Date('2026-10-04T04:30:00.000Z');
await mongoose.models.User.collection.insertOne({
_id: userId,
tenantId: 'tenant-a',
name: 'Eastern User',
email: 'eastern@example.com',
});
await mongoose.models.Conversation.collection.insertOne({
conversationId: 'dst-fallback',
tenantId: 'tenant-a',
user: userId.toString(),
createdAt: activity,
updatedAt: activity,
isTemporary: false,
});
await mongoose.models.Message.collection.insertOne({
messageId: 'dst-fallback-message',
conversationId: 'dst-fallback',
tenantId: 'tenant-a',
user: userId.toString(),
createdAt: activity,
updatedAt: activity,
isCreatedByUser: true,
isTemporary: false,
text: 'Message before the repeated hour',
});
const result = await createInsightsMethods(mongoose).getInsights({
tenantId: 'tenant-a',
range: 'custom',
fromTimestamp: '2026-10-04T04:00:00.000Z',
toTimestamp: '2026-11-03T04:59:59.999Z',
timeZone: 'America/New_York',
});
expect(result.summary.totalConversations).toBe(1);
expect(result.summary.totalMessages).toBe(1);
expect(result.daily).toHaveLength(30);
expect(result.daily[0]).toEqual(
expect.objectContaining({ date: '2026-10-04', conversations: 1, messages: 1 }),
);
});
it('fills every local calendar date across a daylight saving time change', async () => {
const result = await createInsightsMethods(mongoose).getInsights({
tenantId: 'tenant-a',
range: 'custom',
fromTimestamp: '2026-03-08T04:30:00.000Z',
toTimestamp: '2026-03-09T04:30:00.000Z',
timeZone: 'America/New_York',
});
expect(result.daily).toEqual([
{ date: '2026-03-07', conversations: 0, messages: 0, totalTokens: 0, users: 0 },
{ date: '2026-03-08', conversations: 0, messages: 0, totalTokens: 0, users: 0 },
{ date: '2026-03-09', conversations: 0, messages: 0, totalTokens: 0, users: 0 },
]);
});
it('searches tenant conversations by ID and user without inspecting message text', async () => {
const now = new Date();
const aliceId = new mongoose.Types.ObjectId();
const bobId = new mongoose.Types.ObjectId();
const otherTenantUserId = new mongoose.Types.ObjectId();
await mongoose.models.User.collection.insertMany([
{
_id: aliceId,
tenantId: 'tenant-a',
name: 'Alice Example',
email: 'alice@example.com',
},
{
_id: bobId,
tenantId: 'tenant-a',
name: 'Bob Example',
email: 'bob@example.com',
},
{
_id: otherTenantUserId,
tenantId: 'tenant-b',
name: 'Secret User',
email: 'secret@example.com',
},
]);
await mongoose.models.Conversation.collection.insertMany([
{
conversationId: 'incident-review-123',
tenantId: 'tenant-a',
user: aliceId.toString(),
createdAt: now,
updatedAt: now,
isTemporary: false,
},
{
conversationId: 'capacity-plan-456',
tenantId: 'tenant-a',
user: bobId.toString(),
createdAt: now,
updatedAt: now,
isTemporary: false,
},
{
conversationId: 'other-tenant-789',
tenantId: 'tenant-b',
user: otherTenantUserId.toString(),
createdAt: now,
updatedAt: now,
isTemporary: false,
},
]);
await mongoose.models.Message.collection.insertMany([
{
messageId: 'message-a',
conversationId: 'incident-review-123',
tenantId: 'tenant-a',
user: aliceId.toString(),
createdAt: now,
updatedAt: now,
isCreatedByUser: true,
isTemporary: false,
text: 'Review the production incident',
},
{
messageId: 'message-b',
conversationId: 'capacity-plan-456',
tenantId: 'tenant-a',
user: bobId.toString(),
createdAt: now,
updatedAt: now,
isCreatedByUser: true,
isTemporary: false,
text: 'Forecast the sapphire cluster capacity',
},
{
messageId: 'message-secret',
conversationId: 'other-tenant-789',
tenantId: 'tenant-b',
user: otherTenantUserId.toString(),
createdAt: now,
updatedAt: now,
isCreatedByUser: true,
isTemporary: false,
text: 'Cross tenant secret phrase',
},
]);
const methods = createInsightsMethods(mongoose);
const byConversationId = await methods.getInsights({
tenantId: 'tenant-a',
range: '7d',
search: 'review-123',
});
const byUser = await methods.getInsights({
tenantId: 'tenant-a',
range: '7d',
search: 'alice@example.com',
});
const byMessage = await methods.getInsights({
tenantId: 'tenant-a',
range: '7d',
search: 'SAPPHIRE CLUSTER',
});
const crossTenant = await methods.getInsights({
tenantId: 'tenant-a',
range: '7d',
search: 'secret phrase',
});
expect(byConversationId.summary.totalConversations).toBe(2);
expect(byConversationId.latest.conversations.map((row) => row.conversationId)).toEqual([
'incident-review-123',
]);
expect(byUser.latest.conversations.map((row) => row.conversationId)).toEqual([
'incident-review-123',
]);
expect(byMessage.latest.conversations).toEqual([]);
expect(crossTenant.latest).toEqual(expect.objectContaining({ conversations: [], pages: 1 }));
});
it('omits the unfiltered recent-conversation facet during searches', async () => {
const aggregateSpy = jest.spyOn(mongoose.models.Conversation, 'aggregate');
try {
await createInsightsMethods(mongoose).getInsights({
tenantId: 'tenant-a',
range: '7d',
search: 'conversation-id',
});
type FacetStage = { $facet?: Record<string, unknown> };
const facets = aggregateSpy.mock.calls
.flatMap(([pipeline]) => (pipeline as FacetStage[]).map((stage) => stage.$facet))
.filter((facet): facet is Record<string, unknown> => facet != null);
const unfilteredFacet = facets.find((facet) => 'daily' in facet);
const searchedFacet = facets.find(
(facet) => 'recentConversations' in facet && !('daily' in facet),
);
expect(unfilteredFacet).not.toHaveProperty('recentConversations');
expect(searchedFacet).toHaveProperty('recentConversations');
} finally {
aggregateSpy.mockRestore();
}
});
it('keeps identity matches in a DocumentDB-compatible conversation aggregation', async () => {
const aggregateSpy = jest.spyOn(mongoose.models.Conversation, 'aggregate');
try {
await createInsightsMethods(mongoose).getInsights({
tenantId: 'tenant-a',
range: '7d',
search: 'alice@example.com',
});
type SearchStage = {
$addFields?: Record<string, unknown>;
$lookup?: Record<string, unknown>;
};
const searchedPipeline = aggregateSpy.mock.calls
.map(([pipeline]) => pipeline as SearchStage[])
.find((pipeline) => pipeline.some((stage) => stage.$lookup));
const identityLookup = searchedPipeline?.find((stage) => stage.$lookup)?.$lookup;
const ownerConversion = searchedPipeline?.find((stage) => stage.$addFields)?.$addFields;
expect(identityLookup).toEqual({
from: 'users',
localField: 'insightsUserId',
foreignField: '_id',
as: 'matchedIdentity',
});
expect(identityLookup).not.toHaveProperty('let');
expect(identityLookup).not.toHaveProperty('pipeline');
expect(ownerConversion).toEqual({
insightsUserId: {
$convert: { input: '$user', to: 'objectId', onError: null, onNull: null },
},
});
} finally {
aggregateSpy.mockRestore();
}
});
});

View file

@ -0,0 +1,699 @@
import {
INSIGHTS_MAX_RANGE_DAYS,
INSIGHTS_SEARCH_MAX_LENGTH,
INSIGHTS_SEARCH_MIN_LENGTH,
} from 'librechat-data-provider';
import type {
TInsightsResponse,
TInsightsParams,
TInsightsDailyPoint,
TInsightsUser,
TInsightsChurnedUser,
TInsightsConversation,
} from 'librechat-data-provider';
import type { Model } from 'mongoose';
import type { IConversation, IMessage, IUser } from '~/types';
export type InsightsOptions = TInsightsParams & {
tenantId?: string;
};
export type InsightsResult = TInsightsResponse;
export type InsightsMethods = {
getInsights: (options?: InsightsOptions) => Promise<InsightsResult>;
};
type ConversationFacet = {
conversationCount: Array<{ total: number }>;
daily: Array<{ date: string; conversations: number }>;
recentConversations?: RecentConversation[];
};
type ConversationListFacet = Pick<ConversationFacet, 'conversationCount' | 'recentConversations'>;
type MessageFacet = {
totals: MessageTotals[];
daily: MessageDay[];
userCount: Array<{ total: number }>;
dailyUsers: Array<{ date: string; users: number }>;
topUsers: MessageSummary[];
};
type UserMessageTotals = {
messages: number;
};
type RecentConversation = {
conversationId: string;
date: Date;
userId: string;
};
type MessageSummary = {
_id: string;
messages: number;
};
type MessageTotals = {
messages: number;
totalTokens: number;
};
type ChurnedUserActivity = {
_id: string;
lastSeen: Date;
};
type ChurnedUserSummary = UserMessageTotals & {
_id: string;
conversations: number;
firstSeen: Date;
};
type ConversationOwner = {
conversationId: string;
userId: string;
};
type ConversationMessageSummary = UserMessageTotals & {
_id: ConversationOwner;
totalTokens: number;
};
type UserConversationCount = {
_id: string;
conversations: number;
};
type MessageDay = {
date: string;
messages: number;
totalTokens: number;
};
type FirstMessage = {
_id: ConversationOwner;
text: string;
};
type UserSummary = {
_id: { toString(): string };
name?: string;
username?: string;
email?: string;
};
const rangeDays = {
'24h': 1,
'7d': 7,
'30d': 30,
};
const defaultRange = '7d';
const churnedUserWindowDays = 28;
const churnedUserLimit = 8;
const dayMs = 24 * 60 * 60 * 1000;
function tenantMatch(tenantId?: string) {
return tenantId ? { tenantId } : { tenantId: { $exists: false } };
}
function validTimeZone(timeZone?: string) {
if (!timeZone) {
return 'UTC';
}
try {
new Intl.DateTimeFormat('en', { timeZone }).format();
return timeZone;
} catch {
return 'UTC';
}
}
function dateKey(date: Date, timeZone: string) {
const parts = new Intl.DateTimeFormat('en', {
day: '2-digit',
month: '2-digit',
timeZone,
year: 'numeric',
}).formatToParts(date);
const values = new Map(parts.map((part) => [part.type, part.value]));
return `${values.get('year')}-${values.get('month')}-${values.get('day')}`;
}
function dateKeyValue(key: string) {
const [year, month, day] = key.split('-').map(Number);
return Date.UTC(year, month - 1, day);
}
function addCalendarDaysToKey(key: string, days: number) {
const date = new Date(dateKeyValue(key));
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function calendarDayDifference(startKey: string, endKey: string) {
return Math.round((dateKeyValue(endKey) - dateKeyValue(startKey)) / dayMs);
}
function startOfZonedDate(key: string, timeZone: string) {
const target = dateKeyValue(key);
let instant = target;
const formatter = new Intl.DateTimeFormat('en', {
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: '2-digit',
second: '2-digit',
timeZone,
year: 'numeric',
});
for (let iteration = 0; iteration < 2; iteration += 1) {
const parts = new Map(
formatter.formatToParts(new Date(instant)).map((part) => [part.type, part.value]),
);
const rendered = Date.UTC(
Number(parts.get('year')),
Number(parts.get('month')) - 1,
Number(parts.get('day')),
Number(parts.get('hour')),
Number(parts.get('minute')),
Number(parts.get('second')),
);
instant += target - rendered;
}
return new Date(instant);
}
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim() !== '';
const conversationOwnerKey = ({ conversationId, userId }: ConversationOwner): string =>
JSON.stringify([conversationId, userId]);
const conversationOwnerMatch = ({ conversationId, userId }: ConversationOwner) => ({
conversationId,
user: userId,
});
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function resolveRange(options: InsightsOptions) {
const now = new Date();
const range = options.range ?? defaultRange;
if (range === 'custom' && options.fromTimestamp && options.toTimestamp) {
const customFrom = new Date(options.fromTimestamp);
const customTo = new Date(options.toTimestamp);
if (Number.isFinite(customFrom.getTime()) && Number.isFinite(customTo.getTime())) {
const [start, end] = customFrom <= customTo ? [customFrom, customTo] : [customTo, customFrom];
const timeZone = validTimeZone(options.timeZone);
const startKey = dateKey(start, timeZone);
const endKey = dateKey(end, timeZone);
if (calendarDayDifference(startKey, endKey) < INSIGHTS_MAX_RANGE_DAYS) {
return { from: start, to: end };
}
const firstAllowedKey = addCalendarDaysToKey(endKey, -(INSIGHTS_MAX_RANGE_DAYS - 1));
return { from: startOfZonedDate(firstAllowedKey, timeZone), to: end };
}
}
const days = rangeDays[range as keyof typeof rangeDays] ?? rangeDays[defaultRange];
return { from: new Date(now.getTime() - days * dayMs), to: now };
}
const addDay = (days: Map<string, TInsightsDailyPoint>, date: string): TInsightsDailyPoint => {
const current = days.get(date) ?? {
date,
conversations: 0,
users: 0,
messages: 0,
totalTokens: 0,
};
days.set(date, current);
return current;
};
const toUser = (
userId: string,
conversations: number,
users: Map<string, UserSummary>,
messageSummary: UserMessageTotals | undefined,
): TInsightsUser => {
const user = users.get(userId);
return {
userId,
name: user?.name || user?.username || '',
email: user?.email ?? '',
conversations,
messages: messageSummary?.messages ?? 0,
};
};
export function createInsightsMethods(mongoose: typeof import('mongoose')): InsightsMethods {
async function getInsights(options: InsightsOptions = {}): Promise<InsightsResult> {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const User = mongoose.models.User as Model<IUser>;
const page = Math.max(1, Math.floor(options.page ?? 1));
const pageSize = Math.min(50, Math.max(5, Math.floor(options.pageSize ?? 10)));
const { from, to } = resolveRange(options);
const timeZone = validTimeZone(options.timeZone);
const churnedCutoff = new Date(to.getTime() - churnedUserWindowDays * dayMs);
const churnedActivityFrom = new Date(from.getTime() - churnedUserWindowDays * dayMs);
const tenant = tenantMatch(options.tenantId);
const tenantConversations = {
...tenant,
isTemporary: { $ne: true },
};
const conversationMatch = {
...tenantConversations,
user: { $nin: [null, ''] },
createdAt: { $gte: from, $lte: to },
};
const messageMatch = {
...tenant,
user: { $nin: [null, ''] },
isTemporary: { $ne: true },
createdAt: { $gte: from, $lte: to },
};
const conversationScope = [{ $match: conversationMatch }];
const messageScope = [{ $match: messageMatch }];
const requestedSearch = options.search?.trim().slice(0, INSIGHTS_SEARCH_MAX_LENGTH) ?? '';
const search = requestedSearch.length >= INSIGHTS_SEARCH_MIN_LENGTH ? requestedSearch : '';
const searchRegex = search ? new RegExp(escapeRegex(search), 'i') : undefined;
const searchedConversationAggregation = searchRegex
? Conversation.aggregate<ConversationListFacet>([
{ $match: conversationMatch },
{
$addFields: {
insightsUserId: {
$convert: { input: '$user', to: 'objectId', onError: null, onNull: null },
},
},
},
{
$lookup: {
from: 'users',
localField: 'insightsUserId',
foreignField: '_id',
as: 'matchedIdentity',
},
},
{
$match: {
$or: [
{ conversationId: searchRegex },
{ user: searchRegex },
{
matchedIdentity: {
$elemMatch: {
...(options.tenantId
? { tenantId: options.tenantId }
: { tenantId: { $exists: false } }),
$or: [
{ name: searchRegex },
{ username: searchRegex },
{ email: searchRegex },
],
},
},
},
],
},
},
{
$facet: {
conversationCount: [{ $count: 'total' }],
recentConversations: [
{ $sort: { createdAt: -1, _id: -1 } },
{ $skip: (page - 1) * pageSize },
{ $limit: pageSize },
{
$project: {
_id: 0,
conversationId: 1,
date: '$createdAt',
userId: '$user',
},
},
],
},
},
])
: Promise.resolve([] as ConversationListFacet[]);
const insightsAggregations = await Promise.all([
Conversation.aggregate<ConversationFacet>([
...conversationScope,
{
$facet: {
conversationCount: [{ $count: 'total' }],
daily: [
{
$group: {
_id: {
$dateToString: {
format: '%Y-%m-%d',
date: '$createdAt',
timezone: timeZone,
},
},
conversations: { $sum: 1 },
},
},
{ $project: { _id: 0, date: '$_id', conversations: 1 } },
{ $sort: { date: 1 } },
],
...(searchRegex
? {}
: {
recentConversations: [
{ $sort: { createdAt: -1, _id: -1 } },
{ $skip: (page - 1) * pageSize },
{ $limit: pageSize },
{
$project: {
_id: 0,
conversationId: 1,
date: '$createdAt',
userId: '$user',
},
},
],
}),
},
},
]),
Message.aggregate<MessageFacet>([
...messageScope,
{
$facet: {
totals: [
{
$group: {
_id: 'all',
messages: { $sum: 1 },
totalTokens: { $sum: { $ifNull: ['$tokenCount', 0] } },
},
},
],
daily: [
{
$group: {
_id: {
$dateToString: {
format: '%Y-%m-%d',
date: '$createdAt',
timezone: timeZone,
},
},
messages: { $sum: 1 },
totalTokens: { $sum: { $ifNull: ['$tokenCount', 0] } },
},
},
{ $project: { _id: 0, date: '$_id', messages: 1, totalTokens: 1 } },
{ $sort: { date: 1 } },
],
userCount: [
{ $match: { user: { $nin: [null, ''] }, isCreatedByUser: true } },
{ $group: { _id: '$user' } },
{ $count: 'total' },
],
dailyUsers: [
{ $match: { user: { $nin: [null, ''] }, isCreatedByUser: true } },
{
$group: {
_id: {
date: {
$dateToString: {
format: '%Y-%m-%d',
date: '$createdAt',
timezone: timeZone,
},
},
user: '$user',
},
},
},
{ $group: { _id: '$_id.date', users: { $sum: 1 } } },
{ $project: { _id: 0, date: '$_id', users: 1 } },
{ $sort: { date: 1 } },
],
topUsers: [
{ $match: { user: { $nin: [null, ''] } } },
{
$group: {
_id: '$user',
messages: { $sum: 1 },
},
},
{ $sort: { messages: -1, _id: 1 } },
{ $limit: 8 },
],
},
},
]),
Message.aggregate<ChurnedUserActivity>([
{
$match: {
...tenant,
isTemporary: { $ne: true },
user: { $nin: [null, ''] },
isCreatedByUser: true,
createdAt: { $gte: churnedActivityFrom, $lte: to },
},
},
{
$group: {
_id: '$user',
lastSeen: { $max: '$createdAt' },
},
},
{ $match: { lastSeen: { $gte: churnedActivityFrom, $lte: churnedCutoff } } },
{ $sort: { lastSeen: -1, _id: 1 } },
{ $limit: churnedUserLimit },
]),
searchedConversationAggregation,
]);
const [conversationFacets, messageFacets, churnedUserRows, searchedConversationFacets] =
insightsAggregations;
const conversationFacet = conversationFacets[0];
const conversationListFacet = searchRegex ? searchedConversationFacets[0] : conversationFacet;
const messageFacet = messageFacets[0];
const messageTotals = messageFacet?.totals ?? [];
const topMessageUsers = messageFacet?.topUsers ?? [];
const latestRows = conversationListFacet?.recentConversations ?? [];
const latestConversationOwners = latestRows
.filter(
(row): row is RecentConversation & { userId: string } =>
isNonEmptyString(row.conversationId) && isNonEmptyString(row.userId),
)
.map(({ conversationId, userId }) => ({ conversationId, userId }));
const latestConversationIds = [
...new Set(latestConversationOwners.map(({ conversationId }) => conversationId)),
];
const latestConversationMatches = latestConversationOwners.map(conversationOwnerMatch);
const topUserIds = topMessageUsers.map(({ _id }) => _id);
const churnedUserIds = churnedUserRows.map(({ _id }) => _id);
const userIds = new Set<string>([
...topUserIds,
...churnedUserIds,
...latestRows.map(({ userId }) => userId).filter(isNonEmptyString),
]);
const [
latestMessageSummaries,
firstMessages,
topConversationCounts,
churnedUserSummaries,
userRows,
] = await Promise.all([
latestConversationMatches.length === 0
? Promise.resolve([] as ConversationMessageSummary[])
: Message.aggregate<ConversationMessageSummary>([
{
$match: {
...tenant,
isTemporary: { $ne: true },
conversationId: { $in: latestConversationIds },
$or: latestConversationMatches,
createdAt: { $gte: from, $lte: to },
},
},
{
$group: {
_id: { conversationId: '$conversationId', userId: '$user' },
messages: { $sum: 1 },
totalTokens: { $sum: { $ifNull: ['$tokenCount', 0] } },
},
},
]),
latestConversationMatches.length === 0
? Promise.resolve([] as FirstMessage[])
: Message.aggregate<FirstMessage>([
{
$match: {
...tenant,
isTemporary: { $ne: true },
conversationId: { $in: latestConversationIds },
$or: latestConversationMatches,
isCreatedByUser: true,
},
},
{ $sort: { createdAt: 1, _id: 1 } },
{
$group: {
_id: { conversationId: '$conversationId', userId: '$user' },
text: { $first: { $ifNull: ['$text', ''] } },
},
},
]),
topUserIds.length === 0
? Promise.resolve([] as UserConversationCount[])
: Message.aggregate<UserConversationCount>([
{
$match: {
...messageMatch,
user: { $in: topUserIds },
isCreatedByUser: true,
},
},
{
$group: {
_id: { user: '$user', conversationId: '$conversationId' },
},
},
{
$group: {
_id: '$_id.user',
conversations: { $sum: 1 },
},
},
]),
churnedUserIds.length === 0
? Promise.resolve([] as ChurnedUserSummary[])
: Message.aggregate<ChurnedUserSummary>([
{
$match: {
...tenant,
isTemporary: { $ne: true },
user: { $in: churnedUserIds },
createdAt: { $lte: to },
},
},
{
$group: {
_id: { user: '$user', conversationId: '$conversationId' },
messages: { $sum: 1 },
firstSeen: { $min: '$createdAt' },
},
},
{
$group: {
_id: '$_id.user',
conversations: { $sum: 1 },
messages: { $sum: '$messages' },
firstSeen: { $min: '$firstSeen' },
},
},
]),
userIds.size === 0
? Promise.resolve([] as UserSummary[])
: User.find({ ...tenant, _id: { $in: [...userIds] } })
.select('_id name username email')
.lean<UserSummary[]>(),
]);
const users = new Map(userRows.map((user) => [user._id.toString(), user]));
const latestSummaries = new Map(
latestMessageSummaries.map((summary) => [conversationOwnerKey(summary._id), summary]),
);
const firstByConversation = new Map(
firstMessages.map((message) => [conversationOwnerKey(message._id), message.text]),
);
const days = new Map<string, TInsightsDailyPoint>();
const firstDay = dateKey(from, timeZone);
const lastDay = dateKey(to, timeZone);
for (let key = firstDay; key <= lastDay; key = addCalendarDaysToKey(key, 1)) {
addDay(days, key);
}
for (const row of conversationFacet?.daily ?? []) {
const day = addDay(days, row.date);
day.conversations = row.conversations;
}
for (const row of messageFacet?.daily ?? []) {
const day = addDay(days, row.date);
day.messages = row.messages;
day.totalTokens = row.totalTokens;
}
for (const row of messageFacet?.dailyUsers ?? []) {
const day = addDay(days, row.date);
day.users = row.users;
}
const totalConversations = conversationFacet?.conversationCount[0]?.total ?? 0;
const matchingConversations = conversationListFacet?.conversationCount[0]?.total ?? 0;
const totalMessages = messageTotals[0]?.messages ?? 0;
const totalTokens = messageTotals[0]?.totalTokens ?? 0;
const topConversationCountsByUser = new Map(
topConversationCounts.map((summary) => [summary._id, summary.conversations]),
);
const churnedSummariesByUser = new Map(
churnedUserSummaries.map((summary) => [summary._id, summary]),
);
return {
summary: {
totalUsers: messageFacet?.userCount[0]?.total ?? 0,
totalConversations,
totalMessages,
totalTokens,
},
daily: [...days.values()].sort((left, right) => left.date.localeCompare(right.date)),
topUsers: topMessageUsers.map((row) =>
toUser(row._id, topConversationCountsByUser.get(row._id) ?? 0, users, row),
),
churnedUsers: churnedUserRows.map((row): TInsightsChurnedUser => {
const user = users.get(row._id);
const summary = churnedSummariesByUser.get(row._id);
return {
userId: row._id,
name: user?.name || user?.username || '',
email: user?.email ?? '',
conversations: summary?.conversations ?? 0,
messages: summary?.messages ?? 0,
firstSeen: summary?.firstSeen.toISOString() ?? row.lastSeen.toISOString(),
lastSeen: row.lastSeen.toISOString(),
};
}),
latest: {
conversations: latestRows.map((row): TInsightsConversation => {
const userId = isNonEmptyString(row.userId) ? row.userId : '';
const user = userId ? users.get(userId) : undefined;
const ownerKey = conversationOwnerKey({ conversationId: row.conversationId, userId });
const summary = latestSummaries.get(ownerKey);
return {
...row,
userId,
date: row.date.toISOString(),
name: user?.name || user?.username || '',
email: user?.email ?? '',
firstMessage: firstByConversation.get(ownerKey) ?? '',
messages: summary?.messages ?? 0,
totalTokens: summary?.totalTokens ?? 0,
};
}),
page,
pageSize,
pages: Math.max(1, Math.ceil(matchingConversations / pageSize)),
},
};
}
return { getInsights };
}

View file

@ -91,6 +91,7 @@ const convoSchema: Schema<IConversation> = new Schema(
convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
convoSchema.index({ createdAt: 1, updatedAt: 1 });
convoSchema.index({ conversationId: 1, user: 1, tenantId: 1 }, { unique: true });
convoSchema.index({ tenantId: 1, isTemporary: 1, createdAt: -1, _id: -1 });
convoSchema.index({ user: 1, _id: 1 });
convoSchema.index({ user: 1, chatProjectId: 1, updatedAt: -1, _id: -1 });
convoSchema.index({ user: 1, chatProjectId: 1, createdAt: -1, _id: -1 });

View file

@ -0,0 +1,40 @@
import type { IndexDefinition, IndexOptions } from 'mongoose';
import conversationSchema from './convo';
import messageSchema from './message';
type SchemaIndexes = Array<[IndexDefinition, IndexOptions]>;
function hasIndex(indexes: SchemaIndexes, fields: IndexDefinition): boolean {
return indexes.some(([candidate]) => JSON.stringify(candidate) === JSON.stringify(fields));
}
describe('Insights indexes', () => {
it('registers the dashboard timeline and user activity indexes', () => {
expect(
hasIndex(conversationSchema.indexes(), {
tenantId: 1,
isTemporary: 1,
createdAt: -1,
_id: -1,
}),
).toBe(true);
expect(
hasIndex(messageSchema.indexes(), {
tenantId: 1,
isTemporary: 1,
createdAt: -1,
_id: -1,
}),
).toBe(true);
expect(
hasIndex(messageSchema.indexes(), {
tenantId: 1,
isTemporary: 1,
isCreatedByUser: 1,
user: 1,
createdAt: -1,
_id: -1,
}),
).toBe(true);
});
});

View file

@ -223,6 +223,15 @@ const messageSchema: Schema<IMessage> = new Schema(
messageSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
messageSchema.index({ createdAt: 1 });
messageSchema.index({ messageId: 1, user: 1, tenantId: 1 }, { unique: true });
messageSchema.index({ tenantId: 1, isTemporary: 1, createdAt: -1, _id: -1 });
messageSchema.index({
tenantId: 1,
isTemporary: 1,
isCreatedByUser: 1,
user: 1,
createdAt: -1,
_id: -1,
});
/**
* Serves the conversation fetch ({conversationId, user} filter + createdAt