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/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/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.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/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/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..bc8ce92637a 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,nullzero,default:'{}'"` 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/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) + } + }) + } +} 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 '{}'; 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/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: diff --git a/pkg/api/requests/device.go b/pkg/api/requests/device.go index 4d328cba559..98251369163 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,max=20,dive,keys,min=1,max=64,endkeys,max=256"` } // 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..f0f021089ac 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"` + Taggable `json:",inline" bson:",inline"` } 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 diff --git a/ui-react/apps/console/src/hooks/__tests__/useDevices.test.ts b/ui-react/apps/console/src/hooks/__tests__/useDevices.test.ts index 9f5ac8f7326..505cc9b5e9c 100644 --- a/ui-react/apps/console/src/hooks/__tests__/useDevices.test.ts +++ b/ui-react/apps/console/src/hooks/__tests__/useDevices.test.ts @@ -3,10 +3,13 @@ import { buildFilter } from "../useDevices"; describe("buildFilter", () => { describe("search only", () => { - it("encodes a name filter", () => { + it("encodes a name OR custom_fields filter", () => { const result = JSON.parse(atob(buildFilter("my-device", []))); expect(result).toEqual([ + { type: "operator", params: { name: "or" } }, { type: "property", params: { name: "name", operator: "contains", value: "my-device" } }, + { type: "operator", params: { name: "or" } }, + { type: "property", params: { name: "custom_fields", operator: "contains", value: "my-device" } }, ]); }); }); @@ -24,7 +27,10 @@ describe("buildFilter", () => { it("encodes both filters in the same array", () => { const result = JSON.parse(atob(buildFilter("srv", ["prod"]))); expect(result).toEqual([ + { type: "operator", params: { name: "or" } }, { type: "property", params: { name: "name", operator: "contains", value: "srv" } }, + { type: "operator", params: { name: "or" } }, + { type: "property", params: { name: "custom_fields", operator: "contains", value: "srv" } }, { type: "property", params: { name: "tags.name", operator: "contains", value: ["prod"] } }, ]); }); 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..1fab96ca6c5 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,132 @@ function RenameSection({ ); } +/* ─── Custom Fields Section ─── */ +function CustomFieldsSection({ + uid, + customFields, +}: { + uid: string; + customFields: Record; +}) { + const mutation = useUpdateDeviceCustomFields(); + const canEdit = useHasPermission("device:rename"); + 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} +
+ {canEdit && ( + confirmKey === key + ? ( +
+ Remove? + + +
+ ) + : ( + + ) + )} +
+ ))} +
+ {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}

} +
+ ); +} + /* ─── Page ─── */ export default function DeviceDetails() { const { uid } = useParams<{ uid: string }>(); @@ -588,9 +715,17 @@ export default function DeviceDetails() { - {/* Tags */} -
- + {/* Tags + Custom Fields */} +
+
+ +
+
+ +
{/* Delete Dialog */} 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 }) => ( +