From 006e421cd280fddd53eca542c8bb947a6a625556 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Tue, 18 Aug 2026 13:51:51 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A1=20feat:=20add=20DB-backed=20admin?= =?UTF-8?q?=20insights=20(#14898)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .env.example | 3 + api/server/experimental.js | 1 + api/server/index.js | 1 + api/server/routes/__tests__/config.spec.js | 14 + api/server/routes/__tests__/insights.spec.js | 93 ++ api/server/routes/config.js | 1 + api/server/routes/index.js | 2 + api/server/routes/insights.js | 17 + .../src/components/Insights/InsightsView.tsx | 746 ++++++++++++++++ .../src/components/Insights/dateRange.spec.ts | 12 + client/src/components/Insights/dateRange.ts | 7 + client/src/components/Insights/index.ts | 1 + .../UnifiedSidebar/ExpandedPanel.tsx | 24 +- .../src/components/UnifiedSidebar/Sidebar.tsx | 3 + .../UnifiedSidebar/UnifiedSidebar.tsx | 41 +- .../__tests__/ExpandedPanel.spec.tsx | 57 +- .../UnifiedSidebar/mobile/Header.tsx | 11 +- .../UnifiedSidebar/mobile/ShortcutTargets.tsx | 23 +- .../UnifiedSidebar/mobile/Switcher.tsx | 28 +- .../mobile/__tests__/ShortcutTargets.spec.tsx | 47 +- .../ui/LocalizedDateRangePicker.tsx | 207 +++++ .../LocalizedDateRangePicker.spec.tsx | 92 ++ client/src/components/ui/index.ts | 1 + client/src/data-provider/Insights/index.ts | 1 + client/src/data-provider/Insights/queries.ts | 33 + client/src/data-provider/index.ts | 1 + .../src/hooks/Nav/useUnifiedSidebarLinks.ts | 38 +- client/src/locales/en/translation.json | 34 +- client/src/routes/index.tsx | 9 + packages/api/src/index.ts | 2 + packages/api/src/insights/handlers.spec.ts | 164 ++++ packages/api/src/insights/handlers.ts | 103 +++ packages/api/src/insights/index.ts | 1 + .../MCPConnectionSseConflict.test.ts | 21 +- packages/data-provider/src/api-endpoints.ts | 3 + packages/data-provider/src/config.ts | 1 + packages/data-provider/src/data-service.ts | 16 + packages/data-provider/src/index.ts | 1 + packages/data-provider/src/keys.ts | 2 + packages/data-provider/src/types/insights.ts | 70 ++ .../data-schemas/src/admin/capabilities.ts | 2 + packages/data-schemas/src/methods/index.ts | 8 +- .../data-schemas/src/methods/insights.spec.ts | 807 ++++++++++++++++++ packages/data-schemas/src/methods/insights.ts | 699 +++++++++++++++ packages/data-schemas/src/schema/convo.ts | 1 + .../data-schemas/src/schema/insights.spec.ts | 40 + packages/data-schemas/src/schema/message.ts | 9 + 47 files changed, 3447 insertions(+), 51 deletions(-) create mode 100644 api/server/routes/__tests__/insights.spec.js create mode 100644 api/server/routes/insights.js create mode 100644 client/src/components/Insights/InsightsView.tsx create mode 100644 client/src/components/Insights/dateRange.spec.ts create mode 100644 client/src/components/Insights/dateRange.ts create mode 100644 client/src/components/Insights/index.ts create mode 100644 client/src/components/ui/LocalizedDateRangePicker.tsx create mode 100644 client/src/components/ui/__tests__/LocalizedDateRangePicker.spec.tsx create mode 100644 client/src/data-provider/Insights/index.ts create mode 100644 client/src/data-provider/Insights/queries.ts create mode 100644 packages/api/src/insights/handlers.spec.ts create mode 100644 packages/api/src/insights/handlers.ts create mode 100644 packages/api/src/insights/index.ts create mode 100644 packages/data-provider/src/types/insights.ts create mode 100644 packages/data-schemas/src/methods/insights.spec.ts create mode 100644 packages/data-schemas/src/methods/insights.ts create mode 100644 packages/data-schemas/src/schema/insights.spec.ts diff --git a/.env.example b/.env.example index 301ab8adb6..eb00215d0e 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/api/server/experimental.js b/api/server/experimental.js index 387e6940a5..2d2ff5ec6f 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -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); diff --git a/api/server/index.js b/api/server/index.js index 87aed78629..f3cf7c67ec 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -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); diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index ee53575574..7b81f5a145 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -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); diff --git a/api/server/routes/__tests__/insights.spec.js b/api/server/routes/__tests__/insights.spec.js new file mode 100644 index 0000000000..3f754eb0e8 --- /dev/null +++ b/api/server/routes/__tests__/insights.spec.js @@ -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); + }); +}); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 6a14f5f760..6e16abfd9f 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -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), diff --git a/api/server/routes/index.js b/api/server/routes/index.js index c2578e4353..96baaf92b6 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -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, diff --git a/api/server/routes/insights.js b/api/server/routes/insights.js new file mode 100644 index 0000000000..2f58bb82df --- /dev/null +++ b/api/server/routes/insights.js @@ -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; diff --git a/client/src/components/Insights/InsightsView.tsx b/client/src/components/Insights/InsightsView.tsx new file mode 100644 index 0000000000..6ca7ceb0d6 --- /dev/null +++ b/client/src/components/Insights/InsightsView.tsx @@ -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; +type Localize = ReturnType; +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 ( +
+ {children} +
+ ); +} + +function EmptyState({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function LoadingState({ message }: { message: string }) { + return ( + + + {message} + + ); +} + +function Sparkline({ + values, + label, + locale, +}: { + values: SparklinePoint[]; + label: string; + locale: string; +}) { + const patternId = useId().replace(/:/g, ''); + const [activeIndex, setActiveIndex] = useState(); + 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
; + } + + 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 ( +
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))); + }} + > + + {activePoint && ( + <> + + + + {formatter.format(new Date(activePoint.date))}{' '} + {formatExactValue(activePoint.value, locale)} + + + )} +
+ ); +} + +function KpiCard({ card, locale }: { card: KpiCardData; locale: string }) { + const localize = useLocalize(); + return ( + +

{card.title}

+
+ {formatValue(card.value, locale)} +
+ +
+ ); +} + +function TablePanel({ title, children }: { title: React.ReactNode; children: React.ReactNode }) { + return ( + +

{title}

+ {children} +
+ ); +} + +function UserCell({ name, email, localize }: { name: string; email: string; localize: Localize }) { + return ( +
+
{displayUserName(name, localize)}
+
{email}
+
+ ); +} + +function TopUsersTable({ + rows, + localize, + locale, +}: { + rows: TInsightsUser[]; + localize: Localize; + locale: string; +}) { + return ( + + {rows.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + {rows.map((entry) => ( + + + + + + ))} + +
{localize('com_insights_user')} + {localize('com_insights_messages')} + + {localize('com_insights_chats')} +
+ + + {formatExactValue(entry.messages, locale)} + + {formatExactValue(entry.conversations, locale)} +
+
+ )} +
+ ); +} + +function ChurnedUsersTable({ + rows, + localize, + locale, +}: { + rows: TInsightsChurnedUser[]; + localize: Localize; + locale: string; +}) { + const definition = localize('com_insights_churned_users_definition'); + return ( + + {localize('com_insights_churned_users')} + + + ); +} + +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>; + setSearch: React.Dispatch>; + localize: Localize; + locale: string; +}) { + return ( + +
+
+

+ {localize('com_insights_latest_conversations')} +

+ {isFetching && } +
+
+
+
+
+ + + + + + + + + + + + {rows.map((conversation) => ( + + + + + + + + ))} + +
{localize('com_insights_date')}{localize('com_insights_user')}{localize('com_insights_first_message')} + {localize('com_insights_messages')} + + {localize('com_insights_total_tokens')} +
+ {formatRecentChatDate(conversation.date, locale)} + + + + + {conversation.firstMessage || localize('com_insights_no_message')} + + + {formatExactValue(conversation.messages, locale)} + + {formatValue(conversation.totalTokens, locale)} +
+
+ {rows.length === 0 && ( + + )} +
+ {localize('com_insights_page_of', { page, pages })} +
+ + +
+
+
+ ); +} + +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('7d'); + const [customDateRange, setCustomDateRange] = useState(); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const dateRangeSelectionTimeout = useRef(); + 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(() => { + 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(() => { + 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 ( +
+ +
+ ); + } + const accessStatus = responseStatus(access.error); + if (access.isError && accessStatus !== 403 && accessStatus !== 404) { + return ( +
+ + + {localize('com_insights_load_error')} + +
+ ); + } + if (!isAllowed) { + return ; + } + + return ( +
+
+
+ {isSmallScreen && } +

{localize('com_insights_title')}

+
+
+
+ {ranges.map((item) => ( + + ))} +
+
+ +
+
+
+
+
+ {insights.isLoading && } + {insights.isError && ( + + + {localize('com_insights_load_error')} + + )} + {data && ( + <> +
+ {kpiCards.map((card) => ( + + ))} +
+
+ + +
+ + + )} +
+
+
+ ); +} diff --git a/client/src/components/Insights/dateRange.spec.ts b/client/src/components/Insights/dateRange.spec.ts new file mode 100644 index 0000000000..63f13dc58a --- /dev/null +++ b/client/src/components/Insights/dateRange.spec.ts @@ -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); + }); +}); diff --git a/client/src/components/Insights/dateRange.ts b/client/src/components/Insights/dateRange.ts new file mode 100644 index 0000000000..9a9057a90f --- /dev/null +++ b/client/src/components/Insights/dateRange.ts @@ -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 }; +} diff --git a/client/src/components/Insights/index.ts b/client/src/components/Insights/index.ts new file mode 100644 index 0000000000..56d3fd433d --- /dev/null +++ b/client/src/components/Insights/index.ts @@ -0,0 +1 @@ +export { default } from './InsightsView'; diff --git a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx index 437320712a..5e9748acc7 100644 --- a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx +++ b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx @@ -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) => { 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({ ))}
diff --git a/client/src/components/UnifiedSidebar/Sidebar.tsx b/client/src/components/UnifiedSidebar/Sidebar.tsx index 3f3064ac11..7c01d76ca0 100644 --- a/client/src/components/UnifiedSidebar/Sidebar.tsx +++ b/client/src/components/UnifiedSidebar/Sidebar.tsx @@ -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} />