mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-04 05:28:30 +00:00
💡 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:
parent
389cfebea1
commit
006e421cd2
47 changed files with 3447 additions and 51 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
93
api/server/routes/__tests__/insights.spec.js
Normal file
93
api/server/routes/__tests__/insights.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
17
api/server/routes/insights.js
Normal file
17
api/server/routes/insights.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue