diff --git a/.env.defaults b/.env.defaults index fabcb48..ab6f80f 100644 --- a/.env.defaults +++ b/.env.defaults @@ -10,3 +10,4 @@ FGSEA_PRERANKED_SERVICE_URL=https://fgseadev.baderlab.net/v1/preranked FGSEA_RNASEQ_SERVICE_URL=https://fgseadev.baderlab.net/v1/rnaseq EM_SERVICE_URL=https://emjavadev.baderlab.net/v1 BRIDGEDB_URL=https://webservice.bridgedb.org +REPORT_SECRET=baderlab diff --git a/.env.local b/.env.local index 13f4285..60872ec 100644 --- a/.env.local +++ b/.env.local @@ -10,3 +10,4 @@ FGSEA_PRERANKED_SERVICE_URL=http://localhost:8000/preranked FGSEA_RNASEQ_SERVICE_URL=http://localhost:8000/rnaseq EM_SERVICE_URL=http://localhost:8080/v1 BRIDGEDB_URL=https://webservice.bridgedb.org +REPORT_SECRET=baderlab diff --git a/src/client/components/network-editor/export-controller.js b/src/client/components/network-editor/export-controller.js index 38e8f59..cf88b74 100644 --- a/src/client/components/network-editor/export-controller.js +++ b/src/client/components/network-editor/export-controller.js @@ -25,6 +25,7 @@ const Path = { DATA_ENRICH: 'data/enrichment_results.txt', DATA_RANKS: 'data/ranks.txt', DATA_GENESETS: 'data/gene_sets.gmt', + DATA_JSON: 'data/network.json', README: 'README.md' }; @@ -60,6 +61,7 @@ export class ExportController { const blob2 = await this._createNetworkImageBlob(ImageSize.LARGE); const blob3 = await this._createNetworkPDFBlob(); const blob4 = await this._createSVGLegendBlob(); + // const blob5 = await this._createNetworkJSONBlob(); const files = await filesPromise; const readme = createREADME(this.controller); @@ -69,6 +71,7 @@ export class ExportController { zip.file(Path.IMAGE_LARGE, blob2); zip.file(Path.IMAGE_PDF, blob3); zip.file(Path.IMAGE_LEGEND, blob4); + // zip.file(Path.DATA_JSON, blob5); zip.file(Path.DATA_ENRICH, files[0]); zip.file(Path.DATA_RANKS, files[1]); zip.file(Path.DATA_GENESETS, files[2]); @@ -148,6 +151,16 @@ export class ExportController { return blob; } + async _createNetworkJSONBlob() { + const { cy } = this.controller; + const json = cy.json(); + const blob = new Blob( + [ JSON.stringify(json, null, 2) ], { + type: 'text/plain' + }); + return blob; + } + async _createNetworkPDFBlob() { const { cy } = this.controller; const blob = await cy.pdf({ diff --git a/src/client/components/network-editor/pathway-table.js b/src/client/components/network-editor/pathway-table.js index c1204d9..d76db60 100644 --- a/src/client/components/network-editor/pathway-table.js +++ b/src/client/components/network-editor/pathway-table.js @@ -244,24 +244,29 @@ const linkoutProps = { target: "_blank", rel: "noreferrer", underline: "hover" const DEF_ORDER = 'desc'; const DEF_ORDER_BY = 'nes'; -const descendingComparator = (a, b, orderBy) => { +export const descendingComparator = (a, b, orderBy) => { + const aVal = a[orderBy], bVal = b[orderBy]; + // null values come last in ascending! - if (a[orderBy] == null) { + if (aVal == null) { return -1; } - if (b[orderBy] == null) { + if (bVal == null) { return 1; } - if (b[orderBy] < a[orderBy]) { + if(typeof aVal === 'string' && typeof bVal === 'string') { + return aVal.localeCompare(bVal, undefined, { sensitivity: 'accent' }); + } + if (bVal < aVal) { return -1; } - if (b[orderBy] > a[orderBy]) { + if (bVal > aVal) { return 1; } return 0; }; -const getComparator = (order, orderBy) => { +export const getComparator = (order, orderBy) => { return order === 'desc' ? (a, b) => descendingComparator(a, b, orderBy) : (a, b) => -descendingComparator(a, b, orderBy); diff --git a/src/client/components/report/index.js b/src/client/components/report/index.js new file mode 100644 index 0000000..4b9bca6 --- /dev/null +++ b/src/client/components/report/index.js @@ -0,0 +1,34 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { ThemeProvider } from '@material-ui/core/styles'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import { currentTheme } from '../../theme'; +import Report from './report'; + + +export function ReportHome({ secret }) { + const [ theme, setTheme ] = useState(currentTheme); + + useEffect(() => { + // Listen for changes in the user's theme preference + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleThemeChange = () => setTheme(currentTheme()); + mediaQuery.addEventListener('change', handleThemeChange); + return () => { + mediaQuery.removeEventListener('change', handleThemeChange); + }; + }, []); + + return ( + + + + + ); +} + +ReportHome.propTypes = { + secret: PropTypes.string, +}; + +export default ReportHome; \ No newline at end of file diff --git a/src/client/components/report/report.js b/src/client/components/report/report.js new file mode 100644 index 0000000..ca225f7 --- /dev/null +++ b/src/client/components/report/report.js @@ -0,0 +1,124 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { getComparator } from '../network-editor/pathway-table'; +import { Select, MenuItem } from '@material-ui/core'; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; + + +const useStyles = makeStyles((theme) => ({ + orderBy: { + margin: theme.spacing(1), + minWidth: 200, + }, + order: { + margin: theme.spacing(1), + minWidth: 120, + }, +})); + + +async function fetchReport(secret) { + try { + const countRes = await fetch(`/api/report/count/${secret}`); + if(!countRes.ok) { + return 'error'; + } + const networkRes = await fetch(`/api/report/networks/${secret}`); + if(!networkRes.ok) { + return 'error'; + } + + const counts = await countRes.json(); + const networks = await networkRes.json(); + + return { counts, networks }; + } catch(err) { + console.log(err); + return 'error'; + } +} + + +export function Report({ secret }) { + const classes = useStyles(); + + const [ report, setReport ] = useState(null); + const [ order, setOrder] = useState('desc'); + const [ orderBy, setOrderBy ] = useState('creationTime'); + + useEffect(() => { + fetchReport(secret).then(setReport); + }, []); + + if(!report) { + return
Loading...
; + } else if(report === 'error') { + return
Error fetching report.
; + } + + const comparator = getComparator(order, orderBy); + const sortedNetworks = report.networks.sort(comparator); + + return
+

