[CORL-1016] Admin Dashboard (#2942)

* count things in redis

* add rest routes for daily comment totals

* add route for new commenters

* retrieve hourly totals for new commenters and comments

* add rate limiting

* add routes and components for dashboard

* add top stories component

* add activity charts

* clean up rest responses

* style dashboard components

* make dashboard site-aware

* change new commenters to new signups

* generate new signups by day chart

* update status pie chart

* update status pie chart

* allow site selection in dashboard

* cache daily signups

* count user bans in redis

* clean up route and method names

* clean up comment statuses chart and change from pie to bar

* fix package lock

* make daily counts time zone aware

* count rejected comments

* clean up today counts

* count comments from site

* store hourly keys in utc

* show daily user signups

* move siteID from params to query params

* add average line for daily comments chart

* make average comments count hourly

* show percent values for rejected comments

* style dashboard

* simplify dashboard rest routes

* style today totals and top stories

* style signup and comment activity chart

* add site selector to nav

* feat: redis/mongo refactor

* fix: some small tweaks

- Added comments to magic numbers
- Added errors for missing input
- Consolidated promise resolutions

* Revert "add site selector to nav"

This reverts commit 1c2b1dee34fb2742b04932079fd45f7f3de98418.

* show first site dashboard by default

* style dashboard site switcher

* updte snapshot

* add dashboard 118n keys

* udpate comment activiyt chart with legend and tooltip

* implement refresh button and loading states

* change colours of charts for today

* adjust today values spacing

* remove unused package

* don't pass redis into oauth strat

* remove unused package

* Revert "remove unused package"

This reverts commit 5b7c83a072604810ce7097865655e9ef8114d9e0.

* fix merge conflicts

* Fix icons bug on smaller screens

* resolve merge conflict

Co-authored-by: Wyatt Johnson <me@wyattjoh.ca>
This commit is contained in:
Tessa Thornton
2020-06-12 10:39:28 -04:00
committed by GitHub
parent 4179306cd0
commit 8966dad344
66 changed files with 2394 additions and 110 deletions
+1 -1
View File
@@ -54,5 +54,5 @@
"search.exclude": { "search.exclude": {
"package-lock.json": true "package-lock.json": true
}, },
"debug.node.autoAttach": "off", "debug.node.autoAttach": "off"
} }
+282 -93
View File
@@ -7324,6 +7324,19 @@
"postcss": "7.x.x" "postcss": "7.x.x"
} }
}, },
"@types/d3-path": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.8.tgz",
"integrity": "sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA=="
},
"@types/d3-shape": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.2.tgz",
"integrity": "sha512-LtD8EaNYCaBRzHzaAiIPrfcL3DdIysc81dkGlQvv7WQP3+YXV7b0JJTtR1U3bzeRieS603KF4wUo+ZkJVenh8w==",
"requires": {
"@types/d3-path": "*"
}
},
"@types/debug": { "@types/debug": {
"version": "0.0.30", "version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-0.0.30.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-0.0.30.tgz",
@@ -8007,8 +8020,7 @@
"@types/prop-types": { "@types/prop-types": {
"version": "15.5.8", "version": "15.5.8",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.8.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.8.tgz",
"integrity": "sha512-3AQoUxQcQtLHsK25wtTWIoIpgYjH3vSDroZOUr7PpCHw/jLY1RB9z9E8dBT/OSmwStVgkRNvdh+ZHNiomRieaw==", "integrity": "sha512-3AQoUxQcQtLHsK25wtTWIoIpgYjH3vSDroZOUr7PpCHw/jLY1RB9z9E8dBT/OSmwStVgkRNvdh+ZHNiomRieaw=="
"dev": true
}, },
"@types/q": { "@types/q": {
"version": "1.5.1", "version": "1.5.1",
@@ -8040,7 +8052,6 @@
"version": "16.9.31", "version": "16.9.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.31.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.31.tgz",
"integrity": "sha512-NpYJpNMWScFXtx3A2BJMeew2G3+9SEslVWMdxNJ6DLvxIuxWjY1bizK9q5Y1ujhln31vtjmhjOAYDr9Xx3k9FQ==", "integrity": "sha512-NpYJpNMWScFXtx3A2BJMeew2G3+9SEslVWMdxNJ6DLvxIuxWjY1bizK9q5Y1ujhln31vtjmhjOAYDr9Xx3k9FQ==",
"dev": true,
"requires": { "requires": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^2.2.0" "csstype": "^2.2.0"
@@ -8129,6 +8140,21 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"@types/recharts": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.11.tgz",
"integrity": "sha512-1E1+3uKo7EyNk3LZ6J2cqfayIdQEUt1YOq2Y/9eRSXUzasf100ncF4f0iARnaXnED6DyFgTPRomwtChcwLYvhA==",
"requires": {
"@types/d3-shape": "*",
"@types/react": "*",
"@types/recharts-scale": "*"
}
},
"@types/recharts-scale": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/recharts-scale/-/recharts-scale-1.0.0.tgz",
"integrity": "sha512-HR/PrCcxYb2YHviTqH7CMdL1TUhUZLTUKzfrkMhxm1HTa5mg/QtP8XMiuSPz6dZ6wecazAOu8aYZ5DqkNlgHHQ=="
},
"@types/recompose": { "@types/recompose": {
"version": "0.30.7", "version": "0.30.7",
"resolved": "https://registry.npmjs.org/@types/recompose/-/recompose-0.30.7.tgz", "resolved": "https://registry.npmjs.org/@types/recompose/-/recompose-0.30.7.tgz",
@@ -13023,8 +13049,7 @@
"classnames": { "classnames": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==", "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
"dev": true
}, },
"clean-css": { "clean-css": {
"version": "4.2.1", "version": "4.2.1",
@@ -15621,6 +15646,73 @@
"es5-ext": "^0.10.9" "es5-ext": "^0.10.9"
} }
}, },
"d3-array": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz",
"integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="
},
"d3-collection": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz",
"integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A=="
},
"d3-color": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
},
"d3-format": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.4.tgz",
"integrity": "sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw=="
},
"d3-interpolate": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
"integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
"requires": {
"d3-color": "1"
}
},
"d3-path": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
},
"d3-scale": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz",
"integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==",
"requires": {
"d3-array": "^1.2.0",
"d3-collection": "1",
"d3-format": "1",
"d3-interpolate": "1",
"d3-time": "1",
"d3-time-format": "2"
}
},
"d3-shape": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
"requires": {
"d3-path": "1"
}
},
"d3-time": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
"integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="
},
"d3-time-format": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.2.3.tgz",
"integrity": "sha512-RAHNnD8+XvC4Zc4d2A56Uw0yJoM7bsvOlJR33bclxq399Rak/b9bhvu/InjxdWhPtkgU53JJcleJTGkNRnN6IA==",
"requires": {
"d3-time": "1"
}
},
"damerau-levenshtein": { "damerau-levenshtein": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.5.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.5.tgz",
@@ -15927,6 +16019,11 @@
"integrity": "sha1-rzJJRl4TOYjDB1D3fqr0RQXKpeM=", "integrity": "sha1-rzJJRl4TOYjDB1D3fqr0RQXKpeM=",
"dev": true "dev": true
}, },
"decimal.js-light": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.0.tgz",
"integrity": "sha512-b3VJCbd2hwUpeRGG3Toob+CRo8W22xplipNhP3tN7TSVB/cyMX71P1vM2Xjc9H74uV6dS2hDDmo/rHq8L87Upg=="
},
"decode-uri-component": { "decode-uri-component": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
@@ -17539,7 +17636,6 @@
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
"dev": true,
"requires": { "requires": {
"@babel/runtime": "^7.1.2" "@babel/runtime": "^7.1.2"
} }
@@ -21389,28 +21485,28 @@
"dependencies": { "dependencies": {
"abbrev": { "abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": false, "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"ansi-regex": { "ansi-regex": {
"version": "2.1.1", "version": "2.1.1",
"resolved": false, "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
"resolved": false, "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
"integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"are-we-there-yet": { "are-we-there-yet": {
"version": "1.1.5", "version": "1.1.5",
"resolved": false, "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz",
"integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21421,14 +21517,14 @@
}, },
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"resolved": false, "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": false, "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21439,42 +21535,42 @@
}, },
"chownr": { "chownr": {
"version": "1.1.1", "version": "1.1.1",
"resolved": false, "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz",
"integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"code-point-at": { "code-point-at": {
"version": "1.1.0", "version": "1.1.0",
"resolved": false, "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": false, "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"resolved": false, "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"core-util-is": { "core-util-is": {
"version": "1.0.2", "version": "1.0.2",
"resolved": false, "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"debug": { "debug": {
"version": "4.1.1", "version": "4.1.1",
"resolved": false, "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
"integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21484,28 +21580,28 @@
}, },
"deep-extend": { "deep-extend": {
"version": "0.6.0", "version": "0.6.0",
"resolved": false, "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"delegates": { "delegates": {
"version": "1.0.0", "version": "1.0.0",
"resolved": false, "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"detect-libc": { "detect-libc": {
"version": "1.0.3", "version": "1.0.3",
"resolved": false, "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"fs-minipass": { "fs-minipass": {
"version": "1.2.5", "version": "1.2.5",
"resolved": false, "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz",
"integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21515,14 +21611,14 @@
}, },
"fs.realpath": { "fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": false, "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"gauge": { "gauge": {
"version": "2.7.4", "version": "2.7.4",
"resolved": false, "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
"integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21539,7 +21635,7 @@
}, },
"glob": { "glob": {
"version": "7.1.3", "version": "7.1.3",
"resolved": false, "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz",
"integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21554,14 +21650,14 @@
}, },
"has-unicode": { "has-unicode": {
"version": "2.0.1", "version": "2.0.1",
"resolved": false, "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"iconv-lite": { "iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": false, "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21571,7 +21667,7 @@
}, },
"ignore-walk": { "ignore-walk": {
"version": "3.0.1", "version": "3.0.1",
"resolved": false, "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz",
"integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21581,7 +21677,7 @@
}, },
"inflight": { "inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": false, "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21592,21 +21688,21 @@
}, },
"inherits": { "inherits": {
"version": "2.0.3", "version": "2.0.3",
"resolved": false, "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"ini": { "ini": {
"version": "1.3.5", "version": "1.3.5",
"resolved": false, "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"is-fullwidth-code-point": { "is-fullwidth-code-point": {
"version": "1.0.0", "version": "1.0.0",
"resolved": false, "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21616,14 +21712,14 @@
}, },
"isarray": { "isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": false, "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"minimatch": { "minimatch": {
"version": "3.0.4", "version": "3.0.4",
"resolved": false, "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21633,14 +21729,14 @@
}, },
"minimist": { "minimist": {
"version": "0.0.8", "version": "0.0.8",
"resolved": false, "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"minipass": { "minipass": {
"version": "2.3.5", "version": "2.3.5",
"resolved": false, "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz",
"integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21651,7 +21747,7 @@
}, },
"minizlib": { "minizlib": {
"version": "1.2.1", "version": "1.2.1",
"resolved": false, "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz",
"integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21661,7 +21757,7 @@
}, },
"mkdirp": { "mkdirp": {
"version": "0.5.1", "version": "0.5.1",
"resolved": false, "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21671,7 +21767,7 @@
}, },
"ms": { "ms": {
"version": "2.1.1", "version": "2.1.1",
"resolved": false, "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
"integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==",
"dev": true, "dev": true,
"optional": true "optional": true
@@ -21685,7 +21781,7 @@
}, },
"needle": { "needle": {
"version": "2.3.0", "version": "2.3.0",
"resolved": false, "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.0.tgz",
"integrity": "sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==", "integrity": "sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21697,7 +21793,7 @@
}, },
"node-pre-gyp": { "node-pre-gyp": {
"version": "0.12.0", "version": "0.12.0",
"resolved": false, "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz",
"integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21716,7 +21812,7 @@
}, },
"nopt": { "nopt": {
"version": "4.0.1", "version": "4.0.1",
"resolved": false, "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz",
"integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21727,14 +21823,14 @@
}, },
"npm-bundled": { "npm-bundled": {
"version": "1.0.6", "version": "1.0.6",
"resolved": false, "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz",
"integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==", "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"npm-packlist": { "npm-packlist": {
"version": "1.4.1", "version": "1.4.1",
"resolved": false, "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.1.tgz",
"integrity": "sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==", "integrity": "sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21745,7 +21841,7 @@
}, },
"npmlog": { "npmlog": {
"version": "4.1.2", "version": "4.1.2",
"resolved": false, "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
"integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21758,21 +21854,21 @@
}, },
"number-is-nan": { "number-is-nan": {
"version": "1.0.1", "version": "1.0.1",
"resolved": false, "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": false, "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"once": { "once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": false, "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21782,21 +21878,21 @@
}, },
"os-homedir": { "os-homedir": {
"version": "1.0.2", "version": "1.0.2",
"resolved": false, "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
"integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"os-tmpdir": { "os-tmpdir": {
"version": "1.0.2", "version": "1.0.2",
"resolved": false, "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"osenv": { "osenv": {
"version": "0.1.5", "version": "0.1.5",
"resolved": false, "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
"integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21807,21 +21903,21 @@
}, },
"path-is-absolute": { "path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"resolved": false, "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"process-nextick-args": { "process-nextick-args": {
"version": "2.0.0", "version": "2.0.0",
"resolved": false, "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
"integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"rc": { "rc": {
"version": "1.2.8", "version": "1.2.8",
"resolved": false, "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21834,7 +21930,7 @@
"dependencies": { "dependencies": {
"minimist": { "minimist": {
"version": "1.2.0", "version": "1.2.0",
"resolved": false, "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
"dev": true, "dev": true,
"optional": true "optional": true
@@ -21843,7 +21939,7 @@
}, },
"readable-stream": { "readable-stream": {
"version": "2.3.6", "version": "2.3.6",
"resolved": false, "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21859,7 +21955,7 @@
}, },
"rimraf": { "rimraf": {
"version": "2.6.3", "version": "2.6.3",
"resolved": false, "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21869,49 +21965,49 @@
}, },
"safe-buffer": { "safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"resolved": false, "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"safer-buffer": { "safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": false, "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"sax": { "sax": {
"version": "1.2.4", "version": "1.2.4",
"resolved": false, "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"semver": { "semver": {
"version": "5.7.0", "version": "5.7.0",
"resolved": false, "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz",
"integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"set-blocking": { "set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": false, "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.2", "version": "3.0.2",
"resolved": false, "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"string-width": { "string-width": {
"version": "1.0.2", "version": "1.0.2",
"resolved": false, "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21923,7 +22019,7 @@
}, },
"string_decoder": { "string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": false, "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21933,7 +22029,7 @@
}, },
"strip-ansi": { "strip-ansi": {
"version": "3.0.1", "version": "3.0.1",
"resolved": false, "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21943,14 +22039,14 @@
}, },
"strip-json-comments": { "strip-json-comments": {
"version": "2.0.1", "version": "2.0.1",
"resolved": false, "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"tar": { "tar": {
"version": "4.4.8", "version": "4.4.8",
"resolved": false, "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.8.tgz",
"integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==", "integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21966,14 +22062,14 @@
}, },
"util-deprecate": { "util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": false, "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"wide-align": { "wide-align": {
"version": "1.1.3", "version": "1.1.3",
"resolved": false, "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
"integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
"dev": true, "dev": true,
"optional": true, "optional": true,
@@ -21983,14 +22079,14 @@
}, },
"wrappy": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": false, "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"yallist": { "yallist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": false, "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz",
"integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==",
"dev": true, "dev": true,
"optional": true "optional": true
@@ -33108,8 +33204,7 @@
"js-tokens": { "js-tokens": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
"dev": true
}, },
"js-yaml": { "js-yaml": {
"version": "3.13.1", "version": "3.13.1",
@@ -34018,7 +34113,7 @@
}, },
"chalk": { "chalk": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
"dev": true, "dev": true,
"requires": { "requires": {
@@ -34314,8 +34409,7 @@
"lodash.debounce": { "lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
"dev": true
}, },
"lodash.deburr": { "lodash.deburr": {
"version": "4.1.0", "version": "4.1.0",
@@ -34494,9 +34588,7 @@
"lodash.throttle": { "lodash.throttle": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=", "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
"dev": true,
"optional": true
}, },
"lodash.toarray": { "lodash.toarray": {
"version": "4.4.0", "version": "4.4.0",
@@ -34635,7 +34727,6 @@
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
"integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
"dev": true,
"requires": { "requires": {
"js-tokens": "^3.0.0" "js-tokens": "^3.0.0"
} }
@@ -34885,6 +34976,11 @@
"integrity": "sha1-mnHEh0chjrylHlGmbaaCA4zct78=", "integrity": "sha1-mnHEh0chjrylHlGmbaaCA4zct78=",
"dev": true "dev": true
}, },
"math-expression-evaluator": {
"version": "1.2.22",
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz",
"integrity": "sha512-L0j0tFVZBQQLeEjmWOvDLoRciIY8gQGWahvkztXUal8jH8R5Rlqo9GCvgqvXcy9LQhEWdQCVvzqAbxgYNt4blQ=="
},
"mathjs": { "mathjs": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-2.7.0.tgz",
@@ -42971,7 +43067,6 @@
"version": "15.6.2", "version": "15.6.2",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz",
"integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==",
"dev": true,
"requires": { "requires": {
"loose-envify": "^1.3.1", "loose-envify": "^1.3.1",
"object-assign": "^4.1.1" "object-assign": "^4.1.1"
@@ -43178,7 +43273,6 @@
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"dev": true,
"requires": { "requires": {
"performance-now": "^2.1.0" "performance-now": "^2.1.0"
} }
@@ -44000,8 +44094,7 @@
"react-fast-compare": { "react-fast-compare": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
"dev": true
}, },
"react-feather": { "react-feather": {
"version": "2.0.3", "version": "2.0.3",
@@ -44063,7 +44156,6 @@
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.1.tgz", "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.1.tgz",
"integrity": "sha512-CnwD822LU8NDBnjCpZ4ySh8L6HYyngViTZLfBBb3NjtrpN8m49clH8hidHouq20I51Y6TpCTISCBbqiY5GamwA==", "integrity": "sha512-CnwD822LU8NDBnjCpZ4ySh8L6HYyngViTZLfBBb3NjtrpN8m49clH8hidHouq20I51Y6TpCTISCBbqiY5GamwA==",
"dev": true,
"requires": { "requires": {
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"prop-types": "^15.5.4", "prop-types": "^15.5.4",
@@ -44165,8 +44257,7 @@
"react-lifecycles-compat": { "react-lifecycles-compat": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
"dev": true
}, },
"react-live": { "react-live": {
"version": "2.2.2", "version": "2.2.2",
@@ -44395,7 +44486,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.2.0.tgz", "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.2.0.tgz",
"integrity": "sha512-v1ht1aHg5k/thv56DRcjw+WtojuuDHFUgGfc+bFHOWsF4ZK6C2V57DO0Or0GPsg6+LSTE0M6Ry/gfzhzSwbc5w==", "integrity": "sha512-v1ht1aHg5k/thv56DRcjw+WtojuuDHFUgGfc+bFHOWsF4ZK6C2V57DO0Or0GPsg6+LSTE0M6Ry/gfzhzSwbc5w==",
"dev": true,
"requires": { "requires": {
"shallowequal": "^1.0.1" "shallowequal": "^1.0.1"
} }
@@ -44406,6 +44496,38 @@
"integrity": "sha512-bL5W5mAxSW6+cLwqqVWY47Silqgy2DKDTR4hDBrLrUqC5BXc29YVx17l2IZk5v36VcDEq1Bszu2oHm1qBwKqBA==", "integrity": "sha512-bL5W5mAxSW6+cLwqqVWY47Silqgy2DKDTR4hDBrLrUqC5BXc29YVx17l2IZk5v36VcDEq1Bszu2oHm1qBwKqBA==",
"dev": true "dev": true
}, },
"react-smooth": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-1.0.5.tgz",
"integrity": "sha512-eW057HT0lFgCKh8ilr0y2JaH2YbNcuEdFpxyg7Gf/qDKk9hqGMyXryZJ8iMGJEuKH0+wxS0ccSsBBB3W8yCn8w==",
"requires": {
"lodash": "~4.17.4",
"prop-types": "^15.6.0",
"raf": "^3.4.0",
"react-transition-group": "^2.5.0"
},
"dependencies": {
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"react-transition-group": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
"integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
"requires": {
"dom-helpers": "^3.4.0",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2",
"react-lifecycles-compat": "^3.0.4"
}
}
}
},
"react-static-container": { "react-static-container": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/react-static-container/-/react-static-container-1.0.2.tgz", "resolved": "https://registry.npmjs.org/react-static-container/-/react-static-container-1.0.2.tgz",
@@ -44585,6 +44707,50 @@
} }
} }
}, },
"recharts": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-1.8.5.tgz",
"integrity": "sha512-tM9mprJbXVEBxjM7zHsIy6Cc41oO/pVYqyAsOHLxlJrbNBuLs0PHB3iys2M+RqCF0//k8nJtZF6X6swSkWY3tg==",
"requires": {
"classnames": "^2.2.5",
"core-js": "^2.6.10",
"d3-interpolate": "^1.3.0",
"d3-scale": "^2.1.0",
"d3-shape": "^1.2.0",
"lodash": "^4.17.5",
"prop-types": "^15.6.0",
"react-resize-detector": "^2.3.0",
"react-smooth": "^1.0.5",
"recharts-scale": "^0.4.2",
"reduce-css-calc": "^1.3.0"
},
"dependencies": {
"core-js": {
"version": "2.6.11",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz",
"integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg=="
},
"react-resize-detector": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-2.3.0.tgz",
"integrity": "sha512-oCAddEWWeFWYH5FAcHdBYcZjAw9fMzRUK9sWSx6WvSSOPVRxcHd5zTIGy/mOus+AhN/u6T4TMiWxvq79PywnJQ==",
"requires": {
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"prop-types": "^15.6.0",
"resize-observer-polyfill": "^1.5.0"
}
}
}
},
"recharts-scale": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.3.tgz",
"integrity": "sha512-t8p5sccG9Blm7c1JQK/ak9O8o95WGhNXD7TXg/BW5bYbVlr6eCeRBNpgyigD4p6pSSMehC5nSvBUPj6F68rbFA==",
"requires": {
"decimal.js-light": "^2.4.1"
}
},
"rechoir": { "rechoir": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
@@ -44653,6 +44819,31 @@
"redis-errors": "^1.0.0" "redis-errors": "^1.0.0"
} }
}, },
"reduce-css-calc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz",
"integrity": "sha1-dHyRTgSWFKTJz7umKYca0dKSdxY=",
"requires": {
"balanced-match": "^0.4.2",
"math-expression-evaluator": "^1.2.14",
"reduce-function-call": "^1.0.1"
},
"dependencies": {
"balanced-match": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
"integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg="
}
}
},
"reduce-function-call": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz",
"integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==",
"requires": {
"balanced-match": "^1.0.0"
}
},
"redux": { "redux": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz",
@@ -45914,8 +46105,7 @@
"resize-observer-polyfill": { "resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
"dev": true
}, },
"resolve": { "resolve": {
"version": "1.8.1", "version": "1.8.1",
@@ -46446,8 +46636,7 @@
"shallowequal": { "shallowequal": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
"dev": true
}, },
"shebang-command": { "shebang-command": {
"version": "1.2.0", "version": "1.2.0",
+3 -1
View File
@@ -65,6 +65,7 @@
"@hapi/joi": "^17.1.1", "@hapi/joi": "^17.1.1",
"@metascraper/helpers": "^5.11.6", "@metascraper/helpers": "^5.11.6",
"@rudderstack/rudder-sdk-node": "0.0.2", "@rudderstack/rudder-sdk-node": "0.0.2",
"@types/recharts": "^1.8.9",
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"akismet-api": "^5.0.0", "akismet-api": "^5.0.0",
"apollo-server-express": "^2.11.0", "apollo-server-express": "^2.11.0",
@@ -135,6 +136,8 @@
"querystringify": "^2.1.1", "querystringify": "^2.1.1",
"source-map-support": "^0.5.16", "source-map-support": "^0.5.16",
"stack-utils": "^2.0.1", "stack-utils": "^2.0.1",
"react-helmet": "^5.2.1",
"recharts": "^1.8.5",
"striptags": "^3.1.1", "striptags": "^3.1.1",
"tsscmp": "^1.0.6", "tsscmp": "^1.0.6",
"url-regex": "^5.0.0", "url-regex": "^5.0.0",
@@ -327,7 +330,6 @@
"react-error-overlay": "^6.0.7", "react-error-overlay": "^6.0.7",
"react-final-form": "6.3.0", "react-final-form": "6.3.0",
"react-final-form-arrays": "^3.1.1", "react-final-form-arrays": "^3.1.1",
"react-helmet": "^5.2.1",
"react-popper": "^1.3.7", "react-popper": "^1.3.7",
"react-relay": "^9.0.0", "react-relay": "^9.0.0",
"react-relay-network-modern": "^4.6.1", "react-relay-network-modern": "^4.6.1",
@@ -5,6 +5,6 @@ import Navigation from "./Navigation";
it("renders correctly", () => { it("renders correctly", () => {
const renderer = createRenderer(); const renderer = createRenderer();
renderer.render(<Navigation showConfigure />); renderer.render(<Navigation showConfigure showDashboard={true} />);
expect(renderer.getRenderOutput()).toMatchSnapshot(); expect(renderer.getRenderOutput()).toMatchSnapshot();
}); });
@@ -7,6 +7,7 @@ import NavigationLink from "./NavigationLink";
interface Props { interface Props {
showConfigure: boolean; showConfigure: boolean;
showDashboard: boolean;
} }
const Navigation: FunctionComponent<Props> = (props) => ( const Navigation: FunctionComponent<Props> = (props) => (
@@ -25,6 +26,11 @@ const Navigation: FunctionComponent<Props> = (props) => (
<NavigationLink to="/admin/configure">Configure</NavigationLink> <NavigationLink to="/admin/configure">Configure</NavigationLink>
</Localized> </Localized>
)} )}
{props.showDashboard && (
<Localized id="navigation-dashboard">
<NavigationLink to="/admin/dashboard">Dashboard</NavigationLink>
</Localized>
)}
</AppBarNavigation> </AppBarNavigation>
); );
@@ -21,6 +21,9 @@ class NavigationContainer extends React.Component<Props> {
public render() { public render() {
return ( return (
<Navigation <Navigation
showDashboard={
!!this.props.viewer && can(this.props.viewer, Ability.VIEW_STATISTICS)
}
showConfigure={ showConfigure={
!!this.props.viewer && !!this.props.viewer &&
can(this.props.viewer, Ability.CHANGE_CONFIGURATION) can(this.props.viewer, Ability.CHANGE_CONFIGURATION)
@@ -38,5 +38,14 @@ exports[`renders correctly 1`] = `
Configure Configure
</NavigationLink> </NavigationLink>
</Localized> </Localized>
<Localized
id="navigation-dashboard"
>
<NavigationLink
to="/admin/dashboard"
>
Dashboard
</NavigationLink>
</Localized>
</withPropsOnChange(Navigation)> </withPropsOnChange(Navigation)>
`; `;
+1
View File
@@ -23,6 +23,7 @@ const permissionMap = {
CHANGE_STORY_STATUS: [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR], CHANGE_STORY_STATUS: [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR],
// Mutation.inviteUsers // Mutation.inviteUsers
INVITE_USERS: [GQLUSER_ROLE.ADMIN], INVITE_USERS: [GQLUSER_ROLE.ADMIN],
VIEW_STATISTICS: [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR],
}; };
export type AbilityType = keyof typeof permissionMap; export type AbilityType = keyof typeof permissionMap;
+4
View File
@@ -29,6 +29,8 @@ import { Sites } from "./routes/Configure/sections/Sites";
import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute"; import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute";
import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute"; import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute";
import WebhookEndpointsLayout from "./routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout"; import WebhookEndpointsLayout from "./routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout";
import DashboardRoute from "./routes/Dashboard";
import SiteDashboardRoute from "./routes/Dashboard/SiteDashboardRoute";
import ForgotPasswordRoute from "./routes/ForgotPassword"; import ForgotPasswordRoute from "./routes/ForgotPassword";
import InviteRoute from "./routes/Invite"; import InviteRoute from "./routes/Invite";
import LoginRoute from "./routes/Login"; import LoginRoute from "./routes/Login";
@@ -111,6 +113,8 @@ export default makeRouteConfig(
/> />
</Route> </Route>
<Route path="stories" {...StoriesRoute.routeConfig} /> <Route path="stories" {...StoriesRoute.routeConfig} />
<Route path="dashboard" {...DashboardRoute.routeConfig} />
<Route path="dashboard/:siteID" {...SiteDashboardRoute.routeConfig} />
<Route path="community" {...CommunityRoute.routeConfig} /> <Route path="community" {...CommunityRoute.routeConfig} />
<Route <Route
{...createAuthCheckRoute({ {...createAuthCheckRoute({
@@ -0,0 +1,3 @@
.columns {
width: 50%;
}
@@ -0,0 +1,32 @@
import React, { FunctionComponent } from "react";
import { Flex, HorizontalGutter } from "coral-ui/components/v2";
import { CommentActivity, SignupActivity, Today, TopStories } from "./sections";
import styles from "./Dashboard.css";
interface Props {
siteID: string;
lastUpdated: string;
}
const Dashboard: FunctionComponent<Props> = (props) => (
<HorizontalGutter spacing={4}>
<Today siteID={props.siteID} lastUpdated={props.lastUpdated} />
<Flex spacing={5}>
<HorizontalGutter className={styles.columns} spacing={4}>
<CommentActivity
siteID={props.siteID}
lastUpdated={props.lastUpdated}
/>
<SignupActivity siteID={props.siteID} lastUpdated={props.lastUpdated} />
</HorizontalGutter>
<div className={styles.columns}>
<TopStories siteID={props.siteID} lastUpdated={props.lastUpdated} />
</div>
</Flex>
</HorizontalGutter>
);
export default Dashboard;
@@ -0,0 +1,17 @@
.root {
margin-top: var(--v2-spacing-4);
}
.icon {
color: var(--v2-colors-mono-100);
margin-top: var(--v2-spacing-1);
margin-left: var(--v2-spacing-1);
}
.header {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-bold);
font-size: var(--v2-font-size-6);
color: var(--v2-colors-teal-600);
margin: 0;
}
@@ -0,0 +1,155 @@
import React, { useCallback, useState } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import MainLayout from "coral-admin/components/MainLayout";
import { IntersectionProvider } from "coral-framework/lib/intersection";
import {
useLoadMore,
useRefetch,
withPaginationContainer,
} from "coral-framework/lib/relay";
import {
BaseButton,
Button,
ButtonIcon,
ClickOutside,
Dropdown,
Flex,
Popover,
} from "coral-ui/components/v2";
import { DashboardContainer_query as QueryData } from "coral-admin/__generated__/DashboardContainer_query.graphql";
import { DashboardContainerPaginationQueryVariables } from "coral-admin/__generated__/DashboardContainerPaginationQuery.graphql";
import SiteDashboardTimestamp from "./components/SiteDashboardTimestamp";
import Dashboard from "./Dashboard";
import DashboardSiteSelector from "./DashboardSiteSelector";
import styles from "./DashboardContainer.css";
interface Props {
query: QueryData | null;
relay: RelayPaginationProp;
selectedSiteID?: string;
}
const DashboardContainer: React.FunctionComponent<Props> = (props) => {
const sites = props.query
? props.query.sites.edges.map((edge) => edge.node)
: [];
const selectedSite = props.selectedSiteID
? sites.find((s) => s.id === props.selectedSiteID)
: sites[0];
if (!selectedSite) {
return null;
}
const [lastUpdated, setLastUpdated] = useState<string>(new Date().toString());
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const [, isRefetching] = useRefetch<
DashboardContainerPaginationQueryVariables
>(props.relay);
const onRefetch = useCallback(() => {
setLastUpdated(new Date().toString());
}, []);
return (
<MainLayout className={styles.root}>
<Popover
id="dashboardSiteSwitcher"
placement="bottom-end"
description="A dialog of the user menu with related links and actions"
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Dropdown>
<IntersectionProvider>
<DashboardSiteSelector
loading={!props.query || isRefetching}
sites={sites}
onLoadMore={loadMore}
hasMore={!isRefetching && props.relay.hasMore()}
disableLoadMore={isLoadingMore}
/>
</IntersectionProvider>
</Dropdown>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<BaseButton onClick={toggleVisibility} ref={ref}>
<Flex>
<h2 className={styles.header}>{selectedSite.name}</h2>
{
<ButtonIcon className={styles.icon} size="lg">
{visible ? "arrow_drop_up" : "arrow_drop_down"}
</ButtonIcon>
}
</Flex>
</BaseButton>
)}
</Popover>
<Flex alignItems="center" spacing={2}>
<SiteDashboardTimestamp />
<Button variant="text" onClick={onRefetch} iconLeft>
<ButtonIcon>refresh</ButtonIcon>
Refresh
</Button>
</Flex>
<Dashboard siteID={selectedSite.id} lastUpdated={lastUpdated} />
</MainLayout>
);
};
type FragmentVariables = DashboardContainerPaginationQueryVariables;
const enhanced = withPaginationContainer<
Props,
DashboardContainerPaginationQueryVariables,
FragmentVariables
>(
{
query: graphql`
fragment DashboardContainer_query on Query
@argumentDefinitions(
count: { type: "Int!", defaultValue: 20 }
cursor: { type: "Cursor" }
) {
sites(first: $count, after: $cursor)
@connection(key: "SitesConfig_sites") {
edges {
node {
id
name
...DashboardSiteContainer_site
}
}
}
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.query && props.query.sites;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
return {
...prevVars,
count: totalCount,
};
},
getVariables(props, { count, cursor }, fragmentVariables) {
return {
count,
cursor,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query DashboardContainerPaginationQuery($count: Int!, $cursor: Cursor) {
...DashboardContainer_query @arguments(count: $count, cursor: $cursor)
}
`,
}
)(DashboardContainer);
export default enhanced;
@@ -0,0 +1,29 @@
import React from "react";
import { graphql } from "react-relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { DashboardRouteQueryResponse } from "coral-admin/__generated__/DashboardRouteQuery.graphql";
import DashboardSiteSelectorContainer from "./DashboardContainer";
interface Props {
data: DashboardRouteQueryResponse | null;
}
const DashboardRoute: React.FunctionComponent<Props> = ({ data }) => {
if (!data) {
return null;
}
return <DashboardSiteSelectorContainer query={data} />;
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query DashboardRouteQuery {
...DashboardContainer_query
}
`,
})(DashboardRoute);
export default enhanced;
@@ -0,0 +1,3 @@
.button {
color: var(--v2-palette-primary-main);
}
@@ -0,0 +1,36 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { DropdownButton } from "coral-ui/components/v2";
import { DashboardSiteContainer_site } from "coral-admin/__generated__/DashboardSiteContainer_site.graphql";
import styles from "./DashboardSiteContainer.css";
interface Props {
site: DashboardSiteContainer_site;
}
const DashboardSiteContainer: FunctionComponent<Props> = ({ site }) => {
return (
<DropdownButton
className={styles.button}
to={`/admin/dashboard/${site.id}`}
>
{site.name}
</DropdownButton>
);
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment DashboardSiteContainer_site on Site {
id
name
createdAt
}
`,
})(DashboardSiteContainer);
export default enhanced;
@@ -0,0 +1,42 @@
import React, { FunctionComponent } from "react";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import { PropTypesOf } from "coral-framework/types";
import { Flex, Spinner } from "coral-ui/components/v2";
import DashboardSiteContainer from "./DashboardSiteContainer";
interface Props {
sites: Array<
{ id: string } & PropTypesOf<typeof DashboardSiteContainer>["site"]
>;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
}
const SitesTable: FunctionComponent<Props> = (props) => {
return (
<>
{props.sites.map((site) => (
<DashboardSiteContainer site={site} key={site.id} />
))}
{props.loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{props.hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={props.disableLoadMore}
onLoadMore={props.onLoadMore}
/>
</Flex>
)}
</>
);
};
export default SitesTable;
@@ -0,0 +1,45 @@
import { Match, Router } from "found";
import React from "react";
import { graphql } from "react-relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { SiteDashboardRouteQueryResponse } from "coral-admin/__generated__/SiteDashboardRouteQuery.graphql";
import DashboardSiteSelectorContainer from "./DashboardContainer";
interface RouteParams {
siteID: string;
}
interface Props {
data: SiteDashboardRouteQueryResponse | null;
router: Router;
match: Match & { params: RouteParams };
}
const SiteDashboardRoute: React.FunctionComponent<Props> = (props) => {
const { data } = props;
if (data && data.site) {
return (
<DashboardSiteSelectorContainer
query={data}
selectedSiteID={props.match.params.siteID}
/>
);
}
return null;
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query SiteDashboardRouteQuery($siteID: ID!) {
...DashboardContainer_query
site(id: $siteID) {
name
id
}
}
`,
})(SiteDashboardRoute);
export default enhanced;
@@ -0,0 +1,7 @@
.root {
border: 1px solid var(--v2-colors-grey-300);
border-radius: 4px;
padding: var(--v2-spacing-3);
font-family: var(--v2-font-family-primary);
flex: 1;
}
@@ -0,0 +1,14 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import styles from "./DashboardBox.css";
interface Props {
className?: string;
}
const DashboardBox: FunctionComponent<Props> = ({ children, className }) => {
return <div className={cn(styles.root, className)}>{children}</div>;
};
export default DashboardBox;
@@ -0,0 +1,6 @@
.root {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
font-size: var(--v2-font-size-3);
margin-top: 0;
}
@@ -0,0 +1,17 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import styles from "./DashboardComponentHeading.css";
interface Props {
className?: string;
}
const DashboardComponentHeading: FunctionComponent<Props> = ({
children,
className,
}) => {
return <h3 className={cn(styles.root, className)}>{children}</h3>;
};
export default DashboardComponentHeading;
@@ -0,0 +1,3 @@
.root {
height: 100%;
}
@@ -0,0 +1,28 @@
import React, { FunctionComponent } from "react";
import { Flex, Spinner } from "coral-ui/components/v2";
import styles from "./Loader.css";
interface Props {
loading: boolean;
height?: number;
}
const Loader: FunctionComponent<Props> = ({ loading, height }) => {
if (!loading) {
return null;
}
return (
<Flex
style={height ? { height } : {}}
justifyContent="center"
alignItems="center"
className={styles.root}
>
<Spinner />
</Flex>
);
};
export default Loader;
@@ -0,0 +1,11 @@
.timestamp {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
font-size: var(--v2-font-size-1);
color: var(--v2-colors-mono-500);
margin-top: var(--v2-spacing-1);
}
.root {
text-align: left;
}
@@ -0,0 +1,26 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useEffect, useState } from "react";
import { AbsoluteTime } from "coral-ui/components";
import styles from "./SiteDashboardTimestamp.css";
const SiteDashboardHeader: FunctionComponent = () => {
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
useEffect(() => {
setUpdatedAt(new Date());
}, []);
if (!updatedAt) {
return null;
}
return (
<p className={styles.timestamp}>
<Localized id="dashboard-heading-last-updated">
<span>Last updated: </span>
</Localized>{" "}
<AbsoluteTime date={updatedAt.toISOString()} />
</p>
);
};
export default SiteDashboardHeader;
@@ -0,0 +1,8 @@
.valueBoxCompareValue {
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-semi-bold);
}
.valueBoxCompareName {
font-size: var(--v2-font-size-1);
}
@@ -0,0 +1,23 @@
import React, { FunctionComponent } from "react";
import { HorizontalGutter } from "coral-ui/components/v2";
import styles from "./TodayCompareValue.css";
interface Props {
value?: string;
}
const TodayCompareValue: FunctionComponent<Props> = ({
value = "-",
children,
}) => {
return (
<HorizontalGutter spacing={1}>
<p className={styles.valueBoxCompareValue}>{value}</p>
<p className={styles.valueBoxCompareName}>{children}</p>
</HorizontalGutter>
);
};
export default TodayCompareValue;
@@ -0,0 +1,41 @@
.root {
flex: 1;
min-height: 120px;
}
.icon {
display: flex;
justify-content: center;
align-items: center;
min-width: 28px;
height: 28px;
border-radius: 50%;
}
.tealIcon {
background-color: var(--v2-colors-teal-100);
color: var(--v2-colors-teal-600);
}
.redIcon {
background-color: var(--v2-colors-red-100);
color: var(--v2-colors-red-500);
}
.greyIcon {
background-color: var(--v2-colors-grey-200);
color: var(--v2-colors-grey-500);
}
.blueIcon {
background-color: var(--v2-colors-blue-100);
color: var(--v2-colors-blue-500);
}
.outer {
height: 100%;
}
.inner {
height: 100%;
}
@@ -0,0 +1,52 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { Flex, HorizontalGutter, Icon } from "coral-ui/components/v2";
import DashboardBox from "./DashboardBox";
import Loader from "./Loader";
import styles from "./TodayDashboardBox.css";
interface Props {
icon: "forum" | "close" | "badge" | "person_add" | "block";
loading: boolean;
}
const TodayDashboardBox: FunctionComponent<Props> = ({
children,
icon,
loading,
}) => {
return (
<DashboardBox className={styles.root}>
{loading ? (
<Loader loading={loading} />
) : (
<Flex spacing={5} className={styles.outer}>
<div
className={cn(styles.icon, {
[styles.tealIcon]: icon === "forum",
[styles.redIcon]: icon === "close" || icon === "block",
[styles.greyIcon]: icon === "badge",
[styles.blueIcon]: icon === "person_add",
})}
>
<Icon size="md">{icon}</Icon>
</div>
<HorizontalGutter>
<Flex
direction="column"
className={styles.inner}
justifyContent="space-between"
>
{children}
</Flex>
</HorizontalGutter>
</Flex>
)}
</DashboardBox>
);
};
export default TodayDashboardBox;
@@ -0,0 +1,10 @@
.valueBoxValue {
font-size: var(--v2-font-size-7);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-reset);
}
.valueBoxName {
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-semi-bold);
}
@@ -0,0 +1,20 @@
import React, { FunctionComponent } from "react";
import { HorizontalGutter } from "coral-ui/components/v2";
import styles from "./TodayValue.css";
interface Props {
value?: string;
}
const TodayValue: FunctionComponent<Props> = ({ value = "-", children }) => {
return (
<HorizontalGutter spacing={1}>
<p className={styles.valueBoxValue}>{value}</p>
<p className={styles.valueBoxName}>{children}</p>
</HorizontalGutter>
);
};
export default TodayValue;
@@ -0,0 +1,7 @@
export { default as DashboardBox } from "./DashboardBox";
export { default as TodayCompareValue } from "./TodayCompareValue";
export { default as TodayValue } from "./TodayValue";
export { default as TodayDashboardBox } from "./TodayDashboardBox";
export { default as SiteDashboardHeader } from "./SiteDashboardTimestamp";
export { default as DashboardComponentHeading } from "./DashboardComponentHeading";
export { default as Loader } from "./Loader";
@@ -0,0 +1,26 @@
import { Environment } from "relay-runtime";
import { createFetch } from "coral-framework/lib/relay";
export default function createDashboardFetch<T>(name: string, url: string) {
return createFetch(
name,
async (
environment: Environment,
variables: { siteID: string },
{ rest }
) => {
const params = new URLSearchParams(variables);
const { timeZone } = Intl.DateTimeFormat().resolvedOptions();
params.set("tz", timeZone);
// // FIXME: remove, date forced for development
// params.set("date", "2020-05-05T12:30:00.000Z");
return rest.fetch<T>(`${url}?${params.toString()}`, {
method: "GET",
});
}
);
}
@@ -0,0 +1 @@
export { default, default as DashboardRoute } from "./DashboardRoute";
@@ -0,0 +1,8 @@
export const CHART_COLOR_PRIMARY = "#419EA7";
export const CHART_COLOR_PRIMARY_LIGHT = "#59C3C3";
export const CHART_COLOR_SECONDARY = "#F77160";
export const CHART_COLOR_MONO_500 = "#353F44";
export const CHART_COLOR_MONO_100 = "#65696B";
export const CHART_COLOR_GREY_200 = "#EAEFF0";
export const CHART_COLOR_PRIMARY_PALE = "#9FECDF";
export const CHART_COLOR_PRIMARY_DARK = "#2C7B8C";
@@ -0,0 +1,31 @@
.heading {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
font-size: var(--v2-font-size-2);
}
.chart {
font-family: var(--v2-font-family-primary);
}
.loader {
height: 300px;
}
.legend {
color: var(--v2-colors-coral-500);
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-semi-bold);
margin-bottom: 0;
position: relative;
padding-left: var(--v2-spacing-6);
&:before {
width: 24px;
height: 1px;
background: var(--v2-colors-coral-500);
content: "";
position: absolute;
left: 0;
top: 50%;
}
}
@@ -0,0 +1,129 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis,
} from "recharts";
import { Flex } from "coral-ui/components/v2";
import { TimeSeriesMetricsJSON } from "coral-common/rest/dashboard/types";
import { useImmediateFetch } from "coral-framework/lib/relay/fetch";
import { useUIContext } from "coral-ui/components";
import { DashboardBox, DashboardComponentHeading, Loader } from "../components";
import createDashboardFetch from "../createDashboardFetch";
import {
CHART_COLOR_GREY_200,
CHART_COLOR_MONO_500,
CHART_COLOR_PRIMARY,
CHART_COLOR_SECONDARY,
} from "./ChartColors";
import CommentActivityTooltip from "./CommentActivityTooltip";
import styles from "./CommentActivity.css";
interface Props {
locales?: string[];
siteID: string;
lastUpdated: string;
}
const HourlyCommentsMetricsFetch = createDashboardFetch<TimeSeriesMetricsJSON>(
"hourlyCommentsMetricsFetch",
"/dashboard/hourly/comments"
);
const CommentActivity: FunctionComponent<Props> = ({
locales: localesFromProps,
siteID,
lastUpdated,
}) => {
const [hourly, loading] = useImmediateFetch(
HourlyCommentsMetricsFetch,
{ siteID },
lastUpdated
);
const { locales: localesFromContext } = useUIContext();
const locales = localesFromProps || localesFromContext || ["en-US"];
return (
<DashboardBox>
<Localized id="dashboard-comment-activity-heading">
<DashboardComponentHeading>
Hourly comment activity
</DashboardComponentHeading>
</Localized>
<Loader loading={loading} height={300} />
{!loading && (
<>
<ResponsiveContainer height={300}>
<LineChart
className={styles.chart}
data={hourly ? hourly.series : []}
>
{hourly && (
<ReferenceLine
stroke={CHART_COLOR_SECONDARY}
y={hourly.average}
/>
)}
<XAxis
dataKey="timestamp"
stroke={CHART_COLOR_MONO_500}
axisLine={{ strokeWidth: 0 }}
tick={{ fontSize: 12, fontWeight: 600 }}
tickLine={false}
dy={6}
tickFormatter={(unixTime: number) => {
const formatter = new Intl.DateTimeFormat(locales, {
hour: "numeric",
hour12: true,
});
return formatter
.format(new Date(unixTime))
.toLowerCase()
.replace(" ", "");
}}
/>
<YAxis
allowDecimals={false}
tickLine={false}
width={36}
stroke={CHART_COLOR_MONO_500}
axisLine={{ strokeWidth: 0 }}
tick={{ fontSize: 12, fontWeight: 600 }}
/>
<CartesianGrid vertical={false} stroke={CHART_COLOR_GREY_200} />
<Line
strokeWidth={2}
dot={{ strokeWidth: 1 }}
type="monotoneX"
dataKey="count"
stroke={CHART_COLOR_PRIMARY}
/>
<Tooltip
content={(tooltipProps: TooltipProps) => (
<CommentActivityTooltip {...tooltipProps} locales={locales} />
)}
/>
</LineChart>
</ResponsiveContainer>
<Flex alignItems="center" justifyContent="center">
<Localized id="dashboard-comment-activity-legend">
<p className={styles.legend}>All-time average</p>
</Localized>
</Flex>
</>
)}
</DashboardBox>
);
};
export default CommentActivity;
@@ -0,0 +1,27 @@
.root {
background: var(--v2-colors-pure-white);
border-radius: 4px;
border: 1px solid var(--v2-colors-grey-300);
padding: var(--v2-spacing-3);
}
.time {
color: var(--v2-colors-mono-100);
font-size: var(--v2-font-size-2);
margin: 0;
font-weight: var(--v2-font-weight-primary-semi-bold);
}
.count {
color: var(--v2-colors-teal-600);
font-size: var(--v2-font-size-4);
font-weight: var(--v2-font-weight-primary-bold);
margin: 0;
}
.comments {
color: var(--v2-colors-mono-500);
font-size: var(--v2-font-size-1);
font-weight: var(--v2-font-weight-primary-semi-bold);
margin: 0;
}
@@ -0,0 +1,41 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useMemo } from "react";
import { TooltipProps } from "recharts";
import styles from "./CommentActivityTooltip.css";
type Props = TooltipProps & {
locales: string[];
};
const CommentActivityTooltip: FunctionComponent<Props> = ({
active,
payload,
label,
locales,
}) => {
const formattedLabel = useMemo(() => {
if (label) {
const formatter = new Intl.DateTimeFormat(locales, {
hour: "2-digit",
minute: "2-digit",
});
return formatter.format(new Date(label)).toLowerCase();
}
return "";
}, [label]);
if (active) {
return (
<div className={styles.root}>
<p className={styles.time}>{formattedLabel}</p>
{payload && <p className={styles.count}>{payload[0].value}</p>}
<Localized id="dashboard-comment-activity-tooltip-comments">
<p className={styles.comments}>Comments</p>
</Localized>
</div>
);
}
return null;
};
export default CommentActivityTooltip;
@@ -0,0 +1,9 @@
.heading {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
font-size: var(--v2-font-size-2);
}
.chart {
font-family: var(--v2-font-family-primary);
}
@@ -0,0 +1,114 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import { TimeSeriesMetricsJSON } from "coral-common/rest/dashboard/types";
import { useImmediateFetch } from "coral-framework/lib/relay/fetch";
import { useUIContext } from "coral-ui/components";
import { DashboardBox, DashboardComponentHeading, Loader } from "../components";
import createDashboardFetch from "../createDashboardFetch";
import {
CHART_COLOR_GREY_200,
CHART_COLOR_MONO_500,
CHART_COLOR_PRIMARY_LIGHT,
CHART_COLOR_PRIMARY_PALE,
} from "./ChartColors";
import SignupActivityTick from "./SignupActivityTick";
import styles from "./SignupActivity.css";
interface Props {
locales?: string[];
siteID: string;
lastUpdated: string;
}
const DailySignupMetrics = createDashboardFetch<TimeSeriesMetricsJSON>(
"commenterActivityFetch",
"/dashboard/daily/users"
);
const CommenterActivity: FunctionComponent<Props> = ({
locales: localesFromProps,
siteID,
lastUpdated,
}) => {
const [daily, loading] = useImmediateFetch(
DailySignupMetrics,
{ siteID },
lastUpdated
);
const { locales: localesFromContext } = useUIContext();
const locales = localesFromProps || localesFromContext || ["en-US"];
return (
<DashboardBox>
<Localized id="dashboard-commenters-activity-heading">
<DashboardComponentHeading>New Signups</DashboardComponentHeading>
</Localized>
<Loader loading={loading} height={300} />
{!loading && (
<ResponsiveContainer height={300}>
<BarChart
className={styles.chart}
width={730}
height={250}
data={daily ? daily.series : []}
>
<CartesianGrid vertical={false} stroke={CHART_COLOR_GREY_200} />
<XAxis
height={36}
stroke={CHART_COLOR_MONO_500}
axisLine={{ strokeWidth: 0 }}
tickLine={false}
dataKey="timestamp"
interval={0}
tick={(props) => (
<SignupActivityTick
isToday={
daily &&
daily.series &&
daily.series.length - 1 === props.index
}
locales={locales}
{...props}
/>
)}
/>
<YAxis
allowDecimals={false}
tickLine={false}
width={36}
stroke={CHART_COLOR_MONO_500}
axisLine={{ strokeWidth: 0 }}
tick={{ fontSize: 12, fontWeight: 600 }}
/>
<Bar dataKey="count">
{(daily ? daily.series : []).map((entry, index) => {
return (
<Cell
key={entry.timestamp}
fill={
daily && daily.series && daily.series.length - 1 === index
? CHART_COLOR_PRIMARY_PALE
: CHART_COLOR_PRIMARY_LIGHT
}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</DashboardBox>
);
};
export default CommenterActivity;
@@ -0,0 +1,69 @@
import React, { FunctionComponent, useMemo } from "react";
import {
CHART_COLOR_MONO_100,
CHART_COLOR_MONO_500,
CHART_COLOR_PRIMARY_DARK,
} from "./ChartColors";
interface TickPayload {
value: string;
}
interface Props {
x: number;
locales: string[];
y: number;
payload: TickPayload;
isToday: boolean;
}
const SignupActivityTick: FunctionComponent<Props> = ({
x,
y,
payload,
locales,
isToday,
}) => {
const date = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locales, {
day: "numeric",
month: "numeric",
});
return formatter.format(new Date(payload.value));
}, [payload.value]);
const dayOfWeek = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locales, {
weekday: "short",
});
return formatter.format(new Date(payload.value));
}, [payload.value]);
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={12}
fill={isToday ? CHART_COLOR_PRIMARY_DARK : CHART_COLOR_MONO_500}
fontSize={12}
fontWeight={isToday ? 700 : 600}
textAnchor="middle"
>
{date}
</text>
<text
x={0}
y={0}
dy={28}
fill={isToday ? CHART_COLOR_PRIMARY_DARK : CHART_COLOR_MONO_100}
fontSize={12}
fontWeight={isToday ? 700 : 500}
textAnchor="middle"
>
{dayOfWeek}
</text>
</g>
);
};
export default SignupActivityTick;
@@ -0,0 +1,39 @@
.heading {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
font-size: var(--v2-font-size-4);
}
.valueBoxValue {
font-size: var(--v2-font-size-7);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-reset);
}
.valueBoxName {
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-semi-bold);
}
.valueBoxCompareValue {
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-semi-bold);
}
.valueBoxCompareName {
font-size: var(--v2-font-size-1);
}
.icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.forumIcon {
background-color: var(--v2-colors-teal-100);
color: var(--v2-colors-teal-600);
}
@@ -0,0 +1,129 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { TodayMetricsJSON } from "coral-common/rest/dashboard/types";
import { useImmediateFetch } from "coral-framework/lib/relay/fetch";
import { Flex } from "coral-ui/components/v2";
import {
TodayCompareValue,
TodayDashboardBox,
TodayValue,
} from "../components";
import createDashboardFetch from "../createDashboardFetch";
import styles from "./Today.css";
const TodayMetricsFetch = createDashboardFetch<TodayMetricsJSON>(
"todayMetricsFetch",
"/dashboard/today"
);
const TotalMetricsFetch = createDashboardFetch<TodayMetricsJSON>(
"totalMetricsFetch",
"/dashboard/total"
);
interface Props {
siteID: string;
lastUpdated: string;
}
const TodayTotals: FunctionComponent<Props> = ({ siteID, lastUpdated }) => {
const [today, loading] = useImmediateFetch(
TodayMetricsFetch,
{ siteID },
lastUpdated
);
const [total, totalLoading] = useImmediateFetch(
TotalMetricsFetch,
{ siteID },
lastUpdated
);
return (
<div>
<Localized id="dashboard-today-heading">
<h3 className={styles.heading}>Today's activity</h3>
</Localized>
<Flex justifyContent="space-between" spacing={3}>
<TodayDashboardBox icon="forum" loading={loading || totalLoading}>
<TodayValue value={today?.comments.total.toString()}>
<Localized id="dashboard-today-new-comments">
New comments
</Localized>
</TodayValue>
<TodayCompareValue value={total?.comments.total.toString()}>
<Localized id="dashboard-alltime-new-comments">
All time total
</Localized>
</TodayCompareValue>
</TodayDashboardBox>
<TodayDashboardBox icon="close" loading={loading || totalLoading}>
<TodayValue
value={
today
? `${(today.comments.total > 0
? (today.comments.rejected / today.comments.total) * 100
: 0
).toFixed(2)} %`
: "-.-- %"
}
>
<Localized id="dashboard-today-rejections">
Rejection rate
</Localized>
</TodayValue>
<TodayCompareValue
value={
total
? `${(total.comments.total > 0
? (total.comments.rejected / total.comments.total) * 100
: 0
).toFixed(2)} %`
: "-.-- %"
}
>
<Localized id="dashboard-alltime-rejections">
All time average
</Localized>
</TodayCompareValue>
</TodayDashboardBox>
<TodayDashboardBox icon="badge" loading={loading || totalLoading}>
<TodayValue value={today?.comments.staff.toString()}>
<Localized id="dashboard-today-staff-comments">
Staff comments
</Localized>
</TodayValue>
<TodayCompareValue value={total?.comments.staff.toString()}>
<Localized id="dashboard-alltime-staff-comments">
All time total
</Localized>
</TodayCompareValue>
</TodayDashboardBox>
<TodayDashboardBox icon="person_add" loading={loading || totalLoading}>
<TodayValue value={today?.users.total.toString()}>
<Localized id="dashboard-today-signups">
New community members
</Localized>
</TodayValue>
<TodayCompareValue value={total?.users.total.toString()}>
<Localized id="dashboard-alltime-signups">Total members</Localized>
</TodayCompareValue>
</TodayDashboardBox>
<TodayDashboardBox icon="block" loading={loading || totalLoading}>
<TodayValue value={today?.users.bans.toString()}>
<Localized id="dashboard-today-bans">Banned members</Localized>
</TodayValue>
<TodayCompareValue value={total?.users.bans.toString()}>
<Localized id="dashboard-alltime-bans">
Total banned members
</Localized>
</TodayCompareValue>
</TodayDashboardBox>
</Flex>
</div>
);
};
export default TodayTotals;
@@ -0,0 +1,90 @@
import { Localized } from "@fluent/react/compat";
import { Link } from "found";
import React, { FunctionComponent } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
import { TodayStoriesMetricsJSON } from "coral-common/rest/dashboard/types";
import { getModerationLink } from "coral-framework/helpers";
import { useImmediateFetch } from "coral-framework/lib/relay/fetch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextLink,
} from "coral-ui/components/v2";
import { DashboardBox, DashboardComponentHeading, Loader } from "../components";
import createDashboardFetch from "../createDashboardFetch";
const TodayStoriesMetrics = createDashboardFetch<TodayStoriesMetricsJSON>(
"topStoriesFetch",
"/dashboard/today/stories"
);
interface Props {
siteID: string;
lastUpdated: string;
}
const TopStories: FunctionComponent<Props> = ({ siteID, lastUpdated }) => {
const [today, loading] = useImmediateFetch(
TodayStoriesMetrics,
{ siteID },
lastUpdated
);
return (
<DashboardBox>
<Localized id="dashboard-top-stories-today-heading">
<DashboardComponentHeading>
Today's most commented stories
</DashboardComponentHeading>
</Localized>
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="dashboard-top-stories-table-header-story">
<TableCell>Story</TableCell>
</Localized>
<Localized id="dashboard-top-stories-table-header-comments">
<TableCell align="end">Comments</TableCell>
</Localized>
</TableRow>
</TableHead>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={2}>
<Loader loading={loading} />
</TableCell>
</TableRow>
)}
{today && today.results.length < 1 && (
<TableRow>
<Localized id="dashboard-top-stories-no-comments">
<TableCell colSpan={2}>No comments today.</TableCell>
</Localized>
</TableRow>
)}
{today &&
today.results.map((result) => (
<TableRow key={result.story.id}>
<TableCell>
<Link
to={getModerationLink({ storyID: result.story.id })}
as={TextLink}
>
{result.story.title ? result.story.title : <NotAvailable />}
</Link>
</TableCell>
<TableCell align="end">{result.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DashboardBox>
);
};
export default TopStories;
@@ -0,0 +1,4 @@
export { default as Today } from "./Today";
export { default as SignupActivity } from "./SignupActivity";
export { default as CommentActivity } from "./CommentActivity";
export { default as TopStories } from "./TopStories";
+30 -1
View File
@@ -1,4 +1,5 @@
import React, { useCallback } from "react"; import { isUndefined } from "lodash";
import React, { useCallback, useEffect, useState } from "react";
import { fetchQuery as relayFetchQuery } from "react-relay"; import { fetchQuery as relayFetchQuery } from "react-relay";
import { import {
compose, compose,
@@ -70,6 +71,34 @@ export function useFetch<V, R>(
); );
} }
export function useImmediateFetch<V extends {}, R>(
fetch: Fetch<any, V, Promise<R>>,
variables: V,
refetch?: string
): [R | null, boolean] {
const fetcher = useFetch(fetch);
const [state, setState] = useState<R | null>(null);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
async function doTheFetch() {
setState(null);
setLoading(true);
const value = await fetcher(variables);
// TODO: Maybe we don't need this timeout?
setTimeout(() => {
setState(value);
setLoading(false);
}, 100 + 50 * Math.random());
}
doTheFetch();
}, Object.values(variables).concat(isUndefined(refetch) ? [] : [refetch]));
return [state, loading];
}
/** /**
* withFetch creates a HOC that injects the fetch as * withFetch creates a HOC that injects the fetch as
* a property. * a property.
@@ -1,18 +1,18 @@
import cn from "classnames"; import cn from "classnames";
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { Flex } from "coral-ui/components";
import Icon from "coral-ui/components/Icon";
import BaseButton, { BaseButtonProps } from "coral-ui/components/v2/BaseButton";
import { withStyles } from "coral-ui/hocs"; import { withStyles } from "coral-ui/hocs";
import BaseButton, { BaseButtonProps } from "coral-ui/components/BaseButton";
import Icon from "coral-ui/components/Icon";
import { Flex } from "coral-ui/components";
import styles from "./Button.css"; import styles from "./Button.css";
interface Props extends Omit<BaseButtonProps, "ref"> { interface Props extends Omit<BaseButtonProps, "ref"> {
children: React.ReactNode; children: React.ReactNode;
icon?: React.ReactNode; icon?: React.ReactNode;
href?: string; href?: string;
to?: string;
className?: string; className?: string;
onClick?: React.EventHandler<React.MouseEvent>; onClick?: React.EventHandler<React.MouseEvent>;
classes: typeof styles; classes: typeof styles;
+26
View File
@@ -0,0 +1,26 @@
export interface TodayMetricsJSON {
users: {
total: number;
bans: number;
};
comments: {
total: number;
rejected: number;
staff: number;
};
}
export interface TimeSeriesMetricsJSON {
series: Array<{ count: number; timestamp: string }>;
average?: number;
}
export interface TodayStoriesMetricsJSON {
results: Array<{
count: number;
story: {
id: string;
title?: string;
};
}>;
}
@@ -0,0 +1,189 @@
import {
TimeSeriesMetricsJSON,
TodayMetricsJSON,
TodayStoriesMetricsJSON,
} from "coral-common/rest/dashboard/types";
import { AppOptions } from "coral-server/app";
import { calculateTotalCommentCount } from "coral-server/models/comment";
import {
retrieveAllTimeStaffCommentMetrics,
retrieveAverageCommentsMetric,
retrieveHourlyCommentMetrics,
retrieveTodayCommentMetrics,
retrieveTodayTopStoryMetrics,
} from "coral-server/models/comment/metrics";
import { retrieveSite } from "coral-server/models/site";
import { retrieveManyStories } from "coral-server/models/story";
import {
retrieveAllTimeUserMetrics,
retrieveDailyUserMetrics,
retrieveTodayUserMetrics,
} from "coral-server/models/user/metrics";
import { Request, RequestHandler } from "coral-server/types/express";
function getMetricsOptions(req: Request) {
// Get the current Tenant on the request.
const { id: tenantID } = req.coral?.tenant!;
const now = req.coral?.now!;
// To set a fixed date for the date, uncomment the line below.
// const now = DateTime.utc(2020, 5, 5, 12, 30).toJSDate();
// To allow for date overrides (to load metrics for another time then now),
// uncomment the lines below.
// const { date = req.coral?.now!.toISOString() } = req.query;
// const now = new Date(date);
// Get the Site ID and timezone for this set of metrics.
const { siteID, tz } = req.query;
if (!tz) {
throw new Error("tz was not provided");
}
return { tenantID, siteID, tz, now };
}
export const todayMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
try {
const { tenantID, siteID, tz, now } = getMetricsOptions(req);
if (!siteID) {
throw new Error("siteID was not provided");
}
const [users, comments] = await Promise.all([
retrieveTodayUserMetrics(mongo, tenantID, tz, now),
retrieveTodayCommentMetrics(mongo, tenantID, siteID, tz, now),
]);
const result: TodayMetricsJSON = {
users,
comments,
};
return res.json(result);
} catch (err) {
return next(err);
}
};
export const totalMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
try {
const { tenantID, siteID } = getMetricsOptions(req);
if (!siteID) {
throw new Error("siteID was not provided");
}
const site = await retrieveSite(mongo, tenantID, siteID);
if (!site) {
throw new Error("site specified was not found");
}
const [users, staff] = await Promise.all([
retrieveAllTimeUserMetrics(mongo, tenantID),
retrieveAllTimeStaffCommentMetrics(mongo, tenantID, siteID),
]);
const result: TodayMetricsJSON = {
users,
comments: {
total: calculateTotalCommentCount(site.commentCounts.status),
rejected: site.commentCounts.status.REJECTED,
staff,
},
};
return res.json(result);
} catch (err) {
return next(err);
}
};
export const hourlyCommentsMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
try {
const { tenantID, siteID, tz, now } = getMetricsOptions(req);
if (!siteID) {
throw new Error("siteID was not provided");
}
const [series, average] = await Promise.all([
retrieveHourlyCommentMetrics(mongo, tenantID, siteID, tz, now),
retrieveAverageCommentsMetric(mongo, tenantID, siteID, tz, now),
]);
const result: TimeSeriesMetricsJSON = {
series,
average,
};
return res.json(result);
} catch (err) {
return next(err);
}
};
export const dailyUsersMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
try {
const { tenantID, tz, now } = getMetricsOptions(req);
const result: TimeSeriesMetricsJSON = {
series: await retrieveDailyUserMetrics(mongo, tenantID, tz, now),
};
return res.json(result);
} catch (err) {
return next(err);
}
};
export const todayStoriesMetricsHandler = ({
mongo,
}: AppOptions): RequestHandler => async (req, res, next) => {
try {
const { tenantID, siteID, tz, now } = getMetricsOptions(req);
if (!siteID) {
throw new Error("siteID was not provided");
}
const results = await retrieveTodayTopStoryMetrics(
mongo,
tenantID,
siteID,
tz,
now
);
// Fetch all the stories for each count.
const stories = await retrieveManyStories(
mongo,
tenantID,
results.map(({ _id }) => _id)
);
// Ensure that all entries are not null.
if (!stories.every((story) => story) || results.length !== stories.length) {
throw new Error("some stories with comments were not found");
}
const result: TodayStoriesMetricsJSON = {
results: results.map(({ count }, idx) => {
// We verified above that there were no null values.
const story = stories[idx]!;
return { count, story: { id: story.id, title: story.metadata?.title } };
}),
};
return res.json(result);
} catch (err) {
return next(err);
}
};
@@ -4,5 +4,6 @@ export * from "./graphql";
export * from "./health"; export * from "./health";
export * from "./install"; export * from "./install";
export * from "./version"; export * from "./version";
export * from "./dashboard";
export * from "./user"; export * from "./user";
export * from "./story"; export * from "./story";
@@ -21,7 +21,7 @@ import { SSOToken, SSOVerifier } from "./verifiers/sso";
export type JWTStrategyOptions = Pick< export type JWTStrategyOptions = Pick<
AppOptions, AppOptions,
"signingConfig" | "mongo" | "redis" | "tenantCache" "signingConfig" | "mongo" | "redis" | "tenantCache" | "mongo"
>; >;
/** /**
@@ -1,5 +1,6 @@
import { Db } from "mongodb"; import { Db } from "mongodb";
import { Strategy as BaseStrategy, StrategyCreated } from "passport"; import { Profile, Strategy as BaseStrategy, StrategyCreated } from "passport";
import { VerifyCallback } from "passport-oauth2";
import { Strategy } from "passport-strategy"; import { Strategy } from "passport-strategy";
import { Config } from "coral-server/config"; import { Config } from "coral-server/config";
@@ -12,8 +13,6 @@ import {
TenantCacheAdapter, TenantCacheAdapter,
} from "coral-server/services/tenant/cache"; } from "coral-server/services/tenant/cache";
import { Request } from "coral-server/types/express"; import { Request } from "coral-server/types/express";
import { Profile } from "passport";
import { VerifyCallback } from "passport-oauth2";
interface OAuth2Integration { interface OAuth2Integration {
enabled: boolean; enabled: boolean;
@@ -16,10 +16,7 @@ import {
export { OIDCIDToken } from "../oidc"; export { OIDCIDToken } from "../oidc";
export type OIDCVerifierOptions = Pick< export type OIDCVerifierOptions = Pick<AppOptions, "mongo" | "tenantCache">;
AppOptions,
"mongo" | "redis" | "tenantCache"
>;
export class OIDCVerifier implements Verifier<OIDCIDToken> { export class OIDCVerifier implements Verifier<OIDCIDToken> {
private mongo: Db; private mongo: Db;
+21
View File
@@ -0,0 +1,21 @@
import { UserForbiddenError } from "coral-server/errors";
import { RequestHandler } from "coral-server/types/express";
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
export const roleMiddleware = (roles: GQLUSER_ROLE[]): RequestHandler => (
req,
res,
next
) => {
if (!req.user || !roles.includes(req.user.role)) {
return next(
new UserForbiddenError(
"user does not have sufficient privileges",
req.originalUrl,
req.method
)
);
}
return next();
};
@@ -0,0 +1,30 @@
import { Redis } from "ioredis";
import { RequestLimiter } from "coral-server/app/request/limiter";
import { Config } from "coral-server/config";
import { RequestHandler } from "coral-server/types/express";
export interface MiddlewareOptions {
redis: Redis;
config: Config;
}
export const userLimiterMiddleware = ({
redis,
config,
}: MiddlewareOptions): RequestHandler => {
const limiter = new RequestLimiter({
redis,
ttl: "1m",
max: 5,
prefix: "userID",
config,
});
return async (req, res, next) => {
limiter
.test(req, req.user!.id)
.then(() => next())
.catch((err) => next(err));
};
};
@@ -0,0 +1,25 @@
import { AppOptions } from "coral-server/app";
import {
dailyUsersMetricsHandler,
hourlyCommentsMetricsHandler,
todayMetricsHandler,
todayStoriesMetricsHandler,
totalMetricsHandler,
} from "coral-server/app/handlers";
import { userLimiterMiddleware } from "coral-server/app/middleware/userLimiter";
import { createAPIRouter } from "./helpers";
export function createDashboardRouter(app: AppOptions) {
const router = createAPIRouter({ cache: "30s" });
router.use(userLimiterMiddleware(app));
router.get("/today", todayMetricsHandler(app));
router.get("/total", totalMetricsHandler(app));
router.get("/hourly/comments", hourlyCommentsMetricsHandler(app));
router.get("/daily/users", dailyUsersMetricsHandler(app));
router.get("/today/stories", todayStoriesMetricsHandler(app));
return router;
}
+12
View File
@@ -6,13 +6,17 @@ import { graphQLHandler } from "coral-server/app/handlers";
import { JSONErrorHandler } from "coral-server/app/middleware/error"; import { JSONErrorHandler } from "coral-server/app/middleware/error";
import { persistedQueryMiddleware } from "coral-server/app/middleware/graphql"; import { persistedQueryMiddleware } from "coral-server/app/middleware/graphql";
import { jsonMiddleware } from "coral-server/app/middleware/json"; import { jsonMiddleware } from "coral-server/app/middleware/json";
import { loggedInMiddleware } from "coral-server/app/middleware/loggedIn";
import { errorLogger } from "coral-server/app/middleware/logging"; import { errorLogger } from "coral-server/app/middleware/logging";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound"; import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import { authenticate } from "coral-server/app/middleware/passport"; import { authenticate } from "coral-server/app/middleware/passport";
import { roleMiddleware } from "coral-server/app/middleware/role";
import { tenantMiddleware } from "coral-server/app/middleware/tenant"; import { tenantMiddleware } from "coral-server/app/middleware/tenant";
import { STAFF_ROLES } from "coral-server/models/user/constants";
import { createNewAccountRouter } from "./account"; import { createNewAccountRouter } from "./account";
import { createNewAuthRouter } from "./auth"; import { createNewAuthRouter } from "./auth";
import { createDashboardRouter } from "./dashboard";
import { createNewInstallRouter } from "./install"; import { createNewInstallRouter } from "./install";
import { createStoryRouter } from "./story"; import { createStoryRouter } from "./story";
import { createNewUserRouter } from "./user"; import { createNewUserRouter } from "./user";
@@ -56,6 +60,14 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
graphQLHandler(app) graphQLHandler(app)
); );
router.use(
"/dashboard",
authenticate(options.passport),
loggedInMiddleware,
roleMiddleware(STAFF_ROLES),
createDashboardRouter(app)
);
// General API error handler. // General API error handler.
router.use(notFoundMiddleware); router.use(notFoundMiddleware);
router.use(errorLogger); router.use(errorLogger);
+89
View File
@@ -0,0 +1,89 @@
import { DateTime } from "luxon";
export interface TimeRange {
readonly start: DateTime;
readonly end: DateTime;
readonly hours: number;
}
export type TimeUnit = "day" | "hour";
const TIME_UNIT_FORMAT: Record<TimeUnit, { js: string; mongo: string }> = {
day: { js: "yyyy-LL-dd", mongo: "%Y-%m-%d" },
hour: { js: "yyyy-LL-dd HH:00", mongo: "%Y-%m-%d %H:00" },
};
const TIME_UNIT_HOURS: Record<TimeUnit, number> = {
day: 24,
hour: 1,
};
const TIME_UNIT_MAX: Record<TimeUnit, number> = {
day: 7,
hour: 24,
};
export function getTimeRange(
unit: TimeUnit,
zone: string,
now: Date,
interval = TIME_UNIT_MAX[unit]
): TimeRange {
// Convert the date to the specified zone.
const end = DateTime.fromJSDate(now).setZone(zone);
return {
start: end.startOf(unit).minus({ [unit]: interval - 1 }),
end,
hours: interval * TIME_UNIT_HOURS[unit],
};
}
export function getMongoFormat(unit: TimeUnit): string {
return TIME_UNIT_FORMAT[unit].mongo;
}
export interface Result {
_id: string;
count: number;
}
export interface Point {
count: number;
timestamp: string;
}
export function formatTimeRangeSeries(
unit: TimeUnit,
start: DateTime,
results: Result[]
) {
if (results.length > TIME_UNIT_MAX[unit]) {
throw new Error(
`invalid number of items, expected ${TIME_UNIT_MAX[unit]}, got ${results.length}`
);
}
const series: Point[] = [];
let date = start;
for (let i = 0; i < TIME_UNIT_MAX[unit]; i++) {
const search = date.toFormat(TIME_UNIT_FORMAT[unit].js);
const result = results.find(({ _id }) => _id === search);
// Add the result (or zero if it doesn't exist) to the series.
series.push({
count: result ? result.count : 0,
timestamp: date.toJSDate().toISOString(),
});
// Increment the date by the specified unit.
date = date.plus({ [unit]: 1 });
}
return series;
}
export function getCount<T extends Array<{ count: number }>>(results: T) {
return results.length === 1 ? results[0].count : 0;
}
+175
View File
@@ -0,0 +1,175 @@
import { DateTime } from "luxon";
import { Db } from "mongodb";
import {
formatTimeRangeSeries,
getCount,
getMongoFormat,
getTimeRange,
Result,
} from "coral-server/helpers/metrics";
import { comments as collection } from "coral-server/services/mongodb/collections";
import {
GQLCOMMENT_STATUS,
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
export async function retrieveHourlyCommentMetrics(
mongo: Db,
tenantID: string,
siteID: string,
timezone: string,
now: Date
) {
const { start, end } = getTimeRange("hour", timezone, now);
// Return the last 24 hours (in hour documents).
const results = await collection<Result>(mongo)
.aggregate([
{ $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } },
{
$group: {
_id: {
$dateToString: {
date: "$createdAt",
format: getMongoFormat("hour"),
timezone,
},
},
count: { $sum: 1 },
},
},
{ $sort: { _id: 1 } },
])
.toArray();
return formatTimeRangeSeries("hour", start, results);
}
export async function retrieveTodayCommentMetrics(
mongo: Db,
tenantID: string,
siteID: string,
timezone: string,
now: Date
) {
const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day");
const end = DateTime.fromJSDate(now);
const status = await collection<{ _id: GQLCOMMENT_STATUS; count: number }>(
mongo
)
.aggregate([
{
$match: {
tenantID,
siteID,
createdAt: { $gte: start, $lte: end },
},
},
{
$group: {
_id: "$status",
count: { $sum: 1 },
},
},
])
.toArray();
const rejected = status.find((doc) => doc._id === GQLCOMMENT_STATUS.REJECTED);
const total = status.reduce((acc, doc) => acc + doc.count, 0);
const staff = await collection<{ count: number }>(mongo)
.aggregate([
{
$match: {
tenantID,
siteID,
createdAt: { $gte: start, $lte: end },
"tags.type": GQLTAG.STAFF,
},
},
{ $count: "count" },
])
.toArray();
return {
total,
rejected: rejected ? rejected.count : 0,
staff: getCount(staff),
};
}
export async function retrieveAllTimeStaffCommentMetrics(
mongo: Db,
tenantID: string,
siteID: string
) {
// Get the referenced tenant, site, and staff comments.
const staff = await collection<{ count: number }>(mongo)
.aggregate([
{
$match: {
tenantID,
siteID,
"tags.type": GQLTAG.STAFF,
},
},
{ $count: "count" },
])
.toArray();
return getCount(staff);
}
export async function retrieveAverageCommentsMetric(
mongo: Db,
tenantID: string,
siteID: string,
timezone: string,
now: Date
) {
const { start, hours, end } = getTimeRange("hour", timezone, now, 72);
// Return the last 24 hours (in hour documents).
const results = await collection<{ count: number }>(mongo)
.aggregate([
{ $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } },
{ $count: "count" },
])
.toArray();
const total = getCount(results);
return Math.floor(total / hours);
}
export async function retrieveTodayTopStoryMetrics(
mongo: Db,
tenantID: string,
siteID: string,
timezone: string,
now: Date
) {
const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day");
const end = DateTime.fromJSDate(now);
// Return the last 24 hours worth of comments.
const results = await collection<Result>(mongo)
.aggregate([
{ $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } },
{
$group: {
_id: "$storyID",
count: { $sum: 1 },
},
},
{ $sort: { count: -1 } },
// TODO: 17 was for visual treatment, feel free to change this!
{ $limit: 17 },
])
.toArray();
return results;
}
+4
View File
@@ -121,6 +121,10 @@ async function retrieveConnection(
return resolveConnection(query, input, (_, index) => index + skip + 1); return resolveConnection(query, input, (_, index) => index + skip + 1);
} }
export async function countTenantSites(mongo: Db, tenantID: string) {
return collection(mongo).find({ tenantID }).count();
}
export async function retrieveSiteConnection( export async function retrieveSiteConnection(
mongo: Db, mongo: Db,
tenantID: string, tenantID: string,
+96
View File
@@ -0,0 +1,96 @@
import { DateTime } from "luxon";
import { Db } from "mongodb";
import {
formatTimeRangeSeries,
getCount,
getMongoFormat,
getTimeRange,
Result,
} from "coral-server/helpers/metrics";
import { users as collection } from "coral-server/services/mongodb/collections";
export async function retrieveTodayUserMetrics(
mongo: Db,
tenantID: string,
timezone: string,
now: Date
) {
const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day");
const end = DateTime.fromJSDate(now);
const [total, bans] = await Promise.all([
collection<{ count: number }>(mongo)
.aggregate([
{ $match: { tenantID, createdAt: { $gte: start, $lte: end } } },
{ $count: "count" },
])
.toArray(),
collection<{ count: number }>(mongo)
.aggregate([
{
$match: {
tenantID,
"status.ban.active": true,
"status.ban.history.createdAt": { $gte: start, $lte: end },
},
},
{ $count: "count" },
])
.toArray(),
]);
return {
total: getCount(total),
bans: getCount(bans),
};
}
export async function retrieveAllTimeUserMetrics(mongo: Db, tenantID: string) {
const [bans, total] = await Promise.all([
collection<{ count: number }>(mongo)
.aggregate([
{ $match: { tenantID, "status.ban.active": true } },
{ $count: "count" },
])
.toArray(),
collection<{ count: number }>(mongo)
.aggregate([{ $match: { tenantID } }, { $count: "count" }])
.toArray(),
]);
return {
total: getCount(total),
bans: getCount(bans),
};
}
export async function retrieveDailyUserMetrics(
mongo: Db,
tenantID: string,
timezone: string,
now: Date
) {
const { start, end } = getTimeRange("day", timezone, now);
// Return the last 7 days (in day documents).
const results = await collection<Result>(mongo)
.aggregate([
{ $match: { tenantID, createdAt: { $gte: start, $lte: end } } },
{
$group: {
_id: {
$dateToString: {
date: "$createdAt",
format: getMongoFormat("day"),
timezone,
},
},
count: { $sum: 1 },
},
},
])
.toArray();
return formatTimeRangeSeries("day", start, results);
}
-1
View File
@@ -1,7 +1,6 @@
import { Db } from "mongodb"; import { Db } from "mongodb";
import { ERROR_TYPES } from "coral-common/errors"; import { ERROR_TYPES } from "coral-common/errors";
import { Config } from "coral-server/config"; import { Config } from "coral-server/config";
import { import {
CommentNotFoundError, CommentNotFoundError,
+26
View File
@@ -29,6 +29,7 @@ navigation-moderate = Moderate
navigation-community = Community navigation-community = Community
navigation-stories = Stories navigation-stories = Stories
navigation-configure = Configure navigation-configure = Configure
navigation-dashboard = Dashboard
## User Menu ## User Menu
userMenu-signOut = Sign Out userMenu-signOut = Sign Out
@@ -1254,3 +1255,28 @@ hotkeysModal-shortcuts-ban = Ban comment author
hotkeysModal-shortcuts-zen = Toggle single-comment view hotkeysModal-shortcuts-zen = Toggle single-comment view
authcheck-network-error = A network error occurred. Please refresh the page. authcheck-network-error = A network error occurred. Please refresh the page.
dashboard-heading-last-updated = Last updated:
dashboard-today-heading = Today's activity
dashboard-today-new-comments = New comments
dashboard-alltime-new-comments = All time total
dashboard-today-rejections = Rejection rate
dashboard-alltime-rejections = All time average
dashboard-today-staff-comments = Staff comments
dashboard-alltime-staff-comments = All time total
dashboard-today-signups = New community members
dashboard-alltime-signups = Total members
dashboard-today-bans = Banned members
dashboard-alltime-bans = Total banned members
dashboard-top-stories-today-heading = Today's most commented stories
dashboard-top-stories-table-header-story = Story
dashboard-top-stories-table-header-comments = Comments
dashboard-top-stories-no-comments = No comments today
dashboard-commenters-activity-heading = New community members this week
dashboard-comment-activity-heading = Hourly comment activity
dashboard-comment-activity-tooltip-comments = Comments
dashboard-comment-activity-legend = All-time average