├── .cfignore ├── .crystal-version ├── .envrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── manifest.yml ├── public ├── favico-0.3.10.min.js ├── favicon.png ├── github.png ├── refresh.js └── styles.css ├── shard.lock ├── shard.yml ├── spec ├── job_spec.cr ├── lazy_map_spec.cr ├── my_data_spec.cr ├── pipeline_spec.cr └── resource_spec.cr ├── src ├── concourse-summary.cr └── concourse-summary │ ├── exceptions.cr │ ├── expose_unauthorized_handler.cr │ ├── from_json.cr │ ├── giphy.cr │ ├── http_client.cr │ ├── job.cr │ ├── job_info.cr │ ├── json_or_html.cr │ ├── lazy_map.cr │ ├── my_data.cr │ ├── pipeline.cr │ ├── resource.cr │ └── status.cr └── views ├── group.ecr ├── header.ecr ├── host.ecr ├── index.ecr ├── jobs.ecr ├── layout.ecr └── single_host.ecr /.cfignore: -------------------------------------------------------------------------------- 1 | /download 2 | /.shards/ 3 | /libs/ 4 | /lib/ 5 | /.crystal/ 6 | -------------------------------------------------------------------------------- /.crystal-version: -------------------------------------------------------------------------------- 1 | 0.27.0 2 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export HOSTS="ci.concourse.ci appdog.ci.cf-app.com buildpacks.ci.cf-app.com diego.ci.cf-app.com capi.ci.cf-app.com ci.shoetree.io?login_form" 2 | export REFRESH_INTERVAL=30 3 | export CS_GROUPS="{\"test\":{\"buildpacks.ci.cf-app.com\":{\"binary-builder\":[\"automated-builds\",\"manual-builds\"],\"brats\":null},\"diego.ci.cf-app.com\":{\"greenhouse\":null},\"capi.ci.cf-app.com\":null},\"greenhouse\":{\"diego.ci.cf-app.com\":{\"greenhouse\":null},\"main.bosh-ci.cf-app.com\":{\"windows-stemcells\":null,\"windows-agent-deps\":null}}}" 4 | export CS_GROUPS="{\"test\":{\"buildpacks.ci.cf-app.com\":null,\"diego.ci.cf-app.com\":null,\"capi.ci.cf-app.com\":null},\"greenhouse\":{\"diego.ci.cf-app.com\":{\"greenhouse\":null},\"main.bosh-ci.cf-app.com\":{\"windows-stemcells\":null,\"windows-agent-deps\":null}}}" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | /download 3 | /.shards/ 4 | /libs/ 5 | /lib/ 6 | /.crystal/ 7 | /concourse-summary 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | crystal: 3 | - latest 4 | addons: 5 | apt: 6 | sources: 7 | - sourceline: 'deb https://dist.crystal-lang.org/apt crystal main' 8 | key_url: 'https://keybase.io/crystal/pgp_keys.asc' 9 | packages: 10 | - crystal 11 | # deploy: 12 | # provider: cloudfoundry 13 | # api: https://api.run.pivotal.io 14 | # username: dgoddard@pivotal.io 15 | # password: 16 | # secure: YUw4yUlYJSYRq7UkjVW+ayWvw36QnEepSog2H2lLisxlYlsxh4xAlFnyohWiTW5oKhnRto9LPyqrxTq2zwsX/8zodk/AOLNstNL1GHfh7hBabcz9FRgyyyz2STbC8dD17ZAILp+IMsXWThja+7uF/ER53C/+KFUhfRQdX0gLa5/plN5uwzcO80EpHpf79vjyAAT/nsoYCDrWDTBNRFJpGOPBZV13hvKSlx6dutxU/Flu35bc6o33/mKFeC6RrghZki7tiPisQmA+pCyBW801cnHggc4GdyeHy9L7A5b1/EvalGdXj+st+Q7iBIYoRFlDgEomyoGwTOSQGBwUkjy0hVV7Ycc965ePRHk3fz3xFCSRf6Vy5vFA4DrtdejW1DvOWuMtY98D9jeYNWLLzeV+Tb+ybwtKSH5xFD5rXn8yNTfe6pjjlCxw+0qDkuE1D0+2GEq8bLxYLHmmUyr47LHtWndWcqyyqeY4bgBpx/hFzo4aToL5QZpyMHwmcq6qLhfEVV1vlAibs1tMB1nhcHFqyT8dBrQ67PCWhCAm1xsgPTCgDbAzrcZqmoafL05gKmLokO3imkwJ+MoIzuPLdks1TyxnPD+sgJvTMl11LIeHQ3jE3jCt1pTEbjWdT/YP+PkY2twWqB/Ygrjji5T/Jobr6A4hBTymFW0MQ76xMqtBY9c= 17 | # organization: labs-playground 18 | # space: "'Dave Goddard'" 19 | # on: 20 | # repo: dgodd/concourse-summary 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Dave Goddard 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # concourse-summary 2 | 3 | Ever wanted to have a quick overview of all of your [concourse](https://concourse-ci.org) pipelines and groups? Then **Concourse Summary** is for you. 4 | 5 | See an example at [concourse-summary-crystal.cfapps.io](https://concourse-summary-crystal.cfapps.io/) 6 | 7 | ### Usage 8 | 9 | As this app is written in [crystal](https://crystal-lang.org/) it can be run in a number of ways: 10 | 11 | #### Using crystal run 12 | 13 | ``` 14 | shards install 15 | crystal run src/concourse-summary.cr 16 | ``` 17 | 18 | #### As a binary 19 | 20 | ``` 21 | shards install 22 | crystal build --release src/concourse-summary.cr 23 | ``` 24 | 25 | #### As a CF app 26 | 27 | You may want to modify the example `manifest.yml` file prior to running your CF push 28 | 29 | ``` 30 | cf push 31 | ``` 32 | 33 | All configuration is managed using environment variables: 34 | 35 | | Variable | Description | Example | 36 | | ------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 37 | | HOSTS | A space seperate list of all concourse hosts that you wish to have a dashboard for | ci.concourse.ci appdog.ci.cf-app.com buildpacks.ci.cf-app.com diego.ci.cf-app.com capi.ci.cf-app.com | 38 | | CS_GROUPS | A json string of a chosen group name, linking to a host, pipeline and groups in concourse | '{"test":{"buildpacks.ci.cf-app.com":{"binary-builder":["automated-builds","manual-builds"],"brats":null},"diego.ci.cf-app.com":{"greenhouse":null},"capi.ci.cf-app.com":null}}' | 39 | | SKIP_SSL_VALIDATION | If set to "true" then SSL Validation will be ignored for all hosts | "true" | 40 | | REFRESH_INTERVAL | An integer in seconds for configuring the page refresh interval, defaults to 30 | 10 | 41 | 42 | ## Query parameters for running instance 43 | 44 | ### Labels 45 | 46 | Labels can filter the returned statuses to only those with the requested name as a substring of either the pipeline name or group name 47 | 48 | eg: [labels=ruby](https://concourse-summary-crystal.cfapps.io/host/buildpacks.ci.cf-app.com?labels=ruby) 49 | 50 | ### Giphy 51 | 52 | Sets giphy backgrounds on green images to make it easier to spot fully green (and reward you for it) 53 | 54 | eg: [giphy=dog](https://concourse-summary-crystal.cfapps.io/host/buildpacks.ci.cf-app.com?giphy=dog) 55 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | - name: concourse-summary-crystal 4 | memory: 256M 5 | stack: cflinuxfs3 6 | buildpack: https://github.com/dgodd/crystal-buildpack/releases/download/0.1.6/crystal_buildpack-cflinuxfs3-v0.1.6.zip 7 | env: 8 | HOSTS: ci.concourse.ci appdog.ci.cf-app.com buildpacks.ci.cf-app.com diego.ci.cf-app.com capi.ci.cf-app.com ci.shoetree.io?login_team=main 9 | CS_GROUPS: '{"test":{"buildpacks.ci.cf-app.com":{"binary-builder":["automated-builds","manual-builds"],"brats":null},"diego.ci.cf-app.com":{"greenhouse":null},"capi.ci.cf-app.com":null}}' 10 | SKIP_SSL_VALIDATION: "true" 11 | -------------------------------------------------------------------------------- /public/favico-0.3.10.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * @fileOverview Favico animations 4 | * @author Miroslav Magda, http://blog.ejci.net 5 | * @version 0.3.10 6 | */ 7 | !function(){var e=function(e){"use strict";function t(e){if(e.paused||e.ended||g)return!1;try{f.clearRect(0,0,s,l),f.drawImage(e,0,0,s,l)}catch(o){}p=setTimeout(function(){t(e)},S.duration),O.setIcon(h)}function o(e){var t=/^#?([a-f\d])([a-f\d])([a-f\d])$/i;e=e.replace(t,function(e,t,o,n){return t+t+o+o+n+n});var o=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return o?{r:parseInt(o[1],16),g:parseInt(o[2],16),b:parseInt(o[3],16)}:!1}function n(e,t){var o,n={};for(o in e)n[o]=e[o];for(o in t)n[o]=t[o];return n}function r(){return b.hidden||b.msHidden||b.webkitHidden||b.mozHidden}e=e?e:{};var i,a,l,s,h,f,c,d,u,y,w,g,x,m,p,b,v={bgColor:"#d00",textColor:"#fff",fontFamily:"sans-serif",fontStyle:"bold",type:"circle",position:"down",animation:"slide",elementId:!1,dataUrl:!1,win:window};x={},x.ff="undefined"!=typeof InstallTrigger,x.chrome=!!window.chrome,x.opera=!!window.opera||navigator.userAgent.indexOf("Opera")>=0,x.ie=/*@cc_on!@*/!1,x.safari=Object.prototype.toString.call(window.HTMLElement).indexOf("Constructor")>0,x.supported=x.chrome||x.ff||x.opera;var C=[];w=function(){},d=g=!1;var E=function(){i=n(v,e),i.bgColor=o(i.bgColor),i.textColor=o(i.textColor),i.position=i.position.toLowerCase(),i.animation=S.types[""+i.animation]?i.animation:v.animation,b=i.win.document;var t=i.position.indexOf("up")>-1,r=i.position.indexOf("left")>-1;if(t||r)for(var d=0;d0?c.height:32,s=c.width>0?c.width:32,h.height=l,h.width=s,f=h.getContext("2d"),M.ready()},c.setAttribute("src",a.getAttribute("href"))):(c.onload=function(){l=32,s=32,c.height=l,c.width=s,h.height=l,h.width=s,f=h.getContext("2d"),M.ready()},c.setAttribute("src",""))},M={};M.ready=function(){d=!0,M.reset(),w()},M.reset=function(){d&&(C=[],u=!1,y=!1,f.clearRect(0,0,s,l),f.drawImage(c,0,0,s,l),O.setIcon(h),window.clearTimeout(m),window.clearTimeout(p))},M.start=function(){if(d&&!y){var e=function(){u=C[0],y=!1,C.length>0&&(C.shift(),M.start())};if(C.length>0){y=!0;var t=function(){["type","animation","bgColor","textColor","fontFamily","fontStyle"].forEach(function(e){e in C[0].options&&(i[e]=C[0].options[e])}),S.run(C[0].options,function(){e()},!1)};u?S.run(u.options,function(){t()},!0):t()}}};var A={},I=function(e){return e.n="number"==typeof e.n?Math.abs(0|e.n):e.n,e.x=s*e.x,e.y=l*e.y,e.w=s*e.w,e.h=l*e.h,e.len=(""+e.n).length,e};A.circle=function(e){e=I(e);var t=!1;2===e.len?(e.x=e.x-.4*e.w,e.w=1.4*e.w,t=!0):e.len>=3&&(e.x=e.x-.65*e.w,e.w=1.65*e.w,t=!0),f.clearRect(0,0,s,l),f.drawImage(c,0,0,s,l),f.beginPath(),f.font=i.fontStyle+" "+Math.floor(e.h*(e.n>99?.85:1))+"px "+i.fontFamily,f.textAlign="center",t?(f.moveTo(e.x+e.w/2,e.y),f.lineTo(e.x+e.w-e.h/2,e.y),f.quadraticCurveTo(e.x+e.w,e.y,e.x+e.w,e.y+e.h/2),f.lineTo(e.x+e.w,e.y+e.h-e.h/2),f.quadraticCurveTo(e.x+e.w,e.y+e.h,e.x+e.w-e.h/2,e.y+e.h),f.lineTo(e.x+e.h/2,e.y+e.h),f.quadraticCurveTo(e.x,e.y+e.h,e.x,e.y+e.h-e.h/2),f.lineTo(e.x,e.y+e.h/2),f.quadraticCurveTo(e.x,e.y,e.x+e.h/2,e.y)):f.arc(e.x+e.w/2,e.y+e.h/2,e.h/2,0,2*Math.PI),f.fillStyle="rgba("+i.bgColor.r+","+i.bgColor.g+","+i.bgColor.b+","+e.o+")",f.fill(),f.closePath(),f.beginPath(),f.stroke(),f.fillStyle="rgba("+i.textColor.r+","+i.textColor.g+","+i.textColor.b+","+e.o+")","number"==typeof e.n&&e.n>999?f.fillText((e.n>9999?9:Math.floor(e.n/1e3))+"k+",Math.floor(e.x+e.w/2),Math.floor(e.y+e.h-.2*e.h)):f.fillText(e.n,Math.floor(e.x+e.w/2),Math.floor(e.y+e.h-.15*e.h)),f.closePath()},A.rectangle=function(e){e=I(e);var t=!1;2===e.len?(e.x=e.x-.4*e.w,e.w=1.4*e.w,t=!0):e.len>=3&&(e.x=e.x-.65*e.w,e.w=1.65*e.w,t=!0),f.clearRect(0,0,s,l),f.drawImage(c,0,0,s,l),f.beginPath(),f.font=i.fontStyle+" "+Math.floor(e.h*(e.n>99?.9:1))+"px "+i.fontFamily,f.textAlign="center",f.fillStyle="rgba("+i.bgColor.r+","+i.bgColor.g+","+i.bgColor.b+","+e.o+")",f.fillRect(e.x,e.y,e.w,e.h),f.fillStyle="rgba("+i.textColor.r+","+i.textColor.g+","+i.textColor.b+","+e.o+")","number"==typeof e.n&&e.n>999?f.fillText((e.n>9999?9:Math.floor(e.n/1e3))+"k+",Math.floor(e.x+e.w/2),Math.floor(e.y+e.h-.2*e.h)):f.fillText(e.n,Math.floor(e.x+e.w/2),Math.floor(e.y+e.h-.15*e.h)),f.closePath()};var T=function(e,t){t=("string"==typeof t?{animation:t}:t)||{},w=function(){try{if("number"==typeof e?e>0:""!==e){var n={type:"badge",options:{n:e}};if("animation"in t&&S.types[""+t.animation]&&(n.options.animation=""+t.animation),"type"in t&&A[""+t.type]&&(n.options.type=""+t.type),["bgColor","textColor"].forEach(function(e){e in t&&(n.options[e]=o(t[e]))}),["fontStyle","fontFamily"].forEach(function(e){e in t&&(n.options[e]=t[e])}),C.push(n),C.length>100)throw new Error("Too many badges requests in queue.");M.start()}else M.reset()}catch(r){throw new Error("Error setting badge. Message: "+r.message)}},d&&w()},U=function(e){w=function(){try{var t=e.width,o=e.height,n=document.createElement("img"),r=o/l>t/s?t/s:o/l;n.setAttribute("crossOrigin","anonymous"),n.onload=function(){f.clearRect(0,0,s,l),f.drawImage(n,0,0,s,l),O.setIcon(h)},n.setAttribute("src",e.getAttribute("src")),n.height=o/r,n.width=t/r}catch(i){throw new Error("Error setting image. Message: "+i.message)}},d&&w()},R=function(e){w=function(){try{if("stop"===e)return g=!0,M.reset(),void(g=!1);e.addEventListener("play",function(){t(this)},!1)}catch(o){throw new Error("Error setting video. Message: "+o.message)}},d&&w()},L=function(e){if(window.URL&&window.URL.createObjectURL||(window.URL=window.URL||{},window.URL.createObjectURL=function(e){return e}),x.supported){var o=!1;navigator.getUserMedia=navigator.getUserMedia||navigator.oGetUserMedia||navigator.msGetUserMedia||navigator.mozGetUserMedia||navigator.webkitGetUserMedia,w=function(){try{if("stop"===e)return g=!0,M.reset(),void(g=!1);o=document.createElement("video"),o.width=s,o.height=l,navigator.getUserMedia({video:!0,audio:!1},function(e){o.src=URL.createObjectURL(e),o.play(),t(o)},function(){})}catch(n){throw new Error("Error setting webcam. Message: "+n.message)}},d&&w()}},O={};O.getIcon=function(){var e=!1,t=function(){for(var e=b.getElementsByTagName("head")[0].getElementsByTagName("link"),t=e.length,o=t-1;o>=0;o--)if(/(^|\s)icon(\s|$)/i.test(e[o].getAttribute("rel")))return e[o];return!1};return i.element?e=i.element:i.elementId?(e=b.getElementById(i.elementId),e.setAttribute("href",e.getAttribute("src"))):(e=t(),e===!1&&(e=b.createElement("link"),e.setAttribute("rel","icon"),b.getElementsByTagName("head")[0].appendChild(e))),e.setAttribute("type","image/png"),e},O.setIcon=function(e){var t=e.toDataURL("image/png");if(i.dataUrl&&i.dataUrl(t),i.element)i.element.setAttribute("href",t),i.element.setAttribute("src",t);else if(i.elementId){var o=b.getElementById(i.elementId);o.setAttribute("href",t),o.setAttribute("src",t)}else if(x.ff||x.opera){var n=a;a=b.createElement("link"),x.opera&&a.setAttribute("rel","icon"),a.setAttribute("rel","icon"),a.setAttribute("type","image/png"),b.getElementsByTagName("head")[0].appendChild(a),a.setAttribute("href",t),n.parentNode&&n.parentNode.removeChild(n)}else a.setAttribute("href",t)};var S={};return S.duration=40,S.types={},S.types.fade=[{x:.4,y:.4,w:.6,h:.6,o:0},{x:.4,y:.4,w:.6,h:.6,o:.1},{x:.4,y:.4,w:.6,h:.6,o:.2},{x:.4,y:.4,w:.6,h:.6,o:.3},{x:.4,y:.4,w:.6,h:.6,o:.4},{x:.4,y:.4,w:.6,h:.6,o:.5},{x:.4,y:.4,w:.6,h:.6,o:.6},{x:.4,y:.4,w:.6,h:.6,o:.7},{x:.4,y:.4,w:.6,h:.6,o:.8},{x:.4,y:.4,w:.6,h:.6,o:.9},{x:.4,y:.4,w:.6,h:.6,o:1}],S.types.none=[{x:.4,y:.4,w:.6,h:.6,o:1}],S.types.pop=[{x:1,y:1,w:0,h:0,o:1},{x:.9,y:.9,w:.1,h:.1,o:1},{x:.8,y:.8,w:.2,h:.2,o:1},{x:.7,y:.7,w:.3,h:.3,o:1},{x:.6,y:.6,w:.4,h:.4,o:1},{x:.5,y:.5,w:.5,h:.5,o:1},{x:.4,y:.4,w:.6,h:.6,o:1}],S.types.popFade=[{x:.75,y:.75,w:0,h:0,o:0},{x:.65,y:.65,w:.1,h:.1,o:.2},{x:.6,y:.6,w:.2,h:.2,o:.4},{x:.55,y:.55,w:.3,h:.3,o:.6},{x:.5,y:.5,w:.4,h:.4,o:.8},{x:.45,y:.45,w:.5,h:.5,o:.9},{x:.4,y:.4,w:.6,h:.6,o:1}],S.types.slide=[{x:.4,y:1,w:.6,h:.6,o:1},{x:.4,y:.9,w:.6,h:.6,o:1},{x:.4,y:.9,w:.6,h:.6,o:1},{x:.4,y:.8,w:.6,h:.6,o:1},{x:.4,y:.7,w:.6,h:.6,o:1},{x:.4,y:.6,w:.6,h:.6,o:1},{x:.4,y:.5,w:.6,h:.6,o:1},{x:.4,y:.4,w:.6,h:.6,o:1}],S.run=function(e,t,o,a){var l=S.types[r()?"none":i.animation];return a=o===!0?"undefined"!=typeof a?a:l.length-1:"undefined"!=typeof a?a:0,t=t?t:function(){},a=0?(A[i.type](n(e,l[a])),m=setTimeout(function(){o?a-=1:a+=1,S.run(e,t,o,a)},S.duration),O.setIcon(h),void 0):void t()},E(),{badge:T,video:R,image:U,webcam:L,reset:M.reset,browser:{supported:x.supported}}};"undefined"!=typeof define&&define.amd?define([],function(){return e}):"undefined"!=typeof module&&module.exports?module.exports=e:this.Favico=e}(); -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgodd/concourse-summary/6b447f090e0e3a9ae2f64443ca6d8fc41ca02479/public/favicon.png -------------------------------------------------------------------------------- /public/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgodd/concourse-summary/6b447f090e0e3a9ae2f64443ca6d8fc41ca02479/public/github.png -------------------------------------------------------------------------------- /public/refresh.js: -------------------------------------------------------------------------------- 1 | window.refresh_interval = window.refresh_interval || 30; 2 | 3 | var styles = document.createElement("style"); 4 | document.head.appendChild(styles); 5 | 6 | var scaleboxes = function() { 7 | var x = document.querySelectorAll('a.outer'); 8 | var notboxes = 32 + (32 * document.querySelectorAll('.group').length); 9 | var y = ((window.innerHeight - notboxes) * window.innerWidth) / x.length; 10 | var w = Math.floor(Math.sqrt(y)) - 4; 11 | var h = w * 2 / 3; 12 | var h = Math.floor(w * 2 / 3); 13 | 14 | // Correct if too long 15 | var perColumn = Math.floor(window.innerWidth / (w + 4)); 16 | var numRows = Math.ceil(x.length / perColumn) 17 | var heightRequired = numRows * (h + 4) + notboxes; 18 | if (heightRequired > window.innerHeight) { 19 | numRows -= 1; 20 | perColumn = Math.ceil(x.length / numRows); 21 | w = Math.floor(window.innerWidth / perColumn) - 8; 22 | h = Math.floor(w * 2 / 3); 23 | } 24 | 25 | // Set styles 26 | boxStyle = "body{overflow:hidden}"; 27 | boxStyle += "a.outer {"; 28 | boxStyle += "width:"+w+"px;"; 29 | boxStyle += "height: "+h+"px;"; 30 | boxStyle += "}"; 31 | boxStyle += "a.outer div.inner {"; 32 | boxStyle += "height: " + h + "px;"; 33 | boxStyle += "line-height: " + Math.floor(h / 4) + "px;"; 34 | boxStyle += "font-size: " + Math.floor(h / 6) + "px;"; 35 | boxStyle += "}"; 36 | styles.innerHTML = boxStyle; 37 | 38 | var numRunning = document.querySelectorAll('a.outer.running').length; 39 | var favicon = new Favico({ animation:'none' }); 40 | favicon.badge(numRunning); 41 | 42 | setTimeout(function(){ 43 | var x = document.querySelectorAll('a.outer .inner > span > span') 44 | for (var i = 0; i < x.length; i++) { 45 | var y = x[i]; 46 | var z = y.parentNode 47 | var multi = (z.offsetWidth * 0.8) / y.offsetWidth 48 | if (multi < 1) { 49 | y.style.fontSize = (multi * 100) + '%' 50 | } 51 | } 52 | }, 10); 53 | }; 54 | 55 | var onerror = function() { 56 | document.body.innerHTML = '
' + Date() + ' (' + refresh_interval + ')

