From 2a0d32f2149ef9848b5e3d962b0f31de6920db5d Mon Sep 17 00:00:00 2001 From: MICHAEL JACKSON Date: Tue, 22 Aug 2017 08:31:33 -0700 Subject: [PATCH] Add /_stats endpoint Also, remove ingest_stats worker and use the cache instead. --- .travis.yml | 6 +- Procfile | 1 - client/About.js | 5 +- client/About.md | 12 +- client/App.js | 20 +- client/CountryUtils.js | 13 - client/DOMUtils.js | 15 - client/Home.js | 5 +- client/Home.md | 69 +- client/Layout.js | 55 +- client/NumberUtils.js | 15 - client/ReactTrainingLogo.png | Bin 27923 -> 0 bytes client/Stats.js | 205 +- client/WindowSize.js | 3 +- client/index.css | 142 +- client/utils/addEvent.js | 9 + client/utils/formatNumber.js | 11 + client/utils/formatPercent.js | 4 + client/utils/parseNumber.js | 4 + client/utils/removeEvent.js | 9 + package.json | 4 +- public/index.html | 5 +- scripts/build.js | 4 - scripts/start.js | 3 - server/CloudflareAPI.js | 48 +- server/IngestStatsWorker.js | 345 - server/StatsServer.js | 155 +- server/cloudflare.js | 93 - server/createApp.js | 40 +- server/dev-data.json | 17445 ------------------------------ server/middleware/serveStats.js | 77 + yarn.lock | 11 - 32 files changed, 555 insertions(+), 18278 deletions(-) delete mode 100644 client/CountryUtils.js delete mode 100644 client/DOMUtils.js delete mode 100644 client/NumberUtils.js delete mode 100644 client/ReactTrainingLogo.png create mode 100644 client/utils/addEvent.js create mode 100644 client/utils/formatNumber.js create mode 100644 client/utils/formatPercent.js create mode 100644 client/utils/parseNumber.js create mode 100644 client/utils/removeEvent.js delete mode 100644 server/IngestStatsWorker.js delete mode 100644 server/cloudflare.js delete mode 100644 server/dev-data.json create mode 100644 server/middleware/serveStats.js diff --git a/.travis.yml b/.travis.yml index 3819858..4beb7a5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,12 @@ language: node_js node_js: -- stable + - stable cache: yarn branches: only: - - master + - master +services: + - redis-server before_deploy: yarn build deploy: provider: heroku diff --git a/Procfile b/Procfile index 4e16c15..35b7fff 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,2 @@ web: node server.js ingest_logs: node server/IngestLogsWorker.js -ingest_stats: node server/IngestStatsWorker.js diff --git a/client/About.js b/client/About.js index 5c0d99c..9b8e883 100644 --- a/client/About.js +++ b/client/About.js @@ -1,8 +1,7 @@ import React from 'react' import contentHTML from './About.md' -function About() { - return
-} +const About = () => +
export default About diff --git a/client/About.md b/client/About.md index e54b017..39360a2 100644 --- a/client/About.md +++ b/client/About.md @@ -1,12 +1,4 @@ -unpkg is an [open source](https://github.com/mjackson/unpkg) project built by me, [Michael Jackson](https://twitter.com/mjackson). I built it because, as an npm package author, it felt tedious for me to use existing, git-based CDNs to make my open source work available via CDN. Development was sponsored by my company, [React Training](https://reacttraining.com). - -
- -
- -We'd love to talk to you more about training your team on [React](https://facebook.github.io/react/). Please [get in touch](mailto:hello@reacttraining.com) if interested. +unpkg is an [open source](https://github.com/unpkg) project built and maintained by [Michael Jackson](https://twitter.com/mjackson). ### Sponsors @@ -21,8 +13,6 @@ The fast, global infrastructure that powers unpkg is generously donated by [Clou
-These sponsors provide some of the most robust, reliable infrastructure available today and I'm happy to be able to partner with them on unpkg. - ### Cache Behavior The CDN caches all files based on their permanent URL, which includes the npm package version. This works because npm does not allow package authors to overwrite a package that has already been published with a different one at the same version number. diff --git a/client/App.js b/client/App.js index 8a92172..5b283af 100644 --- a/client/App.js +++ b/client/App.js @@ -1,20 +1,10 @@ import React from 'react' -import { HashRouter as Router, Switch, Route } from 'react-router-dom' +import { HashRouter } from 'react-router-dom' import Layout from './Layout' -import About from './About' -import Stats from './Stats' -import Home from './Home' -const App = () => ( - - - - - - - - - -) +const App = () => + + + export default App diff --git a/client/CountryUtils.js b/client/CountryUtils.js deleted file mode 100644 index a811a87..0000000 --- a/client/CountryUtils.js +++ /dev/null @@ -1,13 +0,0 @@ -import { - continents as ContinentsIndex, - countries as CountriesIndex -} from 'countries-list' - -const getCountriesByContinent = (continent) => - Object.keys(CountriesIndex).filter(country => CountriesIndex[country].continent === continent) - -export { - ContinentsIndex, - CountriesIndex, - getCountriesByContinent -} diff --git a/client/DOMUtils.js b/client/DOMUtils.js deleted file mode 100644 index 9c96fea..0000000 --- a/client/DOMUtils.js +++ /dev/null @@ -1,15 +0,0 @@ -export const addEvent = (node, type, handler) => { - if (node.addEventListener) { - node.addEventListener(type, handler, false) - } else if (node.attachEvent) { - node.attachEvent('on' + type, handler) - } -} - -export const removeEvent = (node, type, handler) => { - if (node.removeEventListener) { - node.removeEventListener(type, handler, false) - } else if (node.detachEvent) { - node.detachEvent('on' + type, handler) - } -} diff --git a/client/Home.js b/client/Home.js index b18879a..f99e466 100644 --- a/client/Home.js +++ b/client/Home.js @@ -1,8 +1,7 @@ import React from 'react' import contentHTML from './Home.md' -function Home() { - return
-} +const Home = () => +
export default Home diff --git a/client/Home.md b/client/Home.md index a34f27a..f0e5db5 100644 --- a/client/Home.md +++ b/client/Home.md @@ -1,60 +1,43 @@ -unpkg is a fast, global [content delivery network](https://en.wikipedia.org/wiki/Content_delivery_network) for everything on [npm](https://www.npmjs.com/). Use it to quickly and easily load files using a simple URL like: +unpkg is a fast, global [content delivery network](https://en.wikipedia.org/wiki/Content_delivery_network) for everything on [npm](https://www.npmjs.com/). Use it to quickly and easily load any file from any package using a URL like: -
`https://unpkg.com/package@version/file`
+
unpkg.com/:package@:version/:file
-A few examples: +### Examples - * [https://unpkg.com/react@15.3.1/dist/react.min.js](/react@15.3.1/dist/react.min.js) - * [https://unpkg.com/react-dom@15.3.1/dist/react-dom.min.js](/react-dom@15.3.1/dist/react-dom.min.js) - * [https://unpkg.com/history@4.2.0/umd/history.min.js](/history@4.2.0/umd/history.min.js) +Using a fixed version: -You may also use a [tag](https://docs.npmjs.com/cli/dist-tag) or [version range](https://docs.npmjs.com/misc/semver) instead of a fixed version number, or omit the version/tag entirely to use the `latest` tag. + * [unpkg.com/react@15.3.1/dist/react.min.js](/react@15.3.1/dist/react.min.js) + * [unpkg.com/react-dom@15.3.1/dist/react-dom.min.js](/react-dom@15.3.1/dist/react-dom.min.js) - * [https://unpkg.com/react@^0.14/dist/react.min.js](/react@^0.14/dist/react.min.js) - * [https://unpkg.com/react/dist/react.min.js](/react/dist/react.min.js) +You may also use a [semver range](https://docs.npmjs.com/misc/semver) or a [tag](https://docs.npmjs.com/cli/dist-tag) instead of a fixed version number, or omit the version/tag entirely to use the `latest` tag. -If you omit the file path, unpkg will try to serve [the `browser` bundle](https://github.com/defunctzombie/package-browser-field-spec) if present, the [`main` module](https://docs.npmjs.com/files/package.json#main) otherwise. + * [unpkg.com/react@^15/dist/react.min.js](/react@^15/dist/react.min.js) + * [unpkg.com/react/dist/react.min.js](/react/dist/react.min.js) - * [https://unpkg.com/jquery](/jquery) - * [https://unpkg.com/angular-formly](/angular-formly) - * [https://unpkg.com/three](/three) +If you omit the file path, unpkg will serve the package's "main" file. + + * [unpkg.com/jquery](/jquery) + * [unpkg.com/three](/three) Append a `/` at the end of a URL to view a listing of all the files in a package. - * [https://unpkg.com/lodash/](/lodash/) - * [https://unpkg.com/modernizr/](/modernizr/) - * [https://unpkg.com/react/](/react/) + * [unpkg.com/react/](/react/) + * [unpkg.com/lodash/](/lodash/) ### Query Parameters - - - - - - - - - - - - - - - - - - - - - - - - - -
NameDefault ValueDescription
`main``unpkg`, `browser`, `main`The name of the field in [package.json](https://docs.npmjs.com/files/package.json) to use as the main entry point when there is no file path in the URL
`meta`Return metadata about any file in a package as JSON (e.g. `/any/file?meta`)
`expand`Expands all ["bare" `import` specifiers](https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier) in JavaScript modules to unpkg URLs. This feature is *very experimental*
+
+
`?main=:mainField`
+
The name of the field in [package.json](https://docs.npmjs.com/files/package.json) to use as the main entry point when there is no file path in the URL. Defaults to using `unpkg`, [`browser`](https://github.com/defunctzombie/package-browser-field-spec), and then [`main`](https://docs.npmjs.com/files/package.json#main).
-### Suggested Workflow +
`?meta`
+
Return metadata about any file in a package as JSON (e.g. `/any/file?meta`)
+ +
`?module`
+
Expands all ["bare" `import` specifiers](https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier) in JavaScript modules to unpkg URLs. This feature is *very experimental*
+
+ +### Workflow For npm package authors, unpkg relieves the burden of publishing your code to a CDN in addition to the npm registry. All you need to do is include your [UMD](https://github.com/umdjs/umd) build in your npm package (not your repo, that's different!). diff --git a/client/Layout.js b/client/Layout.js index 8861d8d..e72c06f 100644 --- a/client/Layout.js +++ b/client/Layout.js @@ -1,9 +1,11 @@ import React from 'react' -import ReactDOM from 'react-dom' import PropTypes from 'prop-types' import { Motion, spring } from 'react-motion' -import { withRouter, Link } from 'react-router-dom' +import { Switch, Route, Link, withRouter } from 'react-router-dom' import WindowSize from './WindowSize' +import About from './About' +import Stats from './Stats' +import Home from './Home' class Layout extends React.Component { static propTypes = { @@ -14,24 +16,25 @@ class Layout extends React.Component { state = { underlineLeft: 0, underlineWidth: 0, - useSpring: false + useSpring: false, + stats: null } adjustUnderline = (useSpring = false) => { let itemIndex switch (this.props.location.pathname) { - case '/about': - itemIndex = 2 - break case '/stats': itemIndex = 1 break + case '/about': + itemIndex = 2 + break case '/': default: itemIndex = 0 } - const itemNodes = ReactDOM.findDOMNode(this).querySelectorAll('.underlist > li') + const itemNodes = this.listNode.querySelectorAll('li') const currentNode = itemNodes[itemIndex] this.setState({ @@ -43,6 +46,10 @@ class Layout extends React.Component { componentDidMount() { this.adjustUnderline() + + fetch('/_stats/last-month') + .then(res => res.json()) + .then(stats => this.setState({ stats })) } componentDidUpdate(prevProps) { @@ -63,34 +70,36 @@ class Layout extends React.Component {
-

unpkg

-
- {this.props.children} -
-
-

© 2016-{(new Date()).getFullYear()} Michael Jackson

-
-
+ + + }/> + + +
) } diff --git a/client/NumberUtils.js b/client/NumberUtils.js deleted file mode 100644 index 6fb0514..0000000 --- a/client/NumberUtils.js +++ /dev/null @@ -1,15 +0,0 @@ -export const formatNumber = (n) => { - const digits = String(n).split('') - const groups = [] - - while (digits.length) - groups.unshift(digits.splice(-3).join('')) - - return groups.join(',') -} - -export const parseNumber = (s) => - parseInt(s.replace(/,/g, ''), 10) || 0 - -export const formatPercent = (n, fixed = 1) => - String((n.toPrecision(2) * 100).toFixed(fixed)) diff --git a/client/ReactTrainingLogo.png b/client/ReactTrainingLogo.png deleted file mode 100644 index dc180e488776ee2b2c96e1557924b57f9ca2009d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27923 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≺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 - countries.reduce((n, country) => n + (data[country] || 0), 0) +import { continents, countries } from 'countries-list' -const addValues = (a, b) => { - for (const p in b) { - if (p in a) { - a[p] += b[p] - } else { - a[p] = b[p] - } - } -} +const getCountriesByContinent = (continent) => + Object.keys(countries).filter(country => countries[country].continent === continent) + +const sumKeyValues = (hash, keys) => + keys.reduce((n, key) => n + (hash[key] || 0), 0) + +const sumValues = (hash) => + Object.keys(hash).reduce((memo, key) => memo + hash[key], 0) class Stats extends React.Component { static propTypes = { - serverData: PropTypes.object - } - - static defaultProps = { - serverData: window.serverData + data: PropTypes.object } state = { - minRequests: 5000000 - } - - updateMinRequests = (value) => { - this.setState({ minRequests: value }) + minPackageRequests: 100000, + minCountryRequests: 1000000 } render() { - const { minRequests } = this.state - const stats = this.props.serverData.cloudflareStats - const { timeseries, totals } = stats + const { data } = this.props + + if (data == null) + return null + + const totals = data.totals // Summary data - const sinceDate = parseDate(totals.since) - const untilDate = parseDate(totals.until) - const uniqueVisitors = totals.uniques.all + const since = parseDate(totals.since) + const until = parseDate(totals.until) - const totalRequests = totals.requests.all - const cachedRequests = totals.requests.cached - const totalBandwidth = totals.bandwidth.all - const httpStatus = totals.requests.http_status + // Packages + const packageRows = [] - let errorRequests = 0 - for (const status in httpStatus) { - if (httpStatus.hasOwnProperty(status) && status >= 500) - errorRequests += httpStatus[status] - } + Object.keys(totals.requests.package).sort((a, b) => { + return totals.requests.package[b] - totals.requests.package[a] + }).forEach(packageName => { + const requests = totals.requests.package[packageName] + const bandwidth = totals.bandwidth.package[packageName] - // By Region - const regionRows = [] - const requestsByCountry = {} - const bandwidthByCountry = {} - - timeseries.forEach(ts => { - addValues(requestsByCountry, ts.requests.country) - addValues(bandwidthByCountry, ts.bandwidth.country) + if (requests >= this.state.minPackageRequests) { + packageRows.push( + + {packageName} + {formatNumber(requests)} ({formatPercent(requests / totals.requests.all)}%) + {bandwidth + ? {formatBytes(bandwidth)} ({formatPercent(bandwidth / totals.bandwidth.all)}%) + : - + } + + ) + } }) - const byRequestsDescending = (a, b) => - requestsByCountry[b] - requestsByCountry[a] + // Protocols + const protocolRows = Object.keys(totals.requests.protocol).sort((a, b) => { + return totals.requests.protocol[b] - totals.requests.protocol[a] + }).map(protocol => { + const requests = totals.requests.protocol[protocol] - const continentData = Object.keys(ContinentsIndex).reduce((memo, continent) => { - const countries = getCountriesByContinent(continent) + return ( + + {protocol} + {formatNumber(requests)} ({formatPercent(requests / sumValues(totals.requests.protocol))}%) + + ) + }) + + // Regions + const regionRows = [] + + const continentsData = Object.keys(continents).reduce((memo, continent) => { + const localCountries = getCountriesByContinent(continent) memo[continent] = { - countries, - requests: getSum(requestsByCountry, countries), - bandwidth: getSum(bandwidthByCountry, countries) + countries: localCountries, + requests: sumKeyValues(totals.requests.country, localCountries), + bandwidth: sumKeyValues(totals.bandwidth.country, localCountries) } return memo }, {}) - const topContinents = Object.keys(continentData).sort((a, b) => { - return continentData[b].requests - continentData[a].requests + const topContinents = Object.keys(continentsData).sort((a, b) => { + return continentsData[b].requests - continentsData[a].requests }) topContinents.forEach(continent => { - const continentName = ContinentsIndex[continent] - const { countries, requests, bandwidth } = continentData[continent] + const continentName = continents[continent] + const continentData = continentsData[continent] - if (bandwidth !== 0) { + if (continentData.requests > this.state.minCountryRequests && continentData.bandwidth !== 0) { regionRows.push( {continentName} - {formatNumber(requests)} ({formatPercent(requests / totalRequests)}%) - {formatBytes(bandwidth)} ({formatPercent(bandwidth / totalBandwidth)}%) + {formatNumber(continentData.requests)} ({formatPercent(continentData.requests / totals.requests.all)}%) + {formatBytes(continentData.bandwidth)} ({formatPercent(continentData.bandwidth / totals.bandwidth.all)}%) ) - const topCountries = countries.sort(byRequestsDescending) + const topCountries = continentData.countries.sort((a, b) => { + return totals.requests.country[b] - totals.requests.country[a] + }) topCountries.forEach(country => { - const countryRequests = requestsByCountry[country] - const countryBandwidth = bandwidthByCountry[country] + const countryRequests = totals.requests.country[country] + const countryBandwidth = totals.bandwidth.country[country] - if (countryRequests > minRequests) { + if (countryRequests > this.state.minCountryRequests) { regionRows.push( - {CountriesIndex[country].name} - {formatNumber(countryRequests)} ({formatPercent(countryRequests / totalRequests)}%) - {formatBytes(countryBandwidth)} ({formatPercent(countryBandwidth / totalBandwidth)}%) + {countries[country].name} + {formatNumber(countryRequests)} ({formatPercent(countryRequests / totals.requests.all)}%) + {formatBytes(countryBandwidth)} ({formatPercent(countryBandwidth / totals.bandwidth.all)}%) ) } @@ -121,16 +131,67 @@ class Stats extends React.Component { return (
-

From {formatDate(sinceDate, 'MMM D')} to {formatDate(untilDate, 'MMM D')}, unpkg served {formatNumber(totalRequests)} requests to {formatNumber(uniqueVisitors)} unique visitors, {formatPercent(cachedRequests / totalRequests, 0)}% of which came from the cache (CDN). Over the same period, {formatPercent(errorRequests / totalRequests, 4)}% of requests resulted in server error (returned an HTTP status ≥ 500).

+

From {formatDate(since, 'MMM D')} to {formatDate(until, 'MMM D')} unpkg served {formatNumber(totals.requests.all)} requests and a total of {formatBytes(totals.bandwidth.all)} of data to {formatNumber(totals.uniques.all)} unique visitors, {formatPercent(totals.requests.cached / totals.requests.all, 0)}% of which were served from the cache.

-

By Region

+

Packages

- +

Include only packages that received at least requests. +

- + + + + + + + {packageRows} + +
NamePackageRequests (% of total)Bandwidth (% of total)
+ +

Protocols

+ + + + + + + + + + {protocolRows} + +
ProtocolRequests (% of total)
+ +

Regions

+ +

Include only countries that made at least requests. +

+ + + + + diff --git a/client/WindowSize.js b/client/WindowSize.js index 7279396..9d5db5c 100644 --- a/client/WindowSize.js +++ b/client/WindowSize.js @@ -1,6 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' -import { addEvent, removeEvent } from './DOMUtils' +import addEvent from './utils/addEvent' +import removeEvent from './utils/removeEvent' const ResizeEvent = 'resize' diff --git a/client/index.css b/client/index.css index c8bf606..c8c6b9d 100644 --- a/client/index.css +++ b/client/index.css @@ -11,9 +11,9 @@ body { padding: 5px 20px; } -@media all and (min-width: 660px) { +@media (min-width: 800px) { body { - padding: 50px 20px; + padding: 40px 20px 120px; } } @@ -24,94 +24,103 @@ a:visited { color: rebeccapurple; } -code { - background: #eee; +h1 { + font-size: 2em; +} +h2 { + font-size: 1.8em; +} +h3 { + font-size: 1.6em; +} + +ul { + padding-left: 25px; +} + +dd { + margin-left: 25px; } table { - border-color: black; - border-style: solid; - border-width: 0 0 1px 1px; + border: 1px solid black; + border: 0; } -table th, table td { +th { text-align: left; + background-color: #eee; +} +th, td { + padding: 5px; +} +th { + vertical-align: bottom; +} +td { vertical-align: top; - padding: 5px 7px; - border-color: black; - border-style: solid; - border-width: 1px 1px 0 0; -} - -.continent-row { - font-weight: bold; -} -.country-name { - padding-left: 2em; -} - -.table-filter { - display: block; - text-align: right; - color: #666; - margin-bottom: 1em; -} -.table-filter input { - font-size: 1em; - text-align: right; - max-width: 100px; - color: #999; } .wrapper { - max-width: 600px; + max-width: 700px; margin: 0 auto; } -h1, h2, h3, header nav { - font-family: Futura, Helvetica, sans-serif; - text-transform: uppercase; -} -h3 { - margin-top: 2em; -} -header h1 { - font-size: 4em; - line-height: 1; - text-align: center; - letter-spacing: 0.1em; -} -header nav { - margin-bottom: 4em; -} -header nav a:link, -header nav a:visited { - color: black; -} - -.underlist { - list-style-type: none; - padding: 0; +.layout-title { margin: 0; + text-transform: uppercase; + text-align: center; + font-size: 5em; +} +.layout-nav { + margin: 0 0 3em; +} +.layout-nav-list { + margin: 0; + padding: 0; display: flex; justify-content: center; } -.underlist li { - margin: 0 5px; - padding: 0 5px; - +.layout-nav-list li { flex-basis: auto; + list-style-type: none; + display: inline-block; + font-size: 1.1em; + margin: 0 10px; } -.underlist a:link { +.layout-nav-list li a:link { text-decoration: none; } -.underlist-underline { +.layout-nav-list li a:link, +.layout-nav-list li a:visited { + color: black; +} +.layout-nav-underline { height: 4px; background-color: black; position: absolute; left: 0; } +.home-example { + text-align: center; + background-color: #eee; + margin: 2em 0; + padding: 5px 0; +} + +.table-filter { + font-size: 0.8em; + text-align: right; +} + +.regions-table .continent-row { + font-weight: bold; +} +.regions-table .country-row td.country-name { + padding-left: 20px; +} + .about-logos { margin: 2em 0; display: flex; @@ -125,10 +134,3 @@ header nav a:visited { .about-logo img { max-width: 60%; } - -footer { - margin-top: 40px; - border-top: 1px solid black; - font-size: 0.8em; - text-align: right; -} diff --git a/client/utils/addEvent.js b/client/utils/addEvent.js new file mode 100644 index 0000000..775b6d8 --- /dev/null +++ b/client/utils/addEvent.js @@ -0,0 +1,9 @@ +const addEvent = (node, type, handler) => { + if (node.addEventListener) { + node.addEventListener(type, handler, false) + } else if (node.attachEvent) { + node.attachEvent('on' + type, handler) + } +} + +export default addEvent diff --git a/client/utils/formatNumber.js b/client/utils/formatNumber.js new file mode 100644 index 0000000..6c69ad3 --- /dev/null +++ b/client/utils/formatNumber.js @@ -0,0 +1,11 @@ +const formatNumber = (n) => { + const digits = String(n).split('') + const groups = [] + + while (digits.length) + groups.unshift(digits.splice(-3).join('')) + + return groups.join(',') +} + +export default formatNumber diff --git a/client/utils/formatPercent.js b/client/utils/formatPercent.js new file mode 100644 index 0000000..2e6f4f0 --- /dev/null +++ b/client/utils/formatPercent.js @@ -0,0 +1,4 @@ +const formatPercent = (n, fixed = 1) => + String((n.toPrecision(2) * 100).toFixed(fixed)) + +export default formatPercent diff --git a/client/utils/parseNumber.js b/client/utils/parseNumber.js new file mode 100644 index 0000000..089bda7 --- /dev/null +++ b/client/utils/parseNumber.js @@ -0,0 +1,4 @@ +const parseNumber = (s) => + parseInt(s.replace(/,/g, ''), 10) || 0 + +export default parseNumber diff --git a/client/utils/removeEvent.js b/client/utils/removeEvent.js new file mode 100644 index 0000000..c2c176f --- /dev/null +++ b/client/utils/removeEvent.js @@ -0,0 +1,9 @@ +const removeEvent = (node, type, handler) => { + if (node.removeEventListener) { + node.removeEventListener(type, handler, false) + } else if (node.detachEvent) { + node.detachEvent('on' + type, handler) + } +} + +export default removeEvent diff --git a/package.json b/package.json index 6f0fd46..1506e15 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "date-fns": "^1.28.1", "express": "^4.15.2", "gunzip-maybe": "^1.4.0", - "http-client": "^4.3.1", "invariant": "^2.2.2", "isomorphic-fetch": "^2.2.1", "mime": "^1.3.6", @@ -35,7 +34,8 @@ "sri-toolbox": "^0.2.0", "tar-fs": "^1.15.2", "throng": "^4.0.0", - "validate-npm-package-name": "^3.0.0" + "validate-npm-package-name": "^3.0.0", + "warning": "^3.0.0" }, "devDependencies": { "autoprefixer": "6.7.2", diff --git a/public/index.html b/public/index.html index 1b86c00..dc463cd 100644 --- a/public/index.html +++ b/public/index.html @@ -3,10 +3,9 @@ - - + + -
Region Requests (% of total) Bandwidth (% of total)