diff --git a/services/web/app/views/admin/index.pug b/services/web/app/views/admin/index.pug index 88199a2474..201ec01ace 100644 --- a/services/web/app/views/admin/index.pug +++ b/services/web/app/views/admin/index.pug @@ -14,6 +14,7 @@ block content .nav-tabs-container ul.nav.nav-tabs.align-left(role='tablist') +bookmarkable-tabset-header('system-messages', 'System Messages', true) + +bookmarkable-tabset-header('active-projects', 'Active Projects') +bookmarkable-tabset-header('open-sockets', 'Open Sockets') +bookmarkable-tabset-header('open-close-editor', 'Open/Close Editor') +bookmarkable-tabset-header('privileges-matrix', 'Privileges Matrix') @@ -41,6 +42,10 @@ block content input(name='_csrf' type='hidden' value=csrfToken) button.btn.btn-danger(type='submit') Clear all messages + .tab-pane(role='tabpanel' id='active-projects') + .row-spaced + include ../../../modules/admin-tools/app/views/active-projects.pug + .tab-pane(role='tabpanel' id='open-sockets') .row-spaced ul diff --git a/services/web/modules/admin-tools/app/src/AdminToolsController.mjs b/services/web/modules/admin-tools/app/src/AdminToolsController.mjs new file mode 100644 index 0000000000..c51f4006c3 --- /dev/null +++ b/services/web/modules/admin-tools/app/src/AdminToolsController.mjs @@ -0,0 +1,101 @@ +import { expressify } from '@overleaf/promise-utils' +import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' +import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' +import request from 'request' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' + +// Helper function to get all active projects from real-time service +async function getActiveProjectsFromRealTime() { + return new Promise((resolve, reject) => { + const realTimeUrl = Settings.apis.realTime?.url || 'http://127.0.0.1:3026' + const url = `${realTimeUrl}/clients` + const user = Settings.apis.realTime?.user || process.env.WEB_API_USER || 'overleaf' + const pass = Settings.apis.realTime?.pass || process.env.WEB_API_PASSWORD || '' + + logger.info({ url, user }, 'Fetching active clients from real-time service') + + request.get({ + url, + auth: { user, pass, sendImmediately: true }, + json: true, + timeout: 10000 + }, async (error, response, body) => { + if (error) { + logger.error({ err: error }, 'Error getting active clients from real-time') + return resolve([]) + } + if (response.statusCode !== 200) { + logger.warn({ statusCode: response.statusCode }, 'Unexpected response from real-time service') + return resolve([]) + } + if (!Array.isArray(body)) { + logger.warn({ body }, 'Unexpected response body from real-time service') + return resolve([]) + } + + // Group flat client list by project_id + const projectMap = new Map() + for (const client of body) { + if (!projectMap.has(client.project_id)) { + projectMap.set(client.project_id, []) + } + projectMap.get(client.project_id).push(client) + } + + logger.info({ projectCount: projectMap.size }, 'Got active projects from real-time') + + try { + const enrichedProjects = await Promise.all( + [...projectMap.entries()].map(async ([projectId, clients]) => { + try { + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + _id: 1, + owner_ref: 1, + }) + if (!project) { + logger.warn({ projectId }, 'Project not found in database') + return null + } + const owner = await UserGetter.promises.getUser(project.owner_ref, { + email: 1, + first_name: 1, + last_name: 1, + }) + return { + id: project._id.toString(), + name: project.name, + owner: owner ? { + email: owner.email, + name: `${owner.first_name || ''} ${owner.last_name || ''}`.trim() || owner.email, + } : null, + activeUsers: clients.map(c => ({ + name: `${c.first_name} ${c.last_name}`.trim() || c.email || 'Unknown', + email: c.email, + })), + connectionCount: clients.length, + } + } catch (err) { + logger.error({ err, projectId }, 'Error enriching project data') + return null + } + }) + ) + resolve(enrichedProjects.filter(p => p !== null)) + } catch (err) { + logger.error({ err }, 'Error processing active projects') + resolve([]) + } + }) + }) +} + +async function activeProjects(req, res) { + const activeProjects = await getActiveProjectsFromRealTime() + res.json(activeProjects) +} + +export default { + activeProjects: expressify(activeProjects), +} diff --git a/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs index 7a82587191..f994a0142f 100644 --- a/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs +++ b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs @@ -1,6 +1,7 @@ import logger from '@overleaf/logger' import UserListController from './UserListController.mjs' import ProjectListController from './ProjectListController.mjs' +import AdminToolsController from './AdminToolsController.mjs' import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs' import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' @@ -77,5 +78,10 @@ export default { AuthorizationMiddleware.ensureUserIsSiteAdmin, ProjectListController.undeleteProject ) + + webRouter.get('/admin/active-projects', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminToolsController.activeProjects, + ) }, } diff --git a/services/web/modules/admin-tools/app/views/active-projects.pug b/services/web/modules/admin-tools/app/views/active-projects.pug new file mode 100644 index 0000000000..bd96be21e9 --- /dev/null +++ b/services/web/modules/admin-tools/app/views/active-projects.pug @@ -0,0 +1,8 @@ +meta(name="ol-dummy", content="placeholder") + +h3 Active Projects (Currently Being Edited) +p.text-muted Real-time information from the editor service showing who is actively working on which projects. +#active-projects-container + +each file in entrypointScripts('modules/admin-tools/pages/active-projects') + script(type='text/javascript' nonce=scriptNonce src=file) diff --git a/services/web/modules/admin-tools/frontend/js/pages/active-projects.js b/services/web/modules/admin-tools/frontend/js/pages/active-projects.js new file mode 100644 index 0000000000..c9254d0dcd --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/pages/active-projects.js @@ -0,0 +1,101 @@ +document.addEventListener('DOMContentLoaded', () => { + const container = document.getElementById('active-projects-container') + const tabLink = document.querySelector('a[href="#active-projects"]') + const tabPane = document.getElementById('active-projects') + + if (!container || !tabPane) return + + let isLoaded = false + let isLoading = false + + function loadActiveProjects() { + if (isLoaded || isLoading) return + + isLoading = true + + // show loading only if tab is visible + if (tabPane.classList.contains('active')) { + container.innerHTML = '

Loading...

' + } + + fetch('/admin/active-projects') + .then(res => res.json()) + .then(data => { + container.innerHTML = renderTable(data) + isLoaded = true + }) + .catch(() => { + container.innerHTML = + '

Failed to load active projects

' + }) + .finally(() => { + isLoading = false + }) + } + + // preload immediately when /admin page opens + loadActiveProjects() + + // ensure data is loaded when tab is opened + if (tabLink) { + tabLink.addEventListener('shown.bs.tab', () => { + if (!isLoaded) loadActiveProjects() + }) + } +}) + +function renderTable(data) { + if (!Array.isArray(data) || data.length === 0) { + return ` +
+ + Great news! + No projects are currently being edited. +
+ ` + } + + const rows = data.map(project => { + const owner = project.owner || {} + const users = project.activeUsers || [] + + const usersHtml = users.length + ? `` + : 'None detected' + + return ` + + ${project.name} + ${owner.name || 'Unknown'} + ${ + owner.email + ? `${owner.email}` + : 'N/A' + } + ${usersHtml} + ${project.connectionCount} + + ` + }).join('') + + return ` +

+ ${data.length} project(s) currently being edited +

+ + + + + + + + + + + + ${rows} +
Project NameOwnerOwner EmailActive UsersConnections
+ ` +}