feat: optimize node switch logic for large node sets (#12928)

This commit is contained in:
ssongliu
2026-06-03 16:36:42 +08:00
committed by GitHub
parent 15fef4d84e
commit 44e264fe65
15 changed files with 443 additions and 33 deletions
+2 -2
View File
@@ -223,7 +223,7 @@ func doSystemClean(taskItem *task.Task) func(t *task.Task) error {
}
}
dropWithExclude(path.Join(global.Dir.BaseDir, uploadPath), []string{"theme"}, taskItem, &size, &fileCount)
dropWithExclude(path.Join(global.Dir.BaseDir, uploadPath), []string{"theme", "skills-hub"}, taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, downloadPath), taskItem, &size, &fileCount)
logFiles, _ := os.ReadDir(global.Dir.LogDir)
@@ -863,7 +863,7 @@ func loadTreeWithAllFile(isCheck bool, originalPath, treeType, pathItem string,
return lists
}
for _, file := range files {
if treeType == "upload" && (file.Name() == "theme" && file.IsDir()) {
if treeType == "upload" && ((file.Name() == "theme" || file.Name() == "skills-hub") && file.IsDir()) {
continue
}
if treeType == "system_log" && (file.Name() == "1Panel-Core.log" || file.Name() == "1Panel.log" || file.IsDir()) {
+3
View File
@@ -307,11 +307,14 @@ export namespace Setting {
}
export interface NodeItem {
id: number;
groupID?: number;
groupBelong?: string;
addr: string;
status: string;
version: string;
isXpack: boolean;
isBound: boolean;
isFavorite?: boolean;
name: string;
}
export interface SimpleNodeItem {
+3
View File
@@ -43,6 +43,9 @@ export const loadLicenseOptions = () => {
export const listNodeOptions = (type: string) => {
return http.post<Array<Setting.NodeItem>>(`/core/nodes/list`, { type: type });
};
export const updateNodeFavorite = (id: number, isFavorite: boolean) => {
return http.post(`/core/xpack/nodes/favorite`, { id, isFavorite });
};
export const listAllSimpleNodes = () => {
return http.get<Array<Setting.SimpleNodeItem>>(`/core/nodes/simple/all`);
};
+2
View File
@@ -5126,6 +5126,8 @@ const message = {
memTotal: 'Total Memory',
nodeManagement: 'Multi-Machine Management',
multiOverview: 'Multi-Machine',
commonNodes: 'Frequently Used Nodes',
searchNodePlaceholder: 'Search node name, group, or address',
nodeItem: 'Node Management',
panelItem: 'Panel Management',
addPanel: 'Add Panel',
+2
View File
@@ -5177,6 +5177,8 @@ const message = {
memTotal: 'Memoria Total',
nodeManagement: 'Gestión Multi-Máquina',
multiOverview: 'Multimáquina',
commonNodes: 'Nodos frecuentes',
searchNodePlaceholder: 'Buscar nombre, grupo o dirección del nodo',
nodeItem: 'Gestión de Nodos',
panelItem: 'Gestión de Paneles',
addNode: 'Añadir Nodo',
+2
View File
@@ -5146,6 +5146,8 @@ const message = {
memTotal: '総メモリ',
nodeManagement: 'マルチマシン管理',
multiOverview: 'マルチマシン',
commonNodes: 'よく使うノード',
searchNodePlaceholder: 'ノード名グループアドレスを検索',
nodeItem: 'ノード管理',
panelItem: 'パネル管理',
addPanel: 'パネル追加',
+2
View File
@@ -5036,6 +5036,8 @@ const message = {
memTotal: ' 메모리',
nodeManagement: '다중 머신 관리',
multiOverview: '다중 머신',
commonNodes: '자주 사용하는 노드',
searchNodePlaceholder: '노드 이름, 그룹 또는 주소 검색',
nodeItem: '노드 관리',
panelItem: '패널 관리',
addPanel: '패널 추가',
+2
View File
@@ -5204,6 +5204,8 @@ const message = {
memTotal: 'Jumlah Memori',
nodeManagement: 'Pengurusan Multi-Mesin',
multiOverview: 'Multi-Mesin',
commonNodes: 'Nod Kerap Digunakan',
searchNodePlaceholder: 'Cari nama nod, kumpulan atau alamat',
nodeItem: 'Pengurusan Nod',
panelItem: 'Pengurusan Panel',
addPanel: 'Tambah Panel',
+2
View File
@@ -5352,6 +5352,8 @@ const message = {
memTotal: 'Memória Total',
nodeManagement: 'Gerenciamento Multi-Máquina',
multiOverview: 'Multi-Máquina',
commonNodes: 'Nós frequentes',
searchNodePlaceholder: 'Pesquise nome, grupo ou endereço do ',
nodeItem: 'Gerenciamento de Nós',
panelItem: 'Gerenciamento de Painéis',
addPanel: 'Adicionar Painel',
+2
View File
@@ -5207,6 +5207,8 @@ const message = {
memTotal: 'Общая память',
nodeManagement: 'Управление Несколькими Машинами',
multiOverview: 'Несколько Машин',
commonNodes: 'Часто используемые узлы',
searchNodePlaceholder: 'Поиск по имени узла, группе или адресу',
nodeItem: 'Управление Узлами',
panelItem: 'Управление Панелями',
addPanel: 'Добавить Панель',
+2
View File
@@ -5194,6 +5194,8 @@ const message = {
memTotal: 'Toplam Bellek',
nodeManagement: 'Çoklu Makine Yönetimi',
multiOverview: 'Çoklu Makine',
commonNodes: 'Sık Kullanılan Düğümler',
searchNodePlaceholder: 'Düğüm adı, grup veya adres ara',
nodeItem: 'Düğüm Yönetimi',
panelItem: 'Panel Yönetimi',
addPanel: 'Panel Ekle',
+2
View File
@@ -4768,6 +4768,8 @@ const message = {
memTotal: '記憶體總計',
nodeManagement: '多機管理',
multiOverview: '多機概覽',
commonNodes: '常用節點',
searchNodePlaceholder: '搜尋節點名稱群組或地址',
nodeItem: '節點管理',
panelItem: '面板管理',
addPanel: '添加面板',
+2
View File
@@ -4219,6 +4219,8 @@ const message = {
memTotal: '内存总计',
nodeManagement: '多机管理',
multiOverview: '多机概览',
commonNodes: '常用节点',
searchNodePlaceholder: '搜索节点名称分组或地址',
nodeItem: '节点管理',
panelItem: '面板管理',
addPanel: '添加面板',
@@ -1,6 +1,7 @@
<template>
<div>
<el-popover
v-model:visible="popoverVisible"
placement="right-end"
:show-arrow="false"
:offset="0"
@@ -44,16 +45,16 @@
<el-divider v-if="isXpackOrEE" class="divider" />
<div v-if="showNodes()">
<el-scrollbar max-height="168px" :noresize="true">
<el-scrollbar max-height="288px" :noresize="true">
<div
class="dropdown-item"
@click="changeNode(item.name)"
:disabled="item.status !== 'Healthy'"
v-for="item in nodeOptions"
v-for="item in visibleNodeOptions"
:key="item.name"
>
<SvgIcon class="icon" iconName="p-zhuji" />
{{ item.name === 'local' ? globalStore.getMasterAlias() : item.name }}
<span class="node-name">{{ displayNodeName(item) }}</span>
<el-tooltip
v-if="item.status !== 'Healthy' || !item.isBound"
:content="item.isBound ? $t('xpack.node.nodeUnhealthy') : $t('xpack.node.nodeUnbind')"
@@ -65,16 +66,11 @@
</el-tooltip>
</div>
</el-scrollbar>
<div v-if="showMoreNodes" class="dropdown-item more-node-button" @click.stop="openNodeDrawer">
<span>{{ $t('tabs.more') }}...</span>
<span class="more-node-count">{{ nodeOptions.length - defaultNodeLimit }}</span>
</div>
</div>
<el-input
v-if="showNodes() && nodes?.length > 5"
suffix-icon="Search"
v-model="filter"
@input="changeFilter"
class="w-full filter-input"
size="small"
clearable
/>
<el-divider class="divider" />
<div class="dropdown-item" @click="logout">
<SvgIcon class="icon" iconName="p-tuichudenglu3" />
@@ -82,6 +78,13 @@
</div>
</div>
</el-popover>
<NodeDrawer
v-model="nodeDrawerVisible"
:nodes="nodeOptions"
:master-alias="globalStore.getMasterAlias()"
@change="changeNode"
@favorite-change="handleFavoriteChange"
/>
<UserInfo ref="userInfoRef" :currentUser="currentUser" @search="loadCurrentUser()" />
</div>
</template>
@@ -92,7 +95,7 @@ import { countExecutingTask } from '@/api/modules/log';
import { MsgError, MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
import { getAgentSettingInfo } from '@/api/modules/setting';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import bus from '@/global/bus';
import { logOutApi } from '@/api/modules/auth';
import router from '@/routers';
@@ -102,9 +105,9 @@ import { changeToLocal, listNodes, setDefaultNodeInfo } from '@/utils/node';
import { Login } from '@/api/interface/auth';
import { syncAuthInfo } from '@/utils/rbac';
import UserInfo from './user-info/index.vue';
import NodeDrawer from './node-drawer/index.vue';
import { useGlobalStore } from '@/composables/useGlobalStore';
const filter = ref();
const currentUser = ref<Login.AuthInfo>();
const { globalStore, currentNode, currentNodeAddr, defaultNetwork, entrance, isEnterprise, isXpackOrEE } =
useGlobalStore();
@@ -113,11 +116,15 @@ const nodes = ref([]);
const nodeOptions = ref([]);
const loading = ref();
const switchingNode = ref(false);
const popoverVisible = ref(false);
const nodeDrawerVisible = ref(false);
const userInfoRef = ref();
const props = defineProps({
version: String,
});
const defaultNodeLimit = 8;
const emit = defineEmits(['openTask', 'refresh']);
bus.on('refreshTask', () => {
checkTask();
@@ -133,19 +140,28 @@ const loadCurrentName = () => {
return globalStore.getMasterAlias();
};
const visibleNodeOptions = computed(() => {
return nodeOptions.value.slice(0, defaultNodeLimit);
});
const showMoreNodes = computed(() => {
return nodeOptions.value.length > defaultNodeLimit;
});
const showPopover = async () => {
filter.value = '';
await loadNodes();
changeFilter();
};
const changeFilter = () => {
nodeOptions.value = [];
for (const item of nodes.value) {
if (item.name.indexOf(filter.value) !== -1) {
nodeOptions.value.push(item);
}
}
const displayNodeName = (item) => {
return item.name === 'local' ? globalStore.getMasterAlias() : item.name;
};
const openNodeDrawer = () => {
nodeDrawerVisible.value = true;
popoverVisible.value = false;
};
const handleFavoriteChange = async () => {
await loadNodes();
};
const loadNodes = async () => {
@@ -162,11 +178,6 @@ const loadNodes = async () => {
if (nodes.value.length === 0) {
setDefaultNodeInfo();
}
nodes.value.sort((a, b) => {
if (a.name === 'local') return -1;
if (b.name === 'local') return 1;
return 0;
});
nodeOptions.value = nodes.value || [];
loading.value = false;
})
@@ -362,9 +373,28 @@ onMounted(() => {
}
}
}
.filter-input {
padding: 0 8px;
margin-bottom: 4px;
.node-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.more-node-button {
padding-left: 34px;
color: var(--el-color-primary);
}
.more-node-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
line-height: 18px;
}
.dropdown-item:hover {
background: var(--el-menu-item-bg-color-active);
@@ -0,0 +1,354 @@
<template>
<DrawerPro v-model="drawerVisible" :header="$t('setting.selectNode')" @close="handleClose">
<template #content>
<div class="node-drawer">
<el-input
v-model="keyword"
:placeholder="$t('xpack.node.searchNodePlaceholder')"
class="node-search-input"
clearable
/>
<div class="node-grid">
<section v-for="group in nodeGroups" :key="group.key" class="node-section">
<div class="node-table">
<div class="node-table__header">
<div class="node-table__cell node-table__cell--node">
<span class="node-table__group-title">{{ group.label }}</span>
<span class="node-table__summary">{{ group.items.length }}</span>
</div>
<div class="node-table__cell node-table__cell--addr">{{ $t('home.ip') }}</div>
</div>
<div v-for="row in group.items" :key="row.name" class="node-table__row">
<div class="node-table__cell node-table__cell--node">
<el-tooltip
:content="
row.isFavorite ? $t('website.cancelFavorite') : $t('website.favorite')
"
placement="left"
>
<el-button
class="node-favorite-button"
link
v-if="isAdmin"
:type="row.isFavorite ? 'warning' : 'info'"
:icon="row.isFavorite ? 'StarFilled' : 'Star'"
:loading="favoriteLoadingIDs.includes(row.id)"
@click.stop="toggleFavorite(row)"
/>
</el-tooltip>
<div class="node-name-cell">
<SvgIcon class="icon" iconName="p-zhuji" />
<el-button
class="node-name-button"
link
type="primary"
@click="selectNode(row.name)"
>
{{ displayNodeName(row) }}
</el-button>
<el-tooltip
v-if="row.status !== 'Healthy' || !row.isBound"
:content="
row.isBound
? $t('xpack.node.nodeUnhealthy')
: $t('xpack.node.nodeUnbind')
"
placement="left"
>
<span class="node-status-wrap">
<el-icon class="icon-status" type="danger">
<Warning />
</el-icon>
</span>
</el-tooltip>
</div>
</div>
<div class="node-table__cell node-table__cell--addr">
<span class="node-table__addr-text">{{ row.addr }}</span>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
</DrawerPro>
</template>
<script setup lang="ts">
import { Setting } from '@/api/interface/setting';
import { updateNodeFavorite } from '@/api/modules/setting';
import DrawerPro from '@/components/drawer-pro/index.vue';
import i18n from '@/lang';
import { computed, ref, watch } from 'vue';
import { useGlobalStore } from '@/composables/useGlobalStore';
const { isAdmin } = useGlobalStore();
type NodeItem = Setting.NodeItem & {
group?: string;
};
const props = defineProps<{
modelValue: boolean;
nodes: NodeItem[];
masterAlias: string;
}>();
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void;
(event: 'change', value: string): void;
(event: 'favorite-change'): void;
}>();
const keyword = ref('');
const favoriteLoadingIDs = ref<number[]>([]);
const drawerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const filteredNodes = computed(() => {
const searchValue = keyword.value.trim().toLowerCase();
return props.nodes.filter((item) => {
const group = displayGroupName(item);
if (!searchValue) {
return true;
}
return [item.name, group, item.addr].some((value) =>
String(value || '')
.toLowerCase()
.includes(searchValue),
);
});
});
const sortedNodes = computed(() => {
return [...filteredNodes.value];
});
const commonNodeLimit = 8;
const commonNodes = computed(() => {
return sortedNodes.value.slice(0, commonNodeLimit);
});
const nodeGroups = computed(() => {
const groupMap = new Map<string, { key: string; label: string; items: NodeItem[] }>();
const commonNodeIDs = new Set(commonNodes.value.map((item) => item.id));
for (const item of sortedNodes.value.filter((item) => !commonNodeIDs.has(item.id))) {
const label = displayGroupName(item);
if (!groupMap.has(label)) {
groupMap.set(label, {
key: label,
label,
items: [],
});
}
groupMap.get(label)!.items.push(item);
}
const groups = Array.from(groupMap.values()).sort((a, b) => a.label.localeCompare(b.label));
if (commonNodes.value.length > 0) {
groups.unshift({
key: 'common',
label: i18n.global.t('xpack.node.commonNodes'),
items: commonNodes.value,
});
}
return groups;
});
const displayNodeName = (item: NodeItem) => {
return item.name === 'local' ? props.masterAlias : item.name;
};
const displayGroupName = (item: NodeItem) => {
if (item.groupBelong === 'Default') {
return i18n.global.t('commons.table.default');
}
return item.group || item.groupBelong || i18n.global.t('commons.table.default');
};
const selectNode = (name: string) => {
emit('change', name);
emit('update:modelValue', false);
};
const toggleFavorite = async (row: NodeItem) => {
if (favoriteLoadingIDs.value.includes(row.id)) {
return;
}
favoriteLoadingIDs.value.push(row.id);
try {
await updateNodeFavorite(row.id, !row.isFavorite);
emit('favorite-change');
} finally {
favoriteLoadingIDs.value = favoriteLoadingIDs.value.filter((id) => id !== row.id);
}
};
const handleClose = () => {
emit('update:modelValue', false);
};
watch(
() => props.modelValue,
(value) => {
if (value) {
keyword.value = '';
}
},
);
</script>
<style scoped lang="scss">
.node-drawer {
display: flex;
flex-direction: column;
gap: 10px;
height: calc(100vh - 104px);
padding: 0 16px 16px;
box-sizing: border-box;
}
.node-search-input {
flex-shrink: 0;
}
.node-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.node-section {
min-width: 0;
}
.node-table {
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background-color: var(--el-bg-color);
}
.node-table__header,
.node-table__row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 42%);
align-items: stretch;
}
.node-table__header {
min-height: 30px;
background-color: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
font-size: 12px;
font-weight: 500;
}
.node-table__row {
min-height: 34px;
border-top: 1px solid var(--el-border-color-lighter);
color: var(--el-text-color-regular);
font-size: 12px;
}
.node-table__cell {
min-width: 0;
padding: 5px 8px;
line-height: 20px;
}
.node-table__cell--node {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
white-space: nowrap;
}
.node-table__cell--addr {
display: flex;
align-items: center;
justify-content: flex-start;
border-left: 1px solid var(--el-border-color-lighter);
overflow: hidden;
}
.node-table__group-title {
min-width: 0;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
}
.node-table__summary {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-size: 10px;
}
.node-table__addr-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-favorite-button {
flex-shrink: 0;
width: 20px;
height: 20px;
min-height: 20px;
padding: 0;
}
.node-name-cell {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
.icon {
flex-shrink: 0;
font-size: 8px;
}
}
.node-name-button {
height: 20px;
min-height: 20px;
padding: 0;
line-height: 20px;
}
.node-status-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 20px;
}
.icon-status {
font-size: 16px;
}
@media (max-width: 480px) {
.node-table__header,
.node-table__row {
grid-template-columns: minmax(0, 1fr) minmax(96px, 34%);
}
.node-table__cell {
padding: 5px 6px;
}
}
</style>