├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .travis.yml ├── .travis └── keys.tar.enc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.sbt ├── dev.config.json ├── devloop.js ├── example └── src │ └── main │ ├── resources │ └── simplelogger.properties │ └── scala │ └── com │ └── criteo │ └── slab │ └── example │ ├── GraphiteLauncher.scala │ ├── Launcher.scala │ └── SimpleBoard.scala ├── karma.conf.js ├── package-lock.json ├── package.json ├── project ├── build.properties └── plugins.sbt ├── src ├── main │ ├── scala │ │ └── com │ │ │ └── criteo │ │ │ └── slab │ │ │ ├── app │ │ │ ├── BoardConfig.scala │ │ │ ├── StateService.scala │ │ │ ├── Stats.scala │ │ │ └── WebServer.scala │ │ │ ├── core │ │ │ ├── Board.scala │ │ │ ├── Box.scala │ │ │ ├── Check.scala │ │ │ ├── CheckResult.scala │ │ │ ├── Codec.scala │ │ │ ├── Context.scala │ │ │ ├── Executor.scala │ │ │ ├── Layout.scala │ │ │ ├── ReadableView.scala │ │ │ ├── Status.scala │ │ │ ├── Store.scala │ │ │ ├── View.scala │ │ │ └── package.scala │ │ │ ├── lib │ │ │ ├── InMemoryStore.scala │ │ │ ├── Values.scala │ │ │ └── graphite │ │ │ │ ├── GraphiteCodecs.scala │ │ │ │ ├── GraphiteMetric.scala │ │ │ │ └── GraphiteStore.scala │ │ │ ├── package.scala │ │ │ └── utils │ │ │ ├── HttpUtils.scala │ │ │ ├── Jsonable.scala │ │ │ └── package.scala │ └── webapp │ │ ├── js │ │ ├── actions.js │ │ ├── api.js │ │ ├── components │ │ │ ├── App.js │ │ │ ├── BoardList.js │ │ │ ├── Box.js │ │ │ ├── BoxModal.js │ │ │ ├── Calendar.js │ │ │ ├── CheckList.js │ │ │ ├── ErrorPage.js │ │ │ ├── Graph.js │ │ │ ├── StatusFavicon.js │ │ │ ├── Timeline.js │ │ │ └── TimelineController.js │ │ ├── index.js │ │ ├── lib │ │ │ ├── Button.js │ │ │ └── index.js │ │ ├── router.js │ │ ├── sagas │ │ │ └── index.js │ │ ├── state.js │ │ ├── store.js │ │ └── utils │ │ │ ├── api.js │ │ │ ├── fetcher.js │ │ │ └── index.js │ │ ├── public │ │ ├── fonts │ │ │ ├── Lato-Black.ttf │ │ │ ├── Lato-BlackItalic.ttf │ │ │ ├── Lato-Bold.ttf │ │ │ ├── Lato-BoldItalic.ttf │ │ │ ├── Lato-Hairline.ttf │ │ │ ├── Lato-HairlineItalic.ttf │ │ │ ├── Lato-Italic.ttf │ │ │ ├── Lato-Light.ttf │ │ │ ├── Lato-LightItalic.ttf │ │ │ ├── Lato-Regular.ttf │ │ │ ├── MaterialIcons-Regular.ttf │ │ │ ├── Montserrat-Bold.ttf │ │ │ ├── Montserrat-Regular.ttf │ │ │ ├── Raleway-Black.ttf │ │ │ ├── Raleway-Bold.ttf │ │ │ ├── Raleway-ExtraBold.ttf │ │ │ ├── Raleway-ExtraLight.ttf │ │ │ ├── Raleway-Light.ttf │ │ │ ├── Raleway-Medium.ttf │ │ │ ├── Raleway-Regular.ttf │ │ │ ├── Raleway-SemiBold.ttf │ │ │ └── Raleway-Thin.ttf │ │ ├── images │ │ │ ├── favicon.png │ │ │ └── logo.sketch │ │ └── index.ejs │ │ └── style │ │ ├── components │ │ ├── BoardList.styl │ │ ├── Box.styl │ │ ├── BoxModal.styl │ │ ├── Calendar.styl │ │ ├── ErrorPage.styl │ │ └── Timeline.styl │ │ ├── fonts.styl │ │ ├── global.styl │ │ ├── index.styl │ │ ├── lib │ │ └── Button.styl │ │ ├── reset.styl │ │ └── utils.styl └── test │ ├── scala │ └── com │ │ └── criteo │ │ └── slab │ │ ├── app │ │ └── StateServiceSpec.scala │ │ ├── core │ │ ├── BoardSpec.scala │ │ ├── ExecutorSpec.scala │ │ ├── LayoutSpec.scala │ │ ├── ReadableViewSpec.scala │ │ └── package.scala │ │ ├── helper │ │ └── FutureTests.scala │ │ ├── lib │ │ ├── GraphiteMetricSpec.scala │ │ ├── GraphiteStoreSpec.scala │ │ └── InMemoryStoreSpec.scala │ │ └── utils │ │ └── packageSpec.scala │ └── webapp │ ├── sagas.spec.js │ └── utils │ └── api.spec.js ├── webpack.config.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true, 7 | "mocha": true 8 | }, 9 | "extends": "eslint:recommended", 10 | "parserOptions": { 11 | "ecmaFeatures": { 12 | "experimentalObjectRestSpread": true, 13 | "jsx": true 14 | }, 15 | "sourceType": "module" 16 | }, 17 | "parser": "babel-eslint", 18 | "plugins": [ 19 | "react" 20 | ], 21 | "rules": { 22 | "indent": [ 23 | "error", 24 | 2, 25 | { 26 | "SwitchCase": 1 27 | } 28 | ], 29 | "linebreak-style": [ 30 | "error", 31 | "unix" 32 | ], 33 | "quotes": [ 34 | "error", 35 | "single" 36 | ], 37 | "semi": [ 38 | "warn", 39 | "always" 40 | ], 41 | "react/jsx-uses-react": 1, 42 | "react/jsx-uses-vars": 1, 43 | "react/jsx-no-undef": 1, 44 | "no-unused-vars": [ 45 | "error", 46 | { 47 | "argsIgnorePattern": "_" 48 | } 49 | ] 50 | }, 51 | "globals": { 52 | "expect": true 53 | } 54 | }; -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [options] 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | target/ 3 | project/* 4 | !project/*.sbt 5 | !project/build.properties 6 | node_modules/ 7 | examples/ 8 | dist/ 9 | npm-debug.log -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | sudo: required 3 | dist: trusty 4 | jdk: 5 | - oraclejdk8 6 | env: 7 | global: 8 | secure: FNZoZ7N5uRNzXzi3TXDLjbQi1bKmbuL8iPJin2y6+FRJCVchFhGLbCKfgIqOzlP1Qwa/2Zqoy55cxwr/Qu1WSBfksjOkrSyyKbguxRbibjsraP0U4vVTmf++y4CTSOGNlveWTNZObd3F4Tax3SwRjs4l7byuILrM4XIX36RvipqLMNbU7SKH7dlycxTjosKD40JduJDkCHd6M/9EAAclQJhc2erB2A48YH17YOcDZH51e80NKakSDccY2xDkz7j5mRSenCrZhNaOWkkitveLOM24LwMhjoEYr4q62DbVZscLOxbv6x2pjkMxhXZbBoGvcAX0mH9HPCgwLMYmW4Jrn9H+4Tk5hhFnV1JwQ4VwmInrHLhuPwW2q1+r8QgKKiJGKv//07cAlRgVBeuubhac86F1QXnvCszwxym1TlaMmcJ0IUYBvyfepQUyAXVC709J/vMG5yKfnmFIImdajYuPznY8MqPxI6idIgkYeRNgnysJdQ0g6uiQFRm/Kl9xbZ/PqR9k2O9KFbDPMRe84TDHCabu5C+7kko2BDls/WfxOfc88F01iGGLEUCOWtGi6A4jjcJKhudgL3MRnxJeU/pLQjR+kICw5iWJl7JRmPACedzn5FiSeoYzy+9RQDnK0kbMLaw4T/tTiMHr86syDwx+Tis0EnhACo5H/IjYCwg1iAc= 9 | before_install: 10 | - nvm install node 11 | - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - 12 | - echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list 13 | - sudo apt-get update && sudo apt-get -y install yarn 14 | install: 15 | - yarn 16 | script: 17 | - yarn run lint 18 | - yarn run flow 19 | - yarn run test -- --single-run 20 | - yarn run build 21 | - sbt +test 22 | before_deploy: 23 | - openssl aes-256-cbc -K $encrypted_8382f1c42598_key -iv $encrypted_8382f1c42598_iv 24 | -in .travis/keys.tar.enc -out .travis/keys.tar -d 25 | - tar xvf .travis/keys.tar 26 | deploy: 27 | provider: script 28 | script: sbt +publishSigned "sonatypeReleaseAll com.criteo" 29 | skip_cleanup: true 30 | on: 31 | tags: true 32 | branch: master 33 | cache: 34 | directories: 35 | - $HOME/.ivy2/cache 36 | - $HOME/.sbt 37 | - $HOME/.cache/yarn 38 | before_cache: 39 | - find $HOME/.ivy2/cache -name "ivydata-*.properties" -print -delete 40 | - find $HOME/.sbt -name "*.lock" -print -delete 41 | -------------------------------------------------------------------------------- /.travis/keys.tar.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/.travis/keys.tar.enc -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to SLAB 2 | 3 | 4 | ## Getting started 5 | 6 | There are two approaches to start the development cycle 7 | 8 | * Use [loop](https://github.com/criteo/loop): 9 | 10 | Simply execute `npm run start`, it will build and start the server of the `example` project 11 | 12 | * Alternatively, you can manually lauch the webapp and the `example` project's server: 13 | 14 | - Example project 15 | 16 | This project is created to facilitate the development project, it's located in the `example` folder, 17 | 18 | - Compile the project 19 | 20 | `sbt example/compile` 21 | 22 | - Launch the main class 23 | 24 | `com.criteo.slab.example.Launcher` 25 | 26 | 27 | - Web application 28 | 29 | `src/main/webapp` contains all web application code and resources. 30 | 31 | - Install npm packages 32 | 33 | `npm install` 34 | 35 | - Start web dev server 36 | 37 | `npm run serve -- --env.serverPort=$SERVER_PORT` 38 | 39 | where `SERVER_PORT` should be the port of SLAB web server instance 40 | 41 | ## Utilities 42 | 43 | - Generate a package for all supported Scala versions 44 | 45 | `sbt +package` 46 | 47 | - Test Scala code 48 | 49 | `sbt test` 50 | 51 | - Test JavaScript code 52 | 53 | `npm run test` 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slab 2 | 3 | [![Build Status](https://travis-ci.org/criteo/slab.svg?branch=master)](https://travis-ci.org/criteo/slab) 4 | [![Latest version](https://index.scala-lang.org/criteo/slab/slab/latest.svg)](https://index.scala-lang.org/criteo/slab/slab) 5 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 6 | 7 | 8 | An extensible Scala framework for creating monitoring dashboards. 9 | 10 | ![screencast](https://criteo.github.io/slab/public/slab.gif) 11 | 12 | ## Installation 13 | 14 | Add the following to your project's dependencies: 15 | 16 | - sbt 17 | 18 | `"com.criteo" %% "slab" % "latest.release"` 19 | 20 | - Maven 21 | 22 | ```xml 23 | 24 | com.criteo 25 | slab_${SCALA_SHORT_VERSION} 26 | LATEST 27 | 28 | ``` 29 | 30 | Slab is available for Scala `2.11` and `2.12` 31 | 32 | ## Getting started 33 | 34 | The easiest way to get started is to follow the guides: 35 | - [Guide for creating a Slab board](https://criteo.github.com/slab/examples/SimpleBoard.scala.html) 36 | - [Guide for creating a Slab server](https://criteo.github.com/slab/examples/Launcher.scala.html) 37 | 38 | 39 | The documentation is also available: 40 | - [API documentation](https://criteo.github.com/slab/api/com/criteo/slab) 41 | 42 | ## Running the example project 43 | 44 | Ensure you have sbt and npm installed and then: 45 | ``` 46 | $> npm install 47 | $> npm start 48 | ``` 49 | 50 | ## Contribution 51 | 52 | Please read the [contribution guide](/CONTRIBUTING.md) 53 | 54 | ## License 55 | 56 | Licensed under the [Apache License 2.0](/LICENSE) 57 | 58 | ## Copyright 59 | 60 | Copyright © Criteo, 2017. 61 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import scala.sys.process.Process 2 | 3 | lazy val commonSettings = Seq( 4 | organization := "com.criteo", 5 | version := "0.4.14", 6 | scalaVersion := "2.12.4", 7 | crossScalaVersions := Seq("2.11.8", "2.12.4"), 8 | scalacOptions := Seq("-deprecation"), 9 | credentials += Credentials( 10 | "Sonatype Nexus Repository Manager", 11 | "oss.sonatype.org", 12 | "criteo-oss", 13 | sys.env.getOrElse("SONATYPE_PASSWORD", "") 14 | ), 15 | publishTo := Some( 16 | if (isSnapshot.value) 17 | Opts.resolver.sonatypeSnapshots 18 | else 19 | Opts.resolver.sonatypeStaging 20 | ), 21 | pgpPassphrase := sys.env.get("SONATYPE_PASSWORD").map(_.toArray), 22 | pgpSecretRing := file(".travis/secring.gpg"), 23 | pgpPublicRing := file(".travis/pubring.gpg"), 24 | pomExtra in Global := { 25 | https://github.com/criteo/slab 26 | 27 | 28 | Apache 2 29 | http://www.apache.org/licenses/LICENSE-2.0.txt 30 | 31 | 32 | 33 | scm:git:github.com/criteo/slab.git 34 | scm:git:git@github.com:criteo/slab.git 35 | github.com/criteo/slab 36 | 37 | 38 | 39 | Sheng Ran 40 | s.ran@criteo.com 41 | https://github.com/jedirandy 42 | Criteo 43 | http://www.criteo.com 44 | 45 | 46 | Guillaume Bort 47 | g.bort@criteo.com 48 | https://github.com/guillaumebort 49 | Criteo 50 | http://www.criteo.com 51 | 52 | 53 | Justin Coffey 54 | j.coffey@criteo.com 55 | https://github.com/jqcoffey 56 | Criteo 57 | http://www.criteo.com 58 | 59 | 60 | Vincent Guerci 61 | v.guerci@criteo.com 62 | https://github.com/vguerci 63 | Criteo 64 | http://www.criteo.com 65 | 66 | 67 | Jean-Baptiste Catté 68 | jb.catte@criteo.com 69 | https://github.com/jbkt 70 | Criteo 71 | http://www.criteo.com 72 | 73 | 74 | Tudor Mihordea 75 | t.mihordea@criteo.com 76 | https://github.com/tmihordea 77 | Criteo 78 | http://www.criteo.com 79 | 80 | 81 | Cristian Rotundu 82 | c.rotundu@criteo.com 83 | https://github.com/crotundu 84 | Criteo 85 | http://www.criteo.com 86 | 87 | 88 | } 89 | ) 90 | 91 | lazy val root = (project in file(".")) 92 | .settings(commonSettings: _*) 93 | .settings( 94 | name := "slab", 95 | libraryDependencies ++= Seq( 96 | "org.slf4j" % "slf4j-api" % "1.7.25", 97 | "org.json4s" %% "json4s-native" % "3.4.2", 98 | "com.criteo.lolhttp" %% "lolhttp" % "0.13.0", 99 | "com.github.cb372" %% "scalacache-core" % "0.22.0", 100 | "com.github.cb372" %% "scalacache-caffeine" % "0.22.0", 101 | "com.chuusai" %% "shapeless" % "2.3.3", 102 | "org.scalatest" %% "scalatest" % "3.0.1" % Test, 103 | "org.mockito" % "mockito-core" % "2.7.0" % Test 104 | ), 105 | // Disable parallel execution until it stops causing deadlocks with Mockito 106 | parallelExecution in Test := false 107 | ) 108 | 109 | lazy val example = (project in file("example")) 110 | .settings(commonSettings: _*) 111 | .settings( 112 | skip in publish := true, 113 | libraryDependencies ++= Seq( 114 | "org.slf4j" % "slf4j-simple" % "1.7.25" 115 | ) 116 | ) 117 | .settings( 118 | Option(System.getenv().get("GENERATE_EXAMPLE_DOC")).map { _ => 119 | Seq( 120 | autoCompilerPlugins := true, 121 | addCompilerPlugin("com.criteo.socco" %% "socco-plugin" % "0.1.6"), 122 | scalacOptions := Seq( 123 | "-P:socco:out:examples", 124 | "-P:socco:package_scala:http://www.scala-lang.org/api/current/", 125 | "-P:socco:package_lol.http:https://criteo.github.io/lolhttp/api/", 126 | "-P:socco:package_com.criteo.slab:https://criteo.github.io/slab/api/" 127 | ) 128 | ) 129 | }.getOrElse(Nil): _* 130 | ) 131 | .dependsOn(root) 132 | 133 | lazy val buildWebapp = taskKey[Unit]("build webapp") 134 | 135 | buildWebapp := { 136 | Process(s"npm run build -- -p --env.out=${crossTarget.value}/classes") ! 137 | } 138 | 139 | packageBin in Compile := ((packageBin in Compile) dependsOn buildWebapp).value 140 | -------------------------------------------------------------------------------- /dev.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": {}, 3 | "mainClass": "com.criteo.slab.example.Launcher", 4 | "extraClasspath": "example/target/scala-2.12/classes" 5 | } 6 | -------------------------------------------------------------------------------- /devloop.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | let config = loadJson('dev.config.json') 3 | 4 | let installDependencies = run({ 5 | name: 'npm', 6 | cwd: '.', 7 | sh: 'npm install', 8 | watch: 'package.json' 9 | }) 10 | 11 | let flow = run({ 12 | name: 'flow', 13 | cwd: '.', 14 | sh: 'node ./node_modules/flow-bin/cli.js', 15 | watch: ['src/**/*.js', 'src/**/*.jsx'] 16 | }).dependsOn(installDependencies) 17 | 18 | let webpack = run({ 19 | name: 'webpack', 20 | cwd: '.', 21 | sh: './node_modules/.bin/webpack --bail --env.out=target/scala-2.12/classes', 22 | watch: 'webpack.config.js' 23 | }).dependsOn(flow, installDependencies) 24 | 25 | let sbt = startSbt({ 26 | sh: 'sbt', 27 | watch: ['build.sbt'] 28 | }) 29 | 30 | let packageDependencies = sbt.run({ 31 | name: 'server deps', 32 | command: 'assemblyPackageDependency' 33 | }) 34 | 35 | let compileServer = sbt.run({ 36 | name: 'scalac', 37 | command: 'example/compile', 38 | watch: ['src/**/*.scala'] 39 | }).dependsOn(packageDependencies) 40 | 41 | let separator = platform == 'win32' ? ';': ':' 42 | let server = runServer({ 43 | name: 'server', 44 | httpPort, 45 | env: config.env, 46 | sh: `java -cp "target/scala-2.12/*${separator}target/scala-2.12/classes${separator}${config.extraClasspath || ''}" ${config.mainClass} ${httpPort}` 47 | }).dependsOn(compileServer) 48 | 49 | proxy(server, 8080).dependsOn(webpack) 50 | -------------------------------------------------------------------------------- /example/src/main/resources/simplelogger.properties: -------------------------------------------------------------------------------- 1 | org.slf4j.simpleLogger.defaultLogLevel=debug 2 | org.slf4j.simpleLogger.log.lol.http=warn -------------------------------------------------------------------------------- /example/src/main/scala/com/criteo/slab/example/GraphiteLauncher.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.example 2 | 3 | import java.time.Duration 4 | 5 | import com.criteo.slab.app.WebServer 6 | import com.criteo.slab.lib.graphite.{GraphiteStore, GraphiteCodecs} 7 | import org.slf4j.LoggerFactory 8 | 9 | object GraphiteLauncher { 10 | 11 | import scala.concurrent.ExecutionContext.Implicits.global 12 | import SimpleBoard._ 13 | import GraphiteCodecs._ 14 | 15 | val logger = LoggerFactory.getLogger(this.getClass) 16 | 17 | def main(args: Array[String]): Unit = { 18 | val maybeStore = for { 19 | host <- sys.env.get("GRAPHITE_HOST") 20 | port <- sys.env.get("GRAPHITE_PORT").map(_.toInt) 21 | webHost <- sys.env.get("GRAPHITE_WEB_HOST") 22 | } yield new GraphiteStore(host, port, webHost, Duration.ofSeconds(60), Some("slab.example"), Some("slab.example.slo")) 23 | implicit val store = maybeStore match { 24 | case Some(s) => 25 | logger.info("[Slab Example] using Graphite store") 26 | s 27 | case None => 28 | logger.error("Graphite store is not set up") 29 | sys.exit(1) 30 | } 31 | 32 | WebServer(statsDays = 14) 33 | .attach(board) 34 | .apply(8080) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example/src/main/scala/com/criteo/slab/example/Launcher.scala: -------------------------------------------------------------------------------- 1 | // Example: A Slab server 2 | // 3 | // Guide for creating a Slab server 4 | package com.criteo.slab.example 5 | 6 | import java.net.URLDecoder 7 | 8 | import cats.effect.IO 9 | import com.criteo.slab.app.StateService.NotFoundError 10 | import com.criteo.slab.app.WebServer 11 | import com.criteo.slab.lib.InMemoryStore 12 | import lol.http._ 13 | import org.slf4j.LoggerFactory 14 | 15 | object Launcher { 16 | 17 | import SimpleBoard._ 18 | 19 | import scala.concurrent.ExecutionContext.Implicits.global 20 | 21 | private val logger = LoggerFactory.getLogger(this.getClass) 22 | 23 | def main(args: Array[String]): Unit = { 24 | require(args.length == 1, "you must supply a port!") 25 | val port = args(0).toInt 26 | // You should provide codec for checked value types for values to be persistent in a store 27 | import InMemoryStore.codec 28 | // Define a value store for uploading and restoring history 29 | implicit val store = new InMemoryStore 30 | // Create a web server 31 | WebServer() 32 | // You can define custom routes, Slab web server is built with [lolhttp](https://github.com/criteo/lolhttp) 33 | .withRoutes(stateService => { 34 | case GET at "/api/heartbeat" => Ok("ok") 35 | case GET at url"/api/boards/$board/status" => 36 | IO.fromFuture(IO( 37 | stateService 38 | .current(URLDecoder.decode(board, "UTF-8")).map(view => Ok(view.status.name)) 39 | .recover { 40 | case NotFoundError(message) => NotFound(message) 41 | case e => 42 | logger.error(e.getMessage, e) 43 | InternalServerError 44 | } 45 | )) 46 | }) 47 | // Attach a board to the server 48 | .attach(board) 49 | // Launch the server at port 50 | .apply(port) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /example/src/main/scala/com/criteo/slab/example/SimpleBoard.scala: -------------------------------------------------------------------------------- 1 | // Example: A simple Slab board 2 | // 3 | // Guide for creating a Slab board 4 | package com.criteo.slab.example 5 | 6 | import java.text.DecimalFormat 7 | 8 | import com.criteo.slab.core._ 9 | import com.criteo.slab.lib.Values.{Latency, Version} 10 | 11 | import scala.concurrent.Future 12 | import scala.util.Random 13 | 14 | object SimpleBoard { 15 | 16 | // A box for the web service, which contains latency checks 17 | lazy val webService = Box( 18 | "Webservice alpha", 19 | // The list of checks concerned in this box 20 | makeVersionCheck("web.service.version", "Version", 1.2, Status.Success, Some("V1.2")) :: Nil, 21 | // A function that aggregates the views of the children checks 22 | takeMostCritical[Version], 23 | // You can write a description for the box (supports Markdown) 24 | Some( 25 | """ 26 | |# Doc for web server (markdown) 27 | |
28 | |## URL 29 | |- http://example.io 30 | |- http://10.0.0.1 31 | """.stripMargin) 32 | ) 33 | 34 | lazy val gateway = Box( 35 | "Gateway Beta", 36 | List( 37 | makeRandomLatencyCheck("gateway.beta.eu", "EU Gateway latency"), 38 | makeRandomLatencyCheck("gateway.beta.us", "US Gateway latency") 39 | ), 40 | takeMaxLatency 41 | ) 42 | 43 | lazy val pipelineZeta = Box( 44 | "Pipeline Zeta", 45 | List( 46 | makeRandomLatencyCheck("pipeline.zeta.a", "Job A latency"), 47 | makeRandomLatencyCheck("pipeline.zeta.b", "Job B latency"), 48 | makeRandomLatencyCheck("pipeline.zeta.c", "Job C latency") 49 | ), 50 | takeMaxLatency 51 | ) 52 | 53 | lazy val pipelineOmega = Box( 54 | "Pipeline Omega", 55 | List( 56 | makeRandomLatencyCheck("pipeline.omega.a", "Job A latency", Some("Top Job")), 57 | makeRandomLatencyCheck("pipeline.omega.b", "Job B latency"), 58 | makeRandomLatencyCheck("pipeline.omega.c", "Job C latency"), 59 | makeRandomLatencyCheck("pipeline.omega.d", "Job D latency"), 60 | makeRandomLatencyCheck("pipeline.omega.e", "Job E latency") 61 | ), 62 | takeMaxLatency, 63 | // Limit the number of labels shown on the box 64 | labelLimit = Some(3) 65 | ) 66 | 67 | lazy val databaseKappa = Box( 68 | "Database Kappa", 69 | List( 70 | makeRandomLatencyCheck("database.kappa.dc1", "DC1 Latency"), 71 | makeRandomLatencyCheck("database.kappa.dc2", "DC2 Latency") 72 | ), 73 | takeMaxLatency 74 | ) 75 | 76 | lazy val ui = Box( 77 | "User interface", 78 | makeVersionCheck("ui.version", "Version 1000", 1000, Status.Success) :: Nil, 79 | takeMostCritical[Version], 80 | labelLimit = Some(0) 81 | ) 82 | 83 | // Define the layout of the board 84 | lazy val layout = Layout( 85 | // Define 3 columns, each takes 33.3% of the width of the board 86 | Column( 87 | 33.3, 88 | // We put the web server box in this row 89 | Row("Tier 1", 100, Seq(webService)) 90 | ), 91 | Column( 92 | 33.3, 93 | // Define two rows, each row takes 50% of the height of the column 94 | Row("Tier 2 - 1", 50, Seq(gateway)), 95 | Row("Tier 2 - 2", 50, Seq(pipelineZeta, pipelineOmega)) 96 | ), 97 | Column( 98 | 33.3, 99 | Row("Tier 3", 100, Seq(databaseKappa, ui)) 100 | ) 101 | ) 102 | 103 | // Create a board 104 | lazy val board = Board( 105 | "Simple board", 106 | webService :: gateway :: pipelineZeta :: pipelineOmega :: databaseKappa :: ui :: HNil, 107 | (views, _) => views.maxBy(_._2)._2, 108 | layout, 109 | // Declare the links between boxes, which be shown in the UI 110 | Seq(webService -> gateway, gateway -> pipelineZeta, pipelineZeta -> databaseKappa) 111 | ) 112 | 113 | // A function that aggregates the views of the children checks 114 | def takeMostCritical[T](views: Map[Check[T], CheckResult[T]], ctx: Context): View = views.maxBy(_._2.view)._2.view.copy(message = "") 115 | 116 | // Aggregate the latency values and display the max latency 117 | def takeMaxLatency(views: Map[Check[Latency], CheckResult[Latency]], ctx: Context): View = { 118 | val maxLatency = views.map(_._2.value.map(_.underlying)).flatten.foldLeft(0L)(Math.max) 119 | View( 120 | views.maxBy(_._2.view)._2.view.status, 121 | s"max latency $maxLatency ms" 122 | ) 123 | } 124 | 125 | val versionFormatter = new DecimalFormat("##.###") 126 | 127 | // A mock check generator for version values 128 | def makeVersionCheck(id: String, title: String, value: Double, status: Status, label: Option[String] = None) = Check[Version]( 129 | id, 130 | title, 131 | () => Future.successful(Version(value)), 132 | display = (v: Version, _: Context) => View(status, s"version ${versionFormatter format v.underlying}", label) 133 | ) 134 | 135 | // A mock check generator for latency values 136 | def makeRandomLatencyCheck(id: String, title: String, label: Option[String] = None) = Check[Latency]( 137 | id, 138 | title, 139 | () => Future.successful(Latency(Random.nextInt(1000))), 140 | display = (l: Latency, _: Context) => { 141 | val status = if (l.underlying >= 990) { 142 | Status.Error 143 | } else if (l.underlying >= 980) { 144 | Status.Warning 145 | } else { 146 | Status.Success 147 | } 148 | // A view represents the result of a check, such as status, message and label, the message can be in Markdown 149 | View(status, s"latency **${l.underlying}** ms", label) 150 | } 151 | ) 152 | } 153 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpackConfig = require('./webpack.config'); 2 | module.exports = function(config) { 3 | config.set({ 4 | browsers: ['PhantomJS'], 5 | singleRun: false, 6 | frameworks: ['mocha', 'chai'], 7 | files: [ 8 | 'node_modules/babel-polyfill/dist/polyfill.js', 9 | { 10 | pattern: 'src/test/webapp/**/*.spec.js', 11 | watched: false, 12 | included: true, 13 | served: true 14 | } 15 | ], 16 | preprocessors: { 17 | 'src/**/*.js': ['webpack', 'sourcemap'] 18 | }, 19 | webpack: webpackConfig, 20 | webpackMiddleware: { 21 | noInfo: true 22 | }, 23 | reporters: ['progress'], 24 | color: true, 25 | autoWatch: true 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "webpack", 4 | "flow": "flow check src", 5 | "lint": "eslint src", 6 | "serve": "webpack-dev-server", 7 | "test": "karma start", 8 | "start": "loop" 9 | }, 10 | "devDependencies": { 11 | "babel-core": "^6.26.0", 12 | "babel-eslint": "^7.1.1", 13 | "babel-loader": "^6.2.4", 14 | "babel-plugin-transform-class-properties": "^6.10.2", 15 | "babel-plugin-transform-exponentiation-operator": "^6.24.1", 16 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 17 | "babel-preset-es2015": "^6.6.0", 18 | "babel-preset-react": "^6.5.0", 19 | "chai": "^3.5.0", 20 | "copy-webpack-plugin": "^4.2.3", 21 | "css-loader": "^0.26.1", 22 | "devloop": "^0.1.10", 23 | "eslint": "^3.14.1", 24 | "eslint-plugin-react": "^6.9.0", 25 | "file-loader": "^0.11.1", 26 | "flow-bin": "^0.39.0", 27 | "html-webpack-plugin": "^2.28.0", 28 | "karma": "^1.7.1", 29 | "karma-chai": "^0.1.0", 30 | "karma-mocha": "^1.3.0", 31 | "karma-phantomjs-launcher": "^1.0.2", 32 | "karma-sourcemap-loader": "^0.3.7", 33 | "karma-webpack": "^2.0.8", 34 | "mocha": "^3.5.3", 35 | "phantomjs-prebuilt": "^2.1.16", 36 | "style-loader": "^0.13.1", 37 | "stylus": "^0.54.5", 38 | "stylus-loader": "^2.4.0", 39 | "webpack": "^2.2.1", 40 | "webpack-dev-server": "^2.9.5", 41 | "yargs": "^6.3.0" 42 | }, 43 | "dependencies": { 44 | "babel-polyfill": "^6.26.0", 45 | "classnames": "^2.2.5", 46 | "lodash": "^4.17.4", 47 | "marked": "^0.3.9", 48 | "moment": "^2.19.3", 49 | "numeral": "^2.0.6", 50 | "react": "^15.6.2", 51 | "react-addons-create-fragment": "^15.6.2", 52 | "react-datetime": "^2.11.1", 53 | "react-dom": "^15.6.2", 54 | "react-modal": "^1.6.5", 55 | "react-redux": "^5.0.6", 56 | "react-transition-group": "^2.2.1", 57 | "recharts": "^1.0.0-beta.9", 58 | "redux": "^3.5.2", 59 | "redux-saga": "^0.14.3", 60 | "redux-url": "^1.2.3", 61 | "vis": "^4.21.0", 62 | "whatwg-fetch": "^1.0.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.0.4 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.0") 2 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.0") 3 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6") 4 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/app/BoardConfig.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.app 2 | 3 | import com.criteo.slab.core.{Box, Layout} 4 | import com.criteo.slab.utils.Jsonable 5 | import org.json4s.JsonAST.{JArray, JString} 6 | import org.json4s.{CustomSerializer, Serializer} 7 | 8 | /** 9 | * Represents the configuration of a board, to be used by the web app 10 | * @param title The title 11 | * @param layout The layout 12 | * @param links The links 13 | */ 14 | private[slab] case class BoardConfig( 15 | title: String, 16 | layout: Layout, 17 | links: Seq[(Box[_], Box[_])] = Seq.empty, 18 | slo: Double 19 | ) 20 | 21 | object BoardConfig { 22 | implicit object ToJSON extends Jsonable[BoardConfig] { 23 | override val serializers: Seq[Serializer[_]] = 24 | implicitly[Jsonable[Box[_]]].serializers ++ 25 | implicitly[Jsonable[Layout]].serializers :+ 26 | LinkSer 27 | 28 | object LinkSer extends CustomSerializer[Box[_] Tuple2 Box[_]](_ => ( { 29 | case _ => throw new NotImplementedError("Not deserializable") 30 | }, { 31 | case (Box(title1, _, _, _, _), Box(title2, _, _, _, _)) => JArray(List(JString(title1), JString(title2))) 32 | } 33 | )) 34 | 35 | } 36 | } 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/app/StateService.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.app 2 | 3 | import java.time.{Instant, ZoneOffset, ZonedDateTime} 4 | import java.time.temporal.ChronoUnit 5 | import java.util.concurrent.{Executors, TimeUnit} 6 | 7 | import com.criteo.slab.core.{BoardView, Executor, Status} 8 | import org.slf4j.LoggerFactory 9 | 10 | import scala.concurrent.duration.Duration 11 | import scala.concurrent.{ExecutionContext, Future} 12 | import scalacache.caffeine.CaffeineCache 13 | import scalacache.modes.scalaFuture._ 14 | 15 | private[slab] class StateService( 16 | val executors: Seq[Executor[_]], 17 | val interval: Int, 18 | val statsDays: Int = 730 19 | )(implicit ec: ExecutionContext) { 20 | 21 | import StateService._ 22 | 23 | import scalacache._ 24 | import scalacache.memoization._ 25 | 26 | private val logger = LoggerFactory.getLogger(this.getClass) 27 | 28 | private implicit lazy val boardCache = CaffeineCache[BoardView] 29 | private implicit lazy val historyCache = CaffeineCache[Map[Long, String]] 30 | private implicit lazy val sloCache = CaffeineCache[Map[Long, Double]] 31 | 32 | private lazy val scheduler = Executors.newSingleThreadScheduledExecutor() 33 | 34 | def start(): Unit = { 35 | scheduler.scheduleAtFixedRate(Poller, 0, interval, TimeUnit.SECONDS) 36 | } 37 | 38 | // Current board view 39 | def current(board: String): Future[BoardView] = get[Future, BoardView](board).flatMap { 40 | case Some(boardView) => Future.successful(boardView) 41 | case None => 42 | if (executors.exists(_.board.title == board)) 43 | Future.failed(NotReadyError(s"$board is not ready")) 44 | else 45 | Future.failed(NotFoundError(s"$board does not exist")) 46 | } 47 | 48 | // All available board views 49 | def all(): Future[Seq[BoardView]] = 50 | Future 51 | .sequence(executors.map { e => get[Future, BoardView](e.board.title) }) 52 | .map(_.collect { case Some(boardView) => boardView }) 53 | 54 | // History of last 24 hours 55 | def history(board: String): Future[Map[Long, String]] = memoizeF[Future, Map[Long, String]](Some(Duration.create(10, TimeUnit.MINUTES))) { 56 | logger.info(s"Updating history of $board") 57 | val now = Instant.now 58 | executors.find(_.board.title == board) 59 | .fold(Future.failed(NotFoundError(s"$board does not exist")): Future[Map[Long, String]]) { 60 | _ 61 | .fetchHistory(now.minus(1, ChronoUnit.DAYS), now) 62 | .map(_.map { case (ts, view) => (ts, view.status.name) }.toMap) 63 | } 64 | } 65 | 66 | def stats(board: String): Future[Map[Long, Double]] = { 67 | val now = ZonedDateTime.now(ZoneOffset.UTC) 68 | executors.find(_.board.title == board) 69 | .fold(Future.failed(NotFoundError(s"$board does not exist")): Future[Map[Long, Double]]) { 70 | executor => 71 | 72 | def getSloCacheDuration(from: ZonedDateTime): Duration = 73 | if (from.plus(1, ChronoUnit.MONTHS).compareTo(now) > 0) 74 | // expire cache fro current month more often 75 | Duration.create(1, TimeUnit.HOURS) 76 | else 77 | // don't expire all caches in the same time 78 | Duration.create(12, TimeUnit.HOURS).plus(Duration.create((Math.random() * 30).toInt, TimeUnit.MINUTES)) 79 | 80 | //cache only full months 81 | def boardMonthlySloInner(board: String, from: ZonedDateTime, until: ZonedDateTime): Future[Map[Long, Double]] = 82 | memoizeF(Some(getSloCacheDuration(from))) { 83 | executor.fetchHourlySlo(from.toInstant, until.toInstant).map(_.toMap) 84 | } 85 | 86 | val from = now.minus(statsDays, ChronoUnit.DAYS) 87 | val until = now 88 | 89 | //extend the interval with one day in both directions to avoid timezone issues 90 | val fromStartOfMonth = from.minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1) 91 | val months = ChronoUnit.MONTHS.between(fromStartOfMonth, until.plus(1, ChronoUnit.DAYS)).toInt 92 | 93 | val monthlyResults = 94 | for {idx <- 0 to months} yield { 95 | val from = fromStartOfMonth.plus(idx, ChronoUnit.MONTHS) 96 | val to = fromStartOfMonth.plus(idx + 1, ChronoUnit.MONTHS) 97 | boardMonthlySloInner(board, from, to) 98 | } 99 | val fromTs = from.toEpochSecond * 1000 100 | val toTs = until.toEpochSecond * 1000 101 | Future.sequence(monthlyResults).map(_.fold(Map.empty[Long, Double])(_ ++ _).filterKeys(ts => ts >= fromTs && ts < toTs)) 102 | } 103 | 104 | } 105 | 106 | sys.addShutdownHook { 107 | logger.info("Shutting down...") 108 | scheduler.shutdown() 109 | } 110 | 111 | object Poller extends Runnable { 112 | override def run(): Unit = executors foreach { e => 113 | e.apply(None).foreach(put(e.board.title)(_)) 114 | history(e.board.title) 115 | stats(e.board.title) 116 | } 117 | } 118 | 119 | } 120 | 121 | object StateService { 122 | 123 | // Error when the requested board does not exist 124 | case class NotFoundError(message: String) extends Exception(message) 125 | 126 | // Error when the requested board is not ready (initializing) 127 | case class NotReadyError(message: String) extends Exception(message) 128 | 129 | // get aggregated statistics by hour 130 | def getStatsByHour(history: Seq[(Long, BoardView)]): Map[Long, Stats] = { 131 | history 132 | .groupBy { case (ts, _) => 133 | // normalize to the start of hour 134 | ts - ts % 3600000 135 | } 136 | .mapValues { entries => 137 | val (successes, warnings, errors, unknown, total) = entries.foldLeft((0, 0, 0, 0, 0)) { case ((successes, warnings, errors, unknown, total), (_, view)) => 138 | view.status match { 139 | case Status.Success => (successes + 1, warnings, errors, unknown, total + 1) 140 | case Status.Warning => (successes, warnings + 1, errors, unknown, total + 1) 141 | case Status.Error => (successes, warnings, errors + 1, unknown, total + 1) 142 | case Status.Unknown => (successes, warnings, errors, unknown + 1, total + 1) 143 | case _ => (successes, warnings, errors, unknown, total) 144 | } 145 | } 146 | Stats( 147 | successes, 148 | warnings, 149 | errors, 150 | unknown, 151 | total 152 | ) 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/app/Stats.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.app 2 | 3 | import com.criteo.slab.utils.Jsonable 4 | 5 | /** Statistics of history 6 | * 7 | * @param successes Number of checks that are successful 8 | * @param warnings Number of checks that are in the warning state 9 | * @param errors Number of checks that are errors 10 | * @param unknown Number of checks that are unknown 11 | * @param total Total number of checks 12 | */ 13 | case class Stats( 14 | successes: Int, 15 | warnings: Int, 16 | errors: Int, 17 | unknown: Int, 18 | total: Int 19 | ) 20 | 21 | object Stats { 22 | 23 | implicit object ToJSON extends Jsonable[Stats] 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/app/WebServer.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.app 2 | 3 | import java.net.URLDecoder 4 | import java.text.DecimalFormat 5 | import java.time.{Duration, Instant} 6 | 7 | import cats.Eval 8 | import cats.effect.IO 9 | import com.criteo.slab.app.StateService.{NotFoundError, NotReadyError} 10 | import com.criteo.slab.core.Executor.{FetchBoardHistory, FetchBoardHourlySlo, RunBoard} 11 | import com.criteo.slab.core._ 12 | import com.criteo.slab.utils.Jsonable 13 | import com.criteo.slab.utils.Jsonable._ 14 | import lol.http.{Request, Response, _} 15 | import org.json4s.Serializer 16 | import org.slf4j.LoggerFactory 17 | import shapeless.HList 18 | import shapeless.poly.Case3 19 | 20 | import scala.concurrent.{ExecutionContext, Future} 21 | import scala.util.control.NonFatal 22 | 23 | /** Slab Web server 24 | * 25 | * @param pollingInterval The polling interval in seconds 26 | * @param statsDays Specifies how many last days of statistics to be retained 27 | * @param routeGenerator A function that generates custom routes (should starts with "/api") 28 | * @param executors The executors of the boards 29 | * @param ec The execution context for the web server 30 | */ 31 | case class WebServer( 32 | val pollingInterval: Int = 60, 33 | val statsDays: Int = 730, 34 | private val routeGenerator: StateService => PartialFunction[Request, IO[Response]] = _ => PartialFunction.empty, 35 | private val executors: List[Executor[_]] = List.empty 36 | )(implicit ec: ExecutionContext) { 37 | /** 38 | * Attach a board to the server 39 | * 40 | * @param board The board 41 | * @return 42 | */ 43 | def attach[L <: HList, O](board: Board[L])( 44 | implicit 45 | runBoard: Case3.Aux[RunBoard.type, Board[L], Context, Boolean, Future[BoardView]], 46 | fetchBoardHistory: Case3.Aux[FetchBoardHistory.type, Board[L], Instant, Instant, Future[Seq[(Long, BoardView)]]], 47 | fetchBoardHourlySlo: Case3.Aux[FetchBoardHourlySlo.type, Board[L], Instant, Instant, Future[Seq[(Long, Double)]]], 48 | store: Store[O] 49 | ): WebServer = { 50 | this.copy(executors = Executor(board) :: executors) 51 | } 52 | 53 | /** 54 | * Start the web server 55 | * 56 | * @param port The server's port 57 | */ 58 | def apply(port: Int): Unit = { 59 | logger.info(s"Starting server at port: $port") 60 | stateService.start() 61 | 62 | Server.listen(port)(routeLogger(routes orElse routeGenerator(stateService) orElse notFound)) 63 | logger.info(s"Listening to $port") 64 | 65 | sys.addShutdownHook { 66 | logger.info("Shutting down WebServer") 67 | } 68 | } 69 | 70 | /** 71 | * 72 | * @param generator A function that takes StateService and returns routes 73 | * @return Web server with the created routes 74 | */ 75 | def withRoutes(generator: StateService => PartialFunction[Request, IO[Response]]) = this.copy(routeGenerator = generator) 76 | 77 | private val logger = LoggerFactory.getLogger(this.getClass) 78 | 79 | private val decimalFormat = new DecimalFormat("0.####") 80 | 81 | private implicit def stringEncoder = new Jsonable[String] {} 82 | 83 | private implicit def longStringEncoder = new Jsonable[(Long, String)] {} 84 | 85 | private implicit def longStatsEncoder = new Jsonable[(Long, Stats)] { 86 | override val serializers: Seq[Serializer[_]] = implicitly[Jsonable[Stats]].serializers 87 | } 88 | 89 | private lazy val stateService = new StateService(executors, pollingInterval, statsDays) 90 | 91 | private lazy val boards = executors.map(_.board) 92 | 93 | private val routes: PartialFunction[Request, IO[Response]] = { 94 | // Configs of boards 95 | case GET at url"/api/boards" => { 96 | Ok(boards.map { board => BoardConfig(board.title, board.layout, board.links, board.slo) }.toJSON).map(jsonContentType) 97 | } 98 | // Current board view 99 | case GET at url"/api/boards/$board" => { 100 | val boardName = URLDecoder.decode(board, "UTF-8") 101 | IO.fromFuture(IO( 102 | stateService 103 | .current(boardName) 104 | .map((_: ReadableView).toJSON) 105 | .map(Ok(_)) 106 | .map(jsonContentType) 107 | .recover(errorHandler) 108 | )) 109 | } 110 | // Snapshot of the given time point 111 | case GET at url"/api/boards/$board/snapshot/$timestamp" => { 112 | val boardName = URLDecoder.decode(board, "UTF-8") 113 | executors.find(_.board.title == boardName).fold(IO(NotFound(s"Board $boardName does not exist"))) { executor => 114 | IO(Instant.ofEpochMilli(timestamp.toLong)).attempt.flatMap { 115 | case Left(_) => IO(BadRequest("invalid timestamp")) 116 | case Right(dateTime) => 117 | IO.fromFuture(IO( 118 | executor.apply(Some(Context(dateTime))) 119 | .map((_: ReadableView).toJSON) 120 | .map(Ok(_)) 121 | .map(jsonContentType) 122 | .recover(errorHandler) 123 | )) 124 | } 125 | } 126 | } 127 | // History of last 24 hours 128 | case GET at url"/api/boards/$board/history?last" => { 129 | val boardName = URLDecoder.decode(board, "UTF-8") 130 | IO.fromFuture(IO( 131 | stateService 132 | .history(boardName) 133 | .map(h => Ok(h.toJSON)) 134 | .map(jsonContentType) 135 | .recover(errorHandler) 136 | )) 137 | } 138 | // History of the given range 139 | case GET at url"/api/boards/$board/history?from=$fromTS&until=$untilTS" => { 140 | val boardName = URLDecoder.decode(board, "UTF-8") 141 | executors.find(_.board.title == boardName).fold(IO(NotFound(s"Board $boardName does not exist"))) { executor => 142 | IO( 143 | Instant.ofEpochMilli(fromTS.toLong) -> Instant.ofEpochMilli(untilTS.toLong) 144 | ).attempt.flatMap { 145 | case Left(_) => IO(BadRequest("Invalid timestamp")) 146 | case Right((from, until)) => 147 | IO.fromFuture(IO( 148 | executor.fetchHistory(from, until) 149 | .map(_.toMap.mapValues(_.status.name).toJSON) 150 | .map(Ok(_)) 151 | .map(jsonContentType) 152 | .recover(errorHandler) 153 | )) 154 | } 155 | } 156 | } 157 | // Stats of the board 158 | case GET at url"/api/boards/$board/stats" => { 159 | val boardName = URLDecoder.decode(board, "UTF-8") 160 | IO.fromFuture(IO( 161 | stateService.stats(boardName) 162 | .map(_.mapValues(decimalFormat.format)).map(_.toJSON) 163 | .map(Ok(_)) 164 | .map(jsonContentType) 165 | .recover(errorHandler) 166 | )) 167 | } 168 | // Static resources 169 | case GET at url"/$file.$ext" => { 170 | ClasspathResource(s"/$file.$ext").fold(NotFound)(r => Ok(r)) 171 | } 172 | case req if req.method == GET && !req.url.startsWith("/api") => { 173 | ClasspathResource("/index.html").fold(NotFound)(r => Ok(r)) 174 | } 175 | } 176 | 177 | private def notFound: PartialFunction[Request, IO[Response]] = { 178 | case anyReq => { 179 | logger.info(s"${anyReq.method.toString} ${anyReq.url} not found") 180 | Response(404) 181 | } 182 | } 183 | 184 | private def errorHandler: PartialFunction[Throwable, Response] = { 185 | case f: NotFoundError => 186 | NotFound(f.message) 187 | case f: NotReadyError => 188 | Response(412)(f.message) 189 | case NonFatal(e) => 190 | logger.error(e.getMessage, e) 191 | InternalServerError 192 | } 193 | 194 | private def jsonContentType(res: Response) = res.addHeaders(HttpString("content-type") -> HttpString("application/json")) 195 | 196 | private def routeLogger(router: Request => IO[Response]) = (request: Request) => { 197 | val start = Instant.now() 198 | router(request) map { res => 199 | val duration = Duration.between(start, Instant.now) 200 | logger.info(s"${request.method} ${request.url} - ${res.status} ${duration.toMillis}ms") 201 | res 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/Board.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import org.slf4j.LoggerFactory 4 | import shapeless.ops.hlist.ToTraversable 5 | import shapeless.{HList, UnaryTCConstraint} 6 | 7 | /** Top level component 8 | * 9 | * @param title The board title 10 | * @param boxes The children boxes 11 | * @param aggregate Aggregates its children boxes views 12 | * @param layout The layout of the board 13 | * @param links Defines links between boxes, will draw lines in the UI 14 | * @param slo Defines the SLO threshold for the calendar. 15 | */ 16 | case class Board[B <: HList]( 17 | title: String, 18 | boxes: B, 19 | aggregate: (Map[Box[_], View], Context) => View, 20 | layout: Layout, 21 | links: Seq[(Box[_], Box[_])] = Seq.empty, 22 | slo: Double = 0.97 23 | )( 24 | implicit 25 | constraint: UnaryTCConstraint[B, Box], 26 | boxSet: ToTraversable.Aux[B, Set, Box[_]] 27 | ) { 28 | private val logger = LoggerFactory.getLogger(this.getClass) 29 | require({ 30 | val boxesInBoard = boxes.to(boxSet) 31 | val boxesInLayout = layout.columns.foldLeft(Set.empty[Box[_]]) { (set, col) => 32 | set ++ col.rows.foldLeft(Set.empty[Box[_]]) { (set, row) => set ++ row.boxes.toSet } 33 | } 34 | val notInLayout = boxesInBoard.diff(boxesInLayout).map(_.title) 35 | if (notInLayout.size > 0) 36 | logger.error(s"Boxes not present in the layout but in the 'boxes' field: ${notInLayout.mkString(", ")}") 37 | val notInBoard = boxesInLayout.diff(boxesInBoard).map(_.title) 38 | if (notInBoard.size > 0) 39 | logger.error(s"Boxes not present in the 'boxes' field but in the layout: ${notInBoard.mkString(", ")}") 40 | notInLayout.size == 0 && notInBoard.size == 0 41 | }, "Board definition error, please make sure all boxes are present both in board and layout") 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/Box.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import com.criteo.slab.utils.Jsonable 4 | import org.json4s.CustomSerializer 5 | import org.json4s.JsonDSL._ 6 | 7 | /** A box that groups checks 8 | * 9 | * @param title The title 10 | * @param checks The checks 11 | * @param aggregate Aggregates the views of its checks, return a view 12 | * @param description The description of the box in markdown syntax 13 | * @param labelLimit The limit of visible check labels shown on the box 14 | * @tparam T The type bound to the checks 15 | */ 16 | case class Box[T]( 17 | title: String, 18 | checks: Seq[Check[T]], 19 | aggregate: (Map[Check[T], CheckResult[T]], Context) => View, 20 | description: Option[String] = None, 21 | labelLimit: Option[Int] = None 22 | ) 23 | 24 | object Box { 25 | 26 | implicit object toJSON extends Jsonable[Box[_]] { 27 | override val serializers = List(Ser) 28 | 29 | object Ser extends CustomSerializer[Box[_]](_ => ({ 30 | case _ => throw new NotImplementedError("Not deserializable") 31 | }, { 32 | case box: Box[_] => 33 | ("title" -> box.title) ~ ("description" -> box.description) ~ ("labelLimit" -> box.labelLimit.getOrElse(64)) 34 | })) 35 | 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/Check.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import scala.concurrent.Future 4 | 5 | /** Basic unit for declaring a metric to check 6 | * 7 | * @param id The identifier 8 | * @param title The title of the check 9 | * @param apply A function when called, should return a future of target value 10 | * @param display A function that takes a checked value and a [[com.criteo.slab.core.Context Context]] 11 | * @tparam T The type of values to be checked 12 | */ 13 | case class Check[T]( 14 | id: String, 15 | title: String, 16 | apply: () => Future[T], 17 | display: (T, Context) => View 18 | ) 19 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/CheckResult.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | /** 4 | * Represents the result of a check 5 | * 6 | * @param view The view of the check 7 | * @param value The value of the check 8 | * @tparam T The checked value type 9 | */ 10 | case class CheckResult[T]( 11 | view: View, 12 | value: Option[T] 13 | ) 14 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/Codec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import scala.util.Try 4 | 5 | /** 6 | * Codec for values to be put in a store 7 | * @tparam T Input type 8 | * @tparam Repr Encoded type 9 | */ 10 | trait Codec[T, Repr] { 11 | def encode(v: T): Repr 12 | 13 | def decode(v: Repr): Try[T] 14 | } 15 | 16 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/Context.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import java.time.Instant 4 | 5 | /** Represents the context when a check occurs 6 | * 7 | * @param when The datetime when it occurs 8 | */ 9 | case class Context( 10 | when: Instant 11 | ) 12 | 13 | object Context { 14 | def now = Context(Instant.now) 15 | } -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/Executor.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import java.time.Instant 4 | import java.time.temporal.ChronoUnit 5 | 6 | import com.criteo.slab.core.Executor.{FetchBoardHistory, FetchBoardHourlySlo, RunBoard} 7 | import com.criteo.slab.lib.Values.Slo 8 | import org.slf4j.LoggerFactory 9 | import shapeless.ops.hlist.{Mapper, ToTraversable, ZipConst} 10 | import shapeless.poly.Case3 11 | import shapeless.{HList, Poly1, Poly3} 12 | 13 | import scala.concurrent.{ExecutionContext, Future} 14 | 15 | /** 16 | * Executes the logic of a board 17 | * 18 | * @param board The Board 19 | * @param runBoard Function for executions 20 | * @tparam L 21 | */ 22 | private[slab] case class Executor[L <: HList](board: Board[L])( 23 | implicit 24 | runBoard: Case3.Aux[RunBoard.type, Board[L], Context, Boolean, Future[BoardView]], 25 | fetchBoardHistory: Case3.Aux[FetchBoardHistory.type, Board[L], Instant, Instant, Future[Seq[(Long, BoardView)]]], 26 | fetchBoardHourlySlo: Case3.Aux[FetchBoardHourlySlo.type, Board[L], Instant, Instant, Future[Seq[(Long, Double)]]] 27 | ) { 28 | def apply(ctx: Option[Context] = None): Future[BoardView] = { 29 | RunBoard(board, ctx.getOrElse(Context.now), ctx.isDefined) 30 | } 31 | 32 | def fetchHistory(from: Instant, until: Instant): Future[Seq[(Long, BoardView)]] = { 33 | FetchBoardHistory(board, from, until) 34 | } 35 | 36 | def fetchHourlySlo(from: Instant, until: Instant): Future[Seq[(Long, Double)]] = { 37 | FetchBoardHourlySlo(board, from, until) 38 | } 39 | 40 | } 41 | 42 | private[slab] object Executor { 43 | private val logger = LoggerFactory.getLogger(this.getClass) 44 | 45 | object RunBoard extends Poly3 { 46 | implicit def f[L <: HList, A <: HList, B <: HList, Repr]( 47 | implicit 48 | zip: ZipConst.Aux[(Context, Boolean), L, A], 49 | mapper: Mapper.Aux[RunBox.type, A, B], 50 | to: ToTraversable.Aux[B, List, Future[(Box[_], BoxView)]], 51 | ec: ExecutionContext, 52 | store: Store[Repr], 53 | codec: Codec[Slo, Repr] 54 | ): Case.Aux[Board[L], Context, Boolean, Future[BoardView]] = 55 | at { (board, context, isReplay) => 56 | Future.sequence { 57 | board.boxes.zipConst(context -> isReplay).map(RunBox).toList[Future[(Box[_], BoxView)]] 58 | } flatMap { pairs => 59 | val bv = pairs.toMap 60 | val aggView = board.aggregate(bv.mapValues(_.toView), context) 61 | val boardView = BoardView(board.title, aggView.status, aggView.message, bv.values.toList) 62 | if (!isReplay) { 63 | val slo = if (boardView.status == Status.Error) Slo(0) else Slo(1) 64 | store.uploadSlo(board.title, context, slo) 65 | .map(_ => boardView) 66 | .recover { case e => 67 | logger.error(e.getMessage, e) 68 | boardView 69 | } 70 | } else { 71 | Future.successful(boardView) 72 | } 73 | } 74 | } 75 | } 76 | 77 | object RunBox extends Poly1 { 78 | implicit def f[T, Repr]( 79 | implicit 80 | ec: ExecutionContext, 81 | store: Store[Repr], 82 | codec: Codec[T, Repr] 83 | ): Case.Aux[(Box[T], (Context, Boolean)), Future[(Box[T], BoxView)]] = 84 | at { case (box, (context, isReplay)) => 85 | Future.sequence { 86 | if (isReplay) 87 | box.checks.map(replayCheck(_, context)) 88 | else 89 | box.checks.map(runCheck(_, context)) 90 | } map { pairs => 91 | val aggView = box.aggregate( 92 | pairs.map { case (check, (checkView, value)) => check -> CheckResult(checkView.toView, value) }.toMap, 93 | context 94 | ) 95 | (box, BoxView(box.title, aggView.status, aggView.message, pairs.map(_._2._1).toList)) 96 | } 97 | } 98 | } 99 | 100 | def runCheck[T, Repr](check: Check[T], context: Context)( 101 | implicit 102 | store: Store[Repr], 103 | codec: Codec[T, Repr], 104 | ec: ExecutionContext 105 | ) = { 106 | check 107 | .apply() 108 | .flatMap(value => 109 | store 110 | .upload(check.id, context, value) 111 | .map(_ => value) 112 | .recover { case e => 113 | logger.error(e.getMessage, e) 114 | value 115 | } 116 | ) 117 | .map(value => check.display(value, context) -> Some(value)) 118 | .recover { case e => 119 | logger.error(e.getMessage, e) 120 | View(Status.Unknown, e.getMessage) -> None 121 | } 122 | .map { case (view, maybeValue) => 123 | (check, (CheckView(check.title, view.status, view.message, view.label), maybeValue)) 124 | } 125 | } 126 | 127 | def replayCheck[T, Repr](check: Check[T], context: Context)( 128 | implicit 129 | store: Store[Repr], 130 | codec: Codec[T, Repr], 131 | ec: ExecutionContext 132 | ) = { 133 | store 134 | .fetch(check.id, context) 135 | .flatMap { 136 | case Some(v) => Future.successful(check.display(v, context) -> Some(v)) 137 | case None => Future.failed(new NoSuchElementException(s"value of ${check.id} at ${context.when.toEpochMilli} is missing")) 138 | } 139 | .recover { case e => 140 | logger.error(e.getMessage, e) 141 | View(Status.Unknown, e.getMessage) -> None 142 | } 143 | .map { case (view, maybeValue) => 144 | (check, (CheckView(check.title, view.status, view.message, view.label), maybeValue)) 145 | } 146 | } 147 | 148 | object FetchBoardHourlySlo extends Poly3 { 149 | private def avg(values: Seq[Slo]): Double = { 150 | val size = values.size 151 | if (size > 0) values.map(_.underlying).sum / size else 0 152 | } 153 | 154 | implicit def f[L <: HList, Repr]( 155 | implicit 156 | ec: ExecutionContext, 157 | store: Store[Repr], 158 | codec: Codec[Slo, Repr] 159 | ): Case.Aux[Board[L], Instant, Instant, Future[Seq[(Long, Double)]]] = 160 | at { (board, from, until) => 161 | store.fetchSloHistory(board.title, from, until).map { values => 162 | values.map { 163 | case (ts, value) => Instant.ofEpochMilli(ts).truncatedTo(ChronoUnit.HOURS).toEpochMilli -> value 164 | }.groupBy(_._1).map { 165 | case (hour, list) => hour -> avg(list.map(_._2)) 166 | }.toSeq 167 | } 168 | } 169 | } 170 | 171 | // History 172 | object FetchBoardHistory extends Poly3 { 173 | implicit def f[L <: HList, A <: HList, B <: HList]( 174 | implicit 175 | zipConst: ZipConst.Aux[(Instant, Instant), L, A], 176 | mapper: Mapper.Aux[FetchBoxHistory.type, A, B], 177 | toTraversable: ToTraversable.Aux[B, List, Future[Box[_] Tuple2 Seq[(Long, BoxView)]]], 178 | ec: ExecutionContext 179 | ): Case.Aux[Board[L], Instant, Instant, Future[Seq[(Long, BoardView)]]] = 180 | at { (board, from, until) => 181 | Future.sequence { 182 | board.boxes.zipConst((from, until)).map(FetchBoxHistory).toList[Future[Box[_] Tuple2 Seq[(Long, BoxView)]]] 183 | }.map { boxes => 184 | boxes.flatMap { case (box, boxViews) => 185 | boxViews.map { case (ts, view) => 186 | (ts, box, view) 187 | } 188 | }.groupBy(_._1).map { case (ts, xs) => 189 | val boxViews = xs.map { case (_, box, boxView) => (box, boxView) } 190 | val aggView = board.aggregate( 191 | boxViews.map { case (box, boxView) => box -> boxView.toView }.toMap, 192 | Context(Instant.ofEpochMilli(ts)) 193 | ) 194 | (ts, BoardView(board.title, aggView.status, aggView.message, boxViews.map(_._2))) 195 | }.toList 196 | } 197 | } 198 | } 199 | 200 | object FetchBoxHistory extends Poly1 { 201 | implicit def f[T, Repr]( 202 | implicit 203 | store: Store[Repr], 204 | codec: Codec[T, Repr], 205 | ec: ExecutionContext 206 | ): Case.Aux[Box[T] Tuple2 (Instant, Instant), Future[Box[T] Tuple2 Seq[(Long, BoxView)]]] = 207 | at { case (box, (from, until)) => 208 | fetchBoxHistory(box, from, until) 209 | } 210 | } 211 | 212 | def fetchBoxHistory[T, Repr](box: Box[T], from: Instant, until: Instant)( 213 | implicit 214 | store: Store[Repr], 215 | codec: Codec[T, Repr], 216 | ec: ExecutionContext 217 | ) = { 218 | Future.sequence { 219 | box.checks.map(fetchCheckHistory(_, from, until)) 220 | } map { checks => 221 | box -> checks.flatMap { case (check, checkViews) => 222 | checkViews.map { case (ts, (view, value)) => 223 | (ts, (check, view -> Some(value))) 224 | } 225 | }.groupBy(_._1).map { case (ts, tuples) => 226 | val checkViews = tuples.map(_._2) 227 | val aggView = box.aggregate( 228 | checkViews.map { case (check, (checkView, value)) => check -> CheckResult(checkView.toView, value) }.toMap, 229 | Context(Instant.ofEpochMilli(ts)) 230 | ) 231 | (ts, BoxView(box.title, aggView.status, aggView.message, checkViews.map(_._2._1))) 232 | }.toSeq 233 | } 234 | } 235 | 236 | def fetchCheckHistory[T, Repr](check: Check[T], from: Instant, until: Instant)( 237 | implicit 238 | store: Store[Repr], 239 | codec: Codec[T, Repr], 240 | ec: ExecutionContext 241 | ) = { 242 | store 243 | .fetchHistory[T](check.id, from, until) 244 | .map(series => 245 | check -> series.map { case (ts, value) => 246 | val view = check.display(value, Context(Instant.ofEpochMilli(ts))) 247 | (ts, CheckView(check.title, view.status, view.message, view.label) -> value) 248 | } 249 | ) 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/Layout.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import com.criteo.slab.utils.Jsonable 4 | import org.json4s.Serializer 5 | 6 | /** Represents a column on a board 7 | * 8 | * @param percentage The percentage it takes as width 9 | * @param rows The rows in the column 10 | */ 11 | case class Column(percentage: Double, rows: Row*) 12 | 13 | /** Represents a row inside a column 14 | * 15 | * @param title The title 16 | * @param percentage The percentage it takes as height, defaults to 100 17 | * @param boxes The boxes to be displayed in the row 18 | */ 19 | case class Row(title: String, percentage: Double = 100, boxes: Seq[Box[_]]) 20 | 21 | /** Defines the layout of a board 22 | * 23 | * @param columns List of [[Column]] 24 | */ 25 | case class Layout(columns: Column*) 26 | 27 | object Layout { 28 | 29 | implicit object ToJSON extends Jsonable[Layout] { 30 | override val serializers: Seq[Serializer[_]] = implicitly[Jsonable[Box[_]]].serializers 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/ReadableView.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import com.criteo.slab.utils.Jsonable 4 | import org.json4s.Serializer 5 | 6 | /** Serializable view to be used by the web app 7 | * 8 | */ 9 | private[slab] trait ReadableView { 10 | val title: String 11 | val message: String 12 | val status: Status 13 | 14 | def toView = View(status, message) 15 | } 16 | 17 | private[slab] object ReadableView { 18 | 19 | implicit object ToJSON extends Jsonable[ReadableView] { 20 | override val serializers: Seq[Serializer[_]] = implicitly[Jsonable[Status]].serializers 21 | } 22 | } 23 | 24 | private[slab] case class BoardView( 25 | title: String, 26 | status: Status, 27 | message: String, 28 | boxes: Seq[BoxView] 29 | ) extends ReadableView 30 | 31 | private[slab] case class BoxView( 32 | title: String, 33 | status: Status, 34 | message: String, 35 | checks: Seq[CheckView] 36 | ) extends ReadableView 37 | 38 | private[slab] case class CheckView( 39 | title: String, 40 | status: Status, 41 | message: String, 42 | label: Option[String] = None 43 | ) extends ReadableView 44 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/Status.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import com.criteo.slab.utils.Jsonable 4 | import org.json4s.JsonAST.JString 5 | import org.json4s.{CustomSerializer, Serializer} 6 | 7 | /** The status of a check 8 | * 9 | * @param name The name of the status 10 | * @param level The level of the status, used for ordering 11 | */ 12 | sealed class Status(val name: String, val level: Int) extends Ordered[Status] { 13 | override def compare(that: Status) = this.level.compare(that.level) 14 | } 15 | 16 | object Status { 17 | case object Success extends Status("SUCCESS", 0) 18 | case object Warning extends Status("WARNING", 1) 19 | case object Error extends Status("ERROR", 2) 20 | case object Unknown extends Status("UNKNOWN", 3) 21 | 22 | def from(in: String) = in.toUpperCase match { 23 | case "SUCCESS" => Success 24 | case "WARNING" => Warning 25 | case "ERROR" => Error 26 | case "UNKNOWN" => Unknown 27 | } 28 | 29 | implicit object ToJSON extends Jsonable[Status] { 30 | override val serializers: Seq[Serializer[_]] = List(Ser) 31 | 32 | object Ser extends CustomSerializer[Status](_ => ( 33 | { 34 | case JString(status) => Status.from(status) 35 | }, 36 | { 37 | case s: Status => JString(s.name) 38 | } 39 | )) 40 | 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/Store.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import java.time.Instant 4 | 5 | import com.criteo.slab.lib.Values.Slo 6 | 7 | import scala.concurrent.Future 8 | 9 | /** Persists checked values 10 | * 11 | * @tparam Repr Persistent data type in the store 12 | */ 13 | trait Store[Repr] { 14 | def upload[T](id: String, context: Context, v: T)(implicit codec: Codec[T, Repr]): Future[Unit] 15 | 16 | def fetch[T](id: String, context: Context)(implicit codec: Codec[T, Repr]): Future[Option[T]] 17 | 18 | def fetchHistory[T](id: String, from: Instant, until: Instant)(implicit codec: Codec[T, Repr]): Future[Seq[(Long, T)]] 19 | 20 | def uploadSlo(id: String, context: Context, v: Slo)(implicit codec: Codec[Slo, Repr]): Future[Unit] 21 | 22 | def fetchSloHistory(id: String, from: Instant, until: Instant)(implicit codec: Codec[Slo, Repr]): Future[Seq[(Long, Slo)]] 23 | } 24 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/View.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | /** A view represents the status of a given check 4 | * 5 | * @param status The status of the underlying check 6 | * @param message The message to show 7 | * @param label The label 8 | */ 9 | case class View( 10 | status: Status, 11 | message: String, 12 | label: Option[String] = None 13 | ) 14 | 15 | object View { 16 | implicit object DefaultOrd extends Ordering[View] { 17 | override def compare(x: View, y: View): Int = x.status.level - y.status.level 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/core/package.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab 2 | 3 | import shapeless.{HNil => SHNil} 4 | package object core { 5 | def HNil = SHNil 6 | } 7 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/lib/InMemoryStore.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.lib 2 | 3 | import java.time.format.{DateTimeFormatter, FormatStyle} 4 | import java.time.temporal.ChronoUnit 5 | import java.time.{Instant, ZoneId} 6 | import java.util.concurrent.{Executors, TimeUnit} 7 | 8 | import com.criteo.slab.core.{Codec, Context, Store} 9 | import com.criteo.slab.lib.Values.Slo 10 | import org.slf4j.{Logger, LoggerFactory} 11 | 12 | import scala.collection.concurrent.TrieMap 13 | import scala.concurrent.Future 14 | import scala.util.Try 15 | 16 | /** In memory store 17 | * 18 | * Entries are kept in memory, the store checks and removes expired entries periodically 19 | * 20 | * @param expiryDays The number of days for entries to be kept in the store since inserted, expired entries will be removed 21 | */ 22 | class InMemoryStore( 23 | val expiryDays: Int = 30 24 | ) extends Store[Any] { 25 | private val logger = LoggerFactory.getLogger(this.getClass) 26 | private val cache = TrieMap.empty[(String, Long), Any] 27 | private val scheduler = Executors.newSingleThreadScheduledExecutor() 28 | 29 | scheduler.scheduleAtFixedRate(InMemoryStore.createCleaner(cache, expiryDays, logger), 1, 1, TimeUnit.HOURS) 30 | logger.info(s"InMemoryStore started, entries expire in $expiryDays days") 31 | 32 | sys.addShutdownHook { 33 | logger.info(s"Shutting down...") 34 | scheduler.shutdown() 35 | } 36 | 37 | override def upload[T](id: String, context: Context, v: T)(implicit codec: Codec[T, Any]): Future[Unit] = { 38 | logger.debug(s"Uploading $id") 39 | Future.successful { 40 | cache.putIfAbsent((id, context.when.toEpochMilli), codec.encode(v)) 41 | logger.info(s"Store updated, size: ${cache.size}") 42 | } 43 | } 44 | 45 | override def uploadSlo(id: String, context: Context, slo: Slo)(implicit codec: Codec[Slo, Any]): Future[Unit] = { 46 | upload[Slo](id, context, slo) 47 | } 48 | 49 | def fetchSloHistory(id: String, from: Instant, until: Instant)(implicit codec: Codec[Slo, Any]): Future[Seq[(Long, Slo)]] = { 50 | fetchHistory[Slo](id, from, until)(codec) 51 | } 52 | 53 | override def fetch[T](id: String, context: Context)(implicit codec: Codec[T, Any]): Future[Option[T]] = { 54 | logger.debug(s"Fetching $id") 55 | Future.successful { 56 | cache.get((id, context.when.toEpochMilli)) map { v => 57 | codec.decode(v).get 58 | } 59 | } 60 | } 61 | 62 | override def fetchHistory[T]( 63 | id: String, 64 | from: Instant, 65 | until: Instant 66 | )(implicit ev: Codec[T, Any]): Future[Seq[(Long, T)]] = { 67 | logger.debug(s"Fetching the history of $id from ${format(from)} until ${format(until)}, cache size: ${cache.size}") 68 | Future.successful { 69 | cache.withFilter { case ((_id, ts), _) => 70 | _id == id && ts >= from.toEpochMilli && ts <= until.toEpochMilli 71 | }.map { case ((_, ts), repr) => 72 | (ts, ev.decode(repr).get) 73 | }.toList 74 | } 75 | } 76 | 77 | private def format(i: Instant) = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL) 78 | .withZone(ZoneId.systemDefault) 79 | .format(i) 80 | } 81 | 82 | object InMemoryStore { 83 | implicit def codec[T] = new Codec[T, Any] { 84 | override def encode(v: T): Any = v 85 | 86 | override def decode(v: Any): Try[T] = Try(v.asInstanceOf[T]) 87 | } 88 | 89 | def createCleaner(cache: TrieMap[(String, Long), Any], expiryDays: Int, logger: Logger): Runnable = { 90 | object C extends Runnable { 91 | override def run(): Unit = { 92 | val expired = cache.filterKeys(_._2 <= Instant.now.minus(expiryDays, ChronoUnit.DAYS).toEpochMilli).keys 93 | logger.debug(s"${expired.size} out of ${cache.size} entries have expired, cleaning up...") 94 | cache --= expired 95 | } 96 | } 97 | C 98 | } 99 | } -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/lib/Values.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.lib 2 | 3 | /** 4 | * Predefined value types that work with a Graphite store 5 | */ 6 | object Values { 7 | 8 | /** 9 | * A value representing a version number 10 | * 11 | * @param underlying The version 12 | */ 13 | case class Version(val underlying: Double) extends AnyVal 14 | 15 | /** 16 | * A value representing a latency 17 | * 18 | * @param underlying The latency value 19 | */ 20 | case class Latency(val underlying: Long) extends AnyVal 21 | 22 | /** 23 | * A value representing a SLO number 24 | * 25 | * @param underlying The SLO 26 | */ 27 | case class Slo(val underlying: Double) extends AnyVal 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/lib/graphite/GraphiteCodecs.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.lib.graphite 2 | 3 | import java.time.Instant 4 | 5 | import com.criteo.slab.core.Codec 6 | import com.criteo.slab.lib.Values.{Latency, Slo, Version} 7 | import com.criteo.slab.lib.graphite.GraphiteStore.Repr 8 | 9 | import scala.util.Try 10 | 11 | /** 12 | * Codecs for Graphite store 13 | */ 14 | object GraphiteCodecs { 15 | 16 | implicit val latencyCodec = new Codec[Latency, Repr] { 17 | override def encode(v: Latency): Repr = Map( 18 | "latency" -> v.underlying 19 | ) 20 | 21 | override def decode(v: Repr): Try[Latency] = Try { 22 | Latency(v("latency").toLong) 23 | } 24 | } 25 | 26 | implicit val version = new Codec[Version, Repr] { 27 | override def encode(v: Version): Repr = Map( 28 | "version" -> v.underlying 29 | ) 30 | 31 | override def decode(v: Repr): Try[Version] = Try( 32 | Version(v("version")) 33 | ) 34 | } 35 | 36 | implicit val instant = new Codec[Instant, Repr] { 37 | override def encode(v: Instant): Repr = Map( 38 | "datetime" -> v.toEpochMilli 39 | ) 40 | 41 | override def decode(v: Repr): Try[Instant] = Try(Instant.ofEpochMilli(v("datetime").toLong)) 42 | } 43 | 44 | implicit val slo = new Codec[Slo, Repr] { 45 | override def encode(v: Slo): Repr = Map( 46 | "slo" -> v.underlying 47 | ) 48 | 49 | override def decode(v: Repr): Try[Slo] = Try( 50 | Slo(v("slo")) 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/lib/graphite/GraphiteMetric.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.lib.graphite 2 | 3 | import com.criteo.slab.utils.Jsonable 4 | import org.json4s.JsonAST.{JArray, JDouble, JInt, JNull} 5 | import org.json4s.{CustomSerializer, Serializer} 6 | 7 | private[slab] case class DataPoint(value: Option[Double], timestamp: Long) 8 | 9 | object DataPoint { 10 | 11 | implicit object ToJSON extends Jsonable[DataPoint] { 12 | override val serializers: Seq[Serializer[_]] = List(Ser) 13 | 14 | object Ser extends CustomSerializer[DataPoint](_ => ( { 15 | case JArray(JDouble(value) :: JInt(date) :: Nil) => 16 | DataPoint(Some(value), date.toLong) 17 | case JArray(JNull :: JInt(date) :: Nil) => 18 | DataPoint(None, date.toLong) 19 | }, { 20 | case DataPoint(value, date) => 21 | val v = value match { 22 | case Some(v) => JDouble(v) 23 | case None => JNull 24 | } 25 | JArray( 26 | List( 27 | v, 28 | JInt(date) 29 | ) 30 | ) 31 | } 32 | )) 33 | 34 | } 35 | 36 | } 37 | 38 | private[slab] case class GraphiteMetric( 39 | target: String, 40 | datapoints: List[DataPoint] 41 | ) 42 | 43 | object GraphiteMetric { 44 | 45 | implicit object ToJSON extends Jsonable[GraphiteMetric] { 46 | override val serializers: Seq[Serializer[_]] = implicitly[Jsonable[DataPoint]].serializers 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/lib/graphite/GraphiteStore.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.lib.graphite 2 | 3 | import java.io._ 4 | import java.net._ 5 | import java.time._ 6 | import java.time.format.DateTimeFormatter 7 | import java.util.concurrent.TimeUnit.SECONDS 8 | 9 | import com.criteo.slab.core._ 10 | import com.criteo.slab.lib.Values.Slo 11 | import com.criteo.slab.lib.graphite.GraphiteStore.Repr 12 | import com.criteo.slab.utils 13 | import com.criteo.slab.utils.{HttpUtils, Jsonable} 14 | import org.json4s.DefaultFormats 15 | import org.slf4j.LoggerFactory 16 | 17 | import scala.concurrent.duration.{FiniteDuration, Duration => CDuration} 18 | import scala.concurrent.{ExecutionContext, Future} 19 | import scala.util.{Failure, Success, Try} 20 | 21 | /** 22 | * A value store that uses Graphite 23 | * 24 | * @param host The host of the writing endpoint 25 | * @param port The port of the writing endpoint 26 | * @param webHost The URL of the Web host for reading 27 | * @param checkInterval Check interval in [[java.time.Duration Duration]] 28 | * @param group The group name of Graphite metrics 29 | * @param sloGroup The group name of Graphite metrics where SLO will be stored 30 | * @param serverTimeZone The timezone of the server 31 | * @param requestTimeout Request timeout 32 | * @param maxConnections Max connections of the Http client 33 | * @param ec The execution context 34 | */ 35 | class GraphiteStore( 36 | host: String, 37 | port: Int, 38 | webHost: String, 39 | checkInterval: Duration, 40 | group: Option[String] = None, 41 | sloGroup: Option[String] = None, 42 | serverTimeZone: ZoneId = ZoneId.systemDefault(), 43 | requestTimeout: FiniteDuration = CDuration.create(60, SECONDS), 44 | maxConnections: Int = 128 45 | )(implicit ec: ExecutionContext) extends Store[Repr] { 46 | 47 | import GraphiteStore._ 48 | 49 | private val logger = LoggerFactory.getLogger(this.getClass) 50 | 51 | private val jsonFormat = DefaultFormats ++ Jsonable[GraphiteMetric].serializers 52 | 53 | private val DateFormatter = DateTimeFormatter.ofPattern("HH:mm_YYYYMMdd").withZone(serverTimeZone) 54 | 55 | private val GroupPrefix = group.map(_ + ".").getOrElse("") 56 | private val SloGroupPrefix = sloGroup.map(_ + ".").getOrElse("") 57 | 58 | private val Get = HttpUtils.makeGet(new URL(webHost), maxConnections) 59 | 60 | private def cleanId(id: String) = id.replaceAll("\\s", "-").replaceAll("[^0-9a-zA-Z-]", "").toLowerCase() 61 | 62 | // Returns a prefix of Graphite metrics in "groupId.id" 63 | private def getPrefix(id: String) = GroupPrefix + id 64 | 65 | private def getSloPrefix(id: String) = SloGroupPrefix + cleanId(id) 66 | 67 | private def sendToGraphite[T](prefixedId: String, context: Context, v: T)(implicit codec: Codec[T, Repr]): Future[Unit] = { 68 | utils.collectTries(codec.encode(v).toList.map { case (name, value) => 69 | send(host, port, s"$prefixedId.$name", value) 70 | }) match { 71 | case Success(_) => 72 | logger.debug(s"succeeded in uploading $prefixedId") 73 | Future.successful(()) 74 | case Failure(e) => 75 | logger.debug(s"failed to upload $prefixedId", e) 76 | Future.failed(e) 77 | } 78 | } 79 | 80 | override def upload[T](id: String, context: Context, v: T)(implicit codec: Codec[T, Repr]): Future[Unit] = { 81 | sendToGraphite(getPrefix(id), context, v) 82 | } 83 | 84 | override def uploadSlo(id: String, context: Context, slo: Slo)(implicit codec: Codec[Slo, Repr]): Future[Unit] = { 85 | sendToGraphite(getSloPrefix(id), context, slo) 86 | } 87 | 88 | override def fetch[T](id: String, context: Context)(implicit codec: Codec[T, Repr]): Future[Option[T]] = { 89 | val query = HttpUtils.makeQuery(Map( 90 | "target" -> s"${getPrefix(id)}.*", 91 | "from" -> s"${DateFormatter.format(context.when)}", 92 | "until" -> s"${DateFormatter.format(context.when.plus(checkInterval))}", 93 | "format" -> "json" 94 | )) 95 | Get[String](s"/render$query", Map.empty, requestTimeout) flatMap { content => 96 | Jsonable.parse[List[GraphiteMetric]](content, jsonFormat) match { 97 | case Success(metrics) => 98 | val pairs = transformMetrics(s"${getPrefix(id)}", metrics) 99 | if (pairs.isEmpty) 100 | Future.successful(None) 101 | else 102 | codec.decode(pairs) match { 103 | case Success(v) => Future.successful(Some(v)) 104 | case Failure(e) => Future.failed(e) 105 | } 106 | case Failure(e) => Future.failed(e) 107 | } 108 | } 109 | } 110 | 111 | private def fetchGraphiteHistory[T](prefixedId: String, from: Instant, until: Instant)(implicit codec: Codec[T, Repr]): Future[Seq[(Long, T)]] = { 112 | val query = HttpUtils.makeQuery(Map( 113 | "target" -> s"$prefixedId.*", 114 | "from" -> s"${DateFormatter.format(from)}", 115 | "until" -> s"${DateFormatter.format(until)}", 116 | "format" -> "json", 117 | "noNullPoints" -> "true" 118 | )) 119 | Get[String](s"/render$query", Map.empty, requestTimeout) flatMap { content => 120 | Jsonable.parse[List[GraphiteMetric]](content, jsonFormat) map { metrics => 121 | groupMetrics(s"$prefixedId", metrics) 122 | } match { 123 | case Success(metrics) => 124 | logger.debug(s"$prefixedId: fetched ${metrics.size} values") 125 | Future.successful { 126 | metrics.mapValues(codec.decode).collect { 127 | case (ts, Success(v)) => (ts, v) 128 | }.toList 129 | } 130 | case Failure(e) => 131 | logger.debug(s"$prefixedId}: Invalid graphite metric, got $content") 132 | Future.failed(e) 133 | } 134 | } 135 | } 136 | 137 | override def fetchHistory[T](id: String, from: Instant, until: Instant)(implicit codec: Codec[T, Repr]): Future[Seq[(Long, T)]] = { 138 | fetchGraphiteHistory(getPrefix(id), from, until) 139 | } 140 | 141 | override def fetchSloHistory(id: String, from: Instant, until: Instant)(implicit codec: Codec[Slo, Repr]): Future[Seq[(Long, Slo)]] = { 142 | fetchGraphiteHistory(getSloPrefix(id), from, until) 143 | } 144 | } 145 | 146 | 147 | object GraphiteStore { 148 | type Repr = Map[String, Double] 149 | def send(host: String, port: Int, target: String, value: Double): Try[Unit] = { 150 | Try { 151 | val socket = new Socket(InetAddress.getByName(host), port) 152 | val ps = new PrintStream(socket.getOutputStream) 153 | ps.println(s"$target $value ${Instant.now.toEpochMilli / 1000}") 154 | ps.flush 155 | socket.close 156 | } 157 | } 158 | 159 | // take first defined DataPoint of each metric 160 | def transformMetrics(prefix: String, metrics: List[GraphiteMetric]): Repr = { 161 | val pairs = metrics 162 | .map { metric => 163 | metric.datapoints 164 | .find(_.value.isDefined) 165 | .map(dp => (metric.target.stripPrefix(s"${prefix}."), dp.value.get)) 166 | } 167 | .flatten 168 | .toMap 169 | // returns metrics when all available or nothing 170 | if (pairs.size != metrics.size) 171 | Map.empty 172 | else 173 | pairs 174 | } 175 | 176 | // group metrics by timestamp 177 | def groupMetrics(prefix: String, metrics: List[GraphiteMetric]): Map[Long, Repr] = { 178 | metrics 179 | .view 180 | .flatMap { metric => 181 | val name = metric.target.stripPrefix(s"${prefix}.") 182 | metric 183 | .datapoints 184 | .view 185 | .filter(_.value.isDefined) 186 | .map { dp => 187 | (name, dp.value.get, dp.timestamp * 1000) 188 | } 189 | .force 190 | } 191 | .groupBy(_._3) 192 | .mapValues(_.map { case (name, value, _) => (name, value) }.toMap) 193 | } 194 | 195 | case class MissingValueException(message: String) extends Exception(message) 196 | 197 | } 198 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/package.scala: -------------------------------------------------------------------------------- 1 | package com.criteo 2 | 3 | /** Slab API documentation 4 | */ 5 | package object slab 6 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/utils/HttpUtils.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.utils 2 | 3 | import java.net.{URL, URLEncoder} 4 | import java.time.Instant 5 | import java.util.concurrent.TimeUnit.SECONDS 6 | 7 | import cats.effect.IO 8 | import lol.http.{Client, ContentDecoder, Get, HttpString, Response} 9 | import org.slf4j.LoggerFactory 10 | 11 | import scala.concurrent.duration.{Duration, FiniteDuration} 12 | import scala.concurrent.{ExecutionContext, Future} 13 | import scala.util.control.NonFatal 14 | 15 | /** Http utilities 16 | * 17 | */ 18 | object HttpUtils { 19 | private val logger = LoggerFactory.getLogger(this.getClass) 20 | 21 | /** Send a GET request 22 | * 23 | * @param url The URL 24 | * @param headers The request headers 25 | * @param ec The [[ExecutionContext]] 26 | * @tparam A The result type which should provide a content decoder implementation 27 | * @return The result wrapped in [[Future]] 28 | */ 29 | def get[A: ContentDecoder]( 30 | url: URL, 31 | headers: Map[HttpString, HttpString] = Map.empty, 32 | timeout: FiniteDuration = Duration.create(60, SECONDS) 33 | )(implicit ec: ExecutionContext): Future[A] = { 34 | val defaultHeaders = Map( 35 | HttpString("Host") -> HttpString(url.getHost) 36 | ) 37 | val path = url.getPath + Option(url.getQuery).map("?" + _).getOrElse("") 38 | val port = if (url.getPort > 0) url.getPort else url.getDefaultPort 39 | val request = Get(path).addHeaders(defaultHeaders ++ headers) 40 | val fullURL = s"${url.getProtocol}://${url.getHost}:${port}$path" 41 | logger.info(s"Requesting $fullURL") 42 | val start = Instant.now 43 | Client(url.getHost, port, url.getProtocol).runAndStop { client => 44 | client.run(request, timeout = timeout) { res => 45 | logger.info(s"Response from $fullURL, status: ${res.status}, ${Instant.now.toEpochMilli - start.toEpochMilli}ms") 46 | handleResponse(res, fullURL) 47 | } 48 | }.unsafeToFuture().recoverWith(handleError(fullURL)) 49 | } 50 | 51 | /** Make a HTTP Get client of which the connection is kept open 52 | * 53 | * Do not use this until lolhttp client leak issue is resolved 54 | * 55 | * @param url The URL 56 | * @param ec The [[ExecutionContext]] 57 | * @return The client with which a GET request can be sent 58 | */ 59 | def makeGet(url: URL, maxConnections: Int = 10)(implicit ec: ExecutionContext): SafeHTTPGet = { 60 | val port = if (url.getPort > 0) url.getPort else url.getDefaultPort 61 | val client = Client(url.getHost, port, url.getProtocol, maxConnections = 10) 62 | SafeHTTPGet(client, Map( 63 | HttpString("Host") -> HttpString(s"${url.getHost}:$port") 64 | )) 65 | } 66 | 67 | /** Make a query string 68 | * 69 | * @param queries The queries in key-value paris 70 | * @return The query string beginning with "?" 71 | */ 72 | def makeQuery(queries: Map[String, String]): String = 73 | "?" + queries.map { case (key, value) => encodeURI(key) + "=" + encodeURI(value) }.mkString("&") 74 | 75 | private def encodeURI(in: String) = URLEncoder.encode(in, "UTF-8").replace("+", "%20") 76 | 77 | private def handleResponse[A: ContentDecoder](res: Response, url: String)(implicit ec: ExecutionContext): IO[A] = { 78 | if (res.status < 400) 79 | res.readAs[A] 80 | else 81 | res.readAs[String].attempt 82 | .map { 83 | case Left(e) => 84 | logger.error(e.getMessage, e) 85 | "Unable to get the message" 86 | case Right(message) => 87 | message 88 | } 89 | .flatMap { message => 90 | logger.info(s"Request to $url has failed, status: ${res.status}, message: $message") 91 | IO.raiseError(FailedRequestException(res)) 92 | } 93 | } 94 | 95 | private def handleError[A](url: String)(implicit ec: ExecutionContext): PartialFunction[Throwable, Future[A]] = { 96 | case f: FailedRequestException => Future.failed(f) 97 | case NonFatal(e) => 98 | logger.error(s"Error when requesting $url: ${e.getMessage}", e) 99 | Future.failed(e) 100 | } 101 | 102 | /** A response with status >= 400 103 | * 104 | * @param response The [[Response]] 105 | */ 106 | case class FailedRequestException(response: Response) extends Exception 107 | 108 | /** 109 | * Send GET requests using the same HTTP client 110 | * 111 | * @param client The client 112 | * @param defaultHeaders Default request headers 113 | * @param ec [[ExecutionContext]] 114 | */ 115 | case class SafeHTTPGet(client: Client, defaultHeaders: Map[HttpString, HttpString])(implicit ec: ExecutionContext) { 116 | def apply[A: ContentDecoder]( 117 | path: String, 118 | headers: Map[HttpString, HttpString] = Map.empty, 119 | timeout: FiniteDuration = Duration.create(60, SECONDS) 120 | ): Future[A] = { 121 | val fullURL = s"${client.scheme}://${client.host}:${client.port}$path" 122 | val request = Get(path).addHeaders(defaultHeaders ++ headers) 123 | logger.info(s"Requesting $fullURL") 124 | val start = Instant.now 125 | client.run(request, timeout = timeout) { res: Response => 126 | logger.info(s"Response from $fullURL, status: ${res.status}, ${Instant.now.toEpochMilli - start.toEpochMilli}ms") 127 | handleResponse(res, fullURL) 128 | }.unsafeToFuture().recoverWith(handleError(fullURL)) 129 | } 130 | } 131 | 132 | } 133 | 134 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/utils/Jsonable.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.utils 2 | 3 | import org.json4s.native.Serialization 4 | import org.json4s.{DefaultFormats, Formats, Serializer} 5 | import scala.language.higherKinds 6 | 7 | import scala.util.Try 8 | 9 | /** A type class that enables JSON serialization/deserialization 10 | * 11 | * @tparam T The underlying type 12 | */ 13 | private[slab] trait Jsonable[T] { 14 | val serializers: Seq[Serializer[_]] = List.empty 15 | } 16 | 17 | private[slab] object Jsonable { 18 | 19 | def apply[T: Jsonable]() = implicitly[Jsonable[T]] 20 | 21 | def parse[T: Manifest](in: String, formats: Formats = DefaultFormats): Try[T] = { 22 | Try(Serialization.read[T](in)(formats, implicitly[Manifest[T]])) 23 | } 24 | 25 | type Id[T <: AnyRef] = T 26 | 27 | class ToJsonable[C[_ <: AnyRef] <: AnyRef, T <: AnyRef : Jsonable](in: C[T]) { 28 | implicit val formats = DefaultFormats ++ Jsonable[T].serializers 29 | def toJSON: String = Serialization.write(in) 30 | } 31 | 32 | implicit class ToJsonPlain[T <: AnyRef: Jsonable](in: T) extends ToJsonable[Id, T](in) 33 | implicit class ToJsonMap[M[_, _] <: Map[_, _], K, T <: AnyRef: Jsonable](in: M[K, T]) extends ToJsonable[({type l[A] = M[K, A]})#l, T](in) 34 | implicit class ToJsonSeq[S[_] <: Seq[_], T <: AnyRef: Jsonable](in: S[T]) extends ToJsonable[S, T](in) 35 | } 36 | -------------------------------------------------------------------------------- /src/main/scala/com/criteo/slab/utils/package.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab 2 | 3 | 4 | import scala.util.{Failure, Success, Try} 5 | 6 | package object utils { 7 | def collectTries[T](tries: Seq[Try[T]]): Try[Seq[T]] = { 8 | tries.foldLeft(Success(Seq.empty[T]): Try[Seq[T]])( (result, t) => 9 | t match { 10 | case Failure(e) => Failure(e) 11 | case Success(v) => result.map(v +: _) 12 | } 13 | ).map(_.reverse) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/webapp/js/actions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { navigate } from 'redux-url'; 3 | import type { BoardView, BoardConfig } from './state'; 4 | 5 | export type Action = 6 | FETCH_BOARD | FETCH_BOARD_SUCCESS | FETCH_BOARD_FAILURE 7 | | FETCH_BOARDS | FETCH_BOARDS_SUCCESS | FETCH_BOARDS_FAILURE 8 | | FETCH_HISTORY | FETCH_HISTORY_SUCCESS | FETCH_HISTORY_FAILURE 9 | | FETCH_SNAPSHOT | FETCH_SNAPSHOT_SUCCESS | FETCH_SNAPSHOT_FAILURE 10 | | GOTO_SNAPSHOT | GOTO_LIVE_BOARD 11 | | SET_POLLING_INTERVAL; 12 | 13 | export type FETCH_BOARD = { type: 'FETCH_BOARD', board: string }; 14 | export type FETCH_BOARD_SUCCESS = { type: 'FETCH_BOARD_SUCCESS', payload: BoardView }; 15 | export type FETCH_BOARD_FAILURE = { type: 'FETCH_BOARD_FAILURE', payload: string }; 16 | 17 | export function fetchBoard(board: string): FETCH_BOARD { 18 | return { 19 | type: 'FETCH_BOARD', 20 | board 21 | }; 22 | } 23 | 24 | export type FETCH_BOARDS = { type: 'FETCH_BOARDS' }; 25 | export type FETCH_BOARDS_SUCCESS = { type: 'FETCH_BOARDS_SUCCESS', payload: Array }; 26 | export type FETCH_BOARDS_FAILURE = { type: 'FETCH_BOARDS_FAILURE', payload: string }; 27 | 28 | export function fetchBoards(): FETCH_BOARDS { 29 | return { 30 | type: 'FETCH_BOARDS' 31 | }; 32 | } 33 | 34 | export type FETCH_HISTORY = { type: 'FETCH_HISTORY', board: string, date: ?string }; 35 | export type FETCH_HISTORY_SUCCESS = { type: 'FETCH_HISTORY_SUCCESS', board: string, payload: Array }; 36 | export type FETCH_HISTORY_FAILURE = { type: 'FETCH_HISTORY_FAILURE', payload: string }; 37 | 38 | export function fetchHistory(board: string, date: ?string): FETCH_HISTORY { 39 | return { 40 | type: 'FETCH_HISTORY', 41 | board, 42 | date 43 | }; 44 | } 45 | 46 | export type FETCH_STATS = { type: 'FETCH_STATS', board: string}; 47 | export type FETCH_STATS_SUCCESS = { type: 'FETCH_STATS_SUCCESS', payload: Object }; 48 | export type FETCH_STATS_FAILURE = { type: 'FETCH_STATS_FAILURE', payload: string }; 49 | 50 | export function fetchStats(board: string): FETCH_STATS { 51 | return { 52 | type: 'FETCH_STATS', 53 | board 54 | }; 55 | } 56 | 57 | export type FETCH_SNAPSHOT = { type: 'FETCH_SNAPSHOT' }; 58 | export type FETCH_SNAPSHOT_SUCCESS = { type: 'FETCH_SNAPSHOT_SUCCESS', payload: Object }; 59 | export type FETCH_SNAPSHOT_FAILURE = { type: 'FETCH_SNAPSHOT_FAILURE', payload: string }; 60 | 61 | export type SET_POLLING_INTERVAL = { type: 'SET_POLLING_INTERVAL', interval: number }; 62 | 63 | export function setPollingInterval(interval: number): SET_POLLING_INTERVAL { 64 | return { 65 | type: 'SET_POLLING_INTERVAL', 66 | interval 67 | }; 68 | } 69 | 70 | // Routes 71 | export type GOTO_SNAPSHOT = { type: 'GOTO_SNAPSHOT', board: string, timestamp: number }; 72 | 73 | export function navigateToSnapshot(board: string, timestamp: number) { 74 | return navigate(`/${board}/snapshot/${timestamp}`); 75 | } 76 | 77 | export type GOTO_LIVE_BOARD = { type: 'GOTO_LIVE_BOARD', board: string }; 78 | 79 | export function navigateToLiveBoard(board: string) { 80 | return navigate(`/${board}`); 81 | } 82 | -------------------------------------------------------------------------------- /src/main/webapp/js/api.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import moment from 'moment'; 3 | import { fetcher } from './utils'; 4 | 5 | const handleResponse = (res: Promise): Promise => 6 | res 7 | .then( 8 | ({ body }) => body, 9 | ({ body, status = 0 }) => Promise.reject(body || `connection error ${status}`) 10 | ); 11 | 12 | 13 | export const fetchBoard = (board: string): Promise => 14 | handleResponse(fetcher(`/api/boards/${board}`)); 15 | 16 | export const fetchBoards = (): Promise => 17 | handleResponse(fetcher('/api/boards')); 18 | 19 | export const fetchHistory = (board: string): Promise => 20 | handleResponse(fetcher(`/api/boards/${board}/history?last`)); 21 | 22 | export const fetchHistoryOfDay = (board: string, date: ?string): Promise => { 23 | const start = moment(date); 24 | if (!start.isValid()) 25 | return Promise.reject(`invalid date ${date || ''}`); 26 | const end = start.clone().add(1, 'day'); 27 | return handleResponse(fetcher(`/api/boards/${board}/history?from=${start.valueOf()}&until=${end.valueOf()}`)); 28 | }; 29 | 30 | export const fetchSnapshot = (board: string, timestamp: number): Promise => 31 | handleResponse(fetcher(`/api/boards/${board}/snapshot/${timestamp}`)); 32 | 33 | export const fetchStats = (board: string): Promise => 34 | handleResponse(fetcher(`/api/boards/${board}/stats`)); -------------------------------------------------------------------------------- /src/main/webapp/js/components/App.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import type { State, Route, BoardView } from '../state'; 5 | import Graph from './Graph'; 6 | import BoardList from './BoardList'; 7 | import ErrorPage from './ErrorPage'; 8 | import StatusFavicon from './StatusFavicon'; 9 | 10 | type Props = { 11 | isLoading: boolean, 12 | board: ?BoardView, 13 | error: ?string, 14 | route: Route 15 | }; 16 | 17 | class App extends Component { 18 | props: Props; 19 | 20 | constructor(props: Props) { 21 | super(props); 22 | } 23 | 24 | render() { 25 | const { error, board, route } = this.props; 26 | if (error) 27 | return ; 28 | if (route.path === 'BOARDS') return ; 29 | if (route.path === 'BOARD') { 30 | if (board) { 31 | document.title = board.title; 32 | return ( 33 |
34 | 35 | 38 |
39 | ); 40 | } 41 | else 42 | return ( 43 |

44 | Loading... 45 |

46 | ); 47 | } else 48 | return ( 49 |

50 | Not found 51 |

52 | ); 53 | } 54 | } 55 | 56 | const select = (state: State, ownProps: Props): Props => ({ 57 | ...ownProps, 58 | error: state.selectedBoardView.error, 59 | isLoading: state.selectedBoardView.isLoading, 60 | board: state.selectedBoardView.data, 61 | route: state.route, 62 | isLiveMode: state.isLiveMode, 63 | timestamp: state.selectedTimestamp 64 | }); 65 | 66 | export default connect(select)(App); 67 | -------------------------------------------------------------------------------- /src/main/webapp/js/components/BoardList.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import type { State, BoardConfig } from '../state'; 5 | import { fetchBoards } from '../actions'; 6 | 7 | type Props = { 8 | boards: Array, 9 | fetchBoards: () => void 10 | }; 11 | 12 | class BoardList extends Component { 13 | props: Props; 14 | 15 | componentWillMount() { 16 | this.props.fetchBoards(); 17 | } 18 | 19 | render() { 20 | const { boards } = this.props; 21 | return ( 22 |
23 | { 24 | boards.map(board => 25 | {board.title} 26 | ) 27 | } 28 |
29 | ); 30 | } 31 | } 32 | 33 | const select = (state: State) => ({ 34 | boards: state.boards 35 | }); 36 | 37 | const actions = dispatch => ({ 38 | fetchBoards: () => dispatch(fetchBoards()) 39 | }); 40 | 41 | export default connect(select, actions)(BoardList); -------------------------------------------------------------------------------- /src/main/webapp/js/components/Box.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { Component } from 'react'; 3 | 4 | import type { Box, Check } from '../state'; 5 | import BoxModal from './BoxModal'; 6 | 7 | type Props = { 8 | box: Box, 9 | onMouseEnter: () => void, 10 | onMouseLeave: () => void 11 | }; 12 | 13 | type State = { 14 | isModalOpen: boolean 15 | }; 16 | 17 | class BoxView extends Component { 18 | props: Props; 19 | state: State; 20 | constructor(props: Props) { 21 | super(props); 22 | this.state = { 23 | isModalOpen: false 24 | }; 25 | } 26 | 27 | render() { 28 | const { box } = this.props; 29 | return ( 30 |
36 |

{ box.title }

37 | { box.message ? { box.message } : null } 38 |
39 | { 40 | box.checks && box.checks.slice(0, box.labelLimit).map((c: Check) => 41 | {c.label || c.title} 42 | ) 43 | } 44 |
45 | { 46 | box.checks && box.labelLimit !== 0 && box.labelLimit < box.checks.length && 47 |
...
48 | } 49 | this.setState({ isModalOpen: false }) } 53 | /> 54 |
55 | ); 56 | } 57 | 58 | handleBoxClick = () => { 59 | this.setState({ isModalOpen: true }); 60 | } 61 | } 62 | 63 | export default BoxView; 64 | -------------------------------------------------------------------------------- /src/main/webapp/js/components/BoxModal.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { Component } from 'react'; 3 | import createFragment from 'react-addons-create-fragment'; 4 | import marked from 'marked'; 5 | import Modal from 'react-modal'; 6 | import type { Box } from '../state'; 7 | import CheckList from './CheckList'; 8 | 9 | marked.setOptions({ 10 | renderer: new marked.Renderer(), 11 | gfm: true, 12 | tables: true, 13 | breaks: true, 14 | pedantic: false, 15 | sanitize: false, 16 | smartLists: true, 17 | smartypants: false 18 | }); 19 | 20 | type Props = { 21 | isOpen: boolean, 22 | box: Box, 23 | onCloseClick: Function 24 | }; 25 | 26 | const style = { 27 | content: { 28 | top: '15%', 29 | bottom: '15%', 30 | left: '10%', 31 | right: '10%', 32 | padding: '0', 33 | overflow: 'hidden', 34 | boxShadow: '0 0 24px rgba(0,0,0,.5)', 35 | border: 'none', 36 | borderRadius: '0' 37 | }, 38 | overlay: { 39 | background: 'rgba(255,255,255,0.25)' 40 | } 41 | }; 42 | 43 | class BoxModal extends Component { 44 | render() { 45 | const { isOpen, box, onCloseClick } = this.props; 46 | return ( 47 | 55 |
56 |
57 | {box.title} 58 | 59 |
60 |
61 | { 62 | box.message && 63 | createFragment({ 64 | title:

Message

, 65 | content:
66 | }) 67 | } 68 | { 69 | box.description && 70 | createFragment({ 71 | title:

Description

, 72 | content:
73 | }) 74 | } 75 |

Checks

76 | { 77 | isOpen ? 78 | : null 82 | } 83 |
84 |
85 |
86 | ); 87 | } 88 | 89 | shouldComponentUpdate(nextProps: Props) { 90 | // prevent #app from scaling back after each update, as such update will remove .ReactModal__Body--open class from body 91 | if (this.props.isOpen === nextProps.isOpen) 92 | return false; 93 | return true; 94 | } 95 | } 96 | 97 | export default BoxModal; -------------------------------------------------------------------------------- /src/main/webapp/js/components/Calendar.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import {PureComponent} from 'react'; 3 | import {connect} from 'react-redux'; 4 | import moment from 'moment'; 5 | import numeral from 'numeral'; 6 | import classNames from 'classnames'; 7 | import Datetime from 'react-datetime'; 8 | import {BarChart, Bar, XAxis, YAxis, Tooltip, Label, Brush, ReferenceLine, ResponsiveContainer} from 'recharts'; 9 | 10 | import type {Stats, State, StatsGroup} from '../state'; 11 | import { fetchStats } from '../actions'; 12 | 13 | type CalendarProps = { 14 | boardName: string, 15 | isOpen: boolean, 16 | selectedDay: Date, 17 | stats: StatsGroup, 18 | isLoading: boolean, 19 | onDayClick: Function, 20 | onCloseClick: Function, 21 | fetchStats: Function, 22 | slo: number 23 | }; 24 | 25 | type DatetimeProps = { 26 | className: string, 27 | }; 28 | 29 | type Payload = { 30 | value: any, 31 | } 32 | const fullDateFormat = 'YYYY-MM-DD'; 33 | 34 | type CustomChartTooltipProps = { 35 | active: boolean, 36 | payload: Array, 37 | label: string, 38 | }; 39 | function CustomChartTooltip(props: CustomChartTooltipProps){ 40 | const { active } = props; 41 | if (active) { 42 | const { payload, label } = props; 43 | return ( 44 |
45 |
46 | {moment(label).format(fullDateFormat)}:
47 | {numeral(payload[0].value).format('0.00%')} 48 |
49 |
50 | ); 51 | } 52 | return null; 53 | } 54 | 55 | type ThresholdFillBarProps = { 56 | x: number, 57 | y: number, 58 | width: number, 59 | height: number, 60 | value: number, 61 | threshold: number, 62 | }; 63 | function ThresholdFillBar(props: ThresholdFillBarProps) { 64 | const getPath = (x, y, width, height) => `M${x},${y} h ${width} v ${height} h -${width} Z`; 65 | const { x, y, width, height, value, threshold } = props; 66 | 67 | const pathClassName = classNames({ 68 | 'recharts-rectangle': true, 69 | good: value && threshold && value >= threshold, 70 | bad: value && threshold && value < threshold, 71 | }); 72 | 73 | return ; 74 | } 75 | 76 | class Calendar extends PureComponent { 77 | props: CalendarProps; 78 | state = { 79 | flipped: false, 80 | }; 81 | 82 | constructor(props: CalendarProps) { 83 | super(props); 84 | this.props.fetchStats(); 85 | } 86 | 87 | render() { 88 | const buildDomain = (data, slo) => { 89 | const values = data && data.length > 0 && data.map(x => x.percent) || [0, 1]; 90 | const [min, max] = [Math.min(...values), Math.max(...values)]; 91 | return data && data.length > 1 && min !== max 92 | ? min > slo * 0.9 ? [slo * 0.9, 1] : ['auto', 1] 93 | : [0, 1]; 94 | }; 95 | 96 | const {isOpen, selectedDay, onDayClick, onCloseClick, stats, slo} = this.props; 97 | const {flipped} = this.state; 98 | 99 | const data = stats && Object.keys(stats.daily) 100 | .map(key => ({date: Number(key), percent: stats.daily[Number(key)]})) 101 | .sort((a, b) => a.date - b.date); 102 | 103 | const wrapperClass = classNames({ 104 | 'calendar-chart-wrapper': true, 105 | closed: !isOpen, 106 | flipped: flipped, 107 | }); 108 | 109 | const flipperClass = classNames({ 110 | flipper: true, 111 | flipped: flipped, 112 | }); 113 | 114 | const flipViewButtonClass = classNames({ 115 | 'flip-view-button': true, 116 | chart: !flipped, 117 | calendar: flipped, 118 | }); 119 | 120 | return ( 121 |
122 |
123 | 132 | { data && data.length > 0 && 133 | ( 134 | 135 | 136 | 138 | }/> 139 | '' } 140 | startIndex={Math.max(0, data.length - 31)} 141 | endIndex={Math.max(0, data.length - 1)} 142 | travellerWidth={10}/> 143 | }/> 144 | 145 | 148 | 149 | ) 150 | } 151 |
152 | 153 | 154 | { data && data.length > 0 && 155 | () 156 | } 157 |
158 | ); 159 | } 160 | 161 | formatPercentage = (val: number): string => numeral(val).format(val < 1 ? '0.00%' : '0%'); 162 | 163 | getDateInfo(stats, timestamp, slo): [string, string] { 164 | const percentage = stats && stats[timestamp]; 165 | const percentageFormatted = !stats || isNaN(percentage) 166 | ? 'N/A' 167 | : this.formatPercentage(percentage); 168 | const dateClass = classNames({ 169 | 'data-available': percentage && !isNaN(percentage), 170 | 'data-unavailable': !percentage || isNaN(percentage), 171 | good: percentage && percentage >= slo, 172 | bad: percentage && percentage < slo 173 | }); 174 | return [percentageFormatted, dateClass]; 175 | } 176 | 177 | renderDay = (stats: Stats, slo: number) => ( props: DatetimeProps, currentDate: moment) => { 178 | const timestamp = currentDate.valueOf(); 179 | const [percentageFormatted, dateClass] = this.getDateInfo(stats, timestamp, slo); 180 | 181 | props.className += ` ${dateClass}`; 182 | return 183 | { numeral(currentDate.date()).format('00') } 184 | { percentageFormatted } 185 | ; 186 | }; 187 | 188 | renderMonth = (stats: Stats, slo: number) => ( props: DatetimeProps, month: number, year: number) => { 189 | const timestamp = moment().date(1).month(month).year(year).startOf('day').valueOf(); 190 | const [percentageFormatted, dateClass] = this.getDateInfo(stats, timestamp, slo); 191 | 192 | const localMoment = moment(); 193 | const shortMonthName = localMoment.localeData().monthsShort(localMoment.month(month)).substring(0, 3); 194 | 195 | props.className += ` ${dateClass}`; 196 | return 197 | { shortMonthName } 198 | { percentageFormatted } 199 | ; 200 | }; 201 | 202 | renderYear = (stats: Stats, slo: number) => ( props: DatetimeProps, year: number) => { 203 | const timestamp = moment().date(1).month(0).year(year).startOf('day').valueOf(); 204 | const [percentageFormatted, dateClass] = this.getDateInfo(stats, timestamp, slo); 205 | 206 | props.className += ` ${dateClass}`; 207 | return 208 | { year } 209 | { percentageFormatted } 210 | ; 211 | }; 212 | 213 | validateDate = (currentDate: moment) => { 214 | const tomorrow = moment().startOf('day').add(1, 'day'); 215 | return currentDate.isBefore(tomorrow); 216 | }; 217 | 218 | onFlipView = () => this.setState({flipped: !this.state.flipped}); 219 | 220 | xAxisTickFormatter = (val) => moment(val).format(fullDateFormat); 221 | 222 | yAxisTickFormatter = (val) => numeral(val).format('0%'); 223 | } 224 | 225 | const select = (state: State) => ({ 226 | stats: state.stats.data, 227 | isLoading: state.stats.isLoading, 228 | error: state.stats.error, 229 | boardName: state.currentBoard, 230 | }); 231 | 232 | const actions = (dispatch) => ({ 233 | fetchStats: function() { 234 | const props = this; 235 | return dispatch(fetchStats(props.boardName)); 236 | } 237 | }); 238 | 239 | export default connect(select, actions)(Calendar); 240 | -------------------------------------------------------------------------------- /src/main/webapp/js/components/CheckList.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import marked from 'marked'; 3 | import type { Check } from '../state'; 4 | 5 | type Props = { 6 | checks: Array 7 | }; 8 | 9 | const CheckList = ({ checks }: Props) => ( 10 |
11 | {checks.map(({ title, status, message }: Check) => ( 12 |
13 | 14 |
15 |

{title}

16 |
17 |
18 |
19 | ))} 20 |
21 | ); 22 | 23 | export default CheckList; 24 | -------------------------------------------------------------------------------- /src/main/webapp/js/components/ErrorPage.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | type Props = { 3 | message: string 4 | }; 5 | const ErrorPage = ({ message }: Props) => ( 6 |
7 | {byMessage(message)} 8 |
9 | ); 10 | 11 | const byMessage = (message: string) => { 12 | if (!message) 13 | return

Unknown error

; 14 | else if (message.includes('is not ready')) 15 | return ( 16 |
17 |

{message}

18 |

check this page for explanations

19 |
20 | ); 21 | else if (message.includes('does not exist')) 22 | return ( 23 |
24 |

{message}

25 |

Return to board list

26 |
27 | ); 28 | else 29 | return

{message}

; 30 | }; 31 | 32 | export default ErrorPage; -------------------------------------------------------------------------------- /src/main/webapp/js/components/Graph.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { Component } from 'react'; 3 | import { findDOMNode } from 'react-dom'; 4 | import Box from './Box'; 5 | import Timeline from './Timeline'; 6 | 7 | import type { BoardView } from '../state'; 8 | 9 | type Props = { 10 | board: BoardView 11 | }; 12 | 13 | type State = { 14 | hoveredBoxTitle: string 15 | }; 16 | 17 | class Graph extends Component { 18 | props: Props; 19 | state: State; 20 | constructor(props: Props) { 21 | super(props); 22 | this.state = { 23 | hoveredBoxTitle: '' 24 | }; 25 | } 26 | 27 | render() { 28 | const { board } = this.props; 29 | return ( 30 |
31 |
32 |

{board.title}

33 |

{board.message}

34 |
35 |
36 | 37 | { 38 | board.columns.map((col, i) => ( 39 |
40 | { 41 | col.rows.map(row => ( 42 |
43 |

{row.title}

44 | { 45 | row.boxes.map(box => ( 46 | this.setState({ hoveredBoxTitle: box.title })} 51 | onMouseLeave={() => this.setState({ hoveredBoxTitle: '' })} 52 | /> 53 | )) 54 | } 55 |
56 | )) 57 | } 58 |
59 | )) 60 | } 61 |
62 |
63 | 66 |
67 |
68 | ); 69 | } 70 | 71 | componentDidMount() { 72 | this.drawLines(); 73 | window.addEventListener('resize', this.drawLines); 74 | } 75 | 76 | componentDidUpdate() { 77 | this.drawLines(); 78 | } 79 | 80 | componentWillUnmount() { 81 | window.removeEventListener('resize', this.drawLines); 82 | } 83 | 84 | drawLines = () => { 85 | let lines = findDOMNode(this.refs.lines); 86 | const { board } = this.props; 87 | let hoveredTitle = this.state.hoveredBoxTitle; 88 | if(lines && board) { 89 | document.title = board.title; 90 | lines.innerHTML = board.links 91 | .map(([from, to]) => { 92 | return [from, to, findDOMNode(this.refs[from]), findDOMNode(this.refs[to])]; 93 | }) 94 | .filter(([_from, _to, fromNode, toNode]) => toNode && fromNode) 95 | .reduce((html, [from, to, fromNode, toNode]) => { 96 | let extraClass = ''; 97 | if (hoveredTitle === from || hoveredTitle === to) { 98 | extraClass = ' hoveredPath'; 99 | } 100 | return html + ` 101 | 102 | 103 | 104 | 105 | `; 106 | }, ''); 107 | } 108 | } 109 | } 110 | 111 | export default Graph; 112 | 113 | const getBox = (el: HTMLElement): { x: number, y: number } => { 114 | let rect = el.getBoundingClientRect(); 115 | return { 116 | x: rect.left + (rect.right - rect.left) / 2, 117 | y: rect.top + (rect.bottom - rect.top) / 2 118 | }; 119 | }; 120 | 121 | const spline = (from: HTMLElement, to: HTMLElement): string => { 122 | let fromBox = getBox(from), toBox = getBox(to); 123 | let a, b; 124 | // Exact comparison can mess up during a hover animation. 125 | if (Math.abs(fromBox.x - toBox.x) < 5) { 126 | // Here we prefer to draw the line bottom to top because it makes the arc 127 | // look closer to left -> right. 128 | a = fromBox.y > toBox.y ? fromBox : toBox, b = fromBox.y > toBox.y ? toBox : fromBox; 129 | } else { 130 | a = fromBox.x < toBox.x ? fromBox : toBox, b = fromBox.x < toBox.x ? toBox : fromBox; 131 | } 132 | let n = Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)) * .75; 133 | return `M${a.x},${a.y} C${a.x + n},${a.y} ${b.x - n},${b.y} ${b.x},${b.y}`; 134 | }; 135 | -------------------------------------------------------------------------------- /src/main/webapp/js/components/StatusFavicon.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { PureComponent } from 'react'; 3 | import type { Status } from '../state'; 4 | import { statusToColor } from '../utils'; 5 | 6 | type Props = { 7 | status: Status 8 | }; 9 | 10 | const Radius = 32; 11 | 12 | class StatusFavicon extends PureComponent { 13 | props: Props; 14 | 15 | canvas = null; 16 | 17 | render() { 18 | return null; 19 | } 20 | 21 | componentDidUpdate() { 22 | this.update(); 23 | } 24 | 25 | componentDidMount() { 26 | this.update(); 27 | } 28 | 29 | componentWillUnmount() { 30 | const favicon = document.querySelector('#favicon'); 31 | if (favicon instanceof HTMLLinkElement) { 32 | favicon.href = ''; 33 | } 34 | } 35 | 36 | update() { 37 | let { canvas, props: { status } } = this; 38 | if (canvas === null) { 39 | this.canvas = canvas = document.createElement('canvas'); 40 | canvas.width = 2 * Radius; 41 | canvas.height = 2 * Radius; 42 | } 43 | const ctx = canvas.getContext('2d'); 44 | if (ctx) { 45 | ctx.beginPath(); 46 | ctx.arc(Radius, Radius, Radius / 2, 0, 2 * Math.PI); 47 | ctx.fillStyle = statusToColor(status); 48 | ctx.fill(); 49 | } 50 | const favicon = document.querySelector('#favicon'); 51 | if (favicon instanceof HTMLLinkElement) { 52 | favicon.href = canvas.toDataURL('image/x-icon'); 53 | } 54 | } 55 | } 56 | 57 | export default StatusFavicon; -------------------------------------------------------------------------------- /src/main/webapp/js/components/Timeline.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { Component } from 'react'; 3 | import { findDOMNode, render } from 'react-dom'; 4 | import { connect } from 'react-redux'; 5 | import vis from 'vis'; 6 | import moment from 'moment'; 7 | import type { State } from '../state'; 8 | import { navigateToSnapshot, navigateToLiveBoard } from '../actions'; 9 | import Controller from './TimelineController'; 10 | 11 | type Props = { 12 | history: any, 13 | error: ?string, 14 | isLoading: boolean, 15 | isLiveMode: boolean, 16 | navigateToSnapshot: (timestamp: number) => void, 17 | navigateToLiveBoard: () => void, 18 | selectedDate: ?string, 19 | selectedTimestamp: ?number 20 | }; 21 | 22 | class Timeline extends Component { 23 | props: Props; 24 | state: { 25 | hasFocus: boolean 26 | }; 27 | 28 | timeline: any; 29 | dataset: any; 30 | static DATE_FORMAT = 'YYYY-MM-DD HH:mm'; 31 | 32 | constructor(props: Props) { 33 | super(props); 34 | this.timeline = null; 35 | this.dataset = null; 36 | this.state = { 37 | hasFocus: false 38 | }; 39 | } 40 | 41 | render() { 42 | const { history } = this.props; 43 | return ( 44 |
this.setState({ hasFocus: true })} 47 | onMouseLeave={() => this.setState({ hasFocus: false })} 48 | > 49 | 50 | { 51 | history && ( 52 |
53 |
54 |
55 | ) 56 | } 57 |
58 | ); 59 | } 60 | 61 | componentDidMount() { 62 | this.updateAndRender(); 63 | } 64 | 65 | shouldComponentUpdate(nextProps, nextState) { 66 | // No update: go back to a past timepoint 67 | if (this.props.isLiveMode === true && nextProps.isLiveMode === false) { 68 | return false; 69 | } 70 | // No update: focus state changes 71 | if (this.state.hasFocus !== nextState.hasFocus) 72 | return false; 73 | return true; 74 | } 75 | 76 | componentDidUpdate(prevProps) { 77 | const { isLiveMode, isLoading } = this.props; 78 | const { hasFocus } = this.state; 79 | // Switch from past to live mode 80 | if (prevProps.isLiveMode === false && isLiveMode === true) { 81 | this.timeline && this.timeline.setSelection([]); 82 | return; 83 | } 84 | // Do nothing if the user is focusing on the timeline 85 | if (hasFocus && (prevProps.isLoading === isLoading)) 86 | return; 87 | 88 | if (!isLoading) { 89 | this.updateAndRender(); 90 | } 91 | } 92 | 93 | updateAndRender() { 94 | this.updateDataset(); 95 | this.renderTimeline(); 96 | } 97 | 98 | updateDataset() { 99 | this.dataset = Object.entries(this.props.history) 100 | .filter( 101 | ([_, status]: [string, any]) => 102 | status === 'ERROR' || status === 'WARNING' 103 | ) 104 | .map(([ts, status]: [string, any], i) => { 105 | const date = moment(parseInt(ts)); 106 | return { 107 | date, 108 | id: i, 109 | content: '', 110 | start: date.format(Timeline.DATE_FORMAT), 111 | title: date.format(Timeline.DATE_FORMAT), 112 | className: `${status} background` 113 | }; 114 | }) 115 | .sort((a, b) => a.start.localeCompare(b.start)); 116 | } 117 | 118 | renderTimeline() { 119 | const node = findDOMNode(this); 120 | const container = node.querySelector('#container'); 121 | const { history, selectedTimestamp, selectedDate } = this.props; 122 | // if selectedDate is not defined, defaults to last 24 hours 123 | const start = selectedDate ? moment(selectedDate) : moment().subtract(24, 'hour'); 124 | const end = selectedDate ? moment(selectedDate).add(24, 'hour') : moment(); 125 | if (!this.timeline) { 126 | const timeline = new vis.Timeline(container, this.dataset, { 127 | height: 75, 128 | type: 'point', 129 | stack: false, 130 | zoomMin: 60 * 1000, 131 | start, 132 | end 133 | }); 134 | timeline.on('select', ({ items }) => { 135 | if (items.length > 0) { 136 | const entry = this.dataset.find(_ => _.id === items[0]); 137 | const timestamp = entry && entry.date.valueOf(); 138 | timestamp && this.props.navigateToSnapshot(timestamp); 139 | } else { 140 | this.props.navigateToLiveBoard(); 141 | } 142 | }); 143 | this.timeline = timeline; 144 | } else { 145 | this.timeline.setWindow(start.valueOf(), end.valueOf()); 146 | this.timeline.setItems(this.dataset); 147 | } 148 | 149 | if (this.dataset && this.dataset.length == 0) { 150 | render( 151 |
152 | { 153 | Object.keys(history).length > 0 ? 154 |
No warnings or errors in this period
: 155 |
No data available
156 | } 157 |
, 158 | container.querySelector('#message') 159 | ); 160 | } else { 161 | render( 162 |
, 163 | container.querySelector('#message') 164 | ); 165 | } 166 | if (selectedTimestamp) { 167 | const id = this.dataset.find(_ => _.date.valueOf() === selectedTimestamp); 168 | id && this.timeline.setSelection([id.id]); 169 | } 170 | } 171 | } 172 | 173 | const select = (state: State) => ({ 174 | history: state.history.data, 175 | selectedDate: state.history.date, 176 | error: state.history.error, 177 | isLoading: state.history.isLoading, 178 | isLiveMode: state.isLiveMode, 179 | selectedTimestamp: state.selectedTimestamp, 180 | board: state.currentBoard 181 | }); 182 | 183 | const actions = (dispatch) => ({ 184 | navigateToSnapshot: function(timestamp) { 185 | const props = this; 186 | dispatch(navigateToSnapshot(props.board, timestamp)); 187 | }, 188 | navigateToLiveBoard: function() { 189 | const props = this; 190 | dispatch(navigateToLiveBoard(props.board)); 191 | } 192 | }); 193 | 194 | export default connect(select, actions)(Timeline); 195 | -------------------------------------------------------------------------------- /src/main/webapp/js/components/TimelineController.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { PureComponent } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import moment from 'moment'; 5 | 6 | import type { State } from '../state'; 7 | 8 | import { fetchHistory, navigateToLiveBoard } from '../actions'; 9 | import Calendar from './Calendar'; 10 | import { Button } from '../lib'; 11 | 12 | type Props = { 13 | boardName: string, 14 | date: ?string, 15 | isLiveMode: boolean, 16 | isLoading: boolean, 17 | error: ?string, 18 | selectedTimestamp: ?number, 19 | navigateToLiveBoard: () => void, 20 | fetchHistory: (date: ?string) => void, 21 | isLoadingBoard: boolean, 22 | slo: number, 23 | }; 24 | 25 | class TimelineController extends PureComponent { 26 | props: Props; 27 | state: { 28 | isCalendarOpen: boolean, 29 | selectedDay: Date 30 | }; 31 | 32 | constructor(props: Props) { 33 | super(props); 34 | this.state = { 35 | isCalendarOpen: false, 36 | selectedDay: new Date() 37 | }; 38 | } 39 | 40 | render() { 41 | const { 42 | date, 43 | isLiveMode, 44 | selectedTimestamp, 45 | isLoading, 46 | error, 47 | isLoadingBoard, 48 | slo 49 | } = this.props; 50 | const { isCalendarOpen, selectedDay } = this.state; 51 | return ( 52 |
53 | 54 | {date ? date : 'Last 24 hours'} 55 | 61 | {date && 62 | } 65 | {isLoading && } 66 | {error} 67 | 68 | 69 | {isLiveMode 70 | ? 'LIVE' 71 | : `SNAPSHOT ${moment(selectedTimestamp).format('YYYY-MM-DD HH:mm')}`} 72 | {!isLiveMode && } 73 | 74 | { 75 | isLoadingBoard && 76 | } 77 | 84 |
85 | ); 86 | } 87 | 88 | handleDayClick = selectedDay => { 89 | const { selectedDay: prevDay } = this.state; 90 | this.setState({ selectedDay, isCalendarOpen: false }, () => { 91 | if (prevDay - selectedDay !== 0) 92 | this.props.fetchHistory(moment(this.state.selectedDay).format('YYYY-MM-DD')); 93 | }); 94 | }; 95 | 96 | handleLast24HClick = () => { 97 | this.props.fetchHistory(); 98 | this.setState({ selectedDay: new Date(), isCalendarOpen: false }); 99 | }; 100 | 101 | hanldeResetClick = () => { 102 | this.props.navigateToLiveBoard(); 103 | }; 104 | 105 | handleCloseClick = () => { 106 | this.setState({ isCalendarOpen: false }); 107 | }; 108 | } 109 | 110 | const select = (state: State) => ({ 111 | date: state.history.date, 112 | isLiveMode: state.isLiveMode, 113 | selectedTimestamp: state.selectedTimestamp, 114 | isLoading: state.history.isLoading, 115 | error: state.history.error, 116 | boardName: state.currentBoard, 117 | isLoadingBoard: state.selectedBoardView.isLoading, 118 | slo: state.selectedBoardView.data && state.selectedBoardView.data.slo 119 | }); 120 | 121 | const actions = dispatch => ({ 122 | navigateToLiveBoard: function() { 123 | const props = this; 124 | return dispatch(navigateToLiveBoard(props.boardName)); 125 | }, 126 | fetchHistory: function(date) { 127 | const props = this; 128 | return dispatch(fetchHistory(props.boardName, date)); 129 | } 130 | }); 131 | 132 | export default connect(select, actions)(TimelineController); 133 | -------------------------------------------------------------------------------- /src/main/webapp/js/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import configureStore from './store'; 4 | import App from './components/App'; 5 | import { Provider } from 'react-redux'; 6 | 7 | const store = configureStore(); 8 | 9 | const mount = Component => render( 10 | 11 | 12 | , 13 | document.getElementById('app') 14 | ); 15 | 16 | mount(App); 17 | 18 | if (module.hot) { 19 | module.hot.accept('./components/App',() => { 20 | mount(require('./components/App').default); 21 | return true; 22 | }); 23 | } -------------------------------------------------------------------------------- /src/main/webapp/js/lib/Button.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export const Button = ({ children, onClick }: any) => ( 4 | 7 | ); -------------------------------------------------------------------------------- /src/main/webapp/js/lib/index.js: -------------------------------------------------------------------------------- 1 | export * from './Button'; -------------------------------------------------------------------------------- /src/main/webapp/js/router.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { createRouter } from 'redux-url'; 3 | import { createBrowserHistory } from 'history'; 4 | 5 | import type { Action } from './actions'; 6 | const routes = { 7 | '/': 'GOTO_BOARDS', 8 | '/:board': ({ board }): Action => ({ 9 | type: 'GOTO_LIVE_BOARD', 10 | board 11 | }), 12 | '/:board/snapshot/:timestamp': ({ board, timestamp }): Action => ({ 13 | type: 'GOTO_SNAPSHOT', 14 | board, 15 | timestamp: parseInt(timestamp) 16 | }), 17 | '*': 'NOT_FOUND' 18 | }; 19 | 20 | const router = createRouter(routes, createBrowserHistory()); 21 | 22 | export default router; -------------------------------------------------------------------------------- /src/main/webapp/js/sagas/index.js: -------------------------------------------------------------------------------- 1 | import { takeLatest, call, put, fork, select } from 'redux-saga/effects'; 2 | import { delay } from 'redux-saga'; 3 | import { navigate } from 'redux-url'; 4 | import moment from 'moment'; 5 | import * as api from '../api'; 6 | import { combineViewAndLayout, aggregateStatsByDay, aggregateStatsByMonth, aggregateStatsByYear } from '../utils'; 7 | import { setPollingInterval } from '../actions'; 8 | 9 | // fetch the current view of the board 10 | export function* fetchBoard(action, transformer = combineViewAndLayout) { 11 | try { 12 | const boards = yield call(fetchBoards); 13 | const boardView = yield call(api.fetchBoard, action.board); 14 | const config = boards.find(_ => _.title === boardView.title); 15 | const { layout, links, slo } = config; 16 | yield put({ type: 'FETCH_BOARD_SUCCESS', payload: transformer(boardView, layout, links, slo) }); 17 | } catch (error) { 18 | yield put({ type: 'FETCH_BOARD_FAILURE', payload: error }); 19 | } 20 | } 21 | 22 | // from route change 23 | export function* watchLiveBoardChange() { 24 | yield takeLatest('GOTO_LIVE_BOARD', fetchBoard); 25 | } 26 | 27 | // fetch boards 28 | export function* fetchBoards() { 29 | try { 30 | const cached = yield select(state => state.boards); 31 | if (cached && cached.length > 0) 32 | return cached; 33 | const boards = yield call(api.fetchBoards); 34 | yield put({ type: 'FETCH_BOARDS_SUCCESS', payload: boards }); 35 | return boards; 36 | } catch (error) { 37 | yield put({ type: 'FETCH_BOARDS_FAILURE', payload: error }); 38 | } 39 | } 40 | 41 | export function* watchFetchBoards() { 42 | yield takeLatest('FETCH_BOARDS', fetchBoards); 43 | } 44 | 45 | // fetch history 46 | export function* fetchHistory(action) { 47 | try { 48 | const history = yield call( 49 | action.date ? api.fetchHistoryOfDay : api.fetchHistory, 50 | action.board, 51 | action.date 52 | ); 53 | yield put({ type: 'FETCH_HISTORY_SUCCESS', payload: history }); 54 | } catch (error) { 55 | yield put({ type: 'FETCH_HISTORY_FAILURE', payload: error }); 56 | } 57 | } 58 | 59 | export function* watchFetchHistory() { 60 | yield takeLatest('FETCH_HISTORY', fetchHistory); 61 | } 62 | 63 | // fetch snapshot 64 | export function* fetchSnapshot(action, transformer = combineViewAndLayout) { 65 | try { 66 | if (action.isLiveMode) 67 | return; 68 | const boards = yield call(fetchBoards); 69 | const currentBoard = yield select(state => state.currentBoard); 70 | const snapshot = yield call(api.fetchSnapshot, currentBoard, action.timestamp); 71 | const config = boards.find(_ => _.title === snapshot.title); 72 | const { layout, links } = config; 73 | yield put({ type: 'FETCH_SNAPSHOT_SUCCESS', payload: transformer(snapshot, layout, links) }); 74 | } catch (error) { 75 | yield put({ type: 'FETCH_SNAPSHOT_FAILURE', payload: error }); 76 | } 77 | } 78 | 79 | // from route change 80 | export function* watchSnapshotChange() { 81 | yield takeLatest('GOTO_SNAPSHOT', handleSnapshotChange); 82 | } 83 | 84 | export function* handleSnapshotChange(action) { 85 | // if history is not available, fetch it 86 | const history = yield select(state => state.history.data); 87 | if (!history) { 88 | const datetime = moment(action.timestamp); 89 | const date = moment().isSame(datetime, 'day') ? null : datetime.format('YYYY-MM-DD'); 90 | yield put({ type: 'FETCH_HISTORY', board: action.board, date }); 91 | } 92 | yield call(fetchSnapshot, action); 93 | } 94 | 95 | // fetch stats 96 | export function* fetchStats(action) { 97 | try { 98 | const hourlyStats = yield call(api.fetchStats, action.board); 99 | const dailyStats = aggregateStatsByDay(hourlyStats); 100 | const monthlyStats = aggregateStatsByMonth(hourlyStats); 101 | const yearlyStats = aggregateStatsByYear(hourlyStats); 102 | 103 | const stats = { daily: dailyStats, monthly: monthlyStats, yearly: yearlyStats }; 104 | 105 | yield put({ type: 'FETCH_STATS_SUCCESS', payload: stats }); 106 | } catch (error) { 107 | yield put({ type: 'FETCH_HISTORY_FAILURE', payload: error }); 108 | } 109 | } 110 | 111 | export function* watchFetchStats() { 112 | yield takeLatest('FETCH_STATS', handleFetchStats); 113 | } 114 | 115 | export function* handleFetchStats(action) { 116 | const stats = yield select(state => state.stats.data); 117 | if (!stats || !stats[action.startDate]) { 118 | yield call(fetchStats, action); 119 | } 120 | } 121 | 122 | // polling service 123 | export function* poll(init = false) { 124 | const { interval, isLiveMode, date } = yield select(state => ({ 125 | interval: state.pollingIntervalSeconds, 126 | isLiveMode: state.isLiveMode, 127 | date: state.history.date 128 | })); 129 | if (interval > 0) { 130 | const route = yield select(state => state.route); 131 | if (route.path === 'BOARD' && route.board && isLiveMode) { 132 | if (!init) // do not call it in initialization 133 | yield fork(fetchBoard, { type: 'FETCH_BOARD', board: route.board }); 134 | if (!date) // polling history only in last 24 hours mode 135 | yield fork(fetchHistory, { type: 'FETCH_HISTORY', board: route.board }); 136 | } 137 | yield delay(interval * 1000); 138 | yield call(poll); 139 | } 140 | } 141 | 142 | export function* watchPollingIntervalChange() { 143 | yield takeLatest('SET_POLLING_INTERVAL', poll, true); 144 | } 145 | 146 | // root 147 | export default function* rootSaga() { 148 | // watchers 149 | yield fork(watchFetchBoards); 150 | yield fork(watchFetchHistory); 151 | yield fork(watchFetchStats); 152 | yield fork(watchSnapshotChange); 153 | yield fork(watchLiveBoardChange); 154 | yield fork(watchPollingIntervalChange); 155 | 156 | // initial setup 157 | yield put(navigate(location.pathname, true)); 158 | yield put(setPollingInterval(60)); 159 | } 160 | -------------------------------------------------------------------------------- /src/main/webapp/js/state.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Action } from './actions'; 3 | 4 | export type Check = { 5 | title: string, 6 | status: Status, 7 | message: string, 8 | label: ?string 9 | }; 10 | 11 | export type Box = { 12 | title: string, 13 | status: Status, 14 | message: string, 15 | description: ?string, 16 | labelLimit: number, 17 | checks: Array 18 | }; 19 | 20 | export type Row = { 21 | title: string, 22 | percentage: number, 23 | boxes: Array 24 | }; 25 | 26 | export type Column = { 27 | percentage: number, 28 | rows: Array 29 | }; 30 | 31 | export type BoardView = { 32 | title: string, 33 | status: Status, 34 | message: string, 35 | columns: Array, 36 | links: Array, 37 | slo: number 38 | }; 39 | 40 | export type Layout = { 41 | columns: Array 42 | }; 43 | 44 | export type BoardConfig = { 45 | title: string, 46 | layout: Layout, 47 | links: Array 48 | }; 49 | 50 | export type Stats = { 51 | [k: ?number]: number 52 | }; 53 | 54 | export type StatsGroup = { 55 | daily: Stats, 56 | monthly: Stats, 57 | yearly: Stats 58 | }; 59 | 60 | export type Route = { 61 | path: string, 62 | [k: ?string]: any 63 | }; 64 | 65 | export type Link = [string, string]; 66 | 67 | export type Status = 'Unknown' | 'Success' | 'Error' | 'Warning'; 68 | 69 | export type State = { 70 | currentBoard: ?string, 71 | route: Route, 72 | boards: Array, 73 | isLiveMode: boolean, // in live mode, polling the server 74 | history: { 75 | isLoading: boolean, 76 | data: any, 77 | error: ?string, 78 | date: ?string // if date is not specified, history comes from last 24 hours 79 | }, 80 | liveBoardView: { // board view to be updated in live mode 81 | isLoading: boolean, 82 | data: ?BoardView, 83 | error: ?string 84 | }, 85 | selectedBoardView: { // board view to be displayed 86 | isLoading: boolean, 87 | data: ?BoardView, 88 | error: ?string 89 | }, 90 | selectedTimestamp: ?number, // timestamp of the snapshot board to be displayed 91 | stats: { 92 | isLoading: boolean, 93 | data: ?Object, 94 | error: ?string 95 | }, 96 | pollingIntervalSeconds: number 97 | }; 98 | 99 | const initState: State = { 100 | route: { 101 | path: 'NOT_FOUND' 102 | }, 103 | currentBoard: null, 104 | boards: [], 105 | isLiveMode: true, 106 | history: { 107 | isLoading: false, 108 | data: null, 109 | error: null, 110 | date: null 111 | }, 112 | liveBoardView: { 113 | isLoading: false, 114 | data: null, 115 | error: null 116 | }, 117 | selectedBoardView: { 118 | isLoading: false, 119 | data: null, 120 | error: null, 121 | }, 122 | stats: { 123 | isLoading: false, 124 | data: null, 125 | error: null 126 | }, 127 | selectedTimestamp: null, 128 | pollingIntervalSeconds: 0 129 | }; 130 | 131 | export default function reducers(state: State = initState, action: Action): State { 132 | switch (action.type) { 133 | // Current board view 134 | case 'FETCH_BOARD': { 135 | const liveBoardView = { 136 | ...state.liveBoardView, 137 | isLoading: true 138 | }; 139 | return { 140 | ...state, 141 | liveBoardView, 142 | selectedBoardView: state.isLiveMode ? liveBoardView : state.selectedBoardView 143 | }; 144 | } 145 | case 'FETCH_BOARD_SUCCESS': { 146 | const liveBoardView = { 147 | ...state.liveBoardView, 148 | isLoading: false, 149 | data: action.payload, 150 | error: null 151 | }; 152 | return { 153 | ...state, 154 | liveBoardView, 155 | selectedBoardView: state.isLiveMode ? liveBoardView : state.selectedBoardView 156 | }; 157 | } 158 | case 'FETCH_BOARD_FAILURE': { 159 | const liveBoardView = { 160 | ...state.liveBoardView, 161 | isLoading: false, 162 | error: action.payload 163 | }; 164 | return { 165 | ...state, 166 | liveBoardView, 167 | selectedBoardView: state.isLiveMode ? liveBoardView : state.selectedBoardView 168 | }; 169 | } 170 | // Boards 171 | case 'FETCH_BOARDS_SUCCESS': 172 | return { 173 | ...state, 174 | boards: action.payload 175 | }; 176 | case 'FETCH_BOARDS_FAILURE': 177 | return { 178 | ...state, 179 | error: action.payload 180 | }; 181 | // History 182 | case 'FETCH_HISTORY': 183 | return { 184 | ...state, 185 | history: { 186 | ...state.history, 187 | isLoading: true, 188 | date: action.date 189 | } 190 | }; 191 | case 'FETCH_HISTORY_SUCCESS': 192 | return { 193 | ...state, 194 | history: { 195 | ...state.history, 196 | isLoading: false, 197 | error: null, 198 | data: action.payload 199 | } 200 | }; 201 | case 'FETCH_HISTORY_FAILURE': 202 | return { 203 | ...state, 204 | history: { 205 | ...state.history, 206 | isLoading: false, 207 | error: action.payload 208 | } 209 | }; 210 | // Snapshot 211 | case 'FETCH_SNAPSHOT_SUCCESS': { 212 | if (state.isLiveMode) 213 | return state; 214 | else 215 | return { 216 | ...state, 217 | selectedBoardView: { 218 | isLoading: false, 219 | error: null, 220 | data: action.payload 221 | } 222 | }; 223 | } 224 | case 'FETCH_SNAPSHOT_FAILURE': { 225 | if (state.isLiveMode) 226 | return state; 227 | return { 228 | ...state, 229 | selectedBoardView: { 230 | isLoading: false, 231 | error: action.payload, 232 | data: state.selectedBoardView.data 233 | } 234 | }; 235 | } 236 | // Stats 237 | case 'FETCH_STATS': 238 | return { 239 | ...state, 240 | stats: { 241 | ...state.stats, 242 | isLoading: true 243 | } 244 | }; 245 | case 'FETCH_STATS_SUCCESS': 246 | return { 247 | ...state, 248 | stats: { 249 | ...state.stats, 250 | isLoading: false, 251 | data: action.payload, 252 | error: null 253 | } 254 | }; 255 | case 'FETCH_STATS_FAILURE': 256 | return { 257 | ...state, 258 | stats: { 259 | ...state.stats, 260 | isLoading: true, 261 | data: null, 262 | error: action.payload 263 | } 264 | }; 265 | // Polling service 266 | case 'SET_POLLING_INTERVAL': 267 | return { 268 | ...state, 269 | pollingIntervalSeconds: action.interval 270 | }; 271 | // Routes 272 | case 'GOTO_BOARDS': 273 | return { 274 | ...state, 275 | currentBoard: null, 276 | route: { 277 | path: 'BOARDS' 278 | } 279 | }; 280 | case 'GOTO_LIVE_BOARD': 281 | return { 282 | ...state, 283 | isLiveMode: true, 284 | selectedBoardView: { 285 | ...state.selectedBoardView, 286 | isLoading: true 287 | }, 288 | selectedTimestamp: null, 289 | currentBoard: action.board, 290 | route: { 291 | path: 'BOARD', 292 | board: action.board 293 | } 294 | }; 295 | // Time travel 296 | case 'GOTO_SNAPSHOT': 297 | return { 298 | ...state, 299 | isLiveMode: false, 300 | selectedBoardView: { 301 | ...state.selectedBoardView, 302 | isLoading: true 303 | }, 304 | selectedTimestamp: action.timestamp, 305 | currentBoard: action.board, 306 | route: { 307 | path: 'BOARD', 308 | board: action.board 309 | } 310 | }; 311 | case 'NOT_FOUND': 312 | return { 313 | ...state, 314 | currentBoard: null, 315 | route: { 316 | path: 'NOT_FOUND' 317 | } 318 | }; 319 | default: 320 | return state; 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/main/webapp/js/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, compose, applyMiddleware } from 'redux'; 2 | import createSagaMiddlware from 'redux-saga'; 3 | import reducer from './state'; 4 | import rootSaga from './sagas'; 5 | import router from './router'; 6 | 7 | export default function configureStore() { 8 | const sagaMiddleware = createSagaMiddlware(); 9 | const store = createStore( 10 | reducer, 11 | compose( 12 | applyMiddleware( 13 | router, 14 | sagaMiddleware, 15 | ), 16 | window.devToolsExtension ? window.devToolsExtension() : _ => _ 17 | ) 18 | ); 19 | sagaMiddleware.run(rootSaga); 20 | 21 | if (module.hot) { 22 | module.hot.accept(() => { 23 | store.replaceReducer(require('./state').default); 24 | return true; 25 | }); 26 | } 27 | 28 | return store; 29 | } -------------------------------------------------------------------------------- /src/main/webapp/js/utils/api.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | // transfrom data from APIs 3 | import _ from 'lodash'; 4 | import moment from 'moment'; 5 | import type { Stats, BoardView, Layout } from '../state'; 6 | 7 | // combine board view and board layout 8 | export const combineViewAndLayout = (view: any, layout: Layout, links: Array = [], slo: number = 0.97): BoardView => { 9 | // map from box name to [columnIndex, rowIndex, boxIndex] 10 | const map = new Map(); 11 | layout.columns.forEach((col, i) => 12 | col.rows.forEach((row, j) => 13 | row.boxes.forEach((box, k) => map.set(box.title, [i,j,k])) 14 | ) 15 | ); 16 | const result = JSON.parse(JSON.stringify(layout)); 17 | result.columns.forEach(col => 18 | col.rows.forEach(row => 19 | row.boxes.forEach(box => { 20 | box.status = 'Unknown'; 21 | box.message = 'Unknown'; 22 | }) 23 | ) 24 | ); 25 | view.boxes.map(box => { 26 | const [i, j, k] = map.get(box.title) || []; 27 | const _box = result.columns[i].rows[j].boxes[k]; 28 | // mutate result 29 | result.columns[i].rows[j].boxes[k] = { 30 | ..._box, 31 | status: box.status, 32 | message: box.message, 33 | checks: box.checks 34 | }; 35 | }); 36 | return { 37 | ...result, 38 | title: view.title, 39 | message: view.message, 40 | status: view.status, 41 | links, 42 | slo, 43 | }; 44 | }; 45 | 46 | // aggregate hourly statistics from API by local day/month/year 47 | const aggregateStatsBy = (granularity: string, stats: Stats): Stats => 48 | _(stats) 49 | .toPairs() 50 | .groupBy(pair => moment(parseInt(pair[0])).startOf(granularity).valueOf()) 51 | .mapValues(percents => _.reduce(percents, (acc: number, [_, percent]) => (acc + parseFloat(percent)), 0) / percents.length) 52 | .value(); 53 | 54 | export const aggregateStatsByDay = (stats: Stats): Stats => aggregateStatsBy('day', stats); 55 | export const aggregateStatsByMonth = (stats: Stats): Stats => aggregateStatsBy('month', stats); 56 | export const aggregateStatsByYear = (stats: Stats): Stats => aggregateStatsBy('year', stats); 57 | -------------------------------------------------------------------------------- /src/main/webapp/js/utils/fetcher.js: -------------------------------------------------------------------------------- 1 | const resolve = res => { 2 | const contentType = res.headers.get('content-type') || ''; 3 | if (contentType.includes('json')) 4 | return res.json(); 5 | return res.text(); 6 | }; 7 | 8 | export const fetcher = (url, options) => 9 | fetch(url, options) 10 | .then( 11 | res => Promise.all([resolve(res), Promise.resolve(res)]), 12 | () => Promise.reject(Response.error()) 13 | ) 14 | .then(([body, res]) => { 15 | const response = { 16 | body, 17 | _res: res, 18 | status: res.status 19 | }; 20 | if (res.status < 400) 21 | return Promise.resolve(response); 22 | else 23 | return Promise.reject(response); 24 | }); -------------------------------------------------------------------------------- /src/main/webapp/js/utils/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Status } from '../state'; 3 | 4 | export { fetcher } from './fetcher'; 5 | export * from './api'; 6 | 7 | export const statusToColor = (status: Status): string => { 8 | switch(status) { 9 | case 'SUCCESS': return '#2ebd59'; 10 | case 'WARNING': return '#fdb843'; 11 | case 'ERROR': return '#e7624f'; 12 | default: return '#e0e0e0'; 13 | } 14 | }; -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Black.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-BlackItalic.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Bold.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-BoldItalic.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-Hairline.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Hairline.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-HairlineItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-HairlineItalic.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Italic.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Light.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-LightItalic.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Lato-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Lato-Regular.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/MaterialIcons-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/MaterialIcons-Regular.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Montserrat-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Montserrat-Bold.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Montserrat-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Montserrat-Regular.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Raleway-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Black.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Raleway-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Bold.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Raleway-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-ExtraBold.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Raleway-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-ExtraLight.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Raleway-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Light.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Raleway-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Medium.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Raleway-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Regular.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Raleway-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-SemiBold.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/fonts/Raleway-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/fonts/Raleway-Thin.ttf -------------------------------------------------------------------------------- /src/main/webapp/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/images/favicon.png -------------------------------------------------------------------------------- /src/main/webapp/public/images/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/criteo/slab/14837b7bd273ac99e6555be2956998ac79aac390/src/main/webapp/public/images/logo.sketch -------------------------------------------------------------------------------- /src/main/webapp/public/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= htmlWebpackPlugin.options.title %> 7 | 8 | <% for(var i = 0; i < htmlWebpackPlugin.files.css.length; ++i) { %> 9 | 10 | <% } %> 11 | 12 | 13 |
14 | <% for(var i = 0; i < htmlWebpackPlugin.files.js.length; ++i) { %> 15 | 16 | <% } %> 17 | 18 | -------------------------------------------------------------------------------- /src/main/webapp/style/components/BoardList.styl: -------------------------------------------------------------------------------- 1 | .board-list { 2 | padding: 2em 3 | overflow: auto 4 | .link { 5 | text-decoration: none 6 | font-size: 3em 7 | color: white 8 | boxShadow: 0 0 24px rgba(0,0,0,0.5) 9 | text-align: center 10 | padding: .5em 11 | background: #2980b9 12 | margin-bottom: .5em 13 | display: block 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/webapp/style/components/Box.styl: -------------------------------------------------------------------------------- 1 | @import '../global' 2 | 3 | div.box { 4 | display: flex 5 | flex-direction: column 6 | justify-content: center 7 | 8 | width: 80% 9 | margin-top: auto 10 | margin-bottom: auto 11 | border-radius: 3px 12 | box-shadow: 0 0 24px rgba(0,0,0,.5) 13 | padding: 1vh 1.25vh 14 | box-sizing: border-box 15 | cursor: pointer 16 | transition: .25s 17 | overflow: hidden 18 | 19 | &:hover { 20 | transform: scale(1.1) 21 | box-shadow: 0 0 36px rgba(0,0,0,.75) 22 | } 23 | 24 | div.checks { 25 | display: flex 26 | flex-wrap: wrap 27 | > .check { 28 | border: 1px solid rgba(255,255,255,.5) 29 | margin: 3px 3px 0 0 30 | padding: .25em .5em 31 | border-radius: 2px 32 | color: white 33 | box-sizing: border-box 34 | height: 2vh 35 | font-size: 1.25vh 36 | overflow: hidden 37 | white-space: nowrap 38 | text-overflow: ellipsis 39 | display: flex 40 | align-items: center 41 | } 42 | } 43 | 44 | h3 { 45 | float: none 46 | margin-bottom: 2px 47 | font-size: 1.5vmin 48 | font-weight: 500 49 | color: $white 50 | } 51 | 52 | strong { 53 | float: left 54 | display: block 55 | font-size: 1.4vmin 56 | font-weight: 400 57 | color: $white 58 | } 59 | 60 | #more { 61 | text-align: center 62 | color: white 63 | font-weight: bolder 64 | font-size: 2vh 65 | line-height: .25em 66 | } 67 | } -------------------------------------------------------------------------------- /src/main/webapp/style/components/BoxModal.styl: -------------------------------------------------------------------------------- 1 | .box-modal { 2 | $headerHeight = 40px 3 | display: flex 4 | flex-direction: column 5 | height: 100% 6 | header { 7 | height: $headerHeight 8 | background: #2C3E50 9 | font-size: 1.5em 10 | padding: .25em 11 | span { 12 | color: white 13 | line-height: $headerHeight 14 | padding-left: 1em 15 | } 16 | button { 17 | float: right 18 | background: transparent 19 | padding-right: .5em 20 | color: white 21 | border: 0 22 | outline: 0 23 | font-size: 20px 24 | line-height: $headerHeight 25 | &:hover { 26 | cursor: pointer 27 | color: #DFDFDF 28 | } 29 | } 30 | } 31 | main { 32 | display: flex 33 | flex: 1 34 | flex-direction: column 35 | padding: 1em 2em 36 | min-height: 0 37 | > h3 { 38 | font-weight: 500 39 | margin-bottom: .5em 40 | color: #2c3e50 41 | margin-top: .75em 42 | font-size: 18px 43 | > .fa { 44 | margin-right: .5em 45 | } 46 | } 47 | .message { 48 | color: #5C5C5C 49 | } 50 | .description { 51 | padding: 1em 1.5em 52 | background: #FBFBFB 53 | border: 1px solid #EAEAEA 54 | line-height: 1.2em 55 | a { 56 | text-decoration: none 57 | } 58 | } 59 | } 60 | .checks { 61 | flex: 1 62 | overflow: auto 63 | .check { 64 | display: flex 65 | .status { 66 | display: inline-block 67 | width: 3px 68 | } 69 | .content { 70 | padding: 1em 1.5em 71 | background: #FBFBFB 72 | flex: 1 73 | border-top: 1px solid #EAEAEA 74 | border-right: 1px solid #EAEAEA 75 | color: #5C5C5C 76 | > h4 { 77 | margin-bottom: .25em 78 | } 79 | } 80 | &:last-child { 81 | .content { 82 | border-bottom: 1px solid #EAEAEA 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | .ReactModal__Overlay { 90 | background: rgba(255,255,255,0) 91 | transition: background .5s 92 | } 93 | 94 | // Modal opened 95 | .ReactModal__Body--open { 96 | #app { 97 | transform: scale(.9) 98 | } 99 | } 100 | 101 | .ReactModal__Content--after-open { 102 | animation: scaleUp .5s cubic-bezier(0.165, 0.840, 0.440, 1.000) forwards 103 | } 104 | 105 | // Modal closed 106 | #app { 107 | transform: scale(1) 108 | transition: transform .5s 109 | } 110 | 111 | .ReactModal__Content--before-close { 112 | animation: scaleDown .5s cubic-bezier(0.165, 0.840, 0.440, 1.000) forwards 113 | } 114 | 115 | @keyframes scaleUp { 116 | 0% { 117 | transform:scale(.8) translateY(1000px); 118 | opacity:0; 119 | } 120 | 100% { 121 | transform:scale(1) translateY(0px); 122 | opacity:1; 123 | } 124 | } 125 | 126 | @keyframes scaleDown { 127 | 0% { 128 | transform:scale(1) translateY(0px); 129 | opacity:1; 130 | } 131 | 100% { 132 | transform:scale(.8) translateY(1000px); 133 | opacity:0; 134 | } 135 | } -------------------------------------------------------------------------------- /src/main/webapp/style/components/Calendar.styl: -------------------------------------------------------------------------------- 1 | /* hide back of pane during swap */ 2 | .front, .back { 3 | backface-visibility: hidden 4 | -webkit-backface-visibility: hidden 5 | position: absolute !important 6 | top: 0 7 | left: 0 8 | } 9 | 10 | /* front pane, placed above back */ 11 | .front { 12 | z-index: 2 13 | /* for firefox 31 */ 14 | transform: rotateY(0deg) 15 | } 16 | 17 | /* back, initially hidden pane */ 18 | .back { 19 | transform: rotateY(180deg) 20 | } 21 | 22 | .calendar-chart-wrapper { 23 | position: absolute 24 | left: 10px 25 | bottom: 40px 26 | width: 500px 27 | height: 360px 28 | padding: 40px 29 | background: rgb(64, 69, 73) 30 | perspective: 1000px 31 | z-index: 100 32 | transition: bottom 0.5s 33 | overflow: hidden 34 | 35 | .flipper { 36 | width: 100% 37 | height: 100% 38 | transition: 0.5s 39 | transform-style: preserve-3d 40 | 41 | &.flipped { 42 | transform: rotateY(180deg) 43 | } 44 | } 45 | 46 | &.closed { 47 | bottom: -550px 48 | } 49 | 50 | .close-button { 51 | position: absolute 52 | right: 10px 53 | top: 10px 54 | background: transparent 55 | border: none 56 | outline: 0 57 | font-size: 20px 58 | color: #ffffff 59 | 60 | &:hover { 61 | cursor: pointer 62 | color: #DFDFDF 63 | } 64 | } 65 | 66 | .flip-view-button { 67 | position: absolute 68 | right: 10px 69 | bottom: 10px 70 | background transparent 71 | border: none 72 | outline: 0 73 | font-size: 18px 74 | color: #ffffff 75 | 76 | &.chart:before { 77 | font-family: 'FontAwesome' 78 | content: '\f080' 79 | } 80 | 81 | &.calendar:before { 82 | font-family: 'FontAwesome' 83 | content: '\f073' 84 | } 85 | 86 | &:hover { 87 | cursor: pointer 88 | color: #DFDFDF 89 | } 90 | } 91 | 92 | .rdt.rdtStatic { 93 | position: relative 94 | width: 100% 95 | 96 | .rdtPicker { 97 | background: rgb(64, 69, 73) 98 | border: none 99 | width: 100% 100 | padding: 0 101 | color: #ffffff 102 | 103 | th { 104 | width: 40px 105 | height: 40px 106 | border-bottom: 0 107 | text-align: center 108 | vertical-align: middle 109 | font-weight: 400 110 | 111 | &:hover { 112 | cursor: default 113 | } 114 | 115 | &.rdtSwitch, &.rdtPrev, &.rdtNext { 116 | &:hover { 117 | background: rgba(255, 255, 255, .3) 118 | cursor: pointer 119 | } 120 | } 121 | 122 | } 123 | 124 | td { 125 | position: relative 126 | text-align: center 127 | vertical-align: middle 128 | padding-bottom: 5px 129 | 130 | span { 131 | font-weight: 500 132 | pointer-events: none 133 | } 134 | 135 | .date-label { 136 | display: inline 137 | } 138 | 139 | .percent-label { 140 | display: none 141 | font-size: 90% 142 | } 143 | 144 | &:not(.rdtOld):not(.rdtNew):not(.rdtDisabled) { 145 | &.good:after, &.bad:after { 146 | position: relative 147 | left: 5px 148 | font-size: 12px 149 | font-family: 'FontAwesome' 150 | vertical-align: middle 151 | } 152 | &.good:after { 153 | color: $green 154 | content: '\f058' 155 | } 156 | &.bad:after { 157 | content: '\f057' 158 | color: $red 159 | } 160 | 161 | &:hover { 162 | &:after { display: none } 163 | .date-label { display: none } 164 | .percent-label { display: inline } 165 | } 166 | } 167 | 168 | &.rdtToday:before { 169 | border-bottom: 7px solid #ffffff 170 | } 171 | 172 | &.rdtDay { 173 | height: 40px 174 | width: 60px 175 | } 176 | 177 | &.rdtMonth, 178 | &.rdtYear { 179 | height: 93px 180 | width: 80px 181 | } 182 | 183 | &.rdtOld, 184 | &.rdtNew { 185 | color: #666666 186 | 187 | span { 188 | cursor: not-allowed 189 | } 190 | } 191 | 192 | &.rdtYear:hover, 193 | &.rdtMonth:hover, 194 | &.rdtDay:hover, 195 | &.rdtHour:hover, 196 | &.rdtMinute:hover, 197 | &.rdtSecond:hover, 198 | &.rdtTimeToggle:hover, 199 | &.rdtActive, 200 | &.rdtActive:hover { 201 | background: rgba(255, 255, 255, .3) 202 | } 203 | 204 | &.rdtDisabled:hover, &.rdtOld:hover, &.rdtNew:hover { 205 | background: rgba(255, 255, 255, 0) 206 | } 207 | } 208 | } 209 | } 210 | } 211 | 212 | .recharts-wrapper .recharts-surface { 213 | 214 | .recharts-reference-line { 215 | .recharts-reference-line-line { 216 | stroke: $red 217 | stroke-dasharray 5 218 | } 219 | .recharts-text.recharts-label { 220 | font-size: 12px 221 | fill: $red 222 | stroke: none 223 | } 224 | } 225 | 226 | .recharts-brush { 227 | 228 | rect { 229 | stroke: #cccccc 230 | fill: #2b3238 231 | } 232 | 233 | .recharts-brush-texts { 234 | font-size: 12px 235 | stroke: #ffffff 236 | } 237 | 238 | .recharts-brush-slide { 239 | fill: #ffffff 240 | } 241 | 242 | .recharts-brush-traveller rect { 243 | fill: #ccccc 244 | } 245 | } 246 | 247 | .recharts-bar .recharts-bar-rectangles .recharts-bar-rectangle .recharts-rectangle { 248 | &.good { 249 | stroke: none 250 | fill: $green 251 | } 252 | 253 | &.bad { 254 | stroke: none 255 | fill: $red 256 | } 257 | 258 | .rectangle-tooltip-cursor path { 259 | fill: #ffffff 260 | } 261 | } 262 | 263 | .recharts-cartesian-axis { 264 | font-size: 12px 265 | 266 | .recharts-label { 267 | fill: #ffffff 268 | stroke: #ffffff 269 | } 270 | 271 | .recharts-cartesian-axis-line { 272 | stroke: #ffffff 273 | } 274 | 275 | .recharts-cartesian-axis-tick { 276 | 277 | .recharts-cartesian-axis-tick-line { 278 | stroke: #ffffff 279 | } 280 | .recharts-text.recharts-cartesian-axis-tick-value { 281 | stroke: none 282 | fill: #ffffff 283 | } 284 | } 285 | } 286 | } 287 | 288 | .custom-chart-tooltip { 289 | background: rgba(0, 0, 0, .5) 290 | padding: 10px 291 | border-radius: 4px 292 | 293 | .label-value { 294 | font-size: 12px 295 | 296 | .label { 297 | font-weight: bold 298 | } 299 | 300 | .value { 301 | 302 | } 303 | } 304 | } -------------------------------------------------------------------------------- /src/main/webapp/style/components/ErrorPage.styl: -------------------------------------------------------------------------------- 1 | .error-page { 2 | display: flex 3 | flex-direction: column 4 | justify-content: center 5 | align-items: center 6 | font-size: 3em 7 | color: white 8 | h1 { 9 | color: #ff9800 10 | } 11 | a { 12 | color: #2196F3 13 | text-decoration: none 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/webapp/style/components/Timeline.styl: -------------------------------------------------------------------------------- 1 | .timeline { 2 | > #controller { 3 | padding: .25em .5em 4 | position: relative 5 | color: rgba(255,255,255,.8) 6 | background: rgba(128,128,128,.25) 7 | display: flex 8 | font-size: 18px 9 | height: 20px 10 | > .DayPicker { 11 | color: black 12 | position: absolute 13 | bottom: 2em 14 | background: white 15 | } 16 | > .board { 17 | margin-left: auto 18 | } 19 | > span { 20 | display: flex 21 | align-items: baseline 22 | } 23 | } 24 | > .info { 25 | color: white 26 | padding: 1em 27 | text-align: center 28 | } 29 | > #container { 30 | height: 75px 31 | position: relative 32 | > #message { 33 | position: absolute 34 | z-index: 1 35 | width: 100% 36 | height: 100% 37 | > .no-data { 38 | color: white 39 | font-size: 2em 40 | background: rgba(64, 70, 74, 0.72) 41 | width: 100% 42 | height: 100% 43 | display: flex 44 | justify-content: center 45 | align-items: center 46 | } 47 | } 48 | } 49 | .vis-panel.vis-center { 50 | overflow: visible 51 | } 52 | .vis-item { 53 | border-color: transparent 54 | color: white 55 | margin-top: 10px 56 | &:hover { 57 | cursor: pointer 58 | } 59 | &.vis-dot { 60 | top: 0px !important 61 | left: 6px !important 62 | border-radius: 6px 63 | border-width: 6px 64 | transition: transform .5s 65 | &.vis-selected { 66 | transform: scale(1.5) 67 | } 68 | } 69 | &.vis-selected { 70 | background: #2196F3 71 | border-color: transparent 72 | &.vis-point { 73 | background: transparent 74 | } 75 | } 76 | } 77 | .vis-text { 78 | &.vis-minor, &.vis-major { 79 | color: #d8d8d8 !important 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /src/main/webapp/style/fonts.styl: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Material Icons' 3 | font-style: normal 4 | font-weight: 400 5 | src: url('/public/fonts/MaterialIcons-Regular.ttf') format('truetype') 6 | } 7 | 8 | @font-face { 9 | font-family: 'Montserrat'; 10 | font-style: normal 11 | font-weight: 400 12 | src: url('/public/fonts/Montserrat-Regular.ttf') format('truetype') 13 | } 14 | 15 | @font-face { 16 | font-family: 'Montserrat'; 17 | font-style: normal 18 | font-weight: 600 19 | src: url('/public/fonts/Montserrat-Bold.ttf') format('truetype') 20 | } 21 | 22 | @font-face { 23 | font-family: 'Lato'; 24 | font-style: normal 25 | font-weight: 200 26 | src: url('/public/fonts/Lato-Hairline.ttf') format('truetype') 27 | } 28 | 29 | @font-face { 30 | font-family: 'Lato'; 31 | font-style: italic 32 | font-weight: 200 33 | src: url('/public/fonts/Lato-HairlineItalic.ttf') format('truetype') 34 | } 35 | 36 | @font-face { 37 | font-family: 'Lato'; 38 | font-style: normal 39 | font-weight: 300 40 | src: url('/public/fonts/Lato-Light.ttf') format('truetype') 41 | } 42 | 43 | @font-face { 44 | font-family: 'Lato'; 45 | font-style: italic 46 | font-weight: 300 47 | src: url('/public/fonts/Lato-LightItalic.ttf') format('truetype') 48 | } 49 | 50 | @font-face { 51 | font-family: 'Lato'; 52 | font-style: normal 53 | font-weight: 400 54 | src: url('/public/fonts/Lato-Regular.ttf') format('truetype') 55 | } 56 | 57 | @font-face { 58 | font-family: 'Lato'; 59 | font-style: italic 60 | font-weight: 400 61 | src: url('/public/fonts/Lato-Italic.ttf') format('truetype') 62 | } 63 | 64 | @font-face { 65 | font-family: 'Lato'; 66 | font-style: normal 67 | font-weight: 500 68 | src: url('/public/fonts/Lato-Bold.ttf') format('truetype') 69 | } 70 | 71 | @font-face { 72 | font-family: 'Lato'; 73 | font-style: italic 74 | font-weight: 500 75 | src: url('/public/fonts/Lato-BoldItalic.ttf') format('truetype') 76 | } 77 | 78 | @font-face { 79 | font-family: 'Lato'; 80 | font-style: normal 81 | font-weight: 600 82 | src: url('/public/fonts/Lato-Black.ttf') format('truetype') 83 | } 84 | 85 | @font-face { 86 | font-family: 'Lato'; 87 | font-style: italic 88 | font-weight: 600 89 | src: url('/public/fonts/Lato-BlackItalic.ttf') format('truetype') 90 | } 91 | -------------------------------------------------------------------------------- /src/main/webapp/style/global.styl: -------------------------------------------------------------------------------- 1 | $green = #2ebd59 2 | $red = #e7624f 3 | $orange = #fdb843 4 | $black = #2b3238 5 | $white = #ffffff 6 | $gray = #e0e0e0 7 | $darkgray = #bababa 8 | $blue = #2196f3 9 | $lightOrange = hsl(39, 65%, 68%) 10 | -------------------------------------------------------------------------------- /src/main/webapp/style/index.styl: -------------------------------------------------------------------------------- 1 | @import 'reset' 2 | @import 'fonts' 3 | @import 'utils' 4 | @import url('https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css') 5 | @import '~vis/dist/vis.css' 6 | @import '~react-datetime/css/react-datetime.css' 7 | 8 | @import 'lib/Button' 9 | @import 'components/Timeline' 10 | @import 'components/BoardList' 11 | @import 'components/BoxModal' 12 | @import 'components/Box' 13 | @import 'components/Calendar' 14 | @import 'components/ErrorPage' 15 | @import './global' 16 | 17 | html, body { 18 | height: 100% 19 | } 20 | body { 21 | padding: 0 22 | margin: 0 23 | font-family: 'Lato', 'Helvetica', 'Arial', 'Sans' 24 | font-size: 14px 25 | overflow: hidden 26 | background: #9C9C9C 27 | input, select, textarea { 28 | font-family: 'Lato', 'Helvetica', 'Arial', 'Sans' 29 | font-size: 14px 30 | } 31 | 32 | #app { 33 | height: 100% 34 | background: $black 35 | & > div { 36 | height: 100% 37 | } 38 | & .graph { 39 | height: 100% 40 | display: flex 41 | flex-direction: column 42 | 43 | &.lost-connection { 44 | filter: grayscale(100%) 45 | -webkit-filter: grayscale(100%) 46 | } 47 | 48 | header { 49 | height: 7.5vh 50 | z-index: 10 51 | h1 { 52 | flex: 1 53 | color: $white 54 | font-size: 4vh 55 | text-align: center 56 | font-weight: 400 57 | margin-top: 1vh 58 | } 59 | 60 | p { 61 | flex: 1 62 | color: #fff 63 | text-align: center 64 | font-weight: 400 65 | font-size: 2vh 66 | } 67 | } 68 | 69 | main { 70 | flex: 1 71 | display: flex 72 | 73 | svg { 74 | position: absolute 75 | left: 0 76 | top: 0 77 | width: 100% 78 | height: 100% 79 | pointer-events: none 80 | 81 | .fgLine { 82 | stroke-width: 3 83 | fill: none 84 | stroke: $gray 85 | 86 | &.hoveredPath { 87 | stroke: $lightOrange 88 | } 89 | } 90 | 91 | .bgLine { 92 | stroke-width: 6 93 | fill: none 94 | stroke: rgba(255,255,255,.15) 95 | } 96 | } 97 | 98 | section.column { 99 | display: flex 100 | flex-direction: column 101 | justify-content: center 102 | position: relative 103 | border-left: 1px dotted rgba(255,255,255,.35) 104 | 105 | &:first-of-type { 106 | border: none 107 | } 108 | 109 | section.row { 110 | display: flex 111 | flex-direction: column 112 | justify-content: center 113 | align-items: center 114 | position: relative 115 | padding-top: 1.5vh 116 | flex: 1 117 | border-top: 1px dotted rgba(255,255,255,.35) 118 | 119 | &:first-of-type { 120 | border: none 121 | } 122 | 123 | h2 { 124 | font-size: 1.5vh 125 | text-align: center 126 | font-weight: 300 127 | color: $gray 128 | position: absolute 129 | top: .5vh 130 | left: 0 131 | right: 0 132 | } 133 | } 134 | } 135 | } 136 | } 137 | } 138 | } 139 | 140 | .background { 141 | background: $darkgray 142 | &.SUCCESS { 143 | background: $green 144 | } 145 | &.ERROR { 146 | background: $red 147 | } 148 | &.WARNING { 149 | background: $orange 150 | } 151 | &.UNKNOWN { 152 | background: $darkgray 153 | } 154 | } 155 | 156 | .color { 157 | &.SUCCESS { 158 | color: $green 159 | } 160 | &.ERROR { 161 | color: $red 162 | } 163 | &.WARNING { 164 | color: $orange 165 | } 166 | &.UNKNOWN { 167 | color: $darkgray 168 | } 169 | } 170 | 171 | .hidden { 172 | visibility: hidden 173 | } 174 | -------------------------------------------------------------------------------- /src/main/webapp/style/lib/Button.styl: -------------------------------------------------------------------------------- 1 | .button { 2 | background: transparent 3 | color: white 4 | border: 0 5 | margin: 0 .1em 6 | cursor: pointer 7 | font-size: 12px 8 | border-top: 2px solid transparent 9 | border-bottom: 2px solid transparent 10 | &:hover { 11 | color: '#A9A9A9' 12 | border-bottom: 2px solid #4CAF50 13 | background: rgba(255, 255, 255, .1) 14 | } 15 | &:active { 16 | color: white 17 | } 18 | &:focus { 19 | outline: 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/webapp/style/reset.styl: -------------------------------------------------------------------------------- 1 | /* 2 | html5doctor.com Reset Stylesheet 3 | v1.6.1 4 | Last Updated: 2010-09-17 5 | Author: Richard Clark - http://richclarkdesign.com 6 | Twitter: @rich_clark 7 | Stylus-ized by 8 | dale tan 9 | http://www.whatthedale.com 10 | @HellaTan 11 | */ 12 | 13 | html, body, div, span, object, iframe, 14 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 15 | abbr, address, cite, code, 16 | del, dfn, em, img, ins, kbd, q, samp, 17 | small, strong, sub, sup, var, 18 | b, i, 19 | dl, dt, dd, ol, ul, li, 20 | fieldset, form, label, legend, 21 | table, caption, tbody, tfoot, thead, tr, th, td, 22 | article, aside, canvas, details, figcaption, figure, 23 | footer, header, hgroup, menu, nav, section, summary, 24 | time, mark, audio, video 25 | background transparent 26 | border 0 27 | font-size 100% 28 | margin 0 29 | outline 0 30 | padding 0 31 | vertical-align baseline 32 | 33 | body 34 | line-height:1; 35 | 36 | article, aside, details, figcaption, figure, 37 | footer, header, hgroup, menu, nav, section 38 | display block 39 | 40 | nav ul 41 | list-style none 42 | 43 | blockquote, q 44 | quotes none 45 | 46 | blockquote:before, blockquote:after, 47 | q:before, q:after 48 | content '' 49 | content none 50 | 51 | a 52 | background transparent 53 | font-size 100% 54 | margin 0 55 | padding 0 56 | vertical-align baseline 57 | 58 | /* change colours to suit your needs */ 59 | ins 60 | background-color #ff9 61 | color #000 62 | text-decoration none 63 | 64 | /* change colours to suit your needs */ 65 | mark 66 | background-color #ff9 67 | color #000 68 | font-style italic 69 | font-weight bold 70 | 71 | del 72 | text-decoration line-through 73 | 74 | abbr[title], dfn[title] 75 | border-bottom 1px dotted 76 | cursor help 77 | 78 | table 79 | border-collapse collapse 80 | border-spacing 0 81 | 82 | /* change border colour to suit your needs */ 83 | hr 84 | border 0 85 | border-top 1px solid #ccc 86 | display block 87 | height 1px 88 | margin 1em 0 89 | padding 0 90 | 91 | input, select 92 | vertical-align middle 93 | -------------------------------------------------------------------------------- /src/main/webapp/style/utils.styl: -------------------------------------------------------------------------------- 1 | material-icon(name) 2 | &::before { 3 | content: name 4 | -webkit-font-feature-settings: 'liga' 5 | font-family: 'Material Icons' 6 | font-size: 24px 7 | display: inline-block 8 | text-transform: none 9 | {block} 10 | } 11 | 12 | second-material-icon(name) 13 | &::after { 14 | content: name 15 | -webkit-font-feature-settings: 'liga' 16 | font-family: 'Material Icons' 17 | font-size: 24px 18 | display: inline-block 19 | text-transform: none 20 | {block} 21 | } 22 | -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/app/StateServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.app 2 | 3 | import com.criteo.slab.core.{BoardView, Status} 4 | import org.scalatest.{FlatSpec, Matchers} 5 | 6 | class StateServiceSpec extends FlatSpec with Matchers { 7 | "getStatsByHour" should "aggregate stats by hour" in { 8 | val res = StateService.getStatsByHour( 9 | Seq( 10 | 0L -> BoardView("board0", Status.Warning, "", Seq.empty), 11 | 100L -> BoardView("board1", Status.Success, "", Seq.empty), 12 | 60 * 60 * 1000L -> BoardView("board2", Status.Error, "", Seq.empty), 13 | 200L -> BoardView("board3", Status.Unknown, "", Seq.empty) 14 | ) 15 | ) 16 | res should contain theSameElementsAs Seq( 17 | 0 -> Stats( 18 | 1, 19 | 1, 20 | 0, 21 | 1, 22 | 3 23 | ), 24 | 3600000L -> Stats( 25 | 0, 26 | 0, 27 | 1, 28 | 0, 29 | 1 30 | ) 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/core/BoardSpec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import org.scalatest.mockito.MockitoSugar 4 | import org.scalatest.{FlatSpec, Matchers} 5 | import org.mockito.Mockito._ 6 | import shapeless.HNil 7 | 8 | class BoardSpec extends FlatSpec with Matchers with MockitoSugar { 9 | val box1 = mock[Box[_]] 10 | when(box1.title) thenReturn "box 1" 11 | val box2 = mock[Box[_]] 12 | when(box2.title) thenReturn "box 2" 13 | 14 | "constructor" should "require that boxes and layout are correctly defined" in { 15 | intercept[IllegalArgumentException] { 16 | Board( 17 | "a broken board", 18 | box1 :: HNil, 19 | (views, _) => views.values.head, 20 | Layout( 21 | Column( 22 | 100, 23 | Row("row", 10, box2 :: Nil) 24 | ) 25 | ) 26 | ) 27 | } 28 | intercept[IllegalArgumentException] { 29 | Board( 30 | "a broken board", 31 | box1 :: box2 :: HNil, 32 | (views, _) => views.values.head, 33 | Layout( 34 | Column( 35 | 100, 36 | Row("row", 10, List.empty) 37 | ) 38 | ) 39 | ) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/core/ExecutorSpec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import java.time.Instant 4 | 5 | import com.criteo.slab.helper.FutureTests 6 | import org.mockito.Mockito._ 7 | import org.scalatest.{BeforeAndAfterEach, FlatSpec, Matchers} 8 | 9 | class ExecutorSpec extends FlatSpec with Matchers with FutureTests with BeforeAndAfterEach { 10 | override def beforeEach = { 11 | reset(spiedCheck1) 12 | reset(spiedCheck2) 13 | reset(spiedCheck3) 14 | } 15 | 16 | val executor = Executor(board) 17 | "apply" should "fetch current check values if no context is given" in { 18 | val boardView = BoardView( 19 | "board", 20 | Status.Unknown, 21 | "board message", 22 | List( 23 | BoxView( 24 | "box 1", 25 | Status.Success, 26 | "box 1 message", 27 | List( 28 | CheckView( 29 | "check 1", 30 | Status.Success, 31 | "check 1 message: new value" 32 | ), 33 | CheckView( 34 | "check 2", 35 | Status.Success, 36 | "check 2 message: new value" 37 | ) 38 | ) 39 | ), 40 | BoxView( 41 | "box 2", 42 | Status.Success, 43 | "box 2 message", 44 | List( 45 | CheckView( 46 | "check 3", 47 | Status.Success, 48 | "check 3 message: 3" 49 | ) 50 | ) 51 | ) 52 | ) 53 | ) 54 | val f = executor.apply(None) 55 | whenReady(f) { res => 56 | verify(spiedCheck1, times(1)).apply 57 | verify(spiedCheck2, times(1)).apply 58 | verify(spiedCheck3, times(1)).apply 59 | res shouldEqual boardView 60 | } 61 | } 62 | 63 | "apply" should "fetch past check values if a context is given" in { 64 | val boardView = BoardView( 65 | "board", 66 | Status.Unknown, 67 | "board message", 68 | List( 69 | BoxView( 70 | "box 1", 71 | Status.Success, 72 | "box 1 message", 73 | List( 74 | CheckView( 75 | "check 1", 76 | Status.Success, 77 | "check 1 message: 100" 78 | ), 79 | CheckView( 80 | "check 2", 81 | Status.Success, 82 | "check 2 message: 100" 83 | ) 84 | ) 85 | ), 86 | BoxView( 87 | "box 2", 88 | Status.Success, 89 | "box 2 message", 90 | List( 91 | CheckView( 92 | "check 3", 93 | Status.Success, 94 | "check 3 message: 100" 95 | ) 96 | ) 97 | ) 98 | ) 99 | ) 100 | val f = executor.apply(Some(Context(Instant.now))) 101 | whenReady(f) { res => 102 | verify(spiedCheck1, never).apply 103 | verify(spiedCheck2, never).apply 104 | verify(spiedCheck3, never).apply 105 | res shouldEqual boardView 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/core/LayoutSpec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import com.criteo.slab.utils.Jsonable._ 4 | import org.scalatest.{FlatSpec, Matchers} 5 | 6 | class LayoutSpec extends FlatSpec with Matchers { 7 | 8 | "Layout" should "be serializable to JSON" in { 9 | val layout = Layout( 10 | Column(50, Row("A", 25, List( 11 | Box[String]("box1", check1 :: Nil, (vs, _) => vs.head._2.view) 12 | ))) 13 | ) 14 | layout.toJSON shouldEqual """{"columns":[{"percentage":50.0,"rows":[{"title":"A","percentage":25.0,"boxes":[{"title":"box1","labelLimit":64}]}]}]}""" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/core/ReadableViewSpec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.core 2 | 3 | import com.criteo.slab.utils.Jsonable._ 4 | import org.scalatest.{FlatSpec, Matchers} 5 | 6 | class ReadableViewSpec extends FlatSpec with Matchers { 7 | 8 | "toJSON" should "work" in { 9 | val boardView: ReadableView = BoardView( 10 | "board1", 11 | Status.Success, 12 | "msg", 13 | List( 14 | BoxView("box1", Status.Success, "msg", List( 15 | CheckView("check1", Status.Warning, "msg"), 16 | CheckView("check2", Status.Error, "msg", Some("label")) 17 | )) 18 | ) 19 | ) 20 | boardView.toJSON shouldEqual 21 | """ 22 | |{ 23 | |"title":"board1", 24 | |"status":"SUCCESS", 25 | |"message":"msg", 26 | |"boxes":[{ 27 | |"title":"box1", 28 | |"status":"SUCCESS", 29 | |"message":"msg", 30 | |"checks":[ 31 | |{"title":"check1","status":"WARNING","message":"msg"}, 32 | |{"title":"check2","status":"ERROR","message":"msg","label":"label"} 33 | |]} 34 | |]}""".stripMargin.replaceAll("\n", "") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/core/package.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab 2 | 3 | import java.time.Instant 4 | 5 | import com.criteo.slab.lib.Values.Slo 6 | import shapeless.HNil 7 | 8 | import scala.concurrent.Future 9 | import scala.util.Try 10 | import org.mockito.Mockito._ 11 | 12 | package object core { 13 | val check1 = Check[String]( 14 | "c.1", 15 | "check 1", 16 | () => Future.successful("new value"), 17 | (value, _) => View(Status.Success, s"check 1 message: $value") 18 | ) 19 | val check2 = Check[String]( 20 | "c.2", 21 | "check 2", 22 | () => Future.successful("new value"), 23 | (value, _) => View(Status.Success, s"check 2 message: $value") 24 | ) 25 | val check3 = Check[Int]( 26 | "c.3", 27 | "check 3", 28 | () => Future.successful(3), 29 | (value, _) => View(Status.Success, s"check 3 message: $value") 30 | ) 31 | val spiedCheck1 = spy(check1) 32 | val spiedCheck2 = spy(check2) 33 | val spiedCheck3 = spy(check3) 34 | 35 | val box1 = Box[String]( 36 | "box 1", 37 | List(spiedCheck1, spiedCheck2), 38 | (views, _) => views.get(spiedCheck2).map(_.view.copy(message = "box 1 message")).getOrElse(View(Status.Unknown, "unknown")) 39 | ) 40 | 41 | val box2 = Box[Int]( 42 | "box 2", 43 | List(spiedCheck3), 44 | (views, _) => views.values.head.view.copy(message = "box 2 message") 45 | ) 46 | 47 | val board = Board( 48 | "board", 49 | box1 :: box2 :: HNil, 50 | (_, _) => View(Status.Unknown, "board message"), 51 | Layout( 52 | Column( 53 | 50, 54 | Row("r1", 100, List(box1)) 55 | ), 56 | Column( 57 | 50, 58 | Row("r2", 100, List(box2)) 59 | ) 60 | ) 61 | ) 62 | 63 | 64 | implicit def codecInt = new Codec[Int, String] { 65 | 66 | override def encode(v: Int): String = v.toString 67 | 68 | override def decode(v: String): Try[Int] = Try(v.toInt) 69 | } 70 | 71 | implicit def codecSlo = new Codec[Slo, String] { 72 | 73 | override def encode(v: Slo): String = v.underlying.toString 74 | 75 | override def decode(v: String): Try[Slo] = Try(Slo(v.toDouble)) 76 | } 77 | 78 | implicit def codecString = new Codec[String, String] { 79 | override def encode(v: String): String = v 80 | 81 | override def decode(v: String): Try[String] = Try(v) 82 | } 83 | 84 | implicit def store = new Store[String] { 85 | override def upload[T](id: String, context: Context, v: T)(implicit ev: Codec[T, String]): Future[Unit] = Future.successful(()) 86 | 87 | override def fetch[T](id: String, context: Context)(implicit ev: Codec[T, String]): Future[Option[T]] = Future.successful(ev.decode("100").toOption) 88 | 89 | override def fetchHistory[T](id: String, from: Instant, until: Instant)(implicit ev: Codec[T, String]): Future[Seq[(Long, T)]] = Future.successful(List.empty) 90 | 91 | override def uploadSlo(id: String, context: Context, v: Slo)(implicit codec: Codec[Slo, String]): Future[Unit] = Future.successful(()) 92 | 93 | def fetchSloHistory(id: String, from: Instant, until: Instant)(implicit codec: Codec[Slo, String]): Future[Seq[(Long, Slo)]] = Future.successful(List.empty) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/helper/FutureTests.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.helper 2 | 3 | import org.scalatest.concurrent.{Futures, ScalaFutures} 4 | 5 | trait FutureTests extends Futures with ScalaFutures { 6 | implicit val ec = concurrent.ExecutionContext.Implicits.global 7 | } 8 | 9 | object FutureTests extends FutureTests -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/lib/GraphiteMetricSpec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.lib 2 | 3 | import com.criteo.slab.lib.graphite.{DataPoint, GraphiteMetric} 4 | import com.criteo.slab.utils.Jsonable 5 | import org.json4s.DefaultFormats 6 | import org.scalatest.{FlatSpec, Matchers} 7 | 8 | import scala.util.Success 9 | 10 | class GraphiteMetricSpec extends FlatSpec with Matchers { 11 | "JSON serializer" should "be able to read json" in { 12 | val json = """[{"target":"metric.one", "datapoints":[[1.0, 2000], [null, 2060]]}]""".stripMargin.replace("\n", "") 13 | val formats = DefaultFormats ++ Jsonable[GraphiteMetric].serializers 14 | val r = Jsonable.parse[List[GraphiteMetric]](json, formats) 15 | r shouldEqual Success(List(GraphiteMetric("metric.one", List( 16 | DataPoint(Some(1.0), 2000), 17 | DataPoint(None, 2060) 18 | )))) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/lib/GraphiteStoreSpec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.lib 2 | 3 | import java.net._ 4 | import java.time.Duration 5 | import java.util.concurrent._ 6 | 7 | import com.criteo.slab.core.Context 8 | import com.criteo.slab.helper.FutureTests 9 | import com.criteo.slab.lib.Values.Latency 10 | import com.criteo.slab.lib.graphite.{DataPoint, GraphiteMetric, GraphiteStore} 11 | import org.scalatest.{FlatSpec, Matchers} 12 | 13 | import scala.io._ 14 | import com.criteo.slab.lib.graphite.GraphiteCodecs._ 15 | 16 | class GraphiteStoreSpec extends FlatSpec with Matchers with FutureTests { 17 | val port = 5000 18 | val server = new ServerSocket(port) 19 | val store = new GraphiteStore("localhost", port, "http://localhost", Duration.ofSeconds(60)) 20 | 21 | val pool = Executors.newFixedThreadPool(1) 22 | 23 | "value store" should "be able to send metrics to Graphite server" in { 24 | val f = pool.submit(new Echo(server)) 25 | store.upload("metrics", Context.now, Latency(200)) 26 | f.get should startWith("metrics.latency 200") 27 | } 28 | 29 | "upload" should "returns the exception when failed" in { 30 | whenReady( 31 | store.upload("metrics", Context.now, Latency(200)).failed 32 | ) { res => 33 | res shouldBe a[java.net.ConnectException] 34 | } 35 | } 36 | 37 | "transform metrics" should "turn Graphite metrics into pairs" in { 38 | val metrics = List( 39 | GraphiteMetric( 40 | "metric.one", 41 | List( 42 | DataPoint(None, 2000), 43 | DataPoint(Some(1), 2060) 44 | ) 45 | ), 46 | GraphiteMetric( 47 | "metric.two", 48 | List( 49 | DataPoint(None, 2000), 50 | DataPoint(Some(2), 2060) 51 | ) 52 | ) 53 | ) 54 | GraphiteStore.transformMetrics("metric", metrics) shouldEqual Map("one" -> 1.0, "two" -> 2.0) 55 | } 56 | 57 | "transform metrics" should "return empty if some metrics are missing" in { 58 | val metrics = List( 59 | GraphiteMetric( 60 | "metric.one", 61 | List(DataPoint(Some(1), 2000)) 62 | ), 63 | GraphiteMetric( 64 | "metric.two", 65 | List(DataPoint(None, 2000)) 66 | ) 67 | ) 68 | GraphiteStore.transformMetrics("metric", metrics) shouldEqual Map.empty 69 | } 70 | 71 | "group metrics" should "group metrics" in { 72 | val metrics = List( 73 | GraphiteMetric( 74 | "metric.one", 75 | List(DataPoint(Some(1), 1000), DataPoint(Some(2), 2000)) 76 | ), 77 | GraphiteMetric( 78 | "metric.two", 79 | List(DataPoint(Some(3), 1000), DataPoint(Some(4), 2000)) 80 | ) 81 | ) 82 | GraphiteStore.groupMetrics("metric", metrics) shouldEqual Map( 83 | 1000000 -> Map("one" -> 1, "two" -> 3), 84 | 2000000 -> Map("one" -> 2, "two" -> 4) 85 | ) 86 | } 87 | 88 | class Echo(server: ServerSocket) extends Callable[String] { 89 | def call() = { 90 | val s = server.accept 91 | val lines = new BufferedSource(s.getInputStream).getLines 92 | val result = lines.mkString 93 | s.close 94 | server.close 95 | result 96 | } 97 | } 98 | 99 | } -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/lib/InMemoryStoreSpec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.lib 2 | 3 | import java.time.Instant 4 | import java.time.temporal.ChronoUnit 5 | 6 | import org.scalatest.{FlatSpec, Matchers} 7 | import org.slf4j.LoggerFactory 8 | 9 | import scala.collection.concurrent.TrieMap 10 | 11 | class InMemoryStoreSpec extends FlatSpec with Matchers { 12 | val logger = LoggerFactory.getLogger(this.getClass) 13 | "Cleaner" should "remove expired entries" in { 14 | val cache = TrieMap.empty[(String, Long), Any] 15 | val cleaner = InMemoryStore.createCleaner(cache, 1, logger) 16 | 17 | cache += ("a", Instant.now.minus(2, ChronoUnit.DAYS).toEpochMilli) -> 1 18 | cache += ("b", Instant.now.minus(1, ChronoUnit.DAYS).toEpochMilli) -> 2 19 | cache += ("c", Instant.now.toEpochMilli) -> 3 20 | cleaner.run() 21 | cache.size shouldBe 1 22 | cache.head._1._1 shouldBe "c" 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/test/scala/com/criteo/slab/utils/packageSpec.scala: -------------------------------------------------------------------------------- 1 | package com.criteo.slab.utils 2 | 3 | import org.scalatest.{FlatSpec, Matchers} 4 | 5 | import scala.util.{Failure, Success} 6 | 7 | class packageSpec extends FlatSpec with Matchers { 8 | 9 | "collectTries" should "collect all successful values" in { 10 | collectTries( 11 | List(Success(1), Success(2)) 12 | ) shouldEqual Success(List(1, 2)) 13 | } 14 | "collectTries" should "stops at first failure" in { 15 | val e = new Exception("e") 16 | collectTries( 17 | List(Success(1), Failure(e)) 18 | ) shouldEqual Failure(e) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/test/webapp/sagas.spec.js: -------------------------------------------------------------------------------- 1 | import { call, put } from 'redux-saga/effects'; 2 | import * as api from 'src/api'; 3 | import { fetchBoard, fetchBoards } from 'src/sagas'; 4 | describe('saga spec', () => { 5 | describe('fetchBoard', () => { 6 | it('calls API', () => { 7 | const iter = fetchBoard({ board: 'boardname' }, view => view); 8 | expect(iter.next().value).to.deep.equal( 9 | call(fetchBoards) 10 | ); 11 | expect(iter.next([{ layout: {}, links: [] }]).value).to.deep.equal( 12 | call(api.fetchBoard, 'boardname'), 13 | ); 14 | const payload = [1,2,3]; 15 | const result = iter.next(payload).value; 16 | expect(result).to.deep.equal(put({ 17 | type: 'FETCH_BOARD_SUCCESS', 18 | payload 19 | })); 20 | }); 21 | 22 | it('put failure action if any exception is raised', () => { 23 | const iter = fetchBoard({ board: 'boardname' }); 24 | iter.next(); 25 | const error = new Error('uh'); 26 | const next = iter.throw(error); 27 | expect(next.value).to.deep.equal(put({ type: 'FETCH_BOARD_FAILURE', payload: error })); 28 | }); 29 | }); 30 | }); -------------------------------------------------------------------------------- /src/test/webapp/utils/api.spec.js: -------------------------------------------------------------------------------- 1 | import { combineViewAndLayout, aggregateStatsByDay, aggregateStatsByMonth, aggregateStatsByYear } from 'src/utils'; 2 | import moment from 'moment'; 3 | 4 | describe('api utils specs', () => { 5 | describe('combineLayoutAndView', () => { 6 | const view = { 7 | title: 'Board1', 8 | status: 'SUCCESS', 9 | message: 'msg', 10 | boxes: [{ 11 | title: 'Box1', 12 | status: 'SUCCESS', 13 | message: 'msg1', 14 | checks: [{ 15 | title: 'Check1', 16 | status: 'SUCCESS', 17 | message: 'msg11', 18 | label: 'l11' 19 | }] 20 | }, { 21 | title: 'Box2', 22 | status: 'WARNING', 23 | message: 'msg2', 24 | checks: [{ 25 | title: 'Check2', 26 | status: 'WARNING', 27 | message: 'msg22', 28 | label: 'l22' 29 | }] 30 | }] 31 | }; 32 | 33 | const links = [ 34 | ['Box1', 'Box2'] 35 | ]; 36 | 37 | const layout = { 38 | columns: [ 39 | { 40 | percentage: 50, 41 | rows: [{ 42 | title: 'Zone 1', 43 | percentage: 100, 44 | boxes: [{ 45 | title: 'Box1', 46 | description: 'desc1', 47 | labelLimit: 2 48 | }] 49 | }] 50 | }, 51 | { 52 | percentage: 50, 53 | rows: [{ 54 | title: 'Zone 2', 55 | percentage: 100, 56 | boxes: [{ 57 | title: 'Box2', 58 | description: 'desc2', 59 | labelLimit: 0 60 | }] 61 | }] 62 | } 63 | ] 64 | }; 65 | 66 | it('combines board layout and view data, returns an object representing the view', () => { 67 | const result = combineViewAndLayout(view, layout, links); 68 | expect(result).to.deep.equal({ 69 | title: 'Board1', 70 | status: 'SUCCESS', 71 | message: 'msg', 72 | columns: [ 73 | { 74 | percentage: 50, 75 | rows: [{ 76 | title: 'Zone 1', 77 | percentage: 100, 78 | boxes: [{ 79 | title: 'Box1', 80 | description: 'desc1', 81 | status: 'SUCCESS', 82 | message: 'msg1', 83 | labelLimit: 2, 84 | checks: [{ 85 | title: 'Check1', 86 | status: 'SUCCESS', 87 | message: 'msg11', 88 | label: 'l11' 89 | }] 90 | }] 91 | }] 92 | }, 93 | { 94 | percentage: 50, 95 | rows: [{ 96 | title: 'Zone 2', 97 | percentage: 100, 98 | boxes: [{ 99 | title: 'Box2', 100 | status: 'WARNING', 101 | description: 'desc2', 102 | message: 'msg2', 103 | labelLimit: 0, 104 | checks: [{ 105 | title: 'Check2', 106 | status: 'WARNING', 107 | message: 'msg22', 108 | label: 'l22' 109 | }] 110 | }] 111 | }] 112 | } 113 | ], 114 | links, 115 | slo: 0.97 116 | }); 117 | }); 118 | }); 119 | 120 | describe('aggregateStatsByDay', () => { 121 | it('takes hourly stats and aggregate them into daily buckets', () => { 122 | const stats = { 123 | [moment('2000-01-01 00:00').valueOf()]: 0.5, 124 | [moment('2000-01-01 23:00').valueOf()]: 0.9, 125 | [moment('2000-01-02 00:00').valueOf()]: 1, 126 | [moment('2000-01-02 23:59').valueOf()]: 1, 127 | }; 128 | const res = aggregateStatsByDay(stats); 129 | expect(res).to.deep.equal( 130 | { 131 | [moment('2000-01-01 00:00').valueOf()]: 0.7, 132 | [moment('2000-01-02 00:00').valueOf()]: 1, 133 | } 134 | ); 135 | }); 136 | }); 137 | 138 | describe('aggregateStatsByMonth', () => { 139 | it('takes hourly stats and aggregate them into monthly buckets', () => { 140 | const stats = { 141 | [moment('2000-01-01 00:00').valueOf()]: 0.5, 142 | [moment('2000-01-01 23:00').valueOf()]: 0.9, 143 | [moment('2000-01-02 00:00').valueOf()]: 1, 144 | [moment('2000-01-02 23:59').valueOf()]: 1, 145 | [moment('2000-02-01 12:00').valueOf()]: 0.3, 146 | [moment('2000-02-02 13:00').valueOf()]: 0.3, 147 | }; 148 | const res = aggregateStatsByMonth(stats); 149 | expect(res).to.deep.equal( 150 | { 151 | [moment('2000-01-01 00:00').valueOf()]: 0.85, 152 | [moment('2000-02-01 00:00').valueOf()]: 0.3, 153 | } 154 | ); 155 | }); 156 | }); 157 | 158 | describe('aggregateStatsByYear', () => { 159 | it('takes hourly stats and aggregate them into yearly buckets', () => { 160 | const stats = { 161 | [moment('2000-01-01 00:00').valueOf()]: 0.5, 162 | [moment('2000-01-01 23:00').valueOf()]: 0.9, 163 | [moment('2000-01-02 00:00').valueOf()]: 1, 164 | [moment('2000-01-02 23:59').valueOf()]: 1, 165 | [moment('2000-02-01 12:00').valueOf()]: 0.3, 166 | [moment('2000-03-02 13:00').valueOf()]: 0.33, 167 | [moment('2001-01-01 00:00').valueOf()]: 0.1, 168 | [moment('2001-03-01 23:00').valueOf()]: 0.89, 169 | [moment('2001-03-12 00:00').valueOf()]: 0.14, 170 | [moment('2001-04-01 23:59').valueOf()]: 1, 171 | [moment('2001-05-01 12:00').valueOf()]: 0.4, 172 | [moment('2002-01-31 13:00').valueOf()]: 0.72, 173 | [moment('2002-04-04 00:00').valueOf()]: 0.32, 174 | }; 175 | const res = aggregateStatsByYear(stats); 176 | expect(res).to.deep.equal( 177 | { 178 | [moment('2000-01-01 00:00').valueOf()]: 0.6716666666666665, 179 | [moment('2001-01-01 00:00').valueOf()]: 0.506, 180 | [moment('2002-01-01 00:00').valueOf()]: 0.52, 181 | } 182 | ); 183 | }); 184 | }); 185 | }); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var yargs = require('yargs').argv; 3 | var webpack = require('webpack'); 4 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 6 | 7 | yargs.env = yargs.env || {}; 8 | var baseDir = path.resolve(__dirname, 'src/main/webapp'); 9 | var outPath = path.resolve(__dirname, yargs.env.out || 'dist'); 10 | var isProdMode = yargs.p || false; 11 | var config = { 12 | entry: [ 13 | 'whatwg-fetch', 14 | 'babel-polyfill', 15 | path.resolve(baseDir, 'js/index.js'), 16 | path.resolve(baseDir, 'style/index.styl') 17 | ], 18 | output: { 19 | path: outPath, 20 | filename: 'index.js', 21 | publicPath: '/' 22 | }, 23 | module: { 24 | loaders: [ 25 | { 26 | test: /\.jsx?$/, 27 | loader: 'babel-loader', 28 | query: { 29 | cacheDirectory: true, 30 | presets: ['react', 'es2015'], 31 | plugins: [ 32 | 'transform-class-properties', 33 | 'transform-object-rest-spread', 34 | 'transform-exponentiation-operator' 35 | ] 36 | }, 37 | include: [path.join(__dirname, 'src')] 38 | }, 39 | { 40 | test: /\.styl/, 41 | loaders: ['style-loader', 'css-loader', 'stylus-loader'] 42 | }, 43 | { 44 | test: /\.png$/, 45 | loaders: ['file-loader'] 46 | } 47 | ] 48 | }, 49 | resolve: { 50 | alias: { 51 | 'src': path.resolve(__dirname, 'src/main/webapp/js') // for testing 52 | } 53 | }, 54 | plugins: [ 55 | new HtmlWebpackPlugin({ 56 | filename: 'index.html', 57 | template: path.resolve(baseDir, 'public/index.ejs'), 58 | inject: false, 59 | title: 'SLAB' 60 | }), 61 | new CopyWebpackPlugin( 62 | [ 63 | { 64 | from: path.resolve(baseDir, 'public'), 65 | to: 'public' 66 | } 67 | ], 68 | { 69 | ignore: [ 70 | '*.ejs' 71 | ] 72 | } 73 | ), 74 | new webpack.ProvidePlugin({ 75 | 'React': 'react' 76 | }), 77 | new webpack.NamedModulesPlugin() 78 | ], 79 | devServer: { 80 | contentBase: outPath, 81 | compress: true, 82 | port: 9001, 83 | historyApiFallback: true, 84 | proxy: { 85 | '/api': 'http://localhost:' + (yargs.env.serverPort || 8080) 86 | }, 87 | hot: !isProdMode 88 | }, 89 | devtool: isProdMode ? 'source-map' : 'eval-source-map' 90 | }; 91 | 92 | if (!isProdMode) { 93 | config.plugins.push(new webpack.NamedModulesPlugin()); 94 | config.plugins.push(new webpack.HotModuleReplacementPlugin()); 95 | } 96 | 97 | module.exports = config; --------------------------------------------------------------------------------