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": [],
"dependencies": {
"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/http": "1.0.0 <= v < 2.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",
"jweir/elm-iso8601": "3.0.2 <= v < 4.0.0"
},

View File

@ -24,6 +24,7 @@ import Types
import Utils.Types exposing (..)
import Views.SilenceForm.Types exposing (initSilenceForm)
import Views.Status.Types exposing (StatusModel, initStatusModel)
import Views.AlertList.Types exposing (initAlertList)
import Updates exposing (update)
@ -55,7 +56,7 @@ init location =
nullFilter
( 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
model ! [ msg, Task.perform UpdateCurrentTime Time.now ]

View File

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

View File

@ -25,7 +25,6 @@ import Views.SilenceList.Updates
import Views.Status.Types exposing (StatusMsg(InitStatusView))
import Views.Status.Updates
import String exposing (trim)
import Regex
update : Msg -> Model -> ( Model, Cmd Msg )
@ -43,10 +42,10 @@ update msg model =
NavigateToAlerts filter ->
let
( alertGroups, cmd ) =
Views.AlertList.Updates.update FetchAlertGroups model.alertGroups filter
( alertList, cmd ) =
Views.AlertList.Updates.update FetchAlertGroups model.alertList filter
in
( { model | alertGroups = alertGroups, route = AlertsRoute filter, filter = filter }, cmd )
( { model | alertList = alertList, route = AlertsRoute filter, filter = filter }, cmd )
NavigateToSilenceList filter ->
let
@ -87,44 +86,18 @@ update msg model =
RedirectAlerts ->
( model, Navigation.newUrl "/#/alerts" )
UpdateFilter filter text ->
UpdateFilter text ->
let
t =
if trim text == "" then
Nothing
else
Just text
in
( { model | filter = { filter | text = t } }, Cmd.none )
AddLabel msg ( key, value ) ->
let
filter =
prevFilter =
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
( { model | filter = { filter | text = text } }, Task.perform identity (Task.succeed msg) )
( { model | filter = { prevFilter | text = t } }, Cmd.none )
Noop ->
( model, Cmd.none )
@ -137,10 +110,10 @@ update msg model =
MsgForAlertList msg ->
let
( alertGroups, cmd ) =
Views.AlertList.Updates.update msg model.alertGroups model.filter
( alertList, cmd ) =
Views.AlertList.Updates.update msg model.alertList model.filter
in
( { model | alertGroups = alertGroups }, cmd )
( { model | alertList = alertList }, cmd )
MsgForSilenceList msg ->
let

View File

@ -15,7 +15,7 @@ parseDuration =
durationParser : Parser Time.Time
durationParser =
Parser.succeed (List.foldr (+) 0)
|= Parser.zeroOrMore term
|= Parser.repeat Parser.zeroOrMore term
|. Parser.end
@ -42,7 +42,7 @@ term =
|> List.map (\( unit, ms ) -> Parser.succeed ms |. Parser.symbol unit)
|> Parser.oneOf
)
|. Parser.ignoreWhile ((==) ' ')
|. Parser.ignore Parser.zeroOrMore ((==) ' ')
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 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
@ -18,3 +32,136 @@ generateQueryString { receiver, showSilenced, text } =
|> List.filterMap (uncurry generateQueryParam)
|> 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.Attributes exposing (class)
import Types exposing (Msg(MsgForSilenceForm), Model, Route(..))
import Utils.Types exposing (ApiResponse(..))
import Utils.Views exposing (error, loading)
import Views.SilenceList.Views as SilenceList
import Views.SilenceForm.Views as SilenceForm
@ -33,15 +32,7 @@ currentView model =
Silence.view model
AlertsRoute filter ->
case model.alertGroups of
Success alertGroups ->
AlertList.view alertGroups model.filter (text "")
Loading ->
loading
Failure msg ->
AlertList.view [] model.filter (error msg)
AlertList.view model.alertList filter
SilenceListRoute route ->
SilenceList.view model.silences model.silence model.currentTime model.filter

View File

@ -1,20 +1,151 @@
module Views.AlertList.FilterBar exposing (view)
import Html exposing (Html, div, span, input, text, button, i)
import Html.Attributes exposing (value, class)
import Html.Events exposing (onClick, onInput)
import Html exposing (Html, Attribute, div, span, input, text, button, i, small)
import Html.Attributes exposing (value, class, style, disabled, id)
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
view filterText inputChangedMsg buttonClickedMsg =
div [ class "input-group" ]
[ span [ class "input-group-addon" ]
[ i [ class "fa fa-filter" ] []
keys :
{ backspace : Int
, enter : Int
}
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 Utils.Filter
type AlertListMsg
= AlertGroupsFetch (ApiData (List AlertGroup))
| 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 (..)
import Alerts.Api as Api
import Views.AlertList.Types exposing (AlertListMsg(..))
import Alerts.Types exposing (AlertGroup)
import Views.AlertList.Types exposing (AlertListMsg(..), Model)
import Navigation
import Utils.Types exposing (ApiData, ApiResponse(..), Filter)
import Utils.Filter exposing (generateQueryString)
import Types exposing (Msg(MsgForAlertList))
import Utils.Types exposing (ApiData, ApiResponse(Loading), Filter)
import Utils.Filter exposing (generateQueryString, stringifyFilter, parseFilter)
import Types exposing (Msg(MsgForAlertList, Noop))
import Dom
import Task
update : AlertListMsg -> ApiData (List AlertGroup) -> Filter -> ( ApiData (List AlertGroup), Cmd Types.Msg )
update msg groups filter =
immediatelyFilter : Filter -> Model -> ( Model, Cmd Types.Msg )
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
AlertGroupsFetch alertGroups ->
( alertGroups, Cmd.none )
( { model | alertGroups = alertGroups }, Cmd.none )
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 ->
( groups, Navigation.newUrl ("/#/alerts" ++ generateQueryString filter) )
AddFilterMatcher emptyMatcherText matcher ->
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)
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.FilterBar
import Html exposing (..)
import Html.Attributes exposing (..)
import Utils.Date
import Utils.Types exposing (Filter)
import Utils.Views exposing (..)
import Types exposing (Msg(MsgForAlertList, Noop, CreateSilenceFromAlert, UpdateFilter, AddLabel))
import Utils.Types exposing (ApiResponse(Success, Loading, Failure), Filter)
import Utils.Filter
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 alertGroups filter errorHtml =
view : Model -> Filter -> Html Msg
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
filteredGroups =
receiver filter.receiver alertGroups
|> 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
div []
[ Views.AlertList.FilterBar.view filterText (Types.UpdateFilter filter) (MsgForAlertList FilterAlerts)
, errorHtml
, alertHtml
]
if List.isEmpty filteredGroups then
div [ class "mt2" ] [ text "no alerts found" ]
else
ul
[ classList
[ ( "list", True )
, ( "pa0", True )
]
]
(List.map alertGroupView filteredGroups)
alertGroupView : AlertGroup -> Html Msg
@ -80,10 +87,16 @@ alertView alert =
labelButton : ( String, String ) -> Html Msg
labelButton (( key, value ) as label) =
labelButton ( key, value ) =
onClickMsgButton
(key ++ "=" ++ value)
(AddLabel Noop label)
(AddFilterMatcher False
{ key = key
, op = Utils.Filter.Eq
, value = value
}
|> MsgForAlertList
)
alertHeader : ( String, String ) -> Html msg

View File

@ -47,7 +47,7 @@ silences silences filter errorHtml =
(List.map silenceList silences)
in
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", href "#/silences/new" ] [ text "New Silence" ]
, errorHtml

File diff suppressed because one or more lines are too long