Skip to main content
Glama
ActionQueueList.vue9.62 kB
<template> <div v-if="actionViewList.length === 0" :class=" clsx( 'flex flex-row items-center justify-center', 'm-xs p-xs border min-h-[calc(100%-16px)]', themeClasses('border-neutral-400', 'border-neutral-600'), ) " > <EmptyState icon="arrow--up" text="No actions queued yet" secondaryText="An action is a specific operation queued up to change your infrastructure, such as creating, refreshing, updating, or deleting a real-world resource." class="max-w-[420px]" /> </div> <div v-else class="actions list"> <template v-for="section in actionDisplayLists" :key="section.title"> <div v-if="section.actions.length > 0" class="flex flex-col items-stretch gap-xs p-xs" > <div :class=" clsx( 'flex flex-row items-center gap-xs w-full h-8', themeClasses( 'text-neutral-600 [&_*]:border-neutral-400', 'text-neutral-400 [&_*]:border-neutral-600', ), ) " > <div class="flex-none"> {{ section.title }} </div> <div class="border-b flex-1 h-0" /> <NewButton v-if="section.title === 'Failed'" label="Retry All" icon="restart" @click="retryAll" /> </div> <ActionQueueListItem v-for="action in section.actions" :key="action.id" :ref="(el) => setActionQueueListItemRef(action.id, el)" :action="action" :actionsById="actionsById" :actionChildren="actionChildren" :noInteraction="false" /> </div> </template> </div> </template> <script lang="ts" setup> import { PropType, ref, computed, watch, nextTick } from "vue"; import { NewButton, themeClasses } from "@si/vue-lib/design-system"; import clsx from "clsx"; import { ActionState } from "@/api/sdf/dal/action"; import { DefaultMap } from "@/utils/defaultmap"; import { ActionProposedView } from "./types"; import EmptyState from "./EmptyState.vue"; import ActionQueueListItem from "./ActionQueueListItem.vue"; import { routes, useApi } from "./api_composables"; const props = defineProps({ actionViewList: { type: Array as PropType<ActionProposedView[]>, required: true, }, highlightedActionIds: { type: Object as PropType<Set<string>>, default: () => new Set(), }, }); // Create a map of actions by ID for looking up dependencies const actionsById = computed(() => { const map = new Map<string, ActionProposedView>(); for (const action of props.actionViewList) { map.set(action.id, action); } return map; }); const queuedShouldShow = (action: ActionProposedView) => { const blockedByParent = (action.holdStatusInfluencedBy?.length ?? 0) > 0; if (blockedByParent) { return false; } return true; }; // Walk through one time to get a structure of parent / child dependency const actionChildren = computed(() => { const actions = [...props.actionViewList]; // sort independent actions first actions.sort((a, b) => a.dependentOn.length - b.dependentOn.length); let action = actions.shift(); const parentage = new DefaultMap<string, ActionProposedView[]>( () => [] as ActionProposedView[], ); while (action) { if (action.dependentOn.length === 0) parentage.get(action.id); for (const id of action.dependentOn) { const deps = parentage.get(id); // prevent circular refs const myDeps = parentage.get(action.id); if (myDeps.find((a) => a.id === id)) continue; const parentA = actionsById.value.get(id); if (parentA && parentA.state === ActionState.Running) continue; deps.push(action); } action = actions.shift(); } return parentage; }); // display every top level parent that is not present as a child // from the above data that already removes circular deps const uniqueParents = computed(() => { const walkedAlready: Set<string> = new Set(); const allIDS = new Set(actionChildren.value.keys()); [...actionChildren.value.entries()].forEach(([id, children]) => { children.forEach((c) => { // sometimes we see our own ID as a child of ourselves // more defensiveness if (!walkedAlready.has(c.id) && id !== c.id) allIDS.delete(c.id); }); walkedAlready.add(id); }); return props.actionViewList.filter((a) => allIDS.has(a.id)); }); // Create sorted lists of actions - Failed, Running, Queued const actionDisplayLists = computed(() => { const failed = { title: "Failed", actions: [] as ActionProposedView[], }; const running = { title: "Running", actions: [] as ActionProposedView[], }; const queued = { title: "Queued", actions: [] as ActionProposedView[], }; const hold = { title: "On Hold", actions: [] as ActionProposedView[], }; const actions = [...uniqueParents.value]; let action = actions.shift(); while (action) { const deps = action.dependentOn; switch (action.state) { case ActionState.Failed: failed.actions.push(action); break; case ActionState.Running: case ActionState.Dispatched: running.actions.push(action); break; case ActionState.Queued: if (queuedShouldShow(action)) queued.actions.push(action); break; case ActionState.OnHold: if (!hold.actions.find((a) => deps.includes(a.id))) hold.actions.push(action); break; default: break; } action = actions.shift(); } return [failed, running, queued, hold]; }); const retryApi = useApi(); const retryAll = () => { const failedActions = actionDisplayLists.value[0]?.actions; if (failedActions && failedActions.length > 0) { failedActions.forEach((action) => { const call = retryApi.endpoint(routes.ActionRetry, { id: action.id }); call.put({}); }); } }; // Track refs to ActionCard components by action ID const actionQueueListItemRefs = ref< Map<string, InstanceType<typeof ActionQueueListItem>> >(new Map()); // eslint-disable-next-line @typescript-eslint/no-explicit-any const setActionQueueListItemRef = (actionId: string, el: any) => { if (el) { actionQueueListItemRefs.value.set(actionId, el); } else { actionQueueListItemRefs.value.delete(actionId); } }; // Watch for changes in highlighted actions and scroll to show as many as possible watch( () => props.highlightedActionIds, async (newHighlightedIds) => { if (newHighlightedIds.size === 0) return; // Wait for DOM to update await nextTick(); // Find all highlighted actions and their positions const highlightedActions = props.actionViewList.filter((action) => newHighlightedIds.has(action.id), ); if (highlightedActions.length === 0) return; if (highlightedActions.length === 1) { // Single action: scroll to center it const firstAction = highlightedActions[0]; if (firstAction) { const actionCardRef = actionQueueListItemRefs.value.get(firstAction.id); if (actionCardRef && actionCardRef.$el) { actionCardRef.$el.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest", }); } } } else { // Multiple actions: try to show all or scroll to the middle one const firstAction = highlightedActions[0]; const lastAction = highlightedActions[highlightedActions.length - 1]; if (firstAction && lastAction) { const firstActionRef = actionQueueListItemRefs.value.get( firstAction.id, ); const lastActionRef = actionQueueListItemRefs.value.get(lastAction.id); if (firstActionRef?.$el && lastActionRef?.$el) { // Get the container element (scrollable parent) const container = firstActionRef.$el.closest(".scrollable"); if (container) { const firstRect = firstActionRef.$el.getBoundingClientRect(); const lastRect = lastActionRef.$el.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const totalHeight = lastRect.bottom - firstRect.top; const containerHeight = containerRect.height; if (totalHeight <= containerHeight) { // All actions can fit in view, scroll to show them all const middleY = (firstRect.top + lastRect.bottom) / 2; const containerMiddleY = containerRect.top + containerRect.height / 2; const scrollOffset = middleY - containerMiddleY; container.scrollBy({ top: scrollOffset, behavior: "smooth", }); } else { // Actions don't all fit, scroll to the middle action const middleIndex = Math.floor(highlightedActions.length / 2); const middleAction = highlightedActions[middleIndex]; if (middleAction) { const middleActionRef = actionQueueListItemRefs.value.get( middleAction.id, ); if (middleActionRef?.$el) { middleActionRef.$el.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest", }); } } } } } } } }, { deep: true, flush: "post" }, ); </script>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server