Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cli/api/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,15 @@ func (a *Api) Devices() DeviceApi {

// ListPage fetches a single page of devices. It returns the devices,
// whether more pages are available, and the total number of pages.
func (d DeviceApi) ListPage(page int, limit int, sortBy string) ([]DeviceListItem, bool, int, error) {
func (d DeviceApi) ListPage(page int, limit int, sortBy string, labelFilters []string) ([]DeviceListItem, bool, int, error) {
offset := (page - 1) * limit
resource := fmt.Sprintf("/v1/devices?limit=%d&offset=%d", limit, offset)
if sortBy != "" {
resource += "&order-by=" + sortBy
}
for _, lf := range labelFilters {
resource += "&label=" + url.QueryEscape(lf)
}
var devices []DeviceListItem
headers, err := d.api.GetWithHeaders(resource, &devices)
if err != nil {
Expand Down
10 changes: 7 additions & 3 deletions cli/subcommands/devices/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ var listCmd = &cobra.Command{
if err := validateSortBy(sortBy); err != nil {
return err
}
labelFilters, _ := cmd.Flags().GetStringSlice("label")
page, _ := cmd.Flags().GetInt("page")
api := api.CtxGetApi(cmd.Context())
listDevices(api.Devices(), columns, page, sortBy)
listDevices(api.Devices(), columns, page, sortBy, labelFilters)
return nil
},
}
Expand All @@ -63,6 +64,9 @@ func init() {
"Comma-separated list of columns to display (available: "+colmnsStr+")")
listCmd.Flags().IntP("page", "p", 1, "Page number to display")
listCmd.Flags().StringP("sort", "s", "", "Sort order for devices ("+sortStr+")")
listCmd.Flags().StringSliceP("label", "l", nil,
"Filter by label in the format key.comparison.value (e.g. env.eq.production).\n"+
"Comparisons: eq, ne, contains, ncontains. Can be repeated for AND logic.")
}

func validateSortBy(sortBy string) error {
Expand All @@ -85,8 +89,8 @@ func validateColumns(columnsStr string) ([]string, error) {
return columns, nil
}

func listDevices(dapi api.DeviceApi, columns []string, page int, sortBy string) {
devices, hasMore, totalPages, err := dapi.ListPage(page, defaultPageLimit, sortBy)
func listDevices(dapi api.DeviceApi, columns []string, page int, sortBy string, labelFilters []string) {
devices, hasMore, totalPages, err := dapi.ListPage(page, defaultPageLimit, sortBy, labelFilters)
cobra.CheckErr(err)

headers := make([]string, 0, len(columns))
Expand Down
33 changes: 33 additions & 0 deletions server/ui/api/handlers_devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type LabelsPutReq map[string]*string
// @Description Requires scope: devices:read or devices:read-update
// @Tags Devices
// @Param _ query DeviceListOpts false "Sorting options"
// @Param label query []string false "Label filters in the format key.comparison.value (e.g. env.eq.production). Comparison operators: eq, ne, contains, ncontains. Multiple filters are ANDed together."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, IIUC this requires the UI to pass multiple filters as multiple label=x.y.z&label=a.b.c query parameters (form style) and emphasizes on label filtering.

Maybe, we should consider a more broad filtering like filter=x.y.z,a.b.c extending beyond labels, and using a comma (or semicolon) as a separator for compactness (whatever separator the UI is currently using)?

// @Accept json
// @Produce json
// @Success 200 {array} DeviceListItem
Expand All @@ -52,6 +53,12 @@ func (h *handlers) deviceList(c echo.Context) error {
return EchoError(c, err, http.StatusBadRequest, "Failed to parse list options")
}

if filters, err := parseLabelFilters(c.QueryParams()["label"]); err != nil {
return EchoError(c, err, http.StatusBadRequest, err.Error())
} else {
opts.LabelFilters = filters
}

devices, total, err := h.storage.DevicesList(opts)
if err != nil {
return EchoError(c, err, http.StatusInternalServerError, "Unexpected error listing devices")
Expand All @@ -61,6 +68,32 @@ func (h *handlers) deviceList(c echo.Context) error {
return c.JSON(http.StatusOK, devices)
}

// parseLabelFilters parses query parameters of the form "key.comparison.value"
// into LabelFilter structs. The value may contain dots.
func parseLabelFilters(params []string) ([]storage.LabelFilter, error) {
if len(params) == 0 {
return nil, nil
}
filters := make([]storage.LabelFilter, 0, len(params))
for _, p := range params {
// Split into at most 3 parts: key, comparison, value (value may contain dots)
parts := strings.SplitN(p, ".", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("invalid label filter %q: expected format key.comparison.value", p)
}
f := storage.LabelFilter{
Label: parts[0],
Comparison: storage.LabelComparison(parts[1]),
Value: parts[2],
}
if err := f.Validate(); err != nil {
return nil, err
}
Comment on lines +80 to +91

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be beneficial to use some lexer, e.g. the participle library which allows defining the LabelFilter as below.

TBH. I'm not sure how much benefit any lexer would give us. A simple split (as you did) seems sufficient to me, and in case if we switch to =|~=|!= kind of operators, a simple split would work well too.

var filterLexer = lexer.MustSimple([]lexer.SimpleRule{
	{Name: "Label", Pattern: `^[\w_]+`},
	{Name: "Operator", Pattern: `.eq.|.ne.|.contains.|.ncontains.`}, 
	{Name: "Value", Pattern: `[\w\.\-_]+$`},
})

type LabelFilter struct {
	Label      string          `parser:"@Label"`
	Comparison LabelComparison `parser:"@Operator"`
	Value      string          `parser:"@Value"`
}

// global initialization somewhere
parser, err := participle.Build[LabelFilter](participle.Lexer(filterLexer))
if err != nil { panic(err) }

// usage
input := "name.contains.abcd"
filter, err := parser.ParseString("", input)

filters = append(filters, f)
}
return filters, nil
}

func setPaginationHeaders(c echo.Context, opts storage.DeviceListOpts, total int) {
if opts.Limit <= 0 {
return
Expand Down
61 changes: 61 additions & 0 deletions server/ui/api/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,67 @@ func TestApiDeviceList(t *testing.T) {

}

func TestApiDeviceListLabelFilters(t *testing.T) {
tc := NewTestClient(t)
tc.u.AllowedScopes = users.ScopeDevicesRU

// Create 3 devices with labels
_, err := tc.gw.DeviceCreate("dev-1", "pk1", false)
require.Nil(t, err)
_, err = tc.gw.DeviceCreate("dev-2", "pk2", false)
require.Nil(t, err)
_, err = tc.gw.DeviceCreate("dev-3", "pk3", false)
require.Nil(t, err)

headers := []string{"content-type", "application/json"}
tc.PATCH("/devices/dev-1/labels", 200,
`{"upserts":{"env":"production","region":"us-east"}}`, headers...)
tc.PATCH("/devices/dev-2/labels", 200,
`{"upserts":{"env":"staging","region":"us-west"}}`, headers...)
tc.PATCH("/devices/dev-3/labels", 200,
`{"upserts":{"env":"production","region":"eu-central"}}`, headers...)

var devices []apiStorage.DeviceListItem

// eq filter
data := tc.GET("/devices?order-by=uuid-asc&label=env.eq.production", 200)
require.Nil(t, json.Unmarshal(data, &devices))
require.Len(t, devices, 2)
assert.Equal(t, "dev-1", devices[0].Uuid)
assert.Equal(t, "dev-3", devices[1].Uuid)

// ne filter
data = tc.GET("/devices?order-by=uuid-asc&label=env.ne.production", 200)
require.Nil(t, json.Unmarshal(data, &devices))
require.Len(t, devices, 1)
assert.Equal(t, "dev-2", devices[0].Uuid)

// contains filter
data = tc.GET("/devices?order-by=uuid-asc&label=region.contains.us", 200)
require.Nil(t, json.Unmarshal(data, &devices))
require.Len(t, devices, 2)
assert.Equal(t, "dev-1", devices[0].Uuid)
assert.Equal(t, "dev-2", devices[1].Uuid)

// ncontains filter
data = tc.GET("/devices?order-by=uuid-asc&label=region.ncontains.us", 200)
require.Nil(t, json.Unmarshal(data, &devices))
require.Len(t, devices, 1)
assert.Equal(t, "dev-3", devices[0].Uuid)

// multiple filters (AND)
data = tc.GET("/devices?order-by=uuid-asc&label=env.eq.production&label=region.contains.eu", 200)
require.Nil(t, json.Unmarshal(data, &devices))
require.Len(t, devices, 1)
assert.Equal(t, "dev-3", devices[0].Uuid)

// bad format returns 400
tc.GET("/devices?label=bad-format", 400)

// invalid comparison returns 400
tc.GET("/devices?label=env.invalid.val", 400)
}

func TestApiDeviceGet(t *testing.T) {
tc := NewTestClient(t)
tc.GET("/devices/foo", 403)
Expand Down
44 changes: 29 additions & 15 deletions server/ui/web/handlers_devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,18 @@ func (h handlers) devicesList(c echo.Context) error {
if sort == "" {
sort = "created-at-desc"
}
labelFilters := c.QueryParams()["label"]

const pageSize = 50
offset := (page - 1) * pageSize

resource := fmt.Sprintf("/v1/devices?limit=%d&offset=%d", pageSize, offset)
if sort != "" {
resource += "&order-by=" + sort
}
for _, lf := range labelFilters {
resource += "&label=" + lf
}

var devices []api.DeviceListItem
headers, err := getJsonWithHeaders(c.Request().Context(), resource, &devices)
Expand All @@ -42,24 +47,33 @@ func (h handlers) devicesList(c echo.Context) error {
hasNext := linkHasRel(headers.Get("Link"), "next")
totalPages := linkTotalPages(headers.Get("Link"), pageSize)

var knownLabels []string
if err := getJson(c.Request().Context(), "/v1/known-labels/devices", &knownLabels); err != nil {
return h.handleUnexpected(c, err)
}

ctx := struct {
baseCtx
Devices []api.DeviceListItem
CanDelete bool
Page int
TotalPages int
HasNext bool
HasPrev bool
Sort string
Devices []api.DeviceListItem
CanDelete bool
Page int
TotalPages int
HasNext bool
HasPrev bool
Sort string
LabelFilters []string
KnownLabels []string
}{
baseCtx: h.baseCtx(c, "Devices", "devices"),
Devices: devices,
CanDelete: CtxGetSession(c.Request().Context()).User.AllowedScopes.Has(users.ScopeDevicesD),
Page: page,
TotalPages: totalPages,
HasNext: hasNext,
HasPrev: page > 1,
Sort: sort,
baseCtx: h.baseCtx(c, "Devices", "devices"),
Devices: devices,
CanDelete: CtxGetSession(c.Request().Context()).User.AllowedScopes.Has(users.ScopeDevicesD),
Page: page,
TotalPages: totalPages,
HasNext: hasNext,
HasPrev: page > 1,
Sort: sort,
LabelFilters: labelFilters,
KnownLabels: knownLabels,
}
return h.templates.ExecuteTemplate(c.Response(), "devices_list.html", ctx)
}
Expand Down
Loading
Loading