Skip to content

Commit

Permalink
cockpit-actions: Allow actions to be triggered by changes in data-lak…
Browse files Browse the repository at this point in the history
…e variables

- Added a new button in the configuration view to link actions to data-lake variables
- User can specify the minimum interval between two automatic calls to the action
  • Loading branch information
rafaellehmkuhl committed Feb 20, 2025
1 parent 0feda53 commit c85f739
Show file tree
Hide file tree
Showing 4 changed files with 392 additions and 44 deletions.
144 changes: 144 additions & 0 deletions src/components/configuration/ActionLinkConfig.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<template>
<v-dialog v-model="dialog.show" max-width="500px">
<v-card class="rounded-lg" :style="interfaceStore.globalGlassMenuStyles">
<v-card-title class="text-h6 font-weight-bold py-4 text-center">Link Action to Variables</v-card-title>
<v-card-text class="px-8">
<div v-if="dialog.action" class="mb-4">
<p class="text-subtitle-1 font-weight-bold">Action: {{ dialog.action.name }}</p>
<p class="text-caption">Type: {{ humanizeString(dialog.action.type) }}</p>
</div>

<v-text-field
v-model="searchQuery"
label="Search variables"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
class="mb-2"
clearable
@update:model-value="menuOpen = true"
@click:clear="menuOpen = false"
@update:focused="(isFocused: boolean) => (menuOpen = isFocused)"
/>

<v-select
v-model="dialog.selectedVariables"
:items="filteredDataLakeVariables"
label="Data Lake Variables"
multiple
chips
variant="outlined"
density="compact"
theme="dark"
closable-chips
:menu-props="{ modelValue: menuOpen }"
@update:menu="menuOpen = $event"
/>

<v-text-field
v-model="dialog.minInterval"
label="Minimum interval between calls (ms)"
type="number"
min="0"
variant="outlined"
density="compact"
class="mt-4"
/>

<div class="mt-4">
<p class="text-caption">
The action will be called whenever any of the selected variables change, respecting the minimum interval
between consecutive calls.
</p>
</div>
</v-card-text>
<v-divider class="mt-2 mx-10" />
<v-card-actions>
<div class="flex justify-between items-center pa-2 w-full h-full">
<v-btn variant="text" @click="closeDialog">Cancel</v-btn>
<v-btn :disabled="!isFormValid" @click="saveConfig">Save</v-btn>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { getActionLink, removeActionLink, saveActionLink } from '@/libs/actions/action-links'
import { getAllDataLakeVariablesInfo } from '@/libs/actions/data-lake'
import { humanizeString } from '@/libs/utils'
import { useAppInterfaceStore } from '@/stores/appInterface'
import { ActionConfig } from '@/types/cockpit-actions'
const interfaceStore = useAppInterfaceStore()
const searchQuery = ref('')
const menuOpen = ref(false)
const defaultDialogConfig = {
show: false,
action: null as ActionConfig | null,
selectedVariables: [] as string[],
minInterval: 1000,
}
const dialog = ref(defaultDialogConfig)
const availableDataLakeVariables = computed(() => {
const variables = getAllDataLakeVariablesInfo()
return Object.values(variables).map((variable) => ({
title: variable.id,
value: variable.id,
}))
})
const filteredDataLakeVariables = computed(() => {
const variables = availableDataLakeVariables.value
if (!searchQuery.value) return variables
const query = searchQuery.value.toLowerCase()
return variables.filter((variable) => variable.title.toLowerCase().includes(query))
})
const isFormValid = computed(() => {
return dialog.value.action && dialog.value.minInterval >= 0
})
const openDialog = (item: ActionConfig): void => {
const existingLink = getActionLink(item.id)
dialog.value = {
show: true,
action: item,
selectedVariables: existingLink?.variables || [],
minInterval: existingLink?.minInterval || 1000,
}
}
const closeDialog = (): void => {
dialog.value = defaultDialogConfig
}
const saveConfig = (): void => {
if (!dialog.value.action) return
// Always remove the existing link first
removeActionLink(dialog.value.action.id)
// Only create a new link if variables are selected
if (dialog.value.selectedVariables.length > 0) {
saveActionLink(
dialog.value.action.id,
dialog.value.action.type,
dialog.value.selectedVariables,
dialog.value.minInterval
)
}
closeDialog()
}
defineExpose({
openDialog,
})
</script>
149 changes: 149 additions & 0 deletions src/libs/actions/action-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { listenDataLakeVariable, unlistenDataLakeVariable } from '@/libs/actions/data-lake'
import { executeActionCallback } from '@/libs/joystick/protocols/cockpit-actions'

