├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── app.js ├── bin └── www ├── bower.json ├── config ├── config.js ├── defaults.js └── env │ ├── dev.js │ ├── prod.js │ └── test.js ├── docs ├── AWS_SDK_CONFIGURATION.md ├── CONFIGURATION.md ├── ClientServerInteraction-SequenceDiagram.png ├── ClientServerInteraction-SequenceDiagram.puml ├── FetchStatus-StateDiagram.puml ├── FetchStatus-StateDiagram1.png ├── FetchStatus-StateDiagram2.png ├── SHORT_POLLING_FETCH_STATUS.md └── graph.png ├── package-lock.json ├── package.json ├── public ├── favicon.png ├── javascripts │ ├── graph.js │ ├── thirdparty │ │ ├── bootstrap-3.3.7.min.js │ │ ├── bootstrap.min.js │ │ ├── colorbrewer.js │ │ ├── d3-context-menu.js │ │ ├── d3.min.js │ │ ├── jquery.min.js │ │ ├── js.cookie.js │ │ └── spin.min.js │ └── types.js ├── stylesheets │ ├── app.css │ ├── fonts │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ └── thirdparty │ │ ├── bootstrap-3.3.7.min.css │ │ └── d3-context-menu.css └── test_data │ ├── ecs_instance_summaries_with_tasks-demo-cluster-100.json │ ├── ecs_instance_summaries_with_tasks-demo-cluster-50.json │ ├── ecs_instance_summaries_with_tasks-demo-cluster-75.json │ └── ecs_instance_summaries_with_tasks-demo-cluster-8.json ├── routes ├── clusterState.js ├── clusterStateCache.js ├── fetchStatus.js ├── fileUtils.js ├── index.js ├── promiseDelayer.js ├── staticClusterDataProvider.js └── utils.js ├── test ├── clusterStateCacheSpec.js ├── clusterStateSpec.js ├── configSpec.js └── promiseDelayerSpec.js └── views ├── error.ejs └── index.ejs /.gitignore: -------------------------------------------------------------------------------- 1 | aws_config.json 2 | .idea 3 | node_modules 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # https://hub.docker.com/r/library/node/ 2 | 3 | FROM node:9.11.1-alpine 4 | 5 | # >> FIX: 6 | # Fixes error Ubuntu: "gyp ERR! stack Error: Can't find Python executable "python", you can set the PYTHON env variable" 7 | # REF: https://gist.github.com/vidhill/0a85dc1848feee4171944dc4d7757895 8 | # REF: https://github.com/nodejs/node-gyp/issues/1105 9 | 10 | # build base includes g++ and gcc and Make 11 | RUN apk update && apk add python build-base 12 | 13 | # << END FIX 14 | 15 | # Bundle app source 16 | COPY . / 17 | RUN cd /; npm -d install 18 | 19 | EXPOSE 3000 20 | CMD ["npm", "start"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # c3vis - Cloud Container Cluster Visualizer 2 | 3 | Helps visualize the resource reservation of Amazon ECS clusters. 4 | 5 | Deploying software as “containers” promises to solve many problems with regards to interoperability of environments, speed to deploy, and cost reduction. 6 | But understanding where our software lives now becomes more difficult both for development and operations teams. 7 | This is due to the fact that it is quite laborious to find the information indicating where the software is now located and the quantity of resources still available for more software. 8 | Several ECS console screens must be viewed, and the amount of time required to process this information grows with the amount of software deployed. 9 |   10 | The Cloud Container Cluster Visualizer (c3vis) aims to give administrators and teams one place to gain rapid insight into the state of where the containers are running and the capacity available for more containers. 11 | 12 | ![alt tag](docs/graph.png) 13 | 14 | The visualization displays the EC2 instances in the selected cluster as vertical bars. The Tasks allocated to the instances are represented as stacked boxes indicating their reserved memory or CPU. 15 | Each unique Task Definition is represented as a different color, with the legend showing the Task Family name and revision number. 16 | Each Task will contain one or more containers, the task box shows accumulated reserved memory or CPU for all containers in the Task. ECS Services are not currently represented. 17 | 18 | 19 | ## Configuration 20 | 21 | See [CONFIGURATION](docs/CONFIGURATION.md) for details on server-side configurable options that affect cache entry TTL and AWS API call throttling. 22 | 23 | ## Configuring AWS SDK 24 | 25 | See [AWS_SDK_CONFIGURATION](docs/AWS_SDK_CONFIGURATION.md) for instructions 26 | on configuring the AWS SDK for server-side AWS connectivity. 27 | 28 | ## Requirements 29 | 30 | Node >= 0.12 31 | 32 | ## Building and Running 33 | 34 | The c3vis server is based on ExpressJS. The client predominantly uses D3.js, 35 | jQuery and Bootstrap. 36 | 37 | Run the following to build and run the server ("package.json" contains instructions to pre-install required node modules): 38 | 39 | ``` 40 | npm start 41 | ``` 42 | 43 | Now browse to the app at `http://localhost:3000`. 44 | 45 | ## Testing 46 | 47 | To run the server-side unit test suite with mocha and chai: 48 | 49 | ``` 50 | npm run test 51 | ``` 52 | 53 | ## Usage 54 | 55 | ### Approach 56 | 57 | When a client browser first connects to the c3vis server, the Cluster dropdown will be populated with ECS cluster names for the configured region. 58 | 59 | Select from the dropdown to view the resources allocated to that cluster. If no cluster names appear in the dropdown, check the server logs and ensure the correct region is configured (see below). 60 | 61 | The list of clusters and the user's current selection are stored in cookies. Use the ```[refresh list]``` dropdown entry to refresh the list of clusters. 62 | 63 | The Y axis shows total memory or CPU available for the instances. Memory is the default resource type represented. Use the "resourceType" query parameter to toggle between "memory" and "cpu". E.g. ```localhost:3000/?resourceType=cpu``` 64 | 65 | The X axis displays the Private IP Address for each EC2 instance. Right-clicking the IP address shows the context menu with links to browse the instance in the ECS and EC2 consoles. 66 | 67 | ### AWS API Call Throttling 68 | 69 | In order to prevent AWS API Rate limiting issues for large clusters, the server: 70 | 71 | * Introduces a delay between API calls (configurable via `aws.apiDelay` setting) 72 | * Limits the number of items retrieved per page in `list` and `describe` API calls (configurable via `aws.*PageSize`) 73 | * Limits the number of asynchronous API calls it makes at a time (configurable via `aws.maxSimultaneous*Calls`) 74 | 75 | You can increase or decrease each of these settings to suit each environment c3vis is deployed to. 76 | 77 | ### Short Polling, Server-Side Caching and Fetch Status 78 | 79 | For each cluster requested, the server caches cluster data in-memory while the client polls the server until the cache is populated. 80 | 81 | For an explanation on how the client polls the server for cluster data and the applicable fetch statuses, see [SHORT_POLLING_FETCH_STATUS](docs/SHORT_POLLING_FETCH_STATUS.md). 82 | 83 | 84 | ## Debugging 85 | 86 | ### Sample Clusters for Testing 87 | 88 | From the browser, use a ```"?static=true"``` query parameter to have the server return static test data. Useful for testing when server is unable to connect to AWS. 89 | 90 | Browse to `http://localhost:3000/?static=true`. 91 | 92 | ### Server Debug Logging 93 | 94 | To see all debug log entries: 95 | 96 | ``` 97 | DEBUG=* npm start 98 | ``` 99 | 100 | To see just API debug log entries: 101 | 102 | ``` 103 | DEBUG=api npm start 104 | ``` 105 | 106 | ## Running with Docker 107 | 108 | Build and tag the image: 109 | 110 | ``` 111 | docker build -t c3vis . 112 | ``` 113 | 114 | Run the container: (can remove ```AWS_ACCESS_KEY_ID``` and ```AWS_SECRET_ACCESS_KEY``` if deployed somewhere with appropriate IAM access) 115 | 116 | ``` 117 | docker run -e "AWS_REGION=" -e "AWS_ACCESS_KEY_ID=" -e "AWS_SECRET_ACCESS_KEY=" -p 3000:3000 c3vis 118 | ``` 119 | 120 | E.g. To run with `prod` target environment configuration: 121 | 122 | ``` 123 | docker run -e "TARGET_ENV=prod" -p 3000:3000 c3vis 124 | ``` 125 | 126 | 127 | Browse to [http://localhost:3000](http://localhost:3000) 128 | 129 | 130 | # Credits 131 | 132 | Created by [Matt Callanan](https://github.com/mattcallanan) with contributions from [Mark Malone](https://github.com/malonem) and with thanks to internal Expedia reviewers for their suggestions and advice. 133 | 134 | 135 | # Legal 136 | 137 | This project is available under the [Apache 2.0 License](http://www.apache.org/licenses/LICENSE-2.0.html). 138 | 139 | Copyright 2018 Expedia Inc. 140 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## UI 2 | * Menubar with: 3 | * Selectable Region 4 | * Selectable AWS account – requires ability for mutliple config files server side 5 | * Show clusters as tabbed view with one cluster per tab 6 | * Show an exploded view of task with more details when hovering over tasks: 7 | * Show containers within tasks 8 | * Show memory breakdown across containers 9 | * Sliding timebar to see historical data for comparison (like google street view) 10 | * Show container actual memory utilisation vs reserved memory utilisation 11 | * Provide access to more troubleshooting information (such as docker logs, ECS logs) 12 | * Add footer with fetched/expiry timestamp, #instances/services/tasks, Average CPU/Memory Reservation 13 | 14 | ## Server 15 | * Write a plugin system that lets adopters plugin their own statistics from favourite monitoring tool 16 | * Pluggable backend system that could support other public or private cloud providers 17 | * Return instances with FETCHED_INSTANCES FetchStatus to allow client to draw instances outline until tasks retrieved asynchronously 18 | * Arrow functions: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions 19 | 20 | 21 | 22 | ## Testing 23 | * Capture ECS JSON responses for testing and replay with mock AWS ECS server 24 | * https://fbflex.wordpress.com/2013/11/18/mocking-out-amazon-aws-sdk-with-the-betamax-recording-proxy-for-testing/ 25 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var path = require('path'); 3 | var favicon = require('serve-favicon'); 4 | var logger = require('morgan'); 5 | var cookieParser = require('cookie-parser'); 6 | var bodyParser = require('body-parser'); 7 | 8 | var routes = require('./routes/index'); 9 | 10 | var app = express(); 11 | 12 | // view engine setup 13 | app.set('views', path.join(__dirname, 'views')); 14 | app.set('view engine', 'ejs'); 15 | 16 | // uncomment after placing your favicon in /public 17 | //app.use(favicon(__dirname + '/public/favicon.ico')); 18 | app.use(logger('dev')); 19 | app.use(bodyParser.json()); 20 | app.use(bodyParser.urlencoded({ extended: false })); 21 | app.use(cookieParser()); 22 | app.use(express.static(path.join(__dirname, 'public'))); 23 | 24 | app.use('/', routes); 25 | 26 | app.use('/healthcheck', function (req, res) { 27 | res.send('ok'); 28 | }); 29 | 30 | // catch 404 and forward to error handler 31 | app.use(function(req, res, next) { 32 | var err = new Error('Not Found'); 33 | err.status = 404; 34 | next(err); 35 | }); 36 | 37 | // error handlers 38 | 39 | // development error handler 40 | // will print stacktrace 41 | if (app.get('env') === 'development') { 42 | app.use(function(err, req, res, next) { 43 | res.status(err.status || 500); 44 | res.render('error', { 45 | message: err.message, 46 | error: err 47 | }); 48 | }); 49 | } 50 | 51 | // production error handler 52 | // no stacktraces leaked to user 53 | app.use(function(err, req, res, next) { 54 | res.status(err.status || 500); 55 | res.render('error', { 56 | message: err.message, 57 | error: {} 58 | }); 59 | }); 60 | 61 | module.exports = app; 62 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('c3vis:server'); 9 | var http = require('http'); 10 | var config = require('../config/config'); 11 | 12 | /** 13 | * Get port from environment and store in Express. 14 | */ 15 | 16 | var port = normalizePort(config.port); 17 | app.set('port', port); 18 | 19 | /** 20 | * Create HTTP server. 21 | */ 22 | 23 | var server = http.createServer(app); 24 | 25 | /** 26 | * Listen on provided port, on all network interfaces. 27 | */ 28 | 29 | server.listen(port); 30 | server.on('error', onError); 31 | server.on('listening', onListening); 32 | 33 | /** 34 | * Normalize a port into a number, string, or false. 35 | */ 36 | 37 | function normalizePort(val) { 38 | var port = parseInt(val, 10); 39 | 40 | if (isNaN(port)) { 41 | // named pipe 42 | return val; 43 | } 44 | 45 | if (port >= 0) { 46 | // port number 47 | return port; 48 | } 49 | 50 | return false; 51 | } 52 | 53 | /** 54 | * Event listener for HTTP server "error" event. 55 | */ 56 | 57 | function onError(error) { 58 | if (error.syscall !== 'listen') { 59 | throw error; 60 | } 61 | 62 | var bind = typeof port === 'string' 63 | ? 'Pipe ' + port 64 | : 'Port ' + port; 65 | 66 | // handle specific listen errors with friendly messages 67 | switch (error.code) { 68 | case 'EACCES': 69 | console.error(bind + ' requires elevated privileges'); 70 | process.exit(1); 71 | break; 72 | case 'EADDRINUSE': 73 | console.error(bind + ' is already in use'); 74 | process.exit(1); 75 | break; 76 | default: 77 | throw error; 78 | } 79 | } 80 | 81 | /** 82 | * Event listener for HTTP server "listening" event. 83 | */ 84 | 85 | function onListening() { 86 | var addr = server.address(); 87 | var bind = typeof addr === 'string' 88 | ? 'pipe ' + addr 89 | : 'port ' + addr.port; 90 | debug('Listening on ' + bind); 91 | } 92 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "c3vis", 3 | "version": "0.1.0", 4 | "homepage": "https://github.com/ExpediaDotCom/c3vis", 5 | "authors": [ 6 | "Matt Callanan" 7 | ], 8 | "license": "Apache-2.0", 9 | "ignore": [ 10 | "**/.*", 11 | "node_modules", 12 | "bower_components", 13 | "test", 14 | "tests" 15 | ], 16 | "dependencies": { 17 | "d3": "~3.5.6", 18 | "jquery": "~1", 19 | "js-cookie": "~2.0.2", 20 | "spin.js": "~2.3.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | const lodash = require('lodash'); 2 | const TARGET_ENV = process.env.TARGET_ENV || 'dev'; 3 | 4 | function _loadDefaultConfig() { 5 | return require('./defaults.js'); 6 | } 7 | 8 | function _loadOverrideConfig(targetEnvironment) { 9 | try { 10 | // Extend configuration with environment-specific configuration 11 | console.debug(`Overriding default configuration with '${targetEnvironment}' environment configuration from ${_overrideConfigFilename(targetEnvironment)} (TARGET_ENV=${process.env.TARGET_ENV}, NODE_ENV=${process.env.NODE_ENV})`); 12 | return require(_overrideConfigFilename(targetEnvironment)); 13 | } catch (err) { 14 | console.error(`ERROR: Could not load configuration file for target environment '${targetEnvironment}'. Skipping. (${err})`); 15 | return {} 16 | } 17 | } 18 | 19 | function _overrideConfigFilename(targetEnvironment) { 20 | return `./env/${targetEnvironment}.js`; 21 | } 22 | 23 | module.exports = lodash.merge(_loadDefaultConfig(), _loadOverrideConfig(TARGET_ENV)); 24 | -------------------------------------------------------------------------------- /config/defaults.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | environmentName: undefined, 3 | port: process.env.PORT || 3000, 4 | clusterStateCacheTtl: 30 * 60 * 1000, // Invalidate clusters in cache after 30 minutes 5 | aws: { 6 | configFile: './aws_config.json', 7 | apiDelay: 100, // milliseconds to pause between AWS API calls to prevent API rate limiting 8 | listInstancesPageSize: 100, // max 100 9 | describeInstancesPageSize: 100, // max 100 10 | listTasksPageSize: 100, // max 100 11 | describeTasksPageSize: 100, // max 100 12 | maxSimultaneousDescribeTasksCalls: 2, 13 | maxSimultaneousDescribeTaskDefinitionCalls: 1, 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /config/env/dev.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Add dev environment config overrides here and enable at startup with TARGET_ENV=dev environment variable 3 | environmentName: "Development" 4 | }; 5 | -------------------------------------------------------------------------------- /config/env/prod.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Add prod environment config overrides here and enable at startup with TARGET_ENV=prod environment variable 3 | environmentName: "Production" 4 | }; 5 | -------------------------------------------------------------------------------- /config/env/test.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Add test environment config overrides here and enable at startup with TARGET_ENV=test environment variable 3 | environmentName: "Test" 4 | }; 5 | -------------------------------------------------------------------------------- /docs/AWS_SDK_CONFIGURATION.md: -------------------------------------------------------------------------------- 1 | # Configuring AWS SDK 2 | 3 | The c3vis server uses the AWS JavaScript SDK to connect to AWS APIs. 4 | 5 | As per [Configuring the SDK for JavaScript](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/configuring-the-jssdk.html), the AWS JavaScript SDK will get its configuration from the server's environment. 6 | 7 | ## Provide Explicit AWS SDK Configuration with `aws_config.json` Configuration File 8 | 9 | AWS SDK configuration can be overridden by providing an `aws_config.json` file (this file location is overridable with `aws.configFile` option, see [CONFIGURATION.md](CONFIGURATION.md)). 10 | 11 | E.g. to set the region used by c3vis server to `us-east-1`, create an `aws_config.json` file in the root directory with the following: 12 | 13 | ``` 14 | { 15 | "region": "us-east-1" 16 | } 17 | ``` 18 | 19 | The contents of this file override all other sources of AWS SDK configuration. 20 | The settings are applied to the AWS Global Configuration Object using `AWS.config.update()` as per [Using the Global Configuration Object](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/global-config-object.html) 21 | 22 | ## AWS Region 23 | 24 | As per above section, AWS Region can be provided in local `aws_config.json` file. 25 | 26 | Otherwise the Region will be configured as per [Setting the AWS Region](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-region.html). 27 | 28 | ## AWS Credentials 29 | 30 | If using `aws_config.json` file as per above section, you can add AWS credentials properties `accessKeyId` and `secretAccessKey` to the `aws_config.json` 31 | See [https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-json-file.html](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-json-file.html). 32 | 33 | *NOTE: Storing credentials in plaintext file is not recommended, especially if there is a risk this file could be committed to version control.* 34 | 35 | Otherwise, the credentials will be loaded as per priority listed [here](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-credentials-node.html). 36 | 37 | ## IAM Role Permissions 38 | 39 | ### EC2 IAM Role Permissions 40 | 41 | When running c3vis on EC2 instances using an IAM role, ensure the role has the 42 | following permissions: 43 | 44 | * `ecs:listContainerInstances` 45 | * `ecs:describeContainerInstances` 46 | * `ecs:listTasks` 47 | * `ecs:describeTasks` 48 | * `ecs:describeTaskDefinition` 49 | * `ecs:listClusters` 50 | * `ec2:describeInstances` 51 | 52 | Sample IAM Inline Policy: 53 | ``` 54 | { 55 | "Version": "2012-10-17", 56 | "Statement": [ 57 | { 58 | "Effect": "Allow", 59 | "Action": [ 60 | "ecs:listContainerInstances", 61 | "ecs:describeContainerInstances", 62 | "ecs:listTasks", 63 | "ecs:describeTasks", 64 | "ecs:describeTaskDefinition", 65 | "ecs:listClusters", 66 | "ec2:describeInstances" 67 | ], 68 | "Resource": [ 69 | "*" 70 | ] 71 | } 72 | ] 73 | } 74 | ``` 75 | 76 | ### ECS IAM Task Role 77 | 78 | When running c3vis on an ECS cluster, you can use an ECS Task IAM Role, which 79 | can be created using the process documented [here](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html#create_task_iam_policy_and_role). 80 | Ensure the IAM Policy has the permissions listed above. 81 | 82 | ## Security Warning 83 | 84 | **WARNING:** c3vis makes ECS data from the above API calls (including environment variables in task definitions) available to clients/browsers. 85 | Ensure the c3vis server is available only to users that should have access to this information. 86 | -------------------------------------------------------------------------------- /docs/CONFIGURATION.md: -------------------------------------------------------------------------------- 1 | # c3vis Configuration 2 | 3 | Server-side settings are configurable via configuration files. Default settings for all environments can be found in [config/defaults.js](config/defaults.js). 4 | 5 | ## Environment Overrides 6 | 7 | Different environments may require different settings (e.g. rate at which you 8 | want to make AWS API calls may be different on a laptop vs production 9 | environment). 10 | Settings can be overridden per environment by adding entries to a config file 11 | with a name matching the `TARGET_ENV` environment variable. 12 | E.g. if `TARGET_ENV` = `test`, the `config/env/test.js` file overrides will be 13 | applied and override the settings in `config/defaults.js`. 14 | 15 | Note: `TARGET_ENV` is kept distinct from `NODE_ENV` as the recommended Express 16 | JS setting for `NODE_ENV` is `production` when running outside a development 17 | context (see: https://expressjs.com/en/advanced/best-practice-performance.html#set-node_env-to-production) 18 | 19 | Blank files are provided for the following configuration files: 20 | 21 | `TARGET_ENV`|Configuration File 22 | ------------|------------------ 23 | `undefined` |[config/env/dev.js](config/env/dev.js) (`dev` environment is assumed if `TARGET_ENV` not set) 24 | `"dev"` |[config/env/dev.js](config/env/dev.js) 25 | `"test"` |[config/env/test.js](config/env/test.js) 26 | `"prod"` |[config/env/prod.js](config/env/prod.js) 27 | 28 | ## Configuration Options 29 | 30 | The following configuration options are available: 31 | 32 | Config Key|Description|Default 33 | --------- |-----------|------- 34 | `port`|Server port to listen on|`3000` 35 | `clusterStateCacheTtl`|Expiry time (in milliseconds) per cluster data entry in cluster state cache|`1800000` (30 mins) 36 | `aws.configFile`|Configuration file containing AWS SDK configuration|`./aws_config.json` 37 | `aws.apiDelay`|Number of milliseconds to pause between AWS API calls to prevent API rate limiting|`100` 38 | `aws.listInstancesPageSize`|Number of Instances to retrieve per `listInstances` ECS API call (max `100`)|`100` 39 | `aws.describeInstancesPageSize`|Number of Instances to retrieve per `describeInstances` ECS API call (max `100`)|`100` 40 | `aws.listTasksPageSize`|Number of Tasks to retrieve per `listTasks` ECS API call (max `100`)|`100` 41 | `aws.describeTasksPageSize`|Number of Tasks to retrieve per `describeTasks` ECS API call (max `100`)|`100` 42 | `aws.maxSimultaneousDescribeTasksCalls`|Number of `describeTasks` ECS API calls to make in quick succession before waiting for results|`2` 43 | `aws.maxSimultaneousDescribeTaskDefinitionCalls`|Number of `describeTaskDefinitions` ECS API calls to make in quick succession before waiting for results|`1` 44 | 45 | ## AWS SDK Configuration 46 | 47 | See [AWS_SDK_CONFIGURATION](AWS_SDK_CONFIGURATION.md) 48 | -------------------------------------------------------------------------------- /docs/ClientServerInteraction-SequenceDiagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExpediaGroup/c3vis/fb1ea18e78557de0616a8b55a629368024c5539e/docs/ClientServerInteraction-SequenceDiagram.png -------------------------------------------------------------------------------- /docs/ClientServerInteraction-SequenceDiagram.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | skinparam sequenceArrowThickness 2 3 | skinparam roundcorner 20 4 | skinparam maxmessagesize 60 5 | skinparam sequenceParticipant underline 6 | 7 | actor User 8 | box "Client" #LightGreen 9 | participant "Browser" as A 10 | end box 11 | box "Node.js Server" #LightBlue 12 | participant "Request" as B 13 | participant "Background Processing" as C 14 | participant "ClusterStateCache" as D 15 | end box 16 | 17 | activate D 18 | 19 | User -> A: Select Cluster 20 | activate A 21 | 22 | A -> B: "/api/instance_summaries_with_tasks" 23 | activate B 24 | 25 | B -> C: Get ClusterState 26 | activate C 27 | C -> D: Add ClusterState with FetchStatus:INITIAL 28 | activate D 29 | C --> B: FetchStatus:INITIAL 30 | 31 | B --> A: FetchStatus:INITIAL 32 | deactivate B 33 | 34 | C -> D: FetchStatus:FETCHING 35 | 36 | A -> B: "/api/instance_summaries_with_tasks" 37 | activate B 38 | B -> C: Get ClusterState 39 | C --> B: FetchStatus:FETCHING 40 | B --> A: FetchStatus:FETCHING 41 | deactivate B 42 | 43 | C -> D: FetchStatus:FETCHED 44 | 45 | A -> B: "/api/instance_summaries_with_tasks" 46 | activate B 47 | B -> C: Get ClusterState 48 | C --> B: FetchStatus:FETCHED 49 | B --> A: FetchStatus:FETCHED InstanceSummaries:[...] 50 | deactivate B 51 | 52 | D --> D : Expire after 30mins 53 | deactivate D 54 | 55 | A --> User: Render Graph 56 | deactivate A 57 | 58 | deactivate C 59 | deactivate D 60 | 61 | @enduml 62 | -------------------------------------------------------------------------------- /docs/FetchStatus-StateDiagram.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | '[*] --> null 4 | [*] --> INITIAL 5 | INITIAL --> FETCHING 6 | FETCHING --> FETCHED 7 | 'INITIAL --> null : Expired 8 | 'FETCHING --> null : Expired 9 | 'FETCHED --> null : Expired 10 | 'ERROR --> null : Expired 11 | INITIAL --> ERROR : Failed 12 | FETCHING --> ERROR : Failed 13 | FETCHED --> ERROR : Failed 14 | 15 | 'FETCHED --> [*] : Success 16 | 'ERROR --> [*] : Error 17 | 18 | @enduml 19 | 20 | @startuml 21 | 22 | [*] --> null 23 | null --> INITIAL 24 | INITIAL --> FETCHING 25 | FETCHING --> FETCHED 26 | INITIAL --> null : Expired 27 | FETCHING --> null : Expired 28 | FETCHED --> null : Expired 29 | ERROR --> null : Expired 30 | INITIAL --> ERROR 31 | FETCHING --> ERROR 32 | FETCHED --> ERROR 33 | 34 | 'FETCHED --> [*] : Success 35 | 'ERROR --> [*] : Error 36 | 37 | @enduml 38 | 39 | -------------------------------------------------------------------------------- /docs/FetchStatus-StateDiagram1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExpediaGroup/c3vis/fb1ea18e78557de0616a8b55a629368024c5539e/docs/FetchStatus-StateDiagram1.png -------------------------------------------------------------------------------- /docs/FetchStatus-StateDiagram2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExpediaGroup/c3vis/fb1ea18e78557de0616a8b55a629368024c5539e/docs/FetchStatus-StateDiagram2.png -------------------------------------------------------------------------------- /docs/SHORT_POLLING_FETCH_STATUS.md: -------------------------------------------------------------------------------- 1 | # c3vis Request Handling & Caching 2 | 3 | ## Short Polling for `instance_summaries_with_tasks` API 4 | 5 | The following diagram depicts the interaction between the client and server 6 | when retrieving cluster data. 7 | 8 | When a client first asks the server for instances and tasks for a cluster (or 9 | on the first request after cluster state has expired), the server will: 10 | 1. Create a `ClusterState` object with `FetchStatus` = `INITIAL` 11 | 2. Store it in the in-memory `ClusterStateCache` 12 | 3. Return `INITIAL` to client 13 | 4. Proceed to process the request in the background ("Background Processing" swimlane) 14 | 5. Background: Populate the cached `ClusterState` object with cluster data 15 | 6. Background: Update the `ClusterState` object's `FetchStatus` 16 | 7. Return current state to client 17 | 18 | Client will poll the server until it finds `INITIAL` or `ERROR` state. 19 | 20 | ![alt tag](ClientServerInteraction-SequenceDiagram.png) 21 | 22 | ### Force Refresh 23 | 24 | If the user clicks "Refresh Server Cache", the client will send a 25 | `forceRefresh=true` parameter on its first polling attempt. The server upon 26 | seeing `forceRefresh=true` will invalidate the cached `ClusterState` entry 27 | and begin the above process again. 28 | 29 | ## Fetch Status State Diagram 30 | 31 | Fetch Status State|Description 32 | ------------------|----------- 33 | INITIAL|State object created 34 | FETCHING|Server has begun fetching cluster data asynchronously 35 | FETCHED|Server has completed fetching cluster data 36 | ERROR|Server encountered an error fetching cluster data 37 | 38 | ![alt tag](FetchStatus-StateDiagram1.png) 39 | 40 | ## Cluster State Cache Expiry and Fetch Status 41 | 42 | When a ClusterState object is added to the in-memory cache, 43 | it is set to expire within `clusterStateCacheTtl` milliseconds. 44 | The default being 30mins. After that time, the cache object is 45 | set to `null` and won't be set to `INITIAL` until necessitated by 46 | a subsequent client request. 47 | 48 | ![alt tag](FetchStatus-StateDiagram2.png) 49 | 50 | -------------------------------------------------------------------------------- /docs/graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExpediaGroup/c3vis/fb1ea18e78557de0616a8b55a629368024c5539e/docs/graph.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "c3vis", 3 | "version": "0.3.2", 4 | "private": true, 5 | "scripts": { 6 | "prestart": "npm install", 7 | "start": "node ./bin/www", 8 | "test": "mocha test/" 9 | }, 10 | "dependencies": { 11 | "aws-sdk": "2.814.0", 12 | "aws-sdk-promise": "0.0.2", 13 | "batch-promises": "0.0.3", 14 | "body-parser": "1.18.3", 15 | "cookie-parser": "1.3.5", 16 | "debug": "3.1.0", 17 | "ejs": "2.5.5", 18 | "express": "4.16.3", 19 | "lodash": "4.17.21", 20 | "memory-cache": "0.2.0", 21 | "moment": "2.29.2", 22 | "morgan": "1.9.1", 23 | "serve-favicon": "2.5.0", 24 | "sleep": "3.0.1" 25 | }, 26 | "devDependencies": { 27 | "chai": "4.1.2", 28 | "mocha": "5.1.1", 29 | "nodemon": "1.17.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExpediaGroup/c3vis/fb1ea18e78557de0616a8b55a629368024c5539e/public/favicon.png -------------------------------------------------------------------------------- /public/javascripts/graph.js: -------------------------------------------------------------------------------- 1 | /* See http://bl.ocks.org/mbostock/3886208 for D3 stacked bar chart documentation */ 2 | 3 | function taskFamilyAndRevision(t) { 4 | return t.taskDefinitionArn.substring(t.taskDefinitionArn.lastIndexOf('/') + 1) 5 | } 6 | 7 | // ECS API returns memory as MBs and CPU as CPU Units 8 | // For memory we want to convert from MBs (e.g. 4096 MBs) to bytes (4096000) to show correct units on Y axis 9 | function translateResourceAmountForYAxis(resourceAmount, resourceType) { 10 | if (resourceType == ResourceEnum.MEMORY) { 11 | return resourceAmount * 1000000; 12 | } else if (resourceType == ResourceEnum.CPU) { 13 | return resourceAmount; 14 | } else { 15 | throw "Unknown resource type: " + resourceType; 16 | } 17 | } 18 | 19 | function showInfo(graph, message) { 20 | console.log(message); 21 | showMessage(graph, message, 'black'); 22 | } 23 | 24 | function showError(graph, message) { 25 | console.error(message || "Error"); 26 | showMessage(graph, message, 'red'); 27 | } 28 | 29 | function showMessage(graph, message, color) { 30 | if (graph !== null) { 31 | graph.append("text").attr("x", 0).attr("y", 20).attr("fill", color).text(message || "Error"); 32 | } 33 | } 34 | 35 | function handleError(errorMsg, graph, onError) { 36 | showError(graph, errorMsg); 37 | onError(errorMsg); 38 | } 39 | 40 | function ecsInstanceConsoleUrl(data, ec2IpAddress) { 41 | const instance = data.find(function (element, index, array) { 42 | return element.ec2IpAddress == ec2IpAddress; 43 | }); 44 | return instance != null ? instance.ecsInstanceConsoleUrl : null; 45 | } 46 | 47 | function ec2InstanceConsoleUrl(data, ec2IpAddress) { 48 | const instance = data.find(function (element, index, array) { 49 | return element.ec2IpAddress == ec2IpAddress; 50 | }); 51 | return instance != null ? instance.ec2InstanceConsoleUrl : null; 52 | } 53 | 54 | function ec2InstanceId(data, ec2IpAddress) { 55 | const instance = data.find(function (element, index, array) { 56 | return element.ec2IpAddress == ec2IpAddress; 57 | }); 58 | return instance != null ? instance.ec2InstanceId : null; 59 | } 60 | 61 | function copyToClipboard(text) { 62 | let copyElement = document.createElement('input'); 63 | copyElement.setAttribute('type', 'text'); 64 | copyElement.setAttribute('value', text); 65 | copyElement = document.body.appendChild(copyElement); 66 | copyElement.select(); 67 | try { 68 | if (!document.execCommand('copy')) { 69 | throw "document.execCommand('copy') is not supported or enabled" 70 | } 71 | } catch (e) { 72 | console.log("document.execCommand('copy'); is not supported"); 73 | window.prompt('Copy this to clipboard (Ctrl+C or Cmd+C):', text); 74 | } finally { 75 | copyElement.remove(); 76 | } 77 | } 78 | 79 | function addD3DataToTask(task, resourceType, y0) { 80 | const resourceAllocation = task.taskDefinition.containerDefinitions.reduce(function (sum, b) { 81 | return sum + (resourceType == ResourceEnum.MEMORY ? b.memory : b.cpu); 82 | }, 0); 83 | const y1 = y0 + resourceAllocation; 84 | task.d3Data = { 85 | name: taskFamilyAndRevision(task), 86 | resourceAllocation: resourceAllocation, // sum of all containers' resource (memory/cpu) allocation 87 | y0: y0, 88 | y1: y1 89 | }; 90 | return y1; 91 | } 92 | 93 | function registeredResource(d, resourceType) { 94 | if (resourceType == ResourceEnum.MEMORY) { 95 | return d.registeredMemory; 96 | } else if (resourceType == ResourceEnum.CPU) { 97 | return d.registeredCpu; 98 | } else { 99 | throw "Unknown resource type: " + resourceType; 100 | } 101 | } 102 | 103 | function remainingResource(d, resourceType) { 104 | if (resourceType == ResourceEnum.MEMORY) { 105 | return d.remainingMemory; 106 | } else if (resourceType == ResourceEnum.CPU) { 107 | return d.remainingCpu; 108 | } else { 109 | throw "Unknown resource type: " + resourceType; 110 | } 111 | } 112 | 113 | function recreateMainGraphElement(chartDivId, graphWidth, leftMargin, rightMargin, totalHeight, topMargin, bottomMargin) { 114 | d3.select('#' + chartDivId).select("svg").remove(); 115 | return d3.select('#' + chartDivId) 116 | .append("svg") 117 | .attr("class", "cluster-graph") 118 | .attr("id", "cluster-graph") 119 | .attr("width", graphWidth + leftMargin + rightMargin) 120 | .attr("height", totalHeight + topMargin + bottomMargin) 121 | .attr("float", "left") 122 | .append("g").attr("transform", "translate(" + leftMargin + "," + topMargin + ")"); 123 | } 124 | 125 | const GRAPH_TOP_MARGIN = 20; 126 | const GRAPH_BOTTOM_MARGIN = 100; 127 | const RIGHT_MARGIN = 50; 128 | const LEFT_MARGIN = 50; 129 | const GRAPH_HEIGHT = 520 - GRAPH_TOP_MARGIN - GRAPH_BOTTOM_MARGIN; 130 | const TOTAL_HEIGHT = 400; 131 | const DEFAULT_GRAPH_WIDTH = 1000; 132 | const EXPANDED_GRAPH_WIDTH = 1300; 133 | 134 | function renderErrorGraph(chartDivId, errorMsg, onError) { 135 | const graph = recreateMainGraphElement(chartDivId, DEFAULT_GRAPH_WIDTH, LEFT_MARGIN, RIGHT_MARGIN, TOTAL_HEIGHT, GRAPH_TOP_MARGIN, GRAPH_BOTTOM_MARGIN); 136 | handleError(errorMsg, graph, onError); 137 | return graph; 138 | } 139 | 140 | function errorResponseText(apiResponseError) { 141 | const errorMsg = apiResponseError instanceof XMLHttpRequest 142 | ? apiResponseError.responseText 143 | : JSON.stringify(apiResponseError); 144 | return `Server Error: ${errorMsg}`; 145 | } 146 | 147 | function renderGraph(timestampDivId, chartDivId, legendDivId, cluster, resourceTypeText, onCompletion, onError) { 148 | if (window.apiResponseError) { 149 | const apiResponseError = window.apiResponseError; 150 | const errorMsg = `Server Error: ${errorResponseText(apiResponseError)}`; 151 | return renderErrorGraph(chartDivId, errorMsg, onError); 152 | } else if (window.apiResponseData == null || window.apiResponseData.instanceSummaries === null) { 153 | const errorMsg = "Response from server contains no data."; 154 | return renderErrorGraph(chartDivId, errorMsg, onError); 155 | } 156 | 157 | const showTaskBreakdown = true; // TODO: Parameterise 158 | 159 | const instanceSummaries = window.apiResponseData.instanceSummaries; 160 | const createTimestamp = window.apiResponseData.createTimestamp; 161 | const localizedClusterCacheTimestamp = new Date(Date.parse(createTimestamp)); 162 | 163 | try { 164 | const resourceType = parseResourceType(resourceTypeText, ResourceEnum.MEMORY); 165 | const graphWidth = window.apiResponseError ? DEFAULT_GRAPH_WIDTH : (instanceSummaries.length > 50 ? EXPANDED_GRAPH_WIDTH : DEFAULT_GRAPH_WIDTH) - LEFT_MARGIN - RIGHT_MARGIN; //establishes width based on data set size 166 | const colorRange = d3.scale.ordinal().range(colorbrewer.Pastel1[9].concat(colorbrewer.Pastel2[8]).concat(colorbrewer.Set1[9]).concat(colorbrewer.Set2[8]).concat(colorbrewer.Set3[12])); 167 | const xRange = d3.scale.ordinal().rangeRoundBands([10, graphWidth], .1); 168 | const yRange = d3.scale.linear().rangeRound([GRAPH_HEIGHT, 0]); 169 | const xAxis = d3.svg.axis().scale(xRange).orient("bottom"); 170 | const yAxis = d3.svg.axis().scale(yRange).orient("left").tickFormat(d3.format(".2s")); 171 | 172 | // Main graph area 173 | const graph = recreateMainGraphElement(chartDivId, graphWidth, LEFT_MARGIN, RIGHT_MARGIN, TOTAL_HEIGHT, GRAPH_TOP_MARGIN, GRAPH_BOTTOM_MARGIN); 174 | 175 | if (instanceSummaries.length == 0) { 176 | showInfo(graph, "No instances are registered for the '" + cluster + "' cluster."); 177 | onCompletion(); 178 | return graph; 179 | } 180 | 181 | // TODO: Move this to footer 182 | graph.append("g") 183 | .append("text") 184 | .attr("x", 30) 185 | .attr("y", -5) 186 | .text(localizedClusterCacheTimestamp ? `Fetched: ${localizedClusterCacheTimestamp}` : "No fetch timestamp available"); 187 | 188 | let uniqueTaskDefs = instanceSummaries.reduce(function (acc, current) { 189 | return acc.concat(current.tasks.map(function (t) { 190 | return taskFamilyAndRevision(t); 191 | })) 192 | }, []); 193 | uniqueTaskDefs = uniqueTaskDefs.filter(function (elem, pos) { 194 | return uniqueTaskDefs.indexOf(elem) == pos; 195 | }); 196 | uniqueTaskDefs.sort(); 197 | 198 | colorRange.domain(uniqueTaskDefs); 199 | 200 | instanceSummaries.forEach(function (instance) { 201 | // Add d3Data to each task for later display 202 | let y0 = 0; 203 | instance.tasks.forEach(function (task) { 204 | y0 = addD3DataToTask(task, resourceType, y0); 205 | }); 206 | }); 207 | 208 | // Set X axis ordinal domain range to be list of server names 209 | xRange.domain(instanceSummaries.map(function (d) { 210 | return d.ec2IpAddress; 211 | })); 212 | 213 | // Calculate maximum resource (memory/cpu) across all servers 214 | const maxResource = d3.max(instanceSummaries, function (d) { 215 | return registeredResource(d, resourceType); 216 | }); 217 | // Set Y axis linear domain range from 0 to maximum memory/cpu in bytes 218 | yRange.domain([0, translateResourceAmountForYAxis(maxResource, resourceType)]); 219 | 220 | // Draw X axis 221 | const xAxisLabels = graph.append("g") 222 | .attr("class", "graph-axis") 223 | .attr("transform", "translate(0," + GRAPH_HEIGHT + ")") 224 | .call(xAxis); 225 | 226 | const menu = [ 227 | { 228 | title: 'Copy IP Address', 229 | action: function (elm, d, i) { 230 | copyToClipboard(d); 231 | } 232 | }, 233 | { 234 | title: 'Open ECS Container Instance Console', 235 | action: function (elm, d, i) { 236 | window.open(ecsInstanceConsoleUrl(instanceSummaries, d), '_blank'); 237 | } 238 | }, 239 | { 240 | title: 'Open EC2 Instance Console', 241 | action: function (elm, d, i) { 242 | window.open(ec2InstanceConsoleUrl(instanceSummaries, d), '_blank'); 243 | } 244 | } 245 | ]; 246 | 247 | xAxisLabels.selectAll("text") 248 | .attr("cursor", "pointer") 249 | .on('contextmenu', d3.contextMenu(menu)) 250 | // X axis tooltip 251 | .append("svg:title") 252 | .text(function (d) { 253 | return "Right-click for options"; 254 | }); 255 | 256 | // Rotate X axis labels 90 degrees if bar is wide enough to cause overlapping 257 | if (xRange.rangeBand() < 80) { 258 | xAxisLabels.selectAll("text") 259 | .attr("y", 0) 260 | .attr("x", 9) 261 | .attr("dy", ".35em") 262 | .attr("transform", "rotate(90)") 263 | .style("text-anchor", "start"); 264 | } 265 | 266 | // Make the font smaller if bar is wide enough to cause overlapping 267 | if (xRange.rangeBand() < 14) { 268 | xAxisLabels.selectAll("text") 269 | .attr("class", "graph-axis-small") 270 | } 271 | 272 | // Draw Y axis 273 | graph.append("g") 274 | .attr("class", "graph-axis") 275 | .call(yAxis) 276 | .append("text") 277 | .attr("transform", "rotate(-90)") 278 | .attr("y", 6) 279 | .attr("dy", ".71em") 280 | .style("text-anchor", "end") 281 | .text(resourceLabel(resourceType)); 282 | 283 | // TODO: Request task data in parallel with instance data. Draw instance outline first then draw task boxes 284 | // Create svg elements for each server 285 | const instance = graph.selectAll(".instance") 286 | .data(instanceSummaries) 287 | .enter().append("g") 288 | .attr("class", "g") 289 | .attr("transform", function (d) { 290 | return "translate(" + xRange(d.ec2IpAddress) + ",0)"; 291 | }); 292 | 293 | // For each server, draw entire resource (memory/cpu) available as grey rect 294 | instance.append("rect") 295 | .attr("class", "instance-block") 296 | .attr("width", xRange.rangeBand()) 297 | .attr("y", function (d) { 298 | return yRange(translateResourceAmountForYAxis(registeredResource(d, resourceType), resourceType)) 299 | }) 300 | .attr("height", function (d) { 301 | return yRange(translateResourceAmountForYAxis(maxResource - (registeredResource(d, resourceType)), resourceType)); 302 | }); 303 | 304 | if (showTaskBreakdown) { 305 | // For each task on each server, represent resource (memory/cpu) allocation as a rect 306 | instance.selectAll(".task") 307 | .data(function (d) { 308 | return d.tasks; 309 | }) 310 | .enter().append("rect") 311 | .attr("class", "task-block") 312 | .attr("width", xRange.rangeBand()) 313 | .attr("y", function (d) { 314 | return yRange(translateResourceAmountForYAxis(d.d3Data.y1, resourceType)); 315 | }) 316 | .attr("height", function (d) { 317 | return yRange(translateResourceAmountForYAxis(d.d3Data.y0, resourceType)) - yRange(translateResourceAmountForYAxis(d.d3Data.y1, resourceType)); 318 | }) 319 | .style("fill", function (d) { 320 | return colorRange(d.d3Data.name); 321 | }) 322 | // Use name as hover tooltip 323 | .append("svg:title") 324 | .text(function (d) { 325 | return d.d3Data.name + " (" + resourceLabel(resourceType) + ": " + d.d3Data.resourceAllocation + ")"; 326 | }); 327 | 328 | // Draw legend 329 | 330 | const taskData = uniqueTaskDefs.sort(); 331 | const longestLength = taskData.reduce(function (a, b) { 332 | return a.length > b.length ? a : b; 333 | }, []).length; 334 | 335 | // TODO: Add hover highlight of related blocks 336 | d3.select('#' + legendDivId).select("svg").remove(); 337 | const svg2 = d3.select('#' + legendDivId) 338 | .append("svg") 339 | .attr("class", "cluster-legend") 340 | .attr("id", "cluster-legend") 341 | .attr("width", (longestLength * 10) + 20) 342 | .attr("height", (20 * taskData.length) + 20); 343 | 344 | const legend = svg2.append("g") 345 | .attr("class", "legend"); 346 | 347 | legend.selectAll('rect') 348 | .data(taskData) 349 | .enter() 350 | .append("rect") 351 | .attr("x", 1) 352 | .attr("y", function (d, i) { 353 | return ((i * 20) + GRAPH_TOP_MARGIN); 354 | }) 355 | .attr("width", 18) 356 | .attr("height", 18) 357 | .style("fill", function (d) { 358 | return colorRange(d); 359 | }) 360 | .style("stroke-width", 0.5) 361 | .style("stroke", "rgb(51, 51, 51)"); 362 | 363 | legend.selectAll('text') 364 | .data(taskData) 365 | .enter() 366 | .append("text") 367 | .attr("x", 25) 368 | .attr("width", 5) 369 | .attr("height", 5) 370 | .attr("y", function (d, i) { 371 | return ((i * 20) + GRAPH_TOP_MARGIN + 12); 372 | }) 373 | .text(function (d) { 374 | return d; 375 | }); 376 | 377 | } else { 378 | // For each each server, represent total cpu allocation as a single orange rect 379 | instance.append("rect") 380 | .attr("width", xRange.rangeBand()) 381 | .attr("y", function (d) { 382 | const usedCpu = translateResourceAmountForYAxis(registeredResource(d, resourceType), resourceType) - translateResourceAmountForYAxis(remainingResource(d, resourceType), resourceType); 383 | return yRange(usedCpu) 384 | }) 385 | .attr("height", function (d) { 386 | return yRange(translateResourceAmountForYAxis(remainingResource(d, resourceType), resourceType)) - yRange(translateResourceAmountForYAxis(registeredResource(d, resourceType), resourceType)); 387 | }) 388 | .style("fill", "orange") 389 | .style("stroke", "grey"); 390 | } 391 | 392 | return graph; 393 | 394 | } catch (e) { 395 | handleError(e.stack ? e.stack : e, null, onError); 396 | } finally { 397 | onCompletion(); 398 | } 399 | } 400 | 401 | function calculateInterval(attemptIndex, defaultInterval) { 402 | // For first 4 attempts, take shorter but progressively longer intervals. 403 | // E.g. if defaultInterval = 5000 then take 1s,2s,3s,4s for first 4 attempts respectively 404 | return attemptIndex < 5 ? attemptIndex * (defaultInterval / 5) : defaultInterval; 405 | } 406 | 407 | function pollUntilFetched(c3visApiUrl, forceRefresh, attemptIndex, onFetched, onError) { 408 | const interval = 5000; 409 | const maxAttempts = 120; 410 | 411 | if (attemptIndex >= maxAttempts) { 412 | const errorMsg = `Could not successfully retrieve cluster details from '${c3visApiUrl}' after ${maxAttempts} polling attempts.`; 413 | onError(errorMsg); 414 | return; 415 | } 416 | 417 | const optionalForceRefreshParam = (forceRefresh ? "&forceRefresh=true" : ""); 418 | const updatedC3visApiUrl = c3visApiUrl + optionalForceRefreshParam; 419 | 420 | console.log(`Polling '${updatedC3visApiUrl}' until found in 'fetched' state. Attempt #${attemptIndex}/${maxAttempts}`); 421 | 422 | // TODO: Upgrade to D3 v5, convert to use promises 423 | 424 | d3.json(updatedC3visApiUrl, function (apiResponseError, apiResponseData) { 425 | // TODO: Display multiple graphs if server returns > 100 instances 426 | if (apiResponseError != null) { 427 | window.apiResponseError = apiResponseError; 428 | console.debug(` window.apiResponseError set to '${window.apiResponseError}'`); 429 | onError(errorResponseText(apiResponseError)); 430 | } 431 | if (apiResponseData != null) { 432 | window.apiResponseData = apiResponseData; 433 | console.debug(` window.apiResponseData contains response data for cluster '${apiResponseData.clusterName}'.`); 434 | if (apiResponseData.errorDetails != null) { 435 | onError(`Server Error: ${apiResponseData.errorDetails}`); 436 | } else { 437 | console.debug(` Found '${apiResponseData.fetchStatus}' status in response from '${c3visApiUrl}'`); 438 | if (apiResponseData.fetchStatus === 'fetched') { 439 | onFetched(); 440 | } else { 441 | console.debug(` Not yet fetched, trying again after ${calculateInterval(attemptIndex, interval)}ms`); 442 | setTimeout(function () { 443 | // Use forceRefresh=false to ensure server is instructed to refresh only once at start of polling loop 444 | pollUntilFetched(c3visApiUrl, false, attemptIndex + 1, onFetched, onError) 445 | }, calculateInterval(attemptIndex, interval)); 446 | } 447 | } 448 | } 449 | }); 450 | } 451 | 452 | function populateGraph(useStaticData, forceRefresh, timestampDivId, chartDivId, legendDivId, cluster, resourceTypeText, onCompletion, onError) { 453 | try { 454 | if (!cluster && !useStaticData) { 455 | handleError("Please select a cluster.", null, onError); 456 | return; 457 | } 458 | 459 | const clusterParam = "cluster=" + (cluster ? cluster : "default"); 460 | const optionalStaticParam = (useStaticData ? "&static=true" : ""); 461 | const c3visApiUrl = "/api/instance_summaries_with_tasks?" + clusterParam + optionalStaticParam; 462 | 463 | window.fetchStatus = ''; 464 | console.debug(`Requesting '${c3visApiUrl}'...`); 465 | // TODO: Timeout after 10mins 466 | pollUntilFetched(c3visApiUrl, forceRefresh, 1, function() { 467 | renderGraph(timestampDivId, chartDivId, legendDivId, cluster, resourceTypeText, onCompletion, onError); 468 | }, function(e) { 469 | renderErrorGraph(chartDivId, `${e.message || JSON.stringify(e)}`, onError); 470 | }); 471 | } catch (e) { 472 | console.error(e.stack); 473 | renderErrorGraph(chartDivId, `ERROR. Uncaught Exception: ${e}`, onError); 474 | } 475 | } 476 | -------------------------------------------------------------------------------- /public/javascripts/thirdparty/bootstrap-3.3.7.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.7 (http://getbootstrap.com) 3 | * Copyright 2011-2016 Twitter, Inc. 4 | * Licensed under the MIT license 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1||b[0]>3)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){if(a(b.target).is(this))return b.handleObj.handler.apply(this,arguments)}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.7",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a("#"===f?[]:f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.7",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c).prop(c,!0)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c).prop(c,!1))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")?(c.prop("checked")&&(a=!1),b.find(".active").removeClass("active"),this.$element.addClass("active")):"checkbox"==c.prop("type")&&(c.prop("checked")!==this.$element.hasClass("active")&&(a=!1),this.$element.toggleClass("active")),c.prop("checked",this.$element.hasClass("active")),a&&c.trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active")),this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target).closest(".btn");b.call(d,"toggle"),a(c.target).is('input[type="radio"], input[type="checkbox"]')||(c.preventDefault(),d.is("input,button")?d.trigger("focus"):d.find("input:visible,button:visible").first().trigger("focus"))}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=null,this.sliding=null,this.interval=null,this.$active=null,this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.7",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));if(!(a>this.$items.length-1||a<0))return this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){if(!this.sliding)return this.slide("next")},c.prototype.prev=function(){if(!this.sliding)return this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&/show|hide/.test(b)&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a('[data-toggle="collapse"][href="#'+b.id+'"],[data-toggle="collapse"][data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.7",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":e.data();c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function c(c){c&&3===c.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=b(d),f={relatedTarget:this};e.hasClass("open")&&(c&&"click"==c.type&&/input|textarea/i.test(c.target.tagName)&&a.contains(e[0],c.target)||(e.trigger(c=a.Event("hide.bs.dropdown",f)),c.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger(a.Event("hidden.bs.dropdown",f)))))}))}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.7",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=b(e),g=f.hasClass("open");if(c(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(document.createElement("div")).addClass("dropdown-backdrop").insertAfter(a(this)).on("click",c);var h={relatedTarget:this};if(f.trigger(d=a.Event("show.bs.dropdown",h)),d.isDefaultPrevented())return;e.trigger("focus").attr("aria-expanded","true"),f.toggleClass("open").trigger(a.Event("shown.bs.dropdown",h))}return!1}},g.prototype.keydown=function(c){if(/(38|40|27|32)/.test(c.which)&&!/input|textarea/i.test(c.target.tagName)){var d=a(this);if(c.preventDefault(),c.stopPropagation(),!d.is(".disabled, :disabled")){var e=b(d),g=e.hasClass("open");if(!g&&27!=c.which||g&&27==c.which)return 27==c.which&&e.find(f).trigger("focus"),d.trigger("click");var h=" li:not(.disabled):visible a",i=e.find(".dropdown-menu"+h);if(i.length){var j=i.index(c.target);38==c.which&&j>0&&j--,40==c.which&&jdocument.documentElement.clientHeight;this.$element.css({paddingLeft:!this.bodyIsOverflowing&&a?this.scrollbarWidth:"",paddingRight:this.bodyIsOverflowing&&!a?this.scrollbarWidth:""})},c.prototype.resetAdjustments=function(){this.$element.css({paddingLeft:"",paddingRight:""})},c.prototype.checkScrollbar=function(){var a=window.innerWidth;if(!a){var b=document.documentElement.getBoundingClientRect();a=b.right-Math.abs(b.left)}this.bodyIsOverflowing=document.body.clientWidth
',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(a.isFunction(this.options.viewport)?this.options.viewport.call(this,this.$element):this.options.viewport.selector||this.options.viewport),this.inState={click:!1,hover:!1,focus:!1},this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusin"==b.type?"focus":"hover"]=!0),c.tip().hasClass("in")||"in"==c.hoverState?void(c.hoverState="in"):(clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.isInStateTrue=function(){for(var a in this.inState)if(this.inState[a])return!0;return!1},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);if(c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusout"==b.type?"focus":"hover"]=!1),!c.isInStateTrue())return clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide()},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element),this.$element.trigger("inserted.bs."+this.type);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.getPosition(this.$viewport);h="bottom"==h&&k.bottom+m>o.bottom?"top":"top"==h&&k.top-mo.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.right&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){if(!this.$tip&&(this.$tip=a(this.options.template),1!=this.$tip.length))throw new Error(this.type+" `template` option must consist of exactly 1 top-level element!");return this.$tip},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),b?(c.inState.click=!c.inState.click,c.isInStateTrue()?c.enter(c):c.leave(c)):c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type),a.$tip&&a.$tip.detach(),a.$tip=null,a.$arrow=null,a.$viewport=null,a.$element=null})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;!e&&/destroy|hide/.test(b)||(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.7",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.7",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.7",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return e=a-d&&"bottom"},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); -------------------------------------------------------------------------------- /public/javascripts/thirdparty/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.5 (http://getbootstrap.com) 3 | * Copyright 2011-2015 Twitter, Inc. 4 | * Licensed under the MIT license 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){return a(b.target).is(this)?b.handleObj.handler.apply(this,arguments):void 0}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.5",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a(f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.5",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")?(c.prop("checked")&&(a=!1),b.find(".active").removeClass("active"),this.$element.addClass("active")):"checkbox"==c.prop("type")&&(c.prop("checked")!==this.$element.hasClass("active")&&(a=!1),this.$element.toggleClass("active")),c.prop("checked",this.$element.hasClass("active")),a&&c.trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active")),this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target);d.hasClass("btn")||(d=d.closest(".btn")),b.call(d,"toggle"),a(c.target).is('input[type="radio"]')||a(c.target).is('input[type="checkbox"]')||c.preventDefault()}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=null,this.sliding=null,this.interval=null,this.$active=null,this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.5",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));return a>this.$items.length-1||0>a?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){return this.sliding?void 0:this.slide("next")},c.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&/show|hide/.test(b)&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a('[data-toggle="collapse"][href="#'+b.id+'"],[data-toggle="collapse"][data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.5",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":e.data();c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function c(c){c&&3===c.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=b(d),f={relatedTarget:this};e.hasClass("open")&&(c&&"click"==c.type&&/input|textarea/i.test(c.target.tagName)&&a.contains(e[0],c.target)||(e.trigger(c=a.Event("hide.bs.dropdown",f)),c.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger("hidden.bs.dropdown",f))))}))}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.5",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=b(e),g=f.hasClass("open");if(c(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(document.createElement("div")).addClass("dropdown-backdrop").insertAfter(a(this)).on("click",c);var h={relatedTarget:this};if(f.trigger(d=a.Event("show.bs.dropdown",h)),d.isDefaultPrevented())return;e.trigger("focus").attr("aria-expanded","true"),f.toggleClass("open").trigger("shown.bs.dropdown",h)}return!1}},g.prototype.keydown=function(c){if(/(38|40|27|32)/.test(c.which)&&!/input|textarea/i.test(c.target.tagName)){var d=a(this);if(c.preventDefault(),c.stopPropagation(),!d.is(".disabled, :disabled")){var e=b(d),g=e.hasClass("open");if(!g&&27!=c.which||g&&27==c.which)return 27==c.which&&e.find(f).trigger("focus"),d.trigger("click");var h=" li:not(.disabled):visible a",i=e.find(".dropdown-menu"+h);if(i.length){var j=i.index(c.target);38==c.which&&j>0&&j--,40==c.which&&jdocument.documentElement.clientHeight;this.$element.css({paddingLeft:!this.bodyIsOverflowing&&a?this.scrollbarWidth:"",paddingRight:this.bodyIsOverflowing&&!a?this.scrollbarWidth:""})},c.prototype.resetAdjustments=function(){this.$element.css({paddingLeft:"",paddingRight:""})},c.prototype.checkScrollbar=function(){var a=window.innerWidth;if(!a){var b=document.documentElement.getBoundingClientRect();a=b.right-Math.abs(b.left)}this.bodyIsOverflowing=document.body.clientWidth
',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(a.isFunction(this.options.viewport)?this.options.viewport.call(this,this.$element):this.options.viewport.selector||this.options.viewport),this.inState={click:!1,hover:!1,focus:!1},this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusin"==b.type?"focus":"hover"]=!0),c.tip().hasClass("in")||"in"==c.hoverState?void(c.hoverState="in"):(clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.isInStateTrue=function(){for(var a in this.inState)if(this.inState[a])return!0;return!1},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusout"==b.type?"focus":"hover"]=!1),c.isInStateTrue()?void 0:(clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide())},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element),this.$element.trigger("inserted.bs."+this.type);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.getPosition(this.$viewport);h="bottom"==h&&k.bottom+m>o.bottom?"top":"top"==h&&k.top-mo.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.right&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){if(!this.$tip&&(this.$tip=a(this.options.template),1!=this.$tip.length))throw new Error(this.type+" `template` option must consist of exactly 1 top-level element!");return this.$tip},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),b?(c.inState.click=!c.inState.click,c.isInStateTrue()?c.enter(c):c.leave(c)):c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type),a.$tip&&a.$tip.detach(),a.$tip=null,a.$arrow=null,a.$viewport=null})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;(e||!/destroy|hide/.test(b))&&(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.5",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.5",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.5",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return c>e?"top":!1;if("bottom"==this.affixed)return null!=c?e+this.unpin<=f.top?!1:"bottom":a-d>=e+g?!1:"bottom";var h=null==this.affixed,i=h?e:f.top,j=h?g:b;return null!=c&&c>=e?"top":null!=d&&i+j>=a-d?"bottom":!1},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); -------------------------------------------------------------------------------- /public/javascripts/thirdparty/colorbrewer.js: -------------------------------------------------------------------------------- 1 | // This product includes color specifications and designs developed by Cynthia Brewer (http://colorbrewer.org/). 2 | var colorbrewer = {YlGn: { 3 | 3: ["#f7fcb9","#addd8e","#31a354"], 4 | 4: ["#ffffcc","#c2e699","#78c679","#238443"], 5 | 5: ["#ffffcc","#c2e699","#78c679","#31a354","#006837"], 6 | 6: ["#ffffcc","#d9f0a3","#addd8e","#78c679","#31a354","#006837"], 7 | 7: ["#ffffcc","#d9f0a3","#addd8e","#78c679","#41ab5d","#238443","#005a32"], 8 | 8: ["#ffffe5","#f7fcb9","#d9f0a3","#addd8e","#78c679","#41ab5d","#238443","#005a32"], 9 | 9: ["#ffffe5","#f7fcb9","#d9f0a3","#addd8e","#78c679","#41ab5d","#238443","#006837","#004529"] 10 | },YlGnBu: { 11 | 3: ["#edf8b1","#7fcdbb","#2c7fb8"], 12 | 4: ["#ffffcc","#a1dab4","#41b6c4","#225ea8"], 13 | 5: ["#ffffcc","#a1dab4","#41b6c4","#2c7fb8","#253494"], 14 | 6: ["#ffffcc","#c7e9b4","#7fcdbb","#41b6c4","#2c7fb8","#253494"], 15 | 7: ["#ffffcc","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#0c2c84"], 16 | 8: ["#ffffd9","#edf8b1","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#0c2c84"], 17 | 9: ["#ffffd9","#edf8b1","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#253494","#081d58"] 18 | },GnBu: { 19 | 3: ["#e0f3db","#a8ddb5","#43a2ca"], 20 | 4: ["#f0f9e8","#bae4bc","#7bccc4","#2b8cbe"], 21 | 5: ["#f0f9e8","#bae4bc","#7bccc4","#43a2ca","#0868ac"], 22 | 6: ["#f0f9e8","#ccebc5","#a8ddb5","#7bccc4","#43a2ca","#0868ac"], 23 | 7: ["#f0f9e8","#ccebc5","#a8ddb5","#7bccc4","#4eb3d3","#2b8cbe","#08589e"], 24 | 8: ["#f7fcf0","#e0f3db","#ccebc5","#a8ddb5","#7bccc4","#4eb3d3","#2b8cbe","#08589e"], 25 | 9: ["#f7fcf0","#e0f3db","#ccebc5","#a8ddb5","#7bccc4","#4eb3d3","#2b8cbe","#0868ac","#084081"] 26 | },BuGn: { 27 | 3: ["#e5f5f9","#99d8c9","#2ca25f"], 28 | 4: ["#edf8fb","#b2e2e2","#66c2a4","#238b45"], 29 | 5: ["#edf8fb","#b2e2e2","#66c2a4","#2ca25f","#006d2c"], 30 | 6: ["#edf8fb","#ccece6","#99d8c9","#66c2a4","#2ca25f","#006d2c"], 31 | 7: ["#edf8fb","#ccece6","#99d8c9","#66c2a4","#41ae76","#238b45","#005824"], 32 | 8: ["#f7fcfd","#e5f5f9","#ccece6","#99d8c9","#66c2a4","#41ae76","#238b45","#005824"], 33 | 9: ["#f7fcfd","#e5f5f9","#ccece6","#99d8c9","#66c2a4","#41ae76","#238b45","#006d2c","#00441b"] 34 | },PuBuGn: { 35 | 3: ["#ece2f0","#a6bddb","#1c9099"], 36 | 4: ["#f6eff7","#bdc9e1","#67a9cf","#02818a"], 37 | 5: ["#f6eff7","#bdc9e1","#67a9cf","#1c9099","#016c59"], 38 | 6: ["#f6eff7","#d0d1e6","#a6bddb","#67a9cf","#1c9099","#016c59"], 39 | 7: ["#f6eff7","#d0d1e6","#a6bddb","#67a9cf","#3690c0","#02818a","#016450"], 40 | 8: ["#fff7fb","#ece2f0","#d0d1e6","#a6bddb","#67a9cf","#3690c0","#02818a","#016450"], 41 | 9: ["#fff7fb","#ece2f0","#d0d1e6","#a6bddb","#67a9cf","#3690c0","#02818a","#016c59","#014636"] 42 | },PuBu: { 43 | 3: ["#ece7f2","#a6bddb","#2b8cbe"], 44 | 4: ["#f1eef6","#bdc9e1","#74a9cf","#0570b0"], 45 | 5: ["#f1eef6","#bdc9e1","#74a9cf","#2b8cbe","#045a8d"], 46 | 6: ["#f1eef6","#d0d1e6","#a6bddb","#74a9cf","#2b8cbe","#045a8d"], 47 | 7: ["#f1eef6","#d0d1e6","#a6bddb","#74a9cf","#3690c0","#0570b0","#034e7b"], 48 | 8: ["#fff7fb","#ece7f2","#d0d1e6","#a6bddb","#74a9cf","#3690c0","#0570b0","#034e7b"], 49 | 9: ["#fff7fb","#ece7f2","#d0d1e6","#a6bddb","#74a9cf","#3690c0","#0570b0","#045a8d","#023858"] 50 | },BuPu: { 51 | 3: ["#e0ecf4","#9ebcda","#8856a7"], 52 | 4: ["#edf8fb","#b3cde3","#8c96c6","#88419d"], 53 | 5: ["#edf8fb","#b3cde3","#8c96c6","#8856a7","#810f7c"], 54 | 6: ["#edf8fb","#bfd3e6","#9ebcda","#8c96c6","#8856a7","#810f7c"], 55 | 7: ["#edf8fb","#bfd3e6","#9ebcda","#8c96c6","#8c6bb1","#88419d","#6e016b"], 56 | 8: ["#f7fcfd","#e0ecf4","#bfd3e6","#9ebcda","#8c96c6","#8c6bb1","#88419d","#6e016b"], 57 | 9: ["#f7fcfd","#e0ecf4","#bfd3e6","#9ebcda","#8c96c6","#8c6bb1","#88419d","#810f7c","#4d004b"] 58 | },RdPu: { 59 | 3: ["#fde0dd","#fa9fb5","#c51b8a"], 60 | 4: ["#feebe2","#fbb4b9","#f768a1","#ae017e"], 61 | 5: ["#feebe2","#fbb4b9","#f768a1","#c51b8a","#7a0177"], 62 | 6: ["#feebe2","#fcc5c0","#fa9fb5","#f768a1","#c51b8a","#7a0177"], 63 | 7: ["#feebe2","#fcc5c0","#fa9fb5","#f768a1","#dd3497","#ae017e","#7a0177"], 64 | 8: ["#fff7f3","#fde0dd","#fcc5c0","#fa9fb5","#f768a1","#dd3497","#ae017e","#7a0177"], 65 | 9: ["#fff7f3","#fde0dd","#fcc5c0","#fa9fb5","#f768a1","#dd3497","#ae017e","#7a0177","#49006a"] 66 | },PuRd: { 67 | 3: ["#e7e1ef","#c994c7","#dd1c77"], 68 | 4: ["#f1eef6","#d7b5d8","#df65b0","#ce1256"], 69 | 5: ["#f1eef6","#d7b5d8","#df65b0","#dd1c77","#980043"], 70 | 6: ["#f1eef6","#d4b9da","#c994c7","#df65b0","#dd1c77","#980043"], 71 | 7: ["#f1eef6","#d4b9da","#c994c7","#df65b0","#e7298a","#ce1256","#91003f"], 72 | 8: ["#f7f4f9","#e7e1ef","#d4b9da","#c994c7","#df65b0","#e7298a","#ce1256","#91003f"], 73 | 9: ["#f7f4f9","#e7e1ef","#d4b9da","#c994c7","#df65b0","#e7298a","#ce1256","#980043","#67001f"] 74 | },OrRd: { 75 | 3: ["#fee8c8","#fdbb84","#e34a33"], 76 | 4: ["#fef0d9","#fdcc8a","#fc8d59","#d7301f"], 77 | 5: ["#fef0d9","#fdcc8a","#fc8d59","#e34a33","#b30000"], 78 | 6: ["#fef0d9","#fdd49e","#fdbb84","#fc8d59","#e34a33","#b30000"], 79 | 7: ["#fef0d9","#fdd49e","#fdbb84","#fc8d59","#ef6548","#d7301f","#990000"], 80 | 8: ["#fff7ec","#fee8c8","#fdd49e","#fdbb84","#fc8d59","#ef6548","#d7301f","#990000"], 81 | 9: ["#fff7ec","#fee8c8","#fdd49e","#fdbb84","#fc8d59","#ef6548","#d7301f","#b30000","#7f0000"] 82 | },YlOrRd: { 83 | 3: ["#ffeda0","#feb24c","#f03b20"], 84 | 4: ["#ffffb2","#fecc5c","#fd8d3c","#e31a1c"], 85 | 5: ["#ffffb2","#fecc5c","#fd8d3c","#f03b20","#bd0026"], 86 | 6: ["#ffffb2","#fed976","#feb24c","#fd8d3c","#f03b20","#bd0026"], 87 | 7: ["#ffffb2","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#b10026"], 88 | 8: ["#ffffcc","#ffeda0","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#b10026"], 89 | 9: ["#ffffcc","#ffeda0","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#bd0026","#800026"] 90 | },YlOrBr: { 91 | 3: ["#fff7bc","#fec44f","#d95f0e"], 92 | 4: ["#ffffd4","#fed98e","#fe9929","#cc4c02"], 93 | 5: ["#ffffd4","#fed98e","#fe9929","#d95f0e","#993404"], 94 | 6: ["#ffffd4","#fee391","#fec44f","#fe9929","#d95f0e","#993404"], 95 | 7: ["#ffffd4","#fee391","#fec44f","#fe9929","#ec7014","#cc4c02","#8c2d04"], 96 | 8: ["#ffffe5","#fff7bc","#fee391","#fec44f","#fe9929","#ec7014","#cc4c02","#8c2d04"], 97 | 9: ["#ffffe5","#fff7bc","#fee391","#fec44f","#fe9929","#ec7014","#cc4c02","#993404","#662506"] 98 | },Purples: { 99 | 3: ["#efedf5","#bcbddc","#756bb1"], 100 | 4: ["#f2f0f7","#cbc9e2","#9e9ac8","#6a51a3"], 101 | 5: ["#f2f0f7","#cbc9e2","#9e9ac8","#756bb1","#54278f"], 102 | 6: ["#f2f0f7","#dadaeb","#bcbddc","#9e9ac8","#756bb1","#54278f"], 103 | 7: ["#f2f0f7","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#4a1486"], 104 | 8: ["#fcfbfd","#efedf5","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#4a1486"], 105 | 9: ["#fcfbfd","#efedf5","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#54278f","#3f007d"] 106 | },Blues: { 107 | 3: ["#deebf7","#9ecae1","#3182bd"], 108 | 4: ["#eff3ff","#bdd7e7","#6baed6","#2171b5"], 109 | 5: ["#eff3ff","#bdd7e7","#6baed6","#3182bd","#08519c"], 110 | 6: ["#eff3ff","#c6dbef","#9ecae1","#6baed6","#3182bd","#08519c"], 111 | 7: ["#eff3ff","#c6dbef","#9ecae1","#6baed6","#4292c6","#2171b5","#084594"], 112 | 8: ["#f7fbff","#deebf7","#c6dbef","#9ecae1","#6baed6","#4292c6","#2171b5","#084594"], 113 | 9: ["#f7fbff","#deebf7","#c6dbef","#9ecae1","#6baed6","#4292c6","#2171b5","#08519c","#08306b"] 114 | },Greens: { 115 | 3: ["#e5f5e0","#a1d99b","#31a354"], 116 | 4: ["#edf8e9","#bae4b3","#74c476","#238b45"], 117 | 5: ["#edf8e9","#bae4b3","#74c476","#31a354","#006d2c"], 118 | 6: ["#edf8e9","#c7e9c0","#a1d99b","#74c476","#31a354","#006d2c"], 119 | 7: ["#edf8e9","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#005a32"], 120 | 8: ["#f7fcf5","#e5f5e0","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#005a32"], 121 | 9: ["#f7fcf5","#e5f5e0","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#006d2c","#00441b"] 122 | },Oranges: { 123 | 3: ["#fee6ce","#fdae6b","#e6550d"], 124 | 4: ["#feedde","#fdbe85","#fd8d3c","#d94701"], 125 | 5: ["#feedde","#fdbe85","#fd8d3c","#e6550d","#a63603"], 126 | 6: ["#feedde","#fdd0a2","#fdae6b","#fd8d3c","#e6550d","#a63603"], 127 | 7: ["#feedde","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#8c2d04"], 128 | 8: ["#fff5eb","#fee6ce","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#8c2d04"], 129 | 9: ["#fff5eb","#fee6ce","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#a63603","#7f2704"] 130 | },Reds: { 131 | 3: ["#fee0d2","#fc9272","#de2d26"], 132 | 4: ["#fee5d9","#fcae91","#fb6a4a","#cb181d"], 133 | 5: ["#fee5d9","#fcae91","#fb6a4a","#de2d26","#a50f15"], 134 | 6: ["#fee5d9","#fcbba1","#fc9272","#fb6a4a","#de2d26","#a50f15"], 135 | 7: ["#fee5d9","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#99000d"], 136 | 8: ["#fff5f0","#fee0d2","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#99000d"], 137 | 9: ["#fff5f0","#fee0d2","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#a50f15","#67000d"] 138 | },Greys: { 139 | 3: ["#f0f0f0","#bdbdbd","#636363"], 140 | 4: ["#f7f7f7","#cccccc","#969696","#525252"], 141 | 5: ["#f7f7f7","#cccccc","#969696","#636363","#252525"], 142 | 6: ["#f7f7f7","#d9d9d9","#bdbdbd","#969696","#636363","#252525"], 143 | 7: ["#f7f7f7","#d9d9d9","#bdbdbd","#969696","#737373","#525252","#252525"], 144 | 8: ["#ffffff","#f0f0f0","#d9d9d9","#bdbdbd","#969696","#737373","#525252","#252525"], 145 | 9: ["#ffffff","#f0f0f0","#d9d9d9","#bdbdbd","#969696","#737373","#525252","#252525","#000000"] 146 | },PuOr: { 147 | 3: ["#f1a340","#f7f7f7","#998ec3"], 148 | 4: ["#e66101","#fdb863","#b2abd2","#5e3c99"], 149 | 5: ["#e66101","#fdb863","#f7f7f7","#b2abd2","#5e3c99"], 150 | 6: ["#b35806","#f1a340","#fee0b6","#d8daeb","#998ec3","#542788"], 151 | 7: ["#b35806","#f1a340","#fee0b6","#f7f7f7","#d8daeb","#998ec3","#542788"], 152 | 8: ["#b35806","#e08214","#fdb863","#fee0b6","#d8daeb","#b2abd2","#8073ac","#542788"], 153 | 9: ["#b35806","#e08214","#fdb863","#fee0b6","#f7f7f7","#d8daeb","#b2abd2","#8073ac","#542788"], 154 | 10: ["#7f3b08","#b35806","#e08214","#fdb863","#fee0b6","#d8daeb","#b2abd2","#8073ac","#542788","#2d004b"], 155 | 11: ["#7f3b08","#b35806","#e08214","#fdb863","#fee0b6","#f7f7f7","#d8daeb","#b2abd2","#8073ac","#542788","#2d004b"] 156 | },BrBG: { 157 | 3: ["#d8b365","#f5f5f5","#5ab4ac"], 158 | 4: ["#a6611a","#dfc27d","#80cdc1","#018571"], 159 | 5: ["#a6611a","#dfc27d","#f5f5f5","#80cdc1","#018571"], 160 | 6: ["#8c510a","#d8b365","#f6e8c3","#c7eae5","#5ab4ac","#01665e"], 161 | 7: ["#8c510a","#d8b365","#f6e8c3","#f5f5f5","#c7eae5","#5ab4ac","#01665e"], 162 | 8: ["#8c510a","#bf812d","#dfc27d","#f6e8c3","#c7eae5","#80cdc1","#35978f","#01665e"], 163 | 9: ["#8c510a","#bf812d","#dfc27d","#f6e8c3","#f5f5f5","#c7eae5","#80cdc1","#35978f","#01665e"], 164 | 10: ["#543005","#8c510a","#bf812d","#dfc27d","#f6e8c3","#c7eae5","#80cdc1","#35978f","#01665e","#003c30"], 165 | 11: ["#543005","#8c510a","#bf812d","#dfc27d","#f6e8c3","#f5f5f5","#c7eae5","#80cdc1","#35978f","#01665e","#003c30"] 166 | },PRGn: { 167 | 3: ["#af8dc3","#f7f7f7","#7fbf7b"], 168 | 4: ["#7b3294","#c2a5cf","#a6dba0","#008837"], 169 | 5: ["#7b3294","#c2a5cf","#f7f7f7","#a6dba0","#008837"], 170 | 6: ["#762a83","#af8dc3","#e7d4e8","#d9f0d3","#7fbf7b","#1b7837"], 171 | 7: ["#762a83","#af8dc3","#e7d4e8","#f7f7f7","#d9f0d3","#7fbf7b","#1b7837"], 172 | 8: ["#762a83","#9970ab","#c2a5cf","#e7d4e8","#d9f0d3","#a6dba0","#5aae61","#1b7837"], 173 | 9: ["#762a83","#9970ab","#c2a5cf","#e7d4e8","#f7f7f7","#d9f0d3","#a6dba0","#5aae61","#1b7837"], 174 | 10: ["#40004b","#762a83","#9970ab","#c2a5cf","#e7d4e8","#d9f0d3","#a6dba0","#5aae61","#1b7837","#00441b"], 175 | 11: ["#40004b","#762a83","#9970ab","#c2a5cf","#e7d4e8","#f7f7f7","#d9f0d3","#a6dba0","#5aae61","#1b7837","#00441b"] 176 | },PiYG: { 177 | 3: ["#e9a3c9","#f7f7f7","#a1d76a"], 178 | 4: ["#d01c8b","#f1b6da","#b8e186","#4dac26"], 179 | 5: ["#d01c8b","#f1b6da","#f7f7f7","#b8e186","#4dac26"], 180 | 6: ["#c51b7d","#e9a3c9","#fde0ef","#e6f5d0","#a1d76a","#4d9221"], 181 | 7: ["#c51b7d","#e9a3c9","#fde0ef","#f7f7f7","#e6f5d0","#a1d76a","#4d9221"], 182 | 8: ["#c51b7d","#de77ae","#f1b6da","#fde0ef","#e6f5d0","#b8e186","#7fbc41","#4d9221"], 183 | 9: ["#c51b7d","#de77ae","#f1b6da","#fde0ef","#f7f7f7","#e6f5d0","#b8e186","#7fbc41","#4d9221"], 184 | 10: ["#8e0152","#c51b7d","#de77ae","#f1b6da","#fde0ef","#e6f5d0","#b8e186","#7fbc41","#4d9221","#276419"], 185 | 11: ["#8e0152","#c51b7d","#de77ae","#f1b6da","#fde0ef","#f7f7f7","#e6f5d0","#b8e186","#7fbc41","#4d9221","#276419"] 186 | },RdBu: { 187 | 3: ["#ef8a62","#f7f7f7","#67a9cf"], 188 | 4: ["#ca0020","#f4a582","#92c5de","#0571b0"], 189 | 5: ["#ca0020","#f4a582","#f7f7f7","#92c5de","#0571b0"], 190 | 6: ["#b2182b","#ef8a62","#fddbc7","#d1e5f0","#67a9cf","#2166ac"], 191 | 7: ["#b2182b","#ef8a62","#fddbc7","#f7f7f7","#d1e5f0","#67a9cf","#2166ac"], 192 | 8: ["#b2182b","#d6604d","#f4a582","#fddbc7","#d1e5f0","#92c5de","#4393c3","#2166ac"], 193 | 9: ["#b2182b","#d6604d","#f4a582","#fddbc7","#f7f7f7","#d1e5f0","#92c5de","#4393c3","#2166ac"], 194 | 10: ["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#d1e5f0","#92c5de","#4393c3","#2166ac","#053061"], 195 | 11: ["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#f7f7f7","#d1e5f0","#92c5de","#4393c3","#2166ac","#053061"] 196 | },RdGy: { 197 | 3: ["#ef8a62","#ffffff","#999999"], 198 | 4: ["#ca0020","#f4a582","#bababa","#404040"], 199 | 5: ["#ca0020","#f4a582","#ffffff","#bababa","#404040"], 200 | 6: ["#b2182b","#ef8a62","#fddbc7","#e0e0e0","#999999","#4d4d4d"], 201 | 7: ["#b2182b","#ef8a62","#fddbc7","#ffffff","#e0e0e0","#999999","#4d4d4d"], 202 | 8: ["#b2182b","#d6604d","#f4a582","#fddbc7","#e0e0e0","#bababa","#878787","#4d4d4d"], 203 | 9: ["#b2182b","#d6604d","#f4a582","#fddbc7","#ffffff","#e0e0e0","#bababa","#878787","#4d4d4d"], 204 | 10: ["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#e0e0e0","#bababa","#878787","#4d4d4d","#1a1a1a"], 205 | 11: ["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#ffffff","#e0e0e0","#bababa","#878787","#4d4d4d","#1a1a1a"] 206 | },RdYlBu: { 207 | 3: ["#fc8d59","#ffffbf","#91bfdb"], 208 | 4: ["#d7191c","#fdae61","#abd9e9","#2c7bb6"], 209 | 5: ["#d7191c","#fdae61","#ffffbf","#abd9e9","#2c7bb6"], 210 | 6: ["#d73027","#fc8d59","#fee090","#e0f3f8","#91bfdb","#4575b4"], 211 | 7: ["#d73027","#fc8d59","#fee090","#ffffbf","#e0f3f8","#91bfdb","#4575b4"], 212 | 8: ["#d73027","#f46d43","#fdae61","#fee090","#e0f3f8","#abd9e9","#74add1","#4575b4"], 213 | 9: ["#d73027","#f46d43","#fdae61","#fee090","#ffffbf","#e0f3f8","#abd9e9","#74add1","#4575b4"], 214 | 10: ["#a50026","#d73027","#f46d43","#fdae61","#fee090","#e0f3f8","#abd9e9","#74add1","#4575b4","#313695"], 215 | 11: ["#a50026","#d73027","#f46d43","#fdae61","#fee090","#ffffbf","#e0f3f8","#abd9e9","#74add1","#4575b4","#313695"] 216 | },Spectral: { 217 | 3: ["#fc8d59","#ffffbf","#99d594"], 218 | 4: ["#d7191c","#fdae61","#abdda4","#2b83ba"], 219 | 5: ["#d7191c","#fdae61","#ffffbf","#abdda4","#2b83ba"], 220 | 6: ["#d53e4f","#fc8d59","#fee08b","#e6f598","#99d594","#3288bd"], 221 | 7: ["#d53e4f","#fc8d59","#fee08b","#ffffbf","#e6f598","#99d594","#3288bd"], 222 | 8: ["#d53e4f","#f46d43","#fdae61","#fee08b","#e6f598","#abdda4","#66c2a5","#3288bd"], 223 | 9: ["#d53e4f","#f46d43","#fdae61","#fee08b","#ffffbf","#e6f598","#abdda4","#66c2a5","#3288bd"], 224 | 10: ["#9e0142","#d53e4f","#f46d43","#fdae61","#fee08b","#e6f598","#abdda4","#66c2a5","#3288bd","#5e4fa2"], 225 | 11: ["#9e0142","#d53e4f","#f46d43","#fdae61","#fee08b","#ffffbf","#e6f598","#abdda4","#66c2a5","#3288bd","#5e4fa2"] 226 | },RdYlGn: { 227 | 3: ["#fc8d59","#ffffbf","#91cf60"], 228 | 4: ["#d7191c","#fdae61","#a6d96a","#1a9641"], 229 | 5: ["#d7191c","#fdae61","#ffffbf","#a6d96a","#1a9641"], 230 | 6: ["#d73027","#fc8d59","#fee08b","#d9ef8b","#91cf60","#1a9850"], 231 | 7: ["#d73027","#fc8d59","#fee08b","#ffffbf","#d9ef8b","#91cf60","#1a9850"], 232 | 8: ["#d73027","#f46d43","#fdae61","#fee08b","#d9ef8b","#a6d96a","#66bd63","#1a9850"], 233 | 9: ["#d73027","#f46d43","#fdae61","#fee08b","#ffffbf","#d9ef8b","#a6d96a","#66bd63","#1a9850"], 234 | 10: ["#a50026","#d73027","#f46d43","#fdae61","#fee08b","#d9ef8b","#a6d96a","#66bd63","#1a9850","#006837"], 235 | 11: ["#a50026","#d73027","#f46d43","#fdae61","#fee08b","#ffffbf","#d9ef8b","#a6d96a","#66bd63","#1a9850","#006837"] 236 | },Accent: { 237 | 3: ["#7fc97f","#beaed4","#fdc086"], 238 | 4: ["#7fc97f","#beaed4","#fdc086","#ffff99"], 239 | 5: ["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0"], 240 | 6: ["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f"], 241 | 7: ["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f","#bf5b17"], 242 | 8: ["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f","#bf5b17","#666666"] 243 | },Dark2: { 244 | 3: ["#1b9e77","#d95f02","#7570b3"], 245 | 4: ["#1b9e77","#d95f02","#7570b3","#e7298a"], 246 | 5: ["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e"], 247 | 6: ["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02"], 248 | 7: ["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d"], 249 | 8: ["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d","#666666"] 250 | },Paired: { 251 | 3: ["#a6cee3","#1f78b4","#b2df8a"], 252 | 4: ["#a6cee3","#1f78b4","#b2df8a","#33a02c"], 253 | 5: ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99"], 254 | 6: ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c"], 255 | 7: ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f"], 256 | 8: ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00"], 257 | 9: ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6"], 258 | 10: ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a"], 259 | 11: ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a","#ffff99"], 260 | 12: ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a","#ffff99","#b15928"] 261 | },Pastel1: { 262 | 3: ["#fbb4ae","#b3cde3","#ccebc5"], 263 | 4: ["#fbb4ae","#b3cde3","#ccebc5","#decbe4"], 264 | 5: ["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6"], 265 | 6: ["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc"], 266 | 7: ["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc","#e5d8bd"], 267 | 8: ["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc","#e5d8bd","#fddaec"], 268 | 9: ["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc","#e5d8bd","#fddaec","#f2f2f2"] 269 | },Pastel2: { 270 | 3: ["#b3e2cd","#fdcdac","#cbd5e8"], 271 | 4: ["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4"], 272 | 5: ["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9"], 273 | 6: ["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9","#fff2ae"], 274 | 7: ["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9","#fff2ae","#f1e2cc"], 275 | 8: ["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9","#fff2ae","#f1e2cc","#cccccc"] 276 | },Set1: { 277 | 3: ["#e41a1c","#377eb8","#4daf4a"], 278 | 4: ["#e41a1c","#377eb8","#4daf4a","#984ea3"], 279 | 5: ["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00"], 280 | 6: ["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33"], 281 | 7: ["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33","#a65628"], 282 | 8: ["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33","#a65628","#f781bf"], 283 | 9: ["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33","#a65628","#f781bf","#999999"] 284 | },Set2: { 285 | 3: ["#66c2a5","#fc8d62","#8da0cb"], 286 | 4: ["#66c2a5","#fc8d62","#8da0cb","#e78ac3"], 287 | 5: ["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854"], 288 | 6: ["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854","#ffd92f"], 289 | 7: ["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854","#ffd92f","#e5c494"], 290 | 8: ["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854","#ffd92f","#e5c494","#b3b3b3"] 291 | },Set3: { 292 | 3: ["#8dd3c7","#ffffb3","#bebada"], 293 | 4: ["#8dd3c7","#ffffb3","#bebada","#fb8072"], 294 | 5: ["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3"], 295 | 6: ["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462"], 296 | 7: ["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69"], 297 | 8: ["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5"], 298 | 9: ["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9"], 299 | 10: ["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd"], 300 | 11: ["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd","#ccebc5"], 301 | 12: ["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd","#ccebc5","#ffed6f"] 302 | }}; 303 | -------------------------------------------------------------------------------- /public/javascripts/thirdparty/d3-context-menu.js: -------------------------------------------------------------------------------- 1 | (function(root, factory) { 2 | if (typeof module === 'object' && module.exports) { 3 | module.exports = function(d3) { 4 | d3.contextMenu = factory(d3); 5 | return d3.contextMenu; 6 | }; 7 | } else { 8 | root.d3.contextMenu = factory(root.d3); 9 | } 10 | }( this, 11 | function(d3) { 12 | return function (menu, opts) { 13 | 14 | var openCallback, 15 | closeCallback; 16 | 17 | if (typeof opts === 'function') { 18 | openCallback = opts; 19 | } else { 20 | opts = opts || {}; 21 | openCallback = opts.onOpen; 22 | closeCallback = opts.onClose; 23 | } 24 | 25 | // create the div element that will hold the context menu 26 | d3.selectAll('.d3-context-menu').data([1]) 27 | .enter() 28 | .append('div') 29 | .attr('class', 'd3-context-menu'); 30 | 31 | // close menu 32 | d3.select('body').on('click.d3-context-menu', function() { 33 | d3.select('.d3-context-menu').style('display', 'none'); 34 | if (closeCallback) { 35 | closeCallback(); 36 | } 37 | }); 38 | 39 | // this gets executed when a contextmenu event occurs 40 | return function(data, index) { 41 | var elm = this; 42 | 43 | d3.selectAll('.d3-context-menu').html(''); 44 | var list = d3.selectAll('.d3-context-menu').append('ul'); 45 | list.selectAll('li').data(typeof menu === 'function' ? menu(data) : menu).enter() 46 | .append('li') 47 | .html(function(d) { 48 | return (typeof d.title === 'string') ? d.title : d.title(data); 49 | }) 50 | .on('click', function(d, i) { 51 | d.action(elm, data, index); 52 | d3.select('.d3-context-menu').style('display', 'none'); 53 | 54 | if (closeCallback) { 55 | closeCallback(); 56 | } 57 | }); 58 | 59 | // the openCallback allows an action to fire before the menu is displayed 60 | // an example usage would be closing a tooltip 61 | if (openCallback) { 62 | if (openCallback(data, index) === false) { 63 | return; 64 | } 65 | } 66 | 67 | // display context menu 68 | d3.select('.d3-context-menu') 69 | .style('left', (d3.event.pageX - 2) + 'px') 70 | .style('top', (d3.event.pageY - 2) + 'px') 71 | .style('display', 'block'); 72 | 73 | d3.event.preventDefault(); 74 | d3.event.stopPropagation(); 75 | }; 76 | }; 77 | } 78 | )); 79 | -------------------------------------------------------------------------------- /public/javascripts/thirdparty/js.cookie.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * JavaScript Cookie v2.0.2 3 | * https://github.com/js-cookie/js-cookie 4 | * 5 | * Copyright 2006, 2015 Klaus Hartl 6 | * Released under the MIT license 7 | */ 8 | (function (factory) { 9 | if (typeof define === 'function' && define.amd) { 10 | define(factory); 11 | } else if (typeof exports === 'object') { 12 | module.exports = factory(); 13 | } else { 14 | var _OldCookies = window.Cookies; 15 | var api = window.Cookies = factory(window.jQuery); 16 | api.noConflict = function () { 17 | window.Cookies = _OldCookies; 18 | return api; 19 | }; 20 | } 21 | }(function () { 22 | function extend () { 23 | var i = 0; 24 | var result = {}; 25 | for (; i < arguments.length; i++) { 26 | var attributes = arguments[ i ]; 27 | for (var key in attributes) { 28 | result[key] = attributes[key]; 29 | } 30 | } 31 | return result; 32 | } 33 | 34 | function init (converter) { 35 | function api (key, value, attributes) { 36 | var result; 37 | 38 | // Write 39 | 40 | if (arguments.length > 1) { 41 | attributes = extend({ 42 | path: '/' 43 | }, api.defaults, attributes); 44 | 45 | if (typeof attributes.expires === 'number') { 46 | var expires = new Date(); 47 | expires.setMilliseconds(expires.getMilliseconds() + attributes.expires * 864e+5); 48 | attributes.expires = expires; 49 | } 50 | 51 | try { 52 | result = JSON.stringify(value); 53 | if (/^[\{\[]/.test(result)) { 54 | value = result; 55 | } 56 | } catch (e) {} 57 | 58 | value = encodeURIComponent(String(value)); 59 | value = value.replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g, decodeURIComponent); 60 | 61 | key = encodeURIComponent(String(key)); 62 | key = key.replace(/%(23|24|26|2B|5E|60|7C)/g, decodeURIComponent); 63 | key = key.replace(/[\(\)]/g, escape); 64 | 65 | return (document.cookie = [ 66 | key, '=', value, 67 | attributes.expires && '; expires=' + attributes.expires.toUTCString(), // use expires attribute, max-age is not supported by IE 68 | attributes.path && '; path=' + attributes.path, 69 | attributes.domain && '; domain=' + attributes.domain, 70 | attributes.secure ? '; secure' : '' 71 | ].join('')); 72 | } 73 | 74 | // Read 75 | 76 | if (!key) { 77 | result = {}; 78 | } 79 | 80 | // To prevent the for loop in the first place assign an empty array 81 | // in case there are no cookies at all. Also prevents odd result when 82 | // calling "get()" 83 | var cookies = document.cookie ? document.cookie.split('; ') : []; 84 | var rdecode = /(%[0-9A-Z]{2})+/g; 85 | var i = 0; 86 | 87 | for (; i < cookies.length; i++) { 88 | var parts = cookies[i].split('='); 89 | var name = parts[0].replace(rdecode, decodeURIComponent); 90 | var cookie = parts.slice(1).join('='); 91 | 92 | if (cookie.charAt(0) === '"') { 93 | cookie = cookie.slice(1, -1); 94 | } 95 | 96 | cookie = converter && converter(cookie, name) || cookie.replace(rdecode, decodeURIComponent); 97 | 98 | if (this.json) { 99 | try { 100 | cookie = JSON.parse(cookie); 101 | } catch (e) {} 102 | } 103 | 104 | if (key === name) { 105 | result = cookie; 106 | break; 107 | } 108 | 109 | if (!key) { 110 | result[name] = cookie; 111 | } 112 | } 113 | 114 | return result; 115 | } 116 | 117 | api.get = api.set = api; 118 | api.getJSON = function () { 119 | return api.apply({ 120 | json: true 121 | }, [].slice.call(arguments)); 122 | }; 123 | api.defaults = {}; 124 | 125 | api.remove = function (key, attributes) { 126 | api(key, '', extend(attributes, { 127 | expires: -1 128 | })); 129 | }; 130 | 131 | api.withConverter = init; 132 | 133 | return api; 134 | } 135 | 136 | return init(); 137 | })); 138 | -------------------------------------------------------------------------------- /public/javascripts/thirdparty/spin.min.js: -------------------------------------------------------------------------------- 1 | // http://spin.js.org/#v2.3.1 2 | !function(a,b){"object"==typeof exports?module.exports=b():"function"==typeof define&&define.amd?define(b):a.Spinner=b()}(this,function(){"use strict";function a(a,b){var c,d=document.createElement(a||"div");for(c in b)d[c]=b[c];return d}function b(a){for(var b=1,c=arguments.length;c>b;b++)a.appendChild(arguments[b]);return a}function c(a,b,c,d){var e=["opacity",b,~~(100*a),c,d].join("-"),f=.01+c/d*100,g=Math.max(1-(1-a)/b*(100-f),a),h=j.substring(0,j.indexOf("Animation")).toLowerCase(),i=h&&"-"+h+"-"||"";return m[e]||(k.insertRule("@"+i+"keyframes "+e+"{0%{opacity:"+g+"}"+f+"%{opacity:"+a+"}"+(f+.01)+"%{opacity:1}"+(f+b)%100+"%{opacity:"+a+"}100%{opacity:"+g+"}}",k.cssRules.length),m[e]=1),e}function d(a,b){var c,d,e=a.style;if(b=b.charAt(0).toUpperCase()+b.slice(1),void 0!==e[b])return b;for(d=0;d',c)}k.addRule(".spin-vml","behavior:url(#default#VML)"),h.prototype.lines=function(a,d){function f(){return e(c("group",{coordsize:k+" "+k,coordorigin:-j+" "+-j}),{width:k,height:k})}function h(a,h,i){b(m,b(e(f(),{rotation:360/d.lines*a+"deg",left:~~h}),b(e(c("roundrect",{arcsize:d.corners}),{width:j,height:d.scale*d.width,left:d.scale*d.radius,top:-d.scale*d.width>>1,filter:i}),c("fill",{color:g(d.color,a),opacity:d.opacity}),c("stroke",{opacity:0}))))}var i,j=d.scale*(d.length+d.width),k=2*d.scale*j,l=-(d.width+d.length)*d.scale*2+"px",m=e(f(),{position:"absolute",top:l,left:l});if(d.shadow)for(i=1;i<=d.lines;i++)h(i,-2,"progid:DXImageTransform.Microsoft.Blur(pixelradius=2,makeshadow=1,shadowopacity=.3)");for(i=1;i<=d.lines;i++)h(i);return b(a,m)},h.prototype.opacity=function(a,b,c,d){var e=a.firstChild;d=d.shadow&&d.lines||0,e&&b+d>1)+"px"})}for(var i,k=0,l=(f.lines-1)*(1-f.direction)/2;k { 5 | return JSON.parse(fs.readFileSync(path, "utf8")); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const config = require('../config/config'); 2 | const debug = require('debug')('api'); 3 | const express = require('express'); 4 | const router = express.Router(); 5 | const fs = require('fs'); 6 | const moment = require('moment'); 7 | const AWS = require('aws-sdk-promise'); 8 | const batchPromises = require('batch-promises'); 9 | // AWS variable has default credentials from Shared Credentials File or Environment Variables. 10 | // (see: https://docs.aws.amazon.com/AWSJavaScriptSDK/guide/node-configuring.html) 11 | // Override default credentials with configFile (e.g. './aws_config.json') if it exists 12 | if (fs.existsSync(config.aws.configFile)) { 13 | console.log(`Updating with settings from '${config.aws.configFile}'...`); 14 | AWS.config.update(JSON.parse(fs.readFileSync(config.aws.configFile, 'utf8'))); 15 | } 16 | console.log(`Targeting AWS region '${AWS.config.region}'`); 17 | 18 | const utils = require('./utils'); 19 | 20 | const FetchStatus = require('./fetchStatus'); 21 | const ClusterState = require('./clusterState'); 22 | const clusterStateCache = require('../routes/clusterStateCache'); 23 | const clusterStateCacheTtl = config.clusterStateCacheTtl; 24 | const taskDefinitionCache = require('memory-cache'); 25 | const promiseDelayer = require('./promiseDelayer'); 26 | const staticClusterDataProvider = require('./staticClusterDataProvider.js'); 27 | 28 | const ecs = new AWS.ECS(); 29 | const ec2 = new AWS.EC2(); 30 | 31 | /* Home page */ 32 | 33 | router.get('/', function (req, res, next) { 34 | res.render('index', { 35 | title: 'c3vis - Cloud Container Cluster Visualizer', 36 | useStaticData: staticDataRequested(req), 37 | resourceType: req.query.resourceType ? req.query.resourceType : 'memory' 38 | }); 39 | }); 40 | 41 | function staticDataRequested(req) { 42 | return req.query.static ? (req.query.static.toLowerCase() === "true") : false; 43 | } 44 | 45 | /* API endpoints 46 | * ============= 47 | * Endpoints take a "?static=true" query param to enable testing with static data when AWS credentials aren't available 48 | */ 49 | 50 | router.get('/api/instance_summaries_with_tasks', function (req, res, next) { 51 | Promise.resolve() 52 | .then(function() { 53 | debugLog(`Headers: ${JSON.stringify(req.headers, null, 4)}`); 54 | if (!req.query.cluster) { 55 | send400Response("Please provide a 'cluster' parameter", res); 56 | reject("No 'cluster' parameter provided."); 57 | } else { 58 | const clusterName = req.query.cluster; 59 | const useStaticData = staticDataRequested(req); 60 | const forceRefresh = req.query.forceRefresh === 'true'; 61 | return getInstanceSummariesWithTasks(res, clusterName, useStaticData, forceRefresh); 62 | } 63 | }) 64 | .catch(function (err) { 65 | const reason = new Error(`Failed getting instance summaries: ${err}`); 66 | reason.stack += `\nCaused By:\n` + err.stack; 67 | sendErrorResponse(reason, res); 68 | }); 69 | }); 70 | 71 | function getInstanceSummariesWithTasks(res, clusterName, useStaticData, forceRefresh) { 72 | return Promise.resolve(getOrInitializeClusterState(clusterName, forceRefresh)) 73 | .then(function(clusterState) { 74 | if (clusterState == null) { 75 | throw new Error(`clusterState for '${clusterName}' cluster not cached and could not be initialised.`); 76 | } else if (clusterState.fetchStatus === FetchStatus.ERROR) { 77 | // Server previously encountered an error while asynchronously processing cluster. Send error to client. 78 | console.log(`Sending current state to client with fetchStatus '${clusterState.fetchStatus}'.`); 79 | sendErrorResponse(clusterState.errorDetails, res); 80 | return clusterState; 81 | } else { 82 | // Send current state to client. If only just initialised, next then() block will process in background while client polls periodically 83 | console.log(`Sending current state to client with fetchStatus '${clusterState.fetchStatus}'.`); 84 | res.json(clusterState); 85 | return clusterState; 86 | } 87 | }) 88 | .then(function(clusterState) { 89 | if (clusterState.fetchStatus === FetchStatus.INITIAL) { 90 | // Populate cluster state in the background while client polls asynchronously 91 | if (useStaticData) { 92 | populateStaticClusterStateWithInstanceSummaries(clusterName); 93 | } else { 94 | populateClusterStateWithInstanceSummaries(clusterName); 95 | } 96 | } 97 | }) 98 | .catch(function(err) { 99 | console.log(`${err}\n${err.stack}`); 100 | setClusterStateError(clusterName, err); 101 | // NOTE: Don't re-throw here, to avoid 'UnhandledPromiseRejectionWarning' in router calling function 102 | }); 103 | } 104 | 105 | function populateStaticClusterStateWithInstanceSummaries(clusterName) { 106 | console.log(`populateStaticClusterStateWithInstanceSummaries(${clusterName})`); 107 | updateClusterState(clusterName, FetchStatus.FETCHING, {}); 108 | try { 109 | // Return some static instance details with task details 110 | const instanceSummaries = staticClusterDataProvider.getStaticClusterData(clusterName); 111 | updateClusterState(clusterName, FetchStatus.FETCHED, instanceSummaries); 112 | } catch (err) { 113 | console.log(`${err}\n${err.stack}`); 114 | setClusterStateError(clusterName, `Encountered error processing static file for '${clusterName}' cluster: ${err}`); 115 | } 116 | } 117 | 118 | function updateClusterState(clusterName, status, instanceSummaries) { 119 | console.log(`Setting fetch status to "${status}" for cluster "${clusterName}"`); 120 | const clusterState = getOrInitializeClusterState(clusterName); 121 | clusterState.fetchStatus = status; 122 | clusterState.instanceSummaries = instanceSummaries; 123 | console.log(`Updated: clusterState for '${clusterName}' cluster = ${JSON.stringify(clusterState)}`) 124 | } 125 | 126 | function getOrInitializeClusterState(clusterName, forceRefresh = false) { 127 | // NOTE: Cache will return null if cluster is not yet cached OR if cluster entry has expired 128 | let clusterState = clusterStateCache.get(clusterName); 129 | if (clusterState != null && forceRefresh) { 130 | console.log(`Client requested a force refresh of cluster data already cached at ${clusterState.createTimestamp} (${moment(clusterState.createTimestamp).fromNow()})`); 131 | } 132 | if (clusterState == null || forceRefresh) { 133 | clusterState = new ClusterState(clusterName); 134 | clusterStateCache.put(clusterName, clusterState, clusterStateCacheTtl); 135 | } 136 | return clusterState; 137 | } 138 | 139 | function setClusterStateError(clusterName, errorDetails) { 140 | console.log(`Setting errorDetails for '${clusterName}' cluster to: ${errorDetails}`); 141 | const clusterState = getOrInitializeClusterState(clusterName); 142 | clusterState.fetchStatus = FetchStatus.ERROR; 143 | clusterState.errorDetails = errorDetails; 144 | } 145 | 146 | function populateClusterStateWithInstanceSummaries(cluster) { 147 | console.log(`populateClusterStateWithInstanceSummaries(${cluster})`); 148 | updateClusterState(cluster, FetchStatus.FETCHING, {}); 149 | 150 | let tasksArray = []; 151 | getTasksWithTaskDefinitions(cluster) 152 | .then(function (tasksResult) { 153 | tasksArray = tasksResult; 154 | return listAllContainerInstances(cluster); 155 | }) 156 | .then(function (listAllContainerInstanceArns) { 157 | debugLog(`\tFound ${listAllContainerInstanceArns.length} ContainerInstanceARNs...`); 158 | if (listAllContainerInstanceArns.length === 0) { 159 | return new Promise(function (resolve, reject) { 160 | resolve(null); 161 | }); 162 | } else { 163 | const containerInstanceBatches = listAllContainerInstanceArns.map(function (instances, index) { 164 | return index % config.aws.describeInstancesPageSize === 0 ? listAllContainerInstanceArns.slice(index, index + config.aws.describeInstancesPageSize) : null; 165 | }).filter(function (instances) { 166 | return instances; 167 | }); 168 | return batchPromises(1, containerInstanceBatches, containerInstanceBatch => new Promise((resolve, reject) => { 169 | // The containerInstanceBatch iteratee will fire after each batch 170 | debugLog(`\tCalling ecs.describeContainerInstances for Container Instance batch: ${containerInstanceBatch}`); 171 | resolve(ecs.describeContainerInstances({ 172 | cluster: cluster, 173 | containerInstances: containerInstanceBatch 174 | }).promise().then(promiseDelayer.delay(config.aws.apiDelay))); 175 | })); 176 | } 177 | }) 178 | .then(function (describeContainerInstancesResponses) { 179 | if (!describeContainerInstancesResponses || describeContainerInstancesResponses.length === 0) { 180 | return new Promise(function (resolve, reject) { 181 | console.warn("No Container Instances found"); 182 | updateClusterState(cluster, FetchStatus.FETCHED, []); 183 | }); 184 | } else { 185 | const containerInstances = describeContainerInstancesResponses.reduce(function (acc, current) { 186 | return acc.concat(current.data.containerInstances); 187 | }, []); 188 | const ec2instanceIds = containerInstances.map(function (i) { 189 | return i.ec2InstanceId; 190 | }); 191 | console.log(`Found ${ec2instanceIds.length} ec2InstanceIds for cluster '${cluster}': ${ec2instanceIds}`); 192 | return ec2.describeInstances({InstanceIds: ec2instanceIds}).promise() 193 | .then(function (ec2Instances) { 194 | const instances = [].concat.apply([], ec2Instances.data.Reservations.map(function (r) { 195 | return r.Instances 196 | })); 197 | const privateIpAddresses = instances.map(function (i) { 198 | return i.PrivateIpAddress 199 | }); 200 | console.log(`\twith ${privateIpAddresses.length} matching Private IP addresses: ${privateIpAddresses}`); 201 | const instanceSummaries = containerInstances.map(function (instance) { 202 | const ec2IpAddress = instances.find(function (i) { 203 | return i.InstanceId === instance.ec2InstanceId 204 | }).PrivateIpAddress; 205 | return { 206 | "ec2IpAddress": ec2IpAddress, 207 | "ec2InstanceId": instance.ec2InstanceId, 208 | "ec2InstanceConsoleUrl": "https://console.aws.amazon.com/ec2/v2/home?region=" + AWS.config.region + "#Instances:instanceId=" + instance.ec2InstanceId, 209 | "ecsInstanceConsoleUrl": "https://console.aws.amazon.com/ecs/home?region=" + AWS.config.region + "#/clusters/" + cluster + "/containerInstances/" + instance["containerInstanceArn"].substring(instance["containerInstanceArn"].lastIndexOf("/") + 1), 210 | "registeredCpu": utils.registeredCpu(instance), 211 | "registeredMemory": utils.registeredMemory(instance), 212 | "remainingCpu": utils.remainingCpu(instance), 213 | "remainingMemory": utils.remainingMemory(instance), 214 | "tasks": tasksArray.filter(function (t) { 215 | return t.containerInstanceArn === instance.containerInstanceArn; 216 | }) 217 | } 218 | }); 219 | updateClusterState(cluster, FetchStatus.FETCHED, instanceSummaries); 220 | }); 221 | } 222 | }) 223 | .catch(function(err) { 224 | setClusterStateError(cluster, err); 225 | }); 226 | } 227 | 228 | router.get('/api/cluster_names', function (req, res, next) { 229 | const useStaticData = staticDataRequested(req); 230 | getClusterNames(useStaticData, res); 231 | }); 232 | 233 | function getClusterNames(useStaticData, res) { 234 | if (useStaticData) { 235 | res.json(["demo-cluster-8", "demo-cluster-50", "demo-cluster-75", "demo-cluster-100", "invalid"]); 236 | } else { 237 | ecs.listClusters({}, function (err, data1) { 238 | if (err) { 239 | sendErrorResponse(err, res); 240 | } else { 241 | res.json(data1.clusterArns.map(function (str) { 242 | return str.substring(str.indexOf("/") + 1) 243 | })); 244 | } 245 | }); 246 | } 247 | } 248 | 249 | function listAllContainerInstances(cluster) { 250 | return new Promise(function (resolve, reject) { 251 | listContainerInstanceWithToken(cluster, null, []) 252 | .then(function (containerInstanceArns) { 253 | resolve(containerInstanceArns); 254 | }) 255 | .catch(function (err) { 256 | reject(err); 257 | }); 258 | }); 259 | } 260 | 261 | function listContainerInstanceWithToken(cluster, token, instanceArns) { 262 | const params = {cluster: cluster, maxResults: config.aws.listInstancesPageSize}; 263 | if (token) { 264 | params['nextToken'] = token; 265 | } 266 | debugLog("Calling ecs.listContainerInstances..."); 267 | return ecs.listContainerInstances(params).promise() 268 | .then(function (listContainerInstanceResponse) { 269 | const containerInstanceArns = instanceArns.concat(listContainerInstanceResponse.data.containerInstanceArns); 270 | const nextToken = listContainerInstanceResponse.data.nextToken; 271 | if (containerInstanceArns.length === 0) { 272 | return []; 273 | } else if (nextToken) { 274 | return listContainerInstanceWithToken(cluster, nextToken, containerInstanceArns); 275 | } else { 276 | return containerInstanceArns; 277 | } 278 | }); 279 | } 280 | 281 | function listAllTasks(cluster) { 282 | return new Promise(function (resolve, reject) { 283 | listTasksWithToken(cluster, null, []) 284 | .then(function (allTasks) { 285 | resolve(allTasks); 286 | }).catch(function (err) { 287 | reject(err); 288 | }); 289 | }); 290 | } 291 | 292 | function listTasksWithToken(cluster, token, tasks) { 293 | const params = {cluster: cluster, maxResults: config.aws.listTasksPageSize}; 294 | if (token) { 295 | params['nextToken'] = token; 296 | } 297 | debugLog(`\tCalling ecs.listTasks with token: ${token} ...`); 298 | // TODO: Handle errors, e.g.: (node:27333) UnhandledPromiseRejectionWarning: ClusterNotFoundException: Cluster not found. 299 | return ecs.listTasks(params).promise() 300 | .then(promiseDelayer.delay(config.aws.apiDelay)) 301 | .then(function (tasksResponse) { 302 | debugLog(`\t\tReceived tasksResponse with ${tasksResponse.data.taskArns.length} Task ARNs`); 303 | const taskArns = tasks.concat(tasksResponse.data.taskArns); 304 | const nextToken = tasksResponse.data.nextToken; 305 | if (taskArns.length === 0) { 306 | return []; 307 | } else if (nextToken) { 308 | return listTasksWithToken(cluster, nextToken, taskArns); 309 | } else { 310 | debugLog(`\t\tReturning ${taskArns.length} taskArns from listTasksWithToken: ${taskArns}`); 311 | return taskArns; 312 | } 313 | }); 314 | } 315 | 316 | function getTasksWithTaskDefinitions(cluster) { 317 | console.log(`Getting Tasks annotated with Task Definitions for cluster '${cluster}'...`); 318 | return new Promise(function (resolve, reject) { 319 | let tasksArray = []; 320 | listAllTasks(cluster) 321 | .then(function (allTaskArns) { 322 | if (allTaskArns.length === 0) { 323 | console.warn("\tNo Task ARNs found"); 324 | resolve([]); 325 | } else { 326 | return allTaskArns.map(function (tasks, index) { 327 | return index % config.aws.describeTasksPageSize === 0 ? allTaskArns.slice(index, index + config.aws.describeTasksPageSize) : null; 328 | }).filter(function (tasks) { 329 | return tasks; 330 | }); 331 | } 332 | }) 333 | .then(function (taskBatches) { 334 | // Describe up to maxSimultaneousDescribeTasksCalls (e.g. 2) pages of describeTasksPageSize (e.g. 100) ARNs at a time 335 | // Without batchPromises, we will fire all ecs.describeTasks calls one after the other and could run into API rate limit issues 336 | return batchPromises(config.aws.maxSimultaneousDescribeTasksCalls, taskBatches, taskBatch => new Promise((resolve, reject) => { 337 | // The iteratee will fire after each batch 338 | debugLog(`\tCalling ecs.describeTasks for Task batch: ${taskBatch}`); 339 | resolve(ecs.describeTasks({cluster: cluster, tasks: taskBatch}).promise() 340 | .then(promiseDelayer.delay(config.aws.apiDelay))); 341 | })); 342 | }) 343 | .then(function (describeTasksResponses) { 344 | tasksArray = describeTasksResponses.reduce(function (acc, current) { 345 | return acc.concat(current.data.tasks); 346 | }, []); 347 | console.log(`Found ${tasksArray.length} tasks for cluster '${cluster}'`); 348 | // Wait for the responses from maxSimultaneousDescribeTaskDefinitionCalls describeTaskDefinition calls before invoking another maxSimultaneousDescribeTaskDefinitionCalls calls 349 | // Without batchPromises, we will fire all ecs.describeTaskDefinition calls one after the other and could run into API rate limit issues 350 | return batchPromises(config.aws.maxSimultaneousDescribeTaskDefinitionCalls, tasksArray, task => new Promise((resolve, reject) => { 351 | const cachedTaskDef = taskDefinitionCache.get(task.taskDefinitionArn); 352 | if (cachedTaskDef) { 353 | debugLog(`\tReusing cached Task Definition for Task Definition ARN: ${task.taskDefinitionArn}`); 354 | resolve(cachedTaskDef); 355 | } else { 356 | debugLog(`\tCalling ecs.describeTaskDefinition for Task Definition ARN: ${task.taskDefinitionArn}`); 357 | resolve(ecs.describeTaskDefinition({taskDefinition: task.taskDefinitionArn}).promise() 358 | .then(promiseDelayer.delay(config.aws.apiDelay)) 359 | .then(function (taskDefinition) { 360 | debugLog(`\t\tReceived taskDefinition for ARN "${task.taskDefinitionArn}". Caching in memory.`); 361 | taskDefinitionCache.put(task.taskDefinitionArn, taskDefinition); 362 | return taskDefinition; 363 | }) 364 | .catch(function (err) { 365 | debugLog(`\t\tFAILED ecs.describeTaskDefinition call for '${task.taskDefinitionArn}': ${err}`); 366 | return Promise.reject(err); 367 | })); 368 | } 369 | })); 370 | }) 371 | .then(function (taskDefs) { 372 | console.log(`Found ${taskDefs.length} task definitions for cluster '${cluster}'`); 373 | // Fill in task details in tasksArray with taskDefinition details (e.g. memory, cpu) 374 | taskDefs.forEach(function (taskDef) { 375 | tasksArray 376 | .filter(function (t) { 377 | return t["taskDefinitionArn"] === taskDef.data.taskDefinition.taskDefinitionArn; 378 | }) 379 | .forEach(function (t) { 380 | t["taskDefinition"] = taskDef.data.taskDefinition; 381 | }); 382 | }); 383 | resolve(tasksArray); 384 | }) 385 | .catch(function (err) { 386 | console.error("\nCaught error in getTasksWithTaskDefinitions():", err); 387 | reject(err); 388 | }); 389 | }); 390 | } 391 | 392 | function send400Response(errMsg, res) { 393 | console.log(errMsg); 394 | res.status(400).send(`Error: ${errMsg}`); 395 | } 396 | 397 | function sendErrorResponse(err, res) { 398 | console.log(err); 399 | res.status(500).send(err.message || err); 400 | } 401 | 402 | function debugLog(msg) { 403 | debug(msg); 404 | } 405 | 406 | module.exports = router; 407 | -------------------------------------------------------------------------------- /routes/promiseDelayer.js: -------------------------------------------------------------------------------- 1 | class PromiseDelayer { 2 | delay(ms) { 3 | return function(data) { 4 | return new Promise(resolve => setTimeout(() => resolve(data), ms)); 5 | }; 6 | } 7 | } 8 | 9 | module.exports = new PromiseDelayer(); 10 | -------------------------------------------------------------------------------- /routes/staticClusterDataProvider.js: -------------------------------------------------------------------------------- 1 | fileUtils = require('./fileUtils.js'); 2 | 3 | const STATIC_DATA_PATH = "public/test_data"; 4 | 5 | function getStaticClusterData(clusterName) { 6 | const path = `${STATIC_DATA_PATH}/ecs_instance_summaries_with_tasks-${clusterName}.json`; 7 | return fileUtils.readJsonFile(path); 8 | } 9 | 10 | module.exports.STATIC_DATA_PATH = STATIC_DATA_PATH; 11 | module.exports.getStaticClusterData = getStaticClusterData; 12 | -------------------------------------------------------------------------------- /routes/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | registeredCpu: function (instance) { 3 | return instance["registeredResources"].find(function (element, index, array) {return element.name == 'CPU'}).integerValue; 4 | }, 5 | registeredMemory: function (instance) { 6 | return instance["registeredResources"].find(function (element, index, array) {return element.name == 'MEMORY'}).integerValue; 7 | }, 8 | registeredPorts: function (instance) { 9 | return instance["registeredResources"].find(function (element, index, array) {return element.name == 'PORTS'}).integerValue; 10 | }, 11 | remainingCpu: function (instance) { 12 | return instance["remainingResources"].find(function (element, index, array) {return element.name == 'CPU'}).integerValue; 13 | }, 14 | remainingMemory: function (instance) { 15 | return instance["remainingResources"].find(function (element, index, array) {return element.name == 'MEMORY'}).integerValue; 16 | }, 17 | remainingPorts: function (instance) { 18 | return instance["remainingResources"].find(function (element, index, array) {return element.name == 'PORTS'}).integerValue; 19 | } 20 | }; -------------------------------------------------------------------------------- /test/clusterStateCacheSpec.js: -------------------------------------------------------------------------------- 1 | const subject = require('../routes/clusterStateCache'); 2 | const assert = require('chai').assert; 3 | 4 | describe('ClusterStateCache', function() { 5 | describe('#put()', function() { 6 | it('should return null for missing cluster', function() { 7 | assert.equal(subject.get("a"), null); 8 | }); 9 | // TODO: Solve subject.put() preventing test from stopping 10 | // it('should store value', function() { 11 | // subject.put("a", 123); 12 | // assert.equal(subject.get("a"), 123); 13 | // }); 14 | }); 15 | describe('#get()', function() { 16 | it('should expire after TTL', function() { 17 | subject.put("b", 123, 200); 18 | setTimeout(() => { 19 | assert.equal(subject.get("b"), null); 20 | }, 400); 21 | }); 22 | }); 23 | }); -------------------------------------------------------------------------------- /test/clusterStateSpec.js: -------------------------------------------------------------------------------- 1 | const ClusterState = require('../routes/clusterState'); 2 | const FetchStatus = require("../routes/fetchStatus"); 3 | const subject = new ClusterState(); 4 | const assert = require('chai').assert; 5 | 6 | describe('ClusterState', function() { 7 | describe('#constructor()', function() { 8 | it('should set default values', function() { 9 | const subject = new ClusterState("MyClusterName"); 10 | assert.equal(subject.clusterName, "MyClusterName"); 11 | assert.equal(subject.errorDetails, null); 12 | assert.equal(subject.fetchStatus, FetchStatus.INITIAL); 13 | assert.deepEqual(subject.instanceSummaries, {}); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/configSpec.js: -------------------------------------------------------------------------------- 1 | const assert = require('chai').assert; 2 | const CONFIG_MODULE_FILE_NAME = '../config/config'; 3 | const DEFAULT_ENVIRONMENT_NAME = "Development"; 4 | 5 | describe('config', function() { 6 | it('should have default values', function() { 7 | const subject = require(CONFIG_MODULE_FILE_NAME); 8 | checkDefaults(subject); 9 | assert.equal(subject.environmentName, DEFAULT_ENVIRONMENT_NAME); 10 | }); 11 | it('should have default values when TARGET_ENV file is missing', function() { 12 | process.env.TARGET_ENV = "missing_env"; 13 | // Invalidate cached config module so we can load it again with new TARGET_ENV 14 | delete require.cache[require.resolve(CONFIG_MODULE_FILE_NAME)]; 15 | const subject = require(CONFIG_MODULE_FILE_NAME); 16 | checkDefaults(subject); 17 | assert.equal(subject.environmentName, DEFAULT_ENVIRONMENT_NAME); 18 | }); 19 | it('should override with environment values', function() { 20 | process.env.TARGET_ENV = "test"; 21 | // Invalidate cached config module so we can load it again with new TARGET_ENV 22 | delete require.cache[require.resolve(CONFIG_MODULE_FILE_NAME)]; 23 | const subject = require(CONFIG_MODULE_FILE_NAME); 24 | checkDefaults(subject); 25 | assert.equal(subject.environmentName, "Test"); 26 | }); 27 | }); 28 | 29 | function checkDefaults(subject) { 30 | assert.equal(subject.port, 3000); 31 | assert.equal(subject.clusterStateCacheTtl, 30 * 60 * 1000); 32 | assert.equal(subject.aws.apiDelay, 100); 33 | assert.equal(subject.aws.configFile, './aws_config.json'); 34 | assert.equal(subject.aws.listInstancesPageSize, 100); 35 | assert.equal(subject.aws.describeInstancesPageSize, 100); 36 | assert.equal(subject.aws.listTasksPageSize, 100); 37 | assert.equal(subject.aws.describeTasksPageSize, 100); 38 | assert.equal(subject.aws.maxSimultaneousDescribeTasksCalls, 2); 39 | assert.equal(subject.aws.maxSimultaneousDescribeTaskDefinitionCalls, 1); 40 | } 41 | -------------------------------------------------------------------------------- /test/promiseDelayerSpec.js: -------------------------------------------------------------------------------- 1 | const subject = require('../routes/promiseDelayer'); 2 | const assert = require('chai').assert; 3 | 4 | const DELAY_MILLIS = 200; 5 | const ACCEPTABLE_DELTA_MILLIS = 10; 6 | const DUMMY_DATA = "dummy data"; 7 | 8 | function checkElapsedTimeWithinAcceptableDelta(timeBeforeDelay, timeAfterDelay) { 9 | const elapsedTime = timeAfterDelay - timeBeforeDelay; 10 | assert.approximately(elapsedTime, DELAY_MILLIS, ACCEPTABLE_DELTA_MILLIS, `Expected elapsed time to be roughly ${DELAY_MILLIS}ms but was ${elapsedTime}ms`) 11 | } 12 | 13 | describe('PromiseDelayer', function () { 14 | describe('#delayPromise', function () { 15 | it('delays a promise and passes through data', function () { 16 | let timeBeforeDelay; 17 | let timeAfterDelay; 18 | return Promise.resolve() 19 | .then(() => { 20 | timeBeforeDelay = new Date(); 21 | return DUMMY_DATA 22 | }) 23 | .then(subject.delay(DELAY_MILLIS)) 24 | .then((data) => { 25 | timeAfterDelay = new Date(); 26 | checkElapsedTimeWithinAcceptableDelta(timeBeforeDelay, timeAfterDelay); 27 | assert.equal(data, DUMMY_DATA) 28 | }); 29 | }); 30 | it('delays a promise with no data', function () { 31 | let timeBeforeDelay; 32 | let timeAfterDelay; 33 | return Promise.resolve() 34 | .then(() => { 35 | timeBeforeDelay = new Date(); 36 | }) 37 | .then(subject.delay(DELAY_MILLIS)) 38 | .then(() => { 39 | timeAfterDelay = new Date(); 40 | checkElapsedTimeWithinAcceptableDelta(timeBeforeDelay, timeAfterDelay); 41 | }); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /views/error.ejs: -------------------------------------------------------------------------------- 1 |

<%= message %>

2 |

<%= error.status %>

3 |
<%= error.stack %>
4 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= title %> 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 97 | 98 | 99 | 100 | 101 | 141 | 142 | 143 | 144 |
145 |
146 |
147 |
148 | 149 | 180 | 181 | 182 | 183 | --------------------------------------------------------------------------------