├── .gitignore ├── README.md ├── src ├── index.js ├── index.html ├── Msg.elm ├── Main.elm ├── Components │ ├── Help.elm │ ├── Breadcrumbs.elm │ ├── Navbar.elm │ └── LiveSearch.elm ├── Urls.elm ├── Update.elm ├── View.elm ├── Pages │ ├── Jobset.elm │ └── Project.elm ├── Utils.elm └── Models.elm ├── default.nix ├── package.json ├── elm-package.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | elm-stuff/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Superseeded by https://github.com/hercules-ci/hercules 2 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Require index.html so it gets copied to dist 4 | require('./index.html'); 5 | 6 | var app = require('./Main.elm').Main.fullscreen(); 7 | 8 | app.ports.title.subscribe(function(title) { 9 | document.title = "Hydra - " + title; 10 | }); 11 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Msg.elm: -------------------------------------------------------------------------------- 1 | module Msg exposing (..) 2 | 3 | import Material 4 | import Http 5 | 6 | import Components.LiveSearch as LiveSearch 7 | import Urls exposing (Page) 8 | 9 | 10 | type LoginType 11 | = Hydra 12 | | Google 13 | 14 | 15 | type Msg 16 | = Mdl (Material.Msg Msg) 17 | | FetchSucceed String 18 | | FetchFail Http.Error 19 | | LoginUserClick LoginType 20 | | LogoutUserClick 21 | | PreferencesClick 22 | | LiveSearchMsg LiveSearch.Msg 23 | | NewPage Page 24 | | ClickCreateProject 25 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { backend ? (import ./../backend {})}: 2 | 3 | with import (fetchTarball https://github.com/NixOS/nixpkgs/archive/e725c927d4a09ee116fe18f2f0718364678a321f.tar.gz) {}; 4 | 5 | stdenv.mkDerivation { 6 | name = "hydra-frontend"; 7 | 8 | src = ./.; 9 | 10 | buildInputs = [ elmPackages.elm nodejs ]; 11 | 12 | patchPhase = '' 13 | patchShebangs node_modules/webpack 14 | ''; 15 | 16 | buildPhase = '' 17 | ${backend}/bin/gen-elm src 18 | 19 | # https://github.com/NixHercules/hercules/issues/3 20 | sed -i "s@'@@g" src/Hercules.elm 21 | 22 | npm run build 23 | ''; 24 | 25 | installPhase = '' 26 | mkdir $out 27 | cp dist/* $out/ 28 | ''; 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "description": "Hydra frontend", 5 | "main": "index.js", 6 | "dependencies": { 7 | "elm-hot-loader": "^0.3.2", 8 | "elm-webpack-loader": "3.0.0", 9 | "file-loader": "^0.8.5", 10 | "webpack": "^1.13.0", 11 | "webpack-dev-middleware": "^1.6.1", 12 | "webpack-dev-server": "^1.14.1" 13 | }, 14 | "devDependencies": {}, 15 | "scripts": { 16 | "test": "echo \"Error: no test specified\" && exit 1", 17 | "build": "webpack --optimize-minimize --bail", 18 | "watch": "webpack --watch", 19 | "dev": "webpack-dev-server --watch --hot --port 3000" 20 | }, 21 | "author": "Domen Kozar", 22 | "license": "ISC" 23 | } 24 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "helpful summary of your project, less than 80 characters", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "src" 8 | ], 9 | "exposed-modules": [], 10 | "dependencies": { 11 | "NoRedInk/elm-decode-pipeline": "2.0.0 <= v < 3.0.0", 12 | "debois/elm-mdl": "7.4.0 <= v < 8.0.0", 13 | "elm-lang/core": "4.0.0 <= v < 5.0.0", 14 | "elm-lang/html": "1.0.0 <= v < 2.0.0", 15 | "elm-lang/navigation": "1.0.0 <= v < 2.0.0", 16 | "evancz/elm-http": "3.0.1 <= v < 4.0.0", 17 | "evancz/url-parser": "1.0.0 <= v < 2.0.0" 18 | }, 19 | "elm-version": "0.17.0 <= v < 0.18.0" 20 | } 21 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"), 2 | webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: { 6 | app: [ 7 | './src/index.js' 8 | ] 9 | }, 10 | 11 | output: { 12 | path: path.resolve(__dirname + '/dist'), 13 | filename: 'app.js', 14 | }, 15 | 16 | module: { 17 | loaders: [ 18 | { 19 | test: /\.html$/, 20 | include: /src/, 21 | loader: 'file?name=[name].[ext]', 22 | }, 23 | { 24 | test: /\.elm$/, 25 | include: /src/, 26 | loader: 'elm-hot!elm-webpack?warn=true', 27 | } 28 | ], 29 | 30 | noParse: /\.elm$/, 31 | }, 32 | 33 | devServer: { 34 | inline: true, 35 | stats: 'errors-only', 36 | historyApiFallback: true 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (..) 2 | 3 | import Http 4 | import Task 5 | import Json.Decode as Json 6 | import Json.Decode exposing ((:=)) 7 | import Material 8 | import Navigation 9 | 10 | import Msg exposing (..) 11 | import Models exposing (..) 12 | import Update exposing (..) 13 | import View exposing (..) 14 | import Urls exposing (..) 15 | 16 | 17 | init : Result String Page -> ( AppModel, Cmd Msg ) 18 | init result = 19 | let 20 | ( model, cmds ) = 21 | urlUpdate result initialModel 22 | in 23 | model ! [ doInit, Material.init Mdl, cmds ] 24 | 25 | 26 | doInit : Cmd Msg 27 | doInit = 28 | Task.perform FetchFail FetchSucceed (Http.get decodeInit "/api/init") 29 | 30 | 31 | decodeInit : Json.Decoder (String) 32 | decodeInit = 33 | Json.succeed "a" 34 | 35 | 36 | 37 | -- Json.list (Json.succeed Event 38 | -- |: ("id" := Json.int) 39 | -- |: ("title" := Json.string) 40 | -- ) 41 | 42 | 43 | subscriptions : AppModel -> Sub Msg 44 | subscriptions model = 45 | Sub.none 46 | 47 | 48 | main : Program Never 49 | main = 50 | Navigation.program (Navigation.makeParser urlParser) 51 | { init = init 52 | , update = update 53 | , view = view 54 | , urlUpdate = urlUpdate 55 | , subscriptions = Material.subscriptions Mdl 56 | } 57 | -------------------------------------------------------------------------------- /src/Components/Help.elm: -------------------------------------------------------------------------------- 1 | module Components.Help exposing (..) 2 | 3 | import Html exposing (..) 4 | import Material.Icon as Icon 5 | import Material.Options as Options 6 | import Material.Tooltip as Tooltip 7 | 8 | import Msg exposing (..) 9 | import Models exposing (..) 10 | 11 | 12 | {-| Uses ports to communicate with jQuery and initialize twitter 13 | bootstrap popover plugin. Each help element is a questionmark 14 | icon which on popover shows some help text. 15 | -} 16 | popoverHelp : AppModel -> List (Html Msg) -> Html Msg 17 | popoverHelp model html = 18 | span 19 | [] 20 | [ Icon.view "help" 21 | [ Options.css "margin" "0 6px" 22 | , Options.css "color" "#0088CC" 23 | , Options.css "cursor" "help" 24 | , Tooltip.attach Mdl [3] 25 | ] 26 | , Tooltip.render Mdl [3] model.mdl 27 | [ Tooltip.large ] 28 | html 29 | ] 30 | 31 | projectHelp : AppModel -> Html Msg 32 | projectHelp model = 33 | popoverHelp model [ text "TODO" ] 34 | 35 | jobsetHelp : AppModel -> Html Msg 36 | jobsetHelp model = 37 | popoverHelp 38 | model 39 | [ text "Jobsets evaluate a Nix expression and provide an overview of successful/failed builds." ] 40 | 41 | 42 | evaluationHelp : AppModel -> Html Msg 43 | evaluationHelp model = 44 | popoverHelp model [ text "" ] 45 | 46 | 47 | buildHelp : AppModel -> Html Msg 48 | buildHelp model = 49 | popoverHelp model [ text "" ] 50 | 51 | 52 | buildStepHelp : AppModel -> Html Msg 53 | buildStepHelp model = 54 | popoverHelp model [ text ""] 55 | -------------------------------------------------------------------------------- /src/Urls.elm: -------------------------------------------------------------------------------- 1 | module Urls exposing (..) 2 | 3 | import Debug 4 | import Navigation 5 | import String 6 | import UrlParser exposing (Parser, (), format, int, oneOf, s, string) 7 | 8 | 9 | {-| Main type representing current url/page 10 | -} 11 | type Page 12 | = Home 13 | | Project String 14 | | NewProject 15 | | Jobset String String 16 | 17 | 18 | urlParser : Navigation.Location -> Result String Page 19 | urlParser location = 20 | String.dropLeft 1 location.pathname 21 | |> Debug.log "pathname" 22 | |> UrlParser.parse identity pageParser 23 | 24 | 25 | pageParser : Parser (Page -> a) a 26 | pageParser = 27 | oneOf 28 | [ format Home (s "") 29 | , format Project (s "project" string) 30 | , format NewProject (s "create-project") 31 | , format Jobset (s "jobset" string string) 32 | ] 33 | 34 | 35 | pageToURL : Page -> String 36 | pageToURL page = 37 | case page of 38 | Home -> 39 | "/" 40 | 41 | Project name -> 42 | "/project/" ++ name 43 | 44 | NewProject -> 45 | "/create-project" 46 | 47 | Jobset project jobset -> 48 | "/jobset/" ++ project ++ "/" ++ jobset 49 | 50 | 51 | pageToTitle : Page -> String 52 | pageToTitle page = 53 | case page of 54 | Home -> 55 | "Projects" 56 | 57 | Project name -> 58 | "Project " ++ name 59 | 60 | NewProject -> 61 | "New Project" 62 | 63 | Jobset project jobset -> 64 | "Jobset " ++ jobset ++ " of project " ++ project 65 | -------------------------------------------------------------------------------- /src/Components/Breadcrumbs.elm: -------------------------------------------------------------------------------- 1 | module Components.Breadcrumbs exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | import List 6 | import Material.Icon as Icon 7 | import Material.Options as Options 8 | 9 | import Msg exposing (..) 10 | import Models exposing (..) 11 | import Urls as Urls exposing (..) 12 | import Utils exposing (onClickPage) 13 | 14 | 15 | type alias Breadcrumb = 16 | { name : String 17 | , page : Maybe Page 18 | } 19 | 20 | 21 | renderBreadcrumbs : List Breadcrumb -> List (Html Msg) 22 | renderBreadcrumbs breadcrumbs = 23 | let 24 | home = a (onClickPage Home) [ text "Hydra"] 25 | render breadcrumb = 26 | case breadcrumb.page of 27 | Just page -> 28 | a (onClickPage page) 29 | [ text breadcrumb.name ] 30 | 31 | Nothing -> 32 | span [ class "active" ] 33 | [ text breadcrumb.name ] 34 | in List.intersperse 35 | (Icon.view "keyboard_arrow_right" 36 | [ Icon.size36 37 | , Options.css "top" "10px" 38 | , Options.css "position" "relative" 39 | ]) 40 | (home :: List.map render breadcrumbs) 41 | 42 | 43 | breadCrumbs : AppModel -> List (Html Msg) 44 | breadCrumbs model = 45 | let 46 | breadcrumbs = 47 | case model.currentPage of 48 | Home -> 49 | [] 50 | 51 | NewProject -> 52 | [ Breadcrumb "New Project" Nothing ] 53 | 54 | Project project -> 55 | [ Breadcrumb project Nothing ] 56 | 57 | Jobset project jobset -> 58 | [ Breadcrumb project (Just (Urls.Project project)) 59 | , Breadcrumb jobset Nothing 60 | ] 61 | in renderBreadcrumbs breadcrumbs 62 | -------------------------------------------------------------------------------- /src/Update.elm: -------------------------------------------------------------------------------- 1 | port module Update exposing (..) 2 | 3 | import Material 4 | import Navigation 5 | import Models exposing (..) 6 | import Msg exposing (..) 7 | import Components.LiveSearch as LiveSearch 8 | import Urls exposing (..) 9 | 10 | 11 | update : Msg -> AppModel -> ( AppModel, Cmd Msg ) 12 | update msg model = 13 | case msg of 14 | Mdl msg' -> 15 | Material.update msg' model 16 | 17 | FetchSucceed init -> 18 | ( model, Cmd.none ) 19 | 20 | FetchFail msg -> 21 | ( model, Cmd.none ) 22 | 23 | LoginUserClick loginType -> 24 | let 25 | -- TODO: well, actually do the login proceedure 26 | user = 27 | { id = "domenkozar" 28 | , name = "Domen Kožar" 29 | , email = "domen@dev.si" 30 | , roles = [] 31 | , recieveEvaluationErrors = False 32 | } 33 | in 34 | case loginType of 35 | Hydra -> 36 | ( { model | user = Just user }, Cmd.none ) 37 | 38 | Google -> 39 | ( { model | user = Just user }, Cmd.none ) 40 | 41 | LogoutUserClick -> 42 | -- TODO: well, should we cleanup something? 43 | ( { model | user = Nothing }, Cmd.none ) 44 | 45 | PreferencesClick -> 46 | ( model, Cmd.none ) 47 | 48 | LiveSearchMsg searchmsg -> 49 | let 50 | ( newmodel, cmds ) = 51 | LiveSearch.update searchmsg model 52 | in 53 | ( newmodel, Cmd.map LiveSearchMsg cmds ) 54 | 55 | NewPage page -> 56 | ( model, Navigation.newUrl (pageToURL page) ) 57 | 58 | ClickCreateProject -> 59 | -- TODO: http 60 | ( model, Cmd.none ) 61 | 62 | 63 | urlUpdate : Result String Page -> AppModel -> ( AppModel, Cmd b ) 64 | urlUpdate result model = 65 | case result of 66 | Err msg -> 67 | let 68 | msg = 69 | (Debug.log "urlUpdate:" msg) 70 | 71 | alert = 72 | { kind = Danger 73 | , msg = "Given URL returned 404." 74 | } 75 | in 76 | { model | alert = Just alert } ! [] 77 | 78 | Ok page -> 79 | ( { model 80 | | currentPage = page 81 | , alert = Nothing 82 | } 83 | , title (pageToTitle page) 84 | ) 85 | 86 | 87 | 88 | -- Ports 89 | 90 | 91 | port title : String -> Cmd msg 92 | -------------------------------------------------------------------------------- /src/View.elm: -------------------------------------------------------------------------------- 1 | module View exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | import Maybe 6 | import List 7 | import Material.Scheme 8 | import Material.Color as Color 9 | import Material.Layout as Layout 10 | import Material.Options as Options 11 | import Material.Footer as Footer 12 | 13 | import Components.Navbar as Navbar 14 | import Pages.Project exposing (..) 15 | import Pages.Jobset as Jobset exposing (..) 16 | import Msg exposing (..) 17 | import Models exposing (..) 18 | import Utils exposing (..) 19 | import Urls exposing (..) 20 | 21 | 22 | view : AppModel -> Html Msg 23 | view model = 24 | Options.div 25 | [ ] 26 | [ Material.Scheme.topWithScheme Color.BlueGrey Color.LightBlue <| 27 | Layout.render Mdl model.mdl 28 | [ Layout.fixedHeader ] 29 | { header = Navbar.view model 30 | , drawer = [] 31 | , tabs = ( Navbar.tabs model, [ Color.background (Color.color Color.LightBlue Color.A700) ] ) 32 | , main = viewBody model 33 | } 34 | ] 35 | 36 | 37 | viewBody : AppModel -> List (Html Msg) 38 | viewBody model = 39 | let 40 | softwareLink name url version = 41 | p [] 42 | [ a [ href url ] 43 | [ text name ] 44 | , span [] 45 | [ text " @ " 46 | , text version ] 47 | ] 48 | in Options.div 49 | [ Options.css "margin" "30px" 50 | , Options.css "min-height" "100%" 51 | ] 52 | (pageToView model) 53 | :: [ Footer.mini [ Options.css "position" "absolute" 54 | , Options.css "bottom" "-70px" 55 | , Options.css "width" "100%"] 56 | { left = Footer.left [] 57 | [ Footer.logo 58 | [] 59 | [] 60 | ] 61 | , right = Footer.right [] 62 | [ Footer.logo 63 | [] 64 | [ Footer.html <| softwareLink "Nix" "http://nixos.org/nix/" model.hydraConfig.nixVersion 65 | , Footer.html <| softwareLink "Hydra" "http://nixos.org/hydra/" model.hydraConfig.hydraVersion 66 | ] 67 | ] 68 | } 69 | ] 70 | 71 | 72 | pageToView : AppModel -> List (Html Msg) 73 | pageToView model = 74 | case model.currentPage of 75 | Home -> 76 | Pages.Project.view model model.currentPage 77 | 78 | Project name -> 79 | Pages.Project.view model model.currentPage 80 | 81 | NewProject -> 82 | Pages.Project.view model model.currentPage 83 | 84 | Jobset projectName jobsetName -> 85 | case List.head (List.filter (\p -> p.name == projectName) model.projects) of 86 | Just project -> 87 | case List.head (List.filter (\j -> j.name == jobsetName) project.jobsets) of 88 | Just jobset -> 89 | Jobset.view model 90 | 91 | Nothing -> 92 | render404 ("Jobset " ++ jobsetName ++ " does not exist.") 93 | 94 | Nothing -> 95 | render404 ("Project " ++ projectName ++ " does not exist.") 96 | -------------------------------------------------------------------------------- /src/Components/Navbar.elm: -------------------------------------------------------------------------------- 1 | module Components.Navbar exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.App as App 5 | import Html.Attributes exposing (..) 6 | import Maybe 7 | import Material.Layout as Layout 8 | import Material.Icon as Icon 9 | import Material.Menu as Menu 10 | import Material.Options as Options 11 | 12 | import Msg exposing (..) 13 | import Models exposing (AppModel) 14 | import Components.Breadcrumbs exposing (breadCrumbs) 15 | import Components.LiveSearch as LiveSearch 16 | import Urls exposing (..) 17 | import Utils exposing (..) 18 | 19 | 20 | tabs : AppModel -> List (Html Msg) 21 | tabs model = 22 | [ span [] [ whiteBadge [] [ text (toString model.queueStats.numBuilding)], text " in progress"] 23 | , span [] [ whiteBadge [] [ text (toString model.queueStats.numWaiting)], text " in queue" ] 24 | , span [] [ whiteBadge [] [ text (toString model.queueStats.numMachines) ], text " machines" ] 25 | , text "evaluations" 26 | , text "builds" 27 | , text "steps" 28 | ] 29 | 30 | view : AppModel -> List (Html Msg) 31 | view model = 32 | let 33 | menuItems = 34 | case model.user of 35 | Nothing -> 36 | [ Menu.item 37 | [ Menu.onSelect <| LoginUserClick Google ] 38 | [ menuIcon "input" 39 | , text "Sign in with Google" ] 40 | , Menu.item 41 | [ Menu.onSelect <| LoginUserClick Hydra ] 42 | [ menuIcon "input" 43 | , text "Sign in with a password" ] 44 | ] 45 | 46 | Just user -> 47 | [ Menu.item 48 | [ Menu.onSelect <| PreferencesClick ] 49 | [ menuIcon "settings" 50 | , text "Preferences" ] 51 | , Menu.item 52 | [ Menu.onSelect <| LogoutUserClick ] 53 | [ Icon.view "power_settings_new" [ Options.css "width" "40px" 54 | , Options.css "color" "red" ] 55 | , text "Sign out" ] 56 | ] 57 | 58 | in [ Layout.row [] 59 | [ Layout.title 60 | [ ] 61 | ([ if model.hydraConfig.logo == "" then 62 | text "" 63 | else 64 | img 65 | ([ src model.hydraConfig.logo 66 | , alt "Hydra Logo" 67 | , class "logo" 68 | , style [ ( "height", "37px" ), ( "margin", "5px" ) ] 69 | ] ++ (onClickPage Home)) 70 | [] 71 | ] ++ (breadCrumbs model)) 72 | , Layout.spacer 73 | , Layout.navigation [] 74 | [ App.map LiveSearchMsg (LiveSearch.view model) 75 | , span [] (Maybe.withDefault [] (Maybe.map (\user -> [ text user.name ]) model.user)) 76 | , Menu.render Mdl [1] model.mdl 77 | [ Menu.ripple 78 | , Menu.bottomRight 79 | , Menu.icon "account_circle" ] 80 | menuItems 81 | ] 82 | ] 83 | ] 84 | -------------------------------------------------------------------------------- /src/Pages/Jobset.elm: -------------------------------------------------------------------------------- 1 | module Pages.Jobset exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | import Material.Menu as Menu 6 | import Material.List as List 7 | import Material.Options as Options 8 | import Material.Tabs as Tabs 9 | import Material.Table as Table 10 | 11 | import Models exposing (..) 12 | import Msg exposing (..) 13 | import Utils exposing (..) 14 | import Urls as Urls exposing(..) 15 | 16 | 17 | view : AppModel -> List (Html Msg) 18 | view model = 19 | case model.jobsetPage of 20 | Err _ -> [p [] [ text "TODO"]] 21 | Ok jobset -> 22 | renderHeader model "Jobset" (Just jobset.name) Nothing 23 | ++ 24 | [ Tabs.render Mdl [4] model.mdl 25 | [ Tabs.ripple 26 | --, Tabs.onSelectTab SelectTab 27 | --, Tabs.activeTab model.tab 28 | ] 29 | [ Tabs.label 30 | [ Options.center ] 31 | [ Options.span [ Options.css "width" "4px" ] [] 32 | , text "Evaluations" 33 | ] 34 | , Tabs.label 35 | [ Options.center ] 36 | [ Options.span [ Options.css "width" "4px" ] [] 37 | , text "Jobs" 38 | ] 39 | , Tabs.label 40 | [ Options.center ] 41 | [ Options.span [ Options.css "width" "4px" ] [] 42 | , text "Channels" 43 | ] 44 | ] 45 | (if List.isEmpty [ 1 ] 46 | then render404 "No evaluations yet." 47 | else [ List.ul [] 48 | [ List.li [ List.withSubtitle ] 49 | [ List.content [] 50 | [ text "Lastest check" 51 | , List.subtitle [] [ text "2016-08-06 12:38:01" ] 52 | ] 53 | ] 54 | , List.li [ List.withSubtitle ] 55 | [ List.content [] 56 | [ text "Lastest evaluation" 57 | , List.subtitle [] [ a 58 | [ href "TODO" ] 59 | [ text "2016-08-06 17:45:55" ] 60 | ] 61 | ] 62 | ] 63 | , List.li [ List.withSubtitle ] 64 | [ List.content [] 65 | [ text "Lastest finished evaluation" 66 | , List.subtitle [] [ a 67 | [ href "TODO" ] 68 | [ text "2016-08-06 17:45:55" ] ] 69 | ] 70 | ] 71 | ] 72 | , Table.table [ Options.css "width" "100%" ] 73 | [ Table.thead [] 74 | [ Table.tr [] 75 | [ Table.th [] [ text "#"] 76 | , Table.th [ ] [ text "Input chages" ] 77 | , Table.th [ ] [ text "Job status" ] 78 | , Table.th [ ] [ text "Time" ] 79 | ] 80 | ] 81 | , Table.tbody [] 82 | (jobset.evaluations |> List.map (\evaluation -> 83 | Table.tr [] 84 | [ Table.td [] [ a 85 | (onClickPage (Urls.Jobset "123" "foo")) 86 | [ text "123" ] ] 87 | , Table.td [] [ text "snabbBsrc → e1fdc74" ] 88 | , Table.td [] (statusLabels 145 62 23) 89 | , Table.td [] [ text "2016-08-05 13:43:40" ] 90 | ] 91 | ) 92 | ) 93 | ] 94 | ] 95 | ) 96 | ] 97 | -------------------------------------------------------------------------------- /src/Utils.elm: -------------------------------------------------------------------------------- 1 | module Utils exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | import Html.Events exposing (..) 6 | import Json.Decode as Json 7 | import Material.Elevation as Elevation 8 | import Material.Button as Button 9 | import Material.Color as Color 10 | import Material.Icon as Icon 11 | import Material.Options as Options 12 | import Msg exposing (..) 13 | import Urls exposing (..) 14 | import Models exposing (..) 15 | 16 | 17 | menuIcon : String -> Html Msg 18 | menuIcon name = 19 | Icon.view name [ Options.css "width" "40px" ] 20 | 21 | 22 | onPreventDefaultClick : msg -> Attribute msg 23 | onPreventDefaultClick message = 24 | onWithOptions "click" { defaultOptions | preventDefault = True } (Json.succeed message) 25 | 26 | 27 | onClickPage : Page -> List (Attribute Msg) 28 | onClickPage page = 29 | [ style [("pointer", "cursor")] 30 | , href (pageToURL page) 31 | , onPreventDefaultClick (NewPage page) 32 | ] 33 | 34 | 35 | optionalTag : Bool -> Html Msg -> Html Msg 36 | optionalTag doInclude html = 37 | if doInclude then 38 | html 39 | else 40 | text "" 41 | 42 | 43 | statusLabels : Int -> Int -> Int -> List (Html Msg) 44 | statusLabels succeeded failed queued = 45 | [ optionalTag (succeeded > 0) 46 | (badge 47 | (Color.color Color.Green Color.S500) 48 | [ Options.attribute <| title "Jobs succeeded" ] 49 | [ text (toString succeeded) ] 50 | ) 51 | , optionalTag (failed > 0) 52 | (badge 53 | (Color.color Color.Red Color.S500) 54 | [ Options.attribute <| title "Jobs failed" ] 55 | [ text (toString failed) ] 56 | ) 57 | , optionalTag (queued > 0) 58 | (badge 59 | (Color.color Color.Grey Color.S500) 60 | [ Options.attribute <| title "Jobs in queue" ] 61 | [ text (toString queued) ] 62 | ) 63 | ] 64 | 65 | 66 | badge : Color.Color -> List (Options.Property c Msg) -> List (Html Msg) -> Html Msg 67 | badge color properties content = 68 | Options.span 69 | ([ Options.css "border-radius" "9px" 70 | , Options.css "padding" "3px 5px" 71 | , Options.css "line-height" "14px" 72 | , Options.css "white-space" "nowrap" 73 | , Options.css "font-weight" "bold" 74 | , Options.css "font-size" "12px" 75 | , Options.css "margin" "0 3px" 76 | , Options.css "cursor" "help" 77 | , Options.css "color" (if color == Color.white then "#000" else "#FFF") 78 | , Color.background color 79 | ] ++ properties) 80 | content 81 | 82 | 83 | whiteBadge : List (Options.Property c Msg) -> List (Html Msg) -> Html Msg 84 | whiteBadge properties content = 85 | badge Color.white properties content 86 | 87 | 88 | render404 : String -> List (Html Msg) 89 | render404 reason = 90 | [ Options.div 91 | [ Elevation.e2 92 | , Options.css "padding" "40px" 93 | , Options.center 94 | ] 95 | [ text reason ] 96 | ] 97 | 98 | 99 | renderHeader : AppModel -> String -> Maybe String -> Maybe Page -> List (Html Msg) 100 | renderHeader model name subname page = 101 | let 102 | subnameHtml = case subname of 103 | Nothing -> [] 104 | Just s -> [ small [ style [ ( "margin-left", "10px" ) ]] 105 | [ text s ]] 106 | pageHtml = case page of 107 | Nothing -> [] 108 | Just p -> [ 109 | Button.render Mdl [2] model.mdl 110 | [ Button.fab 111 | , Button.colored 112 | , Button.onClick (NewPage p) 113 | , Options.css "margin-left" "20px" 114 | ] 115 | [ Icon.i "add"] 116 | ] 117 | in 118 | [ h1 119 | [ style [ ( "margin-bottom", "30px" ) ] ] 120 | ([ text name ] ++ subnameHtml ++ pageHtml) 121 | ] 122 | -------------------------------------------------------------------------------- /src/Components/LiveSearch.elm: -------------------------------------------------------------------------------- 1 | module Components.LiveSearch exposing (update, view, search, Msg) 2 | 3 | import Html exposing (..) 4 | import Html.Events exposing (..) 5 | import Json.Decode as Json 6 | import String 7 | import Material 8 | import Material.Textfield as Textfield 9 | import Material.Color as Color 10 | import Material.Icon as Icon 11 | import Material.Options as Options 12 | 13 | import Models exposing (..) 14 | 15 | 16 | type Msg 17 | = SearchInput String 18 | | SearchEscape 19 | | Mdl (Material.Msg Msg) 20 | 21 | 22 | compareCaseInsensitve : String -> String -> Bool 23 | compareCaseInsensitve s1 s2 = 24 | String.contains (String.toLower s1) (String.toLower s2) 25 | 26 | 27 | {-| Filter project by Project name or Jobset name 28 | -} 29 | searchProject : String -> Project -> Project 30 | searchProject searchstring project = 31 | let 32 | projectFilteredJobsets = 33 | { project | jobsets = List.map (filterByName searchstring) project.jobsets } 34 | 35 | hasJobsets = 36 | List.any (\j -> j.isShown) projectFilteredJobsets.jobsets 37 | 38 | newproject = 39 | filterByName searchstring project 40 | in 41 | if 42 | newproject.isShown 43 | -- if project matches, display all jobsets 44 | then 45 | { newproject | jobsets = List.map (\j -> { j | isShown = True }) newproject.jobsets } 46 | else if 47 | hasJobsets 48 | -- if project doesn't match, only display if any of jobsets match 49 | then 50 | { projectFilteredJobsets | isShown = True } 51 | else 52 | newproject 53 | 54 | 55 | filterByName : String -> { b | name : String, isShown : Bool } -> { b | name : String, isShown : Bool } 56 | filterByName searchstring project = 57 | if compareCaseInsensitve searchstring project.name then 58 | { project | isShown = True } 59 | else 60 | { project | isShown = False } 61 | 62 | 63 | {-| Filter any record by isShown field 64 | 65 | TODO: recursively apply all lists in the structure 66 | -} 67 | search : List { a | isShown : Bool } -> List { a | isShown : Bool } 68 | search projects = 69 | List.filter (\x -> x.isShown) projects 70 | 71 | 72 | update : Msg -> AppModel -> ( AppModel, Cmd Msg ) 73 | update msg model = 74 | case msg of 75 | SearchInput searchstring -> 76 | let 77 | newprojects = 78 | List.map (searchProject searchstring) model.projects 79 | in 80 | ( { model 81 | | projects = newprojects 82 | , searchString = searchstring 83 | } 84 | , Cmd.none 85 | ) 86 | 87 | -- on Escape, clear search bar and return all projects/jobsets 88 | SearchEscape -> 89 | ( { model 90 | | searchString = "" 91 | , projects = List.map (searchProject "") model.projects 92 | } 93 | , Cmd.none 94 | ) 95 | Mdl msg' -> 96 | Material.update msg' model 97 | 98 | 99 | view : AppModel -> Html Msg 100 | view model = 101 | span 102 | [] 103 | [ Textfield.render Mdl [0] model.mdl 104 | [ Textfield.label "Search" 105 | , Textfield.floatingLabel 106 | , Textfield.text' 107 | , Textfield.onInput SearchInput 108 | , onEscape SearchEscape 109 | , Textfield.value model.searchString 110 | , Textfield.style [ Options.css "border-radius" "0.5em" 111 | , Color.background Color.primaryDark ] 112 | ] 113 | , Icon.view 114 | "search" 115 | [ Icon.onClick (SearchInput model.searchString) -- TODO: trigger a proper search page 116 | , Options.css "position" "relative" 117 | , Options.css "top" "8px" 118 | , Options.css "right" "28px" 119 | , Options.css "z-index" "100" 120 | , Options.css "cursor" "pointer" 121 | ] 122 | ] 123 | 124 | onEscape : msg -> Textfield.Property msg 125 | onEscape msg = 126 | Textfield.on "keydown" (Json.map (always msg) (Json.customDecoder keyCode isEscape)) 127 | 128 | 129 | isEscape : Int -> Result String () 130 | isEscape code = 131 | case code of 132 | 27 -> 133 | Ok () 134 | 135 | _ -> 136 | Err "not the right key code" 137 | -------------------------------------------------------------------------------- /src/Models.elm: -------------------------------------------------------------------------------- 1 | module Models exposing (..) 2 | 3 | import Material 4 | import Date 5 | 6 | import Urls exposing (..) 7 | 8 | 9 | type AlertType 10 | = Danger 11 | | Info 12 | | Warning 13 | | Success 14 | 15 | 16 | type alias Alert = 17 | { kind : AlertType 18 | , msg : String 19 | } 20 | 21 | 22 | type alias User = 23 | { id : String 24 | , name : String 25 | , email : String 26 | , roles : List String 27 | , recieveEvaluationErrors : Bool 28 | } 29 | 30 | 31 | type alias Jobset = 32 | { id : String 33 | , name : String 34 | , description : String 35 | , queued : Int 36 | , failed : Int 37 | , succeeded : Int 38 | , lastEvaluation : String 39 | , isShown : Bool 40 | } 41 | 42 | 43 | type alias Project = 44 | { id : String 45 | , name : String 46 | , description : String 47 | , jobsets : List Jobset 48 | , isShown : Bool 49 | } 50 | 51 | 52 | type alias HydraConfig = 53 | { logo : String 54 | , hydraVersion : String 55 | , nixVersion : String 56 | } 57 | 58 | 59 | type alias QueueStats = 60 | { numBuilding : Int 61 | , numWaiting : Int 62 | , numMachines : Int 63 | } 64 | 65 | type alias JobSummary = 66 | { succeeded : Int 67 | , failed : Int 68 | , inQueue : Int 69 | } 70 | 71 | type alias Evaluation = 72 | { id : Int 73 | , inputChanges : String 74 | , jobSummary : JobSummary 75 | , evaluatedAt : Result String Date.Date 76 | } 77 | 78 | type alias JobsetPage = 79 | { latestCheckTime : Result String Date.Date 80 | , latestEvaluationTime : Result String Date.Date 81 | , latestFinishedEvaluationTime : Result String Date.Date 82 | , evaluations : List Evaluation 83 | , name : String 84 | } 85 | 86 | type AjaxError msg = 87 | AjaxFail msg | 88 | Loading 89 | 90 | type alias AppModel = 91 | { alert : Maybe Alert 92 | , hydraConfig : HydraConfig 93 | , projects : List Project 94 | , jobsets : Result AjaxError (List Jobset) 95 | , jobsetPage : Result AjaxError JobsetPage 96 | , user : Maybe User 97 | , mdl : Material.Model 98 | , queueStats : QueueStats 99 | , searchString : String 100 | , currentPage : Page 101 | } 102 | 103 | 104 | initialModel : AppModel 105 | initialModel = 106 | let 107 | jobsets = [ { id = "release-16.03" 108 | , name = "release-16.03" 109 | , description = "NixOS 16.03 release branch" 110 | , queued = 5 111 | , failed = 275 112 | , succeeded = 24315 113 | , lastEvaluation = "2016-05-21 13:57:13" 114 | , isShown = True 115 | } 116 | , { id = "trunk-combined" 117 | , name = "trunk-combined" 118 | , description = "Combined NixOS/Nixpkgs unstable" 119 | , queued = 1 120 | , failed = 406 121 | , succeeded = 24243 122 | , lastEvaluation = "2016-05-21 13:57:03" 123 | , isShown = True 124 | } 125 | ] 126 | in 127 | { alert = Nothing 128 | , user = Nothing 129 | , mdl = Material.model 130 | , currentPage = Home 131 | , searchString = "" 132 | , hydraConfig = 133 | -- TODO: downsize logo, serve it with webpack 134 | { logo = "http://nixos.org/logo/nixos-logo-only-hires.png" 135 | , hydraVersion = "0.1.1234.abcdef" 136 | , nixVersion = "1.12pre1234_abcdef" 137 | } 138 | , queueStats = QueueStats 124 32345 19 139 | -- Pages 140 | , jobsetPage = Ok 141 | { latestCheckTime = Date.fromString "2016-08-06 12:38:01" 142 | , latestEvaluationTime = Date.fromString "2016-08-06 17:45:55" 143 | , latestFinishedEvaluationTime = Date.fromString "2016-08-06 17:45:55" 144 | , name = "Hardcodedfoobar" 145 | , evaluations = 146 | [ { id = 123 147 | , inputChanges = "snabbBsrc → e1fdc74" 148 | , jobSummary = { succeeded = 145, failed = 62, inQueue = 23 } 149 | , evaluatedAt = Date.fromString "2016-08-05 13:43:40" 150 | } 151 | 152 | ] 153 | } 154 | , jobsets = Ok [] 155 | , projects = 156 | [ { id = "nixos" 157 | , name = "NixOS" 158 | , description = "the purely functional Linux distribution" 159 | , isShown = True 160 | , jobsets = jobsets 161 | } 162 | , { id = "nix" 163 | , name = "Nix" 164 | , description = "the purely functional package manager" 165 | , isShown = True 166 | , jobsets = 167 | [ { id = "master" 168 | , name = "master" 169 | , description = "Master branch" 170 | , queued = 0 171 | , failed = 33 172 | , succeeded = 1 173 | , isShown = True 174 | , lastEvaluation = "2016-05-21 13:57:13" 175 | } 176 | ] 177 | } 178 | , { id = "nixpkgs" 179 | , name = "Nixpkgs" 180 | , description = "Nix Packages collection" 181 | , isShown = True 182 | , jobsets = 183 | [ { id = "trunk" 184 | , name = "trunk" 185 | , description = "Trunk" 186 | , isShown = True 187 | , queued = 0 188 | , failed = 7798 189 | , succeeded = 24006 190 | , lastEvaluation = "2016-05-21 13:57:13" 191 | } 192 | , { id = "staging" 193 | , name = "staging" 194 | , description = "Staging" 195 | , isShown = True 196 | , queued = 0 197 | , failed = 31604 198 | , succeeded = 63 199 | , lastEvaluation = "2016-05-21 13:57:03" 200 | } 201 | ] 202 | } 203 | , { id = "nixops" 204 | , name = "NixOps" 205 | , description = "Deploying NixOS machines" 206 | , isShown = True 207 | , jobsets = [] 208 | } 209 | ] 210 | } 211 | -------------------------------------------------------------------------------- /src/Pages/Project.elm: -------------------------------------------------------------------------------- 1 | module Pages.Project exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (..) 5 | import Maybe 6 | import List 7 | import Material.Button as Button 8 | import Material.Options as Options 9 | import Material.Elevation as Elevation 10 | import Material.Menu as Menu 11 | import Material.Table as Table 12 | import Material.Textfield as Textfield 13 | import Material.Toggles as Toggles 14 | 15 | import Components.LiveSearch exposing (search) 16 | import Components.Help exposing (..) 17 | import Msg exposing (..) 18 | import Models exposing (Project, AppModel) 19 | import Urls exposing (..) 20 | import Utils exposing (..) 21 | 22 | 23 | 24 | view : AppModel -> Page -> List (Html Msg) 25 | view model page = 26 | case page of 27 | Home -> 28 | projectsView model model.projects 29 | 30 | Project name -> 31 | case List.head (List.filter (\p -> p.name == name) model.projects) of 32 | Just project -> 33 | [ renderProject model 0 project ] 34 | 35 | Nothing -> 36 | render404 ("Project " ++ name ++ " does not exist.") 37 | NewProject -> 38 | newProjectView model 39 | 40 | -- TODO: get rid of this 41 | _ -> [] 42 | 43 | 44 | projectsView : AppModel -> List Project -> List (Html Msg) 45 | projectsView model projects = 46 | let 47 | newprojects = 48 | List.indexedMap (renderProject model) (search projects) 49 | in 50 | renderHeader model "Projects" Nothing (Just NewProject) 51 | ++ if List.isEmpty newprojects then 52 | render404 "Zero projects. Maybe add one?" 53 | else 54 | newprojects 55 | 56 | 57 | newProjectView : AppModel -> List (Html Msg) 58 | newProjectView model = 59 | renderHeader model "Add a new project" Nothing Nothing 60 | ++ 61 | [ Html.form [] 62 | [ Textfield.render Mdl [5] model.mdl 63 | [ Textfield.label "Identifier (e.g. hydra)" 64 | , Textfield.floatingLabel 65 | , Textfield.text' 66 | , Options.css "display" "block" 67 | ] 68 | , Textfield.render Mdl [6] model.mdl 69 | [ Textfield.label "Display name (e.g. Hydra)" 70 | , Textfield.floatingLabel 71 | , Textfield.text' 72 | , Options.css "display" "block" 73 | ] 74 | , Textfield.render Mdl [7] model.mdl 75 | [ Textfield.label "Description (e.g. Builds Nix expressions and provides insight about the process)" 76 | , Textfield.floatingLabel 77 | , Textfield.text' 78 | , Options.css "display" "block" 79 | ] 80 | , Textfield.render Mdl [8] model.mdl 81 | [ Textfield.label "URL (e.g. https://github.com/NixOS/hydra)" 82 | , Textfield.floatingLabel 83 | , Textfield.text' 84 | , Options.css "display" "block" 85 | ] 86 | ] 87 | , Toggles.checkbox Mdl [9] model.mdl 88 | [ Toggles.ripple 89 | --, Toggles.onClick MyToggleMsg 90 | ] 91 | [ text "Is visible on the project list?" ] 92 | , Toggles.checkbox Mdl [10] model.mdl 93 | [ Toggles.ripple 94 | --, Toggles.onClick MyToggleMsg 95 | ] 96 | [ text "Is enabled?" ] 97 | , Textfield.render Mdl [11] model.mdl 98 | [ Textfield.label "Owner" 99 | , Textfield.floatingLabel 100 | , Textfield.text' 101 | , Options.css "display" "block" 102 | , Textfield.value (Maybe.withDefault "" (Maybe.map (\u -> u.id) model.user)) 103 | ] 104 | , Button.render Mdl [12] model.mdl 105 | [ Button.raised 106 | , Button.colored 107 | --, Button.onClick MyClickMsg 108 | ] 109 | [ text "Create project"] 110 | ] 111 | 112 | 113 | renderProject : AppModel -> Int -> Project -> Html Msg 114 | renderProject model i project = 115 | Options.div 116 | [ Elevation.e2 117 | , Options.css "margin" "30px" 118 | , Options.css "padding" "8px" 119 | ] 120 | [ h3 121 | [] 122 | [ a (onClickPage (Urls.Project project.name)) 123 | [ Options.span 124 | [ Options.css "margin" "16px" ] 125 | [ text (project.name) ] 126 | ] 127 | , small 128 | [ class "hidden-xs" ] 129 | [ text ("(" ++ project.description ++ ")") ] 130 | -- TODO: correct index 131 | , Menu.render Mdl [i + 10] model.mdl 132 | [ Menu.ripple 133 | , Menu.bottomRight 134 | , Options.css "float" "right" 135 | ] 136 | [ Menu.item [] 137 | [ menuIcon "add" 138 | , text "Add a jobset" 139 | ] 140 | , Menu.item [] 141 | [ menuIcon "settings" 142 | , text "Configuration" 143 | ] 144 | , Menu.item [] 145 | [ menuIcon "delete" 146 | , text "Delete the project" 147 | ] 148 | ] 149 | ] 150 | , if List.isEmpty project.jobsets 151 | then Options.span 152 | [ Options.center 153 | , Options.css "margin" "30px" 154 | ] 155 | [ text "No Jobsets configured yet." ] 156 | else Table.table [ Options.css "width" "100%" ] 157 | [ Table.thead [] 158 | [ Table.tr [] 159 | [ Table.th [] [ text "Jobset", jobsetHelp model] 160 | , Table.th [ ] [ text "Description" ] 161 | , Table.th [ ] [ text "Job status" ] 162 | , Table.th [ ] [ text "Last evaluation" ] 163 | ] 164 | ] 165 | , Table.tbody [] 166 | (search project.jobsets |> List.map (\jobset -> 167 | Table.tr [] 168 | [ Table.td [] [ a 169 | (onClickPage (Urls.Jobset project.name jobset.id)) 170 | [ text jobset.name ] ] 171 | , Table.td [] [ text jobset.description ] 172 | , Table.td [] (statusLabels jobset.succeeded jobset.failed jobset.queued) 173 | , Table.td [] [ text jobset.lastEvaluation ] 174 | ] 175 | 176 | ) 177 | ) 178 | ] 179 | ] 180 | --------------------------------------------------------------------------------