/**
* Interface representing a link between an action and data-lake variables
*/
interface ActionLink {
/** The ID of the action */
actionId: string
/** The type of the action */
actionType: string
/** Array of data-lake variable IDs to watch */
variables: string[]
/** Minimum time (in ms) between consecutive action executions */
minInterval: number
/** Timestamp of the last execution */
lastExecutionTime: number
}

const actionLinks: Record<string, ActionLink> = {}
const listenerIds: Record<string, string[]> = {}

/**
* Save a new action link configuration and set up the watchers
* @param {string} actionId The ID of the action to link
* @param {string} actionType The type of the action
* @param {string[]} variables Array of data-lake variable IDs to watch
* @param {number} minInterval Minimum time (in ms) between consecutive action executions
*/
export const saveActionLink = (
actionId: string,
actionType: string,
variables: string[],
minInterval: number
): void => {
// Remove any existing link for this action
removeActionLink(actionId)

// Save the new link configuration
actionLinks[actionId] = {
actionId,
actionType,
variables,
minInterval,
lastExecutionTime: 0,
}

// Set up listeners for each variable
listenerIds[actionId] = variables.map((variableId) =>
listenDataLakeVariable(variableId, () => {
executeLinkedAction(actionId)
})
)

saveLinksToPersistentStorage()
}

/**
* Remove an action link and clean up its watchers
* @param {string} actionId The ID of the action to unlink
*/
export const removeActionLink = (actionId: string): void => {
const link = actionLinks[actionId]
if (!link) return

// Remove all listeners
if (listenerIds[actionId]) {
link.variables.forEach((variableId, index) => {
unlistenDataLakeVariable(variableId, listenerIds[actionId][index])
})
delete listenerIds[actionId]
}

delete actionLinks[actionId]

saveLinksToPersistentStorage()
}

/**
* Get the link configuration for an action
* @param {string} actionId The ID of the action
* @returns {ActionLink | null} The link configuration if it exists, null otherwise
*/
export const getActionLink = (actionId: string): ActionLink | null => {
return actionLinks[actionId] || null
}

/**
* Execute a linked action, respecting the minimum interval between executions
* @param {string} actionId The ID of the action to execute
*/
const executeLinkedAction = (actionId: string): void => {
const link = actionLinks[actionId]
if (!link) return

const now = Date.now()
if (now - link.lastExecutionTime >= link.minInterval) {
link.lastExecutionTime = now
executeActionCallback(actionId)
}
}

/**
* Get all action links
* @returns {Record<string, ActionLink>} Record of all action links
*/
export const getAllActionLinks = (): Record<string, ActionLink> => {
return { ...actionLinks }
}

// Load saved links from localStorage on startup
const loadSavedLinks = (): void => {
try {
const savedLinks = localStorage.getItem('cockpit-action-links')
if (savedLinks) {
const links = JSON.parse(savedLinks) as Record<string, Omit<ActionLink, 'lastExecutionTime'>>
Object.entries(links).forEach(([actionId, link]) => {
saveActionLink(actionId, link.actionType, link.variables, link.minInterval)
})
}
} catch (error) {
console.error('Failed to load saved action links:', error)
}
}

// Save links to localStorage when they change
const saveLinksToPersistentStorage = (): void => {
try {
// Don't save the lastExecutionTime
const linksToSave = Object.entries(actionLinks).reduce(
(acc, [id, link]) => ({
...acc,
[id]: {
actionId: link.actionId,
actionType: link.actionType,
variables: link.variables,
minInterval: link.minInterval,
},
}),
{}
)
localStorage.setItem('cockpit-action-links', JSON.stringify(linksToSave))
} catch (error) {
console.error('Failed to save action links:', error)
}
}

// Initialize
loadSavedLinks()
40 changes: 40 additions & 0 deletions src/types/cockpit-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Custom action types
*/
export enum customActionTypes {
httpRequest = 'http-request',
mavlinkMessage = 'mavlink-message',
javascript = 'javascript',
}

/**
* Custom action types names
*/
export const customActionTypesNames: Record<customActionTypes, string> = {
[customActionTypes.httpRequest]: 'HTTP Request',
[customActionTypes.mavlinkMessage]: 'MAVLink Message',
[customActionTypes.javascript]: 'JavaScript',
}

/**
* Represents the configuration of a custom action
*/
export interface ActionConfig {
/**
* Action ID
*/
id: string
/**
* Action name
*/
name: string
/**
* Action type
*/
type: customActionTypes
/**
* Action configuration
* Specific to the action type
*/
config: any
}
Loading

0 comments on commit c85f739

Please sign in to comment.