Browse Source

feat: expand entity list and detail APIs with properties and related … (#1068)

James Read 16 hours ago
parent
commit
506ad4c883

+ 6 - 0
config.yaml

@@ -323,6 +323,12 @@ entities:
   # Docs: https://docs.olivetin.app/entities/intro.html
   # Docs: https://docs.olivetin.app/entities/intro.html
   - file: entities/servers.yaml
   - file: entities/servers.yaml
     name: server
     name: server
+    icon: ssh
+    properties:
+      - name: hostname
+        title: Hostname
+      - name: ip
+        title: IP
 
 
   - file: entities/containers.json
   - file: entities/containers.json
     name: container
     name: container

+ 87 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -247,6 +247,27 @@ export declare type ActionArgumentChoice = Message<"olivetin.api.v1.ActionArgume
  */
  */
 export declare const ActionArgumentChoiceSchema: GenMessage<ActionArgumentChoice>;
 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
  * @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;
    * @generated from field: map<string, string> fields = 5;
    */
    */
   fields: { [key: string]: string };
   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
  * @generated from message olivetin.api.v1.GetEntitiesRequest
  */
  */
 export declare type GetEntitiesRequest = 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;
    * @generated from field: repeated string used_on_dashboards = 3;
    */
    */
   usedOnDashboards: string[];
   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>;
 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
  * @generated from message olivetin.api.v1.GetEntityRequest
  */
  */
@@ -2194,3 +2280,4 @@ export declare const OliveTinApiService: GenService<{
     output: typeof EntitySchema;
     output: typeof EntitySchema;
   },
   },
 }>;
 }>;
+

File diff suppressed because it is too large
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 19 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -58,12 +58,19 @@ message ActionArgumentChoice {
 	string title = 2;
 	string title = 2;
 }
 }
 
 
+message EntityRelatedAction {
+	Action action = 1;
+	map<string, string> prefilled_arguments = 2;
+}
+
 message Entity {
 message Entity {
 	string title = 1;
 	string title = 1;
     string unique_key = 2;
     string unique_key = 2;
     string type = 3;
     string type = 3;
     repeated string directories = 4;
     repeated string directories = 4;
     map<string, string> fields = 5;
     map<string, string> fields = 5;
+    repeated EntityRelatedAction related_actions = 6;
+    string icon = 7;
 }
 }
 
 
 message GetDashboardResponse {
 message GetDashboardResponse {
@@ -428,6 +435,10 @@ message GetActionBindingResponse {
 }
 }
 
 
 message GetEntitiesRequest {
 message GetEntitiesRequest {
+  string entity_type = 1;
+  string filter = 2;
+  int32 page = 3;
+  int32 page_size = 4;
 }
 }
 
 
 message GetEntitiesResponse {
 message GetEntitiesResponse {
@@ -438,6 +449,14 @@ message EntityDefinition {
   string title = 1;
   string title = 1;
   repeated Entity instances = 2;
   repeated Entity instances = 2;
   repeated string used_on_dashboards = 3;
   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 {
 message GetEntityRequest {

File diff suppressed because it is too large
+ 177 - 109
service/gen/olivetin/api/v1/olivetin.pb.go


+ 95 - 25
service/internal/api/api.go

@@ -4,9 +4,14 @@ import (
 	ctx "context"
 	ctx "context"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
+	"fmt"
+	"net/http"
 	"os"
 	"os"
 	"path"
 	"path"
 	"sort"
 	"sort"
+	"strings"
+	"sync"
+	"time"
 
 
 	"connectrpc.com/connect"
 	"connectrpc.com/connect"
 	"google.golang.org/protobuf/encoding/protojson"
 	"google.golang.org/protobuf/encoding/protojson"
@@ -16,11 +21,6 @@ import (
 	"github.com/google/uuid"
 	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
 
 
-	"fmt"
-	"net/http"
-	"sync"
-	"time"
-
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	auth "github.com/OliveTin/OliveTin/internal/auth"
 	auth "github.com/OliveTin/OliveTin/internal/auth"
 	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	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()
 	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{
 	res := &apiv1.GetEntitiesResponse{
 		EntityDefinitions: entityDefinitions,
 		EntityDefinitions: entityDefinitions,
@@ -1400,7 +1386,7 @@ func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.
 	return connect.NewResponse(res), nil
 	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))
 	instanceKeys := make([]string, 0, len(entityInstances))
 	for key := range entityInstances {
 	for key := range entityInstances {
 		instanceKeys = append(instanceKeys, key)
 		instanceKeys = append(instanceKeys, key)
@@ -1414,6 +1400,7 @@ func buildSortedEntityInstances(entityType string, entityInstances map[string]*e
 			Title:     e.Title,
 			Title:     e.Title,
 			UniqueKey: e.UniqueKey,
 			UniqueKey: e.UniqueKey,
 			Type:      entityType,
 			Type:      entityType,
+			Fields:    entityListFields(e.Data, properties),
 		})
 		})
 	}
 	}
 	return instances
 	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))
 		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
 	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{
 	res := &apiv1.Entity{
 		Title:       entity.Title,
 		Title:       entity.Title,
 		UniqueKey:   entity.UniqueKey,
 		UniqueKey:   entity.UniqueKey,
 		Type:        entityType,
 		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
 	return res
 }
 }

+ 147 - 0
service/internal/api/api_entities_list.go

@@ -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)
+}

+ 107 - 0
service/internal/api/api_entities_list_test.go

@@ -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")
+}

