From 8de06f4b07e6cc28b8e730a705ef6d4971182a13 Mon Sep 17 00:00:00 2001 From: Daniel Gatis Date: Tue, 28 Apr 2026 08:31:12 -0300 Subject: [PATCH 01/11] feat(pkg): add custom_fields to Device model and DeviceUpdate request Add `CustomFields map[string]string` to the Device model (BSON/JSON tagged) and a `*map[string]string` pointer field to DeviceUpdate so that omitting the field in a PUT payload leaves existing values untouched. --- pkg/api/requests/device.go | 7 ++++--- pkg/models/device.go | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/api/requests/device.go b/pkg/api/requests/device.go index 4d328cba559..6e4c94e0a40 100644 --- a/pkg/api/requests/device.go +++ b/pkg/api/requests/device.go @@ -14,9 +14,10 @@ type DeviceList struct { } type DeviceUpdate struct { - TenantID string `header:"X-Tenant-ID"` - UID string `param:"uid" validate:"required"` - Name string `json:"name" validate:"device_name,omitempty"` + TenantID string `header:"X-Tenant-ID"` + UID string `param:"uid" validate:"required"` + Name string `json:"name" validate:"device_name,omitempty"` + CustomFields *map[string]string `json:"custom_fields" validate:"omitempty"` } // DeviceParam is a structure to represent and validate a device UID as path param. diff --git a/pkg/models/device.go b/pkg/models/device.go index 063fcb42a5d..09443224d80 100644 --- a/pkg/models/device.go +++ b/pkg/models/device.go @@ -49,6 +49,8 @@ type Device struct { Position *DevicePosition `json:"position" bson:"position"` Acceptable bool `json:"acceptable" bson:"acceptable,omitempty"` + CustomFields map[string]string `json:"custom_fields,omitempty" bson:"custom_fields,omitempty"` + Taggable `json:",inline" bson:",inline"` } From b8b9c15187c2e3d5a0f3375b4a8ec6213bf75eae Mon Sep 17 00:00:00 2001 From: Daniel Gatis Date: Tue, 28 Apr 2026 08:31:55 -0300 Subject: [PATCH 02/11] feat(api): implement custom_fields storage and filtering for devices - Service: apply CustomFields from request when non-nil; register "custom_fields" as a valid filter field with "contains" operator - MongoDB: route custom_fields filter via ParseCustomFieldsFilter using \$objectToArray + \$regexMatch to search across all map values - PostgreSQL entity: add JSONB column mapping to Device entity - PostgreSQL filters: add fromCustomFieldsFilter that generates an EXISTS (jsonb_each_text) subquery for ILIKE value matching - Migration 002: add custom_fields jsonb column with default '{}' --- api/services/device.go | 5 +++ api/store/mongo/internal/filters.go | 40 +++++++++++++++++ api/store/mongo/query-options.go | 12 +++++ api/store/pg/entity/device.go | 45 ++++++++++--------- api/store/pg/internal/filters.go | 22 +++++++++ .../002_device_custom_fields.tx.down.sql | 1 + .../002_device_custom_fields.tx.up.sql | 1 + 7 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 api/store/pg/migrations/002_device_custom_fields.tx.down.sql create mode 100644 api/store/pg/migrations/002_device_custom_fields.tx.up.sql diff --git a/api/services/device.go b/api/services/device.go index 47daff1718e..1e3739ac907 100644 --- a/api/services/device.go +++ b/api/services/device.go @@ -30,6 +30,7 @@ var DeviceFilterFields = query.NewFieldConstraints(map[string][]string{ "info.platform": {"contains", "eq", "ne"}, "tags.name": {"contains", "eq"}, "online": {"bool", "eq"}, + "custom_fields": {"contains"}, }) // DeviceSortFields is the set of field names accepted in the sort_by query @@ -380,6 +381,10 @@ func (s *service) UpdateDevice(ctx context.Context, req *requests.DeviceUpdate) device.Name = strings.ToLower(req.Name) } + if req.CustomFields != nil { + device.CustomFields = *req.CustomFields + } + if err := s.store.DeviceUpdate(ctx, device); err != nil { // nolint:revive return err } diff --git a/api/store/mongo/internal/filters.go b/api/store/mongo/internal/filters.go index 9ada613a315..06f626a5ee6 100644 --- a/api/store/mongo/internal/filters.go +++ b/api/store/mongo/internal/filters.go @@ -144,3 +144,43 @@ func fromLt(value interface{}) (bson.M, error) { func fromNe(value interface{}) (bson.M, error) { return bson.M{"$ne": value}, nil } + +// ParseCustomFieldsFilter builds a MongoDB $match condition that searches across all values +// of the custom_fields document. Only "contains" with a string value is supported. +func ParseCustomFieldsFilter(fp *query.FilterProperty) (bson.M, bool, error) { + if fp.Operator != "contains" { + return nil, false, nil + } + + v, ok := fp.Value.(string) + if !ok { + return nil, false, errors.New("custom_fields contains filter requires a string value") + } + + // Use $objectToArray to iterate over all values in the custom_fields map, + // then check if any value matches the regex. + condition := bson.M{ + "$expr": bson.M{ + "$gt": bson.A{ + bson.M{ + "$size": bson.M{ + "$filter": bson.M{ + "input": bson.M{"$objectToArray": bson.M{"$ifNull": bson.A{"$custom_fields", bson.M{}}}}, + "as": "cf", + "cond": bson.M{ + "$regexMatch": bson.M{ + "input": "$$cf.v", + "regex": v, + "options": "i", + }, + }, + }, + }, + }, + 0, + }, + }, + } + + return condition, true, nil +} diff --git a/api/store/mongo/query-options.go b/api/store/mongo/query-options.go index 9051de75ccc..71c6b0a9b33 100644 --- a/api/store/mongo/query-options.go +++ b/api/store/mongo/query-options.go @@ -132,6 +132,18 @@ func (*queryOptions) Match(filters *query.Filters) store.QueryOption { return query.ErrFilterInvalid } + if param.Name == "custom_fields" { + condition, ok, err := internal.ParseCustomFieldsFilter(param) + switch { + case err != nil: + return query.ErrFilterPropertyInvalid + case ok: + conditions = append(conditions, condition) + } + + continue + } + property, ok, err := internal.ParseFilterProperty(param) switch { case err != nil: diff --git a/api/store/pg/entity/device.go b/api/store/pg/entity/device.go index b90a1e94da6..b0a845522b8 100644 --- a/api/store/pg/entity/device.go +++ b/api/store/pg/entity/device.go @@ -10,27 +10,28 @@ import ( type Device struct { bun.BaseModel `bun:"table:devices"` - ID string `bun:"id,pk"` - NamespaceID string `bun:"namespace_id,type:uuid"` - CreatedAt time.Time `bun:"created_at"` - UpdatedAt time.Time `bun:"updated_at"` - RemovedAt *time.Time `bun:"removed_at"` - LastSeen time.Time `bun:"last_seen"` - DisconnectedAt time.Time `bun:"disconnected_at,nullzero"` - Online bool `bun:",scanonly"` - Acceptable bool `bun:",scanonly"` - Status string `bun:"status"` - StatusUpdatedAt time.Time `bun:"status_updated_at"` - Name string `bun:"name"` - MAC string `bun:"mac"` - PublicKey string `bun:"public_key"` - Identifier string `bun:"identifier"` - PrettyName string `bun:"pretty_name"` - Version string `bun:"version"` - Arch string `bun:"arch"` - Platform string `bun:"platform"` - Longitude float64 `bun:"longitude,type:numeric"` - Latitude float64 `bun:"latitude,type:numeric"` + ID string `bun:"id,pk"` + NamespaceID string `bun:"namespace_id,type:uuid"` + CreatedAt time.Time `bun:"created_at"` + UpdatedAt time.Time `bun:"updated_at"` + RemovedAt *time.Time `bun:"removed_at"` + LastSeen time.Time `bun:"last_seen"` + DisconnectedAt time.Time `bun:"disconnected_at,nullzero"` + Online bool `bun:",scanonly"` + Acceptable bool `bun:",scanonly"` + Status string `bun:"status"` + StatusUpdatedAt time.Time `bun:"status_updated_at"` + Name string `bun:"name"` + MAC string `bun:"mac"` + PublicKey string `bun:"public_key"` + Identifier string `bun:"identifier"` + PrettyName string `bun:"pretty_name"` + Version string `bun:"version"` + Arch string `bun:"arch"` + Platform string `bun:"platform"` + Longitude float64 `bun:"longitude,type:numeric"` + Latitude float64 `bun:"latitude,type:numeric"` + CustomFields map[string]string `bun:"custom_fields,type:jsonb"` Namespace *Namespace `bun:"rel:belongs-to,join:namespace_id=id"` Tags []*Tag `bun:"m2m:device_tags,join:Device=Tag"` @@ -54,6 +55,7 @@ func DeviceFromModel(model *models.Device) *Device { StatusUpdatedAt: model.StatusUpdatedAt, Name: model.Name, PublicKey: model.PublicKey, + CustomFields: model.CustomFields, Tags: []*Tag{}, } @@ -112,6 +114,7 @@ func DeviceToModel(entity *Device) *models.Device { Namespace: "", DisconnectedAt: nil, RemoteAddr: "", + CustomFields: entity.CustomFields, Taggable: models.Taggable{ Tags: []models.Tag{}, }, diff --git a/api/store/pg/internal/filters.go b/api/store/pg/internal/filters.go index ef490ac97be..971332584a9 100644 --- a/api/store/pg/internal/filters.go +++ b/api/store/pg/internal/filters.go @@ -101,6 +101,11 @@ func ParseFilterProperty(fp *query.FilterProperty, tableAlias string) (string, [ return fromTagsFilter(fp.Operator, fp.Value) } + // custom_fields is a JSONB column; search across all values. + if fp.Name == "custom_fields" { + return fromCustomFieldsFilter(fp.Operator, fp.Value) + } + var condition string var args []any var err error @@ -280,3 +285,20 @@ func fromLt(column string, value any, tableAlias string) (string, []any, error) func fromNe(column string, value any, tableAlias string) (string, []any, error) { return "? <> ?", []any{qualifyColumn(mapColumnFromLegacyMongo(column), tableAlias), value}, nil } + +// fromCustomFieldsFilter searches across all values of the custom_fields JSONB column. +// Only "contains" is supported: it matches any value using ILIKE. +func fromCustomFieldsFilter(operator string, value any) (string, []any, bool, error) { + if operator != "contains" { + return "", nil, false, nil + } + + v, ok := value.(string) + if !ok { + return "", nil, false, ErrUnsupportedContainsType + } + + const sql = `EXISTS (SELECT 1 FROM jsonb_each_text("device"."custom_fields") WHERE value ILIKE ?)` + + return sql, []any{"%" + v + "%"}, true, nil +} diff --git a/api/store/pg/migrations/002_device_custom_fields.tx.down.sql b/api/store/pg/migrations/002_device_custom_fields.tx.down.sql new file mode 100644 index 00000000000..929dca77ee7 --- /dev/null +++ b/api/store/pg/migrations/002_device_custom_fields.tx.down.sql @@ -0,0 +1 @@ +ALTER TABLE devices DROP COLUMN IF EXISTS custom_fields; diff --git a/api/store/pg/migrations/002_device_custom_fields.tx.up.sql b/api/store/pg/migrations/002_device_custom_fields.tx.up.sql new file mode 100644 index 00000000000..b7fb6836e06 --- /dev/null +++ b/api/store/pg/migrations/002_device_custom_fields.tx.up.sql @@ -0,0 +1 @@ +ALTER TABLE devices ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'; From 1ebaab1cd16b9f6c419c1d3d5c6dd0ae9395f47d Mon Sep 17 00:00:00 2001 From: Daniel Gatis Date: Tue, 28 Apr 2026 08:32:00 -0300 Subject: [PATCH 03/11] feat(openapi): add custom_fields to device schema and PUT request body --- openapi/spec/components/schemas/device.yaml | 8 ++++++++ openapi/spec/paths/api@devices@{uid}.yaml | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/openapi/spec/components/schemas/device.yaml b/openapi/spec/components/schemas/device.yaml index fd1094b1467..f59151d4fdc 100644 --- a/openapi/spec/components/schemas/device.yaml +++ b/openapi/spec/components/schemas/device.yaml @@ -60,6 +60,14 @@ properties: example: -52.322474 tags: $ref: deviceTags.yaml + custom_fields: + description: User-defined key-value metadata for the device. + type: object + additionalProperties: + type: string + example: + env: production + owner: team-a public_url: $ref: devicePublicURL.yaml acceptable: diff --git a/openapi/spec/paths/api@devices@{uid}.yaml b/openapi/spec/paths/api@devices@{uid}.yaml index 096569ff926..a5ddafbdcbc 100644 --- a/openapi/spec/paths/api@devices@{uid}.yaml +++ b/openapi/spec/paths/api@devices@{uid}.yaml @@ -62,6 +62,14 @@ put: $ref: ../components/schemas/deviceName.yaml public_url: $ref: ../components/schemas/devicePublicURL.yaml + custom_fields: + description: User-defined key-value metadata. Replaces all existing custom fields. + type: object + additionalProperties: + type: string + example: + env: production + owner: team-a required: - name responses: From f216236a1265dcc77ac165eba5ce392609dcf088 Mon Sep 17 00:00:00 2001 From: Daniel Gatis Date: Tue, 28 Apr 2026 08:32:04 -0300 Subject: [PATCH 04/11] fix(docker): add binutils-gold to Alpine images for ARM64 linker support golangci-lint uses -fuse-ld=gold which requires binutils-gold on Alpine ARM64. Without it the build fails with "cannot find 'ld'". --- agent/Dockerfile | 2 +- api/Dockerfile | 2 +- cli/Dockerfile | 2 +- gateway/Dockerfile | 2 +- openapi/Dockerfile | 2 +- ssh/Dockerfile | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/agent/Dockerfile b/agent/Dockerfile index 180182b89c6..e05b14167f5 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -58,7 +58,7 @@ FROM base AS development ARG GOPROXY ENV GOPROXY ${GOPROXY} -RUN apk add --update openssl openssh-client util-linux setpriv +RUN apk add --update openssl build-base binutils-gold openssh-client util-linux setpriv RUN go install github.com/air-verse/air@v1.62 && \ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 diff --git a/api/Dockerfile b/api/Dockerfile index 689b93ad96e..20cea388956 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -75,7 +75,7 @@ ARG GOPROXY ARG MJML_VERSION ENV GOPROXY=${GOPROXY} -RUN apk add --update openssl build-base docker-cli npm +RUN apk add --update openssl build-base binutils-gold docker-cli npm RUN npm install -g mjml@${MJML_VERSION} RUN go install github.com/air-verse/air@v1.62 && \ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 && \ diff --git a/cli/Dockerfile b/cli/Dockerfile index 547ffd69b3c..c78323e41ee 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -32,7 +32,7 @@ RUN go build # development stage FROM builder AS development -RUN apk add --update openssl build-base docker-cli +RUN apk add --update openssl build-base binutils-gold docker-cli RUN go install github.com/air-verse/air@v1.62 && \ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 diff --git a/gateway/Dockerfile b/gateway/Dockerfile index bb2789ac884..10f8f09c787 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -39,7 +39,7 @@ RUN mkdir /etc/shellhub-gateway RUN mkdir -p /var/run/openresty /etc/letsencrypt && \ curl -sSL https://ssl-config.mozilla.org/ffdhe2048.txt -o /etc/shellhub-gateway/dhparam.pem -RUN apk add --update openssl build-base +RUN apk add --update openssl build-base binutils-gold RUN go install github.com/air-verse/air@v1.62 && \ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 && \ go install github.com/vektra/mockery/v2/...@v2.20.0 diff --git a/openapi/Dockerfile b/openapi/Dockerfile index d44badfcbaf..029822a0105 100644 --- a/openapi/Dockerfile +++ b/openapi/Dockerfile @@ -3,7 +3,7 @@ FROM golang:1.25.8-alpine3.22 AS base ARG GOPROXY ENV GOPROXY ${GOPROXY} -RUN apk add --no-cache openssl build-base nodejs npm openjdk11-jre git +RUN apk add --no-cache openssl build-base binutils-gold nodejs npm openjdk11-jre git RUN npm install -g @openapitools/openapi-generator-cli diff --git a/ssh/Dockerfile b/ssh/Dockerfile index 40e28aec51a..26e2e66654e 100644 --- a/ssh/Dockerfile +++ b/ssh/Dockerfile @@ -39,7 +39,7 @@ FROM base AS development ARG GOPROXY ENV GOPROXY ${GOPROXY} -RUN apk add --update openssl +RUN apk add --update openssl build-base binutils-gold RUN go install github.com/air-verse/air@v1.62 && \ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 && \ go install github.com/vektra/mockery/v2/...@v2.20.0 From c2cb388b678402ddcd2d46cbc97e729f1e14d655 Mon Sep 17 00:00:00 2001 From: Daniel Gatis Date: Tue, 28 Apr 2026 08:32:07 -0300 Subject: [PATCH 05/11] feat(ui): display custom_fields on device detail page --- ui/src/api/client/api.ts | 4 ++++ ui/src/interfaces/IDevice.ts | 1 + ui/src/views/DetailsDevice.vue | 28 ++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/ui/src/api/client/api.ts b/ui/src/api/client/api.ts index 003d0fdd60f..8387de503c4 100644 --- a/ui/src/api/client/api.ts +++ b/ui/src/api/client/api.ts @@ -434,6 +434,10 @@ export interface Device { * Device\'s acceptable The value \"acceptable\" is based on the number of devices removed and already accepted into a namespace. All devices are \"acceptable\" unless the \"namespace.max_devices\" is reached. This limit is set based on the sum up of accepted and removed devices into the namespace. When this limit is reached, only removed devices between 720 hours or 30 days are set to \"acceptable\". */ 'acceptable'?: boolean; + /** + * User-defined key-value metadata for the device. + */ + 'custom_fields'?: { [key: string]: string }; } diff --git a/ui/src/interfaces/IDevice.ts b/ui/src/interfaces/IDevice.ts index 1a30ec48fd4..067080c9789 100644 --- a/ui/src/interfaces/IDevice.ts +++ b/ui/src/interfaces/IDevice.ts @@ -36,6 +36,7 @@ export interface IDevice { remote_addr: string; position: Position; tags: Array; + custom_fields?: Record; } export interface IDeviceRename { diff --git a/ui/src/views/DetailsDevice.vue b/ui/src/views/DetailsDevice.vue index d1c87f91838..01433f410d0 100644 --- a/ui/src/views/DetailsDevice.vue +++ b/ui/src/views/DetailsDevice.vue @@ -182,6 +182,34 @@ + + Date: Tue, 28 Apr 2026 08:32:12 -0300 Subject: [PATCH 06/11] feat(ui-react): add custom fields management to devices - Device list: new Custom Fields column showing key/value badges with pill styling; search also queries custom field values - Device detail: CustomFieldsSection component with add form, inline delete confirmation, and useUpdateDeviceCustomFields mutation - useDevices: extend buildFilter to OR-search across custom_fields values - useDeviceMutations: export useUpdateDeviceCustomFields hook --- .../console/src/hooks/useDeviceMutations.ts | 8 + ui-react/apps/console/src/hooks/useDevices.ts | 10 +- .../apps/console/src/pages/DeviceDetails.tsx | 139 +++++++++++++++++- .../apps/console/src/pages/devices/index.tsx | 18 +++ 4 files changed, 168 insertions(+), 7 deletions(-) diff --git a/ui-react/apps/console/src/hooks/useDeviceMutations.ts b/ui-react/apps/console/src/hooks/useDeviceMutations.ts index fb72f02bf0b..0725ad1ef2b 100644 --- a/ui-react/apps/console/src/hooks/useDeviceMutations.ts +++ b/ui-react/apps/console/src/hooks/useDeviceMutations.ts @@ -42,6 +42,14 @@ export function useRenameDevice() { }); } +export function useUpdateDeviceCustomFields() { + const invalidate = useInvalidateByIds("getDevices", "getDevice"); + return useMutation({ + ...updateDeviceMutation(), + onSuccess: invalidate, + }); +} + export function useAddDeviceTag() { const invalidate = useInvalidateByIds("getDevices", "getDevice", "getStatusDevices", "getTags"); return useMutation({ diff --git a/ui-react/apps/console/src/hooks/useDevices.ts b/ui-react/apps/console/src/hooks/useDevices.ts index b21cad249a6..c66b96e559a 100644 --- a/ui-react/apps/console/src/hooks/useDevices.ts +++ b/ui-react/apps/console/src/hooks/useDevices.ts @@ -14,10 +14,12 @@ export type NormalizedDevice = Omit & { tags: string[] export function buildFilter(search: string, tags: string[]): string { const filters: Record[] = []; if (search) { - filters.push({ - type: "property", - params: { name: "name", operator: "contains", value: search }, - }); + filters.push( + { type: "operator", params: { name: "or" } }, + { type: "property", params: { name: "name", operator: "contains", value: search } }, + { type: "operator", params: { name: "or" } }, + { type: "property", params: { name: "custom_fields", operator: "contains", value: search } }, + ); } if (tags.length > 0) { filters.push({ diff --git a/ui-react/apps/console/src/pages/DeviceDetails.tsx b/ui-react/apps/console/src/pages/DeviceDetails.tsx index 815eb1e5556..0424e122d96 100644 --- a/ui-react/apps/console/src/pages/DeviceDetails.tsx +++ b/ui-react/apps/console/src/pages/DeviceDetails.tsx @@ -25,6 +25,7 @@ import { useAddDeviceTag, useRemoveDeviceTag, useRemoveDevice, + useUpdateDeviceCustomFields, } from "../hooks/useDeviceMutations"; import { useNamespace } from "../hooks/useNamespaces"; import { useAuthStore } from "../stores/authStore"; @@ -271,6 +272,129 @@ function RenameSection({ ); } +/* ─── Custom Fields Section ─── */ +function CustomFieldsSection({ + uid, + name, + customFields, +}: { + uid: string; + name: string; + customFields: Record; +}) { + const mutation = useUpdateDeviceCustomFields(); + const [keyInput, setKeyInput] = useState(""); + const [valueInput, setValueInput] = useState(""); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); + const [confirmKey, setConfirmKey] = useState(null); + + const handleAdd = async () => { + const key = keyInput.trim(); + const value = valueInput.trim(); + if (!key || !value) return; + if (key in customFields) { + setError("This key already exists."); + return; + } + setError(null); + setAdding(true); + try { + await mutation.mutateAsync({ + path: { uid }, + body: { name, custom_fields: { ...customFields, [key]: value } }, + }); + setKeyInput(""); + setValueInput(""); + } catch { + setError("Failed to add custom field."); + } + setAdding(false); + }; + + const handleRemove = async (key: string) => { + const updated = { ...customFields }; + delete updated[key]; + try { + await mutation.mutateAsync({ + path: { uid }, + body: { name, custom_fields: updated }, + }); + } catch { + /* invalidation handles UI update */ + } + }; + + return ( +
+

