feat: add entity UI with list filtering and prefilled action arguments (#1069)

This commit is contained in:
James Read 2026-07-07 00:32:15 +01:00 committed by GitHub
commit 6ce93629e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 568 additions and 100 deletions

View File

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

View File

@ -0,0 +1,171 @@
<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, onBeforeUnmount } 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'
import { entityDetailsRoute } from '../utils/entityRoutes.js'
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 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()
}
})
onBeforeUnmount(() => {
if (fetchTimer) {
clearTimeout(fetchTimer)
fetchTimer = null
}
})
</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>

View File

@ -0,0 +1,96 @@
<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'
import { entityDetailsRoute } from '../utils/entityRoutes.js'
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
}))
return [
{ key: 'title', label: 'Name' },
...propertyHeaders
]
})
const tableRows = computed(() =>
props.instances.map(instance => ({
...instance.fields,
title: instance.title,
type: instance.type,
uniqueKey: instance.uniqueKey
}))
)
const currentPageModel = computed({
get: () => props.page,
set: value => emit('update:page', value)
})
const pageSizeModel = computed({
get: () => props.pageSize,
set: value => emit('update:pageSize', value)
})
</script>
<style scoped>
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,65 @@
<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"
aria-label="Filter entities"
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>

View File

@ -0,0 +1,9 @@
export function entityDetailsRoute (entity) {
return {
name: 'EntityDetails',
params: {
entityType: entity.type,
entityKey: entity.uniqueKey
}
}
}

View File

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

View File

@ -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 || '/')
})

View File

@ -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">
@ -44,7 +55,7 @@
<template v-if="justificationRequired">
<label for="justification">Justification:</label>
<input id="justification" name="justification" type="text" v-model="justificationValue" required />
<input id="justification" name="justification" type="text" :value="justificationValue" required @input="handleJustificationInput" />
</template>
<div v-if="actionArguments.length === 0 && !justificationRequired">
@ -65,16 +76,18 @@
</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 {
actionJustificationTemplate,
actionRequiresJustification,
applyArgumentTemplate
} from '../utils/justificationTemplate.js'
import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
const router = useRouter()
@ -90,8 +103,11 @@ const formErrors = ref({})
const actionArguments = ref([])
const popupOnStart = ref('')
const formReady = ref(false)
const justificationRequired = ref(false)
const justificationConfig = ref('')
const justificationValue = ref('')
const justificationEditedManually = ref(false)
const justificationRequired = computed(() => actionRequiresJustification(justificationConfig.value))
const justificationTemplate = computed(() => actionJustificationTemplate(justificationConfig.value))
let isComponentMounted = true
// Computed properties
@ -119,17 +135,21 @@ async function setup() {
icon.value = action.icon
popupOnStart.value = action.popupOnStart || ''
actionArguments.value = action.arguments || []
justificationRequired.value = actionRequiresJustification(action.justification)
justificationConfig.value = action.justification || ''
justificationValue.value = ''
justificationEditedManually.value = false
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
@ -139,7 +159,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) {
@ -155,12 +175,6 @@ async function setup() {
}
})
const prefilledJustification = applyArgumentTemplate(
actionJustificationTemplate(action.justification),
argValues.value
)
justificationValue.value = prefilledJustification.trim() === '' ? '' : prefilledJustification
// Run initial validation on all fields after DOM is updated
await nextTick()
for (const arg of actionArguments.value) {
@ -173,16 +187,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 === ':') {
@ -237,10 +248,16 @@ function getArgumentValue(arg) {
return argValues.value[arg.name] || ''
}
function handleJustificationInput(event) {
justificationValue.value = event.target.value
justificationEditedManually.value = true
}
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) {
@ -265,6 +282,7 @@ function handleChoiceUpdate(arg, value) {
argValues.value[arg.name] = value
updateUrlWithArg(arg.name, value)
validateArgument(arg, value)
updateJustificationFromTemplate()
}
async function validateArgument(arg, value) {
@ -382,6 +400,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 || justificationEditedManually.value) {
return
}
justificationValue.value = applyArgumentTemplate(justificationTemplate.value, getArgumentMapForTemplate())
}
function getUniqueId() {
if (window.isSecureContext) {
return window.crypto.randomUUID()
@ -572,6 +612,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: var(--link-color, #0066cc);
text-decoration: underline;
}
.action-details-title-link:hover {
color: var(--link-hover-color, #004499);
}
form {
grid-template-columns: max-content auto auto;

View File

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

View File

@ -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" rel="noopener noreferrer">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);
}