+ 126 - 0
service/internal/api/api_entity_related_actions.go

@@ -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
+}

+ 270 - 0
service/internal/api/api_entity_related_actions_test.go

@@ -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, "&#128272;", 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")
+}

+ 30 - 4
service/internal/api/api_test.go

@@ -120,6 +120,15 @@ func TestGetActionsAndStart(t *testing.T) {
 
 
 func TestGetEntities(t *testing.T) {
 func TestGetEntities(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg := config.DefaultConfig()
+	cfg.Entities = []*config.EntityFile{
+		{
+			Name: "server",
+			Properties: []config.EntityProperty{
+				{Name: "hostname", Title: "Hostname"},
+			},
+		},
+	}
+	cfg.Sanitize()
 
 
 	ts, client := getNewTestServerAndClient(cfg)
 	ts, client := getNewTestServerAndClient(cfg)
 	defer ts.Close()
 	defer ts.Close()
@@ -138,6 +147,25 @@ func TestGetEntities(t *testing.T) {
 	validateEntityOrderAndStructure(t, entityDefinitions)
 	validateEntityOrderAndStructure(t, entityDefinitions)
 	validateNoDuplicates(t, entityDefinitions)
 	validateNoDuplicates(t, entityDefinitions)
 	validateConsistency(t, client, 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() {
 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, "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, "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) {
 func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {

+ 10 - 3
service/internal/config/config.go

@@ -102,9 +102,16 @@ type WebhookConfig struct {
 // Entity represents a "thing" that can have multiple actions associated with it.
 // Entity represents a "thing" that can have multiple actions associated with it.
 // for example, a media player with a start and stop action.
 // for example, a media player with a start and stop action.
 type EntityFile struct {
 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.
 // PermissionsList defines what users can do with an action.

+ 20 - 0
service/internal/config/sanitize.go

@@ -30,6 +30,7 @@ func (cfg *Config) Sanitize() {
 
 
 	cfg.sanitizeActionGroups()
 	cfg.sanitizeActionGroups()
 	cfg.sanitizeActionGroupReferences()
 	cfg.sanitizeActionGroupReferences()
+	cfg.sanitizeEntities()
 
 
 	if err := cfg.validateReservedActionArgumentNames(); err != nil {
 	if err := cfg.validateReservedActionArgumentNames(); err != nil {
 		log.Fatalf("%v", err)
 		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) {
 func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) {
 	group, found := cfg.ActionGroups[groupName]
 	group, found := cfg.ActionGroups[groupName]
 	if !found {
 	if !found {

Some files were not shown because too many files changed in this diff