diff --git a/cli/api/devices.go b/cli/api/devices.go index bd8c78d3..bdcc5972 100644 --- a/cli/api/devices.go +++ b/cli/api/devices.go @@ -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 { diff --git a/cli/subcommands/devices/list.go b/cli/subcommands/devices/list.go index 6d6430d8..200df50d 100644 --- a/cli/subcommands/devices/list.go +++ b/cli/subcommands/devices/list.go @@ -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 }, } @@ -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 { @@ -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)) diff --git a/server/ui/api/handlers_devices.go b/server/ui/api/handlers_devices.go index 48d7afda..c11c49b0 100644 --- a/server/ui/api/handlers_devices.go +++ b/server/ui/api/handlers_devices.go @@ -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." // @Accept json // @Produce json // @Success 200 {array} DeviceListItem @@ -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") @@ -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 + } + filters = append(filters, f) + } + return filters, nil +} + func setPaginationHeaders(c echo.Context, opts storage.DeviceListOpts, total int) { if opts.Limit <= 0 { return diff --git a/server/ui/api/handlers_test.go b/server/ui/api/handlers_test.go index a03cf6ba..b05e035d 100644 --- a/server/ui/api/handlers_test.go +++ b/server/ui/api/handlers_test.go @@ -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) diff --git a/server/ui/web/handlers_devices.go b/server/ui/web/handlers_devices.go index df664c75..de37824c 100644 --- a/server/ui/web/handlers_devices.go +++ b/server/ui/web/handlers_devices.go @@ -25,6 +25,8 @@ 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 @@ -32,6 +34,9 @@ func (h handlers) devicesList(c echo.Context) error { 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) @@ -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) } diff --git a/server/ui/web/templates/devices_list.html b/server/ui/web/templates/devices_list.html index 8fc3c843..859e0f7b 100644 --- a/server/ui/web/templates/devices_list.html +++ b/server/ui/web/templates/devices_list.html @@ -2,43 +2,177 @@

{{.Title}}

+
+ +
+
+ + @@ -77,15 +211,15 @@

{{.Title}}

diff --git a/server/ui/web/templates/style.css b/server/ui/web/templates/style.css index f58701a1..4d37d124 100644 --- a/server/ui/web/templates/style.css +++ b/server/ui/web/templates/style.css @@ -274,3 +274,34 @@ dialog.file-content p { background-image: url('data:image/svg+xml;utf8,'); } } + +/* Autocomplete dropdown for label filter input */ +.filter-input-wrap { + position: relative; +} +.ac-dropdown { + display: none; + position: absolute; + left: 0; + right: 0; + top: 100%; + z-index: 10; + background: var(--bg-content, #fff); + border: 1px solid var(--pico-muted-border-color, #ccc); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + max-height: 220px; + overflow-y: auto; +} +.ac-item { + padding: 0.45rem 0.75rem; + cursor: pointer; + font-size: 0.95rem; +} +.ac-item:hover, +.ac-item.ac-active { + background: var(--pico-primary-focus, rgba(0,100,200,0.12)); +} +.filter-help { + margin-top: 0; +} diff --git a/server/ui/web/templates/templates.go b/server/ui/web/templates/templates.go index ba641f48..7c4b310a 100644 --- a/server/ui/web/templates/templates.go +++ b/server/ui/web/templates/templates.go @@ -34,6 +34,13 @@ func init() { } return v }, + "labelParams": func(labels []string) template.URL { + var s strings.Builder + for _, l := range labels { + s.WriteString("&label=" + template.URLQueryEscaper(l)) + } + return template.URL(s.String()) + }, } Templates = template.Must(template.New("").Funcs(funcMap).ParseFS(Assets, "*.html", "*.css")) diff --git a/storage/api/api_storage.go b/storage/api/api_storage.go index 21c3e3b5..8e234c8c 100644 --- a/storage/api/api_storage.go +++ b/storage/api/api_storage.go @@ -11,6 +11,7 @@ import ( "io" "iter" "log/slog" + "regexp" "slices" "strings" @@ -45,6 +46,41 @@ const ( ConfigSotaOverride = storage.ConfigSotaOverride ) +// LabelComparison defines the comparison operator for label filters. +type LabelComparison string + +const ( + LabelCmpEqual LabelComparison = "eq" + LabelCmpNotEqual LabelComparison = "ne" + LabelCmpContains LabelComparison = "contains" + LabelCmpNotContains LabelComparison = "ncontains" +) + +// LabelFilter defines a filter on a device's JSONB labels column. +type LabelFilter struct { + Label string + Value string + Comparison LabelComparison +} + +// validLabelKey ensures label keys only contain safe characters for JSON path embedding. +var validLabelKey = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`) + +func (f LabelFilter) Validate() error { + switch f.Comparison { + case LabelCmpEqual, LabelCmpNotEqual, LabelCmpContains, LabelCmpNotContains: + default: + return fmt.Errorf("invalid label comparison: %q", f.Comparison) + } + if f.Label == "" { + return fmt.Errorf("label filter key must not be empty") + } + if !validLabelKey.MatchString(f.Label) { + return fmt.Errorf("invalid label filter key: %q", f.Label) + } + return nil +} + var orderByDeviceMap = map[OrderBy]string{ OrderByDeviceCreatedAsc: "created_at ASC", OrderByDeviceCreatedDsc: "created_at DESC", @@ -74,9 +110,10 @@ var ( // DeviceListOpts lets you set the order devices will be returned // by the `List` api type DeviceListOpts struct { - OrderBy OrderBy `query:"order-by" default:"last-seen-desc"` - Limit int `query:"limit" default:"1000"` - Offset int `query:"offset" default:"0"` + OrderBy OrderBy `query:"order-by" default:"last-seen-desc"` + Limit int `query:"limit" default:"1000"` + Offset int `query:"offset" default:"0"` + LabelFilters []LabelFilter `json:"label-filters,omitempty"` } type DeviceListItem struct { @@ -117,12 +154,10 @@ type Storage struct { db *storage.DbHandle fs *storage.FsHandle - stmtDeviceCount stmtDeviceCount stmtDeviceDelete stmtDeviceDelete stmtDeviceGet stmtDeviceGet stmtDeviceGetGroups stmtDeviceGetGroups stmtDeviceGetLabels stmtDeviceGetLabels - stmtDeviceList map[OrderBy]stmtDeviceList stmtDeviceSetLabels stmtDeviceSetLabels stmtDeviceSetUpdate stmtDeviceSetUpdate } @@ -191,7 +226,6 @@ func NewStorage(db *storage.DbHandle, fs *storage.FsHandle) (*Storage, error) { handle := Storage{db: db, fs: fs} if err := db.InitStmt( - &handle.stmtDeviceCount, &handle.stmtDeviceDelete, &handle.stmtDeviceGet, &handle.stmtDeviceGetGroups, @@ -202,15 +236,6 @@ func NewStorage(db *storage.DbHandle, fs *storage.FsHandle) (*Storage, error) { return nil, err } - handle.stmtDeviceList = make(map[OrderBy]stmtDeviceList, len(orderByDeviceMap)) - for orderBy, orderByStr := range orderByDeviceMap { - stmt := stmtDeviceList{} - if err := stmt.Init(*db, orderByStr); err != nil { - return nil, err - } - handle.stmtDeviceList[orderBy] = stmt - } - return &handle, nil } @@ -219,24 +244,107 @@ func (s Storage) DevicesList(opts DeviceListOpts) ([]DeviceListItem, int, error) if orderBy == "" { orderBy = OrderByDeviceLastSeenDsc } - stmt, ok := s.stmtDeviceList[orderBy] + orderByStr, ok := orderByDeviceMap[orderBy] if !ok { return nil, 0, fmt.Errorf("invalid order by arg: %s", opts.OrderBy) } - total, err := s.stmtDeviceCount.run() + filterSQL, filterArgs, err := buildLabelFilterSQL(opts.LabelFilters) if err != nil { return nil, 0, err } + // Count query + countQuery := "SELECT COUNT(*) FROM devices WHERE deleted=false" + filterSQL + var total int + if err := s.db.QueryRow(countQuery, filterArgs...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to count devices: %w", err) + } + + // List query + listQuery := fmt.Sprintf( + "SELECT uuid, created_at, last_seen, target_name, tag, is_prod, json(labels) FROM devices WHERE deleted=false%s ORDER BY %s LIMIT ? OFFSET ?", + filterSQL, orderByStr, + ) + listArgs := append(filterArgs, opts.Limit, opts.Offset) + rows, err := s.db.Query(listQuery, listArgs...) + if err != nil { + return nil, 0, fmt.Errorf("failed to list devices: %w", err) + } + defer func() { + if err := rows.Close(); err != nil { + slog.Error("failed to close rows in device list", "error", err) + } + }() + devices := make([]DeviceListItem, 0, opts.Limit) - if err := stmt.run(opts.Limit, opts.Offset, &devices); err != nil { + for rows.Next() { + var ( + d DeviceListItem + labels []byte + ) + if err = rows.Scan( + &d.Uuid, &d.CreatedAt, &d.LastSeen, &d.Target, &d.Tag, &d.IsProd, &labels, + ); err != nil { + return nil, 0, err + } + if err = json.Unmarshal(labels, &d.Labels); err != nil { + return nil, 0, fmt.Errorf("failed to parse device labels: %w", err) + } + devices = append(devices, d) + } + if err = rows.Err(); err != nil { return nil, 0, err } return devices, total, nil } +// buildLabelFilterSQL produces SQL WHERE clause fragments and args for label filters. +// Each filter uses the JSONB extract operator (labels ->> '$.') with parameterized values. +// For "name" and "group" labels, it uses the indexed virtual columns directly. +func buildLabelFilterSQL(filters []LabelFilter) (string, []any, error) { + if len(filters) == 0 { + return "", nil, nil + } + + var sb strings.Builder + args := make([]any, 0, len(filters)) + for _, f := range filters { + if err := f.Validate(); err != nil { + return "", nil, err + } + + // Use indexed virtual columns for "name" and "group" labels; + // fall back to JSON extraction for all other labels. + var col string + switch f.Label { + case "name": + col = "name" + case "group": + col = "group_name" + default: + col = fmt.Sprintf("labels ->> '$.%s'", f.Label) + } + + switch f.Comparison { + case LabelCmpEqual: + fmt.Fprintf(&sb, " AND %s = ?", col) + args = append(args, f.Value) + case LabelCmpNotEqual: + fmt.Fprintf(&sb, " AND (%s IS NULL OR %s != ?)", col, col) + args = append(args, f.Value) + case LabelCmpContains: + fmt.Fprintf(&sb, " AND %s LIKE ?", col) + args = append(args, "%"+f.Value+"%") + case LabelCmpNotContains: + fmt.Fprintf(&sb, " AND (%s IS NULL OR %s NOT LIKE ?)", col, col) + args = append(args, "%"+f.Value+"%") + } + } + return sb.String(), args, nil +} + func (s Storage) DeviceGet(uuid string) (*Device, error) { d := Device{storage: s, DeviceListItem: DeviceListItem{Uuid: uuid}} var ( @@ -533,64 +641,6 @@ func (s *stmtDeviceGet) run( createdAt, lastSeen, pubkey, updateName, tag, targetName, ostreeHash, apps, labels, isProd) } -type stmtDeviceList storage.DbStmt - -func (s *stmtDeviceList) Init(db storage.DbHandle, orderBy string) (err error) { - s.Stmt, err = db.Prepare("apiDeviceList", fmt.Sprintf(` - SELECT - uuid, created_at, last_seen, target_name, tag, is_prod, json(labels) - FROM devices - WHERE deleted=false - ORDER BY %s LIMIT ? OFFSET ?`, orderBy), - ) - return -} - -func (s *stmtDeviceList) run(limit, offset int, dl *[]DeviceListItem) error { - if rows, err := s.Stmt.Query(limit, offset); err != nil { - return err - } else { - defer func() { - if err := rows.Close(); err != nil { - slog.Error("failed to close rows in device list", "error", err) - } - }() - for rows.Next() { - var ( - d DeviceListItem - labels []byte - ) - if err = rows.Scan( - &d.Uuid, &d.CreatedAt, &d.LastSeen, &d.Target, &d.Tag, &d.IsProd, &labels, - ); err != nil { - return err - } - if err = json.Unmarshal(labels, &d.Labels); err != nil { - return fmt.Errorf("failed to parse device labels: %w", err) - } - *dl = append(*dl, d) - } - if err = rows.Err(); err != nil { - return err - } - } - return nil -} - -type stmtDeviceCount storage.DbStmt - -func (s *stmtDeviceCount) Init(db storage.DbHandle) (err error) { - s.Stmt, err = db.Prepare("apiDeviceCount", ` - SELECT COUNT(*) FROM devices WHERE deleted=false`, - ) - return -} - -func (s *stmtDeviceCount) run() (count int, err error) { - err = s.Stmt.QueryRow().Scan(&count) - return -} - type stmtDeviceSetLabels storage.DbStmt func (s *stmtDeviceSetLabels) Init(db storage.DbHandle) (err error) { diff --git a/storage/api/api_storage_test.go b/storage/api/api_storage_test.go index ce7c88a0..dd7cf68e 100644 --- a/storage/api/api_storage_test.go +++ b/storage/api/api_storage_test.go @@ -222,3 +222,141 @@ func TestUploadConfigs(t *testing.T) { } }) } + +func TestDeviceListLabelFilters(t *testing.T) { + tmpdir := t.TempDir() + dbFile := filepath.Join(tmpdir, "sql.db") + db, err := storage.NewDb(dbFile) + require.Nil(t, err) + fs, err := storage.NewFs(tmpdir) + require.Nil(t, err) + + s, err := NewStorage(db, fs) + require.Nil(t, err) + + dg, err := gateway.NewStorage(db, fs) + require.Nil(t, err) + + // Create three devices + _, err = dg.DeviceCreate("dev-1", "pk1", false) + require.Nil(t, err) + _, err = dg.DeviceCreate("dev-2", "pk2", false) + require.Nil(t, err) + _, err = dg.DeviceCreate("dev-3", "pk3", false) + require.Nil(t, err) + + // Set labels via PatchDeviceLabels + strPtr := func(s string) *string { return &s } + require.Nil(t, s.PatchDeviceLabels(map[string]*string{ + "env": strPtr("production"), "region": strPtr("us-east"), + }, []string{"dev-1"})) + require.Nil(t, s.PatchDeviceLabels(map[string]*string{ + "env": strPtr("staging"), "region": strPtr("us-west"), + }, []string{"dev-2"})) + require.Nil(t, s.PatchDeviceLabels(map[string]*string{ + "env": strPtr("production"), "region": strPtr("eu-central"), + }, []string{"dev-3"})) + + opts := DeviceListOpts{Limit: 100, OrderBy: OrderByDeviceUuidAsc} + + t.Run("no filters returns all", func(t *testing.T) { + devices, count, err := s.DevicesList(opts) + require.Nil(t, err) + assert.Equal(t, 3, count) + assert.Equal(t, 3, len(devices)) + }) + + t.Run("eq filter", func(t *testing.T) { + o := opts + o.LabelFilters = []LabelFilter{ + {Label: "env", Value: "production", Comparison: LabelCmpEqual}, + } + devices, count, err := s.DevicesList(o) + require.Nil(t, err) + assert.Equal(t, 2, count) + require.Equal(t, 2, len(devices)) + assert.Equal(t, "dev-1", devices[0].Uuid) + assert.Equal(t, "dev-3", devices[1].Uuid) + }) + + t.Run("ne filter", func(t *testing.T) { + o := opts + o.LabelFilters = []LabelFilter{ + {Label: "env", Value: "production", Comparison: LabelCmpNotEqual}, + } + devices, count, err := s.DevicesList(o) + require.Nil(t, err) + assert.Equal(t, 1, count) + require.Equal(t, 1, len(devices)) + assert.Equal(t, "dev-2", devices[0].Uuid) + }) + + t.Run("contains filter", func(t *testing.T) { + o := opts + o.LabelFilters = []LabelFilter{ + {Label: "region", Value: "us", Comparison: LabelCmpContains}, + } + devices, count, err := s.DevicesList(o) + require.Nil(t, err) + assert.Equal(t, 2, count) + require.Equal(t, 2, len(devices)) + assert.Equal(t, "dev-1", devices[0].Uuid) + assert.Equal(t, "dev-2", devices[1].Uuid) + }) + + t.Run("ncontains filter", func(t *testing.T) { + o := opts + o.LabelFilters = []LabelFilter{ + {Label: "region", Value: "us", Comparison: LabelCmpNotContains}, + } + devices, count, err := s.DevicesList(o) + require.Nil(t, err) + assert.Equal(t, 1, count) + require.Equal(t, 1, len(devices)) + assert.Equal(t, "dev-3", devices[0].Uuid) + }) + + t.Run("multiple filters AND", func(t *testing.T) { + o := opts + o.LabelFilters = []LabelFilter{ + {Label: "env", Value: "production", Comparison: LabelCmpEqual}, + {Label: "region", Value: "eu", Comparison: LabelCmpContains}, + } + devices, count, err := s.DevicesList(o) + require.Nil(t, err) + assert.Equal(t, 1, count) + require.Equal(t, 1, len(devices)) + assert.Equal(t, "dev-3", devices[0].Uuid) + }) + + t.Run("filter on missing label returns none for eq", func(t *testing.T) { + o := opts + o.LabelFilters = []LabelFilter{ + {Label: "nonexistent", Value: "anything", Comparison: LabelCmpEqual}, + } + devices, count, err := s.DevicesList(o) + require.Nil(t, err) + assert.Equal(t, 0, count) + assert.Equal(t, 0, len(devices)) + }) + + t.Run("invalid comparison returns error", func(t *testing.T) { + o := opts + o.LabelFilters = []LabelFilter{ + {Label: "env", Value: "x", Comparison: "bad"}, + } + _, _, err := s.DevicesList(o) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "invalid label comparison") + }) + + t.Run("invalid label key returns error", func(t *testing.T) { + o := opts + o.LabelFilters = []LabelFilter{ + {Label: "bad key!", Value: "x", Comparison: LabelCmpEqual}, + } + _, _, err := s.DevicesList(o) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "invalid label filter key") + }) +} diff --git a/storage/db.go b/storage/db.go index 7179349e..04e231c5 100644 --- a/storage/db.go +++ b/storage/db.go @@ -52,6 +52,14 @@ func (d DbHandle) Close() error { return d.db.Close() } +func (d DbHandle) Query(query string, args ...any) (*sql.Rows, error) { + return d.db.Query(query, args...) +} + +func (d DbHandle) QueryRow(query string, args ...any) *sql.Row { + return d.db.QueryRow(query, args...) +} + func (d DbHandle) Prepare(name, query string) (stmt *sql.Stmt, err error) { if stmt, err = d.db.Prepare(query); err != nil { err = fmt.Errorf("unable to prepare '%s' statement: %w", name, err) diff --git a/storage/db_fake.go b/storage/db_fake.go index dbede123..91f9114c 100644 --- a/storage/db_fake.go +++ b/storage/db_fake.go @@ -27,6 +27,14 @@ func (d DbHandle) Close() error { return nil } +func (d DbHandle) Query(query string, args ...any) (*sql.Rows, error) { + return nil, nil +} + +func (d DbHandle) QueryRow(query string, args ...any) *sql.Row { + return nil +} + func (d DbHandle) Prepare(name, query string) (stmt *sql.Stmt, err error) { return nil, nil }
{{ if eq .Sort "uuid-asc" }} - UUID + UUID {{ else if eq .Sort "uuid-desc" }} - UUID + UUID {{ else }} - UUID + UUID {{ end }} {{ if eq .Sort "name-asc" }} - Name + Name {{ else if eq .Sort "name-desc" }} - Name + Name {{ else }} - Name + Name {{ end }} {{ if eq .Sort "created-at-asc" }} - Created at + Created at {{ else if eq .Sort "created-at-desc" }} - Created at + Created at {{ else }} - Created at + Created at {{ end }} {{ if eq .Sort "last-seen-asc" }} - Last seen + Last seen {{ else if eq .Sort "last-seen-desc" }} - Last seen + Last seen {{ else }} - Last seen + Last seen {{ end }} Target