Improve filter (#714)

* Update elm-tools/parser

* Improve filter UI

* Pressing backspace edits the last matcher

* Put escape char back into the output

* Allow editing the matcher

* Update bindata.go

* Const for key codes

* Use qualified imports

* Update bindata.go

* Commented the backspacePressed attribute
This commit is contained in:
Andrey Kuzmin 2017-04-18 20:49:52 +02:00 committed by GitHub
parent c3850708c1
commit 88ec956973
14 changed files with 454 additions and 116 deletions

File diff suppressed because one or more lines are too long

View File

@ -9,10 +9,11 @@
"exposed-modules": [], "exposed-modules": [],
"dependencies": { "dependencies": {
"elm-lang/core": "5.0.0 <= v < 6.0.0", "elm-lang/core": "5.0.0 <= v < 6.0.0",
"elm-lang/dom": "1.1.1 <= v < 2.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0", "elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0", "elm-lang/http": "1.0.0 <= v < 2.0.0",
"elm-lang/navigation": "2.0.1 <= v < 3.0.0", "elm-lang/navigation": "2.0.1 <= v < 3.0.0",
"elm-tools/parser": "1.0.2 <= v < 2.0.0", "elm-tools/parser": "2.0.0 <= v < 3.0.0",
"evancz/url-parser": "2.0.1 <= v < 3.0.0", "evancz/url-parser": "2.0.1 <= v < 3.0.0",
"jweir/elm-iso8601": "3.0.2 <= v < 4.0.0" "jweir/elm-iso8601": "3.0.2 <= v < 4.0.0"
}, },

View File

@ -24,6 +24,7 @@ import Types
import Utils.Types exposing (..) import Utils.Types exposing (..)
import Views.SilenceForm.Types exposing (initSilenceForm) import Views.SilenceForm.Types exposing (initSilenceForm)
import Views.Status.Types exposing (StatusModel, initStatusModel) import Views.Status.Types exposing (StatusModel, initStatusModel)
import Views.AlertList.Types exposing (initAlertList)
import Updates exposing (update) import Updates exposing (update)
@ -55,7 +56,7 @@ init location =
nullFilter nullFilter
( model, msg ) = ( model, msg ) =
update (urlUpdate location) (Model Loading Loading initSilenceForm Loading route filter 0 initStatusModel) update (urlUpdate location) (Model Loading Loading initSilenceForm initAlertList route filter 0 initStatusModel)
in in
model ! [ msg, Task.perform UpdateCurrentTime Time.now ] model ! [ msg, Task.perform UpdateCurrentTime Time.now ]

View File

@ -1,7 +1,7 @@
module Types exposing (Model, Msg(..), Route(..)) module Types exposing (Model, Msg(..), Route(..))
import Alerts.Types exposing (AlertGroup, Alert) import Alerts.Types exposing (AlertGroup, Alert)
import Views.AlertList.Types exposing (AlertListMsg) import Views.AlertList.Types as AlertList exposing (AlertListMsg)
import Views.SilenceList.Types exposing (SilenceListMsg) import Views.SilenceList.Types exposing (SilenceListMsg)
import Views.Silence.Types exposing (SilenceMsg) import Views.Silence.Types exposing (SilenceMsg)
import Views.SilenceForm.Types as SilenceForm exposing (SilenceFormMsg) import Views.SilenceForm.Types as SilenceForm exposing (SilenceFormMsg)
@ -15,7 +15,7 @@ type alias Model =
{ silences : ApiData (List Silence) { silences : ApiData (List Silence)
, silence : ApiData Silence , silence : ApiData Silence
, silenceForm : SilenceForm.Model , silenceForm : SilenceForm.Model
, alertGroups : ApiData (List AlertGroup) , alertList : AlertList.Model
, route : Route , route : Route
, filter : Filter , filter : Filter
, currentTime : Time.Time , currentTime : Time.Time
@ -40,8 +40,7 @@ type Msg
| Noop | Noop
| RedirectAlerts | RedirectAlerts
| UpdateCurrentTime Time.Time | UpdateCurrentTime Time.Time
| UpdateFilter Filter String | UpdateFilter String
| AddLabel Msg Label
type Route type Route

View File

@ -25,7 +25,6 @@ import Views.SilenceList.Updates
import Views.Status.Types exposing (StatusMsg(InitStatusView)) import Views.Status.Types exposing (StatusMsg(InitStatusView))
import Views.Status.Updates import Views.Status.Updates
import String exposing (trim) import String exposing (trim)
import Regex
update : Msg -> Model -> ( Model, Cmd Msg ) update : Msg -> Model -> ( Model, Cmd Msg )
@ -43,10 +42,10 @@ update msg model =
NavigateToAlerts filter -> NavigateToAlerts filter ->
let let
( alertGroups, cmd ) = ( alertList, cmd ) =
Views.AlertList.Updates.update FetchAlertGroups model.alertGroups filter Views.AlertList.Updates.update FetchAlertGroups model.alertList filter
in in
( { model | alertGroups = alertGroups, route = AlertsRoute filter, filter = filter }, cmd ) ( { model | alertList = alertList, route = AlertsRoute filter, filter = filter }, cmd )
NavigateToSilenceList filter -> NavigateToSilenceList filter ->
let let
@ -87,44 +86,18 @@ update msg model =
RedirectAlerts -> RedirectAlerts ->
( model, Navigation.newUrl "/#/alerts" ) ( model, Navigation.newUrl "/#/alerts" )
UpdateFilter filter text -> UpdateFilter text ->
let let
t = t =
if trim text == "" then if trim text == "" then
Nothing Nothing
else else
Just text Just text
in
( { model | filter = { filter | text = t } }, Cmd.none )
AddLabel msg ( key, value ) -> prevFilter =
let
filter =
model.filter model.filter
label =
key ++ "=" ++ toString value
labels =
Maybe.withDefault "" filter.text
|> Regex.replace Regex.All (Regex.regex "{|}") (\_ -> "")
|> Regex.split Regex.All (Regex.regex "\\s*,\\s*")
|> List.filter (String.isEmpty >> not)
|> (\labels ->
if List.member label labels then
labels
else
labels ++ [ label ]
)
|> String.join ", "
text =
if String.isEmpty labels then
Nothing
else
Just ("{" ++ labels ++ "}")
in in
( { model | filter = { filter | text = text } }, Task.perform identity (Task.succeed msg) ) ( { model | filter = { prevFilter | text = t } }, Cmd.none )
Noop -> Noop ->
( model, Cmd.none ) ( model, Cmd.none )
@ -137,10 +110,10 @@ update msg model =
MsgForAlertList msg -> MsgForAlertList msg ->
let let
( alertGroups, cmd ) = ( alertList, cmd ) =
Views.AlertList.Updates.update msg model.alertGroups model.filter Views.AlertList.Updates.update msg model.alertList model.filter
in in
( { model | alertGroups = alertGroups }, cmd ) ( { model | alertList = alertList }, cmd )
MsgForSilenceList msg -> MsgForSilenceList msg ->
let let

View File

@ -15,7 +15,7 @@ parseDuration =
durationParser : Parser Time.Time durationParser : Parser Time.Time
durationParser = durationParser =
Parser.succeed (List.foldr (+) 0) Parser.succeed (List.foldr (+) 0)
|= Parser.zeroOrMore term |= Parser.repeat Parser.zeroOrMore term
|. Parser.end |. Parser.end
@ -42,7 +42,7 @@ term =
|> List.map (\( unit, ms ) -> Parser.succeed ms |. Parser.symbol unit) |> List.map (\( unit, ms ) -> Parser.succeed ms |. Parser.symbol unit)
|> Parser.oneOf |> Parser.oneOf
) )
|. Parser.ignoreWhile ((==) ' ') |. Parser.ignore Parser.zeroOrMore ((==) ' ')
durationFormat : Time.Time -> String durationFormat : Time.Time -> String

View File

@ -1,7 +1,21 @@
module Utils.Filter exposing (..) module Utils.Filter
exposing
( Matcher
, MatchOperator(..)
, generateQueryParam
, generateQueryString
, stringifyMatcher
, stringifyFilter
, parseFilter
, parseMatcher
)
import Utils.Types exposing (Filter) import Utils.Types exposing (Filter)
import Http exposing (encodeUri) import Http exposing (encodeUri)
import Parser exposing (Parser, (|.), (|=), zeroOrMore, ignore)
import Parser.LanguageKit as Parser exposing (Trailing(..))
import Char
import Set
generateQueryParam : String -> Maybe String -> Maybe String generateQueryParam : String -> Maybe String -> Maybe String
@ -18,3 +32,136 @@ generateQueryString { receiver, showSilenced, text } =
|> List.filterMap (uncurry generateQueryParam) |> List.filterMap (uncurry generateQueryParam)
|> String.join "&" |> String.join "&"
|> (++) "?" |> (++) "?"
type alias Matcher =
{ key : String
, op : MatchOperator
, value : String
}
type MatchOperator
= Eq
| NotEq
| RegexMatch
| NotRegexMatch
matchers : List ( String, MatchOperator )
matchers =
[ ( "=~", RegexMatch )
, ( "!~", NotRegexMatch )
, ( "=", Eq )
, ( "!=", NotEq )
]
parseFilter : String -> Maybe (List Matcher)
parseFilter =
Parser.run filter
>> Result.toMaybe
parseMatcher : String -> Maybe Matcher
parseMatcher =
Parser.run matcher
>> Result.toMaybe
stringifyFilter : List Matcher -> String
stringifyFilter matchers =
case matchers of
[] ->
""
list ->
(list
|> List.map stringifyMatcher
|> String.join ", "
|> (++) "{"
)
++ "}"
stringifyMatcher : Matcher -> String
stringifyMatcher { key, op, value } =
key
++ (matchers
|> List.filter (Tuple.second >> (==) op)
|> List.head
|> Maybe.map Tuple.first
|> Maybe.withDefault ""
)
++ "\""
++ value
++ "\""
filter : Parser (List Matcher)
filter =
Parser.succeed identity
|= Parser.record spaces item
|. Parser.end
matcher : Parser Matcher
matcher =
Parser.succeed identity
|. spaces
|= item
|. spaces
|. Parser.end
item : Parser Matcher
item =
Parser.succeed Matcher
|= Parser.variable isVarChar isVarChar Set.empty
|= (matchers
|> List.map
(\( keyword, matcher ) ->
Parser.succeed matcher
|. Parser.keyword keyword
)
|> Parser.oneOf
)
|= string '"'
spaces : Parser ()
spaces =
ignore zeroOrMore (\char -> char == ' ' || char == '\t')
string : Char -> Parser String
string separator =
Parser.succeed identity
|. Parser.symbol (String.fromChar separator)
|= stringContents separator
|. Parser.symbol (String.fromChar separator)
stringContents : Char -> Parser String
stringContents separator =
Parser.oneOf
[ Parser.succeed (++)
|= keepOne (\char -> char == '\\')
|= keepOne (\char -> True)
, Parser.keep Parser.oneOrMore (\char -> char /= separator && char /= '\\')
]
|> Parser.repeat Parser.oneOrMore
|> Parser.map (String.join "")
isVarChar : Char -> Bool
isVarChar char =
Char.isLower char
|| Char.isUpper char
|| (char == '_')
|| Char.isDigit char
keepOne : (Char -> Bool) -> Parser String
keepOne =
Parser.keep (Parser.Exactly 1)

View File

@ -3,7 +3,6 @@ module Views exposing (..)
import Html exposing (Html, text, div) import Html exposing (Html, text, div)
import Html.Attributes exposing (class) import Html.Attributes exposing (class)
import Types exposing (Msg(MsgForSilenceForm), Model, Route(..)) import Types exposing (Msg(MsgForSilenceForm), Model, Route(..))
import Utils.Types exposing (ApiResponse(..))
import Utils.Views exposing (error, loading) import Utils.Views exposing (error, loading)
import Views.SilenceList.Views as SilenceList import Views.SilenceList.Views as SilenceList
import Views.SilenceForm.Views as SilenceForm import Views.SilenceForm.Views as SilenceForm
@ -33,15 +32,7 @@ currentView model =
Silence.view model Silence.view model
AlertsRoute filter -> AlertsRoute filter ->
case model.alertGroups of AlertList.view model.alertList filter
Success alertGroups ->
AlertList.view alertGroups model.filter (text "")
Loading ->
loading
Failure msg ->
AlertList.view [] model.filter (error msg)
SilenceListRoute route -> SilenceListRoute route ->
SilenceList.view model.silences model.silence model.currentTime model.filter SilenceList.view model.silences model.silence model.currentTime model.filter

View File

@ -1,20 +1,151 @@
module Views.AlertList.FilterBar exposing (view) module Views.AlertList.FilterBar exposing (view)
import Html exposing (Html, div, span, input, text, button, i) import Html exposing (Html, Attribute, div, span, input, text, button, i, small)
import Html.Attributes exposing (value, class) import Html.Attributes exposing (value, class, style, disabled, id)
import Html.Events exposing (onClick, onInput) import Html.Events exposing (onClick, onInput, on, keyCode)
import Utils.Filter exposing (Matcher)
import Views.AlertList.Types exposing (AlertListMsg(AddFilterMatcher, DeleteFilterMatcher, PressingBackspace, UpdateMatcherText))
import Types exposing (Msg(MsgForAlertList, Noop))
import Json.Decode as Json
view : String -> (String -> msg) -> msg -> Html msg keys :
view filterText inputChangedMsg buttonClickedMsg = { backspace : Int
div [ class "input-group" ] , enter : Int
[ span [ class "input-group-addon" ] }
[ i [ class "fa fa-filter" ] [] keys =
{ backspace = 8
, enter = 13
}
viewMatcher : Matcher -> Html Msg
viewMatcher matcher =
div [ class "col col-auto", style [ ( "padding", "5px" ) ] ]
[ div [ class "btn-group" ]
[ button
[ class "btn btn-outline-info"
, onClick (DeleteFilterMatcher True matcher |> MsgForAlertList)
]
[ text <| Utils.Filter.stringifyMatcher matcher
]
, button
[ class "btn btn-outline-danger"
, onClick (DeleteFilterMatcher False matcher |> MsgForAlertList)
]
[ text "×" ]
] ]
, input
[ class "form-control", value filterText, onInput inputChangedMsg ]
[]
, span
[ class "input-group-btn" ]
[ button [ class "btn btn-primary", onClick buttonClickedMsg ] [ text "Filter" ] ]
] ]
lastElem : List a -> Maybe a
lastElem =
List.foldl (Just >> always) Nothing
viewMatchers : List Matcher -> List (Html Msg)
viewMatchers matchers =
matchers
|> List.map viewMatcher
onKey : String -> Int -> Msg -> Attribute Msg
onKey event key msg =
on event
(Json.map
(\k ->
if k == key then
msg
else
Noop
)
keyCode
)
view : List Matcher -> String -> Bool -> Html Msg
view matchers matcherText backspacePressed =
let
className =
if matcherText == "" then
""
else
case maybeMatcher of
Just _ ->
"has-success"
Nothing ->
"has-danger"
maybeMatcher =
Utils.Filter.parseMatcher matcherText
onKeydown =
onKey "keydown" keys.backspace <|
case ( matcherText, backspacePressed ) of
( "", True ) ->
Noop
( "", False ) ->
lastElem matchers
|> Maybe.map (DeleteFilterMatcher True >> MsgForAlertList)
|> Maybe.withDefault Noop
_ ->
PressingBackspace True |> MsgForAlertList
onKeypress =
maybeMatcher
|> Maybe.map (AddFilterMatcher True >> MsgForAlertList)
|> Maybe.withDefault Noop
|> onKey "keypress" keys.enter
onKeyup =
onKey "keyup" keys.backspace (PressingBackspace False |> MsgForAlertList)
onClickAttr =
maybeMatcher
|> Maybe.map (AddFilterMatcher True >> MsgForAlertList)
|> Maybe.withDefault Noop
|> onClick
isDisabled =
maybeMatcher == Nothing
in
div
[ class "row no-gutters align-items-start" ]
(viewMatchers matchers
++ [ div
[ class ("col form-group " ++ className)
, style
[ ( "padding", "5px" )
, ( "min-width", "200px" )
, ( "max-width", "500px" )
]
]
[ div [ class "input-group" ]
[ input
[ id "custom-matcher"
, class "form-control"
, value matcherText
, onKeydown
, onKeyup
, onKeypress
, onInput (UpdateMatcherText >> MsgForAlertList)
]
[]
, span
[ class "input-group-btn" ]
[ button [ class "btn btn-primary", disabled isDisabled, onClickAttr ] [ text "Add" ] ]
]
, small [ class "form-text text-muted" ]
[ text "Custom matcher, e.g."
, button
[ class "btn btn-link btn-sm align-baseline"
, onClick (UpdateMatcherText "env=\"production\"" |> MsgForAlertList)
]
[ text "env=\"production\"" ]
]
]
]
)

View File

@ -1,10 +1,40 @@
module Views.AlertList.Types exposing (AlertListMsg(..)) module Views.AlertList.Types exposing (AlertListMsg(..), Model, initAlertList)
import Utils.Types exposing (ApiData, Filter) import Utils.Types exposing (ApiData, Filter, ApiResponse(Loading))
import Alerts.Types exposing (Alert, AlertGroup) import Alerts.Types exposing (Alert, AlertGroup)
import Utils.Filter
type AlertListMsg type AlertListMsg
= AlertGroupsFetch (ApiData (List AlertGroup)) = AlertGroupsFetch (ApiData (List AlertGroup))
| FetchAlertGroups | FetchAlertGroups
| FilterAlerts | AddFilterMatcher Bool Utils.Filter.Matcher
| DeleteFilterMatcher Bool Utils.Filter.Matcher
| PressingBackspace Bool
| UpdateMatcherText String
{-| A note about the `backspacePressed` attribute:
Holding down the backspace removes (one by one) each last character in the input,
and the whole time sends multiple keyDown events. This is a guard so that if a user
holds down backspace to remove the text in the input, they won't accidentally hold
backspace too long and then delete the preceding matcher as well. So, once a user holds
backspace to clear an input, they have to then lift up the key and press it again to
proceed to deleting the next matcher.
-}
type alias Model =
{ alertGroups : ApiData (List AlertGroup)
, matchers : List Utils.Filter.Matcher
, backspacePressed : Bool
, matcherText : String
}
initAlertList : Model
initAlertList =
{ alertGroups = Loading
, matchers = []
, backspacePressed = False
, matcherText = ""
}

View File

@ -1,22 +1,74 @@
module Views.AlertList.Updates exposing (..) module Views.AlertList.Updates exposing (..)
import Alerts.Api as Api import Alerts.Api as Api
import Views.AlertList.Types exposing (AlertListMsg(..)) import Views.AlertList.Types exposing (AlertListMsg(..), Model)
import Alerts.Types exposing (AlertGroup)
import Navigation import Navigation
import Utils.Types exposing (ApiData, ApiResponse(..), Filter) import Utils.Types exposing (ApiData, ApiResponse(Loading), Filter)
import Utils.Filter exposing (generateQueryString) import Utils.Filter exposing (generateQueryString, stringifyFilter, parseFilter)
import Types exposing (Msg(MsgForAlertList)) import Types exposing (Msg(MsgForAlertList, Noop))
import Dom
import Task
update : AlertListMsg -> ApiData (List AlertGroup) -> Filter -> ( ApiData (List AlertGroup), Cmd Types.Msg ) immediatelyFilter : Filter -> Model -> ( Model, Cmd Types.Msg )
update msg groups filter = immediatelyFilter filter model =
let
newFilter =
{ filter | text = Just (stringifyFilter model.matchers) }
in
( model
, Cmd.batch
[ Navigation.newUrl ("/#/alerts" ++ generateQueryString newFilter)
, Dom.focus "custom-matcher" |> Task.attempt (always Noop)
]
)
update : AlertListMsg -> Model -> Filter -> ( Model, Cmd Types.Msg )
update msg model filter =
case msg of case msg of
AlertGroupsFetch alertGroups -> AlertGroupsFetch alertGroups ->
( alertGroups, Cmd.none ) ( { model | alertGroups = alertGroups }, Cmd.none )
FetchAlertGroups -> FetchAlertGroups ->
( groups, Api.alertGroups filter |> Cmd.map (AlertGroupsFetch >> MsgForAlertList) ) ( { model
| matchers =
filter.text
|> Maybe.andThen parseFilter
|> Maybe.withDefault []
, alertGroups = Loading
}
, Api.alertGroups filter |> Cmd.map (AlertGroupsFetch >> MsgForAlertList)
)
FilterAlerts -> AddFilterMatcher emptyMatcherText matcher ->
( groups, Navigation.newUrl ("/#/alerts" ++ generateQueryString filter) ) immediatelyFilter filter
{ model
| matchers =
if List.member matcher model.matchers then
model.matchers
else
model.matchers ++ [ matcher ]
, matcherText =
if emptyMatcherText then
""
else
model.matcherText
}
DeleteFilterMatcher setMatcherText matcher ->
immediatelyFilter filter
{ model
| matchers = List.filter ((/=) matcher) model.matchers
, matcherText =
if setMatcherText then
Utils.Filter.stringifyMatcher matcher
else
model.matcherText
}
UpdateMatcherText value ->
( { model | matcherText = value }, Cmd.none )
PressingBackspace isPressed ->
( { model | backspacePressed = isPressed }, Cmd.none )

View File

@ -1,44 +1,51 @@
module Views.AlertList.Views exposing (view) module Views.AlertList.Views exposing (view)
import Alerts.Types exposing (Alert, AlertGroup, Block) import Alerts.Types exposing (Alert, AlertGroup, Block)
import Views.AlertList.Types exposing (AlertListMsg(FilterAlerts))
import Views.AlertList.Filter exposing (silenced, receiver, matchers) import Views.AlertList.Filter exposing (silenced, receiver, matchers)
import Views.AlertList.FilterBar import Views.AlertList.FilterBar
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Utils.Date import Utils.Date
import Utils.Types exposing (Filter) import Utils.Types exposing (ApiResponse(Success, Loading, Failure), Filter)
import Utils.Views exposing (..) import Utils.Filter
import Types exposing (Msg(MsgForAlertList, Noop, CreateSilenceFromAlert, UpdateFilter, AddLabel)) import Utils.Views exposing (buttonLink, onClickMsgButton, listButton)
import Views.AlertList.Types exposing (AlertListMsg(AddFilterMatcher), Model)
import Types exposing (Msg(Noop, CreateSilenceFromAlert, MsgForAlertList))
view : List AlertGroup -> Filter -> Html Msg -> Html Msg view : Model -> Filter -> Html Msg
view alertGroups filter errorHtml = view { alertGroups, matchers, matcherText, backspacePressed } filter =
div []
[ Views.AlertList.FilterBar.view matchers matcherText backspacePressed
, case alertGroups of
Success groups ->
viewGroups groups filter
Loading ->
Utils.Views.loading
Failure msg ->
Utils.Views.error msg
]
viewGroups : List AlertGroup -> Filter -> Html Msg
viewGroups alertGroups filter =
let let
filteredGroups = filteredGroups =
receiver filter.receiver alertGroups receiver filter.receiver alertGroups
|> silenced filter.showSilenced |> silenced filter.showSilenced
filterText =
Maybe.withDefault "" filter.text
alertHtml =
if List.isEmpty filteredGroups then
div [ class "mt2" ] [ text "no alerts found found" ]
else
ul
[ classList
[ ( "list", True )
, ( "pa0", True )
]
]
(List.map alertGroupView filteredGroups)
in in
div [] if List.isEmpty filteredGroups then
[ Views.AlertList.FilterBar.view filterText (Types.UpdateFilter filter) (MsgForAlertList FilterAlerts) div [ class "mt2" ] [ text "no alerts found" ]
, errorHtml else
, alertHtml ul
] [ classList
[ ( "list", True )
, ( "pa0", True )
]
]
(List.map alertGroupView filteredGroups)
alertGroupView : AlertGroup -> Html Msg alertGroupView : AlertGroup -> Html Msg
@ -80,10 +87,16 @@ alertView alert =
labelButton : ( String, String ) -> Html Msg labelButton : ( String, String ) -> Html Msg
labelButton (( key, value ) as label) = labelButton ( key, value ) =
onClickMsgButton onClickMsgButton
(key ++ "=" ++ value) (key ++ "=" ++ value)
(AddLabel Noop label) (AddFilterMatcher False
{ key = key
, op = Utils.Filter.Eq
, value = value
}
|> MsgForAlertList
)
alertHeader : ( String, String ) -> Html msg alertHeader : ( String, String ) -> Html msg

View File

@ -47,7 +47,7 @@ silences silences filter errorHtml =
(List.map silenceList silences) (List.map silenceList silences)
in in
div [] div []
[ textField "Filter" filterText (UpdateFilter filter) [ textField "Filter" filterText UpdateFilter
, a [ class "f6 link br2 ba ph3 pv2 mr2 dib blue", onClick (MsgForSilenceList FilterSilences) ] [ text "Filter Silences" ] , a [ class "f6 link br2 ba ph3 pv2 mr2 dib blue", onClick (MsgForSilenceList FilterSilences) ] [ text "Filter Silences" ]
, a [ class "f6 link br2 ba ph3 pv2 mr2 dib blue", href "#/silences/new" ] [ text "New Silence" ] , a [ class "f6 link br2 ba ph3 pv2 mr2 dib blue", href "#/silences/new" ] [ text "New Silence" ]
, errorHtml , errorHtml

File diff suppressed because one or more lines are too long