├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.yml ├── .gitignore ├── .moniteurrc.default.yml ├── .moniteurrc.development.yml ├── .travis.yml ├── CHANGELOG.md ├── Procfile ├── README.md ├── __tests__ └── utils-test.js ├── app.json ├── bin ├── moniteur └── moniteur.js ├── client ├── javascripts │ ├── app.js │ └── assets-graph-theme.js └── stylesheets │ └── style.css ├── docs ├── screenshot.png ├── welcome-process.key └── welcome-process │ ├── welcome-process.001.png │ ├── welcome-process.002.png │ ├── welcome-process.003.png │ ├── welcome-process.004.png │ └── welcome-process.005.png ├── fixtures ├── abc.js ├── main.css ├── main2.css └── xyz.js ├── lib ├── db.js ├── jsparser.js ├── record.js ├── sensors.js └── utils.js ├── package.json ├── routes ├── index.js ├── metrics.js └── settings.js ├── views ├── _faq.pug ├── error.pug ├── index.pug ├── layout.pug ├── settings.pug ├── support.pug └── welcome.pug ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["es2015", {"modules": false}]] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs 2 | ; See editorconfig.org 3 | 4 | ; top-most EditorConfig file 5 | root = true 6 | 7 | [*] 8 | indent_style = space 9 | indent_size = 2 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | __tests__/fixtures/* 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: "standard" 2 | plugins: 3 | - jasmine 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | bower_components 5 | .moniteurdb # local database 6 | .vscode 7 | .moniteurdb 8 | dist 9 | 10 | # Comment this line if you'd like to version your custom configuration 11 | /.moniteurrc.yml 12 | -------------------------------------------------------------------------------- /.moniteurrc.default.yml: -------------------------------------------------------------------------------- 1 | # Default configuration 2 | # 3 | # To override these values, multiple options: 4 | # - Environment variable: 5 | # DB__REDIS_URL="redis://localhost:6379" npm start 6 | # - Arguments: 7 | # npm start -- --db:redis_url "redis://localhost:6379" 8 | # - .moniteurrc.yml (override file): 9 | # copy and paste the contents of this file to .moniteurrc.yml 10 | # and tweak the values as desired 11 | db: 12 | directory: ".moniteurdb" 13 | -------------------------------------------------------------------------------- /.moniteurrc.development.yml: -------------------------------------------------------------------------------- 1 | # Development configuration 2 | assets: 3 | FT desktop CSS bundle: 4 | - "http://s1.ft-static.com/m/style/90975546/bundles/core.css" 5 | # Commented until https://github.com/t32k/stylestats/issues/158 is fixed 6 | # - "http://navigation.webservices.ft.com/v1/navigation/ft/css/style.min.css" 7 | # - "http://s1.ft-static.com/m/style/5c37627a/bundles/nonArticle.css" 8 | Guardian's CSS: 9 | - "http://assets.guim.co.uk/stylesheets/global.css" 10 | # Commented until https://github.com/t32k/stylestats/issues/158 is fixed 11 | # - "http://assets.guim.co.uk/stylesheets/head.default.css" 12 | Main CSS: "fixtures/main.css" 13 | Another CSS: "fixtures/main2.css" 14 | ABC Script: "fixtures/abc.js" 15 | My XYZ Script: "fixtures/xyz.js" 16 | My Bundle of Scripts: 17 | - "fixtures/abc.js" 18 | - "fixtures/xyz.js" 19 | A remote Script: "http://assets.guim.co.uk/javascripts/bootstraps/app.js" 20 | # Uncomment to use Redis instead of the filesystem 21 | # db: 22 | # redis_url: redis://localhost:6379 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8.9.4 4 | before_install: yarn global add greenkeeper-lockfile@1 5 | before_script: greenkeeper-lockfile-update 6 | after_script: greenkeeper-lockfile-upload 7 | env: 8 | global: 9 | secure: g+LI0n3NmGqXlrFPX3gwjf+EOjrDtdl2gpsBFTt7rL3mik8xdEhVxMu4kFBI+CByHP/JiwvYSaVgqw12bf2ATPLcWDwwmjmc3dPJUIHMAkGicLMBYuJRZdQIfB6j8OY1jA8fqCs9xEU691V3N8hOYYsNWY5d4ySMiGaRtvIWM5Q= 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | ## [0.7.15](https://github.com/kaelig/moniteur/compare/v0.7.14...v0.7.15) (2017-05-03) 7 | 8 | 9 | ### Bug Fixes 10 | 11 | * **package:** update yargs to version 8.0.1 ([#22](https://github.com/kaelig/moniteur/issues/22)) ([f211aed](https://github.com/kaelig/moniteur/commit/f211aed)) 12 | * **yarn:** upgrade yarn dependency tree ([cc87fd7](https://github.com/kaelig/moniteur/commit/cc87fd7)) 13 | 14 | 15 | 16 | 17 | ## [0.7.14](https://github.com/kaelig/moniteur/compare/v0.7.13...v0.7.14) (2017-05-03) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * **build:** Use Yarn to fix an issue where some dependency tree relationship would fail ([b1572a6](https://github.com/kaelig/moniteur/commit/b1572a6)) 23 | * **utils:** Fix an issue where querystrings would make extension recognition fail ([40209c0](https://github.com/kaelig/moniteur/commit/40209c0)) 24 | 25 | 26 | 27 | 28 | ## [0.7.13](https://github.com/kaelig/moniteur/compare/v0.7.12...v0.7.13) (2017-05-03) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * **package:** update babel-loader to version 7.0.0 ([d005e60](https://github.com/kaelig/moniteur/commit/d005e60)) 34 | 35 | 36 | 37 | 38 | ## [0.7.12](https://github.com/kaelig/moniteur/compare/v0.7.11...v0.7.12) (2017-03-05) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * **assets.json:** Fix an issue where /assets.json wouldn't work ([68413ba](https://github.com/kaelig/moniteur/commit/68413ba)) 44 | 45 | 46 | 47 | 48 | ## [0.7.11](https://github.com/kaelig/moniteur/compare/v0.7.10...v0.7.11) (2017-03-05) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * **route:** Fix routes for static pages ([c2a293b](https://github.com/kaelig/moniteur/commit/c2a293b)) 54 | 55 | 56 | 57 | 58 | ## [0.7.10](https://github.com/kaelig/moniteur/compare/v0.7.9...v0.7.10) (2017-03-05) 59 | 60 | 61 | 62 | 63 | ## [0.7.9](https://github.com/kaelig/moniteur/compare/v0.7.8...v0.7.9) (2017-03-05) 64 | 65 | 66 | ### Bug Fixes 67 | 68 | * **package:** update babel-preset-babili to version 0.0.11 ([b0f3468](https://github.com/kaelig/moniteur/commit/b0f3468)) 69 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | record: npm run record 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # moniteur [![Build Status](https://travis-ci.org/kaelig/moniteur.svg)](https://travis-ci.org/kaelig/moniteur) [![npm version](https://badge.fury.io/js/moniteur.svg)](http://badge.fury.io/js/moniteur) [![Greenkeeper badge](https://badges.greenkeeper.io/kaelig/moniteur.svg)](https://greenkeeper.io/) [![Standard Version](https://img.shields.io/badge/release-standard%20version-brightgreen.svg)](https://github.com/conventional-changelog/standard-version) 2 | 3 | For people who care about keeping an eye on their CSS and JavaScript file sizes. 4 | 5 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 6 | 7 | **[Demo & Documentation](https://moniteur.herokuapp.com/)** 8 | 9 | ![ ](https://cdn.rawgit.com/kaelig/moniteur/8cc242f6fefa495a0b8746bca41f5980a76e80c7/docs/screenshot.png) 10 | 11 | ---- 12 | 13 | ## CLI 14 | 15 | Moniteur is also available as a command line interface: 16 | 17 | ```bash 18 | npm install -g moniteur 19 | ``` 20 | 21 | ### Usage: 22 | 23 | ```bash 24 | Usage: moniteur [options] [command] 25 | 26 | 27 | Commands: 28 | 29 | record record a snapshot of all asset metrics 30 | serve start the server to show metrics in the browser 31 | assets display the list of assets loaded by moniteur 32 | help display this helpful message 33 | 34 | Options: 35 | 36 | -h, --help output usage information 37 | -V, --version output the version number 38 | ``` 39 | 40 | Configuration: edit the `.moniteurrc.yml` file in the current directory. 41 | 42 | ### Database configuration 43 | 44 | For now, two types of storage are supported: Redis and local filesystem. 45 | 46 | A Redis URL can be passed through an environment variable, 47 | instead of having it stored in the configuration file: 48 | 49 | ``` 50 | DB__REDIS_URL=redis://rediscloud:XXXX@pub-redis-XXXX.us-east-X-X.X.ec2.garantiadata.com:13714 51 | ``` 52 | 53 | Run your application like this: 54 | ``` 55 | DB__REDIS_URL=redis://url moniteur [options] 56 | ``` 57 | 58 | Note that `REDIS_URL` and `REDISCLOUD_URL` are also valid environment variables. 59 | 60 | ### Development 61 | 62 | Clone the repository and run: 63 | 64 | ```bash 65 | npm run dev 66 | ``` 67 | 68 | ## Asset monitor API 69 | 70 | ### Record data 71 | 72 | Takes a snapshot of asset metrics and stores them in the `.moniteur/` 73 | directory. 74 | 75 | ```bash 76 | moniteur record 77 | ``` 78 | 79 | 80 | ## HTTP API 81 | 82 | ### View a JSON representation of all loaded assets 83 | 84 | `/assets.json` 85 | 86 | #### JSON data object for HighCharts (providing the asset name's hash) 87 | 88 | Since forever: 89 | `/metrics/stylesheets/adf6e9c154cb57a818f7fb407085bff6` 90 | 91 | Between two dates: 92 | `/metrics/stylesheets/adf6e9c154cb57a818f7fb407085bff6/1015711104475..1415711104475` 93 | 94 | 95 | ## License 96 | 97 | MIT 98 | 99 | ## Acknowledgments 100 | 101 | _Merci_ to https://github.com/t32k/stylestats, which has been 102 | a great source of inspiration. 103 | 104 | And thanks to @oncletom for helping building the early versions of moniteur. 105 | 106 | ## Roadmap 107 | 108 | - [x] Make moniteur a working node module 109 | - [x] Run as some sort of daemon that monitors asset metrics every X seconds 110 | - [x] Monitor JavaScript files 111 | - [ ] Unit / Integration tests 112 | - [ ] Option to filter graphs by time range (last 7 days, last 30 days, last year) 113 | - [ ] Slack Bot 114 | 115 | ### Ideas 116 | 117 | - [ ] Providing a page's URL, scrape all assets out of it and analyse them 118 | - [ ] Parse all assets in a particular directory 119 | - [ ] Asset size budget limits 120 | - [ ] Email alert when budget is almost reached or exceeded 121 | - [ ] Weekly email recaps 122 | -------------------------------------------------------------------------------- /__tests__/utils-test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, expect, jasmine */ 2 | const utils = require('../lib/utils') 3 | const md5 = require('md5') 4 | 5 | const assets = { 6 | SingleResource: 'http://foo.com/something.css', 7 | Bundle: [ 8 | 'http://bar.com/something.css' 9 | ] 10 | } 11 | 12 | describe('hashedAssets', () => { 13 | it('should hash assets', () => { 14 | const hashedAssets = utils.hashAssets(assets) 15 | expect(hashedAssets[md5('SingleResource')]).toEqual(jasmine.any(String)) 16 | expect(hashedAssets[md5('Bundle')]).toEqual(jasmine.any(Array)) 17 | }) 18 | }) 19 | 20 | describe('assetType', () => { 21 | it('returns the correct type', () => { 22 | expect(utils.getAssetType('/path/something.css')).toEqual('css') 23 | expect(utils.getAssetType('/path/something.css?foo=bar')).toEqual('css') 24 | expect(utils.getAssetType('/path/to.html')).toEqual('html') 25 | expect(utils.getAssetType('http://js.com/something.css')).toEqual('css') 26 | expect(utils.getAssetType(['http://bar.com/something.css'])).toEqual('css') 27 | expect(utils.getAssetType(['http://bar.com/something.css?foo=bar'])).toEqual('css') 28 | }) 29 | }) 30 | 31 | describe('getAssetTypeFromHash', () => { 32 | it('returns the correct asset type', () => { 33 | const hash = md5(Object.keys(assets)[0]) 34 | expect(utils.getAssetTypeFromHash(hash, assets)).toEqual('css') 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Moniteur", 3 | "description": "Monitor your asset size over time, in your browser, or using the provided HTTP API.", 4 | "keywords": [ 5 | "performance", 6 | "tracking" 7 | ], 8 | "website": "http://moniteur.herokuapp.com/", 9 | "repository": "https://github.com/kaelig/moniteur", 10 | "success_url": "/welcome", 11 | "scripts": { 12 | "postdeploy": "npm run record" 13 | }, 14 | "env": { 15 | "ASSETS": { 16 | "description": "Assets to track in YAML (alternatively, use the configuration files)", 17 | "value": "Moniteur's CSS: https://moniteur.herokuapp.com/stylesheets/style.css\nMoniteur's JavaScript: https://moniteur.herokuapp.com/js/bundle.js", 18 | "required": false 19 | }, 20 | "USERNAME": { 21 | "description": "Username for basic authentication protection (optional)", 22 | "required": false 23 | }, 24 | "PASSWORD": { 25 | "description": "Password for basic authentication protection (optional)", 26 | "required": false 27 | } 28 | }, 29 | "addons": [ 30 | "rediscloud", 31 | "scheduler:standard" 32 | ], 33 | "buildpacks": [ 34 | { 35 | "url": "heroku/nodejs" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /bin/moniteur: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('./moniteur.js') 4 | -------------------------------------------------------------------------------- /bin/moniteur.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug') 2 | const program = require('commander') 3 | const db = require('../lib/db') 4 | const Record = require('../lib/record') 5 | const nconf = require('nconf') 6 | const compression = require('compression') 7 | const express = require('express') 8 | const auth = require('http-auth') 9 | const slashes = require('express-slashes') 10 | const path = require('path') 11 | const lem = require('lem') 12 | const yaml = require('js-yaml') 13 | const fs = require('fs') 14 | nconf.formats.yaml = require('nconf-yaml') 15 | const log = debug('moniteur:log') 16 | 17 | program 18 | .version(require('../package.json').version) 19 | 20 | nconf 21 | .env({ 22 | separator: '__', 23 | lowerCase: true, 24 | whitelist: ['REDISCLOUD_URL', 'REDIS_URL', 'DB__REDIS_URL'] 25 | }) 26 | .argv() 27 | 28 | if (process.env.NODE_ENV !== 'production') { 29 | nconf.file('environment', { file: path.join(__dirname, '/../.moniteurrc.development.yml'), format: nconf.formats.yaml }) 30 | } 31 | nconf.file('environment', { file: '.moniteurrc.yml', dir: process.cwd(), search: true, format: nconf.formats.yaml }) 32 | 33 | nconf.defaults(yaml.safeLoad(fs.readFileSync(path.join(__dirname, '/../.moniteurrc.default.yml'), 'utf8'))) 34 | 35 | nconf 36 | .set('assets', process.env.ASSETS ? yaml.safeLoad(process.env.ASSETS) : nconf.get('assets')) 37 | 38 | // nconf evaluates the : in the protocol as a key:value pair 39 | // so we're restoring the colon in the URL protocols 40 | nconf 41 | .set('db:redis_url', 42 | process.env.REDIS_URL ? process.env.REDIS_URL.replace(/redis\/\//, 'redis://') 43 | : (process.env.REDISCLOUD_URL ? process.env.REDISCLOUD_URL.replace(/redis\/\//, 'redis://') 44 | : (nconf.get('db:redis_url') ? nconf.get('db:redis_url').replace(/redis\/\//, 'redis://') 45 | : null))) 46 | 47 | program 48 | .command('record') 49 | .description('record a snapshot of all asset metrics') 50 | .action((cmd, env) => { 51 | log(nconf.get('assets')) 52 | log(nconf.get('db')) 53 | const dbinstance = db(nconf.get('db')) 54 | 55 | const record = new Record(nconf.get('assets'), lem(dbinstance)) 56 | return Promise.all(record.init()).then((data) => { 57 | return Promise.all(record.recordDataPoints()).then((data) => { 58 | dbinstance.close() 59 | return log('DataPoints:', JSON.stringify(data, null, 4)) 60 | }, (reason) => console.log(reason)) 61 | }, (reason) => console.log(reason)) 62 | }) 63 | 64 | program 65 | .command('serve') 66 | .description('start the server to show metrics in the browser') 67 | .action(() => { 68 | const app = express() 69 | 70 | // Basic auth 71 | // Set USERNAME and PASSWORD environment variables 72 | const basic = auth.basic({ 73 | realm: 'Moniteur' 74 | }, (username, password, next) => { 75 | next(username === process.env.USERNAME && password === process.env.PASSWORD) 76 | }) 77 | 78 | if (process.env.USERNAME && process.env.PASSWORD) { 79 | app.use(function (req, res, next) { 80 | // Exclude /metrics so we can fetch() them 81 | if (req.path.startsWith('/metrics')) { 82 | next() 83 | } else { 84 | (auth.connect(basic))(req, res, next) 85 | } 86 | }) 87 | } 88 | 89 | app.set('strict routing', true) 90 | const router = express.Router({ 91 | caseSensitive: app.get('case sensitive routing'), 92 | strict: app.get('strict routing') 93 | }) 94 | app.use(compression()) 95 | 96 | app.use(router) 97 | app.use(slashes()) 98 | 99 | log(nconf.get('db')) 100 | const dbinstance = db(nconf.get('db')) 101 | 102 | router.use((req, res, next) => { 103 | res.locals.assets = nconf.get('assets') 104 | res.locals.db = dbinstance 105 | next() 106 | }) 107 | 108 | // JS Setup 109 | if (app.get('env') === 'development') { 110 | const webpack = require('webpack') 111 | const webpackDevMiddleware = require('webpack-dev-middleware') 112 | const webpackHotMiddleware = require('webpack-hot-middleware') 113 | const webpackConfig = require('../webpack.config') 114 | const bundler = webpack(webpackConfig) 115 | 116 | app.use(webpackDevMiddleware(bundler, { 117 | publicPath: '/js/', 118 | stats: { colors: true } 119 | })) 120 | app.use(webpackHotMiddleware(bundler, { 121 | log: console.log 122 | })) 123 | } 124 | 125 | // view engine setup 126 | app.set('views', path.join(__dirname, '../views')) 127 | app.set('view engine', 'pug') 128 | app.set('view cache', true) 129 | 130 | router.use('/js', express.static(path.join(__dirname, '../dist/js'))) 131 | router.use('/stylesheets', express.static(path.join(__dirname, '../client/stylesheets'))) 132 | router.use('/docs', express.static(path.join(__dirname, '../docs'))) 133 | 134 | router.use('/', require('../routes/index')) 135 | router.get('/welcome', (req, res) => res.render('welcome', { title: 'moniteur: welcome' })) 136 | router.get('/support', (req, res) => res.render('support', { title: 'moniteur: support' })) 137 | router.use('/metrics', require('../routes/metrics')) 138 | app.use('/settings', require('../routes/settings')) 139 | router.get('/assets.json', (req, res) => { 140 | res.json(res.locals.assets) 141 | }) 142 | 143 | // Hide from crawlers 144 | router.get('/robots.txt', (req, res) => { 145 | res.type('text/plain') 146 | res.send('User-agent: *\nDisallow: /') 147 | }) 148 | 149 | // Catch 404 and forward to error handler 150 | app.use((req, res, next) => { 151 | let err = new Error('Not Found') 152 | err.status = 404 153 | next(err) 154 | }) 155 | 156 | // Error handlers 157 | if (app.get('env') === 'development') { 158 | // development error handler 159 | // will print stacktrace 160 | app.use((err, req, res, next) => { 161 | res.status(err.status || 500) 162 | res.render('error', { 163 | message: err.message, 164 | error: err 165 | }) 166 | }) 167 | } else { 168 | // production error handler 169 | // no stacktraces leaked to user 170 | app.use((err, req, res, next) => { 171 | res.status(err.status || 500) 172 | res.render('error', { 173 | message: err.message, 174 | error: {} 175 | }) 176 | }) 177 | } 178 | 179 | app.set('port', process.env.PORT || 3000) 180 | 181 | if (app.get('env') === 'development') { 182 | const browserSync = require('browser-sync') 183 | 184 | browserSync({ 185 | server: { 186 | port: app.get('port'), 187 | baseDir: './', 188 | middleware: [app] 189 | }, 190 | open: false, 191 | logFileChanges: false, 192 | notify: false, 193 | files: [ 194 | 'views/*.pug', 195 | 'client/stylesheets/*.css' 196 | ] 197 | }) 198 | } else { 199 | app.listen(app.get('port')) 200 | } 201 | }) 202 | 203 | program 204 | .command('assets') 205 | .description('display the list of assets loaded by moniteur') 206 | .action(() => console.log(nconf.get('assets'))) 207 | 208 | program.command('help', null, {isDefault: true}) 209 | .description('display this helpful message') 210 | .action(() => program.outputHelp()) 211 | 212 | program.command('*', null, {noHelp: true}) 213 | .action(function (cmd) { 214 | console.error(`${cmd} is not a moniteur command. See usage below`) 215 | program.outputHelp() 216 | }) 217 | 218 | program.parse(process.argv) 219 | -------------------------------------------------------------------------------- /client/javascripts/app.js: -------------------------------------------------------------------------------- 1 | /* global Highcharts */ 2 | /* eslint-env browser */ 3 | require('./assets-graph-theme') 4 | const prettyBytes = require('pretty-bytes') 5 | 6 | Array.from(document.querySelectorAll('.js-asset')).map(assetContainer => { 7 | const assetHash = assetContainer.dataset.assetHash 8 | 9 | return fetch(`/metrics/${assetHash}`) 10 | .then(res => res.json()) 11 | .then(series => { 12 | const sizes = series[0].data 13 | if (!sizes.length) { 14 | assetContainer.querySelector('#js-asset-chart-' + assetHash).innerHTML = 15 | ` 16 |
17 |

Could not find any data for this asset.

18 |

19 | You may need to trigger a recording 20 | so that moniteur has some data to show. 21 |

22 |
23 | ` 24 | return 25 | } 26 | const firstSize = sizes[0][1] 27 | const lastSize = sizes[sizes.length - 1][1] 28 | 29 | const difference = lastSize - firstSize 30 | const prettyDifference = prettyBytes(difference) 31 | const trend = (difference < 0) ? 'down' : 'up' 32 | const trendSign = (difference > 0) ? '+' : '' 33 | const $trendElement = document.querySelector('.js-trend-' + assetHash) 34 | 35 | if (difference !== 0) { 36 | $trendElement.querySelector('.js-trend-sign').textContent = trendSign 37 | $trendElement.classList.add('trend--' + trend) 38 | $trendElement.querySelector('.js-trend-value').textContent = prettyDifference 39 | $trendElement.setAttribute('title', `${trendSign}${prettyDifference} (uncompressed) compared to the beginning of the period`) 40 | } 41 | 42 | Highcharts.chart( 43 | 'js-asset-chart-' + assetHash, 44 | { 45 | chart: { 46 | type: 'spline' 47 | }, 48 | yAxis: [ 49 | { 50 | title: { 51 | text: 'Size' 52 | }, 53 | labels: { 54 | formatter: function () { 55 | return prettyBytes(this.value) 56 | } 57 | } 58 | }, 59 | { 60 | title: { 61 | text: 'Count' 62 | }, 63 | opposite: true 64 | } 65 | ], 66 | tooltip: { 67 | crosshairs: [false, true], 68 | formatter: function () { 69 | return '' + this.series.name + '
' + Highcharts.dateFormat('%b %e, %H:%M', this.x) + ': ' + (this.series.area ? prettyBytes(this.y) : this.y) + '' 70 | } 71 | }, 72 | series: series 73 | } 74 | ) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /client/javascripts/assets-graph-theme.js: -------------------------------------------------------------------------------- 1 | /* global Highcharts */ 2 | 3 | /** 4 | * Assets graph Theme for Highcharts JS 5 | * @author Kaelig Deloumeau-Prigent 6 | * Based on Dark theme for Highcharts JS 7 | * @author Torstein Honsi 8 | */ 9 | 10 | Highcharts.theme = { 11 | colors: ['#2b908f', '#90ee7e', '#f45b5b', 'rgba(119, 152, 192, .33)', 'rgba(170, 238, 238, .33)', '#ff0066', '#eeaaee', 12 | '#55BF3B', '#DF5353', '#7798BF', '#aaeeee'], 13 | chart: { 14 | backgroundColor: '#2a2a2b', 15 | style: { 16 | fontFamily: 'sans-serif' 17 | }, 18 | plotBorderColor: '#606063' 19 | }, 20 | title: { 21 | style: { 22 | display: 'none' 23 | } 24 | }, 25 | credits: { 26 | enabled: false 27 | }, 28 | subtitle: { 29 | style: { 30 | color: '#E0E0E3', 31 | textTransform: 'uppercase' 32 | } 33 | }, 34 | xAxis: { 35 | gridLineColor: '#707073', 36 | labels: { 37 | style: { 38 | color: '#E0E0E3', 39 | fontSize: '12px' 40 | } 41 | }, 42 | lineColor: '#707073', 43 | minorGridLineColor: '#505053', 44 | tickColor: '#707073', 45 | type: 'datetime', 46 | dateTimeLabelFormats: { 47 | month: '%e. %b', 48 | year: '%b' 49 | }, 50 | title: { 51 | enabled: false 52 | } 53 | }, 54 | yAxis: { 55 | gridLineColor: '#707073', 56 | labels: { 57 | style: { 58 | color: '#E0E0E3' 59 | } 60 | }, 61 | lineColor: '#707073', 62 | minorGridLineColor: '#505053', 63 | tickColor: '#707073', 64 | tickWidth: 1, 65 | title: { 66 | style: { 67 | color: '#A0A0A3' 68 | } 69 | } 70 | }, 71 | tooltip: { 72 | backgroundColor: 'rgba(0, 0, 0, 0.85)', 73 | style: { 74 | color: '#F0F0F0' 75 | } 76 | }, 77 | plotOptions: { 78 | series: { 79 | dataLabels: { 80 | color: '#B0B0B3' 81 | }, 82 | marker: { 83 | lineColor: '#333' 84 | } 85 | }, 86 | boxplot: { 87 | fillColor: '#505053' 88 | }, 89 | candlestick: { 90 | lineColor: 'white' 91 | }, 92 | errorbar: { 93 | color: 'white' 94 | }, 95 | areaspline: { 96 | marker: { 97 | symbol: 'circle', 98 | radius: 7 99 | }, 100 | lineWidth: 5, 101 | states: { 102 | hover: { 103 | lineWidth: 6 104 | } 105 | } 106 | }, 107 | spline: { 108 | marker: { 109 | symbol: 'circle', 110 | radius: 4 111 | }, 112 | lineWidth: 3, 113 | states: { 114 | hover: { 115 | lineWidth: 4 116 | } 117 | } 118 | } 119 | }, 120 | legend: { 121 | align: 'center', 122 | itemMarginBottom: 4, 123 | itemStyle: { 124 | color: '#E0E0E3', 125 | fontSize: '14px', 126 | fontWeight: 'lighter' 127 | }, 128 | itemHoverStyle: { 129 | color: '#FFF' 130 | }, 131 | itemHiddenStyle: { 132 | color: '#606063' 133 | } 134 | }, 135 | labels: { 136 | style: { 137 | color: '#707073' 138 | } 139 | }, 140 | 141 | drilldown: { 142 | activeAxisLabelStyle: { 143 | color: '#F0F0F3' 144 | }, 145 | activeDataLabelStyle: { 146 | color: '#F0F0F3' 147 | } 148 | }, 149 | 150 | navigation: { 151 | buttonOptions: { 152 | enabled: false 153 | } 154 | }, 155 | 156 | // scroll charts 157 | rangeSelector: { 158 | buttonTheme: { 159 | fill: '#505053', 160 | stroke: '#000000', 161 | style: { 162 | color: '#CCC' 163 | }, 164 | states: { 165 | hover: { 166 | fill: '#707073', 167 | stroke: '#000000', 168 | style: { 169 | color: 'white' 170 | } 171 | }, 172 | select: { 173 | fill: '#000003', 174 | stroke: '#000000', 175 | style: { 176 | color: 'white' 177 | } 178 | } 179 | } 180 | }, 181 | inputBoxBorderColor: '#505053', 182 | inputStyle: { 183 | backgroundColor: '#333', 184 | color: 'silver' 185 | }, 186 | labelStyle: { 187 | color: 'silver' 188 | } 189 | }, 190 | 191 | navigator: { 192 | handles: { 193 | backgroundColor: '#666', 194 | borderColor: '#AAA' 195 | }, 196 | outlineColor: '#CCC', 197 | maskFill: 'rgba(255,255,255,0.1)', 198 | series: { 199 | color: '#7798BF', 200 | lineColor: '#A6C7ED' 201 | }, 202 | xAxis: { 203 | gridLineColor: '#505053' 204 | } 205 | }, 206 | 207 | scrollbar: { 208 | barBackgroundColor: '#808083', 209 | barBorderColor: '#808083', 210 | buttonArrowColor: '#CCC', 211 | buttonBackgroundColor: '#606063', 212 | buttonBorderColor: '#606063', 213 | rifleColor: '#FFF', 214 | trackBackgroundColor: '#404043', 215 | trackBorderColor: '#404043' 216 | }, 217 | 218 | // special colors for some of the 219 | legendBackgroundColor: 'rgba(0, 0, 0, 0.5)', 220 | background2: '#505053', 221 | dataLabelsColor: '#B0B0B3', 222 | textColor: '#C0C0C0', 223 | contrastTextColor: '#F0F0F3', 224 | maskColor: 'rgba(255,255,255,0.3)' 225 | } 226 | 227 | // Apply the theme 228 | Highcharts.setOptions(Highcharts.theme) 229 | -------------------------------------------------------------------------------- /client/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | margin: 0; 3 | background: #2a2a2b; 4 | color: white; 5 | font-family: -apple-system, BlinkMacSystemFont, Arial, sans-serif; 6 | line-height: 1.5; 7 | text-rendering: optimizeLegibility; 8 | -webkit-font-smoothing: antialiased; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | } 14 | 15 | img { 16 | border: 0; 17 | max-width: 100%; 18 | } 19 | 20 | a { 21 | color: #ddd; 22 | } 23 | 24 | .l-container { 25 | box-sizing: border-box; 26 | margin: 0 auto; 27 | padding: 0 1em; 28 | max-width: 1400px; 29 | } 30 | 31 | .l-header, 32 | .l-footer { 33 | background: rgba(0, 0, 0, .3); 34 | } 35 | .l-header, 36 | .l-footer, 37 | .l-content { 38 | margin: 0; 39 | padding: 1rem 2rem; 40 | } 41 | .l-footer { 42 | margin-top: 2em; 43 | } 44 | .l-footer a { 45 | margin-right: 1.5em; 46 | } 47 | 48 | h1 { 49 | margin: 0; 50 | } 51 | h1 a { 52 | text-decoration: none; 53 | } 54 | h1 a:hover, 55 | h1 a:focus { 56 | text-decoration: underline; 57 | } 58 | 59 | .asset { 60 | box-sizing: border-box; 61 | padding-top: 1rem; 62 | padding-bottom: 1rem; 63 | border-top: .5rem solid rgba(0, 0, 0, .1); 64 | } 65 | @media (min-width: 600px) { 66 | .asset { 67 | padding-top: 2em; 68 | padding-bottom: 2em; 69 | } 70 | } 71 | .asset:first-child { 72 | border-top: 0; 73 | } 74 | .asset__name { 75 | font-size: 2em; 76 | margin-left: 1.33em; 77 | } 78 | .asset__type { 79 | position: relative; 80 | } 81 | .asset__type::before { 82 | content: '{}'; 83 | display: inline; 84 | position: absolute; 85 | top: -.7em; 86 | display: inline-block; 87 | left: -.5em; 88 | letter-spacing: .15em; 89 | font-weight: bolder; 90 | font-size: 2.5em; 91 | opacity: .07; 92 | } 93 | [data-asset-type=js] .asset__type::before { 94 | content: '❮❯'; 95 | } 96 | .asset__linkto { 97 | text-decoration: none; 98 | opacity: 0; 99 | transition: opacity .5s 0; 100 | padding: .3em; 101 | } 102 | .asset__linkto:hover, 103 | .asset__linkto:focus { 104 | opacity: .6 !important; 105 | } 106 | .asset__name:hover .asset__linkto { 107 | opacity: .3; 108 | } 109 | .asset__chart { 110 | min-height: 400px; 111 | width: 100%; 112 | } 113 | .trend { 114 | position: relative; 115 | top: 2px; 116 | float: right; 117 | margin-right: 1em; 118 | padding-right: 12px; 119 | padding-left: 12px; 120 | border-radius: 4px; 121 | background: rgba(255, 255, 255, .1); 122 | font-weight: normal; 123 | font-size: 20px; 124 | line-height: 32px; 125 | font-family: monospace; 126 | } 127 | .trend--down { 128 | background: #399914; 129 | } 130 | .trend--up { 131 | background: #99222F; 132 | } 133 | 134 | .table { 135 | text-align: left; 136 | width: 100%; 137 | } 138 | .table th, 139 | .table td { 140 | padding: .75em 1em; 141 | vertical-align: top; 142 | } 143 | .table tbody th, 144 | .table tbody td { 145 | border-top: 1px solid rgba(255, 255, 255, .1); 146 | } 147 | .table th:first-child { 148 | padding-left: 0; 149 | } 150 | .table__assetname a { 151 | color: #fff; 152 | text-decoration: none; 153 | } 154 | .table__assetresources a { 155 | text-decoration: none; 156 | font-family: Monaco, monospace; 157 | font-size: 14px; 158 | } 159 | 160 | .table__assetname a:hover, 161 | .table__assetname a:focus, 162 | .table__assetresources a:hover, 163 | .table__assetresources a:focus { 164 | text-decoration: underline; 165 | } 166 | 167 | .code { 168 | font-family: Monaco, monospace; 169 | font-size: 16px; 170 | } 171 | 172 | .user-select { 173 | -webkit-user-select: all; 174 | user-select: all; 175 | } 176 | 177 | .welcome-message { 178 | margin-top: .5em; 179 | font-size: 6em; 180 | line-height: 1; 181 | text-align: center; 182 | font-weight: 100; 183 | } 184 | 185 | .align-center { 186 | text-align: center; 187 | } 188 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaelig/moniteur/ea28dfd9b58a0febae0a598925e72e84bf9ff067/docs/screenshot.png -------------------------------------------------------------------------------- /docs/welcome-process.key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaelig/moniteur/ea28dfd9b58a0febae0a598925e72e84bf9ff067/docs/welcome-process.key -------------------------------------------------------------------------------- /docs/welcome-process/welcome-process.001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaelig/moniteur/ea28dfd9b58a0febae0a598925e72e84bf9ff067/docs/welcome-process/welcome-process.001.png -------------------------------------------------------------------------------- /docs/welcome-process/welcome-process.002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaelig/moniteur/ea28dfd9b58a0febae0a598925e72e84bf9ff067/docs/welcome-process/welcome-process.002.png -------------------------------------------------------------------------------- /docs/welcome-process/welcome-process.003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaelig/moniteur/ea28dfd9b58a0febae0a598925e72e84bf9ff067/docs/welcome-process/welcome-process.003.png -------------------------------------------------------------------------------- /docs/welcome-process/welcome-process.004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaelig/moniteur/ea28dfd9b58a0febae0a598925e72e84bf9ff067/docs/welcome-process/welcome-process.004.png -------------------------------------------------------------------------------- /docs/welcome-process/welcome-process.005.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaelig/moniteur/ea28dfd9b58a0febae0a598925e72e84bf9ff067/docs/welcome-process/welcome-process.005.png -------------------------------------------------------------------------------- /fixtures/abc.js: -------------------------------------------------------------------------------- 1 | var foo = 'bar' 2 | export default foo 3 | -------------------------------------------------------------------------------- /fixtures/main.css: -------------------------------------------------------------------------------- 1 | div { 2 | background: red; 3 | } 4 | h1 { 5 | color: black; 6 | } 7 | .element { 8 | font-size: 2em; 9 | } -------------------------------------------------------------------------------- /fixtures/main2.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:"BentonSans";src:url("http://s1.ft-static.com/m/font/ft-velcro/bentonsans-regular.eot");src:url("http://s1.ft-static.com/m/font/ft-velcro/bentonsans-regular.eot?#iefix") format("embedded-opentype"),url("http://s1.ft-static.com/m/font/ft-velcro/bentonsans-regular.woff") format("woff");font-style:normal;font-weight:normal}@font-face{font-family:"BentonSans";src:url("http://s1.ft-static.com/m/font/ft-velcro/bentonsans-bold.eot");src:url("http://s1.ft-static.com/m/font/ft-velcro/bentonsans-bold.eot?#iefix") format("embedded-opentype"),url("http://s1.ft-static.com/m/font/ft-velcro/bentonsans-bold.woff") format("woff");font-style:normal;font-weight:bold}body{margin:0;width:100%;background-color:#fff1e0;font-size:16px;font-family:Arial,Helvetica,sans-serif;line-height:18px}.font-default{font-family:Arial,Helvetica,sans-serif}.font-editorial{font-family:Georgia,"Times New Roman",serif}.nojs .jsOnly{display:none}.forceUppercase{text-transform:uppercase}.forceLowercase{text-transform:lowercase}:link,:visited{text-decoration:none}ul,ol{list-style:none}h1,h2,h3,h4,h5,h6,pre,code{font-weight:normal;font-size:16px}ul,ol,li,h1,h2,h3,h4,h5,h6,pre,form,body,html,p,blockquote,fieldset,input{margin:0;padding:0}acronym,a img,:link img,:visited img,fieldset{border:0}address{font-style:normal}a{color:#2e6e9e;-webkit-font-smoothing:antialiased}a:hover{color:#000}textarea{font-family:Arial,Helvetica,sans-serif}button{cursor:pointer}.clearfix{display:inline-block}.clearfix:after{visibility:hidden;display:block;clear:both;height:0;content:""}/*\*/* html .clearfix{height:1%}.clearfix{display:block}/**/.clear{clear:both}.container{position:relative;margin:0 auto;padding:0 10px;width:973px;font-size:12px}.master-row{float:left;width:972px;z-index:1400}.master-column{float:left;width:972px}.middleSection{margin:12px 0 0;width:973px;z-index:1200;background:url("http://im.ft-static.com/m/img/railBg.gif") repeat-y}.noRailBg{background-image:none}.contentSection{margin-right:20px;width:600px}.msie div.contentSection{margin-top:-1px}.wideContentSection{width:972px}.editorialSection{width:600px}.wideContentSection .editorialSection{width:972px}.railSection{width:352px;background-color:#f6e9d8;font-family:BentonSans,Helvetica,Arial,sans-serif}.msie6 .railSection{margin-right:-3px}.marketingSection{padding-bottom:60px;width:621px}.wideContentSection .marketingSection{padding-bottom:60px;width:972px}.msie6 .railSection,.msie6 .editorialSection,.msie6 .contentSection{overflow:hidden}.topAds{position:absolute;top:3px;left:10px;z-index:10005}.wrapperContentTwoColumn .editorialSection{float:left;margin-right:28px;width:367px}.wrapperContentTwoColumn .marketingSection{float:left;width:213px}.topSection #header{position:relative;clear:both;z-index:300;margin:0;padding-top:10px}.no-banlb div .topSection #header{margin-top:8px}.topSection #header #searchbox{position:absolute;bottom:0;right:0;margin:0;width:270px;height:42px;overflow:hidden}.topSection .colright{position:relative;float:left;width:362px;height:90px}.msie .topSection .colright{margin-top:-1px;zoom:1}#ft-search{position:relative;float:right;margin:0;width:208px;z-index:1}#ft-search .searchContainer{float:left;width:138px;background:transparent}#ft-search .text{border:1px solid #f7ecdd;padding:4px;height:20px;width:130px;vertical-align:top;font-size:14px;background:white url("http://im.ft-static.com/m/img/blank.gif");z-index:1}#ft-search .ft-button{float:right;padding:0!important;width:63px!important;text-align:center}#ft-search .searchType{float:left;margin-right:5px}#ft-search .searchType a{color:#2e6e9e}#ft-search .searchType a:hover{color:#000;text-decoration:underline}#ft-search .searchType input{float:none;margin:-3px 2px -3px 0;padding:0;vertical-align:middle}.msi #ft-search .searchType input{vertical-align:10%}#ft-search .searchType.withRadios li{float:left;clear:left;line-height:14px;min-height:14px;vertical-align:top}#ft-search .searchType li.nojs{display:none}.msie #ft-search .searchType{margin-right:3px;margin-left:-4px}.nojs #ft-search li.js{display:none}.nojs #ft-search li.nojs{display:list-item;width:55px}#ftLogin{position:absolute;top:6px;right:219px;min-height:18px;width:345px;text-align:right;color:#777;font-size:14px;z-index:3}#ftLogin a{padding-left:.5em;color:#2e6e9e}#ftLogin a:hover{color:#000}#ftLogin .ft-link{margin-right:5px;padding:0}.msie6 #ftLogin{height:18px}#ftLogin-user{display:inline-block;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom;text-align:left}.ftLogin-loggedOut{display:block}.ftLogin-loggedIn{display:none}#ftLogin-box{display:none;position:absolute;right:150px;top:30px;padding:9px;text-align:left;background-image:url("http://im.ft-static.com/m/img/shadow-9x9.png");line-height:30px;vertical-align:middle;z-index:400}#ftLogin-box a{padding:0}#ftLogin-box form{position:relative;padding:0 10px 10px;width:300px;background-color:#fff}#ftLogin-box input{vertical-align:middle}#ftLogin-box .text{display:block;position:relative;margin-bottom:6px;border:1px solid #a7a49b;padding:6px;width:286px}#ftLogin-box .checkbox{margin:0 3px 0 0;padding:0}#ftLogin-box .ft-button{position:absolute;right:12px}.msie6 #ftLogin-box .ft-button,.msie7 #ftLogin-box .ft-button{width:64px!important;margin-top:-3px;right:0}.msie6 #ftLogin-box .ft-button{margin-top:-8px;right:16px}#ftLogin-box .closeButton{display:block;position:absolute;top:0;right:0;margin:10px 8px 0 0;height:12px;width:12px;background-image:url("http://im.ft-static.com/m/img/menu_close.gif");cursor:pointer}#ftLogin.open #ftLogin-box{display:block}.ftLogin-rememberMe{padding-bottom:41px}.ftLogin-rememberMe span{float:left}.ftLogin-rememberMe input.button{float:right}.msie6 .ftLogin-rememberMe,.msie7 .ftLogin-rememberMe{padding-bottom:27px}.ftLogin-cookiePolicy{line-height:18px;padding-bottom:3px}.ftLogin-forgotPassword{clear:both;padding-top:1em}#page-title{float:left;width:610px;padding-top:5px}#page-title .bar .bc{display:inline;float:left;margin-right:10px;font-size:16px;line-height:18px;color:#777;text-transform:lowercase}#page-title .bar .bc a,#page-title .bar .bc a:visited,#page-title .bar .bc a:link{color:#777}#page-title .bar .bc a:hover{color:#000}#page-title .bar .bc span{color:#000}.msie #page-title .bar .bc{color:#777}.msie #page-title .bar{zoom:1}#page-title h1,#page-title span.ftlogo{display:block;clear:left;padding:7px 0 1px}#page-title .pagename h1{display:inline;font-size:100%;padding:0;line-height:100%}#page-title span.timezone{float:left;margin-left:16px;padding-top:1px;color:#777}#page-title .section .heading{display:inline-block;background:url("http://im.ft-static.com/m/img/masthead_small.gif") no-repeat 0 2px}#page-title .section img{margin:2px 0 4px 0;visibility:hidden;width:164px;height:14px}.msie #page-title .section img{margin-bottom:2px}#page-title .section .bc{display:block;float:none;margin-bottom:6px;width:400px;font-size:16px;color:#000}#page-title .section .bc a{color:#777}#page-title .section .bc span{font-weight:bold;font-size:14px}#page-title .section .pagename{margin:0;padding:2px 0 6px;min-height:49px;font-size:42px;line-height:100%}#page-title .section .pagename .ftdotcom,#page-title .section .pagename .ftdotcom a,#page-title .section .pagename .ftdotcom a:hover{color:#a7a59b}#page-title .section .pagename a,#page-title .section .pagename a:hover{color:#000}#page-title .section .pagename i{font-size:12px}.msie6 #page-title .section .pagename{height:49px}.edition{float:left;position:relative;padding:1px 22px 0 0}.edition a{color:#2e6e9e}#editionChanger{float:right;position:absolute;right:0;top:2px;min-width:16px;min-height:16px;cursor:pointer}.nojs #editionChanger.basic,#editionChanger.enhanced{background:url("http://im.ft-static.com/m/img/menu_open.gif") right top no-repeat}#editionChanger p{display:none;position:relative;border:1px solid #e9decf;border-bottom:0;padding:6px 6px 0 6px;min-width:132px;color:#777;background-color:#fff;cursor:default}.nojs #editionChanger.basic:hover p{display:block}#editionChanger.open p{display:block}#editionChanger ul{display:none;padding:6px;width:132px;border:1px solid #e9decf;border-top:0;background-color:#fff;cursor:default}#editionChanger ul li{padding-left:22px}#editionChanger ul li.selected{font-weight:bold;color:#000;background:url("http://im.ft-static.com/m/img/menu_tick.gif") no-repeat 5px center}#editionChanger ul li a:hover{color:#000}#editionChanger.basic:hover ul{display:block}#editionChanger.open ul{display:block}#editionChanger span.closeButton{position:absolute;top:10px;right:10px;width:12px;height:12px;background-image:url("http://im.ft-static.com/m/img/menu_close.gif");cursor:pointer}.msie div#editionChanger{top:3px}.msie6 div#editionChanger{width:16px;height:16px}.msie6 div#editionChanger p{width:132px}.subscribeTile{float:right;margin-top:7px;font-size:14px;line-height:26px;text-align:right}.subscribeTile a{color:#2e6e9e}.subscribeTile a:hover{color:#000}.bottomSection{clear:left;position:relative}#renderPageOverlay{position:absolute;left:0;top:0;width:100%;height:100%;z-index:99000}#renderPageOverlay.loading{filter:alpha(opacity=30);opacity:.3;background:black url("http://im.ft-static.com/m/img/loading_big_white.gif") 470px 230px no-repeat}.roundedCorners{-moz-border-radius:8px;-webkit-border-radius:8px;border-radius:8px}.roundedCorners4-all{-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px}.roundedCorners4-topleft{-webkit-border-top-left-radius:4px;-moz-border-radius-topleft:4px;border-top-left-radius:4px}.roundedCorners4-topright{-webkit-border-top-right-radius:4px;-moz-border-radius-topright:4px;border-top-right-radius:4px}.roundedCorners4-bottomleft{-webkit-border-bottom-left-radius:4px;-moz-border-radius-bottomleft:4px;border-bottom-left-radius:4px}.roundedCorners4-bottomright{-webkit-border-bottom-right-radius:4px;-moz-border-radius-bottomright:4px;border-bottom-right-radius:4px}.overlay{padding:8px;background-image:url("http://im.ft-static.com/m/img/overlayBg.png")}.overlay .innerBox{background-color:#fff}.overlayArrow{width:34px;height:21px;background:url("http://im.ft-static.com/m/img/overlay_arrow.png") no-repeat}.overlayTopArrow{background-position:0 0}.overlayBottomArrow{background-position:0 -21px}#dev-env{position:fixed;top:0;right:0;width:150px;background-color:#f00;z-index:1000;text-align:center}#dev-env .info{font-size:10px}.twitter-overlay-container{display:none}.nojs #navigation li a,.nojs #navigation li.on .subnav li a,#navigation li li a,#navigation li.on .subnav li li a{visibility:visible}.nojs #navigation .subnav .subnav{display:none}.nojs #navigation li{margin:0 7px}.msie7 #navigation{float:none}#navigation.basic li:hover .subnav{display:none}#navigation.basic li.on:hover .subnav{display:block}#navigation.basic li.on:hover .subnav .subnav{display:none}#navigation li a{visibility:hidden}#navigation{position:relative;float:left;clear:left;border-top:8px solid #9e2f50;border-bottom:12px solid #fff;padding:4px 0 0 0;width:972px;margin-bottom:19px;background-color:#fff;z-index:2;height:29px}#navigation li{float:left;margin:0;display:inline;font-size:14px;height:24px}#navigation li.first-child{margin-left:12px}#navigation li a{float:left;position:relative;padding:0 0 0 4px;color:#000;height:24px;line-height:24px}#navigation li a span{padding-right:3px}#navigation li.nosub a em{right:-5px;width:5px}#navigation li a em{display:block;position:absolute;right:-15px;top:0;margin-bottom:5px;padding:0;height:24px;width:15px;cursor:pointer}#navigation li a.on{background:#a7a59b left -240px no-repeat;color:#fff;padding-bottom:5px}#navigation li a.on em{background:white right -180px no-repeat}#navigation li.nosub a.on em{background:#a7a59b right -240px no-repeat;padding-bottom:5px}#navigation li a:hover{background:#e4eef5 left top no-repeat}#navigation li.navopen a,#navigation li.navopen a:hover{background:#4781aa left -60px no-repeat;color:#fff;border-bottom:2px solid #4781aa}#navigation li a:hover em{background:right top no-repeat}#navigation li a:hover em:hover{background:white right -60px no-repeat}#navigation li.navopen a:hover em,#navigation li.navopen a em{background:white right -180px no-repeat;border-bottom:2px solid #4781aa}#navigation li.nosub a:hover em{background:#e4eef5 right -120px no-repeat}#navigation li.navopen .subnav{display:block}#navigation>li.first-child>.subnav{margin-left:16px}#navigation>li.on>.subnav{margin-left:0}#navigation li.navopen .subnav a{border-bottom:0 none}#navigation li.navopen .subnav .subnav{display:none}#navigation li ul{display:none;position:absolute;top:30px;margin-left:0;border:2px solid #4781aa;border-bottom:0 none;padding:0;width:214px;background:#fff;z-index:2}.nojs #navigation li ul{top:28px}#navigation li ul li{display:block;float:none;clear:left;margin:0;border-top:1px solid #dfded8;padding:0;font-size:12px;line-height:13px;background:#fff none;height:auto}#navigation .on .nav-highlighted{padding:3px 10px 3px 9px}#navigation .nav-highlight-left,#navigation .nav-highlight-right{display:block;padding:0 0 0 30px;color:#FFF;background:transparent url("http://im.ft-static.com/m/img/nav/highlight-nav.png") no-repeat 0 0}#navigation .nav-highlight-right{padding:2px 4px 2px;background-position:100% 100%}#navigation .nav-highlight-dropdown{padding:0 20px 0 0;background-position:97% -283px}#navigation .subnav .nosub .nav-highlight-dropdown{padding:0;background:0}#navigation .navopen .nav-highlight-left,#navigation .navopen .nav-highlight-right,#navigation .navopen .nav-highlight-dropdown{padding:0;background:0;color:#777}#navigation .on .subnav .subnav li.dummy-child a.nav-highlighted,#navigation .on .subnav .subnav li.dummy-child a.nav-highlighted:hover{padding-right:63px}#navigation li .subnav li.last-child{border-bottom:2px solid #4781aa;margin-bottom:-4px}#navigation li.on .subnav li.last-child{border-bottom:0 none;margin-bottom:0}#navigation li .subnav .subnav li.last-child{border-bottom:2px solid #a7a59b;margin-bottom:-4px}#navigation li ul li a,#navigation .navopen .nav-highlighted{float:none;display:block;padding:10px 13px;height:auto;line-height:14px}#navigation li.navopen ul li a{background:#fff none;color:#777}#navigation li.navopen ul li a:hover{background:#efeeeb none;color:#777;border-bottom:0 none}#navigation li ul li.first-child,#navigation li ul li.first-child a{border-top:0;margin-left:0}#navigation .on a:hover{background-color:#a7a59b}#navigation .on em{background-color:#a7a59b}#navigation .on .subnav{display:block;visibility:visible;padding:0;background-color:#a7a59b;left:0;width:972px;top:32px;border:0 none;position:absolute;z-index:1;margin-left:0}#navigation .on .subnav li{float:left;clear:none;background-color:#fff;color:#fff;border:0 none;margin:0;position:relative}#navigation .on a{margin-left:-3px;margin-right:0;padding-left:7px;padding-right:0}#navigation .on li a{color:#fff;width:auto;padding:5px 22px 5px 9px;margin:0}#navigation .on li.last-child{float:right}#navigation .on li.tools{float:right}#navigation .on li.tools .subnav{margin-left:-156px}#navigation .on li.tools .subnav li.dummy-child{right:-2px;left:auto}#navigation li.on li.nosub a:hover.rss{background-image:url("http://im.ft-static.com/m/img/nav/rss_link_nav.gif")}#navigation .on li.tools .subnav li.section a{color:#2e6e9e}#navigation li.on a:hover{background:#a7a59b left -240px no-repeat}#navigation li.on a:hover em{background:#a7a59b right -240px no-repeat}#navigation li.on li a{background:#a7a59b right -279px no-repeat;color:#fff}#navigation li.on li a:hover{background:#74736c right -303px no-repeat;color:#fff}#navigation li.on li.on a{background:#4780ab right -327px no-repeat;color:#fff}#navigation li.on li.rss a{padding:5px 8px 0 2px}#navigation li.on li.rss a:hover{background-color:#a7a59b}#navigation li.on li li a,#navigation li.on li li a:hover,#navigation li.on li.on li a,#navigation li.on li.on li a:hover{background:#fff none}#navigation li.on li.on{padding-bottom:0}#navigation .on .subnav .subnav li.dummy-child{position:absolute;top:-23px;left:-2px;border:2px solid #a7a59b;border-bottom:0 none;padding:0 3px;background-color:#fff;height:21px;z-index:2}#navigation .on .subnav .subnav li.dummy-child a:hover,#navigation .on .subnav .subnav li.dummy-child a{padding:4px 17px 2px 4px;background:white right -352px no-repeat}#navigation .on .subnav .subnav li.dummy-child:hover,#navigation .on .subnav .subnav li.dummy-child:hover a,#navigation .on .subnav .subnav li.dummy-child a:hover{background-color:#fff}#navigation .on .subnav .subnav{display:none;width:214px;top:21px;background-color:#fff;border:2px solid #a7a59b;padding:0}#navigation .on .subnav li.hover .subnav,#navigation .on .subnav li:hover .subnav{display:block}.editor #navigation .on .subnav li.hover .subnav,.editor #navigation .on .subnav li:hover .subnav{display:none}#navigation .on .subnav .subnav li{float:none;clear:left;font-weight:normal;padding:4px 2px;margin:0;border-top:1px solid #dfded8}#navigation .on .subnav .subnav li.first-child{border-top:0 none}#navigation .on .subnav .subnav li a{color:#777;padding:5px}#navigation .on .subnav .subnav li.hover,#navigation .on .subnav .subnav li.hover a,#navigation .on .subnav .subnav li:hover,#navigation .on .subnav .subnav li:hover a,#navigation .on .subnav .subnav li a:hover{color:#777;background-color:#efeeeb}#navigation .on .subnav .subnav li.dummy-child,#navigation .on .subnav .subnav li.dummy-child a{background-color:#fff}#navigation .on .subnav .subnav li a:hover{padding:5px;border:0 none}#navigation .subnav li.nosub:hover .subnav,#navigation .subnav li.nosub.hover .subnav{display:none}#navigation .on .nodisp{display:none}#navigation .subnav .tr,#navigation .subnav .tl,#navigation .subnav .bl,#navigation .subnav .br{position:absolute;height:5px;width:5px;z-index:2}#navigation .subnav .tr,#navigation .on .subnav .tr{right:-2px;top:-2px;background:white -5px 0 no-repeat}#navigation .subnav li:hover .tr,#navigation .subnav li.hover .tr{background-position:-5px -10px}#navigation .last-child .subnav .tr,#navigation .on .last-child .subnav .tr{display:none}#navigation .last-child .subnav .tl,#navigation .on .last-child .subnav .tl{top:-2px;left:-2px;background:#fff 0 0 no-repeat}#navigation .last-child .subnav li:hover .tl,#navigation .last-child .subnav li.hover .tl{background-position:0 -10px}#navigation .subnav .bl,#navigation .on .subnav .bl{bottom:-4px;left:-2px;background:transparent 0 -5px no-repeat}#navigation .subnav .br,#navigation .on .subnav .br{right:-2px;bottom:-4px;background:transparent -5px -5px no-repeat}#navigation .subnav .subnav .bl,#navigation .on .subnav .subnav .bl,#navigation .subnav .subnav .br,#navigation .on .subnav .subnav .br{bottom:-2px}#navigation .subnav li:hover .bl,#navigation .subnav li.hover .bl{background-position:0 -15px}#navigation .subnav li:hover .br,#navigation .subnav li.hover .br{background-position:-5px -15px}.msie #navigation li .subnav li.last-child{position:relative;z-index:2}.msie #navigation .subnav li.last-child .bl,.msie #navigation .subnav li.last-child .br{bottom:-2px}#navigation .on .subnav .tr,#navigation .on .subnav .tl,#navigation .on .subnav .bl,#navigation .on .subnav .br{display:none}#navigation .on .subnav .subnav .tr{display:none}#navigation .on .subnav .subnav .bl{display:block;background-position:0 -23px}#navigation .on .subnav .subnav .br{display:block;background-position:-5px -23px}#navigation .on .subnav .subnav li:hover .bl{background-position:0 -28px}#navigation .on .subnav .subnav li:hover .br{background-position:-5px -28px}#navigation .on .subnav .subnav li.on .bl{background-position:0 -33px}#navigation .on .subnav .subnav li.on .br{background-position:-5px -33px}#navigation .on .subnav .subnav .dummy-child .tl{display:block;background:white 0 -20px no-repeat;width:2px;height:2px;top:0;left:0}#navigation .on .subnav .subnav .dummy-child .tr{display:block;background-position:-8px -20px;width:2px;height:2px;width:2px;height:2px;top:0;right:0}#navigation .on .subnav .subnav .dummy-child .bl,#navigation .on .subnav .subnav .dummy-child .br{display:none}#navigation .subnav .bl,#navigation .on .subnav .bl,#navigation .subnav .br,#navigation .on .subnav .br,#navigation .last-child .subnav .tl,#navigation .on .last-child .subnav .tl,#navigation .subnav .tr,#navigation .on .subnav .tr,#navigation .on .subnav .subnav .dummy-child .tl{background-image:url("http://im.ft-static.com/m/img/nav/navigation_subnav.gif")}#navigation li a.on,#navigation li a.on em,#navigation li a:hover em,#navigation li a:hover,#navigation li a:hover em:hover,#navigation li.nosub a.on em,#navigation li.nosub a:hover em,#navigation li.navopen a,#navigation li.navopen a:hover,#navigation li.navopen a em,#navigation li.navopen a:hover em,#navigation li.on a:hover,#navigation li.on a:hover em,#navigation li.on li a,#navigation li.on li a:hover,#navigation li.on li.on a,#navigation .on .subnav .subnav li.dummy-child a:hover,#navigation .on .subnav .subnav li.dummy-child a,#navigation .nav-highlight-dropdown{background-image:url("http://im.ft-static.com/m/img/nav/nav.gif")}#navigation li.on li.nosub a,#navigation li.on li.nosub a:hover{background-image:none;padding-right:10px}#navigation .on .subnav .subnav li.on,#navigation .on .subnav .subnav li.on a{background-color:#4780ab;color:#fff;background-image:none}.msie6 #navigation .bl,.msie6 #navigation .br,.msie6 #navigation .tl,.msie6 #navigation .tr{font-size:0;line-height:0}.msie6 #navigation li.navopen ul li,.msie6 #navigation .subnav .subnav li{float:left;width:100%}.msie6 #navigation .subnav .br,.msie6 #navigation .subnav .bl{margin-bottom:0}.msie6 #navigation .subnav .subnav .br,.msie6 #navigation .subnav .subnav .bl{margin-bottom:-1px}.msie6 #navigation .subnav li.last-child{margin-bottom:-5px}.msie6 #navigation .subnav li.last-child a{padding-bottom:11px}.msie6 #navigation .on .subnav li.last-child a{padding-bottom:5px}.msie6 #navigation .subnav .subnav li.last-child{margin-bottom:-4px}.msie6 #navigation .subnav .subnav li.last-child a{padding-bottom:5px}.msie6 #navigation .on .subnav .subnav .dummy-child{position:relative;width:auto;margin-bottom:-23px}.msie6 #navigation .on .subnav .subnav .dummy-child a{padding-bottom:4px}.msie6 #navigation .on .subnav .last-child .subnav .dummy-child{margin-left:78px}.msie6 #navigation .subnav .last-child .subnav{margin-left:-160px}.jobsBoxSearchButton span,.ePaperButton span,.ePaperButton span span,.linkButton a,.linkButton form,.linkButton a span,.linkButton form span{background-image:url("http://im.ft-static.com/m/img/button_sprite.png")}.promobox,.pocket{background-image:url("http://im.ft-static.com/m/img/fade.gif")}.button-pdf .pdf-icon{background-image:url("http://im.ft-static.com/m/img/pdf-icon.gif")}.conkerOverlay{position:absolute;top:0;left:0;width:100%;height:100%;background:#000;filter:alpha(opacity=15);opacity:.15;z-index:9998}.conkerContainer{position:absolute;top:55px;left:355px;padding:12px;width:480px;background-image:url("http://im.ft-static.com/m/img/shadow-9x9.png");font-family:Arial,Helvetica,sans-serif;z-index:9999}.conkerFrame{float:left;padding-bottom:20px;width:480px;background:white url(http://media.ft.com/FTCOM/Images/warningExclamationBlack.gif) 23px 73px no-repeat}.conkerFrame h2{clear:left;margin:20px 25px 20px 25px;border-bottom:1px solid #a7a59b;padding:0 0 8px 0;font-size:17px;font-weight:bold}.conkerFrame p{margin:10px 25px 15px 110px;font-weight:bold;font-size:14px;line-height:1.3em}.conkerFrame a{color:#2e6e9e;text-decoration:none}.conkerFrame a:hover{color:#000}.ft-search-autocomplete-placeholder{font-size:10px!important;line-height:20px;color:#999}.ft-autocomplete{position:absolute;display:none;top:34px;width:100%;border:1px solid #999;background-color:#fff;z-index:3}.ft-autocomplete.open{display:block}.ft-autocomplete-heading{display:block;padding:0 12px;font-size:11px;text-transform:uppercase;color:#fff;background-color:#a7a59b}.ft-autocomplete-item{padding:0}.ft-autocomplete-item a{display:block;position:relative;padding:6px 12px;cursor:pointer;color:#2e6e9e}.ft-autocomplete-item-alt a{background-color:#f8f8f8}.ft-autocomplete-news .ft-autocomplete-item a{font-size:14px;line-height:17px}.ft-autocomplete-quotes .ft-autocomplete-item a{font-size:12px;line-height:15px}.ft-autocomplete-item.highlight a{color:#FFF;background-color:#2e6e9e}.ft-autocomplete-item.highlight .detail{color:#FFF}.ft-autocomplete-item .match{font-weight:bold}.ft-autocomplete-item .detail{position:relative;float:right;margin-left:10px;padding-left:23px;font-size:11px;text-align:left;color:#000;white-space:nowrap;overflow:hidden}.ft-autocomplete-item .wsod-flag{position:absolute;top:2px;left:0}.ft-autocomplete-morelink a{padding-right:12px;text-align:center}.wsod-flag{height:11px;width:16px;background:url("http://im.ft-static.com/m/img/country-flag.bg.gif") no-repeat 50px 50px;overflow:hidden}.flag-ad{background-position:0 0}.flag-ae{background-position:-16px 0}.flag-af{background-position:-32px 0}.flag-ag{background-position:-48px 0}.flag-ai{background-position:-64px 0}.flag-al{background-position:-80px 0}.flag-am{background-position:-96px 0}.flag-an{background-position:-112px 0}.flag-ao{background-position:-128px 0}.flag-ar{background-position:-144px 0}.flag-as{background-position:-160px 0}.flag-at{background-position:-176px 0}.flag-au{background-position:-192px 0}.flag-aw{background-position:-208px 0}.flag-ax{background-position:-224px 0}.flag-az{background-position:-240px 0}.flag-ba{background-position:-256px 0}.flag-bb{background-position:-272px 0}.flag-bd{background-position:-288px 0}.flag-be{background-position:0 -11px}.flag-bf{background-position:-16px -11px}.flag-bg{background-position:-32px -11px}.flag-bh{background-position:-48px -11px}.flag-bi{background-position:-64px -11px}.flag-bj{background-position:-80px -11px}.flag-bm{background-position:-96px -11px}.flag-bn{background-position:-112px -11px}.flag-bo{background-position:-128px -11px}.flag-br{background-position:-144px -11px}.flag-bs{background-position:-160px -11px}.flag-bt{background-position:-176px -11px}.flag-bv{background-position:-192px -11px}.flag-bw{background-position:-208px -11px}.flag-by{background-position:-224px -11px}.flag-bz{background-position:-240px -11px}.flag-ca{background-position:-256px -11px}.flag-catalonia{background-position:-272px -11px}.flag-cc{background-position:-288px -11px}.flag-cd{background-position:0 -22px}.flag-cf{background-position:-16px -22px}.flag-cg{background-position:-32px -22px}.flag-ch{background-position:-48px -22px}.flag-ci{background-position:-64px -22px}.flag-ck{background-position:-80px -22px}.flag-cl{background-position:-96px -22px}.flag-cm{background-position:-112px -22px}.flag-cn{background-position:-128px -22px}.flag-co{background-position:-144px -22px}.flag-cr{background-position:-160px -22px}.flag-cs{background-position:-176px -22px}.flag-cu{background-position:-192px -22px}.flag-cv{background-position:-208px -22px}.flag-cx{background-position:-224px -22px}.flag-cy{background-position:-240px -22px}.flag-cz{background-position:-256px -22px}.flag-de{background-position:-272px -22px}.flag-dj{background-position:-288px -22px}.flag-dk{background-position:0 -33px}.flag-dm{background-position:-16px -33px}.flag-do{background-position:-32px -33px}.flag-dz{background-position:-48px -33px}.flag-ec{background-position:-64px -33px}.flag-ee{background-position:-80px -33px}.flag-eg{background-position:-96px -33px}.flag-eh{background-position:-112px -33px}.flag-england{background-position:-128px -33px}.flag-er{background-position:-144px -33px}.flag-es{background-position:-160px -33px}.flag-et{background-position:-176px -33px}.flag-europeanunion{background-position:-192px -33px}.flag-fam{background-position:-208px -33px}.flag-fi{background-position:-224px -33px}.flag-fj{background-position:-240px -33px}.flag-fk{background-position:-256px -33px}.flag-fm{background-position:-272px -33px}.flag-fo{background-position:-288px -33px}.flag-fr{background-position:0 -44px}.flag-ga{background-position:-16px -44px}.flag-gb{background-position:-32px -44px}.flag-gd{background-position:-48px -44px}.flag-ge{background-position:-64px -44px}.flag-gf{background-position:-80px -44px}.flag-gh{background-position:-96px -44px}.flag-gi{background-position:-112px -44px}.flag-gl{background-position:-128px -44px}.flag-gm{background-position:-144px -44px}.flag-gn{background-position:-160px -44px}.flag-gp{background-position:-176px -44px}.flag-gq{background-position:-192px -44px}.flag-gr{background-position:-208px -44px}.flag-gs{background-position:-224px -44px}.flag-gt{background-position:-240px -44px}.flag-gu{background-position:-256px -44px}.flag-gw{background-position:-272px -44px}.flag-gy{background-position:-288px -44px}.flag-hk{background-position:0 -55px}.flag-hm{background-position:-16px -55px}.flag-hn{background-position:-32px -55px}.flag-hr{background-position:-48px -55px}.flag-ht{background-position:-64px -55px}.flag-hu{background-position:-80px -55px}.flag-id{background-position:-96px -55px}.flag-ie{background-position:-112px -55px}.flag-il{background-position:-128px -55px}.flag-in{background-position:-144px -55px}.flag-io{background-position:-160px -55px}.flag-iq{background-position:-176px -55px}.flag-ir{background-position:-192px -55px}.flag-is{background-position:-208px -55px}.flag-it{background-position:-224px -55px}.flag-jm{background-position:-240px -55px}.flag-jo{background-position:-256px -55px}.flag-jp{background-position:-272px -55px}.flag-ke{background-position:-288px -55px}.flag-kg{background-position:0 -66px}.flag-kh{background-position:-16px -66px}.flag-ki{background-position:-32px -66px}.flag-km{background-position:-48px -66px}.flag-kn{background-position:-64px -66px}.flag-kp{background-position:-80px -66px}.flag-kr{background-position:-96px -66px}.flag-kw{background-position:-112px -66px}.flag-ky{background-position:-128px -66px}.flag-kz{background-position:-144px -66px}.flag-la{background-position:-160px -66px}.flag-lb{background-position:-176px -66px}.flag-lc{background-position:-192px -66px}.flag-li{background-position:-208px -66px}.flag-lk{background-position:-224px -66px}.flag-lr{background-position:-240px -66px}.flag-ls{background-position:-256px -66px}.flag-lt{background-position:-272px -66px}.flag-lu{background-position:-288px -66px}.flag-lv{background-position:0 -77px}.flag-ly{background-position:-16px -77px}.flag-ma{background-position:-32px -77px}.flag-mc{background-position:-48px -77px}.flag-md{background-position:-64px -77px}.flag-me{background-position:-80px -77px}.flag-mg{background-position:-96px -77px}.flag-mh{background-position:-112px -77px}.flag-mk{background-position:-128px -77px}.flag-ml{background-position:-144px -77px}.flag-mm{background-position:-160px -77px}.flag-mn{background-position:-176px -77px}.flag-mo{background-position:-192px -77px}.flag-mp{background-position:-208px -77px}.flag-mq{background-position:-224px -77px}.flag-mr{background-position:-240px -77px}.flag-ms{background-position:-256px -77px}.flag-mt{background-position:-272px -77px}.flag-mu{background-position:-288px -77px}.flag-mv{background-position:0 -88px}.flag-mw{background-position:-16px -88px}.flag-mx{background-position:-32px -88px}.flag-my{background-position:-48px -88px}.flag-mz{background-position:-64px -88px}.flag-na{background-position:-80px -88px}.flag-nc{background-position:-96px -88px}.flag-ne{background-position:-112px -88px}.flag-nf{background-position:-128px -88px}.flag-ng{background-position:-144px -88px}.flag-ni{background-position:-160px -88px}.flag-nl{background-position:-176px -88px}.flag-no{background-position:-192px -88px}.flag-np{background-position:-208px -88px}.flag-nr{background-position:-224px -88px}.flag-nu{background-position:-240px -88px}.flag-nz{background-position:-256px -88px}.flag-om{background-position:-272px -88px}.flag-pa{background-position:-288px -88px}.flag-pe{background-position:0 -99px}.flag-pf{background-position:-16px -99px}.flag-pg{background-position:-32px -99px}.flag-ph{background-position:-48px -99px}.flag-pk{background-position:-64px -99px}.flag-pl{background-position:-80px -99px}.flag-pm{background-position:-96px -99px}.flag-pn{background-position:-112px -99px}.flag-pr{background-position:-128px -99px}.flag-ps{background-position:-144px -99px}.flag-pt{background-position:-160px -99px}.flag-pw{background-position:-176px -99px}.flag-py{background-position:-192px -99px}.flag-qa{background-position:-208px -99px}.flag-re{background-position:-224px -99px}.flag-ro{background-position:-240px -99px}.flag-rs{background-position:-256px -99px}.flag-ru{background-position:-272px -99px}.flag-rw{background-position:-288px -99px}.flag-sa{background-position:0 -110px}.flag-sb{background-position:-16px -110px}.flag-sc{background-position:-32px -110px}.flag-scotland{background-position:-48px -110px}.flag-sd{background-position:-64px -110px}.flag-se{background-position:-80px -110px}.flag-sg{background-position:-96px -110px}.flag-sh{background-position:-112px -110px}.flag-si{background-position:-128px -110px}.flag-sj{background-position:-144px -110px}.flag-sk{background-position:-160px -110px}.flag-sl{background-position:-176px -110px}.flag-sm{background-position:-192px -110px}.flag-sn{background-position:-208px -110px}.flag-so{background-position:-224px -110px}.flag-sr{background-position:-240px -110px}.flag-st{background-position:-256px -110px}.flag-sv{background-position:-272px -110px}.flag-sy{background-position:-288px -110px}.flag-sz{background-position:0 -121px}.flag-tc{background-position:-16px -121px}.flag-td{background-position:-32px -121px}.flag-tf{background-position:-48px -121px}.flag-tg{background-position:-64px -121px}.flag-th{background-position:-80px -121px}.flag-tj{background-position:-96px -121px}.flag-tk{background-position:-112px -121px}.flag-tl{background-position:-128px -121px}.flag-tm{background-position:-144px -121px}.flag-tn{background-position:-160px -121px}.flag-to{background-position:-176px -121px}.flag-tr{background-position:-192px -121px}.flag-tt{background-position:-208px -121px}.flag-tv{background-position:-224px -121px}.flag-tw{background-position:-240px -121px}.flag-tz{background-position:-256px -121px}.flag-ua{background-position:-272px -121px}.flag-ug{background-position:-288px -121px}.flag-um{background-position:0 -132px}.flag-us{background-position:-16px -132px}.flag-uy{background-position:-32px -132px}.flag-uz{background-position:-48px -132px}.flag-va{background-position:-64px -132px}.flag-vc{background-position:-80px -132px}.flag-ve{background-position:-96px -132px}.flag-vg{background-position:-112px -132px}.flag-vi{background-position:-128px -132px}.flag-vn{background-position:-144px -132px}.flag-vu{background-position:-160px -132px}.flag-wales{background-position:-176px -132px}.flag-wf{background-position:-192px -132px}.flag-ws{background-position:-208px -132px}.flag-ye{background-position:-224px -132px}.flag-yt{background-position:-240px -132px}.flag-za{background-position:-256px -132px}.flag-zm{background-position:-272px -132px}.flag-zw{background-position:-288px -132px}.box-topDivider-container{float:left;clear:left;border-top:8px solid #a7a59b;width:342px;margin-bottom:32px;padding-top:3px}.box-topDivider-container.advertising{margin-bottom:13px}.box-topDivider-content{padding:16px 0 0 0}.msie6 .box-topDivider-container,.msie7 .box-topDivider-container{margin-bottom:29px;padding-top:5px}.msie .box-topDivider-container.advertising{margin-bottom:28px}.msie .box-topDivider-content{padding-top:13px}.heading-simple-large{font-size:18px;line-height:22px;margin-top:2px}.heading-simple-large a{color:#000}.heading-simple-large a:hover{color:#2e6e9e}.heading-simple-normal{font-size:16px;margin-top:4px}.heading-simple-normal a{color:#000}.heading-simple-normal a:hover{color:#2e6e9e}.brandPagePromo img.brandPagePromoImage{float:right;padding:0 0 14px 12px}.brandPagePromo .twitterFollowButton{padding-top:13px}.brandPagePromo .box-topDivider-content{padding-left:1px;line-height:17px}#footer{width:972px;background-color:#fff1e0}#footer div{border-top:solid 9px #e9decf;padding:14px 0 0 0}#footer span{color:#999;line-height:1}#footer .copyright{color:#000;text-transform:uppercase}#footer ul{margin:0 0 2px 0}#footer ul li{display:inline;margin:2px 3px 0 0}#footer ul li a{display:inline;margin:0 0 0 5px;color:#2e6e9e}#footer ul li:first-child a{margin-left:0}#footer ul li a:hover{color:#000}.msie6 #footer ul{margin-left:-0.5em}#footer div.pearson{height:18px;background-color:#e9decf;padding:13px 17px 13px 16px;margin-top:14px;border:0}#footer div.pearson div{border:0;padding:0}#footer div.pearson .tagline{width:139px;height:10px;float:left;margin-top:4px;background:url("http://im.ft-static.com/m/img/pearson_always_learning_small.gif") no-repeat}#footer div.pearson .companyName{width:114px;height:18px;float:right;background:url("http://im.ft-static.com/m/img/pearson_logo_small.gif") no-repeat}span.offscreen{position:absolute;left:-5000px;top:auto;width:1px;height:1px;overflow:hidden}#deviceSwitchingLink{background:url("http://im.ft-static.com/m/img/mobile-icon.gif") no-repeat 0 1px transparent;padding-left:13px;position:absolute;right:0;top:23px}.msie #deviceSwitchingLink{background-position:0 2px}.hidden,#page-title .section .hidden{display:none}#print-footer{display:none}#print-footer p{margin-bottom:5px}#print-footer .company{text-transform:uppercase}#print-footer #print-from{margin-bottom:20px}@media print{body{background-color:transparent;width:auto}#page-container{padding-right:0;padding-left:0;z-index:1000}.storyvideo,#DRMUpsell,#header .pagename,#header .colright,#page-title .section .bc,#navigation,.railSection,.storyTools,div.topAds,.topAds,#mpu,.adRequests,div.marketingSection,#footer,#renderPageOverlay,.bottomSection,#inferno-input,#inferno-comments,#ft-article-comments,.ft-livefyre-comments,#print-footer .links,.story-package,#annotationstool,.assankainpagemarkerwrapper,.insideArticleShare,#expandableimage .enlargeIcon{display:none}#page-title .section .heading{display:inline-block}.promobox{background:0;border-top-color:#ccc}#print-footer{display:block;font-size:75%}div#mpu,div#mpu2,div#mpu3{width:1px;height:1px;overflow:hidden;visibility:hidden;min-height:1px}div.topSection #header,div.topSection #page-title,div.topSection #page-title .bar,div.topSection #header img{margin:0;padding:0}.overlay{padding-left:0}div.topSection #header{border-bottom:4px solid #9e2f50;padding-bottom:8px;margin-bottom:16px}div.topSection #header img{width:auto;height:auto;visibility:visible}#page-title .section .heading{background-image:none}#footer #content{border-top:0 none;padding-top:5px}#footer span{color:#000}.bottomSection .freestyle{margin-top:75px}div.container,div.middleSection,div.contentSection,div.editorialSection,div.master-row,div.wideContentSection,div.wideContentSection .editorialSection{width:auto;float:none}div.contentSection{border-right:0 none}#publicationDate{color:#000}.fullstoryHeader{padding-left:0;padding-top:0}.fullstoryBody{border-top-color:#999;padding-left:0;padding-right:0;padding-bottom:0;margin-left:0}a,a:hover,a:link,a:visited,a:active{color:#000;text-decoration:none}.screen-copy{display:none}.slideshow-viewer{display:none}.slideshow-head ul{display:none}.slideshow .slideshow-slide img{display:block}.slideshow .slideshow-caption{display:block}.fullstoryBody caption{background-color:#fff;color:#333}}.ft-badge{display:inline-block;border-radius:5px;padding:2px 4px 0 4px;font-family:BentonSans,Helvetica,Arial,sans-serif;font-size:10px;font-weight:normal;line-height:13px;letter-spacing:1px;text-transform:uppercase}.ft-badge-red{background-color:#c00;color:#fff}.ft-button{display:inline-block;margin:0;padding:0 8px!important;border-width:0!important;border-radius:.3em;width:auto!important;background:#4e8fbd;background:-webkit-gradient(linear,left top,left bottom,from(#549ccf),to(#4782ab));background:-moz-linear-gradient(#549ccf,#4782ab);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#549ccf,endColorstr=#4782ab);-ms-filter:"progid:DXImageTransform.Microsoft.gradient(startColorstr=#549ccf, endColorstr=#4782ab)";zoom:1;box-shadow:inset 0 1px 0 #90bfe0;-moz-box-shadow:inset 0 1px 0 #90bfe0;-webkit-box-shadow:inset 0 1px 0 #90bfe0;font-family:Arial,Helvetica,sans-serif;color:#fff;text-shadow:0 0 0 #FFF;text-decoration:none;white-space:nowrap;cursor:pointer}.ft-button:hover{color:#ddd}.ft-button-large{min-height:30px;line-height:30px;font-size:16px}.ft-button-medium{min-height:24px;line-height:24px;font-size:14px}.ft-button-small{min-height:16px;line-height:16px;font-size:12px}.ft-button-negative{background:#b7315c;background:-webkit-gradient(linear,left top,left bottom,from(#c36),to(#9f2f50));background:-moz-linear-gradient(#c36,#9f2f50);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#cc3366,endColorstr=#9f2f50);-ms-filter:"progid:DXImageTransform.Microsoft.gradient(startColorstr=#cc3366, endColorstr=#9f2f50)";box-shadow:inset 0 1px 0 #d89aad;-moz-box-shadow:inset 0 1px 0 #d89aad;-webkit-box-shadow:inset 0 1px 0 #d89aad}.ft-button.ft-state-disabled,.ft-button[disabled]{background:#acacac;background:-webkit-gradient(linear,left top,left bottom,from(#b5b5b5),to(#a5a5a5));background:-moz-linear-gradient(#b5b5b5,#a5a5a5);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#b5b5b5,endColorstr=#a5a5a5);-ms-filter:"progid:DXImageTransform.Microsoft.gradient(startColorstr=#b5b5b5, endColorstr=#a5a5a5)";box-shadow:inset 0 1px 0 #a5a5a5;-moz-box-shadow:inset 0 1px 0 #a5a5a5;-webkit-box-shadow:inset 0 1px 0 #a5a5a5;cursor:default;color:#fff}.ft-divider{clear:both;margin:0;border-width:0;border-radius:4px;height:9px;background-color:#e9decf}.ft-footer-small{clear:left;height:1px;background:url("http://im.ft-static.com/m/img/ft-velcro/border-horizontal-dotted.png") bottom left repeat-x}.ft-header-small{position:relative;border-radius:4px;background-color:#e9decf;height:9px;line-height:8px;font-size:14px}.ft-header-small-title{display:inline-block;padding:0 10px;font-family:BentonSans,Helvetica,Arial,sans-serif;font-weight:bold;font-size:12px;line-height:10px;background-color:#fff1e0;color:#333}.msie7 .ft-header-small-title{display:inline;zoom:1}.msie6 .ft-header-small-title{position:static;background-color:transparent}.ft-header-small-titleleft{border-top-left-radius:0;border-bottom-left-radius:0}.ft-header-small-titleleft .ft-header-small-title{padding-left:0}.ft-header-small-titlecenter{text-align:center}.ft-header-medium{clear:left;font-family:BentonSans,Helvetica,Arial,sans-serif;overflow:hidden;border-top:4px solid #a7a59b;background:url("http://im.ft-static.com/m/img/ft-velcro/border-horizontal-dotted.png") bottom left repeat-x}.ft-header-medium-title{float:left;margin:8px 0 8px 10px;font-size:12px;line-height:16px;font-weight:bold;font-style:normal;color:#434343}a.ft-header-medium-title:hover{color:#2e6e9e}.msie6 .ft-header-medium{height:34px;overflow:visible}.msie6 .ft-header-medium-title{display:inline}.msie7 .ft-header-medium{min-height:34px}.ft-header-medium-tabs{border-top:0;background-color:#e9decf;background-image:none}.ft-header-medium-tint{background-color:#e9decf}.ft-tabs-large{background-image:none}.ft-tab-large{float:left;margin-left:2px;border-top:4px solid #a8a79c;width:175px;text-align:center;background:#e9decf url("http://im.ft-static.com/m/img/ft-velcro/border-horizontal-dotted.png") bottom left repeat-x}.ft-tab-large:first-child{margin-left:0}.msie6 .ft-tab-large{margin-left:0;width:176px}.ft-tab-title{display:block;white-space:nowrap;color:#434343;text-overflow:ellipsis}.ft-tab-title img{vertical-align:middle}a.ft-tab-title:hover{color:#2e6e9e}.ft-tabs-large .ft-tab-title{font-size:12px;line-height:34px;font-weight:bold;opacity:.5}.ft-tabs-large .ft-state-selected{border-top-color:#a7294d;background:#f6e9d8}.ft-tabs-large .ft-state-selected .ft-tab-title{color:#333;opacity:1}.ft-tabs-large .ft-state-selected .ft-tab-title:hover{cursor:text}.ft-tabs-small{padding-left:20px;background:url("http://im.ft-static.com/m/img/ft-velcro/border-horizontal-dotted.png") bottom left repeat-x;overflow:hidden}.ft-header-medium-title+.ft-tabs-small{float:right}.ft-tabs-large+.ft-tabs-small{clear:left;background-color:#f6e9d8}.msie6 .ft-tabs-small{zoom:1}.ft-tab-small{float:left;margin-right:10px}.ft-tabs-small .ft-tab-title{font-size:11px;line-height:34px}.ft-tabs-small .ft-state-selected{background:url("http://im.ft-static.com/m/img/ft-velcro/mod-tab-selected.gif") bottom center no-repeat}.ft-tabs-small .ft-state-selected .ft-tab-title{color:#000}.ft-tabs-small .ft-state-selected .ft-tab-title:hover{cursor:text}.ft-heading{font-family:BentonSans,Helvetica,Arial,sans-serif;color:#000}.ft-heading a{color:#2e6e9e}.ft-heading a:hover{color:#000}.ft-heading-medium{text-transform:uppercase;font-size:12px;font-weight:bold;line-height:14px}.ft-heading-small{font-size:14px;line-height:18px}.ft-image{display:inline-block;position:relative;margin:0;padding:0;overflow:hidden}.msie7 .ft-image{float:left;display:inline}.ft-image-article{width:600px;height:338px}.ft-image-primary{width:272px;height:153px}.ft-image-secondary{width:167px;height:94px}.ft-image-tertiary{width:114px;height:64px}.ft-image-leader{width:414px;height:233px}.ft-image-fullwidth{width:972px;height:547px}.ft-overlay{position:absolute;padding:5px 10px}.ft-overlay-topleft{top:0;left:0}.ft-overlay-bottom{bottom:0;left:0;right:0}.ft-overlay-bottomright-credit{bottom:0;right:0;padding:2px 2px 1px 2px}.ft-overlay-bg20{background-color:black;background-color:rgba(0,0,0,0.2)}.ft-overlay-bg30{background-color:black;background-color:rgba(0,0,0,0.3)}.ft-overlay-bg50{background-color:black;background-color:rgba(0,0,0,0.5)}.ft-overlay-heading{font-family:BentonSans,Helvetica,Arial,sans-serif;font-size:11px;font-weight:bold;line-height:14px;color:#fff}.ft-overlay-title{font-family:BentonSans,Helvetica,Arial,sans-serif;font-size:20px;line-height:24px;color:#fff}.ft-overlay-caption{font-family:BentonSans,Helvetica,Arial,sans-serif;font-size:12px;line-height:14px;color:#fff}.ft-overlay-credit{display:block;font-size:9px;line-height:9px;text-decoration:none;white-space:nowrap;color:#fff}.ft-link{color:#2e6e9e;text-decoration:none}.ft-link:hover{color:#000}.ft-link-stealth{color:inherit;text-decoration:none}.msie7 .ft-link-stealth{color:expression(this.parentNode.currentStyle["color"])}.ft-link-stealth:hover{color:#2e6e9e}.ft-link-alternative{color:#9b164f}.ft-link-kicker{color:#000}.ft-list{margin:0;padding:0;font-family:BentonSans,Helvetica,Arial,sans-serif;font-size:13px;line-height:21px;color:#777}.ft-list-item{margin:0;padding:0}.ft-list-bulletted .ft-list-item,.ft-list-numbered .ft-list-item{margin-left:18px}.ft-list-plain .ft-list-item{list-style-type:none}.ft-list-bulletted .ft-list-item{list-style-type:disc}.ft-list-numbered .ft-list-item{list-style-type:decimal}.ft-list-wrapping:after{content:".";display:block;clear:both;visibility:hidden;line-height:0;height:0}.ft-list-wrapping .ft-list-item{float:left;clear:none;margin-right:18px}body,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,form,body,html,p,blockquote,fieldset,input{margin:0;padding:0}a{text-decoration:none}h1,h2,h3,h4,h5,h6,pre,code{font-weight:normal}ul,ol{list-style:none}acronym,a img,:link img,:visited img,fieldset{border:0}address{font-style:normal}.ft-spc-btm-full{margin-bottom:20px}.ft-spc-btm-half{margin-bottom:10px}.ft-spc-btm-qtr{margin-bottom:5px}.ft-state-hidden{display:none}.ft-timestamp{font-family:BentonSans,Helvetica,Arial,sans-serif;font-size:12px;font-weight:normal;line-height:14px;color:#777}.ft-title{font-family:Georgia,"Times New Roman",serif;color:#000}.ft-title-large{font-size:32px;line-height:36px}.ft-title-medium{font-size:21px;line-height:26px}.ft-title-small{font-size:15px;font-weight:bold;line-height:17px}.ft-lead{font-family:Georgia,"Times New Roman",serif;color:#505050}.ft-lead-large{font-size:21px;line-height:26px}.ft-lead-medium{font-size:17px;line-height:21px;color:#777}.ft-lead-small{font-family:BentonSans,Helvetica,Arial,sans-serif;font-size:13px;line-height:17px}.ft-byline{font-family:BentonSans,Helvetica,Arial,sans-serif;clear:left;color:#666}.carousel{position:relative;overflow:hidden}.carousel .carousel-collection{position:absolute;top:0;left:0;margin:0;padding:0;list-style-type:none}.carousel .carousel-item{float:left}.carousel-nav{position:absolute;cursor:pointer}.msie .carousel-nav{background:url("http://im.ft-static.com/m/img/blank.gif") transparent repeat}.carousel-nav span{display:block}.carousel-prev{left:0}.carousel-next{right:0}.carousel-hidden{display:none}.comp-header{border-top:4px solid #a7a59b;background:#e9decf url("http://im.ft-static.com/m/img/components/common/comp-header-border.png") bottom left repeat-x;overflow:hidden}.comp-header-title{float:left;margin:8px 0 8px 10px;font-size:12px;line-height:16px;font-weight:bold;font-style:normal;color:#434343}.comp-header-title a{color:#434343}.comp-header-title a:hover{color:#2e6e9e}.comp-header-tabs{float:right;font-size:11px;line-height:34px}.comp-header-tab{float:left;margin-right:10px;white-space:nowrap}.comp-state-selected{background:url("http://im.ft-static.com/m/img/components/common/comp-tab-selected.gif") bottom center no-repeat}.comp-state-selected a{color:#000}.msie6 .comp-header{height:34px;overflow:visible}.msie6 .comp-header-title{display:inline}.msie7 .comp-header{min-height:34px}.story-image{display:inline;position:relative;float:left;margin:0;overflow:hidden}.assassination .story-image{width:466px;height:280px}.primary .story-image{width:272px;min-height:153px;max-height:193px}.secondary .story-image{width:167px;height:96px}.story-image a,.story-image img{display:block}.story-image .credit{position:absolute;right:2px;bottom:2px;font-family:Arial,Helvetica,sans-serif;font-size:9px;line-height:1;color:#dfded8}.fullstoryBody .story-image .credit{line-height:2}.manualSource{cursor:default}.img-overlay{position:absolute;top:4px;left:4px;background-image:url("http://im.ft-static.com/m/img/sprites/image-overlay-icons-sprite.png");height:24px;width:24px}.img-overlay-icon-video{background-position:0 0}.img-overlay-icon-interactive{background-position:left 28px}.img-overlay-icon-qanda{background-position:left 58px}.img-overlay-icon-slideshow{background-position:left 87px}.img-overlay-icon-audio{background-position:left 116px}.msie6 .primary .story-image{height:153px}.ftbf-syndicationIndicator{display:none;background:url("http://im.ft-static.com/m/img/checked.png") no-repeat;height:16px;width:19px;vertical-align:middle;margin:0 0 4px 0;position:relative}.ftbf-syndicationIndicator a{display:none;position:absolute;left:-108px;margin-left:17px;color:#FFF;font-weight:bold;font-family:BentonSans,Helvetica,Arial,sans-serif;font-size:13px;line-height:19px;background:#74736c;width:auto;padding:0 15px 2px;border-radius:4px}.top5_standalone .ft-image{overflow:inherit}.top5_standalone .ft-image.carousel{overflow:hidden}.top5_standalone .ft-image-leader:hover .ft-overlay-title .ftbf-syndicationIndicator a{color:#FFF}.ftbf-syndicationIndicator-notPublic{display:none;background:url("http://im.ft-static.com/m/img/stop.png") no-repeat;height:16px;width:19px;vertical-align:middle;margin:0 0 4px 0}.doublet a{color:#000}.doublet a:hover{color:#2e6e9e}.doublet div .label{display:block;min-height:12px;font-size:12px;line-height:14px;font-weight:bold;text-transform:uppercase}.doublet div a.label{color:#2e6e9e}.doublet div a.label:hover{color:#000}.doublet div .story-image{margin:5px 0;width:161px;height:93px}.doublet div span.content{font-size:13px;line-height:16px}.doublet .doublet-wrapper.position-error,.doublet .position-error div span.image{width:163px;overflow:hidden}.doublet .position-error .doublet-content{margin-top:-2px}.editorialDoublet{margin:0 0 32px 0;overflow:hidden;clear:both}.doublet{display:inline;float:left;margin-top:10px;width:352px}.nojs .doublet{display:none}.doublet-column{display:inline;float:left;margin:0 0 0 10px;width:161px;overflow:hidden}.doublet-column .doublet-content{clear:both}.doublet-column .story-image img{width:161px}.doublet h4{font-size:12px;line-height:13px}.doublet h4 a{color:#2e6e9e}.doublet h4 a:hover{color:#000}.doublet p{margin:10px 0 15px 0;font-size:13px;line-height:18px}.doublet p a{display:block;color:#000}.doublet p a:hover{color:#2e6e9e}.freestyle{clear:both}.railComponent{clear:left;margin-bottom:27px;border-top:8px solid #a7a59b;padding-top:5px;overflow:hidden}.railComponent.advertising{margin-bottom:13px}.railComponent h3,.railComponentHeading{font-size:16px;margin-top:2px}.railComponent h3 a,.railComponentHeading a{color:#000}.railComponent h3 a:hover,.railComponentHeading a:hover{color:#2e6e9e}.mkAd,.tradeCenter{font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:16px}.mkAd .moreLink{float:right;margin-right:15px}div.mkAd p{margin:12px 0 0 16px;padding-bottom:9px}.tradeCenter li{background-color:#999;display:block;float:left;height:60px;margin:14px 21px 2px 18px;width:120px}.tradeCenter h3{margin-top:5px}.partnerLinks{clear:left;margin-bottom:32px;overflow:hidden}.partnerLinks .railLinks{display:inline;float:left;margin:6px 10px 0 10px;list-style:none;width:332px}.partnerLinks .railLinks li{float:left;clear:left;width:332px}.partnerLinks .railLinks li.moreLink{float:none;margin-right:0;clear:both;width:auto}.msie6 .partnerLinks .railLinks{margin-left:8px}.jobsBox{clear:left;margin-bottom:32px}.jobsBoxSearch{margin:12px 0 15px 10px}.jobsBoxSearch legend{display:none}.jobsBoxKeywords{border:1px solid #fff;border-radius:3px;padding:3px 4px 3px 4px;width:243px;height:16px;color:#8c8c8c}.jobsBoxSearchButton{border:0;height:24px;background:transparent;color:#fff;padding:0 0 0 6px;font-family:Arial,Helvetica,sans-serif;cursor:pointer;white-space:nowrap;overflow:visible}.jobsBoxSearchButton:hover{color:#ddd}.jobsBoxSearchButton span{display:inline-block;padding:0 4px 0 0;font-size:14px;line-height:24px;background:#4781aa no-repeat scroll right -64px url("http://im.ft-static.com/m/img/button_sprite.png")}.jobsBoxSearchButton span span{padding:0 4px 0 8px;background-position:left -90px;background-color:transparent}.jobsBoxLinks li{float:left;margin:0 10px 15px 0;width:161px;color:#8a8a8a;font-size:13px;line-height:18px;overflow:hidden}.jobsBoxLinks li.odd{clear:left;margin-left:10px}.jobsBoxLinks li.fullwidth{width:100%;margin-left:0}.jobsBoxLinks a{color:#000}.jobsBoxLinks a:hover{color:#2e6e9e}.jobsBoxOptionalLink{margin:0 10px;font-size:11px}.msie6 .jobsBoxSearch,.msie7 .jobsBoxSearch{margin-left:5px}.msie6 .jobsBoxLinks li{display:inline}.railList{margin:0 0 30px 0;width:352px}.railList .comp-body{padding:15px 10px 0}.railLinks{margin:0 0 0 23px;font-weight:bold;color:#777}ol.railLinks{list-style:decimal}.railLinks li{display:list-item;padding:2px 0;font-size:13px;line-height:18px}.msie ul.railLinks li{padding:3px 0 4px 0}.railLinks a{color:#2e6e9e;font-weight:normal}.railList .moreLink{padding-right:0;text-align:right}.railList .moreLink a{color:#2e6e9e}.railLinks a:hover,.railList .moreLink a:hover{color:#000}.topics a{color:#9b164f}.railMiniVideo{margin-bottom:32px}.editorialSection .railMiniVideo{margin-bottom:20px}.railMiniVideo-player{margin:10px 0 0 10px;width:332px;height:225px;-webkit-transform-style:preserve-3d}.editorialSection .railMiniVideo-player{margin:10px 0 0;width:600px;height:338px}.nojs .railMiniVideo-player{display:none}.railMiniVideo-player-nojs{margin:10px 5px 0 5px;width:342px}#wsodHomepagePlaceholder,#wsodCommoditiesPlaceholder{min-height:181px}.wsodRailModule{margin:0 0 30px 0;overflow:auto}.railSection .ft-header-medium-title a{color:#434343}.railSection .ft-header-medium-title a:hover{color:#2e6e9e}.slideshow{-webkit-font-smoothing:antialiased;-webkit-perspective:1000;-webkit-backface-visibility:hidden;clear:both}.slideshow ul{-webkit-transform:translate3d(0,0,0)}.slideshow .carousel-item{position:relative;background-color:#000}.slideshow .carousel-item img{display:block;margin:0 auto}.hoverable .slideshow .carousel-nav{display:none}.slideshow .carousel-nav span{text-indent:-200px;overflow:hidden}.slideshow:hover .carousel-nav{display:block}.slideshow:hover .carousel-hidden{display:none}.slideshow-narrow,.slideshow-narrow .carousel-item{width:414px;height:233px}.slideshow-standard,.slideshow-standard .carousel-item{width:600px;height:338px}.slideshow-wide,.slideshow-wide .carousel-item{width:972px;height:547px}.nojs .thumbnail-carousel{display:none}.thumbnail-carousel{margin-top:-42px;margin-bottom:42px;height:72px;background-color:#a7a59b}.thumbnails-narrow{width:414px}.thumbnails-standard{width:600px}.thumbnails-wide{width:972px}.thumbnail-carousel .carousel-item{position:relative}.thumbnail-carousel .carousel-item img{display:block;cursor:pointer}.msie6 .carousel-item{display:inline;overflow:hidden}.thumbnails-standard .carousel-item{margin-left:20px;margin-top:4px}.thumbnails-wide .carousel-item{margin-left:16px;margin-top:4px}.thumbnail-carousel .carousel-nav{width:26px;height:72px;background-image:url("http://im.ft-static.com/m/img/slideshow/nav_sprite_thumbnails.png")}.thumbnail-carousel .carousel-prev{background-position:0 0}.thumbnail-carousel .carousel-prev:hover{background-position:0 72px}.thumbnail-carousel .carousel-next{background-position:26px 0}.thumbnail-carousel .carousel-next:hover{background-position:26px 72px}.thumbnails-standard li:first-child,.thumbnails-standard .carousel-group-start{margin-left:42px}.thumbnails-wide li:first-child,.thumbnails-wide .carousel-group-start{margin-left:39px}.thumbnails-standard .carousel-group-end{margin-right:42px}.thumbnails-wide .carousel-group-end{margin-right:39px}.thumbnail-carousel .imageset-selected div.indicate-imageset-selected{background:url("http://im.ft-static.com/m/img/slideshow/select-thumbV3.png") no-repeat;position:absolute;top:0;left:0;width:100%;height:100%}.toolsandservices{clear:left;margin-bottom:18px}.tools-column{float:left;margin:11px 0 12px 0;width:176px;display:inline}.tools-heading{margin:0 0 3px 10px;padding:0;font-weight:bold;font-size:11px;color:#434343}.tools-column ul{padding-bottom:20px;list-style-type:none}.tools-column li{padding:2px 0 2px 10px;color:#74736c;font-weight:bold;font-size:13px;line-height:18px}.tools-column+.tools-column li{padding-right:10px}.tools-column li a{font-weight:normal}.tools-column img{vertical-align:middle}.tools-column a.rss,.tools-column a.rss:visited{padding-left:18px;color:#fa9d3a;background:url("http://im.ft-static.com/m/img/rss.gif") left 50% no-repeat}.tools-column a.rss:hover{color:#000}.msie6 .toolsandservices{overflow:hidden} -------------------------------------------------------------------------------- /fixtures/xyz.js: -------------------------------------------------------------------------------- 1 | // var md5 = require('md5') 2 | // var $ = require('jquery') 3 | // var Highcharts = require('highcharts') 4 | // require('./assets-graph-theme') 5 | // var prettyBytes = require('pretty-bytes') 6 | 7 | // function setTrend (assetHash, firstMetric, secondMetric) { 8 | // var difference = secondMetric - firstMetric 9 | // var trend = (difference < 0) ? 'down' : 'up' 10 | // var trendSign = (difference > 0) ? '+' : '' 11 | 12 | // $trendElement = $('.js-trend-' + assetHash) 13 | 14 | // if (difference !== 0) { 15 | // $trendElement.addClass('trend--' + trend) 16 | // $trendElement.find('.js-trend-sign').text(trendSign) 17 | // $trendElement.find('.js-trend-value').text(prettyBytes(difference)) 18 | // } 19 | // } 20 | 21 | // function graphStylesheets () { 22 | // var sizes 23 | // var firstSize 24 | // var lastSize 25 | 26 | // $('.js-asset').each(function (assetContainer) { 27 | // var assetHash = $(this).data('asset-hash') 28 | 29 | // $.getJSON('/metrics/stylesheets/' + assetHash, function (series) { 30 | // sizes = series[0].data 31 | // firstSize = sizes[0][1] 32 | // lastSize = sizes[sizes.length - 1][1] 33 | // setTrend(assetHash, firstSize, lastSize) 34 | 35 | // new Highcharts.Chart({ 36 | // chart: { 37 | // type: 'spline', 38 | // renderTo: 'js-asset-chart-' + assetHash 39 | // }, 40 | // yAxis: [ 41 | // { 42 | // title: { 43 | // text: 'Size' 44 | // }, 45 | // labels: { 46 | // formatter: function () { 47 | // return prettyBytes(this.value) 48 | // } 49 | // } 50 | // }, 51 | // { 52 | // title: { 53 | // text: 'Count' 54 | // }, 55 | // opposite: true 56 | // } 57 | // ], 58 | // tooltip: { 59 | // crosshairs: [false, true], 60 | // formatter: function () { 61 | // return '' + this.series.name + '
' + Highcharts.dateFormat('%b %e, %H:%M', this.x) + ': ' + (this.series.type === 'area' ? prettyBytes(this.y) : this.y) + '' 62 | // } 63 | // }, 64 | // series: series 65 | // }) 66 | // }) 67 | // }) 68 | // } 69 | 70 | // $(function () { 71 | // graphStylesheets() 72 | // }) 73 | // var md5 = require('md5') 74 | // var $ = require('jquery') 75 | // var Highcharts = require('highcharts') 76 | // require('./assets-graph-theme') 77 | // var prettyBytes = require('pretty-bytes') 78 | 79 | // function setTrend (assetHash, firstMetric, secondMetric) { 80 | // var difference = secondMetric - firstMetric 81 | // var trend = (difference < 0) ? 'down' : 'up' 82 | // var trendSign = (difference > 0) ? '+' : '' 83 | 84 | // $trendElement = $('.js-trend-' + assetHash) 85 | 86 | // if (difference !== 0) { 87 | // $trendElement.addClass('trend--' + trend) 88 | // $trendElement.find('.js-trend-sign').text(trendSign) 89 | // $trendElement.find('.js-trend-value').text(prettyBytes(difference)) 90 | // } 91 | // } 92 | 93 | // function graphStylesheets () { 94 | // var sizes 95 | // var firstSize 96 | // var lastSize 97 | 98 | // $('.js-asset').each(function (assetContainer) { 99 | // var assetHash = $(this).data('asset-hash') 100 | 101 | // $.getJSON('/metrics/stylesheets/' + assetHash, function (series) { 102 | // sizes = series[0].data 103 | // firstSize = sizes[0][1] 104 | // lastSize = sizes[sizes.length - 1][1] 105 | // setTrend(assetHash, firstSize, lastSize) 106 | 107 | // new Highcharts.Chart({ 108 | // chart: { 109 | // type: 'spline', 110 | // renderTo: 'js-asset-chart-' + assetHash 111 | // }, 112 | // yAxis: [ 113 | // { 114 | // title: { 115 | // text: 'Size' 116 | // }, 117 | // labels: { 118 | // formatter: function () { 119 | // return prettyBytes(this.value) 120 | // } 121 | // } 122 | // }, 123 | // { 124 | // title: { 125 | // text: 'Count' 126 | // }, 127 | // opposite: true 128 | // } 129 | // ], 130 | // tooltip: { 131 | // crosshairs: [false, true], 132 | // formatter: function () { 133 | // return '' + this.series.name + '
' + Highcharts.dateFormat('%b %e, %H:%M', this.x) + ': ' + (this.series.type === 'area' ? prettyBytes(this.y) : this.y) + '' 134 | // } 135 | // }, 136 | // series: series 137 | // }) 138 | // }) 139 | // }) 140 | // } 141 | 142 | // $(function () { 143 | // graphStylesheets() 144 | // }) 145 | // var md5 = require('md5') 146 | // var $ = require('jquery') 147 | // var Highcharts = require('highcharts') 148 | // require('./assets-graph-theme') 149 | // var prettyBytes = require('pretty-bytes') 150 | 151 | // function setTrend (assetHash, firstMetric, secondMetric) { 152 | // var difference = secondMetric - firstMetric 153 | // var trend = (difference < 0) ? 'down' : 'up' 154 | // var trendSign = (difference > 0) ? '+' : '' 155 | 156 | // $trendElement = $('.js-trend-' + assetHash) 157 | 158 | // if (difference !== 0) { 159 | // $trendElement.addClass('trend--' + trend) 160 | // $trendElement.find('.js-trend-sign').text(trendSign) 161 | // $trendElement.find('.js-trend-value').text(prettyBytes(difference)) 162 | // } 163 | // } 164 | 165 | // function graphStylesheets () { 166 | // var sizes 167 | // var firstSize 168 | // var lastSize 169 | 170 | // $('.js-asset').each(function (assetContainer) { 171 | // var assetHash = $(this).data('asset-hash') 172 | 173 | // $.getJSON('/metrics/stylesheets/' + assetHash, function (series) { 174 | // sizes = series[0].data 175 | // firstSize = sizes[0][1] 176 | // lastSize = sizes[sizes.length - 1][1] 177 | // setTrend(assetHash, firstSize, lastSize) 178 | 179 | // new Highcharts.Chart({ 180 | // chart: { 181 | // type: 'spline', 182 | // renderTo: 'js-asset-chart-' + assetHash 183 | // }, 184 | // yAxis: [ 185 | // { 186 | // title: { 187 | // text: 'Size' 188 | // }, 189 | // labels: { 190 | // formatter: function () { 191 | // return prettyBytes(this.value) 192 | // } 193 | // } 194 | // }, 195 | // { 196 | // title: { 197 | // text: 'Count' 198 | // }, 199 | // opposite: true 200 | // } 201 | // ], 202 | // tooltip: { 203 | // crosshairs: [false, true], 204 | // formatter: function () { 205 | // return '' + this.series.name + '
' + Highcharts.dateFormat('%b %e, %H:%M', this.x) + ': ' + (this.series.type === 'area' ? prettyBytes(this.y) : this.y) + '' 206 | // } 207 | // }, 208 | // series: series 209 | // }) 210 | // }) 211 | // }) 212 | // } 213 | 214 | // $(function () { 215 | // graphStylesheets() 216 | // }) 217 | // var md5 = require('md5') 218 | // var $ = require('jquery') 219 | // var Highcharts = require('highcharts') 220 | // require('./assets-graph-theme') 221 | // var prettyBytes = require('pretty-bytes') 222 | 223 | // function setTrend (assetHash, firstMetric, secondMetric) { 224 | // var difference = secondMetric - firstMetric 225 | // var trend = (difference < 0) ? 'down' : 'up' 226 | // var trendSign = (difference > 0) ? '+' : '' 227 | 228 | // $trendElement = $('.js-trend-' + assetHash) 229 | 230 | // if (difference !== 0) { 231 | // $trendElement.addClass('trend--' + trend) 232 | // $trendElement.find('.js-trend-sign').text(trendSign) 233 | // $trendElement.find('.js-trend-value').text(prettyBytes(difference)) 234 | // } 235 | // } 236 | 237 | // function graphStylesheets () { 238 | // var sizes 239 | // var firstSize 240 | // var lastSize 241 | 242 | // $('.js-asset').each(function (assetContainer) { 243 | // var assetHash = $(this).data('asset-hash') 244 | 245 | // $.getJSON('/metrics/stylesheets/' + assetHash, function (series) { 246 | // sizes = series[0].data 247 | // firstSize = sizes[0][1] 248 | // lastSize = sizes[sizes.length - 1][1] 249 | // setTrend(assetHash, firstSize, lastSize) 250 | 251 | // new Highcharts.Chart({ 252 | // chart: { 253 | // type: 'spline', 254 | // renderTo: 'js-asset-chart-' + assetHash 255 | // }, 256 | // yAxis: [ 257 | // { 258 | // title: { 259 | // text: 'Size' 260 | // }, 261 | // labels: { 262 | // formatter: function () { 263 | // return prettyBytes(this.value) 264 | // } 265 | // } 266 | // }, 267 | // { 268 | // title: { 269 | // text: 'Count' 270 | // }, 271 | // opposite: true 272 | // } 273 | // ], 274 | // tooltip: { 275 | // crosshairs: [false, true], 276 | // formatter: function () { 277 | // return '' + this.series.name + '
' + Highcharts.dateFormat('%b %e, %H:%M', this.x) + ': ' + (this.series.type === 'area' ? prettyBytes(this.y) : this.y) + '' 278 | // } 279 | // }, 280 | // series: series 281 | // }) 282 | // }) 283 | // }) 284 | // } 285 | 286 | // $(function () { 287 | // graphStylesheets() 288 | // }) 289 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | const level = require('level') 2 | const levelup = require('levelup') 3 | const encode = require('encoding-down') 4 | 5 | module.exports = function (db) { 6 | if (db.redis_url) { 7 | const RedisDown = require('redisdown') 8 | return levelup(encode(new RedisDown('moniteur')), { 9 | url: db.redis_url 10 | }) 11 | } 12 | 13 | // By default, save the database on the filesystem 14 | return level('./' + db.directory) 15 | } 16 | -------------------------------------------------------------------------------- /lib/jsparser.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const request = require('request') 3 | const gzipSize = require('gzip-size') 4 | 5 | /** 6 | * Get promised request 7 | * @param {Object} options 8 | * @returns {Promise} 9 | */ 10 | function requestSync (options) { 11 | return new Promise(function (resolve, reject) { 12 | request(options, function (error, response) { 13 | if (!error && response.statusCode === 200) { 14 | resolve(response) 15 | } else if (!error) { 16 | reject(new Error('Status code is ' + response.statusCode)) 17 | } else { 18 | reject(error) 19 | } 20 | }) 21 | }) 22 | } 23 | 24 | /** 25 | * JSParser class 26 | * @param {Array} urls 27 | * @param {Array} files 28 | * @constructor 29 | */ 30 | function JSParser (urls, files) { 31 | this.urls = urls 32 | this.files = files 33 | this.scripts = [] 34 | this.options = {} 35 | } 36 | 37 | /** 38 | * Parse JS data 39 | */ 40 | JSParser.prototype.parse = function parse () { 41 | // object to return 42 | let parsedData = { 43 | jsString: '', 44 | size: 0, 45 | gzippedSize: 0 46 | } 47 | // remote file requests 48 | const requestPromises = this.urls.map(url => 49 | requestSync({url: url})) 50 | 51 | // JS string array from arguments 52 | // they will be joined into JS string 53 | this.files.forEach((jsFile) => { 54 | // push local js data 55 | this.scripts.push(fs.readFileSync(jsFile, { 56 | encoding: 'utf8' 57 | })) 58 | }) 59 | 60 | // get remote files 61 | return new Promise((resolve, reject) => { 62 | Promise.all(requestPromises).then((results) => { 63 | // requests to scripts defined in html 64 | let requestPromisesInner = [] 65 | 66 | results.forEach((result) => { 67 | // push remote js data 68 | if (result.headers['content-type'].indexOf('javascript') > -1) { 69 | parsedData.files += 1 70 | this.scripts.push(result.body) 71 | } else { 72 | throw new Error('Content type is not JavaScript!') 73 | } 74 | }) 75 | 76 | if (requestPromisesInner.length > 0) { 77 | return Promise.all(requestPromisesInner) 78 | } else { 79 | return true 80 | } 81 | }).then((results) => { 82 | if (Array.isArray(results)) { 83 | results.forEach((result) => { 84 | this.scripts.push(result.body) 85 | }) 86 | } 87 | 88 | // join all JS string 89 | parsedData.jsString = this.scripts.join('') 90 | parsedData.size = Buffer.byteLength(parsedData.jsString, 'utf8') 91 | parsedData.gzippedSize = gzipSize.sync(parsedData.jsString) 92 | return resolve(parsedData) 93 | }) 94 | .catch(reason => reject(reason)) 95 | }) 96 | } 97 | 98 | // export 99 | module.exports = JSParser 100 | -------------------------------------------------------------------------------- /lib/record.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const StyleStats = require('stylestats') 3 | const debug = require('debug') 4 | const validUrl = require('valid-url') 5 | const sensors = require('./sensors') 6 | const JSParser = require('./jsparser') 7 | const utils = require('./utils') 8 | 9 | const log = debug('moniteur:log') 10 | 11 | /** 12 | * Argument is file path or not 13 | * @param {String} file 14 | * @returns {Boolean} 15 | */ 16 | function isFile (file) { 17 | try { 18 | return fs.existsSync(file) && fs.statSync(file).isFile() 19 | } catch (error) { 20 | return false 21 | } 22 | } 23 | 24 | // const setRecorder = (key, ) 25 | 26 | module.exports = class Record { 27 | constructor (assets, db) { 28 | this.assets = assets 29 | this.recorders = {} 30 | this.db = db 31 | } 32 | 33 | init () { 34 | return Object.keys(this.assets).map(assetName => 35 | new Promise((resolve, reject) => { 36 | this.recorders = this.recorders || {} 37 | const assetHash = utils.getAssetHash(assetName) 38 | const assetEndpoints = this.assets[assetName] 39 | const assetType = utils.getAssetType(assetEndpoints) 40 | this.recorders[assetHash] = {} 41 | 42 | if (!assetEndpoints) { 43 | reject(console.error('No asset names were found')) 44 | } 45 | 46 | Object.keys(sensors[assetType]).map(metric => { 47 | resolve(log('Set up recorder:', assetName, metric)) 48 | const key = 'assets.' + assetHash + '.' + metric 49 | this.db.index(key, metric) 50 | this.recorders[assetHash][metric] = this.db.recorder(key) 51 | }) 52 | }) 53 | ) 54 | } 55 | 56 | recordDataPoints () { 57 | return Object.keys(this.assets).map(assetName => { 58 | const assetHash = utils.getAssetHash(assetName) 59 | const assetEndpoints = this.assets[assetName] 60 | const assetType = utils.getAssetType(assetEndpoints) 61 | switch (assetType) { 62 | case 'js': 63 | const assetToParse = !Array.isArray(assetEndpoints) ? assetEndpoints.split(',') : assetEndpoints 64 | const urls = assetToParse.filter(validUrl.isUri) 65 | const files = assetToParse.filter(isFile) 66 | 67 | return new Promise((resolve, reject) => { 68 | const jsparser = new JSParser(urls, files) 69 | 70 | return jsparser.parse() 71 | .then(stats => 72 | Object.keys(sensors['js']).map(metric => 73 | this.recorders[assetHash][metric](stats[metric], () => 74 | resolve(log(`Recorded: ${assetHash} 'js' ${assetEndpoints} ${metric} ${stats[metric]}`)) 75 | ) 76 | ) 77 | ) 78 | .catch(reason => reject(reason)) 79 | }) 80 | case 'css': 81 | return new Promise((resolve, reject) => { 82 | const stats = new StyleStats(assetEndpoints) 83 | 84 | // returns a Promise 85 | // each promise performs an asynchronous StyleStats.parse operation 86 | stats.parse() 87 | .then(result => 88 | Object.keys(sensors['css']).map(metric => 89 | this.recorders[assetHash][metric](result[metric], () => 90 | resolve(log(`Recorded: ${assetHash} css ${assetEndpoints} ${metric} ${result[metric]}`)) 91 | ) 92 | ) 93 | ) 94 | .catch(reason => reject(reason)) 95 | }) 96 | } 97 | }) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/sensors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Telemetry sensors, by asset category 3 | * 4 | * Sensors have properties mapped to the HighCharts API: 5 | * http://api.highcharts.com/highcharts#plotOptions.areaspline 6 | * http://api.highcharts.com/highcharts#plotOptions.spline 7 | */ 8 | 9 | module.exports = { 10 | js: { 11 | size: { 12 | name: 'Size', 13 | yAxis: 0, 14 | type: 'areaspline', 15 | fillOpacity: 0.1 16 | }, 17 | gzippedSize: { 18 | name: 'Size (gzipped)', 19 | yAxis: 0, 20 | type: 'areaspline', 21 | fillOpacity: 0.1 22 | } 23 | }, 24 | css: { 25 | size: { 26 | name: 'Size', 27 | yAxis: 0, 28 | type: 'areaspline', 29 | fillOpacity: 0.1 30 | }, 31 | gzippedSize: { 32 | name: 'Size (gzipped)', 33 | yAxis: 0, 34 | type: 'areaspline', 35 | fillOpacity: 0.1 36 | }, 37 | dataUriSize: { 38 | name: 'Data URI Size', 39 | yAxis: 0, 40 | type: 'areaspline', 41 | fillOpacity: 0.1 42 | }, 43 | rules: { 44 | name: 'Rules', 45 | yAxis: 1, 46 | type: 'spline' 47 | }, 48 | selectors: { 49 | name: 'Selectors', 50 | yAxis: 1, 51 | type: 'spline' 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const md5 = require('md5') 2 | const path = require('path') 3 | const url = require('url') 4 | 5 | /** 6 | * @param {String} name name of the asset to hash 7 | * @returns {String} hashed name 8 | */ 9 | const getAssetHash = md5 10 | 11 | /** 12 | * @param {Object} assets 13 | * @returns {Object} asset object with their names hashed 14 | */ 15 | const hashAssets = assets => 16 | Object.assign( 17 | {}, 18 | ...Object.keys(assets).map(assetName => 19 | ({[getAssetHash(assetName)]: assets[assetName]}) 20 | )) 21 | 22 | /** 23 | * @param {String} p path to a file 24 | * @returns {String} file extension without the dot 25 | */ 26 | const getExtension = p => path.extname(url.parse(p).pathname).slice(1) 27 | 28 | /** 29 | * Get the type of an asset or array of assets 30 | * 31 | * @param {(String|Array)} assetPath path to an asset or array of paths 32 | * @returns String 33 | */ 34 | const getAssetType = assetPath => 35 | /* eslint no-useless-call: 0 */ 36 | // 1. transform assetPath into an array, 37 | // 2. flatten the array 38 | // 3. take its first element 39 | // 4. put it into an array 40 | [[].concat.apply([], [assetPath]).shift()] 41 | .map(getExtension) 42 | .shift() 43 | 44 | /** 45 | * @param {String} assetHash md5 hash of an asset 46 | * @param {Object} assets assets containing the asset 47 | * @returns {String} 48 | */ 49 | const getAssetTypeFromHash = (assetHash, assets) => 50 | getAssetType(hashAssets(assets)[assetHash]) 51 | 52 | module.exports = { 53 | getAssetHash: getAssetHash, 54 | hashAssets: hashAssets, 55 | getAssetType: getAssetType, 56 | getAssetTypeFromHash: getAssetTypeFromHash 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moniteur", 3 | "description": "Monitor your asset size over time, in your browser, or using the provided HTTP API.", 4 | "version": "0.8.2", 5 | "bin": { 6 | "moniteur": "./bin/moniteur" 7 | }, 8 | "engines": { 9 | "node": ">= 8.9.4", 10 | "npm": "latest" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/kaelig/moniteur.git" 15 | }, 16 | "keywords": [ 17 | "webperf", 18 | "performance" 19 | ], 20 | "author": "Kaelig Deloumeau-Prigent ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/kaelig/moniteur/issues" 24 | }, 25 | "homepage": "https://github.com/kaelig/moniteur", 26 | "devDependencies": { 27 | "browser-sync": "^2.23.5", 28 | "cz-conventional-changelog": "^2.1.0", 29 | "eslint": "^4.15.0", 30 | "eslint-config-standard": "^11.0.0", 31 | "eslint-plugin-import": "^2.8.0", 32 | "eslint-plugin-jasmine": "^2.9.1", 33 | "eslint-plugin-node": "^6.0.0", 34 | "eslint-plugin-promise": "^4.0.0", 35 | "eslint-plugin-standard": "^3.0.1", 36 | "jest-cli": "^22.0.6", 37 | "nodemon": "^1.14.11", 38 | "standard-version": "^4.3.0", 39 | "webpack-dev-middleware": "^2.0.4", 40 | "webpack-hot-middleware": "^2.21.0" 41 | }, 42 | "scripts": { 43 | "test": "jest", 44 | "prestart": "webpack -p", 45 | "start": "NODE_ENV=production ./bin/moniteur serve", 46 | "record": "./bin/moniteur record", 47 | "release": "standard-version", 48 | "dev": "DEBUG=moniteur:* nodemon -e js,pug --watch views --watch routes --watch lib --watch .moniteurrc.development.yml --watch .moniteurrc.default.yml --watch .moniteurrc.yml --watch webpack.config.js --watch bin/moniteur.js ./bin/moniteur serve", 49 | "lint": "eslint . --format codeframe --ext .js,.jsx" 50 | }, 51 | "dependencies": { 52 | "babel-core": "^6.26.0", 53 | "babel-loader": "^7.1.2", 54 | "babel-preset-es2015": "^6.24.1", 55 | "commander": "^2.13.0", 56 | "compression": "^1.7.1", 57 | "debug": "^3.1.0", 58 | "express": "^4.16.2", 59 | "express-slashes": "^0.1.1", 60 | "glob": "^7.1.2", 61 | "gzip-size": "^4.1.0", 62 | "highcharts": "^6.0.4", 63 | "highland": "^3.0.0-beta.2", 64 | "http-auth": "^3.2.3", 65 | "js-yaml": "^3.10.0", 66 | "lem": "1.0.0", 67 | "level": "^3.0.0", 68 | "leveldown": "^3.0.0", 69 | "md5": "^2.2.1", 70 | "nconf": "^0.10.0", 71 | "nconf-yaml": "^1.0.2", 72 | "pretty-bytes": "^4.0.2", 73 | "pug": "^2.0.0-rc.4", 74 | "redis": "^2.8.0", 75 | "redisdown": "^0.1.12", 76 | "request": "^2.83.0", 77 | "stylestats": "^7.0.1", 78 | "valid-url": "^1.0.9", 79 | "webpack": "^3.10.0", 80 | "yargs": "^10.1.1" 81 | }, 82 | "config": { 83 | "commitizen": { 84 | "path": "./node_modules/cz-conventional-changelog" 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const utils = require('../lib/utils') 3 | 4 | const router = express.Router() 5 | 6 | module.exports = router.get('/', (req, res) => { 7 | const assets = Object.keys(res.locals.assets).map(asset => 8 | ({ 9 | name: asset, 10 | hash: utils.getAssetHash(asset), 11 | type: utils.getAssetType(res.locals.assets[asset]) 12 | }) 13 | ) 14 | res.render('index', { title: 'moniteur', assets }) 15 | }) 16 | -------------------------------------------------------------------------------- /routes/metrics.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const debug = require('debug') 3 | const lem = require('lem') 4 | const sensors = require('../lib/sensors') 5 | const highland = require('highland') 6 | const utils = require('../lib/utils') 7 | 8 | const _ = highland 9 | const router = express.Router() 10 | const log = debug('moniteur:log') 11 | 12 | // Example: 13 | // Series (since forever): /metrics/adf6e9c154cb57a818f7fb407085bff6 14 | // Series between two dates: /metrics/adf6e9c154cb57a818f7fb407085bff6/1015711104475..1415711104475 15 | module.exports = router.get(/^\/(\w+)(\/(\d+)\.\.(\d+))?$/, (req, res) => { 16 | const assetHash = req.params[0] 17 | const assetType = utils.getAssetTypeFromHash(assetHash, res.locals.assets) 18 | const start = req.params[2] || (Date.now() - 3600 * 24 * 30 * 1000).toString() 19 | const end = req.params[3] || Date.now().toString() 20 | const db = lem(res.locals.db) 21 | 22 | // Get Array of values for metric 23 | _(Object.keys(sensors[assetType])).flatMap(metric => { 24 | const key = `assets.${assetHash}.${metric}` 25 | log('Opening ValueStream for:', key) 26 | return _(db.valuestream( 27 | key, 28 | { 29 | start: start, 30 | end: end 31 | } 32 | )) 33 | .filter(data => data.value !== 0) 34 | .map(data => 35 | [ 36 | data.key, 37 | data.value 38 | ] 39 | ) 40 | .collect() 41 | .map(data => 42 | Object.assign( 43 | {}, 44 | sensors[assetType][metric], 45 | { data })) 46 | }).toArray(data => res.json(data)) 47 | }) 48 | -------------------------------------------------------------------------------- /routes/settings.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const utils = require('../lib/utils') 3 | 4 | const router = express.Router() 5 | 6 | module.exports = router.get('/', (req, res) => { 7 | const assets = Object.keys(res.locals.assets).map(asset => { 8 | const resources = !Array.isArray(res.locals.assets[asset]) 9 | ? Array.of(res.locals.assets[asset]) 10 | : res.locals.assets[asset] 11 | 12 | return { 13 | name: asset, 14 | hash: utils.getAssetHash(asset), 15 | resources: resources 16 | } 17 | }) 18 | res.render('settings', { title: 'moniteur: current settings', assets }) 19 | }) 20 | -------------------------------------------------------------------------------- /views/_faq.pug: -------------------------------------------------------------------------------- 1 | h1 How do I… 2 | h2#faq-scheduling Schedule daily data recordings? 3 | p Moniteur checks assets frequently and generates graphs for you. 4 | img(src='/docs/welcome-process/welcome-process.001.png') 5 | img(src='/docs/welcome-process/welcome-process.002.png') 6 | img(src='/docs/welcome-process/welcome-process.003.png') 7 | img(src='/docs/welcome-process/welcome-process.004.png') 8 | img(src='/docs/welcome-process/welcome-process.005.png') 9 | 10 | h2#faq-assets See what assets are being monitored? 11 | p Here's a full list of the assets being monitored and the URLs that moniteur will check: 12 | p 13 | strong 14 | a(href='/settings') Current settings 15 | 16 | h2#faq-trigger Trigger a new recording? 17 | p Typically, moniteur records asset sizes on a daily basis, but you don't have to wait that long to see how big your CSS and JavaScript files got. 18 | p You can trigger a new recording at any time by executing: 19 | pre.code 20 | code.user-select heroku run record -a moniteur 21 | p Note: you'll need the #[a(href="https://toolbelt.heroku.com/") Heroku toolbelt] to execute this command. 22 | -------------------------------------------------------------------------------- /views/error.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | .l-content 5 | .l-container 6 | h1= message 7 | h2= error.status 8 | pre #{error.stack} 9 | -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | .assets 5 | each asset in assets 6 | - var hash = asset.hash 7 | - var name = asset.name 8 | - var type = asset.type 9 | .js-asset.asset.l-container(id='asset-'+ hash, data-asset-hash=hash, data-asset-type=type) 10 | h2.asset__name 11 | span.asset__type 12 | = name 13 | a.asset__linkto(href='#asset-' + hash) ¶ 14 | span(title='Evolution over time', class='trend js-trend js-trend-' + hash) 15 | span.js-trend-sign = 16 | span.js-trend-value 17 | .js-asset-chart.asset__chart(id='js-asset-chart-'+ hash) 18 | -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | head 4 | meta(charset='utf-8') 5 | meta(http-equiv='x-ua-compatible' content='ie=edge') 6 | title= title 7 | link(rel='stylesheet', href='/stylesheets/style.css') 8 | body 9 | header.l-header 10 | .l-container 11 | h1 12 | a(href='/') moniteur 13 | block content 14 | footer.l-footer 15 | .l-container 16 | a(href='http://git.io/moniteur') Fork on GitHub 17 | a(href='https://github.com/kaelig/moniteur/issues') Report an issue 18 | a(href='/support') FAQ 19 | a(href='/welcome') Welcome screen 20 | a(href='/settings') Current settings 21 | script(src='https://cdn.polyfill.io/v2/polyfill.min.js') 22 | script(src='https://code.highcharts.com/5/highcharts.js') 23 | script(src='/js/bundle.js') 24 | -------------------------------------------------------------------------------- /views/settings.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | .l-content 5 | .l-container 6 | h2 Assets 7 | p These #{assets.length} assets are currently being monitored: 8 | table.table 9 | thead 10 | tr 11 | th Asset 12 | th Resource(s) 13 | tbody 14 | each asset in assets 15 | tr 16 | th.table__assetname 17 | a(href='/#asset-' + asset.hash)=asset.name 18 | td.table__assetresources 19 | - var resources = asset.resources.map(r => `${r}`).join('
'); 20 | !=resources 21 | -------------------------------------------------------------------------------- /views/support.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | .l-content 5 | .l-container 6 | include ./_faq.pug 7 | -------------------------------------------------------------------------------- /views/welcome.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | .l-content 5 | .l-container 6 | p.welcome-message Welcome to moniteur 7 | p.align-center Here are a few steps you might want to follow… 8 | include ./_faq.pug 9 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | devtool: 'source-map', 5 | entry: { 6 | filename: './client/javascripts/app.js' 7 | }, 8 | output: { 9 | filename: 'bundle.js', 10 | path: path.resolve(__dirname, 'dist/js/'), 11 | publicPath: '/js/' 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.js$/, 17 | use: ['babel-loader'] 18 | } 19 | ] 20 | }, 21 | stats: { 22 | // Nice colored output 23 | colors: true 24 | } 25 | } 26 | --------------------------------------------------------------------------------