Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Allow data-lake variables to auto trigger actions #1675

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
6 changes: 4 additions & 2 deletions src/components/configuration/HttpRequestActionConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
required
variant="outlined"
density="compact"
></v-select>
theme="dark"
/>
<v-text-field
v-model="newActionConfig.url"
label="URL"
Expand Down Expand Up @@ -117,7 +118,8 @@
required
variant="outlined"
density="compact"
></v-select>
theme="dark"
/>
<v-text-field
v-if="urlParamDialog.valueType === 'fixed'"
v-model="urlParamDialog.fixedValue"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
required
variant="outlined"
density="compact"
theme="dark"
@update:model-value="resetActionConfig(newActionConfig.messageType)"
/>

Expand Down
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()
10 changes: 7 additions & 3 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,15 @@
::-webkit-scrollbar-thumb:hover {
background-color: hsla(0, 0%, 0%, 0.411);
}
.v-text-field input {
background-color: transparent;
.v-text-field input {
background-color: transparent;
border: none !important;
box-shadow: none !important;
}
.v-field__input {
background-color: transparent;
border: none !important;
box-shadow: none !important;
}
select:focus {
outline: none;
Expand All @@ -68,4 +72,4 @@ select:focus {
@keyframes highlightBackground {
0% { background-color: rgba(255, 234, 43, 0.438); }
100% { background-color: transparent; }
}
}
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