ERROR

'; 57 | document.head.setAttribute("rel", "error"); 58 | }; 59 | var onsuccess = function(request) { 60 | var doc = document.implementation.createHTMLDocument("example"); 61 | doc.documentElement.innerHTML = request.response; 62 | if (document.head.getAttribute("rel") != doc.head.getAttribute("rel")) { 63 | window.location.reload(); 64 | } 65 | document.body.innerHTML=doc.body.innerHTML; 66 | 67 | scaleboxes() 68 | }; 69 | setInterval(function() { 70 | var request = new XMLHttpRequest(); 71 | request.open('GET', location.href, true); 72 | request.onload = function() { 73 | if (request.status >= 200 && request.status < 400) { 74 | onsuccess(request); 75 | } else { 76 | onerror(); 77 | } 78 | }; 79 | request.onerror = onerror; 80 | request.send(); 81 | }, refresh_interval * 1000); 82 | setInterval(function() { 83 | var el = document.getElementById('countdown'); 84 | if(el) { 85 | var counter = parseInt(el.innerText, 10); 86 | el.innerText = counter - 1; 87 | } 88 | }, 1000); 89 | 90 | window.addEventListener("load", function() { scaleboxes() }); 91 | window.addEventListener("resize", function() { scaleboxes() }); 92 | -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | body {margin:0;padding:0;font-family:monospace, sans-serif;font-size:20px;line-height:1.6em;text-align:center;background:#263748;color:#E6E7E8;} 2 | a {color:#E6E7E8;} 3 | .time {line-height:32px;background:#1A252F;color:#E6E7E8;white-space:nowrap;} 4 | .time .right {position:absolute;top:0;;right:0;height:32px;background:#1A252F;} 5 | .time a {text-decoration:none;} 6 | .time .github {width:32px;height:32px;background:url(/github.png);display:inline-block;background-size:contain;} 7 | .scalable { 8 | position:absolute;top:32px;right:0;bottom:0;left:0; 9 | display: flex; 10 | flex-flow: row wrap; 11 | justify-content: space-around; 12 | } 13 | .group > a {display:block;text-decoration:none;} 14 | .group > div {display:flex;flex-flow:row wrap;justify-content:space-around;} 15 | .outer {display:block;width:300px;height:200px;color:white;background:#5C6C7D;position:relative;margin:4px;} 16 | .status {position:absolute;top:0;bottom:0;left:0;right:0;white-space:nowrap;overflow:hidden;text-align:left;} 17 | .paused_job, .aborted, .errored, .failed, .succeeded {position:absolute;top:0;height:100%;margin:0;padding:0;} 18 | .paused_job {background:#3498DB;} 19 | .aborted {background:#8F4B2D;} 20 | .errored {background:#E67E21;} 21 | .failed {background:#E74C3C;} 22 | .succeeded {background:#2ECC71;} 23 | .broken, .paused {position:absolute;top:0;bottom:0;left:0;right:0;box-sizing:border-box;} 24 | .broken {border:14px solid #E67E21;} 25 | .paused {border:14px solid #2682D5;} 26 | .inner {position:absolute;top:0;bottom:0;left:0;right:0;text-align:center;text-decoration:none;white-space:nowrap;overflow:hidden;display:flex;justify-content:center;flex-direction:column;} 27 | .running .inner {height:100%;} 28 | @-webkit-keyframes pulseBorder { 29 | from { outline-offset: 0; } 30 | to { outline-offset: 7px; } 31 | } 32 | .running { 33 | -webkit-animation-name: pulseBorder; 34 | -webkit-animation-iteration-count: infinite; 35 | -webkit-animation-timing-function: ease; 36 | -webkit-animation-direction: alternate; 37 | -webkit-animation-duration: 0.5s; 38 | outline: solid 7px #F2C500; 39 | outline-offset: 0; 40 | } 41 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | shards: 3 | exception_page: 4 | github: crystal-loot/exception_page 5 | version: 0.1.1 6 | 7 | kemal: 8 | github: sdogruyol/kemal 9 | version: 0.25.1 10 | 11 | kilt: 12 | github: jeromegn/kilt 13 | version: 0.4.0 14 | 15 | radix: 16 | github: luislavena/radix 17 | version: 0.3.8 18 | 19 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: concourse-summary 2 | version: 0.2.1 3 | crystal: 0.27.0 4 | 5 | dependencies: 6 | kemal: 7 | github: sdogruyol/kemal 8 | 9 | targets: 10 | concourse-summary: 11 | main: src/concourse-summary.cr 12 | 13 | authors: 14 | - Dave Goddard 15 | 16 | description: | 17 | Show a quick overview of all of your concourse.ci pipelines and groups 18 | 19 | license: MIT 20 | -------------------------------------------------------------------------------- /spec/job_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/concourse-summary/job" 3 | 4 | class Response 5 | @status_code : Int32 6 | @body : String 7 | getter status_code 8 | getter body 9 | def initialize(@status_code, @body) 10 | end 11 | end 12 | class Client 13 | @path : String 14 | @response : Response 15 | def initialize(@path, @response) 16 | end 17 | def get(path) 18 | path.should eq @path 19 | @response 20 | end 21 | end 22 | 23 | describe "Job" do 24 | describe ".all" do 25 | it "returns requested pipelines" do 26 | response = Response.new(200, %([{"name":"fred","groups":[],"next_build":null,"finished_build":null},{"name":"jane","groups":[],"next_build":null,"finished_build":null}])) 27 | client = Client.new("/api/v1/some/path/jobs", response) 28 | 29 | jobs = Job.all(client, "/some/path") 30 | jobs.map(&.name).should eq ["fred","jane"] 31 | end 32 | end 33 | 34 | describe "#groups" do 35 | it "returns groups" do 36 | job = Job.from_json(%({"name":"fred","groups":["A"],"next_build":null,"finished_build":null})) 37 | job.groups.should eq ["A"] 38 | end 39 | 40 | it "turns empty group array in to array of one nil" do 41 | job = Job.from_json(%({"name":"fred","groups":[],"next_build":null,"finished_build":null})) 42 | job.groups.should eq [nil] 43 | end 44 | end 45 | 46 | describe "#broken" do 47 | it "returns false" do 48 | job = Job.from_json(%({"name":"fred","groups":[]})) 49 | job.broken.should be_false 50 | end 51 | 52 | it "returns true" do 53 | job = Job.from_json(%({"name":"fred","groups":[],"inputs":[{"name":"A","resource":"B"}]})) 54 | job.broken = {"A"=>true} 55 | job.broken.should be_true 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/lazy_map_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/concourse-summary/lazy_map" 3 | 4 | describe "Array.lazy_map" do 5 | it "processes the elements" do 6 | [1,2].lazy_map { |x| x * 2 }.should eq [2,4] 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/my_data_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "file" 3 | require "../src/concourse-summary/my_data" 4 | require "../src/concourse-summary/pipeline" 5 | require "../src/concourse-summary/job" 6 | 7 | describe "MyData" do 8 | describe "#label" do 9 | it "group is nil" do 10 | data = MyData.new("mypipe", nil) 11 | data.labels.should eq ["mypipe"] 12 | end 13 | it "group has data" do 14 | data = MyData.new("mypipe", "mygroup") 15 | data.labels.should eq ["mypipe", "mygroup"] 16 | end 17 | end 18 | 19 | describe "#percent" do 20 | it "is 0 by default" do 21 | data = MyData.new("", nil) 22 | data.percent("succeeded").should eq 0 23 | end 24 | 25 | it "is 100 if all succeeded" do 26 | data = MyData.new("", nil) 27 | data.inc("succeeded") 28 | data.percent("pending").should eq 0 29 | data.percent("succeeded").should eq 100 30 | end 31 | 32 | it "is 2/3 if all some succeeded" do 33 | data = MyData.new("", nil) 34 | data.inc("failed") 35 | data.inc("succeeded") 36 | data.inc("succeeded") 37 | data.percent("failed").should eq 34 38 | data.percent("succeeded").should eq 67 39 | end 40 | end 41 | 42 | describe ".statuses" do 43 | it "handles nil group" do 44 | job = Job.from_json("{\"groups\":[], \"name\":\"\", \"next_build\":null, \"finished_build\":null}") 45 | pipeline = Pipeline.from_json("{\"name\":\"pipeline\",\"team_name\":\"\",\"paused\":false}") 46 | statuses = MyData.statuses([ {pipeline, job} ]) 47 | 48 | statuses.size.should eq 1 49 | statuses.first.labels.should eq ["pipeline"] 50 | statuses.first.paused.should be_false 51 | statuses.first.running.should be_false 52 | statuses.first.percent("pending").should eq 100 53 | end 54 | 55 | it "handles single group" do 56 | job = Job.from_json("{\"groups\":[\"group\"], \"name\":\"\", \"next_build\":null, \"finished_build\":null}") 57 | pipeline = Pipeline.from_json("{\"name\":\"pipeline\",\"team_name\":\"\",\"paused\":false}") 58 | statuses = MyData.statuses([ {pipeline, job} ]) 59 | 60 | statuses.size.should eq 1 61 | statuses.first.labels.should eq ["pipeline", "group"] 62 | statuses.first.paused.should be_false 63 | statuses.first.running.should be_false 64 | statuses.first.percent("pending").should eq 100 65 | end 66 | 67 | it "handles multiple groups" do 68 | job = Job.from_json("{\"groups\":[\"group1\",\"group2\"], \"name\":\"\", \"next_build\":null, \"finished_build\":null}") 69 | pipeline = Pipeline.from_json("{\"name\":\"pipeline\",\"team_name\":\"\",\"paused\":false}") 70 | statuses = MyData.statuses([ {pipeline, job} ]) 71 | 72 | statuses.size.should eq 2 73 | statuses[0].labels.should eq ["pipeline", "group1"] 74 | statuses[0].paused.should be_false 75 | statuses[1].labels.should eq ["pipeline", "group2"] 76 | statuses[1].paused.should be_false 77 | end 78 | 79 | it "handles paused" do 80 | job = Job.from_json("{\"groups\":[], \"name\":\"\", \"next_build\":null, \"finished_build\":null}") 81 | pipeline = Pipeline.from_json("{\"name\":\"pipeline\",\"team_name\":\"\",\"paused\":true}") 82 | statuses = MyData.statuses([ {pipeline, job} ]) 83 | 84 | statuses.size.should eq 1 85 | statuses.first.paused.should be_true 86 | end 87 | end 88 | 89 | describe ".remove_group_info" do 90 | it "clears group info from from pipeline/job tuple arrays" do 91 | job = Job.from_json(%({"groups":["g1","g2"], "name":"", "next_build":null, "finished_build":null})) 92 | pipeline = Pipeline.from_json(%({"name":"pipeline","team_name":"","paused":true})) 93 | data = [ {pipeline, job} ] 94 | data[0][1].groups.should eq ["g1","g2"] 95 | 96 | MyData.remove_group_info(data) 97 | 98 | data[0][1].groups.should eq [nil] 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/pipeline_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/concourse-summary/pipeline" 3 | 4 | class Response 5 | @status_code : Int32 6 | @body : String 7 | getter status_code 8 | getter body 9 | def initialize(@status_code, @body) 10 | end 11 | end 12 | class Client 13 | @path : String 14 | @response : Response 15 | def initialize(@path, @response) 16 | end 17 | def get(path) 18 | it { path == @path } 19 | @response 20 | end 21 | end 22 | 23 | 24 | describe "Pipeline" do 25 | describe ".all" do 26 | it "returns requested pipelines" do 27 | response = Response.new(200, %([{"name":"fred pipeline","team_name":"main","paused":false},{"name":"jane","team_name":"team1","paused":false}])) 28 | client = Client.new("/api/v1/pipelines", response) 29 | 30 | pipelines = Pipeline.all(client) 31 | pipelines.map(&.name).should eq ["fred pipeline","jane"] 32 | pipelines.map(&.url).should eq ["/teams/main/pipelines/fred%20pipeline", "/teams/team1/pipelines/jane"] 33 | end 34 | 35 | it "raises exception if 401 status code is returned" do 36 | response = Response.new(401, "") 37 | client = Client.new("/api/v1/pipelines", response) 38 | 39 | expect_raises Unauthorized do 40 | Pipeline.all(client) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/resource_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/concourse-summary/resource" 3 | 4 | class Response 5 | @status_code : Int32 6 | @body : String 7 | getter status_code 8 | getter body 9 | def initialize(@status_code, @body) 10 | end 11 | end 12 | class Client 13 | @path : String 14 | @response : Response 15 | def initialize(@path, @response) 16 | end 17 | def get(path) 18 | path.should eq @path 19 | @response 20 | end 21 | end 22 | 23 | describe "Resource" do 24 | describe ".all" do 25 | it "returns requested pipelines" do 26 | response = Response.new(200, %([{"name":"fred"},{"name":"jane","failing_to_check":true}])) 27 | client = Client.new("/api/v1/some/path/resources", response) 28 | 29 | resources = Resource.all(client, "/some/path") 30 | resources.map(&.name).should eq ["fred","jane"] 31 | end 32 | end 33 | 34 | describe "#broken" do 35 | it "returns hash of broken resources" do 36 | response = Response.new(200, %([{"name":"fred"},{"name":"jane","failing_to_check":true}])) 37 | client = Client.new("/api/v1/some/path/resources", response) 38 | 39 | resources = Resource.broken(client, "/some/path") 40 | resources["fred"].should eq false 41 | resources["jane"].should eq true 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /src/concourse-summary.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "kemal" 3 | 4 | require "./concourse-summary/*" 5 | 6 | serve_static({"gzip" => true, "dir_listing" => false}) 7 | gzip true 8 | 9 | REFRESH_INTERVAL = (ENV["REFRESH_INTERVAL"]? || 30).to_i 10 | GROUPS = parse_groups(ENV["CS_GROUPS"]? || "{}") 11 | 12 | def setup(env) 13 | refresh_interval = REFRESH_INTERVAL 14 | username = env.get?("credentials_username").to_s 15 | password = env.get?("credentials_password").to_s 16 | team_name = "main" 17 | 18 | login_form = env.params.query.has_key?("login_form") 19 | if env.params.query.has_key?("login_team") 20 | team_name = env.params.query["login_team"].to_s 21 | login_form = true 22 | end 23 | if login_form && (username.size == 0 || password.size == 0) 24 | raise Unauthorized.new 25 | end 26 | 27 | collapso_toggle = env.params.query.map {|k,v| %w(giphy labels).includes?(k) ? "#{k}=#{v}" : k } 28 | ignore_groups = env.params.query.has_key?("ignore_groups") 29 | if ignore_groups 30 | collapso_toggle = collapso_toggle - ["ignore_groups"] 31 | else 32 | collapso_toggle = collapso_toggle + ["ignore_groups"] 33 | end 34 | 35 | {refresh_interval,username,password,ignore_groups,collapso_toggle,login_form,team_name} 36 | end 37 | 38 | def process(data, ignore_groups) 39 | if ignore_groups 40 | MyData.remove_group_info(data) 41 | end 42 | statuses = MyData.statuses(data) 43 | end 44 | 45 | get "/giphy/:q" do |env| 46 | src = giphy(env.params.url["q"]) 47 | "" 48 | end 49 | 50 | get "/host/jobs/:host/**" do |env| 51 | refresh_interval,username,password,ignore_groups,collapso_toggle,login_form,team_name = setup(env) 52 | host = env.params.url["host"] 53 | 54 | data = MyData.get_data(host, username, password, nil, login_form) 55 | jobs = data.map do |pipeline,job| 56 | JobInfo.new(pipeline, job) 57 | end.select do |info| 58 | info.running || (!info.status.nil? && info.status != "succeeded" && !info.paused) 59 | end.sort_by{|a| a.start_time || 0 } 60 | 61 | json_or_html(jobs, "jobs") 62 | end 63 | 64 | get "/jobs/match/:keyword/:host/**" do |env| 65 | refresh_interval,username,password,ignore_groups,collapso_toggle,login_form,team_name = setup(env) 66 | keyword = env.params.url["keyword"] 67 | host = env.params.url["host"] 68 | 69 | data = MyData.get_data(host, username, password, nil, login_form, team_name) 70 | jobs = data.map do |pipeline,job| 71 | JobInfo.new(pipeline, job) 72 | end.select do |info| 73 | info.name.includes? keyword 74 | end.sort_by{|a| a.start_time || 0 } 75 | 76 | json_or_html(jobs, "jobs") 77 | end 78 | 79 | get "/host/:host/**" do |env| 80 | refresh_interval,username,password,ignore_groups,collapso_toggle,login_form,team_name = setup(env) 81 | host = env.params.url["host"] 82 | 83 | data = MyData.get_data(host, username, password, nil, login_form, team_name) 84 | statuses = process(data, ignore_groups) 85 | 86 | if env.params.query.has_key?("giphy") 87 | q = env.params.query["giphy"].to_s 88 | giphy_src = giphy(q) 89 | end 90 | if env.params.query.has_key?("labels") 91 | q = env.params.query["labels"].to_s 92 | statuses.select! do |s| 93 | s.labels.any? { |l| l.includes?(q) } 94 | end 95 | end 96 | 97 | json_or_html(statuses, "host") 98 | end 99 | 100 | get "/group/:key" do |env| 101 | refresh_interval,username,password,ignore_groups,collapso_toggle,login_form,team_name = setup(env) 102 | giphy_src = nil 103 | 104 | hosts = GROUPS[env.params.url["key"]] 105 | hosts = hosts.map do |host, pipelines| 106 | data = MyData.get_data(host, username, password, pipelines) 107 | data = MyData.filter_groups(data, pipelines) 108 | statuses = process(data, ignore_groups) 109 | { host, statuses } 110 | end 111 | 112 | json_or_html(hosts, "group") 113 | end 114 | 115 | get "/" do |env| 116 | refresh_interval = REFRESH_INTERVAL 117 | hosts = (ENV["HOSTS"]? || "").split(/\s+/) 118 | groups = GROUPS.keys 119 | 120 | json_or_html(hosts, "index") 121 | end 122 | 123 | Kemal.config.add_handler ExposeUnauthorizedHandler.new 124 | Kemal.run 125 | -------------------------------------------------------------------------------- /src/concourse-summary/exceptions.cr: -------------------------------------------------------------------------------- 1 | class Unauthorized < Exception 2 | end 3 | -------------------------------------------------------------------------------- /src/concourse-summary/expose_unauthorized_handler.cr: -------------------------------------------------------------------------------- 1 | class ExposeUnauthorizedHandler 2 | include HTTP::Handler 3 | 4 | def call(context) 5 | begin 6 | credentials(context) 7 | call_next context 8 | rescue ex : Unauthorized 9 | headers = HTTP::Headers.new 10 | context.response.status_code = 401 11 | context.response.headers["WWW-Authenticate"] = "Basic realm=\"Login Required\"" 12 | context.response.print "Could not verify your access level for that URL.\nYou have to login with proper credentials" 13 | end 14 | end 15 | 16 | def credentials(context) 17 | if context.request.headers["Authorization"]? 18 | if value = context.request.headers["Authorization"] 19 | if value.size > 0 && value.starts_with?("Basic") 20 | username, password = Base64.decode_string(value["Basic".size + 1..-1]).split(":") 21 | context.set "credentials_username", username 22 | context.set "credentials_password", password 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/concourse-summary/from_json.cr: -------------------------------------------------------------------------------- 1 | def Hash.new(pull : JSON::PullParser) 2 | hash = new 3 | pull.read_object do |key| 4 | hash[key] = V.new(pull) 5 | end 6 | hash 7 | end 8 | 9 | alias GroupHash = Hash(String, Hash(String, Array(String)?)?) 10 | 11 | def parse_groups(cs_groups : String) 12 | Hash(String, GroupHash).from_json(cs_groups) 13 | end 14 | 15 | -------------------------------------------------------------------------------- /src/concourse-summary/giphy.cr: -------------------------------------------------------------------------------- 1 | require "http/client" 2 | require "json" 3 | 4 | CACHE = Hash(String, Array(String)).new 5 | 6 | def giphy(q : String) : Array(String)? 7 | if ENV["GIPHY_API_KEY"]? 8 | return CACHE[q] if CACHE.has_key?(q) 9 | response = HTTP::Client.get "https://api.giphy.com/v1/gifs/search?api_key=#{ENV["GIPHY_API_KEY"]}&limit=100&rating=G&lang=en&q=#{URI.escape(q)}" 10 | data = JSON.parse(response.body) 11 | CACHE[q] = data["data"].as_a.map do |img| 12 | img["images"]["fixed_width"]["url"].as_s 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/concourse-summary/http_client.cr: -------------------------------------------------------------------------------- 1 | require "http/client" 2 | require "openssl/ssl/context" 3 | 4 | ## FIXME - MonkeyPatch Cookies. Concourse does not accept URI.escape'd values 5 | class HTTP::Cookie 6 | def to_cookie_header 7 | "#{@name}=#{value}" 8 | end 9 | end 10 | 11 | class HttpClient 12 | @client : HTTP::Client 13 | 14 | def initialize(host, username, password, login_form = false, team_name : String = "main") 15 | if ENV["SKIP_SSL_VALIDATION"]? 16 | context = OpenSSL::SSL::Context::Client.new 17 | context.verify_mode=(OpenSSL::SSL::VerifyMode::None) 18 | else 19 | context = true 20 | end 21 | @client = HTTP::Client.new(host, tls: context) 22 | 23 | if username.to_s.size > 0 24 | if login_form 25 | @client.basic_auth(username.to_s, password.to_s) 26 | resp = @client.get("/auth/basic/token?team_name=#{team_name}") 27 | if resp.status_code == 404 28 | resp = @client.get("/api/v1/teams/#{team_name}/auth/token") 29 | end 30 | cookie = resp.headers["Set-Cookie"]? 31 | if cookie 32 | cookie_name, cookie_value = cookie.split(";").first.split(/=/,2) 33 | @client = HTTP::Client.new(host, tls: context) 34 | @client.before_request do |request| 35 | request.cookies << HTTP::Cookie.new(cookie_name, cookie_value) 36 | end 37 | end 38 | else 39 | @client.basic_auth(username, password) 40 | end 41 | end 42 | end 43 | 44 | def get(url : String) 45 | begin 46 | @client.get(url) 47 | rescue OpenSSL::SSL::Error 48 | HTTP::Client::Response.new(500, "") 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /src/concourse-summary/job.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "./status" 3 | 4 | class Input 5 | JSON.mapping( 6 | name: String, 7 | resource: String, 8 | ) 9 | end 10 | 11 | class Job 12 | JSON.mapping( 13 | name: String, 14 | groups: Array(String?)?, 15 | paused: Bool?, 16 | next_build: Status?, 17 | finished_build: Status?, 18 | inputs: Array(Input)?, 19 | ) 20 | @broken = Hash(String, Bool).new(false) 21 | setter broken 22 | 23 | def groups 24 | return [nil] if @groups.nil? || @groups.not_nil!.size == 0 25 | @groups.not_nil! 26 | end 27 | 28 | def clear_groups 29 | @groups = [] of String? 30 | end 31 | 32 | def running 33 | !!next_build 34 | end 35 | 36 | def broken 37 | @inputs.try do |inputs| 38 | return inputs.any? { |i| @broken[i.name] || @broken[i.resource] } 39 | end 40 | return false 41 | end 42 | 43 | def status 44 | paused.try do |p| 45 | return "paused_job" if p 46 | end 47 | finished_build.try do |build| 48 | build.status 49 | end 50 | end 51 | 52 | def self.all(client, job_url : String) 53 | response = client.get("/api/v1#{job_url}/jobs") 54 | begin 55 | Array(Job).from_json(response.body) 56 | rescue ex 57 | puts "EXCEPTION: #{job_url}" 58 | [] of Job 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /src/concourse-summary/job_info.cr: -------------------------------------------------------------------------------- 1 | class JobInfo 2 | JSON.mapping( 3 | pipeline: String, 4 | name: String, 5 | groups: Array(String), 6 | url: String, 7 | paused: Bool, 8 | running: Bool, 9 | status: String?, 10 | start_time: Int64?, 11 | run_time: String?, 12 | latest_build_num: String, 13 | ) 14 | def initialize(pipeline : Pipeline, job : Job) 15 | @pipeline = pipeline.name 16 | @name = job.name 17 | @url = pipeline.url 18 | @groups = job.groups.select { |x| x.is_a?(String) }.map { |x| x.as(String) } 19 | @paused = pipeline.paused 20 | @running = !job.next_build.nil? 21 | @status = job.status 22 | job.finished_build.try do |build| 23 | build.start_time.try do |start_time| 24 | @start_time = start_time 25 | build.end_time.try do |end_time| 26 | run_time = end_time - start_time 27 | @run_time = Time::Span.new(0, 0, run_time.to_i64).to_s 28 | end 29 | end 30 | end 31 | job.next_build.try do |build| 32 | build.start_time.try do |start_time| 33 | @start_time = start_time 34 | run_time = Time.now.to_unix - start_time 35 | @run_time = Time::Span.new(0, 0, run_time.to_i64).to_s 36 | end 37 | end 38 | 39 | @latest_build_num = "NA" 40 | job.finished_build.try do |build| 41 | @latest_build_num = build.name 42 | end 43 | job.next_build.try do |build| 44 | @latest_build_num = build.name 45 | end 46 | end 47 | 48 | def start_time_ago_days 49 | start_time.try do |start| 50 | (Time.now - Time.unix(start)).days 51 | end 52 | end 53 | 54 | def latest_build 55 | 56 | end 57 | end 58 | 59 | -------------------------------------------------------------------------------- /src/concourse-summary/json_or_html.cr: -------------------------------------------------------------------------------- 1 | macro json_or_html(obj, view) 2 | if env.request.headers["Accept"] == "application/json" 3 | env.response.headers["Access-Control-Allow-Origin"] = "*" 4 | env.response.content_type = "application/json" 5 | {{obj}}.to_json 6 | else 7 | render "views/#{{{view}}}.ecr", "views/layout.ecr" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /src/concourse-summary/lazy_map.cr: -------------------------------------------------------------------------------- 1 | class Array(T) 2 | def lazy_map(&block : T -> U) forall U 3 | ch = Channel(Tuple(Int32,U)).new(size) 4 | self.each_with_index do |obj,i| 5 | spawn do 6 | ch.send({i, block.call(obj)}) 7 | end 8 | end 9 | Array(Tuple(Int32,U)) 10 | .new(size) { ch.receive } 11 | .sort_by(&.[0]) 12 | .map(&.[1]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/concourse-summary/my_data.cr: -------------------------------------------------------------------------------- 1 | require "http/client" 2 | require "openssl/ssl/context" 3 | 4 | class MyData 5 | @pipeline : String? 6 | @pipeline_url : String? 7 | getter pipeline_url 8 | setter pipeline_url 9 | @group : String? 10 | @running = false 11 | getter running 12 | setter running 13 | @paused = false 14 | getter paused 15 | setter paused 16 | @statuses = Hash(String, Int32).new(0) 17 | @broken_resource : Bool 18 | getter broken_resource 19 | setter broken_resource 20 | 21 | def initialize(@pipeline, @group) 22 | @broken_resource = false 23 | end 24 | 25 | def inc(status : String) 26 | @statuses[status] += 1 27 | end 28 | 29 | def labels 30 | [@pipeline, @group].compact 31 | end 32 | 33 | def href 34 | (@pipeline_url ? "#{@pipeline_url}" : "/pipelines/#{@pipeline}") + (@group ? "?groups=#{@group}" : "") 35 | end 36 | 37 | def percent(status) 38 | return 0 if @statuses.size == 0 39 | (@statuses[status].to_f / @statuses.values.sum * 100).ceil.to_i 40 | end 41 | 42 | def all_green 43 | @statuses.size == 1 && @statuses.has_key?("succeeded") 44 | end 45 | 46 | def self.get_data(host, username, password, pipelines = nil, login_form = false, team_name = "main") 47 | client = HttpClient.new(host, username, password, login_form, team_name) 48 | Pipeline.all(client).select do |pipeline| 49 | pipelines.nil? || pipelines.has_key?(pipeline.name) 50 | end.lazy_map do |pipeline| 51 | client = HttpClient.new(host, username, password, login_form, team_name) 52 | broken = Resource.broken(client, pipeline.url) 53 | 54 | client = HttpClient.new(host, username, password, login_form, team_name) 55 | Job.all(client, pipeline.url).map do |job| 56 | job.broken = broken 57 | {pipeline, job} 58 | end 59 | end.flatten 60 | end 61 | 62 | def self.filter_groups(data, pipelines) 63 | return data unless pipelines 64 | single_nil_array = (Array(String | Nil).new << nil) 65 | data.select do |pipeline, job| 66 | groups = pipelines[pipeline.name] 67 | if groups == nil || job.groups.size == 0 || job.groups == single_nil_array 68 | true 69 | elsif typeof(job.groups) == Array(Nil) 70 | true 71 | else 72 | job.groups = (job.groups & groups).as(Array(String | Nil)) 73 | job.groups.size > 0 && job.groups != single_nil_array 74 | end 75 | end 76 | end 77 | 78 | def self.remove_group_info(data : Array(Tuple(Pipeline, Job))) 79 | data.each do |pipeline, job| 80 | job.clear_groups 81 | end 82 | end 83 | 84 | def self.statuses(data : Array(Tuple(Pipeline, Job))) 85 | hash = Hash(Tuple(String, String | Nil), MyData).new do |_, key| 86 | pipeline_name, group = key 87 | MyData.new(pipeline_name, group) 88 | end 89 | data.each do |pipeline, job| 90 | job.groups.each do |group| 91 | key = {pipeline.name, group} 92 | data = hash[key] 93 | data.paused = pipeline.paused 94 | data.pipeline_url = pipeline.url 95 | data.running ||= job.running 96 | data.broken_resource = job.broken 97 | data.inc(job.status || "pending") 98 | hash[key] = data 99 | end 100 | end 101 | hash.values 102 | end 103 | 104 | def to_json(json : JSON::Builder) 105 | json.object do 106 | json.field "pipeline", @pipeline || nil 107 | json.field "group", @group || nil 108 | json.field "url", href 109 | json.field "running", @running 110 | json.field "paused", @paused 111 | json.field "statuses", @statuses.to_json 112 | end 113 | end 114 | end 115 | 116 | class Array[String?] 117 | def &(other : Nil) 118 | Array(String?).new # Should be [nil] i think 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /src/concourse-summary/pipeline.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "./exceptions" 3 | 4 | class Pipeline 5 | JSON.mapping( 6 | name: String, 7 | team_name: String, 8 | paused: Bool 9 | ) 10 | 11 | property url : String = "" 12 | 13 | def self.all(client) 14 | response = client.get("/api/v1/pipelines") 15 | raise Unauthorized.new if response.status_code == 401 16 | return [] of Pipeline if response.status_code == 500 17 | pipelines = Array(Pipeline).from_json(response.body) 18 | pipelines.each do |pipeline| 19 | pipeline.url = "/teams/#{pipeline.team_name}/pipelines/#{URI.escape(pipeline.name)}" 20 | end 21 | return pipelines 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /src/concourse-summary/resource.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "./status" 3 | 4 | class Resource 5 | JSON.mapping( 6 | name: String, 7 | broken: {type: Bool, default: false, key: "failing_to_check"}, 8 | ) 9 | 10 | def self.broken(client, url : String): Hash(String, Bool) 11 | hash = Hash(String, Bool).new(false) 12 | self.all(client, url).each do |r| 13 | hash[r.name] = r.broken if r.broken 14 | end 15 | hash 16 | end 17 | 18 | def self.all(client, url : String) 19 | response = client.get("/api/v1#{url}/resources") 20 | begin 21 | Array(Resource).from_json(response.body) 22 | rescue ex 23 | puts "EXCEPTION: #{url}" 24 | [] of Resource 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/concourse-summary/status.cr: -------------------------------------------------------------------------------- 1 | class Status 2 | JSON.mapping( 3 | status: String, 4 | name: String, 5 | start_time: Int64?, 6 | end_time: Int64?, 7 | ) 8 | end 9 | -------------------------------------------------------------------------------- /views/group.ecr: -------------------------------------------------------------------------------- 1 | <% Kilt.embed "views/header.ecr" %> 2 | 3 | <% hosts.each do |host,statuses| %> 4 |
5 | <%= host %> 6 |
7 | <% Kilt.embed "views/single_host.ecr" %> 8 |
9 |
10 | <% end %> 11 | -------------------------------------------------------------------------------- /views/header.ecr: -------------------------------------------------------------------------------- 1 |
2 | <%= Time.now %> (<%= refresh_interval %>) 3 |
4 | <% if ignore_groups %> 5 | " title="Show Groups">+ 6 | <% else %> 7 | " title="Collapse Groups">- 8 | <% end %> 9 | 10 |   11 |
12 |
13 | -------------------------------------------------------------------------------- /views/host.ecr: -------------------------------------------------------------------------------- 1 | <% Kilt.embed "views/header.ecr" %> 2 | 3 |
4 | <% Kilt.embed "views/single_host.ecr" %> 5 |
6 | -------------------------------------------------------------------------------- /views/index.ecr: -------------------------------------------------------------------------------- 1 |

Concourse Summary

2 |

Use the URL path to show a summary, eg, `/host/[HOST NAME]`

3 | 4 | <% hosts.each do |host| %> 5 |
6 | <%= host %> 7 |
8 | <% end %> 9 | 10 | <% if groups && groups.size > 0 %> 11 |
Groups
12 | <% groups.each do |group| %> 13 |
14 | <%= group %> 15 |
16 | <% end %> 17 | <% end %> 18 | 19 |

This project can be found on Github

20 | -------------------------------------------------------------------------------- /views/jobs.ecr: -------------------------------------------------------------------------------- 1 | <% Kilt.embed "views/header.ecr" %> 2 | 3 | 18 | -------------------------------------------------------------------------------- /views/layout.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Concourse Summary 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <%= content %> 13 | 14 | 15 | -------------------------------------------------------------------------------- /views/single_host.ecr: -------------------------------------------------------------------------------- 1 | <% statuses.each do |data| %> 2 | "> 3 | <% if giphy_src && data.all_green %> 4 |
5 | <% else %> 6 |
7 | <% total = 0 %> 8 | <% %w(paused_job aborted errored failed succeeded).each do |name| %> 9 | <% perc = data.percent(name) %> 10 | <% if perc > 0 %> 11 |
12 | <% total += perc %> 13 | <% end %> 14 | <% end %> 15 |
16 | <% end %> 17 | <% if data.paused %>
<% end %> 18 | <% if data.broken_resource %>
<% end %> 19 |
20 | <% data.labels.each do |label| %> 21 | <%= label %> 22 | <% end %> 23 |
24 |
25 | <% end %> 26 | --------------------------------------------------------------------------------