From 460866b46fc365dba409315db065cedc559cd21c Mon Sep 17 00:00:00 2001 From: luohua13 Date: Thu, 21 May 2026 13:46:40 +0000 Subject: [PATCH] feat(ui): scope filter dropdowns to the current selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compute-management and workload list pages let you filter by overlapping dimensions (card type, node, card). Previously each dropdown listed every value in the cluster, so it was easy to pick combinations that can never match — e.g. a node that has none of the selected card type, or a card that isn't on the selected node — leaving the user with an empty table and no hint why. This adds one-way cascading so a dependent dropdown only offers values that are reachable given the current selection: Compute-management page (card/admin): * The node dropdown now lists only nodes that actually carry the selected card type. When the type changes (via the select, the pie chart, or a ?type= route query) a previously chosen node that no longer matches is cleared, so the table is never filtered by an out-of-scope node. The prune runs synchronously before each refresh to avoid querying with a stale node. Workload list page (task/admin): * Adds a card filter dropdown (the backend GetAllContainers already supports the device_id filter). The dropdown lists only the cards on the selected node, and switching node clears a chosen card that doesn't belong to it. Both cascades reuse the node/card list data already fetched for the filters (the node "type" array and the card "nodeName" field), so no extra API calls are introduced. Signed-off-by: luohua13 --- .../projects/vgpu/views/card/admin/index.vue | 47 +++++++++++++++---- .../projects/vgpu/views/task/admin/index.vue | 43 ++++++++++++++++- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/packages/web/projects/vgpu/views/card/admin/index.vue b/packages/web/projects/vgpu/views/card/admin/index.vue index a8bc6a44..d8781368 100644 --- a/packages/web/projects/vgpu/views/card/admin/index.vue +++ b/packages/web/projects/vgpu/views/card/admin/index.vue @@ -24,7 +24,7 @@ clearable :placeholder="$t('card.allCardTypes')" :options="cardTypeOptions" - @change="applyFilters" + @change="onTypeChange" /> [ - { label: t('card.allNodes'), value: undefined }, - ...rawNodeNames.value.map((name) => ({ label: name, value: name })), -]); +const nodeOptions = computed(() => { + // When a card type is selected, only offer nodes that actually carry that type. + const nodes = filters.type + ? rawNodes.value.filter((node) => node.types.includes(filters.type)) + : rawNodes.value; + return [ + { label: t('card.allNodes'), value: undefined }, + ...nodes.map((node) => ({ label: node.name, value: node.name })), + ]; +}); const cardTypeOptions = computed(() => [ { label: t('card.allCardTypes'), value: undefined }, ...rawCardTypes.value.map((type) => ({ label: type, value: type })), @@ -121,18 +127,37 @@ const fetchFilterOptions = async () => { request(nodeApi.getNodeList({ filters: {} })), request(cardApi.getCardType()), ]); - rawNodeNames.value = nodeList - .map((item) => item?.name) - .filter(Boolean); + rawNodes.value = nodeList + .filter((item) => item?.name) + .map((item) => ({ name: item.name, types: Array.isArray(item.type) ? item.type : [] })); rawCardTypes.value = typeList .map((item) => item?.type) .filter(Boolean); } catch { - rawNodeNames.value = []; + rawNodes.value = []; rawCardTypes.value = []; } }; +// When the selected type no longer includes the chosen node, drop the node so +// the table isn't filtered by an out-of-scope node. Call synchronously before +// applyFilters at every type-mutation site to avoid querying with a stale node. +const pruneNodeScopeByType = () => { + if (!filters.type || !filters.nodeName) { + return; + } + const node = rawNodes.value.find((item) => item.name === filters.nodeName); + if (!node || !node.types.includes(filters.type)) { + filters.nodeName = undefined; + hasManualNodeScope.value = true; + } +}; + +const onTypeChange = () => { + pruneNodeScopeByType(); + applyFilters(); +}; + const handleClick = (params) => { router.push({ path: `/admin/vgpu/card/admin/${params.data.name}`, @@ -336,6 +361,7 @@ const handlePieClick = (params, echarts) => { dataIndex: params.dataIndex, }); filters.type = name; + pruneNodeScopeByType(); applyFilters(); }; @@ -400,6 +426,7 @@ watch( const next = parseTypeFromQuery(value); if (filters.type === next) return; filters.type = next; + pruneNodeScopeByType(); applyFilters(); }, ); diff --git a/packages/web/projects/vgpu/views/task/admin/index.vue b/packages/web/projects/vgpu/views/task/admin/index.vue index 153505e3..38007f35 100644 --- a/packages/web/projects/vgpu/views/task/admin/index.vue +++ b/packages/web/projects/vgpu/views/task/admin/index.vue @@ -27,6 +27,13 @@ :options="statusOptions" @change="applyFilters" /> + import taskApi from '~/vgpu/api/task'; import nodeApi from '~/vgpu/api/node'; +import cardApi from '~/vgpu/api/card'; import Toolbar from '@/components/TablePlus/Toolbar.vue'; import TablePagination from '@/components/TablePlus/Pagination.vue'; import { roundToDecimal, timeParse } from '@/utils'; @@ -95,12 +103,24 @@ const filters = reactive({ name: props.filters?.name || '', nodeName: props.filters?.nodeName, status: props.filters?.status, + deviceId: props.filters?.deviceId, }); const rawNodeNames = ref([]); +const rawCards = ref([]); const nodeOptions = computed(() => [ { label: t('task.allNodes'), value: undefined }, ...rawNodeNames.value.map((name) => ({ label: name, value: name })), ]); +const cardOptions = computed(() => { + // When a node is selected, only offer the cards that live on that node. + const cards = filters.nodeName + ? rawCards.value.filter((card) => card.nodeName === filters.nodeName) + : rawCards.value; + return [ + { label: t('task.allCards'), value: undefined }, + ...cards.map((card) => ({ label: card.uuid, value: card.uuid })), + ]; +}); const statusOptions = computed(() => [ { label: t('task.allStatus'), value: undefined }, { label: t('task.statusCompleted'), value: 'closed' }, @@ -119,12 +139,19 @@ const state = reactive({ const fetchFilterOptions = async () => { try { - const { list: nodeList = [] } = await request(nodeApi.getNodeList({ filters: {} })); + const [{ list: nodeList = [] }, { list: cardList = [] }] = await Promise.all([ + request(nodeApi.getNodeList({ filters: {} })), + request(cardApi.getCardList({ filters: {} })), + ]); rawNodeNames.value = nodeList .map((item) => item?.name) .filter(Boolean); + rawCards.value = cardList + .filter((item) => item?.uuid) + .map((item) => ({ uuid: item.uuid, nodeName: item.nodeName })); } catch { rawNodeNames.value = []; + rawCards.value = []; } }; @@ -251,6 +278,7 @@ const fetchTableData = async () => { ...(nodeName ? { nodeName } : {}), ...(nodeUid ? { nodeUid } : {}), ...(filters.status ? { status: filters.status } : {}), + ...(filters.deviceId ? { deviceId: filters.deviceId } : {}), }, }; const { items = [] } = await taskApi.getTaskListReq(payload); @@ -266,6 +294,17 @@ const { getTrimValue, applyFilters, refreshTable } = useTableFilters({ }); const onNodeNameChange = () => { hasManualNodeScope.value = true; + // The card dropdown is scoped to the selected node; drop a previously chosen + // card if it doesn't belong to that node so the table isn't filtered by an + // out-of-scope device. + if (filters.nodeName && filters.deviceId) { + const stillValid = rawCards.value.some( + (card) => card.nodeName === filters.nodeName && card.uuid === filters.deviceId, + ); + if (!stillValid) { + filters.deviceId = undefined; + } + } applyFilters(); }; @@ -279,12 +318,14 @@ watch( props.filters?.nodeName, props.filters?.nodeUid, props.filters?.status, + props.filters?.deviceId, ], () => { hasManualNodeScope.value = false; filters.name = props.filters?.name || ''; filters.nodeName = props.filters?.nodeName; filters.status = props.filters?.status; + filters.deviceId = props.filters?.deviceId; applyFilters(); }, { immediate: true },