EnrichmentMap:RNA-Seq - Usage Report

+

Demo Networks: {report.counts.demo}

+

User Created Networks ({report.counts.user}):

+
+ Sort: +    + +   + +
+

+ + + + + Network Name + Nodes + Edges + Type + Creation Time + Last Access Time + + + + + {sortedNetworks.map(network => { + const createTime = new Date(network.creationTime).toLocaleString('en-CA'); + const accessTime = new Date(network.lastAccessTime).toLocaleString('en-CA'); + return ( + + {network.networkName} + {network.nodeCount} + {network.edgeCount} + {network.inputType} + {createTime} + {accessTime} + open + + ); + })} + +
+
+
; +} + +Report.propTypes = { + secret: PropTypes.string, +}; + +export default Report; \ No newline at end of file diff --git a/src/client/router.js b/src/client/router.js index bfb89fb..5ff8e95 100644 --- a/src/client/router.js +++ b/src/client/router.js @@ -5,6 +5,7 @@ import PageNotFound from './components/page-not-found'; import { RecentNetworksController } from './components/recent-networks-controller'; import { Home } from './components/home'; import { NetworkEditor } from './components/network-editor'; +import { ReportHome } from './components/report'; const recentNetworksController = new RecentNetworksController(); @@ -38,6 +39,13 @@ export function Router() { )} /> + { + const secret = _.get(props, ['match', 'params', 'secret'], _.get(props, 'secret')); + return ; + }} + /> ( diff --git a/src/server/datastore.js b/src/server/datastore.js index ec4af50..5754d59 100644 --- a/src/server/datastore.js +++ b/src/server/datastore.js @@ -752,6 +752,51 @@ class Datastore { return cursor; } + + async getNetworkCounts() { + const result = await this.db + .collection(NETWORKS_COLLECTION) + .aggregate([ + { $facet: { + 'user': [ + { $match: { demo: { $ne: true } }}, + { $count: 'total' } + ], + 'demo': [ + { $match: { demo: true }}, + { $count: 'total' } + ], + }}, + { $project: { + "user": { $arrayElemAt: ["$user.total", 0] }, + "demo": { $arrayElemAt: ["$demo.total", 0] }, + }} + ]).toArray(); + + return result[0]; + } + + + async getNetworkStatsCursor() { + const cursor = await this.db + .collection(NETWORKS_COLLECTION) + .aggregate([ + { $match: { demo: { $ne: true } }}, + { $project: { + networkName: 1, + creationTime: 1, + lastAccessTime: 1, + geneSetCollection: 1, + inputType: 1, + nodeCount: { $size: '$network.elements.nodes' }, + edgeCount: { $size: '$network.elements.edges' } + }} + ]); + + return cursor; + } + + } const ds = new Datastore(); // singleton diff --git a/src/server/env.js b/src/server/env.js index 3417945..6fedc3f 100644 --- a/src/server/env.js +++ b/src/server/env.js @@ -14,6 +14,7 @@ export const LOG_LEVEL = process.env.LOG_LEVEL; export const BASE_URL = process.env.BASE_URL; export const UPLOAD_LIMIT = process.env.UPLOAD_LIMIT; export const TESTING = ('' + process.env.TESTING).toLowerCase() === 'true'; +export const REPORT_SECRET = process.env.REPORT_SECRET; // Service config export const FGSEA_PRERANKED_SERVICE_URL = process.env.FGSEA_PRERANKED_SERVICE_URL; diff --git a/src/server/index.js b/src/server/index.js index fe1b117..9206fe9 100755 --- a/src/server/index.js +++ b/src/server/index.js @@ -21,6 +21,7 @@ import indexRouter from './routes/index.js'; import apiRouter from './routes/api/index.js'; import createRouter, { createRouterErrorHandler } from './routes/api/create.js'; import exportRouter from './routes/api/export.js'; +import reportRouter from './routes/api/report.js'; import Datastore, { GMT_2 } from './datastore.js'; @@ -108,6 +109,7 @@ app.use('/', indexRouter); app.use('/api', apiRouter); app.use('/api/create', createRouter); app.use('/api/export', exportRouter); +app.use('/api/report', reportRouter); // The error handler must be before any other error middleware and after all controllers if (SENTRY) { diff --git a/src/server/routes/api/index.js b/src/server/routes/api/index.js index 8fff02e..16292ca 100644 --- a/src/server/routes/api/index.js +++ b/src/server/routes/api/index.js @@ -231,7 +231,7 @@ http.delete('/:netid/positions', async function(req, res, next) { }); -async function writeCursorToResult(cursor, res) { +export async function writeCursorToResult(cursor, res) { res.write('['); if(await cursor.hasNext()) { const obj = await cursor.next(); diff --git a/src/server/routes/api/report.js b/src/server/routes/api/report.js new file mode 100644 index 0000000..073aed4 --- /dev/null +++ b/src/server/routes/api/report.js @@ -0,0 +1,46 @@ +import Express from 'express'; +import Datastore from '../../datastore.js'; +import { writeCursorToResult } from './index.js'; +import { REPORT_SECRET } from '../../env.js'; + +const http = Express.Router(); + +// Return enrichment results in TSV format +http.get(`/count/:secret`, async function(req, res, next) { + + try { + const { secret } = req.params; + if(secret !== REPORT_SECRET) { + res.sendStatus(404); + return; + } + + const counts = await Datastore.getNetworkCounts(); + res.send(JSON.stringify(counts)); + + } catch(err) { + next(err); + } +}); + +// Return enrichment results in TSV format +http.get(`/networks/:secret`, async function(req, res, next) { + try { + const { secret } = req.params; + if(secret !== REPORT_SECRET) { + res.sendStatus(404); + return; + } + + const cursor = await Datastore.getNetworkStatsCursor(); + await writeCursorToResult(cursor, res); + cursor.close(); + + } catch(err) { + next(err); + } finally { + res.end(); + } +}); + +export default http; \ No newline at end of file