Admin tools: improve Active Projects UI

- do not show the project owner, email and connections number columns
 - add mailto: all active users of a project when clicking the project title
 - fix error handling
This commit is contained in:
yu-i-i
2026-09-22 02:41:44 +02:00
parent 08bc4c65dc
commit 94f3c564a6
2 changed files with 118 additions and 105 deletions
@@ -1,99 +1,90 @@
import { expressify } from '@overleaf/promise-utils' import { expressify } from '@overleaf/promise-utils'
import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs'
import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' import { fetchJson } from '@overleaf/fetch-utils'
import request from 'request'
import Settings from '@overleaf/settings' import Settings from '@overleaf/settings'
import logger from '@overleaf/logger' import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
const REQUEST_TIMEOUT_MS = 10000
// Helper function to get all active projects from real-time service // Helper function to get all active projects from real-time service
async function getActiveProjectsFromRealTime() { async function getActiveProjectsFromRealTime() {
return new Promise((resolve, reject) => { const realTimeUrl = Settings.apis.realTime?.url || 'http://127.0.0.1:3026'
const realTimeUrl = Settings.apis.realTime?.url || 'http://127.0.0.1:3026' const url = `${realTimeUrl}/clients`
const url = `${realTimeUrl}/clients` const user = Settings.apis.realTime?.user || process.env.WEB_API_USER || 'overleaf'
const user = Settings.apis.realTime?.user || process.env.WEB_API_USER || 'overleaf' const password = Settings.apis.realTime?.pass || process.env.WEB_API_PASSWORD || ''
const pass = Settings.apis.realTime?.pass || process.env.WEB_API_PASSWORD || ''
logger.info({ url, user }, 'Fetching active clients from real-time service') logger.debug({ url, user }, 'Fetching active clients from real-time service')
request.get({ let clients
url, try {
auth: { user, pass, sendImmediately: true }, clients = await fetchJson(url, {
json: true, basicAuth: {
timeout: 10000 user,
}, async (error, response, body) => { password,
if (error) { },
logger.error({ err: error }, 'Error getting active clients from real-time') signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
return resolve([]) })
} } catch (err) {
if (response.statusCode !== 200) { throw new OError('Error getting active clients from real-time', {}, err)
logger.warn({ statusCode: response.statusCode }, 'Unexpected response from real-time service') }
return resolve([]) if (!Array.isArray(clients)) {
} throw new OError('Unexpected response body from real-time service', { body: clients })
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()
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') for (const client of clients) {
let list = projectMap.get(client.project_id)
if (!list) {
list = []
projectMap.set(client.project_id, list)
}
list.push(client)
}
logger.debug({ projectCount: projectMap.size }, 'Got active projects from real-time')
const enrichedProjects = await Promise.all(
[...projectMap.entries()].map(async ([projectId, clients]) => {
try { try {
const enrichedProjects = await Promise.all( const project = await ProjectGetter.promises.getProject(projectId, {
[...projectMap.entries()].map(async ([projectId, clients]) => { _id: 1,
try { name: 1,
const project = await ProjectGetter.promises.getProject(projectId, { })
name: 1,
_id: 1, if (!project) {
owner_ref: 1, throw new OError('Project not found in database', { projectId })
}) }
if (!project) {
logger.warn({ projectId }, 'Project not found in database') return {
return null id: project._id.toString(),
} name: project.name,
const owner = await UserGetter.promises.getUser(project.owner_ref, { activeUsers: clients.map(client => ({
email: 1, name: `${client.first_name || ''} ${client.last_name || ''}`.trim() || client.email || 'Unknown',
first_name: 1, email: client.email,
last_name: 1, })),
}) connectionCount: clients.length,
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) { } catch (err) {
logger.error({ err }, 'Error processing active projects') throw new OError('Error enriching project data', { projectId }, err)
resolve([])
} }
}) })
}) )
return enrichedProjects.filter(Boolean)
} }
async function activeProjects(req, res) { async function activeProjects(req, res) {
const activeProjects = await getActiveProjectsFromRealTime() try {
res.json(activeProjects) const projects = await getActiveProjectsFromRealTime()
res.json(projects)
} catch (err) {
const info = OError.getFullInfo(err)
logger.error(OError.getFullStack(err))
logger.error({ info }, 'Error processing active projects')
throw err
}
} }
export default { export default {
@@ -55,28 +55,49 @@ function renderTable(data) {
` `
} }
const rows = data.map(project => { const rows = data.map((project, projectIndex) => {
const owner = project.owner || {}
const users = project.activeUsers || [] const users = project.activeUsers || []
const mailto = users
.map(u => u.email)
.filter(Boolean)
.join(',')
const projectName = mailto
? `<a href="mailto:${mailto}" style="text-decoration:none;">${project.name}</a>`
: project.name
const stripedStyle = projectIndex % 2 === 1
? 'background-color: var(--bs-table-striped-bg);'
: ''
const usersHtml = users.length const usersHtml = users.length
? `<ul class="list-unstyled mb-0">${users.map(u => ? users.map(user => `
`<li><strong>${u.name}</strong>${u.email ? ` (${u.email})` : ''}</li>` <div>
).join('')}</ul>` ${
user.email
? `<a href="mailto:${user.email}" style="text-decoration:none;">${user.name}</a>`
: user.name
}
</div>
`).join('')
: '<em>None detected</em>' : '<em>None detected</em>'
return ` return `
<tr> <div style="
<td><a href="/project/${project.id}" target="_blank">${project.name}</a></td> display:grid;
<td>${owner.name || 'Unknown'}</td> grid-template-columns:70% 30%;
<td>${ column-gap:1rem;
owner.email ${stripedStyle}
? `<a href="mailto:${owner.email}">${owner.email}</a>` padding:0.5rem 0;
: 'N/A' ">
}</td> <div style="background:inherit;">
<td>${usersHtml}</td> ${projectName}
<td>${project.connectionCount}</td> </div>
</tr> <div style="background:inherit;">
${usersHtml}
</div>
</div>
` `
}).join('') }).join('')
@@ -85,17 +106,18 @@ function renderTable(data) {
<strong>${data.length}</strong> project(s) currently being edited <strong>${data.length}</strong> project(s) currently being edited
</p> </p>
<table class="table table-striped table-hover"> <div style="
<thead> display:grid;
<tr> grid-template-columns:70% 30%;
<th>Project Name</th> column-gap:1rem;
<th>Owner</th> font-weight:bold;
<th>Owner Email</th> padding-bottom:0.5rem;
<th>Active Users</th> border-bottom:1px solid var(--bs-border-color);
<th>Connections</th> ">
</tr> <div>Project Name</div>
</thead> <div>Active User</div>
<tbody>${rows}</tbody> </div>
</table>
${rows}
` `
} }