feat: expand entity list and detail APIs with properties and related … (#1068)
This commit is contained in:
commit
506ad4c883
|
|
@ -323,6 +323,12 @@ entities:
|
|||
# Docs: https://docs.olivetin.app/entities/intro.html
|
||||
- file: entities/servers.yaml
|
||||
name: server
|
||||
icon: ssh
|
||||
properties:
|
||||
- name: hostname
|
||||
title: Hostname
|
||||
- name: ip
|
||||
title: IP
|
||||
|
||||
- file: entities/containers.json
|
||||
name: container
|
||||
|
|
|
|||
|
|
@ -247,6 +247,27 @@ export declare type ActionArgumentChoice = Message<"olivetin.api.v1.ActionArgume
|
|||
*/
|
||||
export declare const ActionArgumentChoiceSchema: GenMessage<ActionArgumentChoice>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.EntityRelatedAction
|
||||
*/
|
||||
export declare type EntityRelatedAction = Message<"olivetin.api.v1.EntityRelatedAction"> & {
|
||||
/**
|
||||
* @generated from field: olivetin.api.v1.Action action = 1;
|
||||
*/
|
||||
action?: Action | undefined;
|
||||
|
||||
/**
|
||||
* @generated from field: map<string, string> prefilled_arguments = 2;
|
||||
*/
|
||||
prefilledArguments: { [key: string]: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.EntityRelatedAction.
|
||||
* Use `create(EntityRelatedActionSchema)` to create a new message.
|
||||
*/
|
||||
export declare const EntityRelatedActionSchema: GenMessage<EntityRelatedAction>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.Entity
|
||||
*/
|
||||
|
|
@ -275,6 +296,16 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
|
|||
* @generated from field: map<string, string> fields = 5;
|
||||
*/
|
||||
fields: { [key: string]: string };
|
||||
|
||||
/**
|
||||
* @generated from field: repeated olivetin.api.v1.EntityRelatedAction related_actions = 6;
|
||||
*/
|
||||
relatedActions: EntityRelatedAction[];
|
||||
|
||||
/**
|
||||
* @generated from field: string icon = 7;
|
||||
*/
|
||||
icon: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1894,6 +1925,25 @@ export declare const GetActionBindingResponseSchema: GenMessage<GetActionBinding
|
|||
* @generated from message olivetin.api.v1.GetEntitiesRequest
|
||||
*/
|
||||
export declare type GetEntitiesRequest = Message<"olivetin.api.v1.GetEntitiesRequest"> & {
|
||||
/**
|
||||
* @generated from field: string entity_type = 1;
|
||||
*/
|
||||
entityType: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string filter = 2;
|
||||
*/
|
||||
filter: string;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 page = 3;
|
||||
*/
|
||||
page: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 page_size = 4;
|
||||
*/
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1936,6 +1986,21 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
|
|||
* @generated from field: repeated string used_on_dashboards = 3;
|
||||
*/
|
||||
usedOnDashboards: string[];
|
||||
|
||||
/**
|
||||
* @generated from field: string icon = 4;
|
||||
*/
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated olivetin.api.v1.EntityProperty properties = 5;
|
||||
*/
|
||||
properties: EntityProperty[];
|
||||
|
||||
/**
|
||||
* @generated from field: int32 total_instances = 6;
|
||||
*/
|
||||
totalInstances: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1944,6 +2009,27 @@ export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition
|
|||
*/
|
||||
export declare const EntityDefinitionSchema: GenMessage<EntityDefinition>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.EntityProperty
|
||||
*/
|
||||
export declare type EntityProperty = Message<"olivetin.api.v1.EntityProperty"> & {
|
||||
/**
|
||||
* @generated from field: string name = 1;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string title = 2;
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.EntityProperty.
|
||||
* Use `create(EntityPropertySchema)` to create a new message.
|
||||
*/
|
||||
export declare const EntityPropertySchema: GenMessage<EntityProperty>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.GetEntityRequest
|
||||
*/
|
||||
|
|
@ -2194,3 +2280,4 @@ export declare const OliveTinApiService: GenService<{
|
|||
output: typeof EntitySchema;
|
||||
},
|
||||
}>;
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -58,12 +58,19 @@ message ActionArgumentChoice {
|
|||
string title = 2;
|
||||
}
|
||||
|
||||
message EntityRelatedAction {
|
||||
Action action = 1;
|
||||
map<string, string> prefilled_arguments = 2;
|
||||
}
|
||||
|
||||
message Entity {
|
||||
string title = 1;
|
||||
string unique_key = 2;
|
||||
string type = 3;
|
||||
repeated string directories = 4;
|
||||
map<string, string> fields = 5;
|
||||
repeated EntityRelatedAction related_actions = 6;
|
||||
string icon = 7;
|
||||
}
|
||||
|
||||
message GetDashboardResponse {
|
||||
|
|
@ -428,6 +435,10 @@ message GetActionBindingResponse {
|
|||
}
|
||||
|
||||
message GetEntitiesRequest {
|
||||
string entity_type = 1;
|
||||
string filter = 2;
|
||||
int32 page = 3;
|
||||
int32 page_size = 4;
|
||||
}
|
||||
|
||||
message GetEntitiesResponse {
|
||||
|
|
@ -438,6 +449,14 @@ message EntityDefinition {
|
|||
string title = 1;
|
||||
repeated Entity instances = 2;
|
||||
repeated string used_on_dashboards = 3;
|
||||
string icon = 4;
|
||||
repeated EntityProperty properties = 5;
|
||||
int32 total_instances = 6;
|
||||
}
|
||||
|
||||
message EntityProperty {
|
||||
string name = 1;
|
||||
string title = 2;
|
||||
}
|
||||
|
||||
message GetEntityRequest {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,9 +4,14 @@ import (
|
|||
ctx "context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
|
@ -16,11 +21,6 @@ import (
|
|||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
acl "github.com/OliveTin/OliveTin/internal/acl"
|
||||
auth "github.com/OliveTin/OliveTin/internal/auth"
|
||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||
|
|
@ -1377,21 +1377,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
|
|||
}
|
||||
|
||||
entityMap := entities.GetEntities()
|
||||
entityNames := make([]string, 0, len(entityMap))
|
||||
for name := range entityMap {
|
||||
entityNames = append(entityNames, name)
|
||||
}
|
||||
sort.Strings(entityNames)
|
||||
|
||||
entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
|
||||
for _, name := range entityNames {
|
||||
def := &apiv1.EntityDefinition{
|
||||
Title: name,
|
||||
UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
|
||||
Instances: buildSortedEntityInstances(name, entityMap[name]),
|
||||
}
|
||||
entityDefinitions = append(entityDefinitions, def)
|
||||
}
|
||||
entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap)
|
||||
|
||||
res := &apiv1.GetEntitiesResponse{
|
||||
EntityDefinitions: entityDefinitions,
|
||||
|
|
@ -1400,7 +1386,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
|
|||
return connect.NewResponse(res), nil
|
||||
}
|
||||
|
||||
func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity) []*apiv1.Entity {
|
||||
func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity, properties []config.EntityProperty) []*apiv1.Entity {
|
||||
instanceKeys := make([]string, 0, len(entityInstances))
|
||||
for key := range entityInstances {
|
||||
instanceKeys = append(instanceKeys, key)
|
||||
|
|
@ -1414,6 +1400,7 @@ func buildSortedEntityInstances(entityType string, entityInstances map[string]*e
|
|||
Title: e.Title,
|
||||
UniqueKey: e.UniqueKey,
|
||||
Type: entityType,
|
||||
Fields: entityListFields(e.Data, properties),
|
||||
})
|
||||
}
|
||||
return instances
|
||||
|
|
@ -1515,17 +1502,100 @@ func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.Ge
|
|||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
|
||||
}
|
||||
|
||||
res := buildEntityResponse(entity, req.Msg.Type, api.cfg.Dashboards)
|
||||
res := buildEntityResponse(entity, req.Msg.Type, api.cfg)
|
||||
res.RelatedActions = api.relatedActionsForEntity(user, req.Msg.Type, entity)
|
||||
return connect.NewResponse(res), nil
|
||||
}
|
||||
|
||||
func buildEntityResponse(entity *entities.Entity, entityType string, dashboards []*config.DashboardComponent) *apiv1.Entity {
|
||||
func entityTypeIcon(cfg *config.Config, entityType string) string {
|
||||
entityFile := entityFileForType(cfg, entityType)
|
||||
if entityFile == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return entityFile.Icon
|
||||
}
|
||||
|
||||
func entityFileForType(cfg *config.Config, entityType string) *config.EntityFile {
|
||||
for _, entityFile := range cfg.Entities {
|
||||
if entityFile != nil && entityFile.Name == entityType {
|
||||
return entityFile
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func entityPropertiesFromFile(entityFile *config.EntityFile) []config.EntityProperty {
|
||||
if entityFile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return entityFile.Properties
|
||||
}
|
||||
|
||||
func entityDefinitionProperties(properties []config.EntityProperty) []*apiv1.EntityProperty {
|
||||
if len(properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]*apiv1.EntityProperty, 0, len(properties))
|
||||
for _, property := range properties {
|
||||
result = append(result, &apiv1.EntityProperty{
|
||||
Name: property.Name,
|
||||
Title: property.Title,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func entityListFields(data any, properties []config.EntityProperty) map[string]string {
|
||||
if len(properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make(map[string]string, len(properties))
|
||||
for _, property := range properties {
|
||||
fields[property.Name] = entityPropertyValue(data, property.Name)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func entityPropertyValue(data any, propertyName string) string {
|
||||
dataMap, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
if value, found := dataMap[propertyName]; found {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
|
||||
return entityPropertyValueCaseInsensitive(dataMap, propertyName)
|
||||
}
|
||||
|
||||
func entityPropertyValueCaseInsensitive(dataMap map[string]any, propertyName string) string {
|
||||
propertyNameLower := strings.ToLower(propertyName)
|
||||
for key, value := range dataMap {
|
||||
if strings.ToLower(key) == propertyNameLower {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildEntityResponse(entity *entities.Entity, entityType string, cfg *config.Config) *apiv1.Entity {
|
||||
properties := entityPropertiesFromFile(entityFileForType(cfg, entityType))
|
||||
res := &apiv1.Entity{
|
||||
Title: entity.Title,
|
||||
UniqueKey: entity.UniqueKey,
|
||||
Type: entityType,
|
||||
Directories: findDirectoriesInEntityFieldsets(entityType, dashboards),
|
||||
Fields: serializeEntityFields(entity.Data),
|
||||
Directories: findDirectoriesInEntityFieldsets(entityType, cfg.Dashboards),
|
||||
Fields: entityFieldsForResponse(entity.Data, properties),
|
||||
Icon: entityTypeIcon(cfg, entityType),
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
)
|
||||
|
||||
const defaultEntityInstancesPageSize = 10
|
||||
|
||||
func (api *oliveTinAPI) buildEntityDefinitionsResponse(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
|
||||
if req != nil && req.EntityType != "" {
|
||||
return api.buildFilteredEntityDefinitions(req, entityMap)
|
||||
}
|
||||
|
||||
return api.buildAllEntityDefinitions(entityMap)
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) buildAllEntityDefinitions(entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
|
||||
entityNames := sortedEntityTypeNames(entityMap)
|
||||
entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
|
||||
|
||||
for _, name := range entityNames {
|
||||
entityFile := entityFileForType(api.cfg, name)
|
||||
properties := entityPropertiesFromFile(entityFile)
|
||||
instances := buildSortedEntityInstances(name, entityMap[name], properties)
|
||||
|
||||
def := &apiv1.EntityDefinition{
|
||||
Title: name,
|
||||
UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
|
||||
Icon: entityTypeIcon(api.cfg, name),
|
||||
Properties: entityDefinitionProperties(properties),
|
||||
TotalInstances: int32(len(instances)),
|
||||
}
|
||||
|
||||
if len(properties) == 0 {
|
||||
def.Instances = instances
|
||||
}
|
||||
|
||||
entityDefinitions = append(entityDefinitions, def)
|
||||
}
|
||||
|
||||
return entityDefinitions
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) buildFilteredEntityDefinitions(req *apiv1.GetEntitiesRequest, entityMap entities.EntitiesByClass) []*apiv1.EntityDefinition {
|
||||
entityInstances, ok := entityMap[req.EntityType]
|
||||
if !ok || len(entityInstances) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
entityFile := entityFileForType(api.cfg, req.EntityType)
|
||||
properties := entityPropertiesFromFile(entityFile)
|
||||
instances := buildSortedEntityInstances(req.EntityType, entityInstances, properties)
|
||||
filtered := filterEntityInstances(instances, req.Filter)
|
||||
pageSize := normalizeEntityInstancesPageSize(req.PageSize)
|
||||
page := normalizeEntityInstancesPage(req.Page)
|
||||
|
||||
def := &apiv1.EntityDefinition{
|
||||
Title: req.EntityType,
|
||||
UsedOnDashboards: findDashboardsForEntity(req.EntityType, api.cfg.Dashboards),
|
||||
Icon: entityTypeIcon(api.cfg, req.EntityType),
|
||||
Properties: entityDefinitionProperties(properties),
|
||||
TotalInstances: int32(len(filtered)),
|
||||
Instances: paginateEntityInstances(filtered, page, pageSize),
|
||||
}
|
||||
|
||||
return []*apiv1.EntityDefinition{def}
|
||||
}
|
||||
|
||||
func sortedEntityTypeNames(entityMap entities.EntitiesByClass) []string {
|
||||
entityNames := make([]string, 0, len(entityMap))
|
||||
for name := range entityMap {
|
||||
entityNames = append(entityNames, name)
|
||||
}
|
||||
sort.Strings(entityNames)
|
||||
return entityNames
|
||||
}
|
||||
|
||||
func normalizeEntityInstancesPage(page int32) int32 {
|
||||
if page < 1 {
|
||||
return 1
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func normalizeEntityInstancesPageSize(pageSize int32) int32 {
|
||||
if pageSize < 1 {
|
||||
return defaultEntityInstancesPageSize
|
||||
}
|
||||
return pageSize
|
||||
}
|
||||
|
||||
func filterEntityInstances(instances []*apiv1.Entity, filter string) []*apiv1.Entity {
|
||||
filter = strings.TrimSpace(strings.ToLower(filter))
|
||||
if filter == "" {
|
||||
return instances
|
||||
}
|
||||
|
||||
filtered := make([]*apiv1.Entity, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
if entityInstanceMatchesFilter(instance, filter) {
|
||||
filtered = append(filtered, instance)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func entityInstanceMatchesFilter(instance *apiv1.Entity, filter string) bool {
|
||||
if strings.Contains(strings.ToLower(instance.Title), filter) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, value := range instance.Fields {
|
||||
if strings.Contains(strings.ToLower(value), filter) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func paginateEntityInstances(instances []*apiv1.Entity, page, pageSize int32) []*apiv1.Entity {
|
||||
start := int((page - 1) * pageSize)
|
||||
if start >= len(instances) {
|
||||
return []*apiv1.Entity{}
|
||||
}
|
||||
|
||||
end := start + int(pageSize)
|
||||
if end > len(instances) {
|
||||
end = len(instances)
|
||||
}
|
||||
|
||||
return instances[start:end]
|
||||
}
|
||||
|
||||
func entityFieldsForResponse(data any, properties []config.EntityProperty) map[string]string {
|
||||
if len(properties) > 0 {
|
||||
return entityListFields(data, properties)
|
||||
}
|
||||
|
||||
return serializeEntityFields(data)
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
)
|
||||
|
||||
func TestGetEntitiesPaginatesAndFiltersInstances(t *testing.T) {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
entities.AddEntity("server", "0", map[string]any{"name": "alpha", "hostname": "alpha.example.com", "ip": "10.0.0.1"})
|
||||
entities.AddEntity("server", "1", map[string]any{"name": "beta", "hostname": "beta.example.com", "ip": "10.0.0.2"})
|
||||
entities.AddEntity("server", "2", map[string]any{"name": "gamma", "hostname": "gamma.example.com", "ip": "10.0.0.3"})
|
||||
t.Cleanup(func() {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
})
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Entities = []*config.EntityFile{
|
||||
{
|
||||
Name: "server",
|
||||
Properties: []config.EntityProperty{
|
||||
{Name: "hostname", Title: "Hostname"},
|
||||
{Name: "ip", Title: "IP"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.Sanitize()
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
filteredResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
|
||||
EntityType: "server",
|
||||
Filter: "beta",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, filteredResp.Msg.EntityDefinitions, 1)
|
||||
assert.Equal(t, int32(1), filteredResp.Msg.EntityDefinitions[0].TotalInstances)
|
||||
require.Len(t, filteredResp.Msg.EntityDefinitions[0].Instances, 1)
|
||||
assert.Equal(t, "beta.example.com", filteredResp.Msg.EntityDefinitions[0].Instances[0].Fields["hostname"])
|
||||
|
||||
pagedResp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
|
||||
EntityType: "server",
|
||||
Page: 2,
|
||||
PageSize: 1,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pagedResp.Msg.EntityDefinitions, 1)
|
||||
assert.Equal(t, int32(3), pagedResp.Msg.EntityDefinitions[0].TotalInstances)
|
||||
require.Len(t, pagedResp.Msg.EntityDefinitions[0].Instances, 1)
|
||||
assert.Equal(t, "1", pagedResp.Msg.EntityDefinitions[0].Instances[0].UniqueKey)
|
||||
}
|
||||
|
||||
func TestGetEntityRestrictsFieldsToConfiguredProperties(t *testing.T) {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
entities.AddEntity("server", "0", map[string]any{
|
||||
"name": "alpha",
|
||||
"hostname": "alpha.example.com",
|
||||
"ip": "10.0.0.1",
|
||||
"groups": []string{"admins"},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
entities.ClearEntitiesOfType("server")
|
||||
})
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Entities = []*config.EntityFile{
|
||||
{
|
||||
Name: "server",
|
||||
Properties: []config.EntityProperty{
|
||||
{Name: "hostname", Title: "Hostname"},
|
||||
{Name: "ip", Title: "IP"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.Sanitize()
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
|
||||
Type: "server",
|
||||
UniqueKey: "0",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.Msg)
|
||||
|
||||
assert.Equal(t, "alpha.example.com", resp.Msg.Fields["hostname"])
|
||||
assert.Equal(t, "10.0.0.1", resp.Msg.Fields["ip"])
|
||||
assert.NotContains(t, resp.Msg.Fields, "groups")
|
||||
assert.NotContains(t, resp.Msg.Fields, "name")
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
"github.com/OliveTin/OliveTin/internal/tpl"
|
||||
)
|
||||
|
||||
type relatedActionCandidate struct {
|
||||
binding *executor.ActionBinding
|
||||
prefilled map[string]string
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) relatedActionsForEntity(user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []*apiv1.EntityRelatedAction {
|
||||
rr := api.createDashboardRenderRequest(user, entityType, entity.UniqueKey)
|
||||
populateActiveBindingStates(rr)
|
||||
|
||||
candidates := collectRelatedActionCandidates(api, user, entityType, entity)
|
||||
sortRelatedActionCandidates(candidates)
|
||||
|
||||
return buildEntityRelatedActions(candidates, rr)
|
||||
}
|
||||
|
||||
func collectRelatedActionCandidates(api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity) []relatedActionCandidate {
|
||||
seen := make(map[string]bool)
|
||||
candidates := make([]relatedActionCandidate, 0)
|
||||
|
||||
api.executor.MapActionBindingsLock.RLock()
|
||||
defer api.executor.MapActionBindingsLock.RUnlock()
|
||||
|
||||
for _, binding := range api.executor.MapActionBindings {
|
||||
tryAppendRelatedCandidate(&candidates, seen, api, user, entityType, entity, binding)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
func tryAppendRelatedCandidate(candidates *[]relatedActionCandidate, seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType string, entity *entities.Entity, binding *executor.ActionBinding) {
|
||||
prefilled, ok := relatedPrefillForBinding(binding, entityType, entity)
|
||||
if !ok || !bindingViewableForRelated(seen, api, user, binding) {
|
||||
return
|
||||
}
|
||||
|
||||
seen[binding.ID] = true
|
||||
*candidates = append(*candidates, relatedActionCandidate{
|
||||
binding: binding,
|
||||
prefilled: prefilled,
|
||||
})
|
||||
}
|
||||
|
||||
func bindingViewableForRelated(seen map[string]bool, api *oliveTinAPI, user *authpublic.AuthenticatedUser, binding *executor.ActionBinding) bool {
|
||||
return binding != nil && binding.Action != nil && !seen[binding.ID] && api.userCanViewAction(user, binding.Action)
|
||||
}
|
||||
|
||||
func relatedPrefillForBinding(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
|
||||
if isEntityBoundBindingFor(binding, entityType, entity) {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
return argumentEntityPrefill(binding, entityType, entity)
|
||||
}
|
||||
|
||||
func argumentEntityPrefill(binding *executor.ActionBinding, entityType string, entity *entities.Entity) (map[string]string, bool) {
|
||||
if binding == nil || binding.Entity != nil || binding.Action == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
prefilled := buildPrefilledArgumentsForEntity(binding.Action, entityType, entity)
|
||||
return prefilled, len(prefilled) > 0
|
||||
}
|
||||
|
||||
func isEntityBoundBindingFor(binding *executor.ActionBinding, entityType string, entity *entities.Entity) bool {
|
||||
if entity == nil || !bindingHasEntity(binding) {
|
||||
return false
|
||||
}
|
||||
|
||||
return binding.Action.Entity == entityType && binding.Entity.UniqueKey == entity.UniqueKey
|
||||
}
|
||||
|
||||
func bindingHasEntity(binding *executor.ActionBinding) bool {
|
||||
return binding != nil && binding.Entity != nil && binding.Action != nil
|
||||
}
|
||||
|
||||
func buildPrefilledArgumentsForEntity(action *config.Action, entityType string, entity *entities.Entity) map[string]string {
|
||||
prefilled := make(map[string]string)
|
||||
|
||||
for i := range action.Arguments {
|
||||
arg := &action.Arguments[i]
|
||||
if arg.Entity != entityType || len(arg.Choices) != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
prefilled[arg.Name] = tpl.ParseTemplateOfActionBeforeExec(arg.Choices[0].Value, entity)
|
||||
}
|
||||
|
||||
return prefilled
|
||||
}
|
||||
|
||||
func sortRelatedActionCandidates(candidates []relatedActionCandidate) {
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].binding.ConfigOrder < candidates[j].binding.ConfigOrder
|
||||
})
|
||||
}
|
||||
|
||||
func buildEntityRelatedActions(candidates []relatedActionCandidate, rr *DashboardRenderRequest) []*apiv1.EntityRelatedAction {
|
||||
result := make([]*apiv1.EntityRelatedAction, 0, len(candidates))
|
||||
|
||||
for _, candidate := range candidates {
|
||||
action := buildAction(candidate.binding, rr)
|
||||
if action == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, &apiv1.EntityRelatedAction{
|
||||
Action: action,
|
||||
PrefilledArguments: candidate.prefilled,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
)
|
||||
|
||||
func setupHostEntityTestData(t *testing.T) {
|
||||
t.Helper()
|
||||
entities.ClearEntitiesOfType("host")
|
||||
entities.AddEntity("host", "0", map[string]any{"name": "stuffbox", "hostname": "192.168.66.8"})
|
||||
entities.AddEntity("host", "1", map[string]any{"name": "lurker", "hostname": "192.168.66.1"})
|
||||
t.Cleanup(func() {
|
||||
entities.ClearEntitiesOfType("host")
|
||||
})
|
||||
}
|
||||
|
||||
func buildRelatedActionsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
|
||||
t.Helper()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.DefaultPermissions.View = false
|
||||
cfg.DefaultPermissions.Exec = false
|
||||
|
||||
cfg.Actions = append(cfg.Actions,
|
||||
&config.Action{
|
||||
Title: "Secret Entity Action",
|
||||
Shell: "echo secret",
|
||||
Entity: "host",
|
||||
},
|
||||
&config.Action{
|
||||
Title: "Hidden Host Action",
|
||||
Shell: "echo hidden",
|
||||
Entity: "host",
|
||||
Hidden: true,
|
||||
},
|
||||
&config.Action{
|
||||
ID: "run_playbook",
|
||||
Title: "Run Automation Playbook",
|
||||
Shell: "host '{{ ansible_host }}'",
|
||||
Arguments: []config.ActionArgument{
|
||||
{
|
||||
Name: "ansible_host",
|
||||
Title: "Host",
|
||||
Entity: "host",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
&config.Action{
|
||||
Title: "Public Host Action",
|
||||
Shell: "echo public",
|
||||
Entity: "host",
|
||||
},
|
||||
)
|
||||
|
||||
cfg.AccessControlLists = append(cfg.AccessControlLists,
|
||||
&config.AccessControlList{
|
||||
Name: "restricted",
|
||||
MatchUsernames: []string{"low"},
|
||||
AddToEveryAction: true,
|
||||
Permissions: config.PermissionsList{View: false, Exec: false, Logs: false, Kill: false},
|
||||
},
|
||||
&config.AccessControlList{
|
||||
Name: "full",
|
||||
MatchUsernames: []string{"admin"},
|
||||
AddToEveryAction: true,
|
||||
Permissions: config.PermissionsList{View: true, Exec: true, Logs: true, Kill: true},
|
||||
},
|
||||
)
|
||||
|
||||
cfg.Entities = []*config.EntityFile{
|
||||
{File: "hosts.yaml", Name: "host", Icon: "ssh"},
|
||||
}
|
||||
cfg.Sanitize()
|
||||
|
||||
lowUser := &authpublic.AuthenticatedUser{Username: "low", Acls: []string{"restricted"}}
|
||||
adminUser := &authpublic.AuthenticatedUser{Username: "admin", Acls: []string{"full"}}
|
||||
|
||||
return cfg, lowUser, adminUser
|
||||
}
|
||||
|
||||
func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
|
||||
t.Helper()
|
||||
|
||||
entity, ok := entities.GetEntityInstances(entityType)[entityKey]
|
||||
require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
|
||||
|
||||
related := api.relatedActionsForEntity(user, entityType, entity)
|
||||
titles := make([]string, 0, len(related))
|
||||
for _, item := range related {
|
||||
if item.Action != nil {
|
||||
titles = append(titles, item.Action.Title)
|
||||
}
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
|
||||
t.Helper()
|
||||
|
||||
entity, ok := entities.GetEntityInstances(entityType)[entityKey]
|
||||
require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
|
||||
|
||||
related := api.relatedActionsForEntity(user, entityType, entity)
|
||||
ids := make([]string, 0, len(related))
|
||||
for _, item := range related {
|
||||
if item.Action != nil {
|
||||
ids = append(ids, item.Action.BindingId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsDeniesRestrictedView(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg, lowUser, _ := buildRelatedActionsTestConfig(t)
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
|
||||
ids := getEntityRelatedBindingIDs(t, api, lowUser, "host", "0")
|
||||
assert.Empty(t, ids)
|
||||
|
||||
titles := getEntityRelatedActionTitles(t, api, lowUser, "host", "0")
|
||||
assert.Empty(t, titles)
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsAllowsAdminView(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
|
||||
titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
|
||||
assert.Contains(t, titles, "Run Automation Playbook")
|
||||
assert.Contains(t, titles, "Public Host Action")
|
||||
assert.Contains(t, titles, "Secret Entity Action")
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsExcludesHiddenActions(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
|
||||
titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
|
||||
assert.NotContains(t, titles, "Hidden Host Action")
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsEntityBoundMatchesInstanceOnly(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Actions = append(cfg.Actions, &config.Action{
|
||||
Title: "{{ host.name }} Wake",
|
||||
Shell: "echo wake",
|
||||
Entity: "host",
|
||||
})
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
|
||||
|
||||
titlesHost0 := getEntityRelatedActionTitles(t, api, user, "host", "0")
|
||||
assert.Len(t, titlesHost0, 1)
|
||||
assert.Equal(t, "stuffbox Wake", titlesHost0[0])
|
||||
|
||||
titlesHost1 := getEntityRelatedActionTitles(t, api, user, "host", "1")
|
||||
assert.Len(t, titlesHost1, 1)
|
||||
assert.Equal(t, "lurker Wake", titlesHost1[0])
|
||||
}
|
||||
|
||||
func TestGetEntityRelatedActionsPrefillsArgumentEntityValues(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Actions = append(cfg.Actions, &config.Action{
|
||||
ID: "run_playbook",
|
||||
Title: "Run Automation Playbook",
|
||||
Shell: "host '{{ ansible_host }}'",
|
||||
Arguments: []config.ActionArgument{
|
||||
{
|
||||
Name: "ansible_host",
|
||||
Title: "Host",
|
||||
Entity: "host",
|
||||
Choices: []config.ActionArgumentChoice{
|
||||
{Title: "{{ host.name }} ({{ host.hostname }})", Value: "{{ host.hostname }}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
api := newServer(ex)
|
||||
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
|
||||
|
||||
entity := entities.GetEntityInstances("host")["0"]
|
||||
related := api.relatedActionsForEntity(user, "host", entity)
|
||||
require.Len(t, related, 1)
|
||||
require.NotNil(t, related[0].Action)
|
||||
assert.Equal(t, "run_playbook", related[0].Action.BindingId)
|
||||
assert.Equal(t, "192.168.66.8", related[0].PrefilledArguments["ansible_host"])
|
||||
}
|
||||
|
||||
func TestGetEntityDeniesGuestsWhenLoginRequired(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.AuthRequireGuestsToLogin = true
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
_, err := client.GetEntity(context.Background(), connect.NewRequest(&apiv1.GetEntityRequest{
|
||||
Type: "host",
|
||||
UniqueKey: "0",
|
||||
}))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestGetEntityReturnsRelatedActionsForAdmin(t *testing.T) {
|
||||
setupHostEntityTestData(t)
|
||||
cfg, _, adminUser := buildRelatedActionsTestConfig(t)
|
||||
cfg.AuthHttpHeaderUsername = "X-Ot-User"
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
|
||||
defer ts.Close()
|
||||
|
||||
req := connect.NewRequest(&apiv1.GetEntityRequest{
|
||||
Type: "host",
|
||||
UniqueKey: "0",
|
||||
})
|
||||
req.Header().Set("X-Ot-User", adminUser.Username)
|
||||
|
||||
resp, err := client.GetEntity(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.Msg)
|
||||
assert.Equal(t, "🔐", resp.Msg.Icon)
|
||||
|
||||
foundPlaybook := false
|
||||
for _, related := range resp.Msg.RelatedActions {
|
||||
if related.Action != nil && related.Action.BindingId == "run_playbook" {
|
||||
foundPlaybook = true
|
||||
assert.Equal(t, "192.168.66.8", related.PrefilledArguments["ansible_host"])
|
||||
}
|
||||
}
|
||||
assert.True(t, foundPlaybook, "admin should see argument-entity related action in GetEntity response")
|
||||
}
|
||||
|
|
@ -120,6 +120,15 @@ func TestGetActionsAndStart(t *testing.T) {
|
|||
|
||||
func TestGetEntities(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Entities = []*config.EntityFile{
|
||||
{
|
||||
Name: "server",
|
||||
Properties: []config.EntityProperty{
|
||||
{Name: "hostname", Title: "Hostname"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.Sanitize()
|
||||
|
||||
ts, client := getNewTestServerAndClient(cfg)
|
||||
defer ts.Close()
|
||||
|
|
@ -138,6 +147,25 @@ func TestGetEntities(t *testing.T) {
|
|||
validateEntityOrderAndStructure(t, entityDefinitions)
|
||||
validateNoDuplicates(t, entityDefinitions)
|
||||
validateConsistency(t, client, entityDefinitions)
|
||||
validateEntityListProperties(t, client)
|
||||
}
|
||||
|
||||
func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiServiceClient) {
|
||||
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
|
||||
EntityType: "server",
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
|
||||
serverDef := resp.Msg.EntityDefinitions[0]
|
||||
require.NotNil(t, serverDef, "server entity definition should be present")
|
||||
require.Len(t, serverDef.Properties, 1)
|
||||
assert.Equal(t, "hostname", serverDef.Properties[0].Name)
|
||||
assert.Equal(t, "Hostname", serverDef.Properties[0].Title)
|
||||
assert.Equal(t, int32(3), serverDef.TotalInstances)
|
||||
require.Len(t, serverDef.Instances, 3)
|
||||
assert.Equal(t, "alpha.example.com", serverDef.Instances[0].Fields["hostname"])
|
||||
}
|
||||
|
||||
func setupTestEntities() {
|
||||
|
|
@ -166,10 +194,8 @@ func validateEntityOrderAndStructure(t *testing.T, entityDefinitions []*apiv1.En
|
|||
assert.Equal(t, "postgres", entityDefinitions[1].Instances[1].UniqueKey, "Second database instance should be 'postgres' (alphabetically second)")
|
||||
|
||||
assert.Equal(t, "server", entityDefinitions[2].Title, "Third entity should be 'server' (alphabetically third)")
|
||||
assert.Equal(t, 3, len(entityDefinitions[2].Instances), "Server should have 3 instances")
|
||||
assert.Equal(t, "alpha", entityDefinitions[2].Instances[0].UniqueKey, "First server instance should be 'alpha' (alphabetically first)")
|
||||
assert.Equal(t, "beta", entityDefinitions[2].Instances[1].UniqueKey, "Second server instance should be 'beta' (alphabetically second)")
|
||||
assert.Equal(t, "zebra", entityDefinitions[2].Instances[2].UniqueKey, "Third server instance should be 'zebra' (alphabetically third)")
|
||||
assert.Equal(t, 0, len(entityDefinitions[2].Instances), "Server instances should not be included in bulk list response")
|
||||
assert.Equal(t, int32(3), entityDefinitions[2].TotalInstances, "Server should report total instance count")
|
||||
}
|
||||
|
||||
func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
|
||||
|
|
|
|||
|
|
@ -102,9 +102,16 @@ type WebhookConfig struct {
|
|||
// Entity represents a "thing" that can have multiple actions associated with it.
|
||||
// for example, a media player with a start and stop action.
|
||||
type EntityFile struct {
|
||||
File string `koanf:"file"`
|
||||
Name string `koanf:"name"`
|
||||
Icon string `koanf:"icon"`
|
||||
File string `koanf:"file"`
|
||||
Name string `koanf:"name"`
|
||||
Icon string `koanf:"icon"`
|
||||
Properties []EntityProperty `koanf:"properties"`
|
||||
}
|
||||
|
||||
// EntityProperty defines a column shown when listing entity instances in the UI.
|
||||
type EntityProperty struct {
|
||||
Name string `koanf:"name"`
|
||||
Title string `koanf:"title"`
|
||||
}
|
||||
|
||||
// PermissionsList defines what users can do with an action.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func (cfg *Config) Sanitize() {
|
|||
|
||||
cfg.sanitizeActionGroups()
|
||||
cfg.sanitizeActionGroupReferences()
|
||||
cfg.sanitizeEntities()
|
||||
|
||||
if err := cfg.validateReservedActionArgumentNames(); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
|
|
@ -291,6 +292,25 @@ func (cfg *Config) sanitizeActionGroupReferences() {
|
|||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) sanitizeEntities() {
|
||||
for _, entityFile := range cfg.Entities {
|
||||
if entityFile == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entityFile.Icon = lookupHTMLIcon(entityFile.Icon, "")
|
||||
sanitizeEntityProperties(entityFile)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeEntityProperties(entityFile *EntityFile) {
|
||||
for idx := range entityFile.Properties {
|
||||
if entityFile.Properties[idx].Title == "" {
|
||||
entityFile.Properties[idx].Title = entityFile.Properties[idx].Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) {
|
||||
group, found := cfg.ActionGroups[groupName]
|
||||
if !found {
|
||||
|
|
|
|||
Loading…
Reference in New Issue