Sfoglia il codice sorgente

feat: add entity UI with list filtering and prefilled action arguments

Introduce entity list/detail components and wire related actions through the
argument form so entity context can prefill values when starting actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
jamesread 12 ore fa
parent
commit
0c4d25e525

+ 15 - 1
frontend/resources/vue/ActionButton.vue

@@ -61,6 +61,11 @@ const props = defineProps({
 	type: String,
 	required: false,
 	default: ''
+  },
+  prefilledArguments: {
+	type: Object,
+	required: false,
+	default: () => ({})
   }
 })
 
@@ -248,7 +253,16 @@ async function handleClick() {
 	return
   }
   if (needsArgumentForm(props.actionData)) {
-	router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
+	const bindingId = props.actionData.bindingId
+	const prefilled = props.prefilledArguments || {}
+	if (Object.keys(prefilled).length > 0) {
+	  router.push({
+		path: `/actionBinding/${bindingId}/argumentForm`,
+		state: { prefilledArguments: prefilled }
+	  })
+	} else {
+	  router.push(`/actionBinding/${bindingId}/argumentForm`)
+	}
   } else {
 	await startAction()
   }

+ 173 - 0
frontend/resources/vue/components/EntityDefinitionSection.vue

@@ -0,0 +1,173 @@
+<template>
+	<Section :padding="!hasTable">
+		<template #title>
+			<span class="section-title-with-icon">
+				Entity:
+				<ActionIconGlyph v-if="definition.icon" class="entity-title-icon" :glyph="definition.icon" />
+				{{ definition.title }}
+			</span>
+		</template>
+		<template v-if="hasTable" #toolbar>
+			<EntityListFilter v-model="searchText" />
+		</template>
+
+		<p v-if="!hasTable">{{ definition.instances.length }} instances.</p>
+
+		<template v-if="hasTable">
+			<p v-if="tableError" class="table-error padding" role="alert">{{ tableError }}</p>
+			<EntityInstancesTable
+				v-else
+				:instances="tableInstances"
+				:properties="definition.properties"
+				:total-instances="totalInstances"
+				v-model:page="currentPage"
+				v-model:page-size="pageSize"
+			/>
+		</template>
+
+		<ul v-else>
+			<li v-for="inst in definition.instances" :key="inst.uniqueKey">
+				<router-link :to="entityDetailsRoute(inst)">
+					{{ inst.title }}
+				</router-link>
+			</li>
+		</ul>
+
+		<div v-if="usedDashboards.length > 0" :class="{ padding: hasTable }">
+			<h3>Used on Dashboards:</h3>
+			<ul>
+				<li v-for="dash in usedDashboards" :key="dash">
+					<template v-if="isEntityDirectory(dash)">
+						{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
+					</template>
+					<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
+						{{ getDashboardTitle(dash) }}
+					</router-link>
+					<span v-else>{{ dash }}</span>
+				</li>
+			</ul>
+		</div>
+	</Section>
+</template>
+
+<script setup>
+	import { computed, ref, watch, onMounted } from 'vue'
+	import Section from 'picocrank/vue/components/Section.vue'
+	import ActionIconGlyph from './ActionIconGlyph.vue'
+	import EntityInstancesTable from './EntityInstancesTable.vue'
+	import EntityListFilter from './EntityListFilter.vue'
+
+	const props = defineProps({
+		definition: {
+			type: Object,
+			required: true
+		}
+	})
+
+	const searchText = ref('')
+	const tableInstances = ref([])
+	const totalInstances = ref(0)
+	const currentPage = ref(1)
+	const pageSize = ref(10)
+	const tableError = ref('')
+	let fetchTimer = null
+
+	const hasTable = computed(() => (props.definition.properties?.length ?? 0) > 0)
+
+	const usedDashboards = computed(() => filteredDashboards(props.definition.usedOnDashboards ?? []))
+
+	watch(searchText, () => {
+		currentPage.value = 1
+		scheduleFetchTableInstances()
+	})
+
+	watch([currentPage, pageSize], () => {
+		scheduleFetchTableInstances()
+	})
+
+	function entityDetailsRoute(inst) {
+		return {
+			name: 'EntityDetails',
+			params: {
+				entityType: inst.type,
+				entityKey: inst.uniqueKey
+			}
+		}
+	}
+
+	function filteredDashboards(dashboards) {
+		return dashboards.filter(d => d && !d.includes('{{'))
+	}
+
+	function isEntityDirectory(dashboardTitle) {
+		return dashboardTitle.endsWith(' [Entity Directory]')
+	}
+
+	function getDashboardTitle(dashboardTitle) {
+		if (isEntityDirectory(dashboardTitle)) {
+			return dashboardTitle.slice(0, -' [Entity Directory]'.length)
+		}
+		return dashboardTitle
+	}
+
+	function scheduleFetchTableInstances() {
+		if (!hasTable.value) {
+			return
+		}
+
+		if (fetchTimer) {
+			clearTimeout(fetchTimer)
+		}
+
+		fetchTimer = setTimeout(() => {
+			fetchTableInstances()
+		}, 250)
+	}
+
+	async function fetchTableInstances() {
+		if (!hasTable.value) {
+			return
+		}
+
+		tableError.value = ''
+		try {
+			const response = await window.client.getEntities({
+				entityType: props.definition.title,
+				filter: searchText.value.trim(),
+				page: currentPage.value,
+				pageSize: pageSize.value
+			})
+
+			const definition = response.entityDefinitions?.find(def => def.title === props.definition.title)
+			tableInstances.value = definition?.instances ?? []
+			totalInstances.value = definition?.totalInstances ?? 0
+		} catch (err) {
+			console.error('Failed to fetch entity instances:', err)
+			tableError.value = 'Failed to load entity instances.'
+			tableInstances.value = []
+			totalInstances.value = 0
+		}
+	}
+
+	onMounted(() => {
+		if (hasTable.value) {
+			fetchTableInstances()
+		}
+	})
+</script>
+
+<style scoped>
+.section-title-with-icon {
+	display: inline-flex;
+	align-items: center;
+	gap: 0.5em;
+}
+
+.entity-title-icon {
+	font-size: 1.2em;
+}
+
+.table-error {
+	color: var(--error, #c00);
+}
+</style>

+ 103 - 0
frontend/resources/vue/components/EntityInstancesTable.vue

@@ -0,0 +1,103 @@
+<template>
+	<Table
+		:data="tableRows"
+		:headers="headers"
+		:show-pagination="false"
+	>
+		<template #cell-title="{ row, value }">
+			<router-link :to="entityDetailsRoute(row)">
+				{{ value }}
+			</router-link>
+		</template>
+	</Table>
+
+	<div v-if="totalInstances > 0" class="padding">
+		<Pagination
+			:total="totalInstances"
+			v-model:page="currentPageModel"
+			v-model:page-size="pageSizeModel"
+			item-title="entities"
+		/>
+	</div>
+</template>
+
+<script setup>
+	import { computed } from 'vue'
+	import Table from 'picocrank/vue/components/Table.vue'
+	import Pagination from 'picocrank/vue/components/Pagination.vue'
+
+	const props = defineProps({
+		instances: {
+			type: Array,
+			required: true
+		},
+		properties: {
+			type: Array,
+			required: true
+		},
+		totalInstances: {
+			type: Number,
+			default: 0
+		},
+		page: {
+			type: Number,
+			default: 1
+		},
+		pageSize: {
+			type: Number,
+			default: 10
+		}
+	})
+
+	const emit = defineEmits(['update:page', 'update:pageSize'])
+
+	const headers = computed(() => {
+		const propertyHeaders = props.properties.map(property => ({
+			key: property.name,
+			label: property.title,
+			sortable: true
+		}))
+
+		return [
+			{ key: 'title', label: 'Name', sortable: true },
+			...propertyHeaders
+		]
+	})
+
+	const tableRows = computed(() =>
+		props.instances.map(instance => ({
+			...instance,
+			...instance.fields
+		}))
+	)
+
+	const currentPageModel = computed({
+		get: () => props.page,
+		set: value => emit('update:page', value)
+	})
+
+	const pageSizeModel = computed({
+		get: () => props.pageSize,
+		set: value => emit('update:pageSize', value)
+	})
+
+	function entityDetailsRoute(row) {
+		return {
+			name: 'EntityDetails',
+			params: {
+				entityType: row.type,
+				entityKey: row.uniqueKey
+			}
+		}
+	}
+</script>
+
+<style scoped>
+a {
+	text-decoration: none;
+}
+
+a:hover {
+	text-decoration: underline;
+}
+</style>

+ 64 - 0
frontend/resources/vue/components/EntityListFilter.vue

@@ -0,0 +1,64 @@
+<template>
+	<label class="input-with-icons">
+		<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
+			<path fill="currentColor"
+				d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
+		</svg>
+		<input
+			:value="modelValue"
+			placeholder="Filter entities..."
+			@input="$emit('update:modelValue', $event.target.value)"
+		/>
+		<button title="Clear search filter" :disabled="!modelValue" @click="$emit('update:modelValue', '')">
+			<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
+				<path fill="currentColor"
+					d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
+			</svg>
+		</button>
+	</label>
+</template>
+
+<script setup>
+	defineProps({
+		modelValue: {
+			type: String,
+			default: ''
+		}
+	})
+
+	defineEmits(['update:modelValue'])
+</script>
+
+<style scoped>
+.input-with-icons {
+	display: flex;
+	align-items: center;
+	gap: 0.5rem;
+	padding: 0.5rem;
+	border: 1px solid var(--border-color, #ccc);
+	border-radius: 0.25rem;
+	background: var(--section-background);
+	width: 100%;
+	max-width: 300px;
+}
+
+.input-with-icons input {
+	border: none;
+	outline: none;
+	background: transparent;
+	flex: 1;
+	color: var(--text-primary);
+}
+
+.input-with-icons button {
+	background: none;
+	border: none;
+	cursor: pointer;
+	color: var(--text-secondary);
+}
+
+.input-with-icons button:disabled {
+	opacity: 0.3;
+	cursor: not-allowed;
+}
+</style>

+ 17 - 0
frontend/resources/vue/utils/prefilledArguments.js

@@ -0,0 +1,17 @@
+export function readPrefilledArgumentsFromNavigation() {
+	const state = window.history.state
+	if (state?.prefilledArguments && typeof state.prefilledArguments === 'object') {
+		return { ...state.prefilledArguments }
+	}
+
+	return {}
+}
+
+export function getInitialArgumentValue(paramName, prefilledArguments) {
+	if (Object.prototype.hasOwnProperty.call(prefilledArguments, paramName)) {
+		return prefilledArguments[paramName]
+	}
+
+	const params = new URLSearchParams(window.location.search.substring(1))
+	return params.get(paramName)
+}

+ 39 - 0
frontend/resources/vue/utils/prefilledArguments.test.mjs

@@ -0,0 +1,39 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
+
+test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
+	const originalState = window.history.state
+	window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
+
+	assert.deepEqual(readPrefilledArgumentsFromNavigation(), { ansible_host: '10.0.0.1' })
+
+	window.history.replaceState(originalState, '')
+})
+
+test('getInitialArgumentValue prefers navigation state over query params', () => {
+	const originalState = window.history.state
+	const originalSearch = window.location.search
+
+	window.history.replaceState({ prefilledArguments: { ansible_host: '10.0.0.1' } }, '')
+	window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
+
+	assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.1')
+
+	window.history.replaceState(originalState, '')
+	window.history.replaceState(window.history.state, '', originalSearch || '/')
+})
+
+test('getInitialArgumentValue falls back to query params when state is absent', () => {
+	const originalState = window.history.state
+	const originalSearch = window.location.search
+
+	window.history.replaceState({}, '')
+	window.history.replaceState(window.history.state, '', '?ansible_host=10.0.0.2')
+
+	assert.equal(getInitialArgumentValue('ansible_host', readPrefilledArgumentsFromNavigation()), '10.0.0.2')
+
+	window.history.replaceState(originalState, '')
+	window.history.replaceState(window.history.state, '', originalSearch || '/')
+})

+ 72 - 12
frontend/resources/vue/views/ArgumentForm.vue

@@ -1,7 +1,18 @@
 <template>
   <section id = "argument-popup">
     <div class="section-header">
-      <h2>Start action: {{ title }}</h2>
+      <h2>
+        <span class="section-title-with-icon">
+          Start action:
+          <router-link
+            :to="`/action/${bindingId}`"
+            class="action-details-title-link"
+          >
+            <ActionIconGlyph v-if="icon" class="action-title-icon" :glyph="icon" />
+            {{ title }}
+          </router-link>
+        </span>
+      </h2>
     </div>
     <div class="section-content padding">
       <form @submit="handleSubmit">
@@ -65,11 +76,14 @@
 </template>
 
 <script setup>
-import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
+import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
 import { useRouter } from 'vue-router'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import ChoiceCombobox from '../components/ChoiceCombobox.vue'
 import ChoiceChecklist from '../components/ChoiceChecklist.vue'
+import ActionIconGlyph from '../components/ActionIconGlyph.vue'
+import { applyArgumentTemplate, actionJustificationTemplate, actionRequiresJustification } from '../utils/justificationTemplate.js'
+import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
 
 const router = useRouter()
 
@@ -85,8 +99,10 @@ const formErrors = ref({})
 const actionArguments = ref([])
 const popupOnStart = ref('')
 const formReady = ref(false)
-const justificationRequired = ref(false)
+const justificationConfig = ref('')
 const justificationValue = ref('')
+const justificationRequired = computed(() => actionRequiresJustification(justificationConfig.value))
+const justificationTemplate = computed(() => actionJustificationTemplate(justificationConfig.value))
 let isComponentMounted = true
 
 // Computed properties
@@ -114,18 +130,20 @@ async function setup() {
     icon.value = action.icon
     popupOnStart.value = action.popupOnStart || ''
     actionArguments.value = action.arguments || []
-    justificationRequired.value = action.justification || false
+    justificationConfig.value = action.justification || ''
     justificationValue.value = ''
     argValues.value = {}
     formErrors.value = {}
     confirmationChecked.value = false
     hasConfirmation.value = false
 
-    // Initialize values from query params or defaults
+    const prefilledArguments = readPrefilledArgumentsFromNavigation()
+
+    // Initialize values from navigation state, query params, or defaults
     actionArguments.value.forEach(arg => {
     if (arg.type === 'confirmation') {
       hasConfirmation.value = true
-      const paramValue = getQueryParamValue(arg.name)
+      const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
       let checkedValue = false
       if (paramValue !== null) {
         checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
@@ -135,7 +153,7 @@ async function setup() {
       argValues.value[arg.name] = checkedValue
       confirmationChecked.value = checkedValue
     } else {
-      const paramValue = getQueryParamValue(arg.name)
+      const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
       if (arg.type === 'checkbox') {
         // For checkboxes, handle boolean default values properly
         if (paramValue !== null) {
@@ -163,16 +181,13 @@ async function setup() {
       formReady.value = true
       document.body.setAttribute('loaded-argument-form', props.bindingId)
     }
+
+    updateJustificationFromTemplate()
   } catch (err) {
     console.error('Failed to load argument form:', err)
   }
 }
 
-function getQueryParamValue(paramName) {
-  const params = new URLSearchParams(window.location.search.substring(1))
-  return params.get(paramName)
-}
-
 function formatLabel(title) {
   const lastChar = title.charAt(title.length - 1)
   if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
@@ -231,6 +246,7 @@ function handleInput(arg, event) {
   const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
   argValues.value[arg.name] = value
   updateUrlWithArg(arg.name, value)
+  updateJustificationFromTemplate()
 }
 
 function handleChange(arg, event) {
@@ -255,6 +271,7 @@ function handleChoiceUpdate(arg, value) {
   argValues.value[arg.name] = value
   updateUrlWithArg(arg.name, value)
   validateArgument(arg, value)
+  updateJustificationFromTemplate()
 }
 
 async function validateArgument(arg, value) {
@@ -372,6 +389,28 @@ function getArgumentValues() {
   return ret
 }
 
+function getArgumentMapForTemplate() {
+  const args = {}
+
+  for (const arg of actionArguments.value) {
+    if (!shouldSendArgument(arg)) {
+      continue
+    }
+
+    args[arg.name] = formatArgumentValueForApi(arg, argValues.value[arg.name])
+  }
+
+  return args
+}
+
+function updateJustificationFromTemplate() {
+  if (!justificationTemplate.value) {
+    return
+  }
+
+  justificationValue.value = applyArgumentTemplate(justificationTemplate.value, getArgumentMapForTemplate())
+}
+
 function getUniqueId() {
   if (window.isSecureContext) {
     return window.crypto.randomUUID()
@@ -562,6 +601,27 @@ onUnmounted(() => {
 </script>
 
 <style scoped>
+.section-title-with-icon {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.action-title-icon {
+  font-size: 1.5rem;
+}
+
+.action-details-title-link {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  color: inherit;
+  text-decoration: none;
+}
+
+.action-details-title-link:hover {
+  text-decoration: underline;
+}
 
 form {
   grid-template-columns: max-content auto auto;

+ 8 - 41
frontend/resources/vue/views/EntitiesView.vue

@@ -9,36 +9,18 @@
 		</p>
 	</Section>
 	<template v-else>
-		<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
-			<p>{{ def.instances.length }} instances.</p>
-
-			<ul>
-				<li v-for="inst in def.instances" :key="inst.uniqueKey">
-					<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
-						{{ inst.title }}
-					</router-link>
-				</li>
-			</ul>
-
-			<h3>Used on Dashboards:</h3>
-			<ul>
-				<li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
-					<template v-if="isEntityDirectory(dash)">
-						{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
-					</template>
-					<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
-						{{ getDashboardTitle(dash) }}
-					</router-link>
-					<span v-else>{{ dash }}</span>
-				</li>
-			</ul>
-		</Section>
+		<EntityDefinitionSection
+			v-for="def in entityDefinitions"
+			:key="def.title"
+			:definition="def"
+		/>
 	</template>
 </template>
 
 <script setup>
 	import { ref, computed, onMounted } from 'vue'
 	import Section from 'picocrank/vue/components/Section.vue'
+	import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
 
 	const definitionsLoaded = ref(false)
 	const entityDefinitions = ref([])
@@ -63,22 +45,7 @@
 		}
 	}
 
-	function filteredDashboards(dashboards) {
-		return dashboards.filter(d => d && !d.includes('{{'))
-	}
-
-	function isEntityDirectory(dashboardTitle) {
-		return dashboardTitle.endsWith(' [Entity Directory]')
-	}
-
-	function getDashboardTitle(dashboardTitle) {
-		if (isEntityDirectory(dashboardTitle)) {
-			return dashboardTitle.slice(0, -' [Entity Directory]'.length)
-		}
-		return dashboardTitle
-	}
-
-    onMounted(() => {
-        fetchEntities()
+	onMounted(() => {
+	    fetchEntities()
 	})
 </script>

+ 68 - 39
frontend/resources/vue/views/EntityDetailsView.vue

@@ -1,5 +1,12 @@
 <template>
-	<Section title="Entity Details">
+	<Section>
+		<template #title>
+			<span class="section-title-with-icon">
+				Entity Details:
+				<ActionIconGlyph v-if="entityIcon" class="entity-title-icon" :glyph="entityIcon" />
+				<span v-if="entityDetails?.title">{{ entityDetails.title }}</span>
+			</span>
+		</template>
 		<template #toolbar>
 			<button @click="goBack" class="back-button">
 				<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
@@ -22,37 +29,50 @@
 				<template v-if="entityDetails.fields">
 					<template v-for="(value, key) in entityDetails.fields" :key="key">
 						<dt>{{ key }}</dt>
-						<dd v-html="value"></dd>
+						<dd>{{ value }}</dd>
 					</template>
 				</template>
 			</dl>
 			<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
-
-			<hr />
-
-			<h3>Dashboard Entity Directories</h3>
-			<div v-if="filteredDirectories.length > 0" class="directories-section">
-				<ul class="directory-list">
-					<li v-for="(directory, idx) in filteredDirectories" :key="idx">
-						<router-link
-							:to="{
-								name: 'Dashboard',
-								params: {
-									title: directory,
-									entityType: entityType,
-									entityKey: entityKey
-								}
-							}">
-							{{ directory }}
-						</router-link>
-					</li>
-				</ul>
-			</div>
-			<p v-else>No directories found for this entity.
-				<a href = "https://docs.olivetin.app/dashboards/entity-directories.html" target = "_blank">Learn more</a>
-			</p>
 		</template>
 	</Section>
+
+	<Section v-if="entityDetails" title="Dashboard Entity Directories">
+		<div v-if="filteredDirectories.length > 0" class="directories-section">
+			<ul class="directory-list">
+				<li v-for="(directory, idx) in filteredDirectories" :key="idx">
+					<router-link
+						:to="{
+							name: 'Dashboard',
+							params: {
+								title: directory,
+								entityType: entityType,
+								entityKey: entityKey
+							}
+						}">
+						{{ directory }}
+					</router-link>
+				</li>
+			</ul>
+		</div>
+		<p v-else>No directories found for this entity.
+			<a href = "https://docs.olivetin.app/dashboards/entity-directories.html" target = "_blank">Learn more</a>
+		</p>
+	</Section>
+
+	<section v-if="entityDetails && relatedActions.length > 0" class="transparent">
+		<div class="dashboard-row">
+			<fieldset>
+				<template v-for="(related, idx) in relatedActions" :key="related.action?.bindingId || idx">
+					<ActionButton
+						v-if="related.action"
+						:action-data="related.action"
+						:prefilled-arguments="related.prefilledArguments"
+					/>
+				</template>
+			</fieldset>
+		</div>
+	</section>
 </template>
 
 <script setup>
@@ -61,6 +81,8 @@
 	import { HugeiconsIcon } from '@hugeicons/vue'
 	import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
 	import Section from 'picocrank/vue/components/Section.vue'
+	import ActionButton from '../ActionButton.vue'
+	import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 
 	const router = useRouter()
 	const entityDetails = ref(null)
@@ -77,6 +99,10 @@
 		return entityDetails.value.directories.filter(d => d)
 	})
 
+	const relatedActions = computed(() => entityDetails.value?.relatedActions ?? [])
+
+	const entityIcon = computed(() => entityDetails.value?.icon ?? '')
+
 	function goBack() {
 		router.push({ name: 'Entities' })
 	}
@@ -121,11 +147,6 @@
     box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
 }
 
-.directories-section h3 {
-    margin-bottom: 0.5em;
-    font-size: 1.1em;
-}
-
 .directory-list a {
     text-decoration: none;
     padding: 0.5em;
@@ -149,9 +170,22 @@
     opacity: 0.8;
 }
 
-hr {
-	border: 0;
-	border-top: 1px solid var(--border-color, #ccc);
+.section-title-with-icon {
+	display: inline-flex;
+	align-items: center;
+	gap: 0.5em;
+}
+
+.entity-title-icon {
+	font-size: 1.2em;
+}
+
+fieldset {
+	display: grid;
+	grid-template-columns: repeat(auto-fit, 180px);
+	grid-auto-rows: 1fr;
+	justify-content: center;
+	place-items: stretch;
 }
 
 @media (prefers-color-scheme: dark) {
@@ -164,11 +198,6 @@ hr {
         background-color: var(--bg-hover, #222);
     }
 
-    .directories-section {
-        border-top-color: var(--border-color, #333);
-    }
-
-
     .directory-list a:hover {
         background-color: var(--bg-hover, #222);
     }