├── .dockerignore ├── setup.cfg ├── static ├── robots.txt ├── fetch.min.js ├── metricsgraphics.css └── metricsgraphics.min.js ├── requirements.txt ├── script ├── bootstrap ├── initialize ├── dev ├── templates │ ├── orchestrate.conf │ └── config-proxy.conf ├── ubuntu-base ├── install └── cloud │ └── examples │ ├── coreos-single.yml │ └── coreos-onmetal-io1.yml ├── MANIFEST.in ├── Dockerfile-alpine3.5 ├── templates ├── ga.html ├── error │ ├── 404.html │ └── 500.html ├── full.html ├── loading.html └── stats.html ├── Dockerfile ├── setup.py ├── .gitignore ├── LICENSE ├── Makefile ├── docs └── operations.md ├── dockworker.py ├── README.md ├── orchestrate.py └── spawnpool.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /user/ 3 | Disallow: /spawn/ 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tornado==4.3.0 2 | docker-py==1.7.2 3 | pycurl==7.43.0 4 | futures==3.0.5 5 | pytz==2015.7 6 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | script/ubuntu-base 6 | script/install 7 | script/initialize 8 | -------------------------------------------------------------------------------- /script/initialize: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Initialize supervisor 5 | supervisorctl update 6 | 7 | # Go time! 8 | supervisorctl start all 9 | -------------------------------------------------------------------------------- /script/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | export CONFIGPROXY_AUTH_TOKEN=`head -c 30 /dev/urandom | xxd -p` 3 | node_modules/.bin/configurable-http-proxy --default-target=http://127.0.0.1:9999 & 4 | python orchestrate.py $@ 5 | kill %% 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include *.py 3 | include Makefile 4 | recursive-include static *.css *.js *.txt 5 | recursive-include templates *.html 6 | recursive-include script * 7 | include *.in 8 | include *.md 9 | include Dockerfile 10 | include LICENSE 11 | -------------------------------------------------------------------------------- /script/templates/orchestrate.conf: -------------------------------------------------------------------------------- 1 | [program:orchestrate] 2 | command=python %TMPNBPATH%/orchestrate.py 3 | autostart=true 4 | autorestart=true 5 | stderr_logfile=/var/log/orchestrate.err.log 6 | stdout_logfile=/var/log/orchestrate.out.log 7 | environment=CONFIGPROXY_AUTH_TOKEN=%TOKEN%,CONFIGPROXY_ENDPOINT=http://127.0.0.1:8001 8 | -------------------------------------------------------------------------------- /script/ubuntu-base: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | export DEBIAN_FRONTEND=noninteractive 6 | sudo apt-get update && apt-get upgrade -y 7 | sudo apt-get install -y nodejs-legacy build-essential curl python-pip python-dev supervisor npm libcurl4-openssl-dev 8 | curl -sSL https://get.docker.io/ubuntu/ | sudo sh 9 | -------------------------------------------------------------------------------- /script/templates/config-proxy.conf: -------------------------------------------------------------------------------- 1 | [program:config-proxy] 2 | command=%TMPNBPATH%/node_modules/.bin/configurable-http-proxy --default-target=http://127.0.0.1:9999 --port=8000 3 | autostart=true 4 | autorestart=true 5 | stderr_logfile=/var/log/config-proxy.err.log 6 | stdout_logfile=/var/log/config-proxy.out.log 7 | environment=CONFIGPROXY_AUTH_TOKEN=%TOKEN% 8 | user=nobody 9 | -------------------------------------------------------------------------------- /Dockerfile-alpine3.5: -------------------------------------------------------------------------------- 1 | FROM alpine:3.5 2 | 3 | RUN apk update && apk add python3 py3-curl py3-tz py3-tornado \ 4 | && pip3 install docker-py==1.7.2 \ 5 | && pip3 install --upgrade pip \ 6 | && rm -fr /root/.cache/pip && rm /var/cache/apk/* \ 7 | && ln -s /usr/bin/python3 /usr/bin/python \ 8 | && mkdir -p /srv/tmpnb 9 | 10 | WORKDIR /srv/tmpnb/ 11 | 12 | COPY . /srv/tmpnb/ 13 | 14 | ENV DOCKER_HOST unix://docker.sock 15 | 16 | CMD python orchestrate.py 17 | -------------------------------------------------------------------------------- /templates/ga.html: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.4-wheezy 2 | 3 | RUN apt-get update && apt-get install python-dev libcurl4-openssl-dev -y 4 | RUN pip install --upgrade pip 5 | 6 | RUN mkdir -p /srv/tmpnb 7 | WORKDIR /srv/tmpnb/ 8 | 9 | # Copy the requirements.txt in by itself first to avoid docker cache busting 10 | # any time any file in the project changes 11 | COPY requirements.txt /srv/tmpnb/requirements.txt 12 | RUN pip install -r requirements.txt 13 | 14 | # Now copy in everything else 15 | COPY . /srv/tmpnb/ 16 | 17 | ENV DOCKER_HOST unix://docker.sock 18 | 19 | CMD python orchestrate.py 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | import sys 3 | 4 | if "develop" in sys.argv or any(arg.startswith("bdist") for arg in sys.argv): 5 | import setuptools 6 | 7 | setup_args = dict( 8 | name="tmpnb", 9 | version="0.1.0", 10 | description="Tool for launching temporary Jupyter notebook servers", 11 | url="https://github.com/jupyter/tmpnb", 12 | license="BSD", 13 | author="Jupyter Development Team", 14 | author_email="jupyter@googlegroups.com", 15 | platforms="Linux, Mac OS X" 16 | ) 17 | 18 | with open("requirements.txt") as required: 19 | setup_args["install_requires"] = required.read().splitlines() 20 | 21 | if __name__ == "__main__": 22 | setup(**setup_args) 23 | -------------------------------------------------------------------------------- /script/install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | pip install -r requirements.txt 6 | npm install jupyter/configurable-http-proxy 7 | docker pull jupyter/demo 8 | 9 | # Link straight from where we cloned this 10 | TMPNBPATH=`pwd` 11 | 12 | # Make sure "nobody" can run the configurable-http-proxy 13 | chmod a+rX . -R 14 | chmod a+x node_modules/configurable-http-proxy/bin/configurable-http-proxy 15 | 16 | # Generate a random token for this node 17 | export TOKEN=$( head -c 30 /dev/urandom | xxd -p ) 18 | echo "Your TOKEN for the configurable-http-proxy is $TOKEN" 19 | 20 | # Create supervisor scripts 21 | for SCRIPT in `ls script/templates`; do 22 | sed -e "s;%TOKEN%;$TOKEN;g" -e "s;%TMPNBPATH%;$TMPNBPATH;g" script/templates/$SCRIPT > /etc/supervisor/conf.d/$SCRIPT 23 | done 24 | -------------------------------------------------------------------------------- /templates/error/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | tmpnb 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |

tmpnb

13 |
14 | 17 | 18 |
19 |

Ah shucks!

20 |

Looks like the page you are looking for doesn't exist!

21 |
22 | 23 |
24 |
25 |
26 | 27 | {% include "../ga.html" %} 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /templates/error/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | tmpnb 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |

tmpnb

13 |
14 | 17 | 18 |
19 |

Ah shucks!

20 |

Looks like there was an error!

21 |

Error Code: {{status_code}}

22 |
23 | 24 |
25 |
26 |
27 | 28 | {% include "../ga.html" %} 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /templates/full.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | tmpnb 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |

No Vacancy

13 |

14 | It looks like we're full up. Every single IPython Notebook is in use right now! Try again later 15 | and maybe you'll have better luck. Sorry for the inconvenience! 16 |

17 |

18 | If you hang around here for a while, you'll automatically retry in {{ cull_period }} 19 | seconds. 20 |

21 |
22 |
23 | 24 | {% include "ga.html" %} 25 | 26 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | # Byte-compiled / optimized / DLL files 31 | __pycache__/ 32 | *.py[cod] 33 | 34 | # C extensions 35 | *.so 36 | 37 | # Distribution / packaging 38 | .Python 39 | env/ 40 | build/ 41 | develop-eggs/ 42 | dist/ 43 | downloads/ 44 | eggs/ 45 | lib/ 46 | lib64/ 47 | parts/ 48 | sdist/ 49 | var/ 50 | *.egg-info/ 51 | .installed.cfg 52 | *.egg 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .coverage 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | 66 | # joe backups 67 | *~ 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Project Jupyter Contributors 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /templates/loading.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | tmpnb 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |

tmpnb

13 |
14 | 17 | 18 |
19 | {% if is_user_path %} 20 |

The server you requested is no longer running or it doesn't exist.

21 | {% end %} 22 |

Starting a new notebook server, just for you...

23 |

24 | 25 | 34 | 35 |
36 |
37 |
38 | 39 | {% include "ga.html" %} 40 | 41 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Configuration parameters 2 | CULL_PERIOD ?= 30 3 | CULL_TIMEOUT ?= 60 4 | CULL_MAX ?= 120 5 | LOGGING ?= debug 6 | POOL_SIZE ?= 5 7 | DOCKER_HOST ?= 127.0.0.1 8 | DOCKER_NETWORK_NAME ?= tmpnb 9 | 10 | tmpnb-image: Dockerfile 11 | docker build -t jupyter/tmpnb . 12 | 13 | images: tmpnb-image demo-image minimal-image 14 | 15 | minimal-image: 16 | docker pull jupyter/minimal-notebook 17 | 18 | demo-image: 19 | docker pull jupyter/demo 20 | 21 | proxy-image: 22 | docker pull jupyter/configurable-http-proxy 23 | 24 | network: 25 | @docker network inspect $(DOCKER_NETWORK_NAME) >/dev/null 2>&1 || docker network create $(DOCKER_NETWORK_NAME) 26 | 27 | proxy: proxy-image network 28 | docker run -d -e CONFIGPROXY_AUTH_TOKEN=devtoken \ 29 | --network $(DOCKER_NETWORK_NAME) \ 30 | -p 8000:8000 \ 31 | -p 8001:8001 \ 32 | --name proxy \ 33 | jupyter/configurable-http-proxy \ 34 | --default-target http://tmpnb:9999 --api-ip 0.0.0.0 35 | 36 | tmpnb: minimal-image tmpnb-image network 37 | docker run -d -e CONFIGPROXY_AUTH_TOKEN=devtoken \ 38 | -e CONFIGPROXY_ENDPOINT=http://proxy:8001 \ 39 | --network $(DOCKER_NETWORK_NAME) \ 40 | --name tmpnb \ 41 | -v /var/run/docker.sock:/docker.sock jupyter/tmpnb python orchestrate.py \ 42 | --image=jupyter/minimal-notebook --cull_timeout=$(CULL_TIMEOUT) --cull_period=$(CULL_PERIOD) \ 43 | --logging=$(LOGGING) --pool_size=$(POOL_SIZE) --cull_max=$(CULL_MAX) \ 44 | --docker_network=$(DOCKER_NETWORK_NAME) \ 45 | --use_tokens=1 46 | 47 | dev: cleanup network proxy tmpnb open 48 | 49 | open: 50 | docker ps | grep tmpnb 51 | -open http:`echo $(DOCKER_HOST) | cut -d":" -f2`:8000 52 | 53 | cleanup: 54 | -docker stop `docker ps -aq --filter name=tmpnb --filter name=proxy --filter name=minimal-notebook` 55 | -docker rm `docker ps -aq --filter name=tmpnb --filter name=proxy --filter name=minimal-notebook` 56 | -docker images -q --filter "dangling=true" | xargs docker rmi 57 | 58 | log-tmpnb: 59 | docker logs -f tmpnb 60 | 61 | log-proxy: 62 | docker logs -f proxy 63 | 64 | .PHONY: cleanup 65 | -------------------------------------------------------------------------------- /templates/stats.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | tmpnb 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |

Stats

13 |
14 |
15 |
16 | 17 | {% include "ga.html" %} 18 | 19 | 20 | 21 | 22 | 23 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /script/cloud/examples/coreos-single.yml: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | 3 | #write_files: 4 | #- path: /usr/share/ssh/sshd_config 5 | # permissions: 0644 6 | # content: | 7 | # UsePrivilegeSeparation sandbox 8 | # Subsystem sftp internal-sftp 9 | # PasswordAuthentication no 10 | 11 | 12 | coreos: 13 | update: 14 | # No cluster, will have to do reboots manually 15 | reboot-strategy: off 16 | 17 | units: 18 | - name: systemd-sysctl.service 19 | command: restart 20 | 21 | - name: docker.service 22 | command: start 23 | content: | 24 | [Unit] 25 | Description=Docker Application Container Engine 26 | Documentation=http://docs.docker.io 27 | Requires=docker.socket 28 | 29 | [Service] 30 | Environment="TMPDIR=/var/tmp/" 31 | ExecStartPre=/bin/mount --make-rprivate / 32 | LimitNOFILE=1048576 33 | LimitNPROC=1048576 34 | # Run docker but don't have docker automatically restart 35 | # containers. This is a job for systemd and unit files. 36 | # Turn off inter-container communication (--icc=false) so the containers 37 | # can't communicate over etcd or with each other. 38 | ExecStart=/usr/bin/docker -d -s=btrfs -r=false --icc=false -H fd:// 39 | 40 | [Install] 41 | WantedBy=multi-user.target 42 | 43 | - name: configproxy.service 44 | enable: true 45 | command: start 46 | content: | 47 | [Unit] 48 | Description=ConfigProxy 49 | After=docker.service 50 | Requires=docker.service 51 | [Service] 52 | Restart=always 53 | ExecStartPre=/usr/bin/docker pull jupyter/configurable-http-proxy 54 | ExecStart=/usr/bin/docker run --net=host --rm --name configproxy -e CONFIGPROXY_AUTH_TOKEN=HOKEYTOKEN jupyter/configurable-http-proxy --default-target http://127.0.0.1:9999 55 | ExecStop=/usr/bin/docker rm -f configproxy 56 | [Install] 57 | WantedBy=tmpnb.target 58 | 59 | - name: pull-user-image.service 60 | command: start 61 | content: | 62 | [Unit] 63 | Description=Pull the image for users on tmpnb 64 | Before=tmpnb.service 65 | 66 | [Service] 67 | Type=oneshot 68 | ExecStart=/usr/bin/docker pull jupyter/demo 69 | 70 | - name: tmpnb.service 71 | enable: true 72 | command: start 73 | content: | 74 | [Unit] 75 | Description=tmpnb 76 | After=configproxy.service 77 | Requires=configproxy.service 78 | [Service] 79 | Restart=always 80 | ExecStartPre=/usr/bin/docker pull jupyter/tmpnb 81 | ExecStart=/usr/bin/docker run --rm --name tmpnb --net=host -e CONFIGPROXY_AUTH_TOKEN=HOKEYTOKEN -v /var/run/docker.sock:/docker.sock jupyter/tmpnb python orchestrate.py --cull-timeout=60 --docker-version=1.13 --static-files=/srv/ipython/IPython/html/static/ 82 | ExecStop=/usr/bin/docker rm -f tmpnb 83 | [Install] 84 | WantedBy=tmpnb.target 85 | -------------------------------------------------------------------------------- /docs/operations.md: -------------------------------------------------------------------------------- 1 | Goal: Reproducible setup for deploying tmpnb nodes scalably and safely 2 | 3 | Ephemeral notebook environments can be deployed on bare metal, virtual, and 4 | container orchestration engine deployments. Each come with their own costs, 5 | benefits, risks and supportability. 6 | 7 | ### How do you dynamically add capacity for additional users elastically? 8 | 9 | The way that tmpnb.org operates is by running the [tmpnb-redirector](https://github.com/jupyter/tmpnb-redirector), which provides 10 | a means to do round-robin redirection to new nodes (at the network/DNS level). 11 | 12 | ```json 13 | $ curl -s https://tmpnb.org/stats | jq 14 | { 15 | "available": 107, 16 | "hosts": { 17 | "https://tmp39.tmpnb.org": { 18 | "available": 25, 19 | "container_image": "jupyter/demo:latest", 20 | "version": "0.1.0", 21 | "capacity": 64 22 | }, 23 | "https://tmp41.tmpnb.org": { 24 | "available": 29, 25 | "container_image": "jupyter/demo:latest", 26 | "version": "0.1.0", 27 | "capacity": 64 28 | }, 29 | "https://tmp40.tmpnb.org": { 30 | "available": 30, 31 | "container_image": "jupyter/demo:latest", 32 | "version": "0.1.0", 33 | "capacity": 64 34 | }, 35 | "https://tmp35.tmpnb.org": { 36 | "available": 23, 37 | "container_image": "jupyter/demo:latest", 38 | "version": "0.1.0", 39 | "capacity": 64 40 | } 41 | }, 42 | "version": "0.0.1", 43 | "capacity": 256 44 | } 45 | ``` 46 | 47 | Health is checked continuously for each node within this setup and ensures that 48 | websocket bottlenecks are constrained to each individual node e.g. `tmp41.tmpnb.org`. 49 | 50 | This does require handling DNS yourself or setting records on deployment (the second is what is used for tmpnb.org). 51 | 52 | With this approach, you can also divide up what's running on VMs, bare 53 | metal, or container environments. You can run across multiple providers with this setup as well. 54 | 55 | ### How do you block outbound network access from notebook servers? 56 | 57 | #### Daemon setting approach 58 | 59 | The current implementation for nature and tmpnb.org is specified within an 60 | Ansible playbook [in the jupyter/tmpnb-deploy](https://github.com/jupyter/tmpnb-deploy/blob/master/roles/notebook/files/docker) repository as an init file for Docker's daemon: 61 | 62 | ```bash 63 | DOCKER_OPTS="--icc=false --ip-forward=false" 64 | ``` 65 | 66 | This requires configuring the proxy and tmpnb to use `--net=host` for networking. It's also not a feasible option within a swarm cluster. 67 | 68 | #### Creating isolated networks 69 | 70 | This predates the Docker networking plugins and overlay support within Carina though and Jupyter has been planning on supporting locked down networks by default. This requires upstream work in docker-py (finished) and work in tmpnb for handling networks (started). 71 | 72 | [Configuring networking - tmpnb#187](https://github.com/jupyter/tmpnb/issues/187) 73 | 74 | ### How do you control disk usage, memory usage, and other quotas. 75 | 76 | Memory usage and CPU usage are as granular as Docker itself allows, specified 77 | through options on tmpnb deployment (e.g. `--cpu-shares` and `--mem-limit `). 78 | Any time there's a new option that Docker allows us to pin down, we can expose it 79 | directly for Docker. 80 | 81 | Disk usage is an open problem for Docker [docker/docker#3804](https://github.com/docker/docker/issues/3804). However, since 82 | the Docker images run underneath can have their own constraints for a subuser (Jupyter has a user named `jovyan`), there may be a way to do this in a typical Linux-centric way. 83 | 84 | ### How does authentication work with tmpnb 85 | 86 | There is no authentication. JupyterHub is your pal there. It may be worth speccing out an auth system like how @parente's [mostly-tmpnb](https://github.com/parente/mostly-tmpnb) works. 87 | 88 | ### Is there an estimate of system limits? 89 | 90 | While many factors can impact system limits, we tested the system limit on one 91 | of the hosts and we think that 1000 simultaneous users is possible (per node). 92 | See [issue 46](https://github.com/jupyter/tmpnb/issues/46) for additional 93 | information. 94 | -------------------------------------------------------------------------------- /static/fetch.min.js: -------------------------------------------------------------------------------- 1 | !function(t){"use strict";function e(t){if("string"!=typeof t&&(t=String(t)),/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(t))throw new TypeError("Invalid character in header field name");return t.toLowerCase()}function r(t){return"string"!=typeof t&&(t=String(t)),t}function o(t){var e={next:function(){var e=t.shift();return{done:void 0===e,value:e}}};return y.iterable&&(e[Symbol.iterator]=function(){return e}),e}function n(t){this.map={},t instanceof n?t.forEach(function(t,e){this.append(e,t)},this):t&&Object.getOwnPropertyNames(t).forEach(function(e){this.append(e,t[e])},this)}function s(t){return t.bodyUsed?Promise.reject(new TypeError("Already read")):void(t.bodyUsed=!0)}function i(t){return new Promise(function(e,r){t.onload=function(){e(t.result)},t.onerror=function(){r(t.error)}})}function a(t){var e=new FileReader;return e.readAsArrayBuffer(t),i(e)}function h(t){var e=new FileReader;return e.readAsText(t),i(e)}function u(){return this.bodyUsed=!1,this._initBody=function(t){if(this._bodyInit=t,"string"==typeof t)this._bodyText=t;else if(y.blob&&Blob.prototype.isPrototypeOf(t))this._bodyBlob=t;else if(y.formData&&FormData.prototype.isPrototypeOf(t))this._bodyFormData=t;else if(y.searchParams&&URLSearchParams.prototype.isPrototypeOf(t))this._bodyText=t.toString();else if(t){if(!y.arrayBuffer||!ArrayBuffer.prototype.isPrototypeOf(t))throw new Error("unsupported BodyInit type")}else this._bodyText="";this.headers.get("content-type")||("string"==typeof t?this.headers.set("content-type","text/plain;charset=UTF-8"):this._bodyBlob&&this._bodyBlob.type?this.headers.set("content-type",this._bodyBlob.type):y.searchParams&&URLSearchParams.prototype.isPrototypeOf(t)&&this.headers.set("content-type","application/x-www-form-urlencoded;charset=UTF-8"))},y.blob?(this.blob=function(){var t=s(this);if(t)return t;if(this._bodyBlob)return Promise.resolve(this._bodyBlob);if(this._bodyFormData)throw new Error("could not read FormData body as blob");return Promise.resolve(new Blob([this._bodyText]))},this.arrayBuffer=function(){return this.blob().then(a)},this.text=function(){var t=s(this);if(t)return t;if(this._bodyBlob)return h(this._bodyBlob);if(this._bodyFormData)throw new Error("could not read FormData body as text");return Promise.resolve(this._bodyText)}):this.text=function(){var t=s(this);return t?t:Promise.resolve(this._bodyText)},y.formData&&(this.formData=function(){return this.text().then(p)}),this.json=function(){return this.text().then(JSON.parse)},this}function f(t){var e=t.toUpperCase();return b.indexOf(e)>-1?e:t}function d(t,e){e=e||{};var r=e.body;if(d.prototype.isPrototypeOf(t)){if(t.bodyUsed)throw new TypeError("Already read");this.url=t.url,this.credentials=t.credentials,e.headers||(this.headers=new n(t.headers)),this.method=t.method,this.mode=t.mode,r||(r=t._bodyInit,t.bodyUsed=!0)}else this.url=t;if(this.credentials=e.credentials||this.credentials||"omit",!e.headers&&this.headers||(this.headers=new n(e.headers)),this.method=f(e.method||this.method||"GET"),this.mode=e.mode||this.mode||null,this.referrer=null,("GET"===this.method||"HEAD"===this.method)&&r)throw new TypeError("Body not allowed for GET or HEAD requests");this._initBody(r)}function p(t){var e=new FormData;return t.trim().split("&").forEach(function(t){if(t){var r=t.split("="),o=r.shift().replace(/\+/g," "),n=r.join("=").replace(/\+/g," ");e.append(decodeURIComponent(o),decodeURIComponent(n))}}),e}function c(t){var e=new n,r=(t.getAllResponseHeaders()||"").trim().split("\n");return r.forEach(function(t){var r=t.trim().split(":"),o=r.shift().trim(),n=r.join(":").trim();e.append(o,n)}),e}function l(t,e){e||(e={}),this.type="default",this.status=e.status,this.ok=this.status>=200&&this.status<300,this.statusText=e.statusText,this.headers=e.headers instanceof n?e.headers:new n(e.headers),this.url=e.url||"",this._initBody(t)}if(!t.fetch){var y={searchParams:"URLSearchParams"in t,iterable:"Symbol"in t&&"iterator"in Symbol,blob:"FileReader"in t&&"Blob"in t&&function(){try{return new Blob,!0}catch(t){return!1}}(),formData:"FormData"in t,arrayBuffer:"ArrayBuffer"in t};n.prototype.append=function(t,o){t=e(t),o=r(o);var n=this.map[t];n||(n=[],this.map[t]=n),n.push(o)},n.prototype["delete"]=function(t){delete this.map[e(t)]},n.prototype.get=function(t){var r=this.map[e(t)];return r?r[0]:null},n.prototype.getAll=function(t){return this.map[e(t)]||[]},n.prototype.has=function(t){return this.map.hasOwnProperty(e(t))},n.prototype.set=function(t,o){this.map[e(t)]=[r(o)]},n.prototype.forEach=function(t,e){Object.getOwnPropertyNames(this.map).forEach(function(r){this.map[r].forEach(function(o){t.call(e,o,r,this)},this)},this)},n.prototype.keys=function(){var t=[];return this.forEach(function(e,r){t.push(r)}),o(t)},n.prototype.values=function(){var t=[];return this.forEach(function(e){t.push(e)}),o(t)},n.prototype.entries=function(){var t=[];return this.forEach(function(e,r){t.push([r,e])}),o(t)},y.iterable&&(n.prototype[Symbol.iterator]=n.prototype.entries);var b=["DELETE","GET","HEAD","OPTIONS","POST","PUT"];d.prototype.clone=function(){return new d(this)},u.call(d.prototype),u.call(l.prototype),l.prototype.clone=function(){return new l(this._bodyInit,{status:this.status,statusText:this.statusText,headers:new n(this.headers),url:this.url})},l.error=function(){var t=new l(null,{status:0,statusText:""});return t.type="error",t};var m=[301,302,303,307,308];l.redirect=function(t,e){if(-1===m.indexOf(e))throw new RangeError("Invalid status code");return new l(null,{status:e,headers:{location:t}})},t.Headers=n,t.Request=d,t.Response=l,t.fetch=function(t,e){return new Promise(function(r,o){function n(){return"responseURL"in i?i.responseURL:/^X-Request-URL:/m.test(i.getAllResponseHeaders())?i.getResponseHeader("X-Request-URL"):void 0}var s;s=d.prototype.isPrototypeOf(t)&&!e?t:new d(t,e);var i=new XMLHttpRequest;i.onload=function(){var t={status:i.status,statusText:i.statusText,headers:c(i),url:n()},e="response"in i?i.response:i.responseText;r(new l(e,t))},i.onerror=function(){o(new TypeError("Network request failed"))},i.ontimeout=function(){o(new TypeError("Network request failed"))},i.open(s.method,s.url,!0),"include"===s.credentials&&(i.withCredentials=!0),"responseType"in i&&y.blob&&(i.responseType="blob"),s.headers.forEach(function(t,e){i.setRequestHeader(e,t)}),i.send("undefined"==typeof s._bodyInit?null:s._bodyInit)})},t.fetch.polyfill=!0}}("undefined"!=typeof self?self:this); 2 | //# sourceMappingURL=./fetch.min.js.map -------------------------------------------------------------------------------- /script/cloud/examples/coreos-onmetal-io1.yml: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | 3 | write_files: 4 | - path: /etc/sysctl.d/nmi_watchdog.conf 5 | permissions: 0644 6 | content: | 7 | kernel.nmi_watchdog=0 8 | 9 | - path: /root/bin/add_raid_uuid_to_fstab.sh 10 | permissions: 0755 11 | content: | 12 | #!/bin/bash 13 | UUID=`lsblk -o NAME,TYPE,UUID | grep raid0 | awk '{print $3}' | head -n1` 14 | grep -q $UUID /etc/fstab && echo "Device already in fstab" && exit 0 15 | echo "UUID=$UUID /var/lib/docker btrfs defaults 0 1" >> /etc/fstab 16 | mkdir -p /var/lib/docker 17 | mount /var/lib/docker 18 | chown root:root /var/lib/docker 19 | chmod 700 /var/lib/docker 20 | mkdir -p /var/lib/docker/nbindexing 21 | chmod 777 /var/lib/docker/nbindexing 22 | 23 | - path: /root/bin/lsi_device_paths.sh 24 | permissions: 0755 25 | content: | 26 | #!/bin/bash 27 | lsblk -i -o KNAME,MODEL | grep NWD-BLP4-1600 | awk '{print $1}' 28 | 29 | - path: /root/bin/lsi_settings.sh 30 | permissions: 0755 31 | content: | 32 | #!/bin/bash 33 | for blockdev in `/root/bin/lsi_device_paths.sh`; do 34 | echo "Applying SSD settings to ${blockdev}" 35 | echo noop | tee /sys/block/${blockdev}/queue/scheduler 36 | echo 4096 | tee /sys/block/${blockdev}/queue/nr_requests 37 | echo 1024 | tee /sys/block/${blockdev}/queue/max_sectors_kb 38 | echo 1 | tee /sys/block/${blockdev}/queue/nomerges 39 | echo 512 | tee /sys/block/${blockdev}/device/queue_depth 40 | done 41 | 42 | - path: /root/bin/lsi_format.sh 43 | permissions: 0755 44 | content: | 45 | #!/bin/bash -e 46 | 47 | fail() { 48 | echo $@ 49 | exit 1 50 | } 51 | 52 | # Machine ID is "free" from systemd. This also is bonus protection 53 | # against someone running this outside of systemd and breaking their machine. 54 | MACHINE_ID=${1} 55 | [ -z "$MACHINE_ID" ] && fail "error; machine ID should be passed in" 56 | 57 | GFILE="/etc/${MACHINE_ID}.raid-setup" 58 | [ -e "${GFILE}" ] && echo "${GFILE} exists, raid already setup?" && exit 0 59 | 60 | [ -b "/dev/md0" ] && mdadm --stop /dev/md0 61 | 62 | BLOCKS="" 63 | for blockdev in `/root/bin/lsi_device_paths.sh`; do 64 | BLOCKS="${BLOCKS} /dev/${blockdev}" 65 | done 66 | 67 | yes | mdadm --create --verbose -f /dev/md0 --level=stripe --raid-devices=2 ${BLOCKS} 68 | mkfs.btrfs /dev/md0 69 | touch /etc/${MACHINE_ID}.raid-setup 70 | 71 | #- path: /usr/share/ssh/sshd_config 72 | # permissions: 0644 73 | # content: | 74 | # UsePrivilegeSeparation sandbox 75 | # Subsystem sftp internal-sftp 76 | # PasswordAuthentication no 77 | 78 | 79 | coreos: 80 | update: 81 | # No cluster, will have to do reboots manually 82 | reboot-strategy: off 83 | 84 | units: 85 | - name: systemd-sysctl.service 86 | command: restart 87 | 88 | - name: lsi-settings.service 89 | command: start 90 | content: | 91 | [Unit] 92 | Description=Configure performance settings for LSI cards 93 | 94 | [Service] 95 | Type=oneshot 96 | RemainAfterExit=yes 97 | ExecStart=/root/bin/lsi_settings.sh 98 | 99 | - name: lsi-initial-setup.service 100 | command: start 101 | content: | 102 | [Unit] 103 | Description=Format and raid LSI cards if not already done 104 | After=lsi-settings.service 105 | 106 | [Service] 107 | Type=oneshot 108 | RemainAfterExit=yes 109 | ExecStart=/root/bin/lsi_format.sh %m 110 | 111 | - name: lsi-docker-mount.service 112 | command: start 113 | content: | 114 | [Unit] 115 | Description=Add mounts for lsi cards into /var/lib/docker 116 | Before=docker.service 117 | After=lsi-initial-setup.service 118 | 119 | [Service] 120 | Type=oneshot 121 | RemainAfterExit=yes 122 | ExecStart=/root/bin/add_raid_uuid_to_fstab.sh 123 | 124 | - name: docker.service 125 | command: start 126 | content: | 127 | [Unit] 128 | Description=Docker Application Container Engine 129 | Documentation=http://docs.docker.io 130 | Requires=docker.socket 131 | 132 | [Service] 133 | Environment="TMPDIR=/var/tmp/" 134 | ExecStartPre=/bin/mount --make-rprivate / 135 | LimitNOFILE=1048576 136 | LimitNPROC=1048576 137 | # Run docker but don't have docker automatically restart 138 | # containers. This is a job for systemd and unit files. 139 | # Turn off inter-container communication (--icc=false) so the containers 140 | # can't communicate over etcd or with each other. 141 | ExecStart=/usr/bin/docker -d -s=btrfs -r=false --icc=false -H fd:// 142 | 143 | [Install] 144 | WantedBy=multi-user.target 145 | 146 | - name: configproxy.service 147 | enable: true 148 | command: start 149 | content: | 150 | [Unit] 151 | Description=ConfigProxy 152 | After=docker.service 153 | Requires=docker.service 154 | [Service] 155 | Restart=always 156 | ExecStartPre=/usr/bin/docker pull jupyter/configurable-http-proxy 157 | ExecStart=/usr/bin/docker run --net=host --name configproxy -e CONFIGPROXY_AUTH_TOKEN=HOKEYTOKEN jupyter/configurable-http-proxy --default-target http://127.0.0.1:9999 158 | ExecStop=/usr/bin/docker rm -f configproxy 159 | [Install] 160 | WantedBy=tmpnb.target 161 | 162 | - name: pull-user-image.service 163 | command: start 164 | content: | 165 | [Unit] 166 | Description=Pull the image for users on tmpnb 167 | After=docker.service 168 | 169 | [Service] 170 | Type=oneshot 171 | ExecStart=/usr/bin/docker pull jupyter/demo 172 | 173 | - name: port-80-redirect-iptables.service 174 | command: start 175 | content: | 176 | [Unit] 177 | Description=Redirect 80 to 8000 178 | After=tmpnb.service 179 | 180 | [Service] 181 | Type=oneshot 182 | ExecStart=/usr/sbin/iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to 8000 183 | 184 | - name: tmpnb.service 185 | enable: true 186 | command: start 187 | content: | 188 | [Unit] 189 | Description=tmpnb 190 | After=configproxy.service 191 | Requires=configproxy.service 192 | [Service] 193 | Restart=always 194 | ExecStartPre=/usr/bin/docker pull jupyter/demo 195 | ExecStartPre=/usr/bin/docker pull jupyter/tmpnb 196 | ExecStart=/usr/bin/docker run --name tmpnb --net=host -e CONFIGPROXY_AUTH_TOKEN=HOKEYTOKEN -v /var/run/docker.sock:/docker.sock jupyter/tmpnb python orchestrate.py --cull-timeout=600 --docker-version=1.13 --pool-size=128 --static-files=/srv/ipython/IPython/html/static/ 197 | ExecStop=/usr/bin/docker rm -f tmpnb 198 | [Install] 199 | WantedBy=tmpnb.target 200 | -------------------------------------------------------------------------------- /static/metricsgraphics.css: -------------------------------------------------------------------------------- 1 | .mg-active-datapoint { 2 | fill: black; 3 | font-size: 0.9rem; 4 | font-weight: 400; 5 | opacity: 0.8; 6 | } 7 | 8 | .mg-area1-color { 9 | fill: #0000ff; 10 | } 11 | 12 | .mg-area2-color { 13 | fill: #05b378; 14 | } 15 | 16 | .mg-area3-color { 17 | fill: #db4437; 18 | } 19 | 20 | .mg-area4-color { 21 | fill: #f8b128; 22 | } 23 | 24 | .mg-area5-color { 25 | fill: #5c5c5c; 26 | } 27 | 28 | .mg-barplot rect.mg-bar { 29 | shape-rendering: auto; 30 | fill: #b6b6fc; 31 | } 32 | 33 | .mg-barplot rect.mg-bar.active { 34 | fill: #9e9efc; 35 | } 36 | 37 | .mg-barplot .mg-bar-prediction { 38 | fill: #5b5b5b; 39 | } 40 | 41 | .mg-barplot .mg-bar-baseline { 42 | stroke: #5b5b5b; 43 | stroke-width: 2; 44 | } 45 | 46 | .mg-baselines line { 47 | opacity: 1; 48 | shape-rendering: auto; 49 | stroke: #b3b2b2; 50 | stroke-width: 1px; 51 | } 52 | 53 | .mg-baselines text { 54 | fill: black; 55 | font-size: 0.7rem; 56 | opacity: 0.5; 57 | stroke: none; 58 | } 59 | 60 | .mg-baselines-small text { 61 | font-size: 0.6rem; 62 | } 63 | 64 | .mg-chart-title .popover { 65 | font-size: 0.95rem; 66 | } 67 | 68 | .mg-chart-title .popover-content { 69 | cursor: auto; 70 | line-height: 17px; 71 | } 72 | 73 | .mg-chart-title .popover.top, 74 | .mg-data-table .popover.top { 75 | margin-top: 0; 76 | } 77 | 78 | .mg-chart-title { 79 | cursor: default; 80 | font-size: 1.8rem; 81 | padding-top: 8px; 82 | text-align: center; 83 | } 84 | 85 | .mg-chart-title .fa { 86 | color: #ccc; 87 | font-size: 1.2rem; 88 | padding-left: 4px; 89 | vertical-align: top; 90 | } 91 | 92 | .mg-chart-title .fa.warning { 93 | font-weight: 300; 94 | } 95 | 96 | .mg-points circle { 97 | opacity: 0.85; 98 | } 99 | 100 | .mg-data-table { 101 | margin-top: 30px; 102 | } 103 | 104 | .mg-data-table thead tr th { 105 | border-bottom: 1px solid darkgray; 106 | cursor: default; 107 | font-size: 1.1rem; 108 | font-weight: normal; 109 | padding: 5px 5px 8px 5px; 110 | text-align: right; 111 | } 112 | 113 | .mg-data-table thead tr th .fa { 114 | color: #ccc; 115 | padding-left: 4px; 116 | } 117 | 118 | .mg-data-table thead tr th .popover { 119 | font-size: 1rem; 120 | font-weight: normal; 121 | } 122 | 123 | .mg-data-table .secondary-title { 124 | color: darkgray; 125 | } 126 | 127 | .mg-data-table tbody tr td { 128 | margin: 2px; 129 | padding: 5px; 130 | vertical-align: top; 131 | } 132 | 133 | .mg-data-table tbody tr td.table-text { 134 | opacity: 0.8; 135 | padding-left: 30px; 136 | } 137 | 138 | .mg-y-axis line.mg-extended-y-ticks { 139 | opacity: 0.4; 140 | } 141 | 142 | .mg-x-axis line.mg-extended-x-ticks { 143 | opacity: 0.4; 144 | } 145 | 146 | .mg-histogram .axis path, 147 | .mg-histogram .axis line { 148 | fill: none; 149 | opacity: 0.7; 150 | shape-rendering: auto; 151 | stroke: #ccc; 152 | } 153 | 154 | .mg-histogram .mg-bar rect { 155 | fill: #b6b6fc; 156 | shape-rendering: auto; 157 | } 158 | 159 | .mg-histogram .mg-bar rect.active { 160 | fill: #9e9efc; 161 | } 162 | 163 | .mg-least-squares-line { 164 | stroke: red; 165 | stroke-width: 1px; 166 | } 167 | 168 | .mg-lowess-line { 169 | fill: none; 170 | stroke: red; 171 | } 172 | 173 | .mg-line1-color { 174 | stroke: #4040e8; 175 | } 176 | 177 | .mg-hover-line1-color { 178 | fill: #4040e8; 179 | } 180 | 181 | .mg-line2-color { 182 | stroke: #05b378; 183 | } 184 | 185 | .mg-hover-line2-color { 186 | fill: #05b378; 187 | } 188 | 189 | .mg-line3-color { 190 | stroke: #db4437; 191 | } 192 | 193 | .mg-hover-line3-color { 194 | fill: #db4437; 195 | } 196 | 197 | .mg-line4-color { 198 | stroke: #f8b128; 199 | } 200 | 201 | .mg-hover-line4-color { 202 | fill: #f8b128; 203 | } 204 | 205 | .mg-line5-color { 206 | stroke: #5c5c5c; 207 | } 208 | 209 | .mg-hover-line5-color { 210 | fill: #5c5c5c; 211 | } 212 | 213 | .mg-line-legend text { 214 | font-size: 0.9rem; 215 | font-weight: 300; 216 | stroke: none; 217 | } 218 | 219 | .mg-line1-legend-color { 220 | color: #4040e8; 221 | fill: #4040e8; 222 | } 223 | 224 | .mg-line2-legend-color { 225 | color: #05b378; 226 | fill: #05b378; 227 | } 228 | 229 | .mg-line3-legend-color { 230 | color: #db4437; 231 | fill: #db4437; 232 | } 233 | 234 | .mg-line4-legend-color { 235 | color: #f8b128; 236 | fill: #f8b128; 237 | } 238 | 239 | .mg-line5-legend-color { 240 | color: #5c5c5c; 241 | fill: #5c5c5c; 242 | } 243 | 244 | .mg-main-area-solid svg .mg-main-area { 245 | fill: #ccccff; 246 | opacity: 1; 247 | } 248 | 249 | .mg-markers line { 250 | opacity: 0.9; 251 | shape-rendering: auto; 252 | stroke: #b3b2b2; 253 | stroke-width: 1px; 254 | } 255 | 256 | .mg-markers text { 257 | fill: black; 258 | font-size: 0.7rem; 259 | opacity: 0.5; 260 | stroke: none; 261 | } 262 | 263 | .mg-missing-text { 264 | opacity: 0.9; 265 | } 266 | 267 | .mg-missing-background { 268 | stroke: blue; 269 | fill: none; 270 | stroke-dasharray: 10,5; 271 | stroke-opacity: 0.05; 272 | stroke-width: 2; 273 | } 274 | 275 | .mg-missing .mg-main-line { 276 | opacity: 0.1; 277 | } 278 | 279 | .mg-missing .mg-main-area { 280 | opacity: 0.03; 281 | } 282 | 283 | path.mg-main-area { 284 | opacity: 0.2; 285 | stroke: none; 286 | } 287 | 288 | path.mg-confidence-band { 289 | fill: #ccc; 290 | opacity: 0.4; 291 | stroke: none; 292 | } 293 | 294 | path.mg-main-line { 295 | fill: none; 296 | opacity: 0.8; 297 | stroke-width: 1.1px; 298 | } 299 | 300 | .mg-points circle { 301 | fill-opacity: 0.4; 302 | stroke-opacity: 1; 303 | } 304 | 305 | circle.mg-points-mono { 306 | fill: #0000ff; 307 | stroke: #0000ff; 308 | } 309 | 310 | /* a selected point in a scatterplot */ 311 | .mg-points circle.selected { 312 | fill-opacity: 1; 313 | stroke-opacity: 1; 314 | } 315 | 316 | .mg-voronoi path { 317 | fill: none; 318 | pointer-events: all; 319 | stroke: none; 320 | stroke-opacity: 0.1; 321 | } 322 | 323 | .mg-x-rug-mono, 324 | .mg-y-rug-mono { 325 | stroke: black; 326 | } 327 | 328 | .mg-x-axis line, 329 | .mg-y-axis line { 330 | opacity: 1; 331 | shape-rendering: auto; 332 | stroke: #b3b2b2; 333 | stroke-width: 1px; 334 | } 335 | 336 | .mg-x-axis text, 337 | .mg-y-axis text, 338 | .mg-histogram .axis text { 339 | fill: black; 340 | font-size: 0.9rem; 341 | opacity: 0.5; 342 | } 343 | 344 | .mg-x-axis .label, 345 | .mg-y-axis .label, 346 | .mg-axis .label { 347 | font-size: 0.8rem; 348 | text-transform: uppercase; 349 | font-weight: 400; 350 | } 351 | 352 | .mg-x-axis-small text, 353 | .mg-y-axis-small text, 354 | .mg-active-datapoint-small { 355 | font-size: 0.6rem; 356 | } 357 | 358 | .mg-x-axis-small .label, 359 | .mg-y-axis-small .label { 360 | font-size: 0.65rem; 361 | } 362 | 363 | .mg-year-marker text { 364 | fill: black; 365 | font-size: 0.7rem; 366 | opacity: 0.5; 367 | } 368 | 369 | .mg-year-marker line { 370 | opacity: 1; 371 | shape-rendering: auto; 372 | stroke: #b3b2b2; 373 | stroke-width: 1px; 374 | } 375 | 376 | .mg-year-marker-small text { 377 | font-size: 0.6rem; 378 | } 379 | -------------------------------------------------------------------------------- /dockworker.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | from collections import namedtuple 3 | from concurrent.futures import ThreadPoolExecutor 4 | import os 5 | 6 | import docker 7 | import requests 8 | 9 | from docker.utils import kwargs_from_env 10 | 11 | from tornado import gen 12 | from tornado.log import app_log 13 | 14 | ContainerConfig = namedtuple('ContainerConfig', [ 15 | 'image', 'command', 'mem_limit', 'cpu_quota', 'cpu_shares', 'container_ip', 16 | 'container_port', 'container_user', 'host_network', 'host_directories', 17 | 'extra_hosts', 'docker_network', 'use_tokens', 18 | ]) 19 | 20 | # Number of times to retry API calls before giving up. 21 | RETRIES = 5 22 | 23 | 24 | class AsyncDockerClient(): 25 | '''Completely ridiculous wrapper for a Docker client that returns futures 26 | on every single docker method called on it, configured with an executor. 27 | If no executor is passed, it defaults ThreadPoolExecutor(max_workers=2). 28 | ''' 29 | def __init__(self, docker_client, executor=None): 30 | if executor is None: 31 | executor = ThreadPoolExecutor(max_workers=2) 32 | self._docker_client = docker_client 33 | self.executor = executor 34 | 35 | def __getattr__(self, name): 36 | '''Creates a function, based on docker_client.name that returns a 37 | Future. If name is not a callable, returns the attribute directly. 38 | ''' 39 | fn = getattr(self._docker_client, name) 40 | 41 | # Make sure it really is a function first 42 | if not callable(fn): 43 | return fn 44 | 45 | def method(*args, **kwargs): 46 | return self.executor.submit(fn, *args, **kwargs) 47 | 48 | return method 49 | 50 | 51 | class DockerSpawner(): 52 | def __init__(self, 53 | docker_host='unix://var/run/docker.sock', 54 | version='auto', 55 | timeout=30, 56 | max_workers=64, 57 | assert_hostname=False, 58 | ): 59 | 60 | #kwargs = kwargs_from_env(assert_hostname=False) 61 | kwargs = kwargs_from_env(assert_hostname=assert_hostname) 62 | 63 | # environment variable DOCKER_HOST takes precedence 64 | kwargs.setdefault('base_url', docker_host) 65 | 66 | blocking_docker_client = docker.Client(version=version, 67 | timeout=timeout, 68 | **kwargs) 69 | 70 | executor = ThreadPoolExecutor(max_workers=max_workers) 71 | 72 | async_docker_client = AsyncDockerClient(blocking_docker_client, 73 | executor) 74 | self.docker_client = async_docker_client 75 | 76 | self.port = 0 77 | 78 | @gen.coroutine 79 | def create_notebook_server(self, base_path, container_name, container_config): 80 | '''Creates a notebook_server running off of `base_path`. 81 | 82 | Returns the (container_id, ip, port) tuple in a Future.''' 83 | 84 | if container_config.host_network or container_config.docker_network: 85 | # Start with specified container port 86 | if self.port == 0: 87 | self.port = int(container_config.container_port) 88 | port = self.port 89 | self.port += 1 90 | # No bindings when using the host network or internal docker network 91 | port_bindings = None 92 | else: 93 | # Bind the specified within-container port to a random port 94 | # on the container-host IP address 95 | port = container_config.container_port 96 | port_bindings = { 97 | container_config.container_port: (container_config.container_ip,) 98 | } 99 | 100 | app_log.debug(container_config) 101 | 102 | # Assumes that the container_config.command is of a format like: 103 | # 104 | # ipython notebook --no-browser --port {port} --ip=0.0.0.0 105 | # --NotebookApp.base_path={base_path} 106 | # --NotebookApp.tornado_settings=\"{ \"template_path\": [ \"/srv/ga\", 107 | # \"/srv/ipython/IPython/html\", 108 | # \"/srv/ipython/IPython/html/templates\" ] }\"" 109 | # 110 | # Important piece here is the parametrized base_path to let the 111 | # underlying process know where the proxy is routing it. 112 | if container_config.use_tokens: 113 | # Generate token for authenticating first request (requires notebook 4.3) 114 | # making each server semi-private for the user who is first assigned. 115 | token = binascii.hexlify(os.urandom(24)).decode('ascii') 116 | else: 117 | token = '' 118 | rendered_command = container_config.command.format(base_path=base_path, port=port, 119 | ip=container_config.container_ip, token=token) 120 | 121 | command = [ 122 | "/bin/sh", 123 | "-c", 124 | rendered_command 125 | ] 126 | 127 | volume_bindings = {} 128 | volumes = [] 129 | if container_config.host_directories: 130 | directories = container_config.host_directories.split(",") 131 | for index, item in enumerate(directories): 132 | directory = item.split(":")[0] 133 | try: 134 | mount_path = item.split(":")[1] 135 | if not mount_path: # /host/dir::ro 136 | raise IndexError 137 | except IndexError: 138 | mount_path = '/mnt/vol' + str(index) 139 | try: 140 | permissions = item.split(":")[2] 141 | if not permissions: 142 | raise IndexError 143 | except IndexError: 144 | permissions = 'rw' 145 | 146 | volumes.append(mount_path) 147 | volume_bindings[directory] = { 148 | 'bind': mount_path, 149 | 'mode': permissions 150 | } 151 | 152 | extra_hosts = dict(map(lambda h: tuple(h.split(':')), 153 | container_config.extra_hosts)) 154 | 155 | host_config = dict( 156 | mem_limit=container_config.mem_limit, 157 | network_mode='host' if container_config.host_network else 'bridge', 158 | binds=volume_bindings, 159 | port_bindings=port_bindings, 160 | extra_hosts=extra_hosts, 161 | cpu_quota=container_config.cpu_quota, 162 | ) 163 | 164 | host_config = docker.Client.create_host_config(self.docker_client, 165 | **host_config) 166 | 167 | cpu_shares = None 168 | 169 | if container_config.cpu_shares: 170 | # Some versions of Docker and docker-py won't cast from string to int 171 | cpu_shares = int(container_config.cpu_shares) 172 | 173 | 174 | resp = yield self._with_retries(self.docker_client.create_container, 175 | image=container_config.image, 176 | user=container_config.container_user, 177 | command=command, 178 | volumes=volumes, 179 | host_config=host_config, 180 | cpu_shares=cpu_shares, 181 | name=container_name) 182 | 183 | docker_warnings = resp.get('Warnings') 184 | if docker_warnings is not None: 185 | app_log.warning(docker_warnings) 186 | 187 | container_id = resp['Id'] 188 | app_log.info("Created container {}".format(container_id)) 189 | 190 | if container_config.docker_network: 191 | yield self._with_retries(self.docker_client.connect_container_to_network, 192 | container_id, 193 | container_config.docker_network, 194 | ) 195 | 196 | yield self._with_retries(self.docker_client.start, 197 | container_id) 198 | 199 | if container_config.host_network: 200 | host_port = port 201 | host_ip = container_config.container_ip 202 | elif container_config.docker_network: 203 | container_info = yield self._with_retries(self.docker_client.inspect_container, container_id) 204 | host_port = port 205 | # get ip of container on the specified docker network 206 | host_ip = container_info['NetworkSettings']['Networks'][container_config.docker_network]['IPAddress'] 207 | else: 208 | container_network = yield self._with_retries(self.docker_client.port, 209 | container_id, 210 | container_config.container_port) 211 | 212 | host_port = container_network[0]['HostPort'] 213 | host_ip = container_network[0]['HostIp'] 214 | 215 | raise gen.Return((container_id, host_ip, int(host_port), token)) 216 | 217 | @gen.coroutine 218 | def shutdown_notebook_server(self, container_id, alive=True): 219 | '''Gracefully stop a running container.''' 220 | 221 | if alive: 222 | yield self._with_retries(self.docker_client.stop, container_id) 223 | yield self._with_retries(self.docker_client.remove_container, container_id) 224 | 225 | @gen.coroutine 226 | def list_notebook_servers(self, pool_regex, all=True): 227 | '''List containers that are managed by a specific pool.''' 228 | 229 | existing = yield self._with_retries(self.docker_client.containers, 230 | all=all, 231 | trunc=False) 232 | 233 | def name_matches(container): 234 | try: 235 | names = container['Names'] 236 | if names is None: 237 | app_log.warn("Docker API returned null Names, ignoring") 238 | return False 239 | except Exception: 240 | app_log.warn("Invalid container: %r", container) 241 | return False 242 | for name in names: 243 | if pool_regex.search(name): 244 | return True 245 | return False 246 | 247 | matching = [container for container in existing if name_matches(container)] 248 | raise gen.Return(matching) 249 | 250 | @gen.coroutine 251 | def _with_retries(self, fn, *args, **kwargs): 252 | '''Attempt a Docker API call. 253 | 254 | If an error occurs, retry up to "max_tries" times before letting the exception propagate 255 | up the stack.''' 256 | 257 | max_tries = kwargs.get('max_tries', RETRIES) 258 | try: 259 | if 'max_tries' in kwargs: 260 | del kwargs['max_tries'] 261 | result = yield fn(*args, **kwargs) 262 | raise gen.Return(result) 263 | except (docker.errors.APIError, requests.exceptions.RequestException) as e: 264 | app_log.error("Encountered a Docker error with {} ({} retries remain): {}".format(fn.__name__, max_tries, e)) 265 | if max_tries > 0: 266 | kwargs['max_tries'] = max_tries - 1 267 | result = yield self._with_retries(fn, *args, **kwargs) 268 | raise gen.Return(result) 269 | else: 270 | raise e 271 | 272 | @gen.coroutine 273 | def copy_files(self, container_id, path): 274 | '''Returns a tarball of path from container_id''' 275 | tarball = yield self.docker_client.copy(container_id, path) 276 | raise gen.Return(tarball) 277 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## tmpnb, the temporary notebook service 2 | 3 | Launches "temporary" Jupyter notebook servers. 4 | 5 | **WARNING: tmpnb is no longer actively maintained.** 6 | 7 | We recommend you switch to using JupyterHub. 8 | 9 | ### Configuration option 1: 10 | 11 | * [JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) with [tmpauthenticator](https://github.com/jupyterhub/tmpauthenticator) 12 | 13 | ### Configuration option 2: 14 | 15 | * [BinderHub](https://binderhub.readthedocs.io/en/latest/) 16 | 17 | ---------------- 18 | 19 | ![tmpnb architecture](https://cloud.githubusercontent.com/assets/836375/5911140/c53e3978-a587-11e4-86a5-695469ef23a5.png) 20 | 21 | tmpnb launches a docker container for each user that requests one. In practice, this gets used to [provide temporary notebooks](https://tmpnb.org), demo the IPython notebook as part of [a Nature article](http://www.nature.com/news/interactive-notebooks-sharing-the-code-1.16261), or even [provide Jupyter kernels for publications](http://odewahn.github.io/publishing-workflows-for-jupyter/#1). 22 | 23 | People have used it at user groups, meetups, and workshops to provide temporary access to a full system without any installation whatsoever. 24 | 25 | #### Quick start 26 | 27 | Get Docker, then: 28 | 29 | ``` 30 | docker pull jupyter/minimal-notebook 31 | export TOKEN=$( head -c 30 /dev/urandom | xxd -p ) 32 | docker run --net=host -d -e CONFIGPROXY_AUTH_TOKEN=$TOKEN --name=proxy jupyter/configurable-http-proxy --default-target http://127.0.0.1:9999 33 | docker run --net=host -d -e CONFIGPROXY_AUTH_TOKEN=$TOKEN --name=tmpnb -v /var/run/docker.sock:/docker.sock jupyter/tmpnb python orchestrate.py --container-user=jovyan --command='jupyter notebook --no-browser --port {port} --ip=0.0.0.0 --NotebookApp.base_url={base_path} --NotebookApp.port_retries=0 --NotebookApp.token="" --NotebookApp.disable_check_xsrf=True' 34 | ``` 35 | NOTE! This will disable Jupyter Notebook's token security. The option for `container-user` assures that the notebook will not run as a privileged user. You can set `--NotebookApp.token` to a string if you want to add a minimal layer of security. 36 | 37 | BAM! Visit your Docker host on port 8000 and you have a working tmpnb setup. The ` -v /var/run/docker.sock:/docker.sock` bit causes the orchestrator container to mount the docker client, which allows the orchestrator container to spawn docker containers on the host (see [this article](http://nathanleclaire.com/blog/2014/07/12/10-docker-tips-and-tricks-that-will-make-you-sing-a-whale-song-of-joy/#bind-mount-the-docker-socket-on-docker-run:1765430f0793020845eca6c8326a4e45) for more information). 38 | 39 | If you are running docker using docker-machine, as is now the standard, get the IP address of your Docker host by running `docker-machine ls`. If you are using boot2docker, then you can find your docker host's ip address by running the following command in your console: `boot2docker ip` 40 | 41 | If it didn't come up, try running `docker ps -a` and `docker logs tmpnb` to help diagnose issues. 42 | 43 | Alternatively, you can choose to setup a docker-compose.yml file: 44 | ``` 45 | httpproxy: 46 | image: jupyter/configurable-http-proxy 47 | environment: 48 | CONFIGPROXY_AUTH_TOKEN: 716238957362948752139417234 49 | container_name: tmpnb-proxy 50 | net: "host" 51 | command: --default-target http://127.0.0.1:9999 52 | ports: 53 | - 8000:8000 54 | 55 | tmpnb_orchestrate: 56 | image: jupyter/tmpnb 57 | net: "host" 58 | container_name: tmpnb_orchestrate 59 | environment: 60 | CONFIGPROXY_AUTH_TOKEN: 716238957362948752139417234 61 | volumes: 62 | - /var/run/docker.sock:/docker.sock 63 | command: python orchestrate.py --command='jupyter notebook --no-browser --port {port} --ip=0.0.0.0 --NotebookApp.base_url={base_path} --NotebookApp.port_retries=0 --NotebookApp.token="" --NotebookApp.disable_check_xsrf=True' 64 | ``` 65 | 66 | Then, you can launch the container with `docker-compose up`, no building is required since they pull directly from images. 67 | 68 | #### Advanced configuration 69 | 70 | If you need to set the `docker-version` or other options, they can be passed to `jupyter/tmpnb` directly: 71 | 72 | ``` 73 | docker run --net=host -d -e CONFIGPROXY_AUTH_TOKEN=$TOKEN -v /var/run/docker.sock:/docker.sock jupyter/tmpnb python orchestrate.py --cull-timeout=60 --docker-version="1.13" --command="jupyter notebook --NotebookApp.base_url={base_path} --ip=0.0.0.0 --port {port}" 74 | ``` 75 | 76 | Note that if you do not pass a value to `docker-version`, tmpnb will automatically use the Docker API version provided by the server. 77 | 78 | The tmpnb server has two APIs: a public one that receives HTTP requests under the `/` proxy route and an administrative one available only on the private, localhost interface by default. You can configure the interfaces (`--ip`, `--admin_ip`) and ports (`--port`, `--admin_port`) of both APIs using command line arguments. 79 | 80 | If you decide to expose the admin API on a public interface, you can protect it by specifying a secret token in the environment variable `ADMIN_AUTH_TOKEN` when starting the `tmpnb` container. Thereafter, all requests made to the admin API must include it in an HTTP header like so: 81 | 82 | ``` 83 | Authorization: token 84 | ``` 85 | 86 | Likewise, if you only want to allow programmatic access to your tmpnb cluster by select clients, you can specify a separate secret token in the environment variable `API_AUTH_TOKEN` when starting the `tmpnb` container. All requests made to the public API must include it in an HTTP header in the same manner as depicted for the admin token above. Note that when this token is set, only the `/api/*` resources of the tmpnb server are available. All human-facing paths are disabled. 87 | 88 | If you want to see the resources available in both the admin and user APIs, look at the handler paths registered in the `orchestrate.py` file. You should consider both APIs to be unstable. 89 | 90 | #### Launching with *your own* Docker images 91 | 92 | tmpnb can run any Docker container provided by the `--image` option, so long as the `--command` option tells where the `{base_path}` and `{port}`. Those are literal strings, complete with curly braces that tmpnb will replace with an assigned `base_path` and `port`. 93 | 94 | ``` 95 | docker run --net=host -d -e CONFIGPROXY_AUTH_TOKEN=$TOKEN \ 96 | -v /var/run/docker.sock:/docker.sock \ 97 | jupyter/tmpnb python orchestrate.py --image='jupyter/demo' --command="jupyter notebook --NotebookApp.base_url={base_path} --ip=0.0.0.0 --port {port}" 98 | ``` 99 | 100 | #### Using [jupyter/docker-stacks](https://github.com/jupyter/docker-stacks) images 101 | 102 | When using the latest [jupyter/docker-stacks](https://github.com/jupyter/docker-stacks) images with tmpnb, you can use the `start-notebook.sh` script or invoke the `jupyter notebook` command directly to run your notebook servers as user `jovyan`. Substitute your desired docker-stacks image name in the command below. 103 | 104 | ```bash 105 | docker run -d \ 106 | --net=host \ 107 | -e CONFIGPROXY_AUTH_TOKEN=$TOKEN \ 108 | -v /var/run/docker.sock:/docker.sock \ 109 | jupyter/tmpnb \ 110 | python orchestrate.py --image='jupyter/minimal-notebook' \ 111 | --command='start-notebook.sh \ 112 | "--NotebookApp.base_url={base_path} \ 113 | --ip=0.0.0.0 \ 114 | --port={port} \ 115 | --NotebookApp.trust_xheaders=True"' 116 | ``` 117 | 118 | #### Options 119 | 120 | ``` 121 | Usage: orchestrate.py [OPTIONS] 122 | 123 | Options: 124 | 125 | --help show this help information 126 | 127 | tornado/log.py options: 128 | 129 | --log-file-max-size max size of log files before rollover 130 | (default 100000000) 131 | --log-file-num-backups number of log files to keep (default 10) 132 | --log-file-prefix=PATH Path prefix for log files. Note that if you 133 | are running multiple tornado processes, 134 | log_file_prefix must be different for each 135 | of them (e.g. include the port number) 136 | --log-rotate-interval The interval value of timed rotating 137 | (default 1) 138 | --log-rotate-mode The mode of rotating files(time or size) 139 | (default size) 140 | --log-rotate-when specify the type of TimedRotatingFileHandler 141 | interval other options:('S', 'M', 'H', 'D', 142 | 'W0'-'W6') (default midnight) 143 | --log-to-stderr Send log output to stderr (colorized if 144 | possible). By default use stderr if 145 | --log_file_prefix is not set and no other 146 | logging is configured. 147 | --logging=debug|info|warning|error|none 148 | Set the Python log level. If 'none', tornado 149 | won't touch the logging configuration. 150 | (default info) 151 | 152 | orchestrate.py options: 153 | 154 | --admin-ip ip for the admin server to listen on 155 | [default: 127.0.0.1] (default 127.0.0.1) 156 | --admin-port port for the admin server to listen on 157 | (default 10000) 158 | --allow-credentials Sets the Access-Control-Allow-Credentials 159 | header. 160 | --allow-headers Sets the Access-Control-Allow-Headers 161 | header. 162 | --allow-methods Sets the Access-Control-Allow-Methods 163 | header. 164 | --allow-origin Set the Access-Control-Allow-Origin header. 165 | Use '*' to allow any origin to access. 166 | --assert-hostname Verify hostname of Docker daemon. (default 167 | False) 168 | --command Command to run when booting the image. A 169 | placeholder for {base_path} should be 170 | provided. A placeholder for {port} and {ip} 171 | can be provided. (default jupyter notebook 172 | --no-browser --port {port} --ip=0.0.0.0 173 | --NotebookApp.base_url={base_path} 174 | --NotebookApp.port_retries=0) 175 | --container-ip Host IP address for containers to bind to. 176 | If host_network=True, the host IP address 177 | for notebook servers to bind to. (default 178 | 127.0.0.1) 179 | --container-port Within container port for notebook servers 180 | to bind to. If host_network=True, the 181 | starting port assigned to notebook servers 182 | on the host. (default 8888) 183 | --container-user User to run container command as 184 | --cpu-quota Limit CPU quota, per container. 185 | Units are CPU-µs per 100ms, so 1 186 | CPU/container would be: 187 | --cpu-quota=100000 188 | --cpu-shares Limit CPU shares, per container 189 | --cull-max Maximum age of a container (s), regardless 190 | of activity. Default: 14400 191 | (4 hours) A container that 192 | has been running for this long will be 193 | culled, even if it is not idle. 194 | (default 14400) 195 | --cull-period Interval (s) for culling idle containers. 196 | (default 600) 197 | --cull-timeout Timeout (s) for culling idle containers. 198 | (default 3600) 199 | --docker-version Version of the Docker API to use (default 200 | auto) 201 | --expose-headers Sets the Access-Control-Expose-Headers 202 | header. 203 | --extra-hosts Extra hosts for the containers, multiple 204 | hosts can be specified by using a 205 | comma-delimited string, specified in the 206 | form hostname:IP (default []) 207 | --host-directories Mount the specified directory as a data 208 | volume, multiple directories can be 209 | specified by using a comma-delimited string, 210 | directory path must provided in full 211 | (eg: /home/steve/data/:r), permissions 212 | default to rw 213 | --host-network Attaches the containers to the host 214 | networking instead of the default docker 215 | bridge. Affects the semantics of 216 | container_port and container_ip. (default 217 | False) 218 | --image Docker container to spawn for new users. 219 | Must be on the system already (default 220 | jupyter/minimal-notebook) 221 | --ip ip for the main server to listen on 222 | [default: all interfaces] 223 | --max-age Sets the Access-Control-Max-Age header. 224 | --max-dock-workers Maximum number of docker workers (default 2) 225 | --mem-limit Limit on Memory, per container (default 226 | 512m) 227 | --pool-name Container name fragment used to identity 228 | containers that belong to this instance. 229 | --pool-size Capacity for containers on this system. Will 230 | be prelaunched at startup. (default 10) 231 | --port port for the main server to listen on 232 | (default 9999) 233 | --redirect-uri URI to redirect users to upon initial 234 | notebook launch (default /tree) 235 | --static-files Static files to extract from the initial 236 | container launch 237 | --user-length Length of the unique /user/:id path 238 | generated per container (default 12) 239 | ``` 240 | 241 | #### Development 242 | 243 | *WARNING* The `Makefile` used in the commands below assume your 244 | containers can be deleted. Please work on an isolated machine and read 245 | the `cleanup` target in the `Makefile` prior to executing. 246 | 247 | ``` 248 | git clone https://github.com/jupyter/tmpnb.git 249 | cd tmpnb 250 | 251 | # Kick off the proxy and run the server. 252 | # Runs on all interfaces on port 8000 by default. 253 | # NOTE: stops and deletes all containers 254 | make dev 255 | ``` 256 | 257 | #### Troubleshooting 258 | 259 | If you are receiving 500 errors after changing the proxy port, make sure 260 | that you are using the correct internal port (proxy port+1 unless you 261 | specify it otherwise). 262 | -------------------------------------------------------------------------------- /orchestrate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import datetime 5 | import os 6 | import re 7 | from textwrap import dedent 8 | import uuid 9 | 10 | import tornado 11 | import tornado.options 12 | from tornado.httpserver import HTTPServer 13 | from tornado.httputil import url_concat 14 | from tornado.log import app_log 15 | from tornado.web import RequestHandler, HTTPError, RedirectHandler 16 | 17 | from tornado import gen, web 18 | 19 | import dockworker 20 | import spawnpool 21 | 22 | 23 | class BaseHandler(RequestHandler): 24 | 25 | # REGEX to test the path specifies a user container 26 | user_path_regex = re.compile("^/?user/\w+") 27 | 28 | def is_user_path(self, path): 29 | return path is not None and BaseHandler.user_path_regex.match(path) 30 | 31 | def write_error(self, status_code, **kwargs): 32 | if status_code == 404: 33 | self.render("error/404.html", status_code = status_code) 34 | else: 35 | self.render("error/500.html", status_code = status_code) 36 | 37 | def prepare(self): 38 | if self.allow_origin: 39 | self.set_header("Access-Control-Allow-Origin", self.allow_origin) 40 | if self.expose_headers: 41 | self.set_header("Access-Control-Expose-Headers", self.expose_headers) 42 | if self.max_age: 43 | self.set_header("Access-Control-Max-Age", self.max_age) 44 | if self.allow_credentials: 45 | self.set_header("Access-Control-Allow-Credentials", self.allow_credentials) 46 | if self.allow_methods: 47 | self.set_header("Access-Control-Allow-Methods", self.allow_methods) 48 | if self.allow_headers: 49 | self.set_header("Access-Control-Allow-Headers", self.allow_headers) 50 | 51 | def get_current_user(self): 52 | if self.api_token is None: 53 | return 'authorized' 54 | # Confirm the client authorization token if an api token is configured 55 | client_token = self.request.headers.get('Authorization') 56 | if client_token == 'token %s' % self.api_token: 57 | return 'authorized' 58 | 59 | @property 60 | def allow_origin(self): 61 | return self.settings['allow_origin'] 62 | 63 | @property 64 | def expose_headers(self): 65 | return self.settings['expose_headers'] 66 | 67 | @property 68 | def max_age(self): 69 | return self.settings['max_age'] 70 | 71 | @property 72 | def allow_credentials(self): 73 | return self.settings['allow_credentials'] 74 | 75 | @property 76 | def allow_methods(self): 77 | return self.settings['allow_methods'] 78 | 79 | @property 80 | def allow_headers(self): 81 | return self.settings['allow_headers'] 82 | 83 | @property 84 | def api_token(self): 85 | return self.settings['api_token'] 86 | 87 | def options(self): 88 | '''Respond to options requests''' 89 | self.set_status(204) 90 | self.finish() 91 | 92 | 93 | class LoadingHandler(BaseHandler): 94 | def get(self, path=None): 95 | self.render("loading.html", is_user_path=self.is_user_path(path)) 96 | 97 | 98 | class APIStatsHandler(BaseHandler): 99 | def get(self): 100 | '''Returns some statistics/metadata about the tmpnb server''' 101 | self.set_header("Content-Type", 'application/json') 102 | response = { 103 | 'available': len(self.pool.available), 104 | 'capacity': self.pool.capacity, 105 | 'version': '0.2.0', 106 | 'container_image': self.pool.container_config.image, 107 | } 108 | self.write(response) 109 | 110 | @property 111 | def pool(self): 112 | return self.settings['pool'] 113 | 114 | 115 | class InfoHandler(BaseHandler): 116 | def get(self): 117 | self.render("stats.html") 118 | 119 | @property 120 | def pool(self): 121 | return self.settings['pool'] 122 | 123 | 124 | class SpawnHandler(BaseHandler): 125 | 126 | @gen.coroutine 127 | def get(self, path=None): 128 | '''Spawns a brand new server''' 129 | try: 130 | if self.is_user_path(path): 131 | # Path is trying to get back to a previously existing container 132 | 133 | # Split /user/{some_user}/long/url/path and acquire {some_user} 134 | path_parts = path.lstrip('/').split('/', 2) 135 | user = path_parts[1] 136 | 137 | # Scrap a container from the pool and replace it with an ad-hoc replacement. 138 | # This takes longer, but is necessary to support ad-hoc containers 139 | container = yield self.pool.adhoc(user) 140 | 141 | url = path 142 | else: 143 | # There is no path or it represents a subpath of the notebook server 144 | # Assign a prelaunched container from the pool and redirect to it. 145 | container = self.pool.acquire() 146 | container_path = container.path 147 | app_log.info("Allocated [%s] from the pool.", container_path) 148 | 149 | # If no path is set, append self.redirect_uri to the redirect target, else 150 | # redirect to specified path. 151 | 152 | if path is None: 153 | redirect_path = self.redirect_uri 154 | else: 155 | redirect_path = path.lstrip('/') 156 | 157 | url = "/{}/{}".format(container_path.strip('/'), redirect_path) 158 | 159 | if container.token: 160 | url = url_concat(url, {'token': container.token}) 161 | app_log.info("Redirecting [%s] -> [%s].", self.request.path, url) 162 | self.redirect(url, permanent=False) 163 | except spawnpool.EmptyPoolError: 164 | app_log.warning("The container pool is empty!") 165 | self.render("full.html", cull_period=self.cull_period) 166 | 167 | @property 168 | def pool(self): 169 | return self.settings['pool'] 170 | 171 | @property 172 | def cull_period(self): 173 | return self.settings['cull_period'] 174 | 175 | @property 176 | def redirect_uri(self): 177 | return self.settings['redirect_uri'] 178 | 179 | 180 | class APISpawnHandler(BaseHandler): 181 | 182 | @web.authenticated 183 | @gen.coroutine 184 | def post(self): 185 | '''Spawns a brand new server programmatically''' 186 | try: 187 | container = self.pool.acquire() 188 | url = container.path 189 | if container.token: 190 | url = url_concat(url, {'token': container.token}) 191 | app_log.info("Allocated [%s] from the pool.", url) 192 | app_log.debug("Responding with container url [%s].", url) 193 | self.write({'url': url}) 194 | except spawnpool.EmptyPoolError: 195 | app_log.warning("The container pool is empty!") 196 | self.set_status(429) 197 | self.write({'status': 'full'}) 198 | 199 | @property 200 | def pool(self): 201 | return self.settings['pool'] 202 | 203 | class AdminHandler(RequestHandler): 204 | 205 | def get_current_user(self): 206 | """Check admin API token, if any""" 207 | if not self.admin_token: 208 | return 'authorized' 209 | 210 | client_token = self.request.headers.get('Authorization') 211 | if client_token != 'token %s' % self.admin_token: 212 | app_log.warn('Rejecting admin request with token %s', client_token) 213 | return 214 | else: 215 | return 'authorized' 216 | 217 | @property 218 | def admin_token(self): 219 | return self.settings['admin_token'] 220 | 221 | class APIPoolHandler(AdminHandler): 222 | @web.authenticated 223 | @gen.coroutine 224 | def delete(self): 225 | '''Drains available containers from the pool.''' 226 | n = yield self.pool.drain() 227 | app_log.info('Drained pool of %d containers', n) 228 | self.finish(dict(drained=n)) 229 | 230 | @property 231 | def pool(self): 232 | return self.settings['pool'] 233 | 234 | def main(): 235 | tornado.options.define('cull_period', default=600, 236 | help="Interval (s) for culling idle containers." 237 | ) 238 | tornado.options.define('cull_timeout', default=3600, 239 | help="Timeout (s) for culling idle containers." 240 | ) 241 | tornado.options.define('cull_max', default=14400, 242 | help=dedent(""" 243 | Maximum age of a container (s), regardless of activity. 244 | 245 | Default: 14400 (4 hours) 246 | 247 | A container that has been running for this long will be culled, 248 | even if it is not idle. 249 | """) 250 | ) 251 | tornado.options.define('container_ip', default='127.0.0.1', 252 | help="""Host IP address for containers to bind to. If host_network=True, 253 | the host IP address for notebook servers to bind to.""" 254 | ) 255 | tornado.options.define('container_port', default='8888', 256 | help="""Within container port for notebook servers to bind to. 257 | If host_network=True, the starting port assigned to notebook servers on the host.""" 258 | ) 259 | tornado.options.define('use_tokens', default=False, 260 | help="""Enable token-authentication of notebook servers. 261 | If host_network=True, the starting port assigned to notebook servers on the host.""" 262 | ) 263 | 264 | command_default = ( 265 | 'jupyter notebook --no-browser' 266 | ' --port {port} --ip=0.0.0.0' 267 | ' --NotebookApp.base_url={base_path}' 268 | ' --NotebookApp.port_retries=0' 269 | ' --NotebookApp.token="{token}"' 270 | ) 271 | 272 | tornado.options.define('command', default=command_default, 273 | help="""Command to run when booting the image. A placeholder for 274 | {base_path} should be provided. A placeholder for {port} and {ip} can be provided.""" 275 | ) 276 | tornado.options.define('port', default=9999, 277 | help="port for the main server to listen on" 278 | ) 279 | tornado.options.define('ip', default=None, 280 | help="ip for the main server to listen on [default: all interfaces]" 281 | ) 282 | tornado.options.define('admin_port', default=10000, 283 | help="port for the admin server to listen on" 284 | ) 285 | tornado.options.define('admin_ip', default='127.0.0.1', 286 | help="ip for the admin server to listen on [default: 127.0.0.1]" 287 | ) 288 | tornado.options.define('max_dock_workers', default=2, 289 | help="Maximum number of docker workers" 290 | ) 291 | tornado.options.define('mem_limit', default="512m", 292 | help="Limit on Memory, per container" 293 | ) 294 | tornado.options.define('cpu_shares', default=None, type=int, 295 | help="Limit CPU shares, per container" 296 | ) 297 | tornado.options.define('cpu_quota', default=None, type=int, 298 | help=dedent(""" 299 | Limit CPU quota, per container. 300 | 301 | Units are CPU-µs per 100ms, so 1 CPU/container would be: 302 | 303 | --cpu-quota=100000 304 | 305 | """) 306 | ) 307 | tornado.options.define('image', default="jupyter/minimal-notebook", 308 | help="Docker container to spawn for new users. Must be on the system already" 309 | ) 310 | tornado.options.define('docker_version', default="auto", 311 | help="Version of the Docker API to use" 312 | ) 313 | tornado.options.define('redirect_uri', default="/tree", 314 | help="URI to redirect users to upon initial notebook launch" 315 | ) 316 | tornado.options.define('pool_size', default=10, 317 | help="Capacity for containers on this system. Will be prelaunched at startup." 318 | ) 319 | tornado.options.define('pool_name', default=None, 320 | help="Container name fragment used to identity containers that belong to this instance." 321 | ) 322 | tornado.options.define('static_files', default=None, 323 | help="Static files to extract from the initial container launch" 324 | ) 325 | tornado.options.define('allow_origin', default=None, 326 | help="Set the Access-Control-Allow-Origin header. Use '*' to allow any origin to access." 327 | ) 328 | tornado.options.define('expose_headers', default=None, 329 | help="Sets the Access-Control-Expose-Headers header." 330 | ) 331 | tornado.options.define('max_age', default=None, 332 | help="Sets the Access-Control-Max-Age header." 333 | ) 334 | tornado.options.define('allow_credentials', default=None, 335 | help="Sets the Access-Control-Allow-Credentials header." 336 | ) 337 | tornado.options.define('allow_methods', default=None, 338 | help="Sets the Access-Control-Allow-Methods header." 339 | ) 340 | tornado.options.define('allow_headers', default=None, 341 | help="Sets the Access-Control-Allow-Headers header." 342 | ) 343 | tornado.options.define('assert_hostname', default=False, 344 | help="Verify hostname of Docker daemon." 345 | ) 346 | tornado.options.define('container_user', default=None, 347 | help="User to run container command as" 348 | ) 349 | tornado.options.define('host_network', default=False, 350 | help="""Attaches the containers to the host networking instead of the 351 | default docker bridge. Affects the semantics of container_port and container_ip.""" 352 | ) 353 | tornado.options.define('docker_network', default=None, 354 | help="""Attaches the containers to the specified docker network. 355 | For use when the proxy, tmpnb, and containers are all in docker.""" 356 | ) 357 | tornado.options.define('host_directories', default=None, 358 | help=dedent(""" 359 | Mount the specified directory as a data volume in a specified location 360 | (provide an empty value to use a default mount path), multiple 361 | directories can be specified by using a comma-delimited string, directory 362 | path must provided in full (eg: /home/steve/data/:/usr/data/:ro), permissions default to 363 | rw""")) 364 | tornado.options.define('user_length', default=12, 365 | help="Length of the unique /user/:id path generated per container" 366 | ) 367 | tornado.options.define('extra_hosts', default=[], multiple=True, 368 | help=dedent(""" 369 | Extra hosts for the containers, multiple hosts can be specified 370 | by using a comma-delimited string, specified in the form hostname:IP""")) 371 | 372 | tornado.options.parse_command_line() 373 | opts = tornado.options.options 374 | 375 | api_token = os.getenv('API_AUTH_TOKEN') 376 | admin_token = os.getenv('ADMIN_AUTH_TOKEN') 377 | proxy_token = os.environ['CONFIGPROXY_AUTH_TOKEN'] 378 | proxy_endpoint = os.environ.get('CONFIGPROXY_ENDPOINT', "http://127.0.0.1:8001") 379 | docker_host = os.environ.get('DOCKER_HOST', 'unix://var/run/docker.sock') 380 | 381 | handlers = [ 382 | (r"/api/spawn/?", APISpawnHandler), 383 | (r"/api/stats/?", APIStatsHandler), 384 | (r"/stats/?", RedirectHandler, {"url": "/api/stats"}), 385 | ] 386 | 387 | # Only add human-facing handlers if there's no spawn API key set 388 | if api_token is None: 389 | handlers.extend([ 390 | (r"/", LoadingHandler), 391 | (r"/spawn/?(/user/\w+(?:/.*)?)?", SpawnHandler), 392 | (r"/spawn/((?:notebooks|tree|files)(?:/.*)?)", SpawnHandler), 393 | (r"/(user/\w+)(?:/.*)?", LoadingHandler), 394 | (r"/((?:notebooks|tree|files)(?:/.*)?)", LoadingHandler), 395 | (r"/info/?", InfoHandler), 396 | ]) 397 | 398 | admin_handlers = [ 399 | (r"/api/pool/?", APIPoolHandler) 400 | ] 401 | 402 | max_idle = datetime.timedelta(seconds=opts.cull_timeout) 403 | max_age = datetime.timedelta(seconds=opts.cull_max) 404 | pool_name = opts.pool_name 405 | if pool_name is None: 406 | # Derive a valid container name from the image name by default. 407 | pool_name = re.sub('[^a-zA-Z0_.-]+', '', opts.image.split(':')[0]) 408 | 409 | container_config = dockworker.ContainerConfig( 410 | image=opts.image, 411 | command=opts.command, 412 | use_tokens=opts.use_tokens, 413 | mem_limit=opts.mem_limit, 414 | cpu_quota=opts.cpu_quota, 415 | cpu_shares=opts.cpu_shares, 416 | container_ip=opts.container_ip, 417 | container_port=opts.container_port, 418 | container_user=opts.container_user, 419 | host_network=opts.host_network, 420 | docker_network=opts.docker_network, 421 | host_directories=opts.host_directories, 422 | extra_hosts=opts.extra_hosts, 423 | ) 424 | 425 | spawner = dockworker.DockerSpawner(docker_host, 426 | timeout=30, 427 | version=opts.docker_version, 428 | max_workers=opts.max_dock_workers, 429 | assert_hostname=opts.assert_hostname, 430 | ) 431 | 432 | static_path = os.path.join(os.path.dirname(__file__), "static") 433 | 434 | pool = spawnpool.SpawnPool(proxy_endpoint=proxy_endpoint, 435 | proxy_token=proxy_token, 436 | spawner=spawner, 437 | container_config=container_config, 438 | capacity=opts.pool_size, 439 | max_idle=max_idle, 440 | max_age=max_age, 441 | static_files=opts.static_files, 442 | static_dump_path=static_path, 443 | pool_name=pool_name, 444 | user_length=opts.user_length 445 | ) 446 | 447 | ioloop = tornado.ioloop.IOLoop().current() 448 | 449 | settings = dict( 450 | default_handler_class=BaseHandler, 451 | static_path=static_path, 452 | cookie_secret=uuid.uuid4(), 453 | xsrf_cookies=False, 454 | debug=True, 455 | cull_period=opts.cull_period, 456 | allow_origin=opts.allow_origin, 457 | expose_headers=opts.expose_headers, 458 | max_age=opts.max_age, 459 | allow_credentials=opts.allow_credentials, 460 | allow_methods=opts.allow_methods, 461 | allow_headers=opts.allow_headers, 462 | spawner=spawner, 463 | pool=pool, 464 | autoescape=None, 465 | proxy_token=proxy_token, 466 | api_token=api_token, 467 | template_path=os.path.join(os.path.dirname(__file__), 'templates'), 468 | proxy_endpoint=proxy_endpoint, 469 | redirect_uri=opts.redirect_uri.lstrip('/'), 470 | ) 471 | 472 | admin_settings = dict( 473 | pool=pool, 474 | admin_token=admin_token 475 | ) 476 | 477 | # Cleanup on a fresh state (likely a restart) 478 | ioloop.run_sync(pool.cleanout) 479 | 480 | # Synchronously cull any existing, inactive containers, and pre-launch a set number of 481 | # containers, ready to serve. 482 | ioloop.run_sync(pool.heartbeat) 483 | 484 | if(opts.static_files): 485 | ioloop.run_sync(pool.copy_static) 486 | 487 | # Periodically execute a heartbeat function to cull used containers and regenerated failed 488 | # ones, self-healing the cluster. 489 | cull_ms = opts.cull_period * 1e3 490 | app_log.info("Culling containers unused for %i seconds every %i seconds.", 491 | opts.cull_timeout, 492 | opts.cull_period) 493 | culler = tornado.ioloop.PeriodicCallback(pool.heartbeat, cull_ms) 494 | culler.start() 495 | 496 | app_log.info("Listening on {}:{}".format(opts.ip or '*', opts.port)) 497 | application = tornado.web.Application(handlers, **settings) 498 | http_server = HTTPServer(application, xheaders=True) 499 | http_server.listen(opts.port, opts.ip) 500 | 501 | app_log.info("Admin listening on {}:{}".format(opts.admin_ip or '*', opts.admin_port)) 502 | admin_application = tornado.web.Application(admin_handlers, **admin_settings) 503 | admin_server = HTTPServer(admin_application, xheaders=True) 504 | admin_server.listen(opts.admin_port, opts.admin_ip) 505 | 506 | ioloop.start() 507 | 508 | if __name__ == "__main__": 509 | main() 510 | -------------------------------------------------------------------------------- /spawnpool.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import json 3 | import os 4 | import random 5 | import string 6 | import socket 7 | 8 | from concurrent.futures import ThreadPoolExecutor 9 | from collections import deque, namedtuple 10 | from datetime import datetime, timedelta 11 | from tornado import gen 12 | from tornado import ioloop 13 | from tornado.log import app_log 14 | from tornado.httpclient import HTTPRequest, HTTPError, AsyncHTTPClient 15 | from tornado.httputil import url_concat 16 | 17 | import pytz 18 | import re 19 | import dockworker 20 | 21 | AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient") 22 | import logging 23 | logging.getLogger("tornado.curl_httpclient").setLevel(logging.INFO) 24 | 25 | _date_fmt = '%Y-%m-%dT%H:%M:%S.%fZ' 26 | 27 | def sample_with_replacement(a, size): 28 | '''Get a random path. If Python had sampling with replacement built in, 29 | I would use that. The other alternative is numpy.random.choice, but 30 | numpy is overkill for this tiny bit of random pathing.''' 31 | return "".join([random.SystemRandom().choice(a) for x in range(size)]) 32 | 33 | 34 | def new_user(size): 35 | return sample_with_replacement(string.ascii_letters + string.digits, size) 36 | 37 | 38 | class PooledContainer(object): 39 | def __init__(self, id, path, token=''): 40 | self.id = id 41 | self.path = path 42 | self.token = token 43 | 44 | def __repr__(self): 45 | return 'PooledContainer(id=%s, path=%s)' % (self.id, self.path) 46 | 47 | class EmptyPoolError(Exception): 48 | '''Exception raised when a container is requested from an empty pool.''' 49 | 50 | pass 51 | 52 | 53 | class SpawnPool(): 54 | '''Manage a pool of precreated Docker containers.''' 55 | 56 | def __init__(self, 57 | proxy_endpoint, 58 | proxy_token, 59 | spawner, 60 | container_config, 61 | capacity, 62 | max_idle, 63 | max_age, 64 | pool_name, 65 | user_length, 66 | static_files=None, 67 | static_dump_path=os.path.join(os.path.dirname(__file__), 68 | "static")): 69 | '''Create a new, empty spawn pool, with nothing preallocated.''' 70 | 71 | self.spawner = spawner 72 | self.container_config = container_config 73 | self.capacity = capacity 74 | self.max_idle = max_idle 75 | self.max_age = max_age 76 | 77 | self.pool_name = pool_name 78 | self.container_name_pattern = re.compile('tmp\.([^.]+)\.(.+)\Z') 79 | 80 | self.proxy_endpoint = proxy_endpoint 81 | self.proxy_token = proxy_token 82 | 83 | self.user_length = user_length 84 | 85 | self.available = deque() 86 | self.started = {} 87 | 88 | self.static_files = static_files 89 | self.static_dump_path = static_dump_path 90 | 91 | self._heart_beating = False 92 | 93 | def acquire(self): 94 | '''Acquire a preallocated container and returns its user path. 95 | 96 | An EmptyPoolError is raised if no containers are ready.''' 97 | 98 | if not self.available: 99 | raise EmptyPoolError() 100 | 101 | container = self.available.pop() 102 | # signal start on acquisition 103 | self.started[container.id] = datetime.utcnow() 104 | return container 105 | 106 | @gen.coroutine 107 | def adhoc(self, user): 108 | '''Launch a container with a fixed path by taking the place of an existing container from 109 | the pool.''' 110 | 111 | to_release = self.acquire() 112 | app_log.debug("Discarding container [%s] to create an ad-hoc replacement.", to_release) 113 | yield self.release(to_release, False) 114 | 115 | launched = yield self._launch_container(user=user, enpool=False) 116 | self.started[launched.id] = datetime.utcnow() 117 | raise gen.Return(launched) 118 | 119 | @gen.coroutine 120 | def release(self, container, replace_if_room=True): 121 | '''Shut down a container and delete its proxy entry. 122 | 123 | Destroy the container in an orderly fashion. If requested and capacity is remaining, create 124 | a new one to take its place.''' 125 | 126 | try: 127 | app_log.info("Releasing container [%s].", container) 128 | self.started.pop(container.id, None) 129 | yield [ 130 | self.spawner.shutdown_notebook_server(container.id), 131 | self._proxy_remove(container.path) 132 | ] 133 | app_log.debug("Container [%s] has been released.", container) 134 | except Exception as e: 135 | app_log.error("Unable to release container [%s]: %s", container, e) 136 | return 137 | 138 | if replace_if_room: 139 | running = yield self.spawner.list_notebook_servers(self.container_name_pattern, all=False) 140 | if len(running) + 1 <= self.capacity: 141 | app_log.debug("Launching a replacement container.") 142 | yield self._launch_container() 143 | else: 144 | app_log.info("Declining to launch a new container because [%i] containers are" + 145 | " already running, and the capacity is [%i].", 146 | len(running), self.capacity) 147 | 148 | @gen.coroutine 149 | def cleanout(self): 150 | '''Completely cleanout containers that are part of this pool.''' 151 | app_log.info("Performing initial pool cleanup") 152 | 153 | containers = yield self.spawner.list_notebook_servers(self.container_name_pattern, all=True) 154 | for container in containers: 155 | try: 156 | app_log.debug("Clearing old container [%s] from pool", container['Id']) 157 | yield self.spawner.shutdown_notebook_server(container['Id']) 158 | except Exception as e: 159 | app_log.warn(e) 160 | 161 | @gen.coroutine 162 | def drain(self): 163 | ''' 164 | Completely cleanout all available containers in the pool and immediately 165 | schedule their replacement. Useful for refilling the pool with a new 166 | container image while leaving in-use containers untouched. Returns the 167 | number of containers drained. 168 | ''' 169 | app_log.info("Draining available containers from pool") 170 | tasks = [] 171 | while 1: 172 | try: 173 | pooled = self.acquire() 174 | app_log.debug("Releasing container [%s] to drain the pool.", pooled.id) 175 | tasks.append(self.release(pooled, replace_if_room=False)) 176 | except EmptyPoolError: 177 | # No more free containers left to acquire 178 | break 179 | yield tasks 180 | raise gen.Return(len(tasks)) 181 | 182 | @gen.coroutine 183 | def heartbeat(self): 184 | '''Examine the pool for any missing, stopped, or idle containers, and replace them. 185 | 186 | A container is considered "used" if it isn't still present in the pool. If no max_age is 187 | specified, an hour is used.''' 188 | 189 | if self._heart_beating: 190 | app_log.debug("Previous heartbeat is still active. Skipping this one.") 191 | return 192 | try: 193 | self._heart_beating = True 194 | 195 | app_log.debug("Heartbeat begun. Measuring current state.") 196 | 197 | diagnosis = Diagnosis(self.max_idle, 198 | self.max_age, 199 | self.spawner, 200 | self.container_name_pattern, 201 | self.proxy_endpoint, 202 | self.proxy_token, 203 | self.started, 204 | ) 205 | yield diagnosis.observe() 206 | 207 | tasks = [] 208 | 209 | for id in diagnosis.stopped_container_ids: 210 | app_log.debug("Removing stopped container [%s].", id) 211 | tasks.append(self.spawner.shutdown_notebook_server(id, alive=False)) 212 | 213 | for path, id in diagnosis.zombie_routes: 214 | app_log.debug("Removing zombie route [%s].", path) 215 | tasks.append(self._proxy_remove(path)) 216 | 217 | unpooled_stale_routes = [(path, id) for path, id in diagnosis.stale_routes 218 | if id not in self._pooled_ids()] 219 | for path, id in unpooled_stale_routes: 220 | app_log.debug("Replacing stale route [%s] and container [%s].", path, id) 221 | container = PooledContainer(path=path, id=id, token='') 222 | tasks.append(self.release(container, replace_if_room=True)) 223 | 224 | # Normalize the container count to its initial capacity by scheduling deletions if we're 225 | # over or scheduling launches if we're under. 226 | current = len(diagnosis.living_container_ids) 227 | under = range(current, self.capacity) 228 | over = range(self.capacity, current) 229 | 230 | if under: 231 | app_log.info("Launching [%i] new containers to populate the pool.", len(under)) 232 | for i in under: 233 | tasks.append(self._launch_container()) 234 | 235 | if over: 236 | app_log.info("Removing [%i] containers to diminish the pool.", len(over)) 237 | for i in over: 238 | try: 239 | pooled = self.acquire() 240 | app_log.info("Releasing container [%s] to shrink the pool.", pooled.id) 241 | tasks.append(self.release(pooled, False)) 242 | except EmptyPoolError: 243 | app_log.warning("Unable to shrink: pool is diminished, all containers in use.") 244 | break 245 | 246 | yield tasks 247 | 248 | # Summarize any actions taken to the log. 249 | def summarize(message, list): 250 | if list: 251 | app_log.info(message, len(list)) 252 | summarize("Removed [%i] stopped containers.", diagnosis.stopped_container_ids) 253 | summarize("Removed [%i] zombie routes.", diagnosis.zombie_routes) 254 | summarize("Replaced [%i] stale containers.", unpooled_stale_routes) 255 | summarize("Launched [%i] new containers.", under) 256 | summarize("Removed [%i] excess containers from the pool.", over) 257 | 258 | app_log.debug("Heartbeat complete. The pool now includes [%i] containers.", 259 | len(self.available)) 260 | finally: 261 | self._heart_beating = False 262 | 263 | @gen.coroutine 264 | def _launch_container(self, user=None, enpool=True): 265 | '''Launch a new notebook server in a fresh container, register it with the proxy, and 266 | add it to the pool.''' 267 | 268 | if user is None: 269 | user = new_user(self.user_length) 270 | 271 | path = "/user/%s/" % user 272 | 273 | # This must match self.container_name_pattern or Bad Things will happen. 274 | # You don't want Bad Things to happen, do you? 275 | container_name = 'tmp.{}.{}'.format(self.pool_name, user) 276 | if not self.container_name_pattern.match(container_name): 277 | raise Exception("[{}] does not match [{}]!".format(container_name, 278 | self.container_name_pattern.pattern)) 279 | 280 | app_log.debug("Launching new notebook server [%s] at path [%s].", 281 | container_name, path) 282 | create_result = yield self.spawner.create_notebook_server(base_path=path, 283 | container_name=container_name, 284 | container_config=self.container_config) 285 | container_id, host_ip, host_port, token = create_result 286 | app_log.debug("Created notebook server [%s] for path [%s] at [%s:%s]", container_name, path, host_ip, host_port) 287 | 288 | # Wait for the server to launch within the container before adding it to the pool or 289 | # serving it to a user. 290 | yield self._wait_for_server(host_ip, host_port, path) 291 | 292 | http_client = AsyncHTTPClient() 293 | headers = {"Authorization": "token {}".format(self.proxy_token)} 294 | 295 | proxy_endpoint = "{}/api/routes{}".format(self.proxy_endpoint, path) 296 | body = json.dumps({ 297 | "target": "http://{}:{}".format(host_ip, host_port), 298 | "container_id": container_id, 299 | }) 300 | 301 | app_log.debug("Proxying path [%s] to port [%s].", path, host_port) 302 | req = HTTPRequest(proxy_endpoint, 303 | method="POST", 304 | headers=headers, 305 | body=body) 306 | try: 307 | yield http_client.fetch(req) 308 | app_log.info("Proxied path [%s] to port [%s].", path, host_port) 309 | except HTTPError as e: 310 | app_log.error("Failed to create proxy route to [%s]: %s", path, e) 311 | 312 | container = PooledContainer(id=container_id, path=path, token=token) 313 | if enpool: 314 | app_log.info("Adding container [%s] to the pool.", container) 315 | self.available.append(container) 316 | 317 | raise gen.Return(container) 318 | 319 | @gen.coroutine 320 | def _wait_for_server(self, ip, port, path, timeout=10, wait_time=0.2): 321 | '''Wait for a server to show up within a newly launched container.''' 322 | 323 | app_log.info("Waiting for a container to launch at [%s:%s].", ip, port) 324 | loop = ioloop.IOLoop.current() 325 | tic = loop.time() 326 | 327 | # Docker starts listening on a socket before the container is fully launched. Wait for that, 328 | # first. 329 | while loop.time() - tic < timeout: 330 | try: 331 | socket.create_connection((ip, port)) 332 | except socket.error as e: 333 | app_log.warn("Socket error on boot: %s", e) 334 | if e.errno != errno.ECONNREFUSED: 335 | app_log.warn("Error attempting to connect to [%s:%i]: %s", 336 | ip, port, e) 337 | yield gen.Task(loop.add_timeout, loop.time() + wait_time) 338 | else: 339 | break 340 | 341 | # Fudge factor of IPython notebook bootup. 342 | # TODO: Implement a webhook in IPython proper to call out when the 343 | # notebook server is booted. 344 | yield gen.Task(loop.add_timeout, loop.time() + .5) 345 | 346 | # Now, make sure that we can reach the Notebook server. 347 | http_client = AsyncHTTPClient() 348 | req = HTTPRequest("http://{}:{}{}".format(ip, port, path)) 349 | 350 | while loop.time() - tic < timeout: 351 | try: 352 | yield http_client.fetch(req) 353 | except HTTPError as http_error: 354 | code = http_error.code 355 | app_log.info("Booting server at [%s], getting HTTP status [%s]", path, code) 356 | yield gen.Task(loop.add_timeout, loop.time() + wait_time) 357 | else: 358 | break 359 | 360 | app_log.info("Server [%s] at address [%s:%s] has booted! Have at it.", 361 | path, ip, port) 362 | 363 | def _pooled_ids(self): 364 | '''Build a set of container IDs that are currently waiting in the pool.''' 365 | 366 | return set(container.id for container in self.available) 367 | 368 | @gen.coroutine 369 | def _proxy_remove(self, path): 370 | '''Remove a path from the proxy.''' 371 | 372 | url = "{}/api/routes/{}".format(self.proxy_endpoint, path.lstrip('/')) 373 | headers = {"Authorization": "token {}".format(self.proxy_token)} 374 | req = HTTPRequest(url, method="DELETE", headers=headers) 375 | http_client = AsyncHTTPClient() 376 | 377 | try: 378 | yield http_client.fetch(req) 379 | except HTTPError as e: 380 | app_log.error("Failed to delete route [%s]: %s", path, e) 381 | 382 | @gen.coroutine 383 | def copy_static(self): 384 | if(self.static_files is None): 385 | raise Exception("static_files must be set in order to dump them") 386 | 387 | container = self.available[0] 388 | 389 | app_log.info("Extracting static files from container {}".format(container.id)) 390 | 391 | tarball = yield self.spawner.copy_files(container.id, self.static_files) 392 | 393 | tar = open(os.path.join(self.static_dump_path, "static.tar"), "wb") 394 | tar.write(tarball.data) 395 | tar.close() 396 | 397 | app_log.debug("Static files extracted") 398 | 399 | class Diagnosis(): 400 | '''Collect and organize information to self-heal a SpawnPool. 401 | 402 | Measure the current state of Docker and the proxy routes and scan for anomalies so the pool can 403 | correct them. This includes zombie containers, containers that are running but not routed in the 404 | proxy, proxy routes that exist without a corresponding container, or other strange conditions.''' 405 | 406 | def __init__(self, cull_idle, cull_max_age, spawner, name_pattern, proxy_endpoint, proxy_token, started): 407 | self.spawner = spawner 408 | self.name_pattern = name_pattern 409 | self.proxy_endpoint = proxy_endpoint 410 | self.proxy_token = proxy_token 411 | self.cull_idle = cull_idle 412 | self.cull_max_age = cull_max_age 413 | self.started = started 414 | 415 | @gen.coroutine 416 | def observe(self): 417 | '''Collect Ground Truth of what's actually running from Docker and the proxy.''' 418 | 419 | results = yield { 420 | "docker": self.spawner.list_notebook_servers(self.name_pattern, all=True), 421 | "proxy": self._proxy_routes() 422 | } 423 | 424 | self.container_ids = set() 425 | self.living_container_ids = [] 426 | self.stopped_container_ids = [] 427 | self.zombie_container_ids = [] 428 | 429 | self.routes = set() 430 | self.live_routes = [] 431 | self.stale_routes = [] 432 | self.zombie_routes = [] 433 | 434 | # Sort Docker results into living and dead containers. 435 | for container in results["docker"]: 436 | id = container['Id'] 437 | self.container_ids.add(id) 438 | if container['Status'].startswith('Up'): 439 | self.living_container_ids.append(id) 440 | else: 441 | self.stopped_container_ids.append(id) 442 | 443 | now = datetime.utcnow() 444 | idle_cutoff = now - self.cull_idle 445 | started_cutoff = now - self.cull_max_age 446 | 447 | # Sort proxy routes into living, stale, and zombie routes. 448 | living_set = set(self.living_container_ids) 449 | for path, route in results["proxy"].items(): 450 | last_activity_s = route.get('last_activity', None) 451 | container_id = route.get('container_id', None) 452 | if container_id: 453 | result = (path, container_id) 454 | if container_id in living_set: 455 | try: 456 | last_activity = datetime.strptime(last_activity_s, _date_fmt) 457 | started = self.started.get(container_id, None) 458 | self.routes.add(result) 459 | if started and last_activity < idle_cutoff: 460 | app_log.info("Culling %s, idle since %s", path, last_activity) 461 | self.stale_routes.append(result) 462 | elif started and started < started_cutoff: 463 | app_log.info("Culling %s, up since %s", path, started) 464 | self.stale_routes.append(result) 465 | else: 466 | app_log.debug("Container %s up since %s, idle since %s", 467 | path, started, last_activity) 468 | self.live_routes.append(result) 469 | except ValueError as e: 470 | app_log.warning("Ignoring a proxy route with an unparsable activity date: %s", e) 471 | else: 472 | # The container doesn't correspond to a living container. 473 | self.zombie_routes.append(result) 474 | 475 | @gen.coroutine 476 | def _proxy_routes(self): 477 | routes = [] 478 | 479 | url = "{}/api/routes".format(self.proxy_endpoint) 480 | headers = {"Authorization": "token {}".format(self.proxy_token)} 481 | req = HTTPRequest(url, method="GET", headers=headers) 482 | http_client = AsyncHTTPClient() 483 | try: 484 | resp = yield http_client.fetch(req) 485 | results = json.loads(resp.body.decode('utf8', 'replace')) 486 | raise gen.Return(results) 487 | except HTTPError as e: 488 | app_log.error("Unable to list existing proxy entries: %s", e) 489 | raise gen.Return({}) 490 | -------------------------------------------------------------------------------- /static/metricsgraphics.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"function"==typeof define&&define.amd?define(["d3","jquery"],e):"object"==typeof exports?module.exports=e(require("d3"),require("jquery")):t.MG=e(t.d3,t.jQuery)}(this,function(t,e){function r(t,e,r){MG.charts[t]={descriptor:e,defaults:r||{}}}function a(r){"use strict";var a=t.select(r.target);if(a.select(".mg-chart-title").remove(),r.target&&r.title){var n=r.show_tooltips&&r.description?'':"";if(a.insert("h2",":first-child").attr("class","mg-chart-title").html(r.title+n),r.show_tooltips&&r.description){var o=e(a.node()).find("h2.mg-chart-title");o.popover({html:!0,animation:!1,content:r.description,trigger:"hover",placement:"top",container:o})}}r.error&&he(r)}function n(t){"use strict";for(var e=ee(t.target),r="point"===t.chart_type?t.buffer/2:2*t.buffer/3,a=[],n=0;n0},u=function(t){return t[e.y_accessor]},f=0;f0){var p=t.extent(d,u);l?(o=Math.min(p[0],o),s=Math.max(p[1],s)):(o=p[0],s=p[1],l=!0)}}o>=0&&!e.min_y&&!e.min_y_from_data&&(o=0),"bar"===e.chart_type&&(o=0,s=t.max(e.data[0],function(t){var r=[];return r.push(t[e.y_accessor]),null!==e.baseline_accessor&&r.push(t[e.baseline_accessor]),null!==e.predictor_accessor&&r.push(t[e.predictor_accessor]),Math.max.apply(null,r)})),o=null!==e.min_y?e.min_y:o,s=null!==e.max_y?e.max_y:s*e.inflator,"log"!==e.y_scale_type&&(o>=0?e.y_axis_negative=!1:(o-=s*(e.inflator-1),e.y_axis_negative=!0)),!e.min_y&&e.min_y_from_data&&(o/=e.inflator),e.processed.min_y=o,e.processed.max_y=s,MG.call_hook("y_axis.process_min_max",e,o,s),o=e.processed.min_y,s=e.processed.max_y,"log"===e.y_scale_type?("histogram"===e.chart_type?o=.2:0>=o&&(o=1),e.scales.Y=t.scale.log().domain([o,s]).range([e.height-e.bottom-e.buffer,e.top]).clamp(!0)):e.scales.Y=t.scale.linear().domain([o,s]).range([e.height-e.bottom-e.buffer,e.top]),e.scales.Y_axis=t.scale.linear().domain([e.processed.min_y,e.processed.max_y]).range([e.height-e.bottom-e.buffer,e.top]);var h=e.yax_format;if(h||(h="count"===e.format?function(r){if(1>r)return e.yax_units+t.round(r,e.decimals);var a=t.formatPrefix(r);return e.yax_units+a.scale(r)+a.symbol}:function(e){var r=t.format("2p");return r(e)}),i.selectAll(".mg-y-axis").remove(),!e.y_axis)return this;a=i.append("g").classed("mg-y-axis",!0).classed("mg-y-axis-small",e.use_small_class),e.y_label&&a.append("text").attr("class","label").attr("x",function(){return-1*(e.top+e.buffer+(e.height-e.bottom-e.buffer-(e.top+e.buffer))/2)}).attr("y",function(){return e.left/2}).attr("dy","0.4em").attr("text-anchor","middle").text(function(){return e.y_label}).attr("transform",function(){return"rotate(-90)"});var m=e.scales.Y.ticks(e.yax_count);"log"===e.y_scale_type&&(m=m.filter(function(t){return Math.abs(r(t))%1<1e-6||Math.abs(r(t))%1>1-1e-6}));var g=e.scales.Y.ticks(e.yax_count).length,_=!0;e.data.forEach(function(t){t.forEach(function(t){return t[e.y_accessor]%1!==0?(_=!1,!1):void 0})}),_&&g>s&&"count"===e.format&&(m=m.filter(function(t){return t%1===0}));var v=m.length;if(!e.x_extended_ticks&&!e.y_extended_ticks&&v){var x,y;v?(x=e.scales.Y(m[0]).toFixed(2),y=e.scales.Y(m[v-1]).toFixed(2)):(x=0,y=0),a.append("line").attr("x1",e.left).attr("x2",e.left).attr("y1",x).attr("y2",y)}return a.selectAll(".mg-yax-ticks").data(m).enter().append("line").classed("mg-extended-y-ticks",e.y_extended_ticks).attr("x1",e.left).attr("x2",function(){return e.y_extended_ticks?e.width-e.right:e.left-e.yax_tick_length}).attr("y1",function(t){return e.scales.Y(t).toFixed(2)}).attr("y2",function(t){return e.scales.Y(t).toFixed(2)}),a.selectAll(".mg-yax-labels").data(m).enter().append("text").attr("x",e.left-3*e.yax_tick_length/2).attr("dx",-3).attr("y",function(t){return e.scales.Y(t).toFixed(2)}).attr("dy",".35em").attr("text-anchor","end").text(function(t){var e=h(t);return e}),e.y_rug&&n(e),this}function s(e){e.scales.Y=t.scale.ordinal().domain(e.categorical_variables).rangeRoundBands([e.height-e.bottom-e.buffer,e.top],e.padding_percentage,e.outer_padding_percentage),e.scalefns.yf=function(t){return e.scales.Y(t[e.y_accessor])};var r=ee(e.target);r.selectAll(".mg-y-axis").remove();var a=r.append("g").classed("mg-y-axis",!0).classed("mg-y-axis-small",e.use_small_class);if(!e.y_axis)return this;var n=a.selectAll("text").data(e.categorical_variables).enter().append("svg:text").attr("x",e.left).attr("y",function(t){return e.scales.Y(t)+e.scales.Y.rangeBand()/2+e.buffer*e.outer_padding_percentage}).attr("dy",".35em").attr("text-anchor","end").text(String);return e.rotate_y_labels&&n.attr({dy:0,transform:function(){var r=t.select(this);return"rotate("+e.rotate_y_labels+" "+r.attr("x")+","+r.attr("y")+")"}}),this}function i(t){"use strict";var e="point"===t.chart_type?t.buffer/2:t.buffer,r=ee(t.target),a=re(t.data),n=r.selectAll("line.mg-x-rug").data(a);n.enter().append("svg:line").attr("class","mg-x-rug").attr("opacity",.3),n.exit().remove(),n.exit().remove(),n.attr("x1",t.scalefns.xf).attr("x2",t.scalefns.xf).attr("y1",t.height-t.top+e).attr("y2",t.height-t.top),t.color_accessor?(n.attr("stroke",t.scalefns.color),n.classed("mg-x-rug-mono",!1)):(n.attr("stroke",null),n.classed("mg-x-rug-mono",!0))}function l(e){"use strict";var r,a=ee(e.target);e.processed={};for(var n=[],o=0;o180?"end":"start",transform:function(){var r=t.select(this);return"rotate("+e.rotate_x_labels+" "+r.attr("x")+","+r.attr("y")+")"}}),this}function u(e){var r,a,n,o;null!==e.color_accessor&&(null===e.color_domain?"number"===e.color_type?(r=t.min(e.data[0],function(t){return t[e.color_accessor]}),a=t.max(e.data[0],function(t){return t[e.color_accessor]}),n=[r,a]):"category"===e.color_type&&(n=t.set(e.data[0].map(function(t){return t[e.color_accessor]})).values(),n.sort()):n=e.color_domain,o=null===e.color_range?"number"===e.color_type?["blue","red"]:null:e.color_range,"number"===e.color_type?e.scales.color=t.scale.linear().domain(n).range(o).clamp(!0):(e.scales.color=null!==e.color_range?t.scale.ordinal().range(o):n.length>10?t.scale.category20():t.scale.category10(),e.scales.color.domain(n)),e.scalefns.color=function(t){return e.scales.color(t[e.color_accessor])})}function f(e){var r,a,n,o;null!==e.size_accessor&&(null===e.size_domain?(r=t.min(e.data[0],function(t){return t[e.size_accessor]}),a=t.max(e.data[0],function(t){return t[e.size_accessor]}),n=[r,a]):n=e.size_domain,o=null===e.size_range?[1,5]:e.size_range,e.scales.size=t.scale.linear().domain(n).range(o).clamp(!0),e.scalefns.size=function(t){return e.scales.size(t[e.size_accessor])})}function d(t,e){t.append("text").attr("class","label").attr("x",function(){return(e.left+e.width-e.right)/2}).attr("y",(e.height-e.bottom/2).toFixed(2)).attr("dy",".50em").attr("text-anchor","middle").text(function(){return e.x_label})}function p(e){return function(r){if(1>r)return e.xax_units+t.round(r,e.decimals);var a=t.formatPrefix(r);return e.xax_units+a.scale(r)+a.symbol}}function h(t){return time_frame=60>t?"seconds":24>=t/3600?"less-than-a-day":96>=t/3600?"four-days":"default"}function m(t,e){return main_time_format=60>e?MG.time_format(t,"%M:%S"):24>=e/3600?MG.time_format(t,"%H:%M"):96>=e/3600?MG.time_format(t,"%H:%M"):MG.time_format(t,"%b %d")}function g(e){if(e.xax_format)return e.xax_format;var r=re(e.data)[0][e.x_accessor];return function(a){var n,o,s;e.time_series&&(n=(e.processed.max_x-e.processed.min_x)/1e3,s=h(n),o=m(e.utc_time,n)),e.processed.main_x_time_format=o,e.processed.x_time_frame=s;var i=(MG.time_format(e.utc_time,"%b %d"),t.formatPrefix(a));return r instanceof Date?e.processed.main_x_time_format(new Date(a)):"number"==typeof r?1>a?e.xax_units+t.round(a,e.decimals):(i=t.formatPrefix(a),e.xax_units+i.scale(a)+i.symbol):a}}function _(t,e){var r=e.scales.X.ticks(e.xax_count).length-1,a=e.scales.X.ticks(e.xax_count);e.xax_start_at_min&&(a[0]=e.processed.min_x),"bar"===e.chart_type||e.x_extended_ticks||e.y_extended_ticks||t.append("line").attr("x1",function(){return 0===e.xax_count?e.left+e.buffer:e.xax_start_at_min?e.scales.X(e.processed.min_x).toFixed(2):e.scales.X(e.scales.X.ticks(e.xax_count)[0]).toFixed(2)}).attr("x2",0===e.xax_count?e.width-e.right-e.buffer:e.scales.X(e.scales.X.ticks(e.xax_count)[r]).toFixed(2)).attr("y1",e.height-e.bottom).attr("y2",e.height-e.bottom),t.selectAll(".mg-xax-ticks").data(a).enter().append("line").attr("x1",function(t){return e.scales.X(t).toFixed(2)}).attr("x2",function(t){return e.scales.X(t).toFixed(2)}).attr("y1",e.height-e.bottom).attr("y2",function(){return e.x_extended_ticks?e.top:e.height-e.bottom+e.xax_tick_length}).attr("class",function(){return e.x_extended_ticks?"mg-extended-x-ticks":void 0})}function v(e,r){var a=r.scales.X.ticks(r.xax_count);if(r.xax_start_at_min&&(a[0]=r.processed.min_x),e.selectAll(".mg-xax-labels").data(a).enter().append("text").attr("x",function(t){return r.scales.X(t).toFixed(2)}).attr("y",(r.height-r.bottom+7*r.xax_tick_length/3).toFixed(2)).attr("dy",".50em").attr("text-anchor","middle").text(function(t){return r.xax_units+r.processed.xax_format(t)}),r.time_series&&(r.show_years||r.show_secondary_x_label)){var n,o,s=r.processed.x_time_frame;switch(s){case"seconds":n=t.time.days,o=MG.time_format(r.utc_time,"%I %p");break;case"less-than-a-day":n=t.time.days,o=MG.time_format(r.utc_time,"%b %d");break;case"four-days":n=t.time.days,o=MG.time_format(r.utc_time,"%b %d");break;default:n=t.time.years,o=MG.time_format(r.utc_time,"%Y")}var i=n(r.processed.min_x,r.processed.max_x);if(r.xax_start_at_min&&0===i.length){var l=a[0];i=[l]}else if(0===i.length){var l=r.scales.X.ticks(r.xax_count)[0];i=[l]}e=e.append("g").classed("mg-year-marker",!0).classed("mg-year-marker-small",r.use_small_class),"default"===s&&r.show_year_markers&&e.selectAll(".mg-year-marker").data(i).enter().append("line").attr("x1",function(t){return r.scales.X(t).toFixed(2)}).attr("x2",function(t){return r.scales.X(t).toFixed(2)}).attr("y1",r.top).attr("y2",r.height-r.bottom),e.selectAll(".mg-year-marker").data(i).enter().append("text").attr("x",function(t,e){return r.xax_start_at_min&&0==e&&(t=a[0]),r.scales.X(t).toFixed(2)}).attr("y",(r.height-r.bottom+7*r.xax_tick_length/1.3).toFixed(2)).attr("dy",r.use_small_class?-3:0).attr("text-anchor","middle").text(function(t){return o(new Date(t))})}}function x(e){var r,a,n=[],o=[].concat.apply([],e.data),s=function(t){return t[e.x_accessor]};if("line"===e.chart_type||"point"===e.chart_type||"histogram"===e.chart_type?(n=t.extent(o,s),r=n[0],a=n[1]):"bar"===e.chart_type&&(r=0,a=t.max(o,function(t){var r=[t[e.x_accessor],t[e.baseline_accessor]?t[e.baseline_accessor]:0,t[e.predictor_accessor]?t[e.predictor_accessor]:0];return Math.max.apply(null,r)})),!(r!==a||e.min_x&&e.max_x)){if(r instanceof Date){var i=MG.clone(r).setDate(r.getDate()-1),l=MG.clone(r).setDate(r.getDate()+1);r=i,a=l}else"number"==typeof r?(r-=1,a+=1):"string"==typeof r&&(r=Number(r)-1,a=Number(a)+1);e.xax_count=2}r=e.min_x||r,a=e.max_x||a,e.x_axis_negative=!1,e.processed.min_x=r,e.processed.max_x=a,y(e),MG.call_hook("x_axis.process_min_max",e,r,a),e.time_series||e.processed.min_x<0&&(e.processed.min_x=e.processed.min_x-e.processed.max_x*(e.inflator-1),e.x_axis_negative=!0),e.additional_buffer="bar"===e.chart_type?5*e.buffer:0}function y(t){var e=t.chart_type;t.processed.xax_format||(t.xax_format?t.processed.xax_format=t.xax_format:"line"===e||"point"===e||"histogram"===e?t.processed.xax_format=g(t):"bar"===e&&(t.processed.xax_format=p(t)))}function b(e){"use strict";function r(t){var e=re(t.data)[0];return e[t.x_accessor]instanceof Date}var n={target:null,title:null,description:null};if(e=arguments[0],e||(e={}),e=ce(e,n),t.select(e.target).empty())return void console.warn('The specified target element "'+e.target+'" could not be found in the page. The chart will not be rendered.');var o=t.select(e.target),s=o.selectAll("svg");e.time_series=r(e);var i=e.width,l=e.height;e.full_width&&(i=ie(e.target)),e.full_height&&(l=le(e.target)),"bar"===e.chart_type&&null===l&&(l=e.height=e.data[0].length*e.bar_height+e.top+e.bottom),(!s.selectAll(".mg-main-line").empty()&&"line"!==e.chart_type||!s.selectAll(".mg-points").empty()&&"point"!==e.chart_type||!s.selectAll(".mg-histogram").empty()&&"histogram"!==e.chart_type||!s.selectAll(".mg-barplot").empty()&&"bar"!==e.chart_type)&&s.remove(),ee(e.target).empty()&&(s=t.select(e.target).append("svg").classed("linked",e.linked).attr("width",i).attr("height",l)),e.width=i,e.height=l,s.selectAll(".mg-clip-path").remove(),s.append("defs").attr("class","mg-clip-path").append("clipPath").attr("id","mg-plot-window-"+ne(e.target)).append("svg:rect").attr("x",e.left).attr("y",e.top).attr("width",e.width-e.left-e.right-e.buffer).attr("height",e.height-e.top-e.bottom-e.buffer+1),i!==Number(s.attr("width"))&&s.attr("width",i),l!==Number(s.attr("height"))&&s.attr("height",l),s.attr("viewBox","0 0 "+i+" "+l),(e.full_width||e.full_height)&&s.attr("preserveAspectRatio","xMinYMin meet"),s.classed("mg-missing",!1),s.selectAll(".mg-missing-text").remove(),s.selectAll(".mg-missing-pane").remove(),a(e),e.use_small_class=e.height-e.top-e.bottom-e.buffer<=e.small_height_threshold&&e.width-e.left-e.right-2*e.buffer<=e.small_width_threshold||e.small_text;var c=0;if(s.selectAll(".mg-main-line")[0].length>=e.data.length)if(e.custom_line_color_map.length>0){var u=function(t){for(var e=new Array(t),r=0;rd;c--)s.selectAll(".mg-main-line.mg-line"+c+"-color").remove()}return this}function w(t){"use strict";function e(e){return t.scales.X(e[t.x_accessor])}function r(t){return e(t).toFixed(2)}function a(e){return t.scales.X(e[t.x_accessor])>t.buffer+t.left&&t.scales.X(e[t.x_accessor])0&&t[0][e.x_accessor]instanceof Date}))>0;if(e.missing_is_hidden&&(e.interpolate="linear"),(e.missing_is_zero||e.missing_is_hidden)&&"line"===e.chart_type&&r)for(var n=0;n=f;f.setDate(f.getDate()+1)){var d={};f.setHours(0,0,0,0),Date.parse(f)===Date.parse(new Date(l))&&i.push(MG.clone(e.data[n][0]));var p=null;e.data[n].forEach(function(t){return Date.parse(t[e.x_accessor])===Date.parse(new Date(f))?(p=t,!1):void 0}),p?p[e.missing_is_hidden_accessor]||null==p[e.y_accessor]?(p._missing=!0,i.push(p)):i.push(p):(d[e.x_accessor]=new Date(f),d[e.y_accessor]=0,d._missing=!0,i.push(d))}else for(var m=0;ms;s+=1){i=t.zip(c,r).map(function(t){return Math.abs(t[1]-t[0])});var u=t.quantile(i.sort(),.5);i=i.map(function(t){return P(t/(6*u))}),o=L(e,r,a,n,i),l=o.x,c=o.y}return t.zip(l,c).map(function(t){var e={};return e.x=t[0],e.y=t[1],e})}function C(t,e,r,a){for(var n=[],o=0;o=0&&1>=t?Math.pow(1-Math.pow(t,e),e):0}function P(t){return E(t,2)}function j(t){return E(t,3)}function $(e){var r=t.sum(e.map(function(t){return t.w}));return{xbar:t.sum(e.map(function(t){return t.w*t.x}))/r,ybar:t.sum(e.map(function(t){return t.w*t.y}))/r}}function N(e,r,a){var n=t.sum(e.map(function(t){return Math.pow(t.w,2)*(t.x-r)*(t.y-a)})),o=t.sum(e.map(function(t){return Math.pow(t.w,2)*Math.pow(t.x-r,2)}));return n/o}function S(t){var e,r,a=$(t);r=a.xbar,e=a.ybar;var n=N(t,r,e);return{beta:n,xbar:r,ybar:e,x0:e-n*r}}function L(e,r,a,n,o){var s=Math.floor(e.length*a),i=e.slice();i.sort(function(t,e){return e>t?-1:t>e?1:0});for(var l,c,u,f,d,p=t.quantile(i,.98),h=t.quantile(i,.02),m=t.zip(e,r,o).sort(),g=Math.abs(p-h)/n,_=h,v=p,x=t.range(_,v,g),y=[],b=0;bt?"M"+u.map(function(e){return e(t)}).join("L"):e}}}function R(t){return"[object Array]"===Object.prototype.toString.call(t)}function q(t){return"[object Function]"===Object.prototype.toString.call(t)}function U(t){return R(t)&&0==t.length}function H(t){return"[object Object]"===Object.prototype.toString.call(t)}function Q(e){var r=e.map(function(t){return R(t)===!0&&t.length>0});return t.sum(r)===e.length}function Z(e){var r=e.map(function(t){return H(t)===!0});return t.sum(r)===e.length}function V(t){return U(t)||Z(t)}function W(e,r){if(e&&1!=e.length)for(var a=0;a=a.top?a.bottom-r.top:!1}function te(t,e){for(var r=t.getBoundingClientRect(),a=0;ar.right||n.rightr&&(t.textContent=e.slice(0,--n)+"...",a=t.getBBox(),"..."!==t.textContent););}function pe(e,r,a,n){e.each(function(){for(var e,o=t.select(this),s=o.text().split(a||/\s+/).reverse(),i=[],l=0,c=1.1,u=(o.attr("y"),0),f=o.text(null).append("tspan").attr("x",0).attr("y",u+"em").attr(n||{});e=s.pop();)i.push(e),f.text(i.join(" ")),(null===r||f.node().getComputedTextLength()>r)&&(i.pop(),f.text(i.join(" ")),i=[e],f=o.append("tspan").attr("x",0).attr("y",++l*c+u+"em").attr(n||{}).text(e))})}function he(e){console.log("ERROR : ",e.target," : ",e.error),t.select(e.target).select(".mg-chart-title").append("i").attr("class","fa fa-x fa-exclamation-circle warning")}window.MG={version:"2.6.0"},MG.register=r,MG._hooks={},MG.add_hook=function(t,e,r){var a;MG._hooks[t]||(MG._hooks[t]=[]),a=MG._hooks[t];var n=a.filter(function(t){return t.func===e}).length>0;if(n)throw"That function is already registered.";a.push({func:e,context:r})},MG.call_hook=function(t){var e,r=MG._hooks[t],a=[].slice.apply(arguments,[1]);return r&&r.forEach(function(t){if(t.func){var r=e||a;r&&r.constructor!==Array&&(r=[r]),r=[].concat.apply([],r),e=t.func.apply(t.context,r)}}),e||a},MG.globals={},MG.deprecations={rollover_callback:{replacement:"mouseover",version:"2.0"},rollout_callback:{replacement:"mouseout",version:"2.0"},show_years:{replacement:"show_secondary_x_label",version:"2.1"}},MG.globals.link=!1,MG.globals.version="1.1",MG.charts={},MG.data_graphic=function(t){"use strict";var e={missing_is_zero:!1,missing_is_hidden:!1,missing_is_hidden_accessor:null,legend:"",legend_target:"",error:"",animate_on_load:!1,top:40,bottom:30,right:10,left:50,buffer:8,width:350,height:220,full_width:!1,full_height:!1,small_height_threshold:120,small_width_threshold:160,small_text:!1,xax_count:6,xax_tick_length:5,xax_start_at_min:!1,yax_count:5,yax_tick_length:5,x_extended_ticks:!1,y_extended_ticks:!1,y_scale_type:"linear",max_x:null,max_y:null,min_x:null,min_y:null,min_y_from_data:!1,point_size:2.5,x_accessor:"date",xax_units:"",x_label:"",x_sort:!0,x_axis:!0,y_axis:!0,y_accessor:"value",y_label:"",yax_units:"",x_rug:!1,y_rug:!1,transition_on_update:!0,mouseover:null,show_rollover_text:!0,show_confidence_band:null,xax_format:null,area:!0,chart_type:"line",data:[],decimals:2,format:"count",inflator:10/9,linked:!1,linked_format:"%Y-%m-%d",list:!1,baselines:null,markers:null,scalefns:{},scales:{},utc_time:!1,show_year_markers:!1,show_secondary_x_label:!0,target:"#viz",interpolate:"cardinal",interpolate_tension:.7,custom_line_color_map:[],max_data_size:null,aggregate_rollover:!1,show_tooltips:!0};MG.call_hook("global.defaults",e),t||(t={});var r=MG.charts[t.chart_type||e.chart_type];ce(t,r.defaults,e),t.list&&(t.x_accessor=0,t.y_accessor=1);for(var a in MG.deprecations)if(t.hasOwnProperty(a)){var n=MG.deprecations[a],o="Use of `args."+a+"` has been deprecated",s=n.replacement;if(s&&(t[s]?o+=". The replacement - `args."+s+"` - has already been defined. This definition will be discarded.":t[s]=t[a]),n.warned)continue;n.warned=!0,s&&(o+=" in favor of `args."+s+"`"),fe(o,n.version)}return MG.call_hook("global.before_init",t),new r.descriptor(t),t.data},"undefined"!=typeof jQuery&&(+function(t){"use strict";function e(e){return this.each(function(){var a=t(this),n=a.data("bs.tooltip"),o="object"==typeof e&&e,s=o&&o.selector;(n||"destroy"!=e)&&(s?(n||a.data("bs.tooltip",n={}),n[s]||(n[s]=new r(this,o))):n||a.data("bs.tooltip",n=new r(this,o)),"string"==typeof e&&n[e]())})}if("function"==typeof t().tooltip)return!0;var r=function(t,e){this.type=this.options=this.enabled=this.timeout=this.hoverState=this.$element=null,this.init("tooltip",t,e)};r.VERSION="3.3.1",r.TRANSITION_DURATION=150,r.DEFAULTS={animation:!0,placement:"top",selector:!1,template:'',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},r.prototype.init=function(e,r,a){this.enabled=!0,this.type=e,this.$element=t(r),this.options=this.getOptions(a),this.$viewport=this.options.viewport&&t(this.options.viewport.selector||this.options.viewport);for(var n=this.options.trigger.split(" "),o=n.length;o--;){var s=n[o];if("click"==s)this.$element.on("click."+this.type,this.options.selector,t.proxy(this.toggle,this));else if("manual"!=s){var i="hover"==s?"mouseenter":"focusin",l="hover"==s?"mouseleave":"focusout";this.$element.on(i+"."+this.type,this.options.selector,t.proxy(this.enter,this)),this.$element.on(l+"."+this.type,this.options.selector,t.proxy(this.leave,this))}}this.options.selector?this._options=t.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},r.prototype.getDefaults=function(){return r.DEFAULTS},r.prototype.getOptions=function(e){return e=t.extend({},this.getDefaults(),this.$element.data(),e),e.delay&&"number"==typeof e.delay&&(e.delay={show:e.delay,hide:e.delay}),e},r.prototype.getDelegateOptions=function(){var e={},r=this.getDefaults();return this._options&&t.each(this._options,function(t,a){r[t]!=a&&(e[t]=a)}),e},r.prototype.enter=function(e){var r=e instanceof this.constructor?e:t(e.currentTarget).data("bs."+this.type);return r&&r.$tip&&r.$tip.is(":visible")?void(r.hoverState="in"):(r||(r=new this.constructor(e.currentTarget,this.getDelegateOptions()),t(e.currentTarget).data("bs."+this.type,r)),clearTimeout(r.timeout),r.hoverState="in",r.options.delay&&r.options.delay.show?void(r.timeout=setTimeout(function(){"in"==r.hoverState&&r.show() 2 | },r.options.delay.show)):r.show())},r.prototype.leave=function(e){var r=e instanceof this.constructor?e:t(e.currentTarget).data("bs."+this.type);return r||(r=new this.constructor(e.currentTarget,this.getDelegateOptions()),t(e.currentTarget).data("bs."+this.type,r)),clearTimeout(r.timeout),r.hoverState="out",r.options.delay&&r.options.delay.hide?void(r.timeout=setTimeout(function(){"out"==r.hoverState&&r.hide()},r.options.delay.hide)):r.hide()},r.prototype.show=function(){var e=t.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(e);var a=t.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(e.isDefaultPrevented()||!a)return;var n=this,o=this.tip(),s=this.getUID(this.type);this.setContent(),o.attr("id",s),this.$element.attr("aria-describedby",s),this.options.animation&&o.addClass("fade");var i="function"==typeof this.options.placement?this.options.placement.call(this,o[0],this.$element[0]):this.options.placement,l=/\s?auto?\s?/i,c=l.test(i);c&&(i=i.replace(l,"")||"top"),o.detach().css({top:0,left:0,display:"block"}).addClass(i).data("bs."+this.type,this),this.options.container?o.appendTo(this.options.container):o.insertAfter(this.$element);var u=this.getPosition(),f=o[0].offsetWidth,d=o[0].offsetHeight;if(c){var p=i,h=this.options.container?t(this.options.container):this.$element.parent(),m=this.getPosition(h);i="bottom"==i&&u.bottom+d>m.bottom?"top":"top"==i&&u.top-dm.width?"left":"left"==i&&u.left-fs.top+s.height&&(n.top=s.top+s.height-l)}else{var c=e.left-o,u=e.left+o+r;cs.width&&(n.left=s.left+s.width-u)}return n},r.prototype.getTitle=function(){var t,e=this.$element,r=this.options;return t=e.attr("data-original-title")||("function"==typeof r.title?r.title.call(e[0]):r.title)},r.prototype.getUID=function(t){do t+=~~(1e6*Math.random());while(document.getElementById(t));return t},r.prototype.tip=function(){return this.$tip=this.$tip||t(this.options.template)},r.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},r.prototype.enable=function(){this.enabled=!0},r.prototype.disable=function(){this.enabled=!1},r.prototype.toggleEnabled=function(){this.enabled=!this.enabled},r.prototype.toggle=function(e){var r=this;e&&(r=t(e.currentTarget).data("bs."+this.type),r||(r=new this.constructor(e.currentTarget,this.getDelegateOptions()),t(e.currentTarget).data("bs."+this.type,r))),r.tip().hasClass("in")?r.leave(r):r.enter(r)},r.prototype.destroy=function(){var t=this;clearTimeout(this.timeout),this.hide(function(){t.$element.off("."+t.type).removeData("bs."+t.type)})};var a=t.fn.tooltip;t.fn.tooltip=e,t.fn.tooltip.Constructor=r,t.fn.tooltip.noConflict=function(){return t.fn.tooltip=a,this}}(jQuery),+function(t){"use strict";function e(e){return this.each(function(){var a=t(this),n=a.data("bs.popover"),o="object"==typeof e&&e,s=o&&o.selector;(n||"destroy"!=e)&&(s?(n||a.data("bs.popover",n={}),n[s]||(n[s]=new r(this,o))):n||a.data("bs.popover",n=new r(this,o)),"string"==typeof e&&n[e]())})}if("function"==typeof t().popover)return!0;var r=function(t,e){this.init("popover",t,e)};if(!t.fn.tooltip)throw new Error("Popover requires tooltip.js");r.VERSION="3.3.1",r.DEFAULTS=t.extend({},t.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),r.prototype=t.extend({},t.fn.tooltip.Constructor.prototype),r.prototype.constructor=r,r.prototype.getDefaults=function(){return r.DEFAULTS},r.prototype.setContent=function(){var t=this.tip(),e=this.getTitle(),r=this.getContent();t.find(".popover-title")[this.options.html?"html":"text"](e),t.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof r?"html":"append":"text"](r),t.removeClass("fade top bottom left right in"),t.find(".popover-title").html()||t.find(".popover-title").hide()},r.prototype.hasContent=function(){return this.getTitle()||this.getContent()},r.prototype.getContent=function(){var t=this.$element,e=this.options;return t.attr("data-content")||("function"==typeof e.content?e.content.call(t[0]):e.content)},r.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")},r.prototype.tip=function(){return this.$tip||(this.$tip=t(this.options.template)),this.$tip};var a=t.fn.popover;t.fn.popover=e,t.fn.popover.Constructor=r,t.fn.popover.noConflict=function(){return t.fn.popover=a,this}}(jQuery)),MG.chart_title=a,MG.y_rug=n,MG.y_axis=o,MG.y_axis_categorical=s,MG.x_rug=i,MG.x_axis=l,MG.x_axis_categorical=c,MG.init=b,MG.markers=w,"undefined"!=typeof jQuery&&+function(t){"use strict";function e(e){e&&3===e.which||(t(n).remove(),t(o).each(function(){var a=t(this),n=r(a),o={relatedTarget:this};n.hasClass("open")&&(n.trigger(e=t.Event("hide.bs.dropdown",o)),e.isDefaultPrevented()||(a.attr("aria-expanded","false"),n.removeClass("open").trigger("hidden.bs.dropdown",o)))}))}function r(e){var r=e.attr("data-target");r||(r=e.attr("href"),r=r&&/#[A-Za-z]/.test(r)&&r.replace(/.*(?=#[^\s]*$)/,""));var a=r&&t(r);return a&&a.length?a:e.parent()}function a(e){return this.each(function(){var r=t(this),a=r.data("bs.dropdown");a||r.data("bs.dropdown",a=new s(this)),"string"==typeof e&&a[e].call(r)})}if("function"==typeof t().dropdown)return!0;var n=".dropdown-backdrop",o='[data-toggle="dropdown"]',s=function(e){t(e).on("click.bs.dropdown",this.toggle)};s.VERSION="3.3.1",s.prototype.toggle=function(a){var n=t(this);if(!n.is(".disabled, :disabled")){var o=r(n),s=o.hasClass("open");if(e(),!s){"ontouchstart"in document.documentElement&&!o.closest(".navbar-nav").length&&t('");var f=function(){var t,r=e(this).data("key"),a=e(this).data("feature");return e("."+a+"-btns button.btn span.title").html(r),i.hasOwnProperty(a)?(t=i[a],s[t](r)):o(a,r),!1};for(var d in this.feature_set){for(a=this.feature_set[d],e(this.target+" div.segments").append('
"),l=0;l'+a[l]+"");e("."+this._strip_punctuation(d)+"-btns .dropdown-menu li a").on("click",f)}return this},this},function(){"use strict";function e(e){this.init=function(t){return this.args=t,t.data&&0!==t.data.length?(A(t),G(t),b(t),l(t),o(t),this.markers(),this.mainPlot(),this.rollover(),this.windowListeners(),MG.call_hook("line.after_init",this),this):(t.error="No data was supplied",he(t),this)},this.mainPlot=function(){var r=ee(e.target);r.selectAll(".mg-line-legend").remove();var a,n;e.legend&&(a=r.append("g").attr("class","mg-line-legend"));var o=0,s=e.transition_on_update?1e3:0,i=function(t){return t[e.y_accessor]},l=t.svg.line().x(e.scalefns.xf).y(e.scalefns.yf).interpolate(e.interpolate).tension(e.interpolate_tension);e.missing_is_zero||(l=l.defined(function(t){return(void 0==t._missing||1!=t._missing)&&null!=t[e.y_accessor]}));var c,u=t.svg.line().defined(function(t){return(void 0==t._missing||1!=t._missing)&&null!=t[e.y_accessor]}).x(e.scalefns.xf).y(function(){return e.scales.Y(o)}).interpolate(e.interpolate).tension(e.interpolate_tension),f=t.svg.area().defined(l.defined()).x(e.scalefns.xf).y0(e.scales.Y.range()[0]).y1(e.scalefns.yf).interpolate(e.interpolate).tension(e.interpolate_tension),d=r.selectAll(".mg-confidence-band");e.show_confidence_band&&(c=t.svg.area().defined(l.defined()).x(e.scalefns.xf).y0(function(t){var r=e.show_confidence_band[0];return e.scales.Y(t[r])}).y1(function(t){var r=e.show_confidence_band[1];return e.scales.Y(t[r])}).interpolate(e.interpolate).tension(e.interpolate_tension));var p,h,m="",g=MG.call_hook("line.before_all_series",[e]);if(g!==!1)for(var _=e.data.length-1;_>=0;_--){p=e.data[_],MG.call_hook("line.before_each_series",[p,e]);var v=_+1;if(e.custom_line_color_map.length>0&&(v=e.custom_line_color_map[_]),e.data[_].line_id=v,0!==p.length){e.show_confidence_band&&(h=d.empty()?r.append("path").attr("class","mg-confidence-band"):d.transition().duration(function(){return e.transition_on_update?1e3:0}),h.attr("d",c(e.data[_])).attr("clip-path","url(#mg-plot-window-"+ne(e.target)+")"));var x=r.selectAll(".mg-main-area.mg-area"+v+"-color"),y=e.area&&!e.use_data_y_min&&!e.y_axis_negative&&e.data.length<=1;y?x.empty()?r.append("path").attr("class","mg-main-area mg-area"+v+"-color").attr("d",f(e.data[_])).attr("clip-path","url(#mg-plot-window-"+ne(e.target)+")"):(r.select(".mg-y-axis").node().parentNode.appendChild(x.node()),x.transition().duration(s).attr("d",f(e.data[_])).attr("clip-path","url(#mg-plot-window-"+ne(e.target)+")")):x.empty()||x.remove();var b=r.select("path.mg-main-line.mg-line"+v+"-color");if(b.empty())e.animate_on_load?(o=t.median(e.data[_],i),r.append("path").attr("class","mg-main-line mg-line"+v+"-color").attr("d",u(e.data[_])).transition().duration(1e3).attr("d",l(e.data[_])).attr("clip-path","url(#mg-plot-window-"+ne(e.target)+")")):r.append("path").attr("class","mg-main-line mg-line"+v+"-color").attr("d",l(e.data[_])).attr("clip-path","url(#mg-plot-window-"+ne(e.target)+")");else{r.select(".mg-y-axis").node().parentNode.appendChild(b.node());var w=b.transition().duration(s);!y&&e.transition_on_update?w.attrTween("d",I(l(e.data[_]),4)):w.attr("d",l(e.data[_]))}if(e.legend)if(R(e.legend)?n=e.legend[_]:q(e.legend)&&(n=e.legend(p)),e.legend_target)m="— "+n+"  "+m;else{var M=p[p.length-1];a.append("svg:text").classed("mg-line"+v+"-legend-color",!0).attr("x",e.scalefns.xf(M)).attr("dx",e.buffer).attr("y",e.scalefns.yf(M)).attr("dy",".35em").attr("font-size",10).attr("font-weight","300").text(n),J(a.selectAll(".mg-line-legend text")[0],e)}MG.call_hook("line.after_each_series",[p,b,e])}}return e.legend_target&&t.select(e.legend_target).html(m),this},this.markers=function(){return w(e),this},this.rollover=function(){var r,a,n=ee(e.target);n.selectAll(".mg-rollover-rect").remove(),n.selectAll(".mg-voronoi").remove(),n.selectAll(".mg-active-datapoint").remove(),n.selectAll(".mg-line-rollover-circle").remove(),n.selectAll(".mg-active-datapoint-container").remove(),n.append("g").attr("class","mg-active-datapoint-container").attr("transform","translate("+(e.width-e.right)+","+e.top/2+")").append("text").attr("class","mg-active-datapoint").classed("mg-active-datapoint-small",e.use_small_class).attr("xml:space","preserve").attr("text-anchor","end"),n.selectAll(".mg-line-rollover-circle").data(e.data).enter().append("circle").attr({"class":function(t){return["mg-line-rollover-circle","mg-line"+t.line_id+"-color","mg-area"+t.line_id+"-color"].join(" ")},cx:0,cy:0,r:0});var o=1;for(a=0;a0?e.custom_line_color_map[a]:o;o++}var i,l;if(e.data.length>1&&!e.aggregate_rollover){var c=t.geom.voronoi().x(function(t){return e.scales.X(t[e.x_accessor]).toFixed(2)}).y(function(t){return e.scales.Y(t[e.y_accessor]).toFixed(2)}).clipExtent([[e.buffer,e.buffer],[e.width-e.buffer,e.height-e.buffer]]);r=n.append("g").attr("class","mg-voronoi"),i=t.nest().key(function(t){return e.scales.X(t[e.x_accessor])+","+e.scales.Y(t[e.y_accessor])}).rollup(function(t){return t[0]}).entries(t.merge(e.data.map(function(t){return t}))).map(function(t){return t.values}),r.selectAll("path").data(c(i)).enter().append("path").filter(function(t){return void 0!==t}).attr("d",function(t){return"M"+t.join("L")+"Z"}).datum(function(t){return t.point}).attr("class",function(t){if(e.linked){var r=t[e.x_accessor],n=MG.time_format(e.utc_time,e.linked_format),o="number"==typeof r?a:n(r);return"mg-line"+t.line_id+"-color roll_"+o}return"mg-line"+t.line_id+"-color"}).on("mouseover",this.rolloverOn(e)).on("mouseout",this.rolloverOff(e)).on("mousemove",this.rolloverMove(e))}else e.data.length>1&&e.aggregate_rollover?(i=t.nest().key(function(t){return t[e.x_accessor]}).entries(t.merge(e.data)).sort(function(t,e){return new Date(t.key)-new Date(e.key)}),i.forEach(function(t){var r=t.values[0];t.key=r[e.x_accessor]}),l=i.map(function(t){return e.scales.X(t.key)}),r=n.append("g").attr("class","mg-rollover-rect"),r.selectAll(".mg-rollover-rects").data(i).enter().append("rect").attr("x",function(t,r){return 1===l.length?e.left+e.buffer:0===r?l[r].toFixed(2):((l[r-1]+l[r])/2).toFixed(2)}).attr("y",e.top).attr("width",function(t,r){return 1===l.length?e.width-e.right-e.buffer:0===r?((l[r+1]-l[r])/2).toFixed(2):r==l.length-1?((l[r]-l[r-1])/2).toFixed(2):((l[r+1]-l[r-1])/2).toFixed(2)}).attr("class",function(t){if(e.linked&&t.values.length>0){var r=MG.time_format(e.utc_time,e.linked_format),n=t.values.map(function(t){return"mg-line"+t.line_id+"-color"}).join(" "),o=t.values[0],s=o[e.x_accessor],i="number"==typeof s?a:r(s);return n+" roll_"+i}return n}).attr("height",e.height-e.bottom-e.top-e.buffer).attr("opacity",0).on("mouseover",this.rolloverOn(e)).on("mouseout",this.rolloverOff(e)).on("mousemove",this.rolloverMove(e))):(o=1,e.custom_line_color_map.length>0&&(o=e.custom_line_color_map[0]),r=n.append("g").attr("class","mg-rollover-rect"),l=e.data[0].map(e.scalefns.xf),r.selectAll(".mg-rollover-rects").data(e.data[0]).enter().append("rect").attr("class",function(t,r){if(e.linked){var a=t[e.x_accessor],n=MG.time_format(e.utc_time,e.linked_format),s="number"==typeof a?r:n(a);return"mg-line"+o+"-color roll_"+s}return"mg-line"+o+"-color"}).attr("x",function(t,r){return 1===l.length?e.left+e.buffer:0===r?l[r].toFixed(2):((l[r-1]+l[r])/2).toFixed(2)}).attr("y",function(t){return e.data.length>1?e.scalefns.yf(t)-6:e.top}).attr("width",function(t,r){return 1===l.length?e.width-e.right-e.buffer:0===r?((l[r+1]-l[r])/2).toFixed(2):r===l.length-1?((l[r]-l[r-1])/2).toFixed(2):((l[r+1]-l[r-1])/2).toFixed(2)}).attr("height",function(){return e.data.length>1?12:e.height-e.bottom-e.top-e.buffer}).attr("opacity",0).on("mouseover",this.rolloverOn(e)).on("mouseout",this.rolloverOff(e)).on("mousemove",this.rolloverMove(e)));if(1==e.data.length&&1==e.data[0].length)n.select(".mg-rollover-rect rect").on("mouseover")(e.data[0][0],0);else if(e.data.length>1)for(var a=0;a0&&void 0!==e.custom_line_color_map[a]&&(s=e.custom_line_color_map[a]),1!=e.data[a].length||n.selectAll(".mg-voronoi .mg-line"+s+"-color").empty()||(n.selectAll(".mg-voronoi .mg-line"+s+"-color").on("mouseover")(e.data[a][0],0),n.selectAll(".mg-voronoi .mg-line"+s+"-color").on("mouseout")(e.data[a][0],0))}return MG.call_hook("line.after_rollover",e),this},this.rolloverOn=function(e){var r,a=ee(e.target);switch(e.processed.x_time_frame){case"seconds":r=MG.time_format(e.utc_time,"%b %e, %Y %H:%M:%S");break;case"less-than-a-day":r=MG.time_format(e.utc_time,"%b %e, %Y %I:%M%p");break;case"four-days":r=MG.time_format(e.utc_time,"%b %e, %Y %I:%M%p");break;default:r=MG.time_format(e.utc_time,"%b %e, %Y")}return function(n,o){if(e.aggregate_rollover&&e.data.length>1)a.selectAll("circle.mg-line-rollover-circle").style("opacity",0),n.values.forEach(function(t){if(t[e.x_accessor]>=e.processed.min_x&&t[e.x_accessor]<=e.processed.max_x&&t[e.y_accessor]>=e.processed.min_y&&t[e.y_accessor]<=e.processed.max_y){a.select("circle.mg-line-rollover-circle.mg-line"+t.line_id+"-color").attr({cx:function(){return e.scales.X(t[e.x_accessor]).toFixed(2)},cy:function(){return e.scales.Y(t[e.y_accessor]).toFixed(2)},r:e.point_size}).style("opacity",1)}});else{if(e.missing_is_hidden&&n._missing||null==n[e.y_accessor])return;n[e.x_accessor]>=e.processed.min_x&&n[e.x_accessor]<=e.processed.max_x&&n[e.y_accessor]>=e.processed.min_y&&n[e.y_accessor]<=e.processed.max_y&&a.selectAll("circle.mg-line-rollover-circle.mg-area"+n.line_id+"-color").attr("class","").attr("class","mg-area"+n.line_id+"-color").classed("mg-line-rollover-circle",!0).attr("cx",function(){return e.scales.X(n[e.x_accessor]).toFixed(2)}).attr("cy",function(){return e.scales.Y(n[e.y_accessor]).toFixed(2)}).attr("r",e.point_size).style("opacity",1)}if(e.linked&&!MG.globals.link&&(MG.globals.link=!0,!e.aggregate_rollover||void 0!==n.value||n.values.length>0)){var s=n.values?n.values[0]:n,i=MG.time_format(e.utc_time,e.linked_format),l=s[e.x_accessor],c="number"==typeof l?o:i(l);t.selectAll(".mg-line"+s.line_id+"-color.roll_"+c).each(function(e){t.select(this).on("mouseover")(e,o)})}a.selectAll("text").filter(function(t){return n===t}).attr("opacity",.3);var u=B(e);if(e.show_rollover_text){var f=a.select(".mg-active-datapoint"),d=0,p=1.1;if(f.select("*").remove(),e.aggregate_rollover&&e.data.length>1){if(e.time_series){var h=new Date(n.key);f.append("tspan").text((r(h)+" "+e.yax_units).trim()),d=1,n.values.forEach(function(t){var r=f.append("tspan").attr({x:0,y:d*p+"em"}).text(u(t[e.y_accessor]));f.append("tspan").attr({x:-r.node().getComputedTextLength(),y:d*p+"em"}).text("— ").classed("mg-hover-line"+t.line_id+"-color",!0).style("font-weight","bold"),d++}),f.append("tspan").attr("x",0).attr("y",d*p+"em").text(" ")}else n.values.forEach(function(t){var r=f.append("tspan").attr({x:0,y:d*p+"em"}).text(e.x_accessor+": "+t[e.x_accessor]+", "+e.y_accessor+": "+e.yax_units+u(t[e.y_accessor]));f.append("tspan").attr({x:-r.node().getComputedTextLength(),y:d*p+"em"}).text("— ").classed("mg-hover-line"+t.line_id+"-color",!0).style("font-weight","bold"),d++});f.append("tspan").attr("x",0).attr("y",d*p+"em").text(" ")}else if(e.time_series){var m=new Date(+n[e.x_accessor]);m.setDate(m.getDate()),f.append("tspan").text(r(m)+" "+e.yax_units+u(n[e.y_accessor]))}else f.append("tspan").text(e.x_accessor+": "+n[e.x_accessor]+", "+e.y_accessor+": "+e.yax_units+u(n[e.y_accessor]))}e.mouseover&&e.mouseover(n,o)}},this.rolloverOff=function(e){var r=ee(e.target);return function(a,n){if(e.linked&&MG.globals.link){MG.globals.link=!1;var o=MG.time_format(e.utc_time,e.linked_format),s=a.values?a.values:[a];s.forEach(function(r){var a=r[e.x_accessor],s="number"==typeof a?n:o(a);t.selectAll(".roll_"+s).each(function(e){t.select(this).on("mouseout")(e)})})}e.aggregate_rollover?r.selectAll("circle.mg-line-rollover-circle").style("opacity",function(){return 0}):r.selectAll("circle.mg-line-rollover-circle.mg-area"+a.line_id+"-color").style("opacity",function(){var t=a.line_id-1;return e.custom_line_color_map.length>0&&void 0!==e.custom_line_color_map.indexOf(a.line_id)&&(t=e.custom_line_color_map.indexOf(a.line_id)),1==e.data[t].length?1:0}),r.select(".mg-active-datapoint").text(""),e.mouseout&&e.mouseout(a,n)}},this.rolloverMove=function(t){return function(e,r){t.mousemove&&t.mousemove(e,r)}},this.windowListeners=function(){return M(this.args),this},this.init(e)}MG.register("line",e)}.call(this),function(){"use strict";function r(r){this.init=function(t){return this.args=t,A(t),D(t),b(t),l(t),o(t),this.mainPlot(),this.markers(),this.rollover(),this.windowListeners(),this},this.mainPlot=function(){var t=ee(r.target);t.selectAll(".mg-histogram").remove();var e=t.append("g").attr("class","mg-histogram"),a=e.selectAll(".mg-bar").data(r.data[0]).enter().append("g").attr("class","mg-bar").attr("transform",function(t){return"translate("+r.scales.X(t[r.x_accessor]).toFixed(2)+","+r.scales.Y(t[r.y_accessor]).toFixed(2)+")"});return a.append("rect").attr("x",1).attr("width",function(){return 1===r.data[0].length?(r.scalefns.xf(r.data[0][0])-r.bar_margin).toFixed(2):(r.scalefns.xf(r.data[0][1])-r.scalefns.xf(r.data[0][0])-r.bar_margin).toFixed(2)}).attr("height",function(t){return 0===t[r.y_accessor]?0:(r.height-r.bottom-r.buffer-r.scales.Y(t[r.y_accessor])).toFixed(2)}),this},this.markers=function(){return w(r),this},this.rollover=function(){{var t=ee(r.target);e(e(r.target).find("svg").get(0))}t.selectAll(".mg-rollover-rect").remove(),t.selectAll(".mg-active-datapoint").remove(),t.append("text").attr("class","mg-active-datapoint").attr("xml:space","preserve").attr("x",r.width-r.right).attr("y",r.top/2).attr("text-anchor","end");var a=t.append("g").attr("class","mg-rollover-rect"),n=a.selectAll(".mg-bar").data(r.data[0]).enter().append("g").attr("class",function(t,e){return r.linked?"mg-rollover-rects roll_"+e:"mg-rollover-rects"}).attr("transform",function(t){return"translate("+r.scales.X(t[r.x_accessor])+",0)"});return n.append("rect").attr("x",1).attr("y",0).attr("width",function(t,e){return 1===r.data[0].length?(r.scalefns.xf(r.data[0][0])-r.bar_margin).toFixed(2):e!==r.data[0].length-1?(r.scalefns.xf(r.data[0][e+1])-r.scalefns.xf(t)).toFixed(2):(r.scalefns.xf(r.data[0][1])-r.scalefns.xf(r.data[0][0])).toFixed(2)}).attr("height",function(){return r.height}).attr("opacity",0).on("mouseover",this.rolloverOn(r)).on("mouseout",this.rolloverOff(r)).on("mousemove",this.rolloverMove(r)),this},this.rolloverOn=function(e){{var r=ee(e.target);MG.time_format(e.utc_time,"%Y-%m-%d")}return function(a,n){r.selectAll("text").filter(function(t){return a===t}).attr("opacity",.3);var o=MG.time_format(e.utc_time,"%b %e, %Y"),s=B(e);r.selectAll(".mg-bar rect").filter(function(t,e){return e===n}).classed("active",!0),e.linked&&!MG.globals.link&&(MG.globals.link=!0,t.selectAll(".mg-rollover-rects.roll_"+n+" rect").each(function(e){t.select(this).on("mouseover")(e,n)})),e.show_rollover_text&&r.select(".mg-active-datapoint").text(function(){if(e.time_series){var t=new Date(+a[e.x_accessor]);return t.setDate(t.getDate()),o(t)+" "+e.yax_units+s(a[e.y_accessor])}return e.x_accessor+": "+s(a[e.x_accessor])+", "+e.y_accessor+": "+e.yax_units+s(a[e.y_accessor])}),e.mouseover&&e.mouseover(a,n)}},this.rolloverOff=function(e){var r=ee(e.target);return function(a,n){e.linked&&MG.globals.link&&(MG.globals.link=!1,t.selectAll(".mg-rollover-rects.roll_"+n+" rect").each(function(e){t.select(this).on("mouseout")(e,n)})),r.selectAll(".mg-bar rect").classed("active",!1),r.select(".mg-active-datapoint").text(""),e.mouseout&&e.mouseout(a,n)}},this.rolloverMove=function(t){return function(e,r){t.mousemove&&t.mousemove(e,r)}},this.windowListeners=function(){return M(this.args),this},this.init(r)}var a={mouseover:function(e){t.select("#histogram svg .mg-active-datapoint").text("Frequency Count: "+e.y)},binned:!1,bins:null,processed_x_accessor:"x",processed_y_accessor:"y",processed_dx_accessor:"dx",bar_margin:1};MG.register("histogram",r,a)}.call(this),function(){"use strict";function e(e){this.init=function(t){return this.args=t,A(t),T(t),b(t),l(t),o(t),this.mainPlot(),this.markers(),this.rollover(),this.windowListeners(),this},this.markers=function(){return w(e),e.least_squares&&Y(e),this},this.mainPlot=function(){var t,r=ee(e.target);r.selectAll(".mg-points").remove(),t=r.append("g").classed("mg-points",!0);var a=t.selectAll("circle").data(e.data[0]).enter().append("svg:circle").attr("class",function(t,e){return"path-"+e}).attr("cx",e.scalefns.xf).attr("cy",e.scalefns.yf);return null!==e.color_accessor?(a.attr("fill",e.scalefns.color),a.attr("stroke",e.scalefns.color)):a.classed("mg-points-mono",!0),null!==e.size_accessor?a.attr("r",e.scalefns.size):a.attr("r",e.point_size),this},this.rollover=function(){var r=ee(e.target);r.selectAll(".mg-voronoi").remove(),r.selectAll(".mg-active-datapoint").remove(),r.append("text").attr("class","mg-active-datapoint").attr("xml:space","preserve").attr("x",e.width-e.right).attr("y",e.top/2).attr("text-anchor","end");var a=t.geom.voronoi().x(e.scalefns.xf).y(e.scalefns.yf).clipExtent([[e.buffer,e.buffer],[e.width-e.buffer,e.height-e.buffer]]),n=r.append("g").attr("class","mg-voronoi");return n.selectAll("path").data(a(e.data[0])).enter().append("path").attr("d",function(t){return void 0!==t?"M"+t.join(",")+"Z":void 0}).attr("class",function(t,e){return"path-"+e}).style("fill-opacity",0).on("mouseover",this.rolloverOn(e)).on("mouseout",this.rolloverOff(e)).on("mousemove",this.rolloverMove(e)),this},this.rolloverOn=function(e){var r=ee(e.target);return function(a,n){r.selectAll(".mg-points circle").classed("selected",!1);var o=r.selectAll(".mg-points circle.path-"+n).classed("selected",!0);e.size_accessor?o.attr("r",function(t){return e.scalefns.size(t)+1}):o.attr("r",e.point_size),e.linked&&!MG.globals.link&&(MG.globals.link=!0,t.selectAll(".mg-voronoi .path-"+n).each(function(){t.select(this).on("mouseover")(a,n)}));var s=MG.time_format(e.utc_time,"%b %e, %Y"),i=B(e);e.show_rollover_text&&r.select(".mg-active-datapoint").text(function(){if(e.time_series){var t=new Date(+a.point[e.x_accessor]);return t.setDate(t.getDate()),s(t)+" "+e.yax_units+i(a.point[e.y_accessor])}return e.x_accessor+": "+i(a.point[e.x_accessor])+", "+e.y_accessor+": "+e.yax_units+i(a.point[e.y_accessor])}),e.mouseover&&e.mouseover(a,n)}},this.rolloverOff=function(e){var r=ee(e.target);return function(a,n){e.linked&&MG.globals.link&&(MG.globals.link=!1,t.selectAll(".mg-voronoi .path-"+n).each(function(){t.select(this).on("mouseout")(a,n)}));var o=r.selectAll(".mg-points circle").classed("unselected",!1).classed("selected",!1);e.size_accessor?o.attr("r",e.scalefns.size):o.attr("r",e.point_size),r.select(".mg-active-datapoint").text(""),e.mouseout&&e.mouseout(a,n)}},this.rolloverMove=function(t){return function(e,r){t.mousemove&&t.mousemove(e,r)}},this.update=function(){return this},this.windowListeners=function(){return M(this.args),this},this.init(e)}var r={buffer:16,ls:!1,lowess:!1,point_size:2.5,size_accessor:null,color_accessor:null,size_range:null,color_range:null,size_domain:null,color_domain:null,color_type:"number"};MG.register("point",e,r)}.call(this),function(){"use strict";function t(t){this.args=t,this.init=function(t){return this.args=t,A(t),O(t),b(t),this.is_vertical="vertical"===t.bar_orientation,this.is_vertical?(c(t),o(t)):(l(t),s(t)),this.mainPlot(),this.markers(),this.rollover(),this.windowListeners(),this 3 | },this.mainPlot=function(){var e,r,a,n,o,s=ee(t.target),i=t.data[0],l=s.select("g.mg-barplot"),c=l.empty(),u=c&&t.animate_on_load,f=u||t.transition_on_update,d=t.transition_duration||1e3;c&&(l=s.append("g").classed("mg-barplot",!0)),e=e=l.selectAll(".mg-bar").data(i),e.exit().remove(),e.enter().append("rect").classed("mg-bar",!0),t.predictor_accessor&&(r=l.selectAll(".mg-bar-prediction").data(i),r.exit().remove(),r.enter().append("rect").classed("mg-bar-prediction",!0)),t.baseline_accessor&&(o=l.selectAll(".mg-bar-baseline").data(i),o.exit().remove(),o.enter().append("line").classed("mg-bar-baseline",!0));var p;return f&&(e=e.transition().duration(d),r&&(r=r.transition().duration(d)),o&&(o=o.transition().duration(d))),s.select(".mg-y-axis").node().parentNode.appendChild(l.node()),this.is_vertical?(p=t.scales.X.rangeBand()/1.5,u&&(e.attr({height:0,y:t.scales.Y(0)}),r&&r.attr({height:0,y:t.scales.Y(0)}),o&&o.attr({y1:t.scales.Y(0),y2:t.scales.Y(0)})),e.attr("y",t.scalefns.yf).attr("x",function(e){return t.scalefns.xf(e)+p/2}).attr("width",p).attr("height",function(e){return 0-(t.scalefns.yf(e)-t.scales.Y(0))}),t.predictor_accessor&&(a=t.predictor_proportion,n=a-1,r.attr("y",function(e){return t.scales.Y(0)-(t.scales.Y(0)-t.scales.Y(e[t.predictor_accessor]))}).attr("x",function(e){return t.scalefns.xf(e)+n*p/(2*a)+p/2}).attr("width",p/a).attr("height",function(e){return 0-(t.scales.Y(e[t.predictor_accessor])-t.scales.Y(0))})),t.baseline_accessor&&(a=t.predictor_proportion,o.attr("x1",function(e){return t.scalefns.xf(e)+p/2-p/a+p/2}).attr("x2",function(e){return t.scalefns.xf(e)+p/2+p/a+p/2}).attr("y1",function(e){return t.scales.Y(e[t.baseline_accessor])}).attr("y2",function(e){return t.scales.Y(e[t.baseline_accessor])}))):(p=t.scales.Y.rangeBand()/1.5,u&&(e.attr("width",0),r&&r.attr("width",0),o&&o.attr({x1:t.scales.X(0),x2:t.scales.X(0)})),e.attr("x",t.scales.X(0)).attr("y",function(e){return t.scalefns.yf(e)+p/2}).attr("height",p).attr("width",function(e){return t.scalefns.xf(e)-t.scales.X(0)}),t.predictor_accessor&&(a=t.predictor_proportion,n=a-1,r.attr("x",t.scales.X(0)).attr("y",function(e){return t.scalefns.yf(e)+n*p/(2*a)+p/2}).attr("height",p/a).attr("width",function(e){return t.scales.X(e[t.predictor_accessor])-t.scales.X(0)})),t.baseline_accessor&&(a=t.predictor_proportion,o.attr("x1",function(e){return t.scales.X(e[t.baseline_accessor])}).attr("x2",function(e){return t.scales.X(e[t.baseline_accessor])}).attr("y1",function(e){return t.scalefns.yf(e)+p/2-p/a+p/2}).attr("y2",function(e){return t.scalefns.yf(e)+p/2+p/a+p/2}))),this},this.markers=function(){return w(t),this},this.rollover=function(){var e,r=ee(t.target);r.selectAll(".mg-rollover-rect").remove(),r.selectAll(".mg-active-datapoint").remove(),r.append("text").attr("class","mg-active-datapoint").attr("xml:space","preserve").attr("x",t.width-t.right).attr("y",t.top/2).attr("dy",".35em").attr("text-anchor","end"),e=r.append("g").attr("class","mg-rollover-rect");var a=e.selectAll(".mg-bar-rollover").data(t.data[0]).enter().append("rect").attr("class","mg-bar-rollover");return this.is_vertical?a.attr("x",t.scalefns.xf).attr("y",function(){return t.scales.Y(0)-t.height}).attr("width",t.scales.X.rangeBand()).attr("height",t.height).attr("opacity",0).on("mouseover",this.rolloverOn(t)).on("mouseout",this.rolloverOff(t)).on("mousemove",this.rolloverMove(t)):a.attr("x",t.scales.X(0)).attr("y",t.scalefns.yf).attr("width",t.width).attr("height",t.scales.Y.rangeBand()+2).attr("opacity",0).on("mouseover",this.rolloverOn(t)).on("mouseout",this.rolloverOff(t)).on("mousemove",this.rolloverMove(t)),this},this.rolloverOn=function(t){var e=ee(t.target),r=this.is_vertical?t.x_accessor:t.y_accessor,a=this.is_vertical?t.y_accessor:t.x_accessor,n=this.is_vertical?t.yax_units:t.xax_units;return function(o,s){e.selectAll("text").filter(function(t){return o===t}).attr("opacity",.3);var i=MG.time_format(t.utc_time,"%b %e, %Y"),l=B(t);e.selectAll("g.mg-barplot .mg-bar").filter(function(t,e){return e===s}).classed("active",!0),t.show_rollover_text&&e.select(".mg-active-datapoint").text(function(){if(t.time_series){var e=new Date(+o[a]);return e.setDate(e.getDate()),i(e)+" "+n+l(o[r])}return o[r]+": "+l(o[a])}),t.mouseover&&t.mouseover(o,s)}},this.rolloverOff=function(t){var e=ee(t.target);return function(r,a){e.selectAll("g.mg-barplot .mg-bar").classed("active",!1),e.select(".mg-active-datapoint").text(""),t.mouseout&&t.mouseout(r,a)}},this.rolloverMove=function(t){return function(e,r){t.mousemove&&t.mousemove(e,r)}},this.windowListeners=function(){return M(this.args),this},this.init(t)}var e={y_accessor:"factor",x_accessor:"value",baseline_accessor:null,predictor_accessor:null,predictor_proportion:5,dodge_accessor:null,binned:!0,padding_percentage:0,outer_padding_percentage:.1,height:500,top:20,bar_height:20,left:70,truncate_x_labels:!0,truncate_y_labels:!0,rotate_x_labels:0,rotate_y_labels:0};MG.register("bar",t,e)}.call(this),MG.data_table=function(r){"use strict";return this.args=r,this.args.standard_col={width:150,font_size:12,font_weight:"normal"},this.args.columns=[],this.formatting_options=[["color","color"],["font-weight","font_weight"],["font-style","font_style"],["font-size","font_size"]],this._strip_punctuation=function(t){var e=t.replace(/[^a-zA-Z0-9 _]+/g,""),r=e.replace(/ +?/g,"");return r},this._format_element=function(t,e,r){this.formatting_options.forEach(function(a){var n=a[0],o=a[1];r[o]&&t.style(n,"string"==typeof r[o]||"number"==typeof r[o]?r[o]:r[o](e))})},this._add_column=function(t,e){var r=this.args.standard_col,a=ce(MG.clone(t),MG.clone(r));a.type=e,this.args.columns.push(a)},this.target=function(){var t=arguments[0];return this.args.target=t,this},this.title=function(){return this._add_column(arguments[0],"title"),this},this.text=function(){return this._add_column(arguments[0],"text"),this},this.bullet=function(){return this},this.sparkline=function(){return this},this.number=function(){return this._add_column(arguments[0],"number"),this},this.display=function(){var r=this.args;a(r);var n,o,s,i,l,c,u,f,d,p,h,m,g=r.target,_=t.select(g).append("table").classed("mg-data-table",!0),v=_.append("colgroup"),x=_.append("thead"),y=_.append("tbody");for(s=x.append("tr"),m=0;m=i;i++)s.push({x:i,y:Math.random()-.03*i});e.scales.X=t.scale.linear().domain([0,s.length]).range([e.left+e.buffer,r-e.right-e.buffer]),e.scales.Y=t.scale.linear().domain([-2,2]).range([n-e.bottom-2*e.buffer,e.top]),e.scalefns.xf=function(t){return e.scales.X(t.x)},e.scalefns.yf=function(t){return e.scales.Y(t.y)};var l=t.svg.line().x(e.scalefns.xf).y(e.scalefns.yf).interpolate(e.interpolate),c=t.svg.area().x(e.scalefns.xf).y0(e.scales.Y.range()[0]).y1(e.scalefns.yf).interpolate(e.interpolate),u=o.append("g").attr("class","mg-missing-pane");u.append("svg:rect").classed("mg-missing-background",!0).attr("x",e.buffer).attr("y",e.buffer).attr("width",r-2*e.buffer).attr("height",n-2*e.buffer).attr("rx",15).attr("ry",15),u.append("path").attr("class","mg-main-line mg-line1-color").attr("d",l(s)),u.append("path").attr("class","mg-main-area mg-area1-color").attr("d",c(s))}return o.selectAll(".mg-missing-text").data([e.missing_text]).enter().append("text").attr("class","mg-missing-text").attr("x",r/2).attr("y",n/2).attr("dy",".50em").attr("text-anchor","middle").text(e.missing_text),this},this.init(e)}var r={top:40,bottom:30,right:10,left:10,buffer:8,legend_target:"",width:350,height:220,missing_text:"Data currently missing or unavailable",scalefns:{},scales:{},show_tooltips:!0,show_missing_background:!0,interpolate:"cardinal"};MG.register("missing-data",e,r)}.call(this),MG.raw_data_transformation=A,MG.process_line=G,MG.process_histogram=D,MG.process_categorical_variables=O,MG.process_point=T,MG.add_ls=Y,MG.add_lowess=F,MG.lowess_robust=X,MG.lowess=C,MG.least_squares=z,MG.format_rollover_number=B,MG.path_tween=I,MG.convert={},MG.convert.date=function(e,r,a){return a="undefined"==typeof a?"%Y-%m-%d":a,e=e.map(function(e){var n=t.time.format(a);return e[r]=n.parse(e[r]),e})},MG.convert.number=function(t,e){return t=t.map(function(t){return t[e]=Number(t[e]),t})},MG.time_format=function(e,r){return e?t.time.format.utc(r):t.time.format(r)};var me=function(t,e,r){var a={};if(null===t)return t;if(Array.prototype.forEach&&t.forEach===Array.prototype.forEach)t.forEach(e,r);else if(t.length===+t.length){for(var n=0,o=t.length;o>n;n++)if(e.call(r,t[n],n,t)===a)return}else for(var s in t)if(e.call(r,t[s],s,t)===a)return;return t};return MG.merge_with_defaults=ce,MG.clone=function(t){var e;if(null===t||"object"!=typeof t)return t;if(t instanceof Date)return e=new Date,e.setTime(t.getTime()),e;if(t instanceof Array){e=[];for(var r=0,a=t.length;a>r;r++)e[r]=MG.clone(t[r]);return e}if(t instanceof Object){e={};for(var n in t)t.hasOwnProperty(n)&&(e[n]=MG.clone(t[n]));return e}throw new Error("Unable to copy obj! Its type isn't supported.")},MG.arr_diff=ue,MG.warn_deprecation=fe,MG.truncate_text=de,MG.wrap_text=pe,MG.error=he,MG}); --------------------------------------------------------------------------------