This commit is contained in:
Родитель
8517fd240e
Коммит
41041d0b3d
|
@ -51,6 +51,12 @@
|
|||
"integrity": "sha1-G44zthqMCcvh+FEzBxuqDb+fpxo=",
|
||||
"dev": true
|
||||
},
|
||||
"@types/cytoscape": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/cytoscape/-/cytoscape-3.4.3.tgz",
|
||||
"integrity": "sha1-i5NTFU3IlSMc00TtHH7/LRORwQM=",
|
||||
"dev": true
|
||||
},
|
||||
"@types/events": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/events/-/events-3.0.0.tgz",
|
||||
|
@ -99,6 +105,12 @@
|
|||
"hoist-non-react-statics": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"@types/js-base64": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/js-base64/-/js-base64-2.3.1.tgz",
|
||||
"integrity": "sha1-w58U8SlAij2WoRBaZQ2LK27rQWg=",
|
||||
"dev": true
|
||||
},
|
||||
"@types/minimatch": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||
|
@ -111,12 +123,27 @@
|
|||
"integrity": "sha1-MV1XDMtWxTRS/4Y4c432BybVtuo=",
|
||||
"dev": true
|
||||
},
|
||||
"@types/msgpack-lite": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/msgpack-lite/-/msgpack-lite-0.1.6.tgz",
|
||||
"integrity": "sha1-J+Kn7qRRTwhO1Pm1P49j5ttNbVA=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "12.0.4",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/node/-/node-12.0.4.tgz",
|
||||
"integrity": "sha1-RoMhgxFckEQQwnXjTPlAOZKZnDI=",
|
||||
"dev": true
|
||||
},
|
||||
"@types/pako": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/pako/-/pako-1.0.1.tgz",
|
||||
"integrity": "sha1-M7I388mv9E0Pgv5jrP+ko2XvSmE=",
|
||||
"dev": true
|
||||
},
|
||||
"@types/prop-types": {
|
||||
"version": "15.7.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/prop-types/-/prop-types-15.7.1.tgz",
|
||||
|
@ -175,6 +202,21 @@
|
|||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-tabs": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/react-tabs/-/react-tabs-2.3.1.tgz",
|
||||
"integrity": "sha1-YtMyJmesIoxND582rJ+GmIwLOXE=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/sigmajs": {
|
||||
"version": "1.0.27",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/sigmajs/-/sigmajs-1.0.27.tgz",
|
||||
"integrity": "sha1-NoA1/IS58pVNqTLuaMrG50tEd6w=",
|
||||
"dev": true
|
||||
},
|
||||
"@types/tapable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/@types/tapable/-/tapable-1.0.4.tgz",
|
||||
|
@ -1390,6 +1432,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"classnames": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/classnames/-/classnames-2.2.6.tgz",
|
||||
"integrity": "sha1-Q5Nb/90pHzJtrQogUwmzjQD2UM4=",
|
||||
"dev": true
|
||||
},
|
||||
"clean-css": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/clean-css/-/clean-css-4.2.1.tgz",
|
||||
|
@ -1613,6 +1661,26 @@
|
|||
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=",
|
||||
"dev": true
|
||||
},
|
||||
"copy-webpack-plugin": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/copy-webpack-plugin/-/copy-webpack-plugin-5.0.3.tgz",
|
||||
"integrity": "sha1-IXnjyP1p8Tr+dNoziJbx8BqHW1w=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cacache": "^11.3.2",
|
||||
"find-cache-dir": "^2.1.0",
|
||||
"glob-parent": "^3.1.0",
|
||||
"globby": "^7.1.1",
|
||||
"is-glob": "^4.0.1",
|
||||
"loader-utils": "^1.2.3",
|
||||
"minimatch": "^3.0.4",
|
||||
"normalize-path": "^3.0.0",
|
||||
"p-limit": "^2.2.0",
|
||||
"schema-utils": "^1.0.0",
|
||||
"serialize-javascript": "^1.7.0",
|
||||
"webpack-log": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"core-js": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/core-js/-/core-js-1.2.7.tgz",
|
||||
|
@ -1625,6 +1693,15 @@
|
|||
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
|
||||
"dev": true
|
||||
},
|
||||
"cose-base": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/cose-base/-/cose-base-1.0.0.tgz",
|
||||
"integrity": "sha1-pEGX6DWzCytb9Kt+ndBuYgHIeYw=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"layout-base": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"create-ecdh": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/create-ecdh/-/create-ecdh-4.0.3.tgz",
|
||||
|
@ -1779,6 +1856,45 @@
|
|||
"integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=",
|
||||
"dev": true
|
||||
},
|
||||
"cytoscape": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/cytoscape/-/cytoscape-3.7.0.tgz",
|
||||
"integrity": "sha1-+AAMoo3MD1FbStaRGXzzlo+SvS4=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"heap": "^0.2.6",
|
||||
"lodash.debounce": "^4.0.8"
|
||||
}
|
||||
},
|
||||
"cytoscape-dagre": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/cytoscape-dagre/-/cytoscape-dagre-2.2.2.tgz",
|
||||
"integrity": "sha1-XzKoXAuoNfFn7+5THfnomsWP9BE=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"dagre": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"cytoscape-fcose": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/cytoscape-fcose/-/cytoscape-fcose-1.0.0.tgz",
|
||||
"integrity": "sha1-/ZFReo0fhKpIfpX/o0tSWgrhmhs=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cose-base": "^1.0.0",
|
||||
"numeric": "1.2.6"
|
||||
}
|
||||
},
|
||||
"dagre": {
|
||||
"version": "0.8.4",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/dagre/-/dagre-0.8.4.tgz",
|
||||
"integrity": "sha1-Jrn7j3vcYMYRCgRYw3UmGDZ4YGE=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"graphlib": "^2.1.7",
|
||||
"lodash": "^4.17.4"
|
||||
}
|
||||
},
|
||||
"dashdash": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/dashdash/-/dashdash-1.14.1.tgz",
|
||||
|
@ -1997,6 +2113,32 @@
|
|||
"randombytes": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"dir-glob": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/dir-glob/-/dir-glob-2.2.2.tgz",
|
||||
"integrity": "sha1-+gnwaUFTyJGLGLoN6vrpR2n8UMQ=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"path-type": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"path-type": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/path-type/-/path-type-3.0.0.tgz",
|
||||
"integrity": "sha1-zvMdyOCho7sNEFwM2Xzzv0f0428=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"pify": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"pify": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/pify/-/pify-3.0.0.tgz",
|
||||
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"dns-equal": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/dns-equal/-/dns-equal-1.0.0.tgz",
|
||||
|
@ -2266,6 +2408,12 @@
|
|||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
|
||||
"dev": true
|
||||
},
|
||||
"event-lite": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/event-lite/-/event-lite-0.1.2.tgz",
|
||||
"integrity": "sha1-g4o+D93e+MyQ8SgAbI5VpOTkwRs=",
|
||||
"dev": true
|
||||
},
|
||||
"eventemitter3": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/eventemitter3/-/eventemitter3-3.1.2.tgz",
|
||||
|
@ -2790,6 +2938,12 @@
|
|||
"is-buffer": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"flexboxgrid": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/flexboxgrid/-/flexboxgrid-6.3.1.tgz",
|
||||
"integrity": "sha1-6ZiYr8B7cEdyK7galYpfuk1OIP0=",
|
||||
"dev": true
|
||||
},
|
||||
"flush-write-stream": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
|
||||
|
@ -3630,6 +3784,28 @@
|
|||
"which": "^1.2.14"
|
||||
}
|
||||
},
|
||||
"globby": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/globby/-/globby-7.1.1.tgz",
|
||||
"integrity": "sha1-+yzP+UAfhgCUXfral0QMypcrhoA=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"array-union": "^1.0.1",
|
||||
"dir-glob": "^2.0.0",
|
||||
"glob": "^7.1.2",
|
||||
"ignore": "^3.3.5",
|
||||
"pify": "^3.0.0",
|
||||
"slash": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"pify": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/pify/-/pify-3.0.0.tgz",
|
||||
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"globule": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/globule/-/globule-1.2.1.tgz",
|
||||
|
@ -3647,6 +3823,15 @@
|
|||
"integrity": "sha1-/7cD4QZuig7qpMi4C6klPu77+wA=",
|
||||
"dev": true
|
||||
},
|
||||
"graphlib": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/graphlib/-/graphlib-2.1.7.tgz",
|
||||
"integrity": "sha1-tqafn0S9neOWPOaASi/J5z2Grsw=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lodash": "^4.17.5"
|
||||
}
|
||||
},
|
||||
"growl": {
|
||||
"version": "1.10.5",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/growl/-/growl-1.10.5.tgz",
|
||||
|
@ -3809,6 +3994,12 @@
|
|||
"integrity": "sha1-hK5l+n6vsWX922FWauFLrwVmTw8=",
|
||||
"dev": true
|
||||
},
|
||||
"heap": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/heap/-/heap-0.2.6.tgz",
|
||||
"integrity": "sha1-CH4fELBGky/IWU3Z5tN4r8nR5aw=",
|
||||
"dev": true
|
||||
},
|
||||
"history": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/history/-/history-4.9.0.tgz",
|
||||
|
@ -4179,6 +4370,12 @@
|
|||
"integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=",
|
||||
"dev": true
|
||||
},
|
||||
"ignore": {
|
||||
"version": "3.3.10",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/ignore/-/ignore-3.3.10.tgz",
|
||||
"integrity": "sha1-Cpf7h2mG6AgcYxFg+PnziRV/AEM=",
|
||||
"dev": true
|
||||
},
|
||||
"import-local": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/import-local/-/import-local-2.0.0.tgz",
|
||||
|
@ -4244,6 +4441,12 @@
|
|||
"integrity": "sha1-7uJfVtscnsYIXgwid4CD9Zar+Sc=",
|
||||
"dev": true
|
||||
},
|
||||
"int64-buffer": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/int64-buffer/-/int64-buffer-0.1.10.tgz",
|
||||
"integrity": "sha1-J3siiofZWtd30HwTgyAiQGpHNCM=",
|
||||
"dev": true
|
||||
},
|
||||
"internal-ip": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/internal-ip/-/internal-ip-4.3.0.tgz",
|
||||
|
@ -4662,6 +4865,12 @@
|
|||
"integrity": "sha1-ARRrNqYhjmTljzqNZt5df8b20FE=",
|
||||
"dev": true
|
||||
},
|
||||
"layout-base": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/layout-base/-/layout-base-1.0.0.tgz",
|
||||
"integrity": "sha1-s1VjO+V/+ifZWd0kcD4Yfvg828M=",
|
||||
"dev": true
|
||||
},
|
||||
"lcid": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/lcid/-/lcid-2.0.0.tgz",
|
||||
|
@ -4725,6 +4934,12 @@
|
|||
"integrity": "sha1-s56mIp72B+zYniyN8SU2iRysm40=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.tail": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/lodash.tail/-/lodash.tail-4.1.1.tgz",
|
||||
|
@ -5099,6 +5314,18 @@
|
|||
"integrity": "sha1-MKWGTrPrsKZvLr5tcnrwagnYbgo=",
|
||||
"dev": true
|
||||
},
|
||||
"msgpack-lite": {
|
||||
"version": "0.1.26",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/msgpack-lite/-/msgpack-lite-0.1.26.tgz",
|
||||
"integrity": "sha1-3TxQsm8FnyXn7e42REGDWOKprYk=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"event-lite": "^0.1.1",
|
||||
"ieee754": "^1.1.8",
|
||||
"int64-buffer": "^0.1.9",
|
||||
"isarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"multicast-dns": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/multicast-dns/-/multicast-dns-6.2.3.tgz",
|
||||
|
@ -5422,6 +5649,12 @@
|
|||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
|
||||
"dev": true
|
||||
},
|
||||
"numeric": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/numeric/-/numeric-1.2.6.tgz",
|
||||
"integrity": "sha1-dlsCvvl5iPz4gNTrPza4D6MTNao=",
|
||||
"dev": true
|
||||
},
|
||||
"oauth-sign": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/oauth-sign/-/oauth-sign-0.9.0.tgz",
|
||||
|
@ -6267,6 +6500,16 @@
|
|||
"tiny-warning": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"react-tabs": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/react-tabs/-/react-tabs-3.0.0.tgz",
|
||||
"integrity": "sha1-YDEaF8dV62qpszEBI+Z9tCFgUSc=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"classnames": "^2.2.0",
|
||||
"prop-types": "^15.5.0"
|
||||
}
|
||||
},
|
||||
"read-pkg": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/read-pkg/-/read-pkg-1.1.0.tgz",
|
||||
|
@ -7149,12 +7392,24 @@
|
|||
"integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
|
||||
"dev": true
|
||||
},
|
||||
"sigma": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/sigma/-/sigma-1.2.1.tgz",
|
||||
"integrity": "sha1-Lr898pcXFa/kmtDAiUXFdLtsV3E=",
|
||||
"dev": true
|
||||
},
|
||||
"signal-exit": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/signal-exit/-/signal-exit-3.0.2.tgz",
|
||||
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
|
||||
"dev": true
|
||||
},
|
||||
"slash": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/slash/-/slash-1.0.0.tgz",
|
||||
"integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=",
|
||||
"dev": true
|
||||
},
|
||||
"snapdragon": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://watchmixer.pkgs.visualstudio.com/_packaging/mixer/npm/registry/snapdragon/-/snapdragon-0.8.2.tgz",
|
||||
|
|
22
package.json
22
package.json
|
@ -4,10 +4,10 @@
|
|||
"description": "Tool to compare webpack bundle files",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"fmt": "prettier --write \"src/**/*.{json,ts}\" && npm run test:lint -- --fix",
|
||||
"fmt": "prettier --write \"src/**/*.{tsx,ts}\" && npm run test:lint -- --fix",
|
||||
"test": "mocha --opts mocha.opts && npm run test:fmt && npm run test:lint",
|
||||
"test:fmt": "prettier --list-different \"src/**/*.{js,json,ts}\" || echo \"Run npm run fmt to fix formatting on these files\"",
|
||||
"test:lint": "tslint --project tsconfig.json \"src/**/*.ts\"",
|
||||
"test:fmt": "prettier --list-different \"src/**/*.{tsx,ts}\" || echo \"Run npm run fmt to fix formatting on these files\"",
|
||||
"test:lint": "tslint --project tsconfig.json \"src/**/*.{ts,tsx}\"",
|
||||
"prepare": "tsc",
|
||||
"start": "webpack-dev-server"
|
||||
},
|
||||
|
@ -31,37 +31,53 @@
|
|||
"devDependencies": {
|
||||
"@mixer/retrieval": "^2.0.2",
|
||||
"@types/chai": "^4.1.7",
|
||||
"@types/cytoscape": "^3.4.3",
|
||||
"@types/filesize": "^4.1.0",
|
||||
"@types/fixed-data-table-2": "^0.8.3",
|
||||
"@types/js-base64": "^2.3.1",
|
||||
"@types/mocha": "^5.2.7",
|
||||
"@types/msgpack-lite": "^0.1.6",
|
||||
"@types/node": "^12.0.4",
|
||||
"@types/pako": "^1.0.1",
|
||||
"@types/react": "^16.8.19",
|
||||
"@types/react-dom": "^16.8.4",
|
||||
"@types/react-redux": "^7.0.9",
|
||||
"@types/react-router-dom": "^4.3.3",
|
||||
"@types/react-tabs": "^2.3.1",
|
||||
"@types/sigmajs": "^1.0.27",
|
||||
"@types/webpack": "^4.4.32",
|
||||
"chai": "^4.2.0",
|
||||
"copy-webpack-plugin": "^5.0.3",
|
||||
"css-loader": "^2.1.1",
|
||||
"cytoscape": "^3.7.0",
|
||||
"cytoscape-dagre": "^2.2.2",
|
||||
"cytoscape-fcose": "^1.0.0",
|
||||
"dayjs": "^1.8.14",
|
||||
"filesize": "^4.1.2",
|
||||
"fixed-data-table-2": "^0.8.26",
|
||||
"flexboxgrid": "^6.3.1",
|
||||
"fs-extra": "^8.0.1",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"js-base64": "^2.5.1",
|
||||
"mocha": "^6.1.4",
|
||||
"msgpack-lite": "^0.1.26",
|
||||
"node-sass": "^4.12.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pako": "^1.0.10",
|
||||
"prettier": "^1.17.1",
|
||||
"react": "^16.8.6",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-icons": "^3.7.0",
|
||||
"react-redux": "^7.0.3",
|
||||
"react-router-dom": "^5.0.0",
|
||||
"react-tabs": "^3.0.0",
|
||||
"redux": "^4.0.1",
|
||||
"redux-devtools-extension": "^2.13.8",
|
||||
"redux-observable": "^1.1.0",
|
||||
"reselect": "^4.0.0",
|
||||
"rxjs": "^6.5.2",
|
||||
"sass-loader": "^7.1.0",
|
||||
"sigma": "^1.2.1",
|
||||
"style-loader": "^0.23.1",
|
||||
"ts-loader": "^6.0.2",
|
||||
"ts-node": "^8.2.0",
|
||||
|
|
|
@ -11,3 +11,7 @@ To develop against the UI:
|
|||
1. Create a folder called "public/samples", and place JSON files in there.
|
||||
2. Set the `WBC_FILES` environment variable to a comma-delimited list of the filenames you placed in there.
|
||||
3. Running the webpack dev server via `npm start` will now serve the files you have placed in there.
|
||||
|
||||
### Limitations
|
||||
|
||||
The main degraded area in our reporting deals with concatenated modules. Essentially, webpack gives us a list of modules and concatenated modules at the top level of their stats output. The concatenated modules are nested inside the parent module, and will contain the full set of module information. However, they don't have module IDs, and when Webpack reports that a given module was imported, it only reports the top-level concatenation. So we can drill into single modules and concatenated bundles fairly well, but we can't cross-reference imports cleanly.
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
.wrapper {
|
||||
font-size: 0.9em;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
:global .row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.icons {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
font-size: 2.5em;
|
||||
|
||||
a {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
import { Retrieval, RetrievalState, shouldAttempt } from '@mixer/retrieval';
|
||||
import * as filesize from 'filesize';
|
||||
import * as React from 'react';
|
||||
import { IoLogoGithub, IoLogoNpm } from 'react-icons/io';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchBundlephobiaData } from '../redux/actions';
|
||||
import { getBundlephobiaData, IAppState } from '../redux/reducer';
|
||||
import { IBundlephobiaStats } from '../redux/services/bundlephobia-api';
|
||||
import * as styles from './bundlephobia-stats.component.scss';
|
||||
import { Errors } from './errors.component';
|
||||
import { BasePanel } from './panels/base-panel.component';
|
||||
import { BooleanPanel } from './panels/boolean-panel.component';
|
||||
import { CounterPanel } from './panels/counter-panel.component';
|
||||
import { Placeholder } from './placeholder.component';
|
||||
import { IndefiniteProgressBar } from './progress-bar.component';
|
||||
import { color } from './util';
|
||||
|
||||
interface IProps {
|
||||
name: string;
|
||||
stats: Retrieval<IBundlephobiaStats>;
|
||||
retrieve(name: string): void;
|
||||
}
|
||||
|
||||
class BundlephobiaStatsComponent extends React.PureComponent<IProps> {
|
||||
public componentDidMount() {
|
||||
if (shouldAttempt(this.props.stats)) {
|
||||
this.props.retrieve(this.props.name);
|
||||
}
|
||||
}
|
||||
public componentDidUpdate(prevProps: IProps) {
|
||||
if (this.props.name !== prevProps.name && shouldAttempt(this.props.stats)) {
|
||||
this.props.retrieve(this.props.name);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const stats = this.props.stats;
|
||||
|
||||
switch (stats.state) {
|
||||
case RetrievalState.Errored:
|
||||
if (stats.error.statusCode === 404) {
|
||||
return (
|
||||
<Placeholder>
|
||||
This package does not appear to be published on the npm registry.
|
||||
</Placeholder>
|
||||
);
|
||||
}
|
||||
|
||||
return <Errors errors={stats.error} />;
|
||||
case RetrievalState.Succeeded:
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<p>
|
||||
The following information was retrieved from the latest version of this package. You
|
||||
may be using an older version of the package in your build--Webpack stats do not tell
|
||||
us your package version.{' '}
|
||||
<a href={`https://bundlephobia.com/result?p=${this.props.name}`} target="_blank">
|
||||
View this package on Bundlephobia
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</p>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-xs-6">
|
||||
<BasePanel
|
||||
value={stats.value.version}
|
||||
color={color.blue}
|
||||
title={'Latest Version'}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-xs-6">
|
||||
<CounterPanel value={stats.value.dependencyCount} title={'Dependencies'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-xs-6">
|
||||
<CounterPanel
|
||||
value={stats.value.size}
|
||||
title={'Minified Sized'}
|
||||
formatter={filesize}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-xs-6">
|
||||
<CounterPanel
|
||||
value={stats.value.gzip}
|
||||
title={'Gzipped Sized'}
|
||||
formatter={filesize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-xs-6">
|
||||
<BooleanPanel
|
||||
value={stats.value.hasJSModule || stats.value.hasJSNext}
|
||||
title={'Tree-Shakable'}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-xs-6">
|
||||
<BooleanPanel value={!stats.value.hasSideEffects} title={'Side-Effect Free'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.icons}>
|
||||
{stats.value.repository && (
|
||||
<a href={stats.value.repository} target="_blank">
|
||||
<IoLogoGithub />
|
||||
</a>
|
||||
)}
|
||||
<a href={`https://www.npmjs.com/package/${this.props.name}`} target="_blank">
|
||||
<IoLogoNpm />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <IndefiniteProgressBar />;
|
||||
}
|
||||
}
|
||||
}
|
||||
export const BundlephobiaStats = connect(
|
||||
(state: IAppState, { name }: { name: string }) => ({
|
||||
stats: getBundlephobiaData(state, name),
|
||||
}),
|
||||
dispatch => ({
|
||||
retrieve(name: string) {
|
||||
dispatch(fetchBundlephobiaData.request({ name }));
|
||||
},
|
||||
}),
|
||||
)(BundlephobiaStatsComponent);
|
|
@ -0,0 +1,89 @@
|
|||
import * as filesize from 'filesize';
|
||||
import * as React from 'react';
|
||||
import { Stats } from 'webpack';
|
||||
import {
|
||||
compareNodeModules,
|
||||
getNodeModuleCount,
|
||||
getNodeModuleSize,
|
||||
getTotalModuleCount,
|
||||
getTreeShakablePercent,
|
||||
} from '../stat-reducers';
|
||||
import { ChangedModuleGraph } from './graphs/changed-module-graph.component';
|
||||
import { ModuleTable } from './module-table.component';
|
||||
import { CounterPanel } from './panels/counter-panel.component';
|
||||
import { NodeModulePanel } from './panels/node-module-panel.component';
|
||||
import { PanelArrangement } from './panels/panel-arrangement.component';
|
||||
import { formatPercent } from './util';
|
||||
import { TreeShakeHint } from './hints/hints.component';
|
||||
|
||||
export const DashboardChunkPage: React.FC<{
|
||||
chunk: number;
|
||||
first: Stats.ToJsonOutput;
|
||||
last: Stats.ToJsonOutput;
|
||||
}> = ({ first, last, chunk }) => {
|
||||
const firstObj = first.chunks!.find(c => c.id === chunk);
|
||||
const lastSize = last.chunks!.find(c => c.id === chunk);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row" style={{ padding: 1 }}>
|
||||
<div className="col-xs-12 col-sm-6">
|
||||
<h2>Chunk Stats</h2>
|
||||
<PanelArrangement>
|
||||
<CounterPanel
|
||||
title="Total Size"
|
||||
value={lastSize ? lastSize.size : 0}
|
||||
oldValue={firstObj ? firstObj.size : 0}
|
||||
formatter={filesize}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Node Modules"
|
||||
value={getTotalModuleCount(last, chunk)}
|
||||
oldValue={getTotalModuleCount(first, chunk)}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Node Modules"
|
||||
value={getTotalModuleCount(last, chunk)}
|
||||
oldValue={getTotalModuleCount(first, chunk)}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Node Module Size"
|
||||
value={getNodeModuleSize(last, chunk)}
|
||||
oldValue={getNodeModuleSize(first, chunk)}
|
||||
formatter={filesize}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Tree-Shaken Node Modules"
|
||||
hint={TreeShakeHint}
|
||||
value={getTreeShakablePercent(last, chunk)}
|
||||
oldValue={getTreeShakablePercent(first, chunk)}
|
||||
formatter={formatPercent}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Node Module Count"
|
||||
value={getNodeModuleCount(last, chunk)}
|
||||
oldValue={getNodeModuleCount(first, chunk)}
|
||||
/>
|
||||
</PanelArrangement>
|
||||
|
||||
<h2>Node Modules</h2>
|
||||
<PanelArrangement>
|
||||
{compareNodeModules(first, last)
|
||||
.sort((a, b) => (b.new ? b.new.totalSize : 0) - (a.new ? a.new.totalSize : 0))
|
||||
.map(comparison => (
|
||||
<NodeModulePanel comparison={comparison} key={comparison.name} inChunk={chunk} />
|
||||
))}
|
||||
</PanelArrangement>
|
||||
</div>
|
||||
<div className="col-xs-12 col-sm-6">
|
||||
<h2>Module List</h2>
|
||||
<ModuleTable first={first} last={last} inChunk={chunk} />
|
||||
</div>
|
||||
</div>
|
||||
<h2>
|
||||
Bundle Tree<small>Only changed files, and their parents, are displayed.</small>
|
||||
</h2>
|
||||
<ChangedModuleGraph previous={first} stats={last} chunkId={chunk} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,29 +1,35 @@
|
|||
import * as React from 'react';
|
||||
import {
|
||||
IoIosCalendar,
|
||||
IoIosArrowRoundForward,
|
||||
IoIosWarning,
|
||||
IoIosCloseCircleOutline,
|
||||
IoIosArrowBack,
|
||||
} from 'react-icons/io';
|
||||
import { IconType } from 'react-icons';
|
||||
import { Stats } from 'webpack';
|
||||
import { Link } from 'react-router-dom';
|
||||
import * as styles from './dashboard-header.component.scss';
|
||||
import * as dayjs from 'dayjs';
|
||||
import * as React from 'react';
|
||||
import { IconType } from 'react-icons';
|
||||
import {
|
||||
IoIosArrowBack,
|
||||
IoIosArrowRoundForward,
|
||||
IoIosCalendar,
|
||||
IoIosCloseCircleOutline,
|
||||
IoIosWarning,
|
||||
} from 'react-icons/io';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import { Stats } from 'webpack';
|
||||
import * as styles from './dashboard-header.component.scss';
|
||||
|
||||
const DashboardHeaderItem: React.FC<{ icon: IconType; href?: string }> = props => {
|
||||
const DashboardHeaderItem: React.FC<{ icon: IconType; href?: string | (() => void) }> = props => {
|
||||
const inner = (
|
||||
<>
|
||||
<props.icon className={styles.icon} /> {props.children}
|
||||
</>
|
||||
);
|
||||
return props.href ? (
|
||||
return typeof props.href === 'string' ? (
|
||||
<Link to={props.href} className={styles.item}>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={styles.item}>{inner}</span>
|
||||
<span
|
||||
className={styles.item}
|
||||
onClick={props.href}
|
||||
style={{ cursor: props.href ? 'pointer' : undefined }}
|
||||
>
|
||||
{inner}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -68,8 +74,8 @@ export const DashboardErrorCount: React.FC<{ last: Stats.ToJsonOutput }> = ({ la
|
|||
</DashboardHeaderItem>
|
||||
);
|
||||
|
||||
export const DashboardClose: React.FC = () => (
|
||||
<DashboardHeaderItem icon={IoIosArrowBack} href="/">
|
||||
Back to URLs
|
||||
export const DashboardClose = withRouter(props => (
|
||||
<DashboardHeaderItem icon={IoIosArrowBack} href={() => props.history.goBack()}>
|
||||
Back
|
||||
</DashboardHeaderItem>
|
||||
);
|
||||
));
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import * as React from 'react';
|
||||
import { Stats } from 'webpack';
|
||||
import { getDirectImportsOfNodeModule } from '../stat-reducers';
|
||||
import { BundlephobiaStats } from './bundlephobia-stats.component';
|
||||
import { DependentGraph } from './graphs/dependent-graph.component';
|
||||
import { ImportsList, IssuerTree } from './imports-list.component';
|
||||
import { ImportsStatsRow } from './imports-stats-row.component';
|
||||
|
||||
export const DashboardNodeModulePage: React.FC<{
|
||||
name: string;
|
||||
first: Stats.ToJsonOutput;
|
||||
last: Stats.ToJsonOutput;
|
||||
}> = ({ first, last, name }) => {
|
||||
const firstImports = getDirectImportsOfNodeModule(first, name);
|
||||
const lastImports = getDirectImportsOfNodeModule(last, name);
|
||||
return (
|
||||
<>
|
||||
<div className="row" style={{ padding: 1 }}>
|
||||
<div className="col-xs-12 col-sm-8">
|
||||
<ImportsStatsRow oldTargets={firstImports} newTargets={lastImports} />
|
||||
|
||||
<h2>Imports of "{name}"</h2>
|
||||
<ImportsList targets={lastImports} />
|
||||
</div>
|
||||
<div className="col-xs-12 col-sm-4">
|
||||
<h2>Bundlephobia Stats</h2>
|
||||
<BundlephobiaStats name={name} />
|
||||
|
||||
<h2>
|
||||
Issuer Tree
|
||||
<small>The shortest from the entrypoint to this module</small>
|
||||
</h2>
|
||||
<IssuerTree targets={lastImports} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
Import Tree<small>A graph of all files that depend on the module.</small>
|
||||
</h2>
|
||||
<DependentGraph previous={first} stats={last} name={name} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,29 +1,112 @@
|
|||
import * as filesize from 'filesize';
|
||||
import * as React from 'react';
|
||||
import { CounterPanel } from './stat-panels.component';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
||||
import { Stats } from 'webpack';
|
||||
import { getTotalChunkSize, getNodeModuleCount } from '../stat-reducers';
|
||||
import { ModulePlot } from './module-plot.component';
|
||||
import {
|
||||
getAverageChunkSize,
|
||||
getEntryChunkSize,
|
||||
getNodeModuleCount,
|
||||
getNodeModuleSize,
|
||||
getTotalChunkSize,
|
||||
getTotalModuleCount,
|
||||
getTreeShakablePercent,
|
||||
} from '../stat-reducers';
|
||||
import { ModuleTable } from './module-table.component';
|
||||
import { OverviewSuggestions } from './overview-suggestions';
|
||||
import { CounterPanel } from './panels/counter-panel.component';
|
||||
import { PanelArrangement } from './panels/panel-arrangement.component';
|
||||
import { formatDuration, formatPercent } from './util';
|
||||
|
||||
import * as tabStyles from './dashboard-tabs.component.scss';
|
||||
import { ChunkGraph } from './graphs/chunk-graph.component';
|
||||
import { TreeShakeHint, WhatIsAnEntrypoint, AverageChunkSize, TotalModules } from './hints/hints.component';
|
||||
|
||||
export const DashboardOverview: React.FC<{
|
||||
first: Stats.ToJsonOutput;
|
||||
last: Stats.ToJsonOutput;
|
||||
}> = ({ first, last }) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<div style={{ flexBasis: 300, marginRight: 16 }}>
|
||||
<CounterPanel
|
||||
title="Total Size"
|
||||
value={getTotalChunkSize(last)}
|
||||
oldValue={getTotalChunkSize(first)}
|
||||
filesize
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Node Modules"
|
||||
value={getNodeModuleCount(last)}
|
||||
oldValue={getNodeModuleCount(first)}
|
||||
/>
|
||||
<div className="row" style={{ padding: 1 }}>
|
||||
<div className="col-xs-12 col-sm-6">
|
||||
<h2>Suggestions</h2>
|
||||
<OverviewSuggestions first={first} last={last} />
|
||||
|
||||
<h2 style={{ marginTop: 64 }}>Stats</h2>
|
||||
<PanelArrangement>
|
||||
<CounterPanel
|
||||
title="Total Size"
|
||||
value={getTotalChunkSize(last)}
|
||||
oldValue={getTotalChunkSize(first)}
|
||||
formatter={filesize}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Download Time (3 Mbps)"
|
||||
value={(getTotalChunkSize(last) / (3500 * 128)) * 1000}
|
||||
oldValue={(getTotalChunkSize(first) / (3500 * 128)) * 1000}
|
||||
formatter={formatDuration}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Avg. Chunk Size"
|
||||
value={getAverageChunkSize(last)}
|
||||
oldValue={getAverageChunkSize(first)}
|
||||
hint={AverageChunkSize}
|
||||
formatter={filesize}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Entrypoint Size"
|
||||
hint={WhatIsAnEntrypoint}
|
||||
value={getEntryChunkSize(last)}
|
||||
oldValue={getEntryChunkSize(first)}
|
||||
formatter={filesize}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Total Modules"
|
||||
hint={TotalModules}
|
||||
value={getTotalModuleCount(last)}
|
||||
oldValue={getTotalModuleCount(first)}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Node Module Size"
|
||||
value={getNodeModuleSize(last)}
|
||||
oldValue={getNodeModuleSize(first)}
|
||||
formatter={filesize}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Tree-Shaken Node Modules"
|
||||
hint={TreeShakeHint}
|
||||
value={getTreeShakablePercent(last)}
|
||||
oldValue={getTreeShakablePercent(first)}
|
||||
formatter={formatPercent}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Node Module Count"
|
||||
value={getNodeModuleCount(last)}
|
||||
oldValue={getNodeModuleCount(first)}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Build Time"
|
||||
value={last.time!}
|
||||
oldValue={first.time!}
|
||||
formatter={formatDuration}
|
||||
/>
|
||||
</PanelArrangement>
|
||||
</div>
|
||||
<div className="col-xs-12 col-sm-6">
|
||||
<Tabs className={tabStyles.tabs}>
|
||||
<TabList>
|
||||
<Tab>Chunk Graph</Tab>
|
||||
<Tab>Module List</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel>
|
||||
<ChunkGraph stats={last} previous={first} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<ModuleTable first={first} last={last} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ModulePlot first={first} last={last} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
@import './variables.scss';
|
||||
|
||||
.tabs {
|
||||
:global {
|
||||
.react-tabs__tab-list {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
background: rgba(#000, 0.2);
|
||||
}
|
||||
|
||||
.react-tabs__tab {
|
||||
list-style-type: none;
|
||||
padding: 8px 32px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 0.8em;
|
||||
|
||||
&--selected {
|
||||
background: $color-pink;
|
||||
color: $color-dark;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.react-tabs__tab-panel {
|
||||
display: none;
|
||||
background: rgba(#000, .1);
|
||||
|
||||
&--selected {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,15 +3,15 @@
|
|||
.wrapper {
|
||||
margin: 32px;
|
||||
padding: 16px;
|
||||
background: $color-dark;
|
||||
background: rgba(#000, 0.4);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 10px 50px rgba(#000, 0.3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(#fff, 0.1);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
|
@ -1,44 +1,84 @@
|
|||
import * as React from 'react';
|
||||
import * as styles from './dashboard.component.scss';
|
||||
import { connect } from 'react-redux';
|
||||
import { IAppState, getKnownStats } from '../reducer';
|
||||
import { Redirect, Route, RouteChildrenProps } from 'react-router';
|
||||
import { Stats } from 'webpack';
|
||||
import { getKnownStats, IAppState } from '../redux/reducer';
|
||||
import { DashboardChunkPage } from './dashboard-chunk-page.component';
|
||||
import {
|
||||
DashboardBuildDate,
|
||||
DashboardClose,
|
||||
DashboardErrorCount,
|
||||
DashboardWarningCount,
|
||||
DashboardClose,
|
||||
} from './dashboard-header.component';
|
||||
import { Redirect, Route } from 'react-router';
|
||||
import { DashboardNodeModulePage } from './dashboard-node-module-page.component';
|
||||
import { DashboardOverview } from './dashboard-overview';
|
||||
import * as styles from './dashboard.component.scss';
|
||||
|
||||
interface IProps {
|
||||
stats: Stats.ToJsonOutput[];
|
||||
}
|
||||
|
||||
const DashboardComponent: React.FC<IProps> = ({ stats }) => {
|
||||
const first = stats[0];
|
||||
const last = stats[stats.length - 1];
|
||||
|
||||
if (!first) {
|
||||
return <Redirect to="/" />;
|
||||
class DashboardComponent extends React.PureComponent<IProps> {
|
||||
private get first() {
|
||||
return this.props.stats[0];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
<DashboardBuildDate first={first} last={last} />
|
||||
<DashboardErrorCount last={last} />
|
||||
<DashboardWarningCount last={last} />
|
||||
<div style={{ flex: 1 }} />
|
||||
<DashboardClose />
|
||||
private get last() {
|
||||
return this.props.stats[this.props.stats.length - 1];
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { first, last } = this;
|
||||
if (!first) {
|
||||
return <Redirect to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
<DashboardBuildDate first={first} last={last} />
|
||||
<DashboardErrorCount last={last} />
|
||||
<DashboardWarningCount last={last} />
|
||||
<div style={{ flex: 1 }} />
|
||||
<DashboardClose />
|
||||
</div>
|
||||
<Route path="/dashboard" exact component={this.renderOverview} />
|
||||
<Route path="/dashboard/chunk/:chunkId" exact component={this.renderChunkPage} />
|
||||
<Route
|
||||
path="/dashboard/nodemodule/:encodedModule"
|
||||
exact
|
||||
component={this.renderNodeModule}
|
||||
/>
|
||||
</div>
|
||||
<Route path="/dashboard" exact>
|
||||
<DashboardOverview first={first} last={last} />
|
||||
</Route>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private readonly renderOverview: React.FC<void> = () => (
|
||||
<DashboardOverview first={this.first} last={this.last} />
|
||||
);
|
||||
};
|
||||
|
||||
private readonly renderChunkPage: React.FC<RouteChildrenProps<{ chunkId: string }>> = ({
|
||||
match,
|
||||
}) =>
|
||||
match && (
|
||||
<DashboardChunkPage
|
||||
chunk={Number(match.params.chunkId)}
|
||||
first={this.first}
|
||||
last={this.last}
|
||||
/>
|
||||
);
|
||||
|
||||
private readonly renderNodeModule: React.FC<RouteChildrenProps<{ encodedModule: string }>> = ({
|
||||
match,
|
||||
}) =>
|
||||
match && (
|
||||
<DashboardNodeModulePage
|
||||
name={Base64.decode(match.params.encodedModule)}
|
||||
first={this.first}
|
||||
last={this.last}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Dashboard = connect((state: IAppState) => ({
|
||||
stats: getKnownStats(state),
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
left: 50%;
|
||||
width: 600px;
|
||||
max-width: 100vh;
|
||||
background: $color-dark;
|
||||
background: rgba(#000, 0.4);
|
||||
z-index: 0;
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
import { IRetrievalError, RetrievalState } from '@mixer/retrieval';
|
||||
import * as React from 'react';
|
||||
import * as styles from './enter-urls.component.scss';
|
||||
import { Button } from './button.component';
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
getBundleUrls,
|
||||
IAppState,
|
||||
BundleStateMap,
|
||||
getGroupedBundleState,
|
||||
getBundleErrors,
|
||||
} from '../reducer';
|
||||
import { RetrievalState, IRetrievalError } from '@mixer/retrieval';
|
||||
import { loadAllUrls, clearLoadedBundles } from '../actions';
|
||||
import { ProgressBar } from './progress-bar.component';
|
||||
import { Errors } from './errors.component';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { clearLoadedBundles, loadAllUrls } from '../redux/actions';
|
||||
import {
|
||||
BundleStateMap,
|
||||
getBundleErrors,
|
||||
getBundleUrls,
|
||||
getGroupedBundleState,
|
||||
IAppState,
|
||||
} from '../redux/reducer';
|
||||
import { Button } from './button.component';
|
||||
import * as styles from './enter-urls.component.scss';
|
||||
import { Errors } from './errors.component';
|
||||
import { ProgressBar } from './progress-bar.component';
|
||||
|
||||
interface IProps {
|
||||
defaultUrls: string[];
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from 'react';
|
||||
import { IError, IRetrievalError } from '@mixer/retrieval';
|
||||
import * as React from 'react';
|
||||
import * as styles from './errors.component.scss';
|
||||
import { classes } from './util';
|
||||
|
||||
|
|
|
@ -0,0 +1,264 @@
|
|||
import * as cytoscape from 'cytoscape';
|
||||
import * as filesize from 'filesize';
|
||||
import * as React from 'react';
|
||||
import { Stats } from 'webpack';
|
||||
import { IWebpackModuleComparisonOutput, normalizeIdentifier } from '../../stat-reducers';
|
||||
import { formatPercentageDifference } from '../util';
|
||||
|
||||
// tslint:disable-next-line
|
||||
cytoscape.use(require('cytoscape-fcose'));
|
||||
|
||||
interface IProps {
|
||||
edges: cytoscape.EdgeDefinition[];
|
||||
nodes: cytoscape.NodeDefinition[];
|
||||
rootNode?: string | string[];
|
||||
width: string | number;
|
||||
height: string | number;
|
||||
onClick?(nodeId: string): void;
|
||||
}
|
||||
|
||||
export class BaseGraph extends React.PureComponent<IProps> {
|
||||
private readonly container = React.createRef<HTMLDivElement>();
|
||||
private mountTimeout?: number | NodeJS.Timeout;
|
||||
private graph?: cytoscape.Core;
|
||||
|
||||
public componentDidMount() {
|
||||
this.mountTimeout = setTimeout(() => this.draw(this.container.current!), 10);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
clearTimeout(this.mountTimeout as number);
|
||||
if (this.graph) {
|
||||
this.graph.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div ref={this.container} style={{ width: this.props.width, height: this.props.height }} />
|
||||
);
|
||||
}
|
||||
|
||||
private draw(container: HTMLDivElement) {
|
||||
const graph = cytoscape({
|
||||
container,
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: true,
|
||||
layout: { name: 'fcose', animate: false } as any,
|
||||
elements: { nodes: this.props.nodes, edges: this.props.edges },
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
label: 'data(label)',
|
||||
width: 'data(width)',
|
||||
height: 'data(width)',
|
||||
color: 'data(fontColor)',
|
||||
'font-size': 5,
|
||||
'background-color': 'data(bgColor)',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node.hover',
|
||||
style: {
|
||||
'background-color': '#fff',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 1.5,
|
||||
'line-color': '#5c2686',
|
||||
'arrow-scale': 0.5,
|
||||
'source-arrow-color': '#5c2686',
|
||||
'source-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
selector: 'edge.highlighted',
|
||||
style: {
|
||||
'line-color': '#fff',
|
||||
'source-arrow-color': '#fff',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (this.graph) {
|
||||
this.graph.destroy();
|
||||
}
|
||||
|
||||
this.graph = graph;
|
||||
|
||||
this.attachPathHoverHandle(graph);
|
||||
this.attachClickListeners(graph);
|
||||
this.attachMouseoverStyles(graph);
|
||||
}
|
||||
|
||||
private attachMouseoverStyles(graph: cytoscape.Core) {
|
||||
graph.on('mouseover', 'node', ev => {
|
||||
const target = ev.target as cytoscape.Collection;
|
||||
target.addClass('hover');
|
||||
});
|
||||
|
||||
graph.on('mouseout', 'node', ev => {
|
||||
const target = ev.target as cytoscape.Collection;
|
||||
target.removeClass('hover');
|
||||
});
|
||||
}
|
||||
|
||||
private attachClickListeners(graph: cytoscape.Core) {
|
||||
const handler = this.props.onClick;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
graph.on('tap', 'node', ev => {
|
||||
const target = ev.target as cytoscape.SingularData;
|
||||
handler(target.id());
|
||||
});
|
||||
}
|
||||
|
||||
private attachPathHoverHandle(graph: cytoscape.Core) {
|
||||
if (!this.props.rootNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = graph.collection(
|
||||
typeof this.props.rootNode === 'string'
|
||||
? [graph.getElementById(this.props.rootNode)]
|
||||
: this.props.rootNode.map(node => graph.getElementById(node)),
|
||||
);
|
||||
|
||||
if (root.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastPath: cytoscape.CollectionReturnValue | null = null;
|
||||
graph.on('mouseover', 'node', ev => {
|
||||
lastPath = graph
|
||||
.elements()
|
||||
.dijkstra({ root: ev.target, directed: true })
|
||||
.pathTo(root);
|
||||
lastPath.addClass('highlighted');
|
||||
});
|
||||
|
||||
graph.on('mouseout', 'node', () => {
|
||||
if (lastPath) {
|
||||
lastPath.removeClass('highlighted');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const fileSizeNode = ({
|
||||
fromSize,
|
||||
toSize,
|
||||
area,
|
||||
...options
|
||||
}: cytoscape.NodeDataDefinition & {
|
||||
fromSize: number;
|
||||
toSize: number;
|
||||
area: number;
|
||||
}): cytoscape.NodeDefinition => {
|
||||
const hue = fromSize < toSize ? 0 : fromSize > toSize ? 110 : 55;
|
||||
const saturation = 40 + Math.min(60, (Math.abs(toSize - fromSize) / (toSize || 1)) * 100);
|
||||
|
||||
return {
|
||||
data: {
|
||||
...options,
|
||||
label: `${options.label} (${filesize(toSize)}), ${formatPercentageDifference(
|
||||
fromSize,
|
||||
toSize,
|
||||
)}`,
|
||||
fontColor: fromSize !== toSize ? '#fff' : '#666',
|
||||
bgColor: fromSize !== toSize ? `hsl(${hue}, ${saturation}%, 50%)` : '#666',
|
||||
width: Math.round(2 * Math.sqrt(area / Math.PI)),
|
||||
height: Math.round(2 * Math.sqrt(area / Math.PI)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const expandNode = <T extends { identifier: string }>({
|
||||
queue,
|
||||
getReasons,
|
||||
createNode,
|
||||
}: {
|
||||
queue: T[];
|
||||
getReasons(node: T): T[];
|
||||
createNode(node: T): cytoscape.NodeDefinition;
|
||||
}) => {
|
||||
const nodes: cytoscape.NodeDefinition[] = [];
|
||||
const edges: cytoscape.EdgeDefinition[] = [];
|
||||
const sources = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.pop()!;
|
||||
sources.add(node.identifier);
|
||||
|
||||
for (const found of getReasons(node)) {
|
||||
if (!sources.has(found.identifier)) {
|
||||
queue.push(found);
|
||||
}
|
||||
|
||||
edges.push({
|
||||
data: {
|
||||
id: `edge${node.identifier}to${found.identifier}`,
|
||||
source: node.identifier,
|
||||
target: found.identifier,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
nodes.push(createNode(node));
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
};
|
||||
|
||||
export const expandModuleComparison = (
|
||||
comparisons: { [name: string]: IWebpackModuleComparisonOutput },
|
||||
roots: IWebpackModuleComparisonOutput[],
|
||||
) => {
|
||||
const maxBubbleArea = 150;
|
||||
const minBubbleArea = 30;
|
||||
const allComparisons = Object.values(comparisons);
|
||||
const maxSize = allComparisons.reduce((max, cmp) => Math.max(max, cmp.toSize), 0);
|
||||
const entries: string[] = [];
|
||||
|
||||
const { nodes, edges } = expandNode({
|
||||
queue: roots,
|
||||
getReasons(node) {
|
||||
let reasons: Stats.Reason[] = [];
|
||||
if (node.old) {
|
||||
reasons = reasons.concat(node.old.reasons as any);
|
||||
}
|
||||
if (node.new) {
|
||||
reasons = reasons.concat(node.new.reasons as any);
|
||||
}
|
||||
|
||||
if (reasons.some(r => r.type.includes('entry'))) {
|
||||
entries.push(node.identifier);
|
||||
}
|
||||
|
||||
return reasons
|
||||
.map(r => comparisons[normalizeIdentifier(r.moduleIdentifier || '')])
|
||||
.filter(ok => !!ok);
|
||||
},
|
||||
createNode(node) {
|
||||
const weight = node.toSize / maxSize;
|
||||
const area = Math.max(minBubbleArea, maxBubbleArea * weight);
|
||||
|
||||
return fileSizeNode({
|
||||
id: node.identifier,
|
||||
label: node.name,
|
||||
area,
|
||||
fromSize: node.fromSize,
|
||||
toSize: node.toSize,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { nodes, edges, entries };
|
||||
};
|
|
@ -0,0 +1,55 @@
|
|||
import * as cytoscape from 'cytoscape';
|
||||
import * as React from 'react';
|
||||
import { Redirect } from 'react-router';
|
||||
import { Stats } from 'webpack';
|
||||
import { compareAllModules } from '../../stat-reducers';
|
||||
import { BaseGraph, expandModuleComparison } from './base-graph.component';
|
||||
|
||||
interface IProps {
|
||||
previous: Stats.ToJsonOutput;
|
||||
stats: Stats.ToJsonOutput;
|
||||
chunkId: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
nodes: cytoscape.NodeDefinition[];
|
||||
edges: cytoscape.EdgeDefinition[];
|
||||
entries: string[];
|
||||
redirect?: string;
|
||||
}
|
||||
|
||||
export class ChangedModuleGraph extends React.PureComponent<IProps, IState> {
|
||||
public state: IState = { redirect: undefined, ...this.buildData() };
|
||||
|
||||
public render() {
|
||||
return this.state.redirect ? (
|
||||
<Redirect to={this.state.redirect} push={true} />
|
||||
) : (
|
||||
<BaseGraph
|
||||
edges={this.state.edges}
|
||||
nodes={this.state.nodes}
|
||||
rootNode={this.state.entries}
|
||||
width="100%"
|
||||
height={window.innerHeight * 0.9}
|
||||
onClick={this.onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private buildData() {
|
||||
const comparisons = compareAllModules(
|
||||
this.props.previous,
|
||||
this.props.stats,
|
||||
this.props.chunkId,
|
||||
);
|
||||
const allComparisons = Object.values(comparisons);
|
||||
|
||||
return expandModuleComparison(
|
||||
comparisons,
|
||||
Object.values(allComparisons).filter(c => c.toSize !== c.fromSize),
|
||||
);
|
||||
}
|
||||
|
||||
private readonly onClick = (nodeId: string) =>
|
||||
this.setState({ redirect: `/dashboard/chunk/${nodeId}` });
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
import * as cytoscape from 'cytoscape';
|
||||
import * as React from 'react';
|
||||
import { Redirect } from 'react-router';
|
||||
import { Stats } from 'webpack';
|
||||
import { BaseGraph, fileSizeNode } from './base-graph.component';
|
||||
|
||||
interface IProps {
|
||||
previous: Stats.ToJsonOutput;
|
||||
stats: Stats.ToJsonOutput;
|
||||
maxBubbleArea?: number;
|
||||
minBubbleArea?: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
nodes: cytoscape.NodeDefinition[];
|
||||
edges: cytoscape.EdgeDefinition[];
|
||||
entries: string[];
|
||||
redirect?: string;
|
||||
}
|
||||
|
||||
export class ChunkGraph extends React.PureComponent<IProps, IState> {
|
||||
public state: IState = { redirect: undefined, ...this.buildData() };
|
||||
|
||||
public render() {
|
||||
return this.state.redirect ? (
|
||||
<Redirect to={this.state.redirect} push={true} />
|
||||
) : (
|
||||
<BaseGraph
|
||||
edges={this.state.edges}
|
||||
nodes={this.state.nodes}
|
||||
rootNode={this.state.entries}
|
||||
width="100%"
|
||||
height={500}
|
||||
onClick={this.onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private buildData() {
|
||||
const { stats, maxBubbleArea = 150, minBubbleArea = 30 } = this.props;
|
||||
const chunks = stats.chunks!;
|
||||
const maxSize = chunks.reduce((max, c) => Math.max(max, c.size), 0);
|
||||
const entries: string[] = [];
|
||||
|
||||
const nodes: cytoscape.ElementDefinition[] = chunks.map(chunk => {
|
||||
if (chunk.entry) {
|
||||
entries.push(String(chunk.id));
|
||||
}
|
||||
|
||||
const weight = chunk.size / maxSize;
|
||||
const area = Math.max(minBubbleArea, maxBubbleArea * weight);
|
||||
const previous = this.props.previous.chunks!.find(c => c.id === chunk.id);
|
||||
|
||||
return fileSizeNode({
|
||||
id: String(chunk.id),
|
||||
chunkId: chunk.id,
|
||||
shortLabel: '' + chunk.id,
|
||||
label: `Chunk ${chunk.id}`,
|
||||
fromSize: previous ? previous.size : 0,
|
||||
toSize: chunk.size,
|
||||
area,
|
||||
});
|
||||
});
|
||||
|
||||
const edges: cytoscape.EdgeDefinition[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
for (const parent of chunk.parents) {
|
||||
edges.push({
|
||||
data: {
|
||||
id: `edge${parent}to${chunk.id}`,
|
||||
source: String(chunk.id),
|
||||
target: String(parent),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges, entries };
|
||||
}
|
||||
|
||||
private readonly onClick = (nodeId: string) =>
|
||||
this.setState({ redirect: `/dashboard/chunk/${nodeId}` });
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import * as cytoscape from 'cytoscape';
|
||||
import * as React from 'react';
|
||||
import { Redirect } from 'react-router';
|
||||
import { Stats } from 'webpack';
|
||||
import {
|
||||
compareAllModules,
|
||||
getDirectImportsOfNodeModule,
|
||||
normalizeIdentifier,
|
||||
} from '../../stat-reducers';
|
||||
import { color } from '../util';
|
||||
import { BaseGraph, expandModuleComparison } from './base-graph.component';
|
||||
|
||||
interface IProps {
|
||||
previous: Stats.ToJsonOutput;
|
||||
stats: Stats.ToJsonOutput;
|
||||
name: string;
|
||||
chunkId?: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
nodes: cytoscape.NodeDefinition[];
|
||||
edges: cytoscape.EdgeDefinition[];
|
||||
entries: string[];
|
||||
redirect?: string;
|
||||
}
|
||||
|
||||
export class DependentGraph extends React.PureComponent<IProps, IState> {
|
||||
public state: IState = { redirect: undefined, ...this.buildData() };
|
||||
|
||||
public render() {
|
||||
return this.state.redirect ? (
|
||||
<Redirect to={this.state.redirect} push={true} />
|
||||
) : (
|
||||
<BaseGraph
|
||||
edges={this.state.edges}
|
||||
nodes={this.state.nodes}
|
||||
rootNode={this.state.entries}
|
||||
width="100%"
|
||||
height={window.innerHeight * 0.9}
|
||||
onClick={this.onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private buildData() {
|
||||
const directImports = getDirectImportsOfNodeModule(this.props.previous, this.props.name);
|
||||
const comparisons = compareAllModules(
|
||||
this.props.previous,
|
||||
this.props.stats,
|
||||
this.props.chunkId,
|
||||
);
|
||||
|
||||
const { nodes, edges } = expandModuleComparison(
|
||||
comparisons,
|
||||
directImports.map(imp => comparisons[normalizeIdentifier(imp.identifier)]).filter(ok => !!ok),
|
||||
);
|
||||
|
||||
for (const edge of edges) {
|
||||
[edge.data.source, edge.data.target] = [edge.data.target, edge.data.source];
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
data: {
|
||||
id: 'index',
|
||||
label: this.props.name,
|
||||
fontColor: '#fff',
|
||||
bgColor: color.blue,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
|
||||
for (const direct of directImports) {
|
||||
edges.push({
|
||||
data: {
|
||||
id: `${direct.identifier}toIndex`,
|
||||
source: direct.identifier,
|
||||
target: 'index',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges, entries: ['index'] };
|
||||
}
|
||||
|
||||
private readonly onClick = (nodeId: string) =>
|
||||
this.setState({ redirect: `/dashboard/chunk/${nodeId}` });
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
@import '../variables.scss';
|
||||
|
||||
.button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: currentColor;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(#000, 0.3);
|
||||
}
|
||||
|
||||
.contents {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 600px;
|
||||
margin: 50px;
|
||||
padding: 50px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.3);
|
||||
background: linear-gradient($color-medium, $color-dark);
|
||||
|
||||
h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 32px 0 16px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-left: 20px;
|
||||
|
||||
+ li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 2px;
|
||||
padding: 0.1em;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import * as styles from './hint-button.component.scss';
|
||||
import { IoIosInformationCircleOutline } from 'react-icons/io';
|
||||
import { classes } from '../util';
|
||||
|
||||
export class HintButton extends React.PureComponent<
|
||||
{ hint: React.ComponentType<{}>; className?: string },
|
||||
{ open: boolean }
|
||||
> {
|
||||
public state = { open: false };
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
<button onClick={this.open} className={classes(styles.button, this.props.className)}>
|
||||
<IoIosInformationCircleOutline />
|
||||
</button>
|
||||
{this.state.open &&
|
||||
ReactDOM.createPortal(
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.backdrop} onClick={this.close} />
|
||||
<div className={styles.contents}>
|
||||
<this.props.hint />
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private readonly open = () => {
|
||||
this.setState({ open: true });
|
||||
};
|
||||
|
||||
private readonly close = () => {
|
||||
this.setState({ open: false });
|
||||
};
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
import * as React from 'react';
|
||||
|
||||
export const TreeShakeHint: React.FC = () => (
|
||||
<>
|
||||
<h2>Tree Shaking</h2>
|
||||
<p>
|
||||
Tree shaking eliminates dead code by only importing the code you use from modules. You want to
|
||||
choose (and build) dependencies that can be tree shaken to avoid having to deliver more code.
|
||||
Google/Chrome{' '}
|
||||
<a
|
||||
href="https://developers.google.com/web/fundamentals/performance/optimizing-javascript/tree-shaking/"
|
||||
target="_blank"
|
||||
>
|
||||
has a great article
|
||||
</a>{' '}
|
||||
pertaining to tree shaking in general, and the Webpack documentation publishes{' '}
|
||||
<a href="https://webpack.js.org/guides/tree-shaking/" target="_blank">
|
||||
some further guidance
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<h3>tl;dr</h3>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
If you import a module, check{' '}
|
||||
<a href="https://bundlephobia.com/" target="_blank">
|
||||
on Bundlephobia
|
||||
</a>{' '}
|
||||
to see if it's tree-shakable. Shakable modules will have a green leaf icon below their name.
|
||||
</li>
|
||||
<li>
|
||||
If you author a module, make sure you publish a build that uses ES modules. Also, add the{' '}
|
||||
<code>sideEffects: false</code> flag into your <code>package.json</code>.
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
export const WhatIsAnEntrypoint: React.FC = () => (
|
||||
<>
|
||||
<h2>Entrypoints</h2>
|
||||
<p>
|
||||
An entrypoint is the first JavaScript that's loaded when a user navigates to your website.
|
||||
This is the bundle that needs to be downloaded before any of your own code in Webpack actually
|
||||
runs. You still might need to load more data, such as a route, before you show anything
|
||||
meaningful. You should try to keep your entrypoint size as small as possible to reduce your
|
||||
time to{' '}
|
||||
<a
|
||||
href="https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint"
|
||||
target="_blank"
|
||||
>
|
||||
time to first meaningful paint
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export const AverageChunkSize: React.FC = () => (
|
||||
<>
|
||||
<h2>Chunks and Sizes</h2>
|
||||
<p>
|
||||
A chunk is one bundle of code downloaded by webpack. You should make use of the available{' '}
|
||||
<a href="https://webpack.js.org/guides/lazy-loading/" target="_blank">
|
||||
lazy loading
|
||||
</a>{' '}
|
||||
tools available to you--such as{' '}
|
||||
<a href="https://reactjs.org/docs/code-splitting.html" target="_blank">
|
||||
React.lazy
|
||||
</a>
|
||||
, for instance--in order to have small, modular bundles. Loading only the code you need keeps
|
||||
your website fast and responsive. The{' '}
|
||||
<a
|
||||
href="https://developers.google.com/web/updates/2017/04/devtools-release-notes#coverage"
|
||||
target="_blank"
|
||||
>
|
||||
Chrome code coverage tool
|
||||
</a>{' '}
|
||||
can help you find sections of your code which are good candidates for code splitting.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export const TotalModules: React.FC = () => (
|
||||
<>
|
||||
<h2>Total Modules</h2>
|
||||
<p>
|
||||
The total modules is a count of the number of source files that Webpack read in your build. It
|
||||
does not directly have an effect on your output, but is a rough measure of complexity and
|
||||
monolithism.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export const UniqueEntrypoints: React.FC = () => (
|
||||
<>
|
||||
<h2>Unique Entrypoints</h2>
|
||||
<p>
|
||||
This is the number of different files that your code requires from the module. Usually you
|
||||
want to only import the file by its entrypoint, and allow tree shaking to prune unneeded code.
|
||||
What you should pay careful attention to here is making sure that you don't accidentally
|
||||
include multiple versions of the same dependency. This can easily happen if you have a
|
||||
dependency depends on a different version of this module that another one.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export const DependentModules: React.FC = () => (
|
||||
<>
|
||||
<h2>Dependent Modules</h2>
|
||||
<p>
|
||||
This is the number of files, in your code or other dependency, that depend on this module. This number lets you easily see if you added or reduced coupling on this dependency.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export const TotalNodeModuleSize: React.FC = () => (
|
||||
<>
|
||||
<h2>Total Module Size</h2>
|
||||
<p>
|
||||
This is how much filesize this module contributes to your code. This may differ from the
|
||||
Bundlephobia number shown on the page, for a few reasons:
|
||||
<ol>
|
||||
<li>The number here takes into account your unique tree-shaking graph.</li>
|
||||
<li>
|
||||
If you import a submodule from this dependency, it will not appear in Bundlephobia's
|
||||
analysis.
|
||||
</li>
|
||||
<li>
|
||||
Bundlephobia may not include certain processing that your custom webpack configuration
|
||||
applies.
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
The number shown here is the accurate, precise filesize that this dependency contributes to
|
||||
your bundle.
|
||||
</p>
|
||||
</p>
|
||||
</>
|
||||
);
|
|
@ -0,0 +1,52 @@
|
|||
.importBox {
|
||||
background: rgba(#000, 0.1);
|
||||
font-family: var(--font-family-mono);
|
||||
|
||||
+ .importBox {
|
||||
margin-top: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.identifier {
|
||||
font-size: 0.9em;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid rgba(#fff, 0.1);
|
||||
}
|
||||
|
||||
.title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.reason {
|
||||
padding: 12px 8px;
|
||||
|
||||
&:nth-child(odd) {
|
||||
background-color: rgba(#fff, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.fakeLine, .issuer {
|
||||
font-size: 0.8em;
|
||||
em {
|
||||
width: 30px;
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
margin-right: 1em;
|
||||
color: rgba(#fff, 0.5);
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.issuer {
|
||||
margin-top: 8px;
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import * as React from 'react';
|
||||
import { Stats } from 'webpack';
|
||||
import { getImportType, replaceLoaderInIdentifier } from '../stat-reducers';
|
||||
import * as styles from './imports-list.component.scss';
|
||||
import { ModuleTypeBadge } from './panels/node-module-panel.component';
|
||||
import { Placeholder } from './placeholder.component';
|
||||
|
||||
/**
|
||||
* Prints the list of modules that import the target modules.
|
||||
*/
|
||||
export const ImportsList: React.FC<{ targets: Stats.FnModules[] }> = ({ targets }) =>
|
||||
targets.length === 0 ? (
|
||||
<Placeholder>This module is not imported in the lastest build.</Placeholder>
|
||||
) : (
|
||||
<>
|
||||
{targets.map(target => (
|
||||
<div key={target.identifier} className={styles.importBox}>
|
||||
<div className={styles.title}>
|
||||
{target.name}
|
||||
<span style={{ flex: 1 }} />
|
||||
<ModuleTypeBadge type={getImportType(target)} />
|
||||
</div>
|
||||
{(target.reasons as any).map((reason: Stats.Reason, i: number) => (
|
||||
<ImportReason key={i} reason={reason} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* Prints the list of issuers that import any of the target modules.
|
||||
*/
|
||||
export const IssuerTree: React.FC<{ targets: Stats.FnModules[] }> = ({ targets }) =>
|
||||
targets.length === 0 ? (
|
||||
<Placeholder>This module is not imported in the lastest build.</Placeholder>
|
||||
) : (
|
||||
<>
|
||||
{targets.map(
|
||||
target =>
|
||||
target.issuerPath && (
|
||||
<div key={target.identifier} className={styles.importBox}>
|
||||
<div className={styles.title}>
|
||||
{target.name}
|
||||
<span style={{ flex: 1 }} />
|
||||
<ModuleTypeBadge type={getImportType(target)} />
|
||||
</div>
|
||||
<ol className={styles.issuer}>
|
||||
{target.issuerPath.map((issuer, i) => (
|
||||
<li key={i}>
|
||||
<em>{i + 1}.</em> {issuer.name}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const ImportReason: React.FC<{ reason: Stats.Reason }> = ({ reason }) => {
|
||||
const request = replaceLoaderInIdentifier(reason.userRequest);
|
||||
return (
|
||||
<div className={styles.reason}>
|
||||
<div className={styles.filename}>{reason.module}</div>
|
||||
<div className={styles.fakeLine}>
|
||||
<em>{reason.loc.split(':')[0]}</em>
|
||||
{reason.type.includes('harmony') ? `import "${request}"` : `require("${request}")`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
import * as filesize from 'filesize';
|
||||
import * as React from 'react';
|
||||
import { Stats } from 'webpack';
|
||||
import { CounterPanel } from './panels/counter-panel.component';
|
||||
import { PanelArrangement } from './panels/panel-arrangement.component';
|
||||
import { color } from './util';
|
||||
import { TotalNodeModuleSize, UniqueEntrypoints, DependentModules } from './hints/hints.component';
|
||||
|
||||
/**
|
||||
* Prints the list of modules that import the target modules.
|
||||
*/
|
||||
export const ImportsStatsRow: React.FC<{
|
||||
oldTargets: Stats.FnModules[];
|
||||
newTargets: Stats.FnModules[];
|
||||
}> = ({ oldTargets, newTargets }) => (
|
||||
<PanelArrangement>
|
||||
<CounterPanel
|
||||
title="Total Size"
|
||||
hint={TotalNodeModuleSize}
|
||||
value={newTargets.reduce((acc, t) => acc + t.size, 0)}
|
||||
oldValue={oldTargets.reduce((acc, t) => acc + t.size, 0)}
|
||||
formatter={filesize}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Unique Entrypoints"
|
||||
hint={UniqueEntrypoints}
|
||||
value={newTargets.length}
|
||||
oldValue={oldTargets.length}
|
||||
color={newTargets.length > 0 ? color.pink : undefined}
|
||||
/>
|
||||
<CounterPanel
|
||||
title="Dependent Modules"
|
||||
hint={DependentModules}
|
||||
value={newTargets.reduce((acc, t) => acc + (t.reasons as any).length, 0)}
|
||||
oldValue={oldTargets.reduce((acc, t) => acc + (t.reasons as any).length, 0)}
|
||||
/>
|
||||
</PanelArrangement>
|
||||
);
|
|
@ -8,37 +8,37 @@
|
|||
.plot :global {
|
||||
.public_fixedDataTable_header,
|
||||
.public_fixedDataTable_hasBottomBorder {
|
||||
border-color: $color-dark;
|
||||
border-color: rgba(#000, 0.1);
|
||||
}
|
||||
|
||||
.public_fixedDataTable_scrollbarSpacer {
|
||||
background: $color-dark;
|
||||
background: rgba(#000, 0.1);
|
||||
}
|
||||
|
||||
.public_fixedDataTable_header,
|
||||
.public_fixedDataTable_header .public_fixedDataTableCell_main {
|
||||
background: darken($color-dark, 3%);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.public_fixedDataTable_footer .public_fixedDataTableCell_main {
|
||||
background: #f6f7f8;
|
||||
border-color: $color-dark;
|
||||
border-color: rgba(#000, 0.1);
|
||||
}
|
||||
|
||||
.public_fixedDataTable_horizontalScrollbar .public_Scrollbar_mainHorizontal,
|
||||
.public_fixedDataTableRow_main {
|
||||
background-color: $color-dark;
|
||||
background-color: rgba(#000, 0.1);
|
||||
}
|
||||
|
||||
.public_fixedDataTableCell_main {
|
||||
background-color: $color-dark;
|
||||
border-color: darken($color-dark, 3%);
|
||||
background: none;
|
||||
border-color: rgba(#000, 0.3);
|
||||
}
|
||||
|
||||
.public_fixedDataTableCell_highlighted,
|
||||
.public_fixedDataTableRow_highlighted,
|
||||
.public_fixedDataTableRow_highlighted .public_fixedDataTableCell_main {
|
||||
background-color: darken($color-dark, 2%);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.public_fixedDataTableCell_cellContent {
|
||||
|
@ -47,18 +47,24 @@
|
|||
}
|
||||
|
||||
.public_fixedDataTable_header .public_fixedDataTableCell_main {
|
||||
font-weight: bold;
|
||||
font-family: var(--font-family-mono);
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.public_Scrollbar_main,
|
||||
.public_Scrollbar_mainOpaque:hover,
|
||||
.public_Scrollbar_faceActive:after {
|
||||
border: none;
|
||||
background: $color-dark;
|
||||
background: rgba(#000, 0.1);
|
||||
}
|
||||
|
||||
.public_Scrollbar_face:after {
|
||||
background: rgba(#fff, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(#fff, 0.3);
|
||||
}
|
|
@ -1,17 +1,18 @@
|
|||
import * as React from 'react';
|
||||
import { IModuleDiffEntry, getModulesDiff } from '../stat-reducers';
|
||||
import { Stats } from 'webpack';
|
||||
import * as styles from './module-plot.component.scss';
|
||||
import { Table, Column, Cell, ColumnCellProps } from 'fixed-data-table-2';
|
||||
import * as filesize from 'filesize';
|
||||
import { Cell, Column, ColumnCellProps, Table } from 'fixed-data-table-2';
|
||||
import * as React from 'react';
|
||||
import { Stats } from 'webpack';
|
||||
import { compareAllModules, IModuleDiffEntry } from '../stat-reducers';
|
||||
import * as styles from './module-table.component.scss';
|
||||
|
||||
import 'fixed-data-table-2/dist/fixed-data-table.css';
|
||||
import { formatFileSizeDifference, formatPercentageDifference } from './util';
|
||||
import { IoIosArrowRoundUp, IoIosArrowRoundDown } from 'react-icons/io';
|
||||
import { IoIosArrowRoundDown, IoIosArrowRoundUp } from 'react-icons/io';
|
||||
import { formatDifference, formatPercentageDifference } from './util';
|
||||
|
||||
interface IProps {
|
||||
first: Stats.ToJsonOutput;
|
||||
last: Stats.ToJsonOutput;
|
||||
inChunk?: number;
|
||||
}
|
||||
|
||||
const enum Sort {
|
||||
|
@ -24,10 +25,16 @@ const enum Sort {
|
|||
interface IState {
|
||||
diffs: IModuleDiffEntry[];
|
||||
order: Sort;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export class ModulePlot extends React.PureComponent<IProps, IState> {
|
||||
public state: IState = { diffs: this.orderDiff(Sort.DiffDesc), order: Sort.DiffDesc };
|
||||
export class ModuleTable extends React.PureComponent<IProps, IState> {
|
||||
public state: IState = { diffs: this.orderDiff(Sort.DiffDesc), order: Sort.DiffDesc, width: 500 };
|
||||
private containerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
public componentDidMount() {
|
||||
setTimeout(this.resize, 10);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IProps) {
|
||||
if (prevProps.last !== this.props.last) {
|
||||
|
@ -37,27 +44,39 @@ export class ModulePlot extends React.PureComponent<IProps, IState> {
|
|||
|
||||
public render() {
|
||||
return (
|
||||
<Table
|
||||
rowHeight={30}
|
||||
width={1000}
|
||||
maxHeight={window.innerHeight * 2 / 3}
|
||||
headerHeight={40}
|
||||
rowsCount={this.state.diffs.length}
|
||||
className={styles.plot}
|
||||
>
|
||||
<Column cell={this.nameCell} width={500} flexGrow={1} header="Module Name" />
|
||||
<Column cell={this.totalSizeCell} width={150} header={this.totalHeader} />
|
||||
<Column cell={this.diffCell} width={200} header={this.diffHeader} />
|
||||
</Table>
|
||||
<div ref={this.containerRef}>
|
||||
<Table
|
||||
rowHeight={30}
|
||||
width={this.state.width}
|
||||
maxHeight={(window.innerHeight * 2) / 3}
|
||||
headerHeight={40}
|
||||
rowsCount={this.state.diffs.length}
|
||||
className={styles.plot}
|
||||
rowClassNameGetter={this.getRowClassName}
|
||||
>
|
||||
<Column cell={this.nameCell} width={100} flexGrow={1} header="Module Name" />
|
||||
<Column cell={this.totalSizeCell} width={150} header={this.totalHeader} />
|
||||
<Column cell={this.diffCell} width={180} header={this.diffHeader} />
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private readonly resize = () => {
|
||||
this.setState({ width: this.containerRef.current!.clientWidth });
|
||||
};
|
||||
|
||||
private readonly getRowClassName = (index: number) => {
|
||||
const { fromSize, toSize } = this.state.diffs[index];
|
||||
return fromSize === toSize ? styles.muted : null;
|
||||
};
|
||||
|
||||
private readonly diffCell = (props: ColumnCellProps) => {
|
||||
const { rowIndex, ...rest } = props;
|
||||
const target = this.state.diffs[rowIndex];
|
||||
return (
|
||||
<Cell {...rest} height={40}>
|
||||
{formatFileSizeDifference(target.fromSize, target.toSize)}
|
||||
{formatDifference(target.fromSize, target.toSize, filesize)}
|
||||
{target.fromSize > 0 && ` (${formatPercentageDifference(target.fromSize, target.toSize)})`}
|
||||
</Cell>
|
||||
);
|
||||
|
@ -98,18 +117,22 @@ export class ModulePlot extends React.PureComponent<IProps, IState> {
|
|||
};
|
||||
|
||||
private readonly toggleOrderByDiff = () => {
|
||||
const order = Math.abs(this.state.order) !== Sort.DiffAsc ? Sort.DiffDesc : this.state.order * -1;
|
||||
const order =
|
||||
Math.abs(this.state.order) !== Sort.DiffAsc ? Sort.DiffDesc : this.state.order * -1;
|
||||
this.setState({ order, diffs: this.orderDiff(order, this.state.diffs) });
|
||||
};
|
||||
|
||||
private readonly toggleOrderByTotal = () => {
|
||||
const order = Math.abs(this.state.order) !== Sort.TotalAsc ? Sort.TotalDesc : this.state.order * -1;
|
||||
const order =
|
||||
Math.abs(this.state.order) !== Sort.TotalAsc ? Sort.TotalDesc : this.state.order * -1;
|
||||
this.setState({ order, diffs: this.orderDiff(order, this.state.diffs) });
|
||||
};
|
||||
|
||||
private orderDiff(
|
||||
sort: Sort,
|
||||
list: IModuleDiffEntry[] = getModulesDiff(this.props.first, this.props.last),
|
||||
list: IModuleDiffEntry[] = Object.values(
|
||||
compareAllModules(this.props.first, this.props.last, this.props.inChunk),
|
||||
),
|
||||
) {
|
||||
const invert = sort < 0 ? -1 : 1;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
@import './variables.scss';
|
||||
|
||||
.tip {
|
||||
font-size: 1.1em;
|
||||
line-height: 1.5em;
|
||||
padding-left: 32px;
|
||||
position: relative;
|
||||
padding-top: 1em;
|
||||
|
||||
+ .tip {
|
||||
margin-top: 1em;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.7em;
|
||||
color: rgba(#fff, 0.7);
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 1em;
|
||||
left: 0;
|
||||
font-size: 1.5em;
|
||||
|
||||
.suggestion & {
|
||||
color: $color-yellow;
|
||||
}
|
||||
|
||||
.awesome & {
|
||||
color: $color-blue;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
import * as filesize from 'filesize';
|
||||
import * as React from 'react';
|
||||
import { IoIosInformationCircleOutline, IoIosThumbsUp } from 'react-icons/io';
|
||||
import { Stats } from 'webpack';
|
||||
import {
|
||||
getEntryChunkSize,
|
||||
getNodeModuleSize,
|
||||
getTotalChunkSize,
|
||||
getTreeShakablePercent,
|
||||
} from '../stat-reducers';
|
||||
import * as styles from './overview-suggestions.component.scss';
|
||||
import { classes, formatPercent } from './util';
|
||||
|
||||
interface IProps {
|
||||
first: Stats.ToJsonOutput;
|
||||
last: Stats.ToJsonOutput;
|
||||
}
|
||||
|
||||
const epsilon = 1024 * 2;
|
||||
|
||||
function nodeModuleSizeTip(first: Stats.ToJsonOutput, last: Stats.ToJsonOutput) {
|
||||
const firstNodeModuleSize = getNodeModuleSize(first);
|
||||
const lastNodeModuleSize = getNodeModuleSize(last);
|
||||
if (lastNodeModuleSize > firstNodeModuleSize + epsilon) {
|
||||
return (
|
||||
<div className={classes(styles.tip, styles.suggestion)}>
|
||||
<IoIosInformationCircleOutline className={styles.icon} />
|
||||
Try to use smaller node modules, or eliminate ones you don't need.{' '}
|
||||
<a href="https://bundlephobia.com/" target="_blank" rel="nofollow noopener">
|
||||
BundlePhobia
|
||||
</a>{' '}
|
||||
can help you find smaller modules.
|
||||
<small>
|
||||
The size of your node modules grew from {filesize(firstNodeModuleSize)} to{' '}
|
||||
{filesize(lastNodeModuleSize)}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastNodeModuleSize < firstNodeModuleSize - epsilon) {
|
||||
return (
|
||||
<div className={classes(styles.tip, styles.awesome)}>
|
||||
You dropped {filesize(firstNodeModuleSize - lastNodeModuleSize)} from your node modules
|
||||
size!
|
||||
<small>Way to go!</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function entrypointTip(last: Stats.ToJsonOutput) {
|
||||
const totalSize = getTotalChunkSize(last);
|
||||
const entrySize = getEntryChunkSize(last);
|
||||
const isMajority = entrySize > totalSize / 2;
|
||||
if ((isMajority || entrySize > 1024 * 512) && totalSize > 1024 * 128) {
|
||||
return (
|
||||
<div className={classes(styles.tip, styles.suggestion)}>
|
||||
<IoIosInformationCircleOutline className={styles.icon} />
|
||||
Your entrypoint size is pretty big. Investigate code splitting and lazy loading to import
|
||||
only the code you need.
|
||||
<small>
|
||||
Your entrypoint{' '}
|
||||
{isMajority
|
||||
? `contains the majority (${filesize(entrySize)}) of your code.`
|
||||
: `is fairly large (${filesize(entrySize)}).`}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
} else if (entrySize < totalSize / 5) {
|
||||
return (
|
||||
<div className={classes(styles.tip, styles.awesome)}>
|
||||
<IoIosThumbsUp className={styles.icon} />
|
||||
Your code is split up well, your entrypoint is {formatPercent(entrySize / totalSize)} of
|
||||
your total code size.
|
||||
<small>Way to go!</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function treeShakeTip(last: Stats.ToJsonOutput) {
|
||||
const percent = getTreeShakablePercent(last);
|
||||
if (percent > 0.8) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes(styles.tip, styles.suggestion)}>
|
||||
<IoIosInformationCircleOutline className={styles.icon} />
|
||||
Some of your modules aren't tree shaken. Choose ones that can be tree-shaken to help reduce
|
||||
your bundle size.
|
||||
<small>{formatPercent(1 - percent)} of your dependencies aren't tree shaken.</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const OverviewSuggestions: React.FC<IProps> = ({ first, last }) => {
|
||||
return (
|
||||
<>
|
||||
{nodeModuleSizeTip(first, last)}
|
||||
{entrypointTip(last)}
|
||||
{treeShakeTip(last)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
import * as React from 'react';
|
||||
import { classes } from '../util';
|
||||
import * as styles from './panels.component.scss';
|
||||
import { HintButton } from '../hints/hint-button.component';
|
||||
|
||||
export const enum ArrowDirection {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
title: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
hint?: React.ComponentType<{}>;
|
||||
footer?: React.ReactNode;
|
||||
color: string;
|
||||
arrow?: ArrowDirection;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
export const BasePanel: React.FC<IProps> = ({
|
||||
onClick,
|
||||
value,
|
||||
footer,
|
||||
arrow,
|
||||
title,
|
||||
color,
|
||||
hint,
|
||||
}) => (
|
||||
<div className={classes(onClick && styles.clickable, styles.panel)} onClick={onClick}>
|
||||
<span className={styles.title}>{title}:</span>
|
||||
{hint && <HintButton hint={hint} className={styles.hint} />}
|
||||
<span className={styles.value} style={{ color }}>
|
||||
{value}
|
||||
{arrow !== undefined && (
|
||||
<div className={classes(styles.arrow, arrow === ArrowDirection.Up && styles.up)} />
|
||||
)}
|
||||
</span>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from 'react';
|
||||
import { color } from '../util';
|
||||
import { BasePanel } from './base-panel.component';
|
||||
|
||||
interface IProps {
|
||||
title: React.ReactNode;
|
||||
value: boolean;
|
||||
goodValue?: boolean;
|
||||
formatter?: (value: boolean) => string;
|
||||
}
|
||||
|
||||
const defaultFormatter = (value: boolean) => (value ? 'Yes' : 'No');
|
||||
|
||||
export const BooleanPanel: React.FC<IProps> = ({
|
||||
value,
|
||||
title,
|
||||
goodValue = true,
|
||||
formatter = defaultFormatter,
|
||||
}) => (
|
||||
<BasePanel
|
||||
title={title}
|
||||
color={!!value === goodValue ? color.blue : color.pink}
|
||||
value={formatter(value)}
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from 'react';
|
||||
import { color, formatValue } from '../util';
|
||||
import { BasePanel } from './base-panel.component';
|
||||
import * as styles from './panels.component.scss';
|
||||
import { StatDelta } from './stat-delta.component';
|
||||
|
||||
interface IProps {
|
||||
title: React.ReactNode;
|
||||
value: number;
|
||||
hint?: React.ComponentType<{}>;
|
||||
color?: string;
|
||||
oldValue?: number;
|
||||
moreIsBetter?: boolean;
|
||||
formatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
export const CounterPanel: React.FC<IProps> = ({
|
||||
value,
|
||||
oldValue,
|
||||
title,
|
||||
formatter,
|
||||
hint,
|
||||
color: explicitColor,
|
||||
moreIsBetter,
|
||||
}) => (
|
||||
<BasePanel
|
||||
title={title}
|
||||
color={explicitColor || getColor(value, oldValue, moreIsBetter)}
|
||||
hint={hint}
|
||||
footer={
|
||||
<StatDelta
|
||||
newValue={value}
|
||||
oldValue={oldValue}
|
||||
className={styles.delta}
|
||||
formatter={formatter}
|
||||
/>
|
||||
}
|
||||
value={formatValue(value, formatter)}
|
||||
/>
|
||||
);
|
||||
|
||||
const getColor = (newValue: number, oldValue?: number, moreIsBetter?: boolean) => {
|
||||
if (oldValue === undefined) {
|
||||
return color.blue;
|
||||
}
|
||||
|
||||
if (moreIsBetter) {
|
||||
[oldValue, newValue] = [newValue, oldValue];
|
||||
}
|
||||
|
||||
if (newValue <= oldValue) {
|
||||
return color.blue;
|
||||
}
|
||||
|
||||
if (newValue / oldValue < 1.02) {
|
||||
return color.yellow;
|
||||
}
|
||||
|
||||
return color.pink;
|
||||
};
|
|
@ -0,0 +1,92 @@
|
|||
import * as filesize from 'filesize';
|
||||
import * as React from 'react';
|
||||
import { IoIosLeaf, IoIosSad } from 'react-icons/io';
|
||||
import { RouteComponentProps, withRouter } from 'react-router';
|
||||
import { ImportType, INodeModule, INodeModuleComparisonOutput } from '../../stat-reducers';
|
||||
import { classes, color, linkToNodeModule } from '../util';
|
||||
import { BasePanel } from './base-panel.component';
|
||||
import * as styles from './panels.component.scss';
|
||||
import { StatDelta } from './stat-delta.component';
|
||||
|
||||
interface IProps {
|
||||
comparison: INodeModuleComparisonOutput;
|
||||
inChunk?: number;
|
||||
}
|
||||
|
||||
const getSizeInChunk = (nodeModule?: INodeModule, chunk?: number) => {
|
||||
if (!nodeModule) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (chunk === undefined) {
|
||||
return nodeModule.totalSize;
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
for (const m of nodeModule.modules) {
|
||||
if (m.chunks.includes(chunk)) {
|
||||
sum += m.size;
|
||||
}
|
||||
}
|
||||
|
||||
return sum;
|
||||
};
|
||||
|
||||
export const NodeModulePanel = withRouter<IProps & RouteComponentProps<{}>>(
|
||||
({ comparison, inChunk, history }) => {
|
||||
const oldSize = getSizeInChunk(comparison.old, inChunk);
|
||||
const newSize = getSizeInChunk(comparison.new, inChunk);
|
||||
|
||||
if (oldSize === 0 && newSize === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BasePanel
|
||||
title={comparison.name}
|
||||
value={filesize(newSize)}
|
||||
color={newSize > oldSize ? color.yellow : color.blue}
|
||||
onClick={() => history.push(linkToNodeModule(comparison.name))}
|
||||
footer={
|
||||
<span className={styles.delta}>
|
||||
{!comparison.old ? (
|
||||
'New (+100%)'
|
||||
) : !comparison.new ? (
|
||||
`Deleted (-${filesize(oldSize)}`
|
||||
) : (
|
||||
<StatDelta oldValue={oldSize} newValue={newSize} formatter={filesize} />
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{comparison.new && <ModuleTypeBadge type={comparison.new.importType} />}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ModuleTypeBadge: React.FC<{ type: number }> = ({ type }) => {
|
||||
if (type & ImportType.CommonJs && type & ImportType.EsModule) {
|
||||
return <span className={classes(styles.moduleType, styles.mixed)}>Mixed Tree-Shaking</span>;
|
||||
}
|
||||
|
||||
if (type & ImportType.EsModule) {
|
||||
return (
|
||||
<span className={classes(styles.moduleType, styles.shake)}>
|
||||
<IoIosLeaf />
|
||||
Tree-Shaken
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (type & ImportType.CommonJs) {
|
||||
return (
|
||||
<span className={classes(styles.moduleType, styles.cjs)}>
|
||||
<IoIosSad />
|
||||
Not Tree-Shaken
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
|
@ -0,0 +1,6 @@
|
|||
import * as React from 'react';
|
||||
import * as styles from './panels.component.scss';
|
||||
|
||||
export const PanelArrangement: React.FC = props => (
|
||||
<div className={styles.arrangement}>{props.children}</div>
|
||||
);
|
|
@ -0,0 +1,105 @@
|
|||
@import '../variables.scss';
|
||||
|
||||
.panel {
|
||||
color: rgba(#fff, 0.4);
|
||||
background: rgba(#000, 0.1);
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
|
||||
+ .panel {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(#fff, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.value,
|
||||
.delta {
|
||||
display: block;
|
||||
font-weight: 200;
|
||||
font-size: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.delta {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.arrangement {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: -1rem -0.5rem;
|
||||
|
||||
.panel {
|
||||
flex-basis: 100%;
|
||||
margin: 1rem 0.5rem;
|
||||
|
||||
@media only screen and (min-width: 48em) {
|
||||
flex-basis: calc(33% - 1rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
border: 0.5em solid transparent;
|
||||
border-bottom-color: currentColor;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
transform: scaleX(0.5) translateY(33%) rotate(180deg);
|
||||
display: inline-block;
|
||||
|
||||
&.up {
|
||||
transform: scaleX(0.5) translateY(-10%) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.moduleType {
|
||||
font-size: 0.7em;
|
||||
color: $color-dark;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
|
||||
svg {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
&.shake {
|
||||
color: $color-blue;
|
||||
}
|
||||
|
||||
&.cjs {
|
||||
background: $color-pink;
|
||||
}
|
||||
|
||||
&.mixed {
|
||||
background: $color-yellow;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import * as React from 'react';
|
||||
import { formatDifference, formatPercentageDifference } from '../util';
|
||||
|
||||
interface IProps {
|
||||
newValue: number;
|
||||
oldValue?: number;
|
||||
className?: string;
|
||||
formatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
export class StatDelta extends React.PureComponent<IProps, { percent: boolean }> {
|
||||
public state = { percent: true };
|
||||
|
||||
public render() {
|
||||
const { oldValue, newValue, className, formatter } = this.props;
|
||||
if (oldValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className} onClick={this.toggleMode}>
|
||||
{this.state.percent
|
||||
? formatPercentageDifference(oldValue, newValue)
|
||||
: formatDifference(oldValue, newValue, formatter)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
private readonly toggleMode = () => {
|
||||
this.setState({ percent: !this.state.percent });
|
||||
};
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import * as React from 'react';
|
||||
|
||||
export const Placeholder: React.FC = props => <div>{props.children}</div>;
|
|
@ -1,6 +1,7 @@
|
|||
@import './variables.scss';
|
||||
|
||||
$bar-height: 9px;
|
||||
$indefinite-width: 10%;
|
||||
|
||||
.progressbar {
|
||||
padding: 5px;
|
||||
|
@ -17,3 +18,20 @@ $bar-height: 9px;
|
|||
box-shadow: inset 0 1px rgba(#fff, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.indefinite {
|
||||
> div {
|
||||
width: $indefinite-width;
|
||||
animation: indeterminate-animation alternate infinite 1s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes indeterminate-animation {
|
||||
0% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX((100% / $indefinite-width - 1) * 100%);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from 'react';
|
||||
import * as styles from './progress-bar.component.scss';
|
||||
import { classes } from './util';
|
||||
|
||||
interface IProps {
|
||||
progress: number;
|
||||
|
@ -16,3 +17,9 @@ export const ProgressBar: React.FC<IProps> = props => (
|
|||
<div style={{ width: `${props.progress * 100}%` }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const IndefiniteProgressBar: React.FC = () => (
|
||||
<div className={classes(styles.progressbar, styles.indefinite)}>
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import * as React from 'react';
|
||||
import { EnterUrls } from './enter-urls.component';
|
||||
import { HashRouter as Router, Route } from 'react-router-dom';
|
||||
import { Dashboard } from './dashboard.component';
|
||||
import { EnterUrls } from './enter-urls.component';
|
||||
|
||||
export const Root: React.FC = () => (
|
||||
<Router>
|
||||
<Route path="/" exact component={EnterUrls} />
|
||||
<Route path="/dashboard" exact component={Dashboard} />
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
</Router>
|
||||
);
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
.panel {
|
||||
max-width: 300px;
|
||||
color: #1c0658;
|
||||
padding: 8px;
|
||||
|
||||
+ .panel {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.value,
|
||||
.delta {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.delta {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 40px;
|
||||
margin-top: 16px;
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as styles from './stat-panels.component.scss';
|
||||
import * as filesize from 'filesize';
|
||||
import {
|
||||
color,
|
||||
formatPercentageDifference,
|
||||
formatNumberDifference,
|
||||
defaultFormatter,
|
||||
formatFileSizeDifference,
|
||||
} from './util';
|
||||
|
||||
interface IProps {
|
||||
title: React.ReactNode;
|
||||
value: number;
|
||||
oldValue?: number;
|
||||
filesize?: boolean;
|
||||
}
|
||||
|
||||
export class CounterPanel extends React.PureComponent<IProps, { percent: boolean }> {
|
||||
public state = { percent: true };
|
||||
public render() {
|
||||
return (
|
||||
<div className={styles.panel} style={{ backgroundColor: this.getColor() }}>
|
||||
<span className={styles.title}>{this.props.title}</span>
|
||||
<span className={styles.value}>{this.formatValue(this.props.value)}</span>
|
||||
{this.props.oldValue !== undefined && (
|
||||
<span className={styles.delta} onClick={this.toggleMode}>
|
||||
{this.difference(this.props.oldValue)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private getColor() {
|
||||
if (!this.props.oldValue) {
|
||||
return color.blue;
|
||||
}
|
||||
|
||||
if (this.props.value <= this.props.oldValue) {
|
||||
return color.blue;
|
||||
}
|
||||
|
||||
if (this.props.value / this.props.oldValue < 1.02) {
|
||||
return color.yellow;
|
||||
}
|
||||
|
||||
return color.pink;
|
||||
}
|
||||
|
||||
private difference(oldValue: number) {
|
||||
const delta = this.props.value - oldValue;
|
||||
return (
|
||||
<>
|
||||
{delta !== 0 && <div className={styles.arrow} data-up={delta > 0} />}
|
||||
{this.state.percent
|
||||
? formatPercentageDifference(oldValue, this.props.value)
|
||||
: this.props.filesize
|
||||
? formatFileSizeDifference(oldValue, this.props.value)
|
||||
: formatNumberDifference(oldValue, this.props.value)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private formatValue(value: number) {
|
||||
return this.props.filesize ? filesize(value) : defaultFormatter.format(value);
|
||||
}
|
||||
|
||||
private readonly toggleMode = () => {
|
||||
this.setState({ percent: !this.state.percent });
|
||||
};
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
import { Base64 } from 'js-base64';
|
||||
import * as styles from './util.component.scss';
|
||||
import * as filesize from 'filesize';
|
||||
|
||||
export const classes = (...classes: (string | null | undefined)[]) => {
|
||||
export const classes = (...classList: Array<string | null | undefined>) => {
|
||||
let str = '';
|
||||
for (const cls of classes) {
|
||||
for (const cls of classList) {
|
||||
if (cls) {
|
||||
str += ' ' + cls;
|
||||
}
|
||||
|
@ -25,28 +25,67 @@ export const percentageFormatter = new Intl.NumberFormat(undefined, {
|
|||
maximumSignificantDigits: 3,
|
||||
});
|
||||
|
||||
export const formatValue = (
|
||||
value: number,
|
||||
formatter: null | ((value: number) => string) = v => defaultFormatter.format(v),
|
||||
) => (formatter === null ? String(value) : formatter(value));
|
||||
|
||||
/**
|
||||
* Formats the difference between two numeric values.
|
||||
*/
|
||||
export const formatNumberDifference = (a: number, b: number) => {
|
||||
export const formatDifference = (
|
||||
a: number,
|
||||
b: number,
|
||||
formatter: null | ((value: number) => string) = v => defaultFormatter.format(v),
|
||||
) => {
|
||||
const delta = b - a;
|
||||
const str = defaultFormatter.format(Math.abs(delta));
|
||||
return `${delta < 0 ? '-' : '+'}${str}`;
|
||||
return `${delta < 0 ? '-' : '+'}${formatValue(Math.abs(delta), formatter)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the difference between two file sizes.
|
||||
* Formats a percentage (0-1).
|
||||
*/
|
||||
export const formatFileSizeDifference = (a: number, b: number) => {
|
||||
const delta = b - a;
|
||||
const str = filesize(Math.abs(delta));
|
||||
return `${delta < 0 ? '-' : '+'}${str}`;
|
||||
};
|
||||
export const formatPercent = (percent: number) =>
|
||||
`${percentageFormatter.format(Math.abs(percent * 100))}%`;
|
||||
|
||||
/**
|
||||
* Formats the difference between two file sizes.
|
||||
*/
|
||||
export const formatPercentageDifference = (a: number, b: number) => {
|
||||
const delta = (b / a - 1) * 100;
|
||||
return `${delta < 0 ? '-' : '+'}${percentageFormatter.format(Math.abs(delta))}%`;
|
||||
if (a === b) {
|
||||
return '+0%';
|
||||
}
|
||||
|
||||
const delta = b / a - 1;
|
||||
return `${delta < 0 ? '-' : '+'}${formatPercent(Math.abs(delta))}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a duration.
|
||||
*/
|
||||
export const formatDuration = (durationInMs: number) => {
|
||||
if (durationInMs < 1000) {
|
||||
return `${durationInMs.toFixed(0)} ms`;
|
||||
}
|
||||
|
||||
if (durationInMs < 10000) {
|
||||
return `${(durationInMs / 1000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
if (durationInMs < 60000) {
|
||||
return `${(durationInMs / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
return `${(durationInMs / 1000 / 60).toFixed(1)} m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a link to the given module.
|
||||
*/
|
||||
export const linkToModule = (identifier: string) =>
|
||||
`/dashboard/ownmodule/${Base64.encodeURI(identifier)}`;
|
||||
|
||||
/**
|
||||
* Creates a link to the given node module.
|
||||
*/
|
||||
export const linkToNodeModule = (name: string) => `/dashboard/nodemodule/${Base64.encodeURI(name)}`;
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
import { Epic as PlainEpic, combineEpics } from 'redux-observable';
|
||||
import { CompareAction, loadAllUrls, doAnalysis } from './actions';
|
||||
import { IAppState } from './reducer';
|
||||
import { filter, mergeMap } from 'rxjs/operators';
|
||||
import { isActionOf } from 'typesafe-actions';
|
||||
|
||||
type Epic = PlainEpic<CompareAction, CompareAction, IAppState, {}>;
|
||||
|
||||
const loadAllUrlsEpic: Epic = actions =>
|
||||
actions.pipe(
|
||||
filter(isActionOf(loadAllUrls)),
|
||||
mergeMap(action => action.payload.urls.map(url => doAnalysis.request({ url }))),
|
||||
);
|
||||
|
||||
export const epics = combineEpics(loadAllUrlsEpic);
|
|
@ -48,3 +48,18 @@ input {
|
|||
textarea:focus {
|
||||
border-color: #ff1690;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 32px 0 16px;
|
||||
}
|
||||
|
||||
h2 small {
|
||||
display: block;
|
||||
font-size: 0.6em;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #36cdc4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension';
|
||||
import { applyMiddleware, createStore, Middleware } from 'redux';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension';
|
||||
import { createEpicMiddleware } from 'redux-observable';
|
||||
import Worker from 'worker-loader!./worker/index.worker';
|
||||
import { Root } from './components/root.component';
|
||||
import { reducer, IAppState } from './reducer';
|
||||
import { createEpicMiddleware } from 'redux-observable';
|
||||
import { epics } from './epics';
|
||||
import { CompareAction } from './actions'
|
||||
import { CompareAction } from './redux/actions';
|
||||
import { epics, IServices } from './redux/epics';
|
||||
import { IAppState, reducer } from './redux/reducer';
|
||||
|
||||
import '../../node_modules/flexboxgrid/css/flexboxgrid.css';
|
||||
import '../../node_modules/normalize.css/normalize.css';
|
||||
import './index.css';
|
||||
import { fetchBundlephobiaApi } from './redux/services/bundlephobia-api';
|
||||
|
||||
const worker = new Worker();
|
||||
const workerMiddlware: Middleware = _store => next => action => {
|
||||
|
@ -19,13 +21,19 @@ const workerMiddlware: Middleware = _store => next => action => {
|
|||
next(action);
|
||||
};
|
||||
|
||||
const epicMw = createEpicMiddleware<CompareAction, CompareAction, IAppState>();
|
||||
const epicMw = createEpicMiddleware<CompareAction, CompareAction, IAppState, IServices>({
|
||||
dependencies: { bundlephobia: fetchBundlephobiaApi },
|
||||
});
|
||||
|
||||
const store = createStore(
|
||||
reducer,
|
||||
undefined,
|
||||
composeWithDevTools(applyMiddleware(workerMiddlware, epicMw)),
|
||||
);
|
||||
worker.onmessage = ev => store.dispatch(ev.data);
|
||||
worker.onmessage = ev => {
|
||||
console.log('data', ev.data);
|
||||
store.dispatch(ev.data);
|
||||
}
|
||||
|
||||
epicMw.run(epics);
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { createStandardAction, createAsyncAction, ActionType } from 'typesafe-actions';
|
||||
import { IError } from '@mixer/retrieval';
|
||||
import { ActionType, createAsyncAction, createStandardAction } from 'typesafe-actions';
|
||||
import { Stats } from 'webpack';
|
||||
import { IBundlephobiaStats } from './services/bundlephobia-api';
|
||||
|
||||
/**
|
||||
* Requests analysis to run for a bundle.
|
||||
|
@ -10,7 +11,21 @@ export const doAnalysis = createAsyncAction(
|
|||
'doAnalysisSuccess',
|
||||
'doAnalysisFailure',
|
||||
'doAnalysisCancel',
|
||||
)<{ url: string }, { url: string; data: Stats.ToJsonOutput }, IError & { url: string }, { url: string }>();
|
||||
)<
|
||||
{ url: string },
|
||||
{ url: string; data: Stats.ToJsonOutput },
|
||||
IError & { url: string },
|
||||
{ url: string }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Requests analysis to run for a bundle.
|
||||
*/
|
||||
export const fetchBundlephobiaData = createAsyncAction(
|
||||
'getBundlephobiaRequest',
|
||||
'getBundlephobiaSuccess',
|
||||
'getBundlephobiaFailure',
|
||||
)<{ name: string }, IBundlephobiaStats, IError & { name: string }>();
|
||||
|
||||
/**
|
||||
* Loads bundles for all the requested urls.
|
||||
|
@ -32,4 +47,5 @@ export type CompareAction = ActionType<
|
|||
| typeof webworkerErrored
|
||||
| typeof loadAllUrls
|
||||
| typeof clearLoadedBundles
|
||||
| typeof fetchBundlephobiaData
|
||||
>;
|
|
@ -0,0 +1,35 @@
|
|||
import { combineEpics, Epic as PlainEpic } from 'redux-observable';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, filter, map, mergeMap } from 'rxjs/operators';
|
||||
import { isActionOf } from 'typesafe-actions';
|
||||
import { CompareAction, doAnalysis, fetchBundlephobiaData, loadAllUrls } from './actions';
|
||||
import { IAppState } from './reducer';
|
||||
import { IBundlephobiaApi } from './services/bundlephobia-api';
|
||||
import { HttpError } from './services/http-error';
|
||||
|
||||
export interface IServices {
|
||||
bundlephobia: IBundlephobiaApi;
|
||||
}
|
||||
|
||||
type Epic = PlainEpic<CompareAction, CompareAction, IAppState, IServices>;
|
||||
|
||||
const loadAllUrlsEpic: Epic = actions =>
|
||||
actions.pipe(
|
||||
filter(isActionOf(loadAllUrls)),
|
||||
mergeMap(action => action.payload.urls.map(url => doAnalysis.request({ url }))),
|
||||
);
|
||||
|
||||
const loadBundlephobiaInfo: Epic = (actions, _, { bundlephobia }) =>
|
||||
actions.pipe(
|
||||
filter(isActionOf(fetchBundlephobiaData.request)),
|
||||
mergeMap(action =>
|
||||
bundlephobia.getBundleStats(action.payload.name).pipe(
|
||||
map(fetchBundlephobiaData.success),
|
||||
catchError((err: HttpError) =>
|
||||
of(fetchBundlephobiaData.failure({ ...err.retrievalError, name: action.payload.name })),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
export const epics = combineEpics(loadAllUrlsEpic, loadBundlephobiaInfo);
|
|
@ -1,16 +1,17 @@
|
|||
import {
|
||||
error,
|
||||
idleRetrieval,
|
||||
IRetrievalError,
|
||||
Retrieval,
|
||||
RetrievalState,
|
||||
idleRetrieval,
|
||||
workingRetrival,
|
||||
success,
|
||||
error,
|
||||
IRetrievalError,
|
||||
workingRetrival,
|
||||
} from '@mixer/retrieval';
|
||||
import { createSelector } from 'reselect';
|
||||
import { CompareAction, doAnalysis, clearLoadedBundles } from './actions';
|
||||
import { getType } from 'typesafe-actions';
|
||||
import { Stats } from 'webpack';
|
||||
import { clearLoadedBundles, CompareAction, doAnalysis, fetchBundlephobiaData } from './actions';
|
||||
import { IBundlephobiaStats } from './services/bundlephobia-api';
|
||||
|
||||
/**
|
||||
* Possible error codes.
|
||||
|
@ -25,15 +26,21 @@ export interface IAppState {
|
|||
* List of bundle loading states.
|
||||
*/
|
||||
bundles: Readonly<{ [url: string]: Retrieval<Stats.ToJsonOutput> }>;
|
||||
|
||||
/**
|
||||
* Mapping of bundlephobia dependency information.
|
||||
*/
|
||||
bundlephobiaData: { [moduleName: string]: Retrieval<IBundlephobiaStats> };
|
||||
}
|
||||
|
||||
declare const INITIAL_FILES: string[];
|
||||
|
||||
const initialState: IAppState = {
|
||||
bundles: INITIAL_FILES.reduce((acc, key) => ({ ...acc, [key]: idleRetrieval }), {}),
|
||||
bundlephobiaData: {},
|
||||
};
|
||||
|
||||
export const reducer = (state = initialState, action: CompareAction) => {
|
||||
export const reducer = (state = initialState, action: CompareAction): IAppState => {
|
||||
switch (action.type) {
|
||||
case getType(clearLoadedBundles):
|
||||
return {
|
||||
|
@ -66,6 +73,27 @@ export const reducer = (state = initialState, action: CompareAction) => {
|
|||
...state,
|
||||
bundles: { ...state.bundles, [action.payload.url]: idleRetrieval },
|
||||
};
|
||||
case getType(fetchBundlephobiaData.request):
|
||||
return {
|
||||
...state,
|
||||
bundlephobiaData: { ...state.bundlephobiaData, [action.payload.name]: workingRetrival },
|
||||
};
|
||||
case getType(fetchBundlephobiaData.success):
|
||||
return {
|
||||
...state,
|
||||
bundlephobiaData: {
|
||||
...state.bundlephobiaData,
|
||||
[action.payload.name]: success(action.payload),
|
||||
},
|
||||
};
|
||||
case getType(fetchBundlephobiaData.failure):
|
||||
return {
|
||||
...state,
|
||||
bundlephobiaData: {
|
||||
...state.bundlephobiaData,
|
||||
[action.payload.name]: error(action.payload),
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
@ -145,3 +173,9 @@ export const getGroupedBundleState = createSelector(
|
|||
return status;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieves bundlephovia data for the given module name.
|
||||
*/
|
||||
export const getBundlephobiaData = (state: IAppState, moduleName: string) =>
|
||||
state.bundlephobiaData[moduleName] || idleRetrieval;
|
|
@ -0,0 +1,62 @@
|
|||
import { from, Observable } from 'rxjs';
|
||||
import { mergeMap } from 'rxjs/operators';
|
||||
import { HttpError } from './http-error';
|
||||
|
||||
export interface IDependencyStat {
|
||||
name: string;
|
||||
approximateSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats returned from the bundlephobia API.
|
||||
*/
|
||||
export interface IBundlephobiaStats {
|
||||
dependencyCount: number;
|
||||
size: number;
|
||||
gzip: number;
|
||||
name: string;
|
||||
hasJSNext: boolean;
|
||||
hasJSModule: boolean;
|
||||
hasSideEffects: true;
|
||||
version: string;
|
||||
repository: string;
|
||||
topLevelExports?: string[];
|
||||
dependencySizes: IDependencyStat[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type that gets bundle stats from the bundlephobia API.
|
||||
*/
|
||||
export interface IBundlephobiaApi {
|
||||
getBundleStats(bundle: string): Observable<IBundlephobiaStats>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundlephoba API implementation using browser fetch.
|
||||
*/
|
||||
export const fetchBundlephobiaApi: IBundlephobiaApi = {
|
||||
getBundleStats(bundle) {
|
||||
const url = `https://bundlephobia.com/api/size?package=${bundle}`;
|
||||
return from(
|
||||
fetch(url, {
|
||||
headers: {
|
||||
'X-Bundlephobia-User': '@mixer/webpack-bundle-compare',
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
mergeMap(async res => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
throw new HttpError(url, {
|
||||
statusCode: res.status,
|
||||
serviceError: {
|
||||
errorCode: 0,
|
||||
errorMessage: await res.text(),
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import { IError } from '@mixer/retrieval';
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(public readonly url: string, public readonly retrievalError: IError) {
|
||||
super(
|
||||
`An unexpected ${retrievalError.statusCode} occurred calling ${url}: "${
|
||||
retrievalError.serviceError ? retrievalError.serviceError.errorMessage : 'unknown'
|
||||
}"`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,24 +7,36 @@ const anyPathSeparator = /\/|\\/;
|
|||
* Replaces the loader path in an identifier.
|
||||
* '(<loader expression>!)?/path/to/module.js'
|
||||
*/
|
||||
const replaceLoaderInIdentifier = (identifier: string) => identifier.replace(loaderRegex, '');
|
||||
export const replaceLoaderInIdentifier = (identifier: string) =>
|
||||
identifier.replace(loaderRegex, '');
|
||||
|
||||
/**
|
||||
* Normalizes an identifier so that it carries over time.
|
||||
*/
|
||||
const normalizeIdentifier = (identifier: string) =>
|
||||
export const normalizeIdentifier = (identifier: string) => identifier.replace(/ [a-z0-9]+$/, '');
|
||||
|
||||
/**
|
||||
* Normalizes an identifier so that it carries over time.
|
||||
*/
|
||||
const humanReadableIdentifier = (identifier: string) =>
|
||||
replaceLoaderInIdentifier(identifier).replace(/ [a-z0-9]+$/, '');
|
||||
|
||||
// tslint:disable-next-line
|
||||
const cacheByArg = <T extends Function>(fn: T): T => {
|
||||
const cacheMap = new WeakMap<any, any>();
|
||||
return function(this: any, arg: any) {
|
||||
if (!cacheMap.has(arg)) {
|
||||
return function(this: any, arg1: any, arg2: any) {
|
||||
let valueMap = cacheMap.get(arg1);
|
||||
if (!valueMap) {
|
||||
valueMap = arg2 && typeof arg2 === 'object' ? new WeakMap<any, any>() : new Map<any, any>();
|
||||
}
|
||||
|
||||
if (!valueMap.has(arg2)) {
|
||||
const value = fn.apply(this, arguments);
|
||||
cacheMap.set(arg, value);
|
||||
valueMap.set(arg2, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
return cacheMap.get(arg);
|
||||
return valueMap.get(arg2);
|
||||
} as any;
|
||||
};
|
||||
|
||||
|
@ -35,6 +47,22 @@ export const getTotalChunkSize = cacheByArg((stats: Stats.ToJsonOutput) =>
|
|||
stats.chunks!.reduce((sum, chunk) => sum + chunk.size, 0),
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the size of the entry chunk(s).
|
||||
*/
|
||||
export const getEntryChunkSize = cacheByArg((stats: Stats.ToJsonOutput) =>
|
||||
stats.chunks!.filter(c => c.entry).reduce((sum, chunk) => sum + chunk.size, 0),
|
||||
);
|
||||
|
||||
/**
|
||||
* Bitfield of module types.
|
||||
*/
|
||||
export const enum ImportType {
|
||||
Unknown = 0,
|
||||
EsModule = 1 << 1,
|
||||
CommonJs = 1 << 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Type containing metadata about an external node module.
|
||||
*/
|
||||
|
@ -53,50 +81,273 @@ export interface INodeModule {
|
|||
* Modules imported from this dependency.
|
||||
*/
|
||||
modules: Stats.FnModules[];
|
||||
|
||||
/**
|
||||
* Type of the node module.
|
||||
*/
|
||||
importType: ImportType;
|
||||
}
|
||||
|
||||
const getNodeModuleFromIdentifier = (identifier: string): string | null => {
|
||||
const parts = replaceLoaderInIdentifier(identifier).split(anyPathSeparator);
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] !== 'node_modules') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let packageName = parts[i + 1];
|
||||
if (packageName[0] === '@') {
|
||||
packageName += '/' + parts[i + 2];
|
||||
}
|
||||
|
||||
return packageName;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get webpack modules, either globally ro in a single chunk.
|
||||
*/
|
||||
export const getWebpackModules = (stats: Stats.ToJsonOutput, filterToChunk?: number) => {
|
||||
let modules: Stats.FnModules[] = [];
|
||||
if (!stats.modules) {
|
||||
return modules;
|
||||
}
|
||||
|
||||
for (const parent of stats.modules) {
|
||||
if (filterToChunk !== undefined && !parent.chunks.includes(filterToChunk)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If it has nested modules, it's a concatened chunk. Remove the
|
||||
// concatenation and only emit children.
|
||||
if (parent.modules) {
|
||||
modules = modules.concat(parent.modules);
|
||||
} else {
|
||||
modules.push(parent);
|
||||
}
|
||||
}
|
||||
|
||||
return modules;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the type of import of the given module.
|
||||
*/
|
||||
export const getImportType = (importedModule: Stats.FnModules) =>
|
||||
(importedModule.reasons as any).reduce(
|
||||
(flags: number, reason: { type: string }) =>
|
||||
(flags |= reason.type.includes('cjs')
|
||||
? ImportType.CommonJs
|
||||
: reason.type.includes('harmony')
|
||||
? ImportType.EsModule
|
||||
: ImportType.Unknown),
|
||||
0,
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the number of dependencies.
|
||||
*/
|
||||
export const getNodeModules = cacheByArg(
|
||||
(stats: Stats.ToJsonOutput): ReadonlyArray<INodeModule> => {
|
||||
const modules: { [key: string]: INodeModule } = {};
|
||||
export const getNodeModules = cacheByArg((stats: Stats.ToJsonOutput, inChunk?: number) => {
|
||||
const modules: { [key: string]: INodeModule } = {};
|
||||
|
||||
for (const importedModule of stats.modules!) {
|
||||
const parts = replaceLoaderInIdentifier(importedModule.identifier).split(anyPathSeparator);
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] !== 'node_modules') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let packageName = parts[i + 1];
|
||||
if (packageName[0] === '@') {
|
||||
packageName += '/' + parts[i + 2];
|
||||
}
|
||||
|
||||
const previous = modules[packageName];
|
||||
if (!previous) {
|
||||
modules[packageName] = {
|
||||
name: packageName,
|
||||
totalSize: importedModule.size,
|
||||
modules: [importedModule],
|
||||
};
|
||||
} else {
|
||||
previous.totalSize += importedModule.size;
|
||||
previous.modules.push(importedModule);
|
||||
}
|
||||
break;
|
||||
}
|
||||
for (const importedModule of getWebpackModules(stats, inChunk)) {
|
||||
const packageName = getNodeModuleFromIdentifier(importedModule.identifier);
|
||||
if (!packageName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Object.values(modules);
|
||||
},
|
||||
const moduleType = getImportType(importedModule);
|
||||
const previous = modules[packageName];
|
||||
if (!previous) {
|
||||
modules[packageName] = {
|
||||
name: packageName,
|
||||
totalSize: importedModule.size,
|
||||
modules: [importedModule],
|
||||
importType: moduleType,
|
||||
};
|
||||
} else {
|
||||
previous.totalSize += importedModule.size;
|
||||
previous.importType |= moduleType;
|
||||
previous.modules.push(importedModule);
|
||||
}
|
||||
}
|
||||
|
||||
return modules;
|
||||
});
|
||||
|
||||
/**
|
||||
* Types of importable modules.
|
||||
*/
|
||||
export const enum ModuleType {
|
||||
Javascript,
|
||||
Style,
|
||||
External,
|
||||
NodeModule,
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies a module type, given an ID.
|
||||
*/
|
||||
export const identifyModuleType = (id: string): ModuleType => {
|
||||
if (id.includes('style-loader') || id.includes('css-loader')) {
|
||||
return ModuleType.Style;
|
||||
}
|
||||
|
||||
if (id.startsWith('external ')) {
|
||||
return ModuleType.External;
|
||||
}
|
||||
|
||||
if (id.includes('node_modules')) {
|
||||
return ModuleType.NodeModule;
|
||||
}
|
||||
|
||||
return ModuleType.Javascript;
|
||||
};
|
||||
|
||||
/**
|
||||
* Grouped output comparing an old and new node module.
|
||||
*/
|
||||
export interface IWebpackModuleComparisonOutput {
|
||||
identifier: string;
|
||||
readableId: string;
|
||||
name: string;
|
||||
type: ModuleType;
|
||||
fromSize: number;
|
||||
toSize: number;
|
||||
nodeModule?: string;
|
||||
old?: Stats.FnModules;
|
||||
new?: Stats.FnModules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a grouped comparison of the old and new modules from the stats.
|
||||
*/
|
||||
export const compareAllModules = (
|
||||
oldStats: Stats.ToJsonOutput,
|
||||
newStats: Stats.ToJsonOutput,
|
||||
inChunk?: number,
|
||||
) => {
|
||||
const oldModules = getWebpackModules(oldStats, inChunk);
|
||||
const newModules = getWebpackModules(newStats, inChunk);
|
||||
|
||||
const output: { [name: string]: IWebpackModuleComparisonOutput } = {};
|
||||
for (const m of oldModules) {
|
||||
const normalized = normalizeIdentifier(m.identifier);
|
||||
output[normalized] = {
|
||||
identifier: normalized,
|
||||
readableId: humanReadableIdentifier(m.identifier),
|
||||
name: replaceLoaderInIdentifier(m.name),
|
||||
type: identifyModuleType(m.identifier),
|
||||
nodeModule: getNodeModuleFromIdentifier(m.identifier) || undefined,
|
||||
toSize: 0,
|
||||
fromSize: m.size,
|
||||
old: m,
|
||||
};
|
||||
}
|
||||
|
||||
for (const m of newModules) {
|
||||
const normalized = normalizeIdentifier(m.identifier);
|
||||
if (output[normalized]) {
|
||||
output[normalized].new = m;
|
||||
output[normalized].toSize = m.size;
|
||||
} else {
|
||||
output[normalized] = {
|
||||
identifier: normalized,
|
||||
readableId: humanReadableIdentifier(m.identifier),
|
||||
name: replaceLoaderInIdentifier(m.name),
|
||||
type: identifyModuleType(m.identifier),
|
||||
nodeModule: getNodeModuleFromIdentifier(m.identifier) || undefined,
|
||||
fromSize: 0,
|
||||
toSize: m.size,
|
||||
new: m,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* Grouped output comparing an old and new node module.
|
||||
*/
|
||||
export interface INodeModuleComparisonOutput {
|
||||
name: string;
|
||||
old?: INodeModule;
|
||||
new?: INodeModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a grouped comparison of the old and new modules from the stats.
|
||||
*/
|
||||
export const compareNodeModules = (
|
||||
oldStats: Stats.ToJsonOutput,
|
||||
newStats: Stats.ToJsonOutput,
|
||||
inChunk?: number,
|
||||
) => {
|
||||
const oldModules = getNodeModules(oldStats, inChunk);
|
||||
const newModules = getNodeModules(newStats, inChunk);
|
||||
|
||||
const output: { [name: string]: INodeModuleComparisonOutput } = {};
|
||||
for (const name of Object.keys(oldModules)) {
|
||||
output[name] = { name, old: oldModules[name] };
|
||||
}
|
||||
|
||||
for (const name of Object.keys(newModules)) {
|
||||
if (output[name]) {
|
||||
output[name].new = newModules[name];
|
||||
} else {
|
||||
output[name] = { name, new: oldModules[name] };
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(output);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the number of node modules.
|
||||
*/
|
||||
export const getNodeModuleSize = cacheByArg((stats: Stats.ToJsonOutput, inChunk?: number) =>
|
||||
Object.values(getNodeModules(stats, inChunk)).reduce((acc, m) => m.totalSize + acc, 0),
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the number of node modules.
|
||||
*/
|
||||
export const getNodeModuleCount = (stats: Stats.ToJsonOutput) => getNodeModules(stats).length;
|
||||
export const getNodeModuleCount = (stats: Stats.ToJsonOutput, inChunk?: number) =>
|
||||
Object.keys(getNodeModules(stats, inChunk)).length;
|
||||
|
||||
/**
|
||||
* Gets the number of node modules.
|
||||
*/
|
||||
export const getTreeShakablePercent = cacheByArg((stats: Stats.ToJsonOutput, inChunk?: number) => {
|
||||
const modules = Object.values(getNodeModules(stats, inChunk));
|
||||
if (modules.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let harmony = 0;
|
||||
for (const { importType: moduleType } of modules) {
|
||||
if (moduleType & ImportType.EsModule && !(moduleType & ImportType.CommonJs)) {
|
||||
harmony++;
|
||||
}
|
||||
}
|
||||
|
||||
return harmony / modules.length;
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets the number of node modules.
|
||||
*/
|
||||
export const getTotalModuleCount = (stats: Stats.ToJsonOutput, inChunk?: number) =>
|
||||
getWebpackModules(stats, inChunk)!.length;
|
||||
|
||||
/**
|
||||
* Gets the number of node modules.
|
||||
*/
|
||||
export const getAverageChunkSize = (stats: Stats.ToJsonOutput) =>
|
||||
stats.chunks!.reduce((acc, c) => acc + c.size / stats.chunks!.length, 0);
|
||||
|
||||
export interface IModuleTreeNode {
|
||||
/**
|
||||
|
@ -122,7 +373,8 @@ export interface IModuleTreeNode {
|
|||
|
||||
export const getModuleTree = cacheByArg(
|
||||
(stats: Stats.ToJsonOutput): IModuleTreeNode => {
|
||||
const building: IModuleTreeNode[] = stats.modules!.map(m => ({
|
||||
const modules = getWebpackModules(stats);
|
||||
const building: IModuleTreeNode[] = modules.map(m => ({
|
||||
id: Number(m.id),
|
||||
name: m.name,
|
||||
size: m.size,
|
||||
|
@ -130,7 +382,7 @@ export const getModuleTree = cacheByArg(
|
|||
}));
|
||||
|
||||
const entries: IModuleTreeNode[] = [];
|
||||
for (const item of stats.modules!) {
|
||||
for (const item of modules) {
|
||||
const self = building[Number(item.id)];
|
||||
const { reasons } = item as any;
|
||||
if (reasons.some((r: any) => r.type === 'single entry')) {
|
||||
|
@ -163,35 +415,7 @@ export interface IModuleDiffEntry {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets metadata about the size difference of all modules.
|
||||
* Gets all direct imports of the given node module.
|
||||
*/
|
||||
export const getModulesDiff = (
|
||||
first: Stats.ToJsonOutput,
|
||||
last: Stats.ToJsonOutput,
|
||||
): IModuleDiffEntry[] => {
|
||||
const moduleMap: { [id: string]: IModuleDiffEntry } = {};
|
||||
for (const m of first.modules!) {
|
||||
moduleMap[normalizeIdentifier(m.identifier)] = {
|
||||
name: replaceLoaderInIdentifier(m.name),
|
||||
fromSize: m.size,
|
||||
toSize: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const changed: IModuleDiffEntry[] = [];
|
||||
for (const m of last.modules!) {
|
||||
const id = normalizeIdentifier(m.identifier);
|
||||
const existing = moduleMap[id];
|
||||
if (existing) {
|
||||
existing.toSize = m.size;
|
||||
} else {
|
||||
moduleMap[id] = {
|
||||
name: replaceLoaderInIdentifier(m.name),
|
||||
fromSize: 0,
|
||||
toSize: m.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return changed.concat(Object.values(moduleMap));
|
||||
};
|
||||
export const getDirectImportsOfNodeModule = (stats: Stats.ToJsonOutput, name: string) =>
|
||||
stats.modules!.filter(m => getNodeModuleFromIdentifier(m.name) === name);
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { Stats } from 'webpack';
|
||||
|
||||
declare module 'webpack' {
|
||||
namespace Stats {
|
||||
// tslint:disable-next-line
|
||||
export interface Reason {
|
||||
moduleId: number;
|
||||
moduleIdentifier: string;
|
||||
module: string;
|
||||
moduleName: string;
|
||||
type: string;
|
||||
loc: string;
|
||||
userRequest: string;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
declare module "worker-loader!*" {
|
||||
declare module 'worker-loader!*' {
|
||||
class WebpackWorker extends Worker {
|
||||
constructor();
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import { Observable, from } from 'rxjs';
|
||||
import { CompareAction, doAnalysis } from '../actions';
|
||||
import { ErrorCode } from '../reducer';
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { Stats } from 'webpack';
|
||||
import { CompareAction, doAnalysis } from '../redux/actions';
|
||||
import { ErrorCode } from '../redux/reducer';
|
||||
import { Semaphore } from './semaphore';
|
||||
import { inflate } from 'pako';
|
||||
import { decode } from 'msgpack-lite';
|
||||
|
||||
const downloadSemaphore = new Semaphore(1);
|
||||
|
||||
|
@ -13,7 +16,7 @@ export function download(url: string): Observable<CompareAction> {
|
|||
try {
|
||||
const res = await fetch(url);
|
||||
return res.ok
|
||||
? doAnalysis.success({ url, data: await res.json() })
|
||||
? doAnalysis.success({ url, data: processDownload(await res.arrayBuffer()) })
|
||||
: doAnalysis.failure({
|
||||
url,
|
||||
statusCode: res.status,
|
||||
|
@ -23,6 +26,7 @@ export function download(url: string): Observable<CompareAction> {
|
|||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('failed', e.stack);
|
||||
return doAnalysis.failure({
|
||||
url,
|
||||
statusCode: 500,
|
||||
|
@ -37,3 +41,16 @@ export function download(url: string): Observable<CompareAction> {
|
|||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
function processDownload(rawResponse: ArrayBuffer): Stats.ToJsonOutput {
|
||||
const asArray = new Uint8Array(rawResponse);
|
||||
const data = asArray[0] === 0x1f && asArray[1] === 0x8b ? inflate(asArray) : asArray;
|
||||
const stats: Stats.ToJsonOutput =
|
||||
data[0] === '{'.charCodeAt(0) ? JSON.parse(new TextDecoder().decode(data)) : decode(data);
|
||||
|
||||
if (stats.modules) {
|
||||
stats.modules = stats.modules.filter(m => m.chunks.length !== 0);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Observable, EMPTY } from 'rxjs';
|
||||
import { CompareAction, webworkerErrored, doAnalysis } from '../actions';
|
||||
import { ErrorCode } from '../reducer';
|
||||
import { EMPTY, Observable } from 'rxjs';
|
||||
import { getType } from 'typesafe-actions';
|
||||
import { CompareAction, doAnalysis, webworkerErrored } from '../redux/actions';
|
||||
import { ErrorCode } from '../redux/reducer';
|
||||
import { download } from './download';
|
||||
|
||||
const ctx: Worker = self as any;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Observable, Subject, of } from 'rxjs';
|
||||
import { Observable, of, Subject } from 'rxjs';
|
||||
import { filter, take } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"extends": ["tslint:recommended", "tslint-config-prettier"],
|
||||
"rules": {
|
||||
"object-literal-sort-keys": false,
|
||||
"no-var-requires": false,
|
||||
"no-unused-expression": false
|
||||
"no-bitwise": false,
|
||||
"variable-name": false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const path = require('path');
|
||||
const { DefinePlugin } = require('webpack');
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
mode: 'development',
|
||||
|
@ -47,6 +48,7 @@ module.exports = {
|
|||
new HtmlWebpackPlugin({
|
||||
title: 'Webpack Bundle Compare',
|
||||
}),
|
||||
new CopyPlugin([{ from: path.join(__dirname, 'public'), to: 'public' }]),
|
||||
new DefinePlugin({
|
||||
INITIAL_FILES: process.env.WBC_FILES
|
||||
? JSON.stringify(process.env.WBC_FILES.split(','))
|
||||
|
@ -54,6 +56,6 @@ module.exports = {
|
|||
}),
|
||||
],
|
||||
devServer: {
|
||||
contentBase: path.join(__dirname, 'public'),
|
||||
disableHostCheck: true,
|
||||
},
|
||||
};
|
||||
|
|
Загрузка…
Ссылка в новой задаче