Custom Fields

+
+ {Object.entries(customFields).map(([key, value]) => ( +
+
+ {key}: + {value} +
+ {confirmKey === key + ? ( +
+ Remove? + + +
+ ) + : ( + + )} +
+ ))} +
+
+ { setKeyInput(e.target.value); setError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAdd(); } }} + placeholder="key" + className="w-24 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" + /> + : + { setValueInput(e.target.value); setError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAdd(); } }} + placeholder="value" + className="w-32 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" + /> + +
+ {error &&

{error}

} +
+ ); +} + /* ─── Page ─── */ export default function DeviceDetails() { const { uid } = useParams<{ uid: string }>(); @@ -588,9 +712,18 @@ export default function DeviceDetails() { - {/* Tags */} -
- + {/* Tags + Custom Fields */} +
+
+ +
+
+ +
{/* Delete Dialog */} diff --git a/ui-react/apps/console/src/pages/devices/index.tsx b/ui-react/apps/console/src/pages/devices/index.tsx index 15a72ef6ed9..e0c81c12cf1 100644 --- a/ui-react/apps/console/src/pages/devices/index.tsx +++ b/ui-react/apps/console/src/pages/devices/index.tsx @@ -135,6 +135,24 @@ export default function Devices() { ), }, + { + key: "custom_fields", + header: "Custom Fields", + render: (device) => { + const entries = Object.entries(device.custom_fields ?? {}); + if (entries.length === 0) return ; + return ( +
+ {entries.map(([k, v]) => ( + + {k} + {v} + + ))} +
+ ); + }, + }, { key: "last_seen", header: "Last Seen", From 8a7e9fb43f658451cc3e8139b72e16100009f1b6 Mon Sep 17 00:00:00 2001 From: Daniel Gatis Date: Tue, 28 Apr 2026 08:32:16 -0300 Subject: [PATCH 07/11] test(api): add tests for custom_fields filtering and service update - pg/internal: TestFromCustomFieldsFilter and TestParseFilterProperty_CustomFields - mongo/internal: TestParseCustomFieldsFilter covering contains, unsupported operators, and non-string value error - services: extend TestDeviceUpdate with cases for setting, clearing, and nil (no-op) custom fields --- api/services/device_test.go | 117 ++++++++++++++++ .../internal/filters_custom_fields_test.go | 104 ++++++++++++++ .../pg/internal/filters_custom_fields_test.go | 128 ++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 api/store/mongo/internal/filters_custom_fields_test.go create mode 100644 api/store/pg/internal/filters_custom_fields_test.go diff --git a/api/services/device_test.go b/api/services/device_test.go index a079fc8893e..6fd9c12ddd4 100644 --- a/api/services/device_test.go +++ b/api/services/device_test.go @@ -2411,6 +2411,123 @@ func TestDeviceUpdate(t *testing.T) { }, expected: nil, }, + { + description: "success when setting custom fields", + req: &requests.DeviceUpdate{ + UID: "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", + TenantID: "00000000-0000-0000-0000-000000000000", + Name: "existingname", + CustomFields: &map[string]string{"env": "production", "owner": "team-a"}, + }, + requiredMocks: func(ctx context.Context) { + device := &models.Device{ + UID: "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", + Name: "existingname", + DisconnectedAt: &now, + } + updatedDevice := &models.Device{ + UID: "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", + Name: "existingname", + DisconnectedAt: &now, + CustomFields: map[string]string{"env": "production", "owner": "team-a"}, + } + queryOptionsMock. + On("InNamespace", "00000000-0000-0000-0000-000000000000"). + Return(nil). + Once() + storeMock. + On("DeviceResolve", ctx, store.DeviceUIDResolver, "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", mock.AnythingOfType("store.QueryOption")). + Return(device, nil). + Once() + // Distinct clears Name when req.Name == device.Name + storeMock. + On("DeviceConflicts", ctx, &models.DeviceConflicts{Name: ""}). + Return([]string{}, false, nil). + Once() + storeMock. + On("DeviceUpdate", ctx, updatedDevice). + Return(nil). + Once() + }, + expected: nil, + }, + { + description: "success when clearing custom fields with empty map", + req: &requests.DeviceUpdate{ + UID: "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", + TenantID: "00000000-0000-0000-0000-000000000000", + Name: "existingname", + CustomFields: &map[string]string{}, + }, + requiredMocks: func(ctx context.Context) { + device := &models.Device{ + UID: "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", + Name: "existingname", + DisconnectedAt: &now, + CustomFields: map[string]string{"env": "production"}, + } + updatedDevice := &models.Device{ + UID: "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", + Name: "existingname", + DisconnectedAt: &now, + CustomFields: map[string]string{}, + } + queryOptionsMock. + On("InNamespace", "00000000-0000-0000-0000-000000000000"). + Return(nil). + Once() + storeMock. + On("DeviceResolve", ctx, store.DeviceUIDResolver, "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", mock.AnythingOfType("store.QueryOption")). + Return(device, nil). + Once() + // Distinct clears Name when req.Name == device.Name + storeMock. + On("DeviceConflicts", ctx, &models.DeviceConflicts{Name: ""}). + Return([]string{}, false, nil). + Once() + storeMock. + On("DeviceUpdate", ctx, updatedDevice). + Return(nil). + Once() + }, + expected: nil, + }, + { + description: "does not modify custom fields when CustomFields is nil", + req: &requests.DeviceUpdate{ + UID: "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", + TenantID: "00000000-0000-0000-0000-000000000000", + Name: "existingname", + CustomFields: nil, + }, + requiredMocks: func(ctx context.Context) { + device := &models.Device{ + UID: "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", + Name: "existingname", + DisconnectedAt: &now, + CustomFields: map[string]string{"env": "production"}, + } + queryOptionsMock. + On("InNamespace", "00000000-0000-0000-0000-000000000000"). + Return(nil). + Once() + storeMock. + On("DeviceResolve", ctx, store.DeviceUIDResolver, "d6c6a5e97217bbe4467eae46ab004695a766c5c43f70b95efd4b6a4d32b33c6e", mock.AnythingOfType("store.QueryOption")). + Return(device, nil). + Once() + // Distinct clears Name when req.Name == device.Name + storeMock. + On("DeviceConflicts", ctx, &models.DeviceConflicts{Name: ""}). + Return([]string{}, false, nil). + Once() + // device passed unchanged — CustomFields still has "env":"production" + storeMock. + On("DeviceUpdate", ctx, device). + Return(nil). + Once() + }, + expected: nil, + }, } service := NewService(storeMock, privateKey, publicKey, storecache.NewNullCache(), clientMock) diff --git a/api/store/mongo/internal/filters_custom_fields_test.go b/api/store/mongo/internal/filters_custom_fields_test.go new file mode 100644 index 00000000000..d07de38e064 --- /dev/null +++ b/api/store/mongo/internal/filters_custom_fields_test.go @@ -0,0 +1,104 @@ +package internal + +import ( + "testing" + + "github.com/shellhub-io/shellhub/pkg/api/query" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson" +) + +func TestParseCustomFieldsFilter(t *testing.T) { + cases := []struct { + description string + fp *query.FilterProperty + wantOk bool + wantErr bool + checkResult func(t *testing.T, result bson.M) + }{ + { + description: "returns not-ok for unsupported operator eq", + fp: &query.FilterProperty{Name: "custom_fields", Operator: "eq", Value: "prod"}, + wantOk: false, + wantErr: false, + checkResult: func(t *testing.T, result bson.M) { + assert.Nil(t, result) + }, + }, + { + description: "returns error when value is not a string", + fp: &query.FilterProperty{Name: "custom_fields", Operator: "contains", Value: 42}, + wantOk: false, + wantErr: true, + checkResult: func(t *testing.T, result bson.M) { + assert.Nil(t, result) + }, + }, + { + description: "returns $expr condition for contains with string value", + fp: &query.FilterProperty{Name: "custom_fields", Operator: "contains", Value: "production"}, + wantOk: true, + wantErr: false, + checkResult: func(t *testing.T, result bson.M) { + require.NotNil(t, result) + // Top-level key must be $expr + exprRaw, ok := result["$expr"] + require.True(t, ok, "result must have $expr key") + + expr, ok := exprRaw.(bson.M) + require.True(t, ok) + + // $expr.$gt must exist + gtRaw, ok := expr["$gt"] + require.True(t, ok, "$expr must have $gt") + + gt, ok := gtRaw.(bson.A) + require.True(t, ok) + require.Len(t, gt, 2) + + // Second element of $gt must be 0 (threshold) + assert.Equal(t, 0, gt[1]) + + // First element is the $size expression + sizeExpr, ok := gt[0].(bson.M) + require.True(t, ok) + _, hasSz := sizeExpr["$size"] + assert.True(t, hasSz, "$gt[0] must be a $size expression") + }, + }, + { + description: "regex contains the search value", + fp: &query.FilterProperty{Name: "custom_fields", Operator: "contains", Value: "team-a"}, + wantOk: true, + wantErr: false, + checkResult: func(t *testing.T, result bson.M) { + require.NotNil(t, result) + // Walk down to the $regexMatch input + expr := result["$expr"].(bson.M) + gt := expr["$gt"].(bson.A) + sizeExpr := gt[0].(bson.M) + filterExpr := sizeExpr["$size"].(bson.M) + filterMap := filterExpr["$filter"].(bson.M) + cond := filterMap["cond"].(bson.M) + regexMatch := cond["$regexMatch"].(bson.M) + + assert.Equal(t, "team-a", regexMatch["regex"]) + assert.Equal(t, "i", regexMatch["options"]) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + result, ok, err := ParseCustomFieldsFilter(tc.fp) + assert.Equal(t, tc.wantOk, ok) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + tc.checkResult(t, result) + }) + } +} diff --git a/api/store/pg/internal/filters_custom_fields_test.go b/api/store/pg/internal/filters_custom_fields_test.go new file mode 100644 index 00000000000..50b0d9bfcf9 --- /dev/null +++ b/api/store/pg/internal/filters_custom_fields_test.go @@ -0,0 +1,128 @@ +package internal + +import ( + "testing" + + "github.com/shellhub-io/shellhub/pkg/api/query" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFromCustomFieldsFilter(t *testing.T) { + cases := []struct { + description string + operator string + value any + wantSQL string + wantArgs []any + wantOk bool + wantErr error + }{ + { + description: "returns SQL for contains with string value", + operator: "contains", + value: "production", + wantSQL: `EXISTS (SELECT 1 FROM jsonb_each_text("device"."custom_fields") WHERE value ILIKE ?)`, + wantArgs: []any{"%production%"}, + wantOk: true, + wantErr: nil, + }, + { + description: "wraps value with %% wildcards", + operator: "contains", + value: "team", + wantSQL: `EXISTS (SELECT 1 FROM jsonb_each_text("device"."custom_fields") WHERE value ILIKE ?)`, + wantArgs: []any{"%team%"}, + wantOk: true, + wantErr: nil, + }, + { + description: "returns not-ok for unsupported operator eq", + operator: "eq", + value: "production", + wantSQL: "", + wantArgs: nil, + wantOk: false, + wantErr: nil, + }, + { + description: "returns not-ok for unsupported operator ne", + operator: "ne", + value: "production", + wantSQL: "", + wantArgs: nil, + wantOk: false, + wantErr: nil, + }, + { + description: "returns error when value is not a string", + operator: "contains", + value: 42, + wantSQL: "", + wantArgs: nil, + wantOk: false, + wantErr: ErrUnsupportedContainsType, + }, + { + description: "returns error when value is a slice", + operator: "contains", + value: []any{"a", "b"}, + wantSQL: "", + wantArgs: nil, + wantOk: false, + wantErr: ErrUnsupportedContainsType, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + sql, args, ok, err := fromCustomFieldsFilter(tc.operator, tc.value) + assert.Equal(t, tc.wantOk, ok) + assert.Equal(t, tc.wantSQL, sql) + assert.Equal(t, tc.wantArgs, args) + assert.Equal(t, tc.wantErr, err) + }) + } +} + +func TestParseFilterProperty_CustomFields(t *testing.T) { + cases := []struct { + description string + fp *query.FilterProperty + wantSQL string + wantArgs []any + wantOk bool + wantErr bool + }{ + { + description: "routes custom_fields contains to JSONB subquery", + fp: &query.FilterProperty{Name: "custom_fields", Operator: "contains", Value: "prod"}, + wantSQL: `EXISTS (SELECT 1 FROM jsonb_each_text("device"."custom_fields") WHERE value ILIKE ?)`, + wantArgs: []any{"%prod%"}, + wantOk: true, + wantErr: false, + }, + { + description: "returns error for custom_fields contains with non-string value", + fp: &query.FilterProperty{Name: "custom_fields", Operator: "contains", Value: 123}, + wantSQL: "", + wantArgs: nil, + wantOk: false, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(t *testing.T) { + sql, args, ok, err := ParseFilterProperty(tc.fp, "device") + assert.Equal(t, tc.wantOk, ok) + assert.Equal(t, tc.wantSQL, sql) + assert.Equal(t, tc.wantArgs, args) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} From 8f1e9387b7ebe7ef1831de60a784bbd61bb63fab Mon Sep 17 00:00:00 2001 From: Daniel Gatis Date: Tue, 28 Apr 2026 08:32:21 -0300 Subject: [PATCH 08/11] test(ui-react): add tests for devices list and device details custom fields - Devices list: rendering, loading, empty state, row navigation, custom fields column (empty, single field, multiple fields), error state - DeviceDetails: loading spinner, device data rendering, custom fields section display, add form, delete confirmation flow, mutation calls, duplicate key validation --- .../devices/__tests__/DeviceDetails.test.tsx | 309 ++++++++++++++++++ .../pages/devices/__tests__/Devices.test.tsx | 301 +++++++++++++++++ 2 files changed, 610 insertions(+) create mode 100644 ui-react/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx create mode 100644 ui-react/apps/console/src/pages/devices/__tests__/Devices.test.tsx diff --git a/ui-react/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx b/ui-react/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx new file mode 100644 index 00000000000..25a8cc4dec1 --- /dev/null +++ b/ui-react/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import React from "react"; +import type { Device } from "@/client"; + +// ── Module mocks ────────────────────────────────────────────────────────────── + +vi.mock("@/hooks/useDevice", () => ({ + useDevice: vi.fn(), +})); + +const mockUpdateCustomFields = vi.fn(); + +vi.mock("@/hooks/useDeviceMutations", () => ({ + useRenameDevice: () => ({ mutateAsync: vi.fn() }), + useAddDeviceTag: () => ({ mutateAsync: vi.fn() }), + useRemoveDeviceTag: () => ({ mutateAsync: vi.fn() }), + useRemoveDevice: () => ({ mutateAsync: vi.fn() }), + useUpdateDeviceCustomFields: () => ({ mutateAsync: mockUpdateCustomFields }), +})); + +vi.mock("@/hooks/useNamespaces", () => ({ + useNamespace: () => ({ namespace: { name: "my-ns" } }), +})); + +vi.mock("@/stores/authStore", () => ({ + useAuthStore: (sel: (s: { tenant: string }) => unknown) => + sel({ tenant: "tenant-1" }), +})); + +vi.mock("@/stores/terminalStore", () => ({ + useTerminalStore: (sel: (s: { sessions: []; restore: () => void }) => unknown) => + sel({ sessions: [], restore: vi.fn() }), +})); + +vi.mock("@/hooks/useHasPermission", () => ({ + useHasPermission: () => true, +})); + +vi.mock("@/components/common/CopyButton", () => ({ + default: ({ text }: { text: string }) => ( + + +
+ ) + : ( - - - ) - : ( - - )} + ) + )} ))} -
- { setKeyInput(e.target.value); setError(null); }} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAdd(); } }} - placeholder="key" - className="w-24 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" - /> - : - { setValueInput(e.target.value); setError(null); }} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAdd(); } }} - placeholder="value" - className="w-32 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" - /> - -
+ {canEdit && ( +
+ { setKeyInput(e.target.value); setError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAdd(); } }} + placeholder="key" + className="w-24 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" + /> + : + { setValueInput(e.target.value); setError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleAdd(); } }} + placeholder="value" + className="w-32 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" + /> + +
+ )} {error &&

{error}

} ); @@ -720,7 +723,6 @@ export default function DeviceDetails() {