feat(terminal): share connection menu and add node quick connect (#13787)

This commit is contained in:
ssongliu
2026-09-14 09:40:15 +08:00
committed by GitHub
parent 9300bf4141
commit aba41c0aea
8 changed files with 423 additions and 391 deletions

View File

@@ -14,8 +14,13 @@ export const deleteGroup = (id: number) => {
return http.post(`/core/groups/del`, { id: id });
};
export const getAgentGroupList = (type: string) => {
return http.post<Array<Group.GroupInfo>>(`/groups/search`, { type: type });
export const getAgentGroupList = (type: string, nodeName?: string) => {
return http.post<Array<Group.GroupInfo>>(
`/groups/search`,
{ type: type },
undefined,
nodeName ? { CurrentNode: nodeName } : undefined,
);
};
export const createAgentGroup = (params: Group.GroupCreate) => {
return http.post<Group.GroupCreate>(`/groups`, params);

View File

@@ -16,19 +16,24 @@ export const getHostTree = (params: Host.ReqSearch) => {
export const updateLocalConn = (param: { withReset: boolean; defaultConn: string }) => {
return http.post(`/settings/ssh/default`, param);
};
export const addHost = (params: Host.HostOperate) => {
export const addHost = (params: Host.HostOperate, nodeName?: string) => {
let request = deepCopy(params) as Host.HostOperate;
encodeBase64Fields(request, ['password', 'privateKey']);
if (params.isLocal) {
return http.post(`/settings/ssh`, request);
return http.post(`/settings/ssh`, request, undefined, nodeName ? { CurrentNode: nodeName } : undefined);
}
return http.postLocalNode<Host.HostOperate>(`/hosts`, request);
};
export const testByInfo = (params: Host.HostConnTest) => {
export const testByInfo = (params: Host.HostConnTest | Host.HostOperate, nodeName?: string) => {
let request = deepCopy(params) as Host.HostOperate;
encodeBase64Fields(request, ['password', 'privateKey']);
if (params.isLocal) {
return http.post<boolean>(`/settings/ssh/check/info`, request);
return http.post<boolean>(
`/settings/ssh/check/info`,
request,
undefined,
nodeName ? { CurrentNode: nodeName } : undefined,
);
}
return http.postLocalNode<boolean>(`/hosts/test/byinfo`, request);
};
@@ -47,15 +52,18 @@ export const deleteHost = (params: { ids: number[] }) => {
return http.postLocalNode(`/hosts/del`, params);
};
// agent
export const loadLocalConn = () => {
return http.get<Host.HostConnTest>(`/settings/ssh/conn`);
};
export const testLocalConn = () => {
return http.post<boolean>(`/settings/ssh/check`);
export const testLocalConn = (nodeName?: string) => {
return http.post<boolean>(
`/settings/ssh/check`,
undefined,
undefined,
nodeName ? { CurrentNode: nodeName } : undefined,
);
};
// live web terminal sessions of the caller; ssh ones live on the local node, local shells on the operated node
export const searchTerminalSessions = (localNode: boolean) => {
return localNode
? http.postLocalNode<TerminalSession[]>(`/hosts/terminal/sessions/search`)

View File

@@ -0,0 +1,342 @@
<template>
<el-popover
v-model:visible="visible"
trigger="click"
placement="bottom-start"
:width="400"
popper-class="terminal-connection-popover"
@before-enter="loadConnections"
>
<template #reference>
<el-button
class="terminal-connection-add"
@click.stop
@keydown.stop
icon="Plus"
text
:aria-label="$t('terminal.createConn')"
/>
</template>
<div class="terminal-connection-menu">
<div class="terminal-connection-actions">
<el-button
v-if="!isNodeAdmin"
text
class="terminal-connection-action"
:disabled="connecting"
@click="onNewSsh"
>
<el-icon><Plus /></el-icon>
{{ $t('terminal.createConn') }}
</el-button>
<el-button text class="terminal-connection-action" :disabled="connecting" @click="connectLocal()">
<el-icon><House /></el-icon>
{{ $t('terminal.localhost') }}
</el-button>
</div>
<template v-if="connectionTree.length > 0 || !isNodeAdmin || loadingConnections">
<el-input
v-model="connectionFilter"
size="small"
clearable
prefix-icon="Search"
:placeholder="$t('commons.button.search')"
:aria-label="$t('terminal.createConn')"
/>
<el-tree
ref="treeRef"
v-loading="loadingConnections"
node-key="id"
default-expand-all
:expand-on-click-node="false"
:data="connectionTree"
:filter-node-method="filterConnection"
:empty-text="$t('commons.msg.noneData')"
class="terminal-connection-tree"
>
<template #default="{ data }">
<span v-if="data.kind === 'group'" class="terminal-connection-group">
{{ data.label }}
</span>
<el-button
v-else
text
class="terminal-connection-item"
:disabled="connecting"
:title="data.node ? `${data.label} (${data.node.addr})` : data.label"
@click.stop="connectItem(data)"
>
<span class="terminal-connection-label">{{ data.label }}</span>
<span v-if="data.node" class="terminal-connection-address">
{{ data.node.addr }}
</span>
</el-button>
</template>
</el-tree>
</template>
</div>
</el-popover>
<HostDialog
ref="hostDialogRef"
@on-conn-terminal="onHostCreated"
@on-new-local="onLocalConfigured"
@load-host-tree="loadHosts"
/>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { ElTree } from 'element-plus';
import i18n from '@/lang';
import { listNodeOptions } from '@/api/modules/setting';
import { getHostTree, testByID, testLocalConn } from '@/api/modules/terminal';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { MsgError } from '@/utils/message';
import type { Host } from '@/api/interface/host';
import type { Setting } from '@/api/interface/setting';
import HostDialog from '@/components/terminal/host-create.vue';
import type { TerminalConnectionOptions } from './types';
const props = defineProps<{
openSession: (options: TerminalConnectionOptions) => Promise<void>;
}>();
const visible = defineModel<boolean>({ default: false });
const { isNodeAdmin, isXpackOrEE, currentNode, globalStore } = useGlobalStore();
const hostDialogRef = ref<InstanceType<typeof HostDialog>>();
const onNewSsh = () => {
if (isNodeAdmin.value || connecting.value) return;
visible.value = false;
hostDialogRef.value?.acceptParams({ isLocal: false });
};
const onHostCreated = (title: string, wsID: number) => connect(wsID, title);
const connectLocal = () => connect(0, i18n.global.t('terminal.localhost'));
const onLocalConfigured = (nodeName: string) =>
connect(
0,
`${i18n.global.t('terminal.localhost')} (${nodeName === 'local' ? globalStore.getMasterAlias() : nodeName})`,
nodeName,
);
interface ConnectionTreeItem {
id: string;
label: string;
kind: 'group' | 'node' | 'host';
children?: ConnectionTreeItem[];
node?: Setting.NodeItem;
wsID?: number;
}
const hostTree = ref<Array<Host.HostTree>>([]);
const treeRef = ref<InstanceType<typeof ElTree>>();
const connectionFilter = ref('');
const nodes = ref<Setting.NodeItem[]>([]);
const loadingConnections = ref(false);
const connecting = ref(false);
const connectionTree = computed<ConnectionTreeItem[]>(() => {
const groups: ConnectionTreeItem[] = [];
const childNodes = isXpackOrEE.value
? nodes.value.filter((node) => node.name !== 'local' && node.status !== 'Deleted')
: [];
if (childNodes.length > 0) {
groups.push({
id: 'panel-nodes',
label: i18n.global.t('xpack.node.node'),
kind: 'group',
children: childNodes.map((node) => ({
id: `node-${node.id}`,
label: node.name,
kind: 'node',
node,
})),
});
}
if (!isNodeAdmin.value) {
groups.push(
...hostTree.value.map((group): ConnectionTreeItem => ({
id: `host-group-${group.id}`,
label: group.label === 'Default' ? i18n.global.t('commons.table.default') : group.label,
kind: 'group',
children: (group.children || []).map((host) => ({
id: `host-${host.id}`,
label: host.label,
kind: 'host',
wsID: host.id,
})),
})),
);
}
return groups;
});
const loadNodes = async () => {
nodes.value = [];
if (!isXpackOrEE.value) return;
const res = await listNodeOptions('all');
nodes.value = res.data || [];
};
const loadHosts = async () => {
hostTree.value = [];
if (isNodeAdmin.value) return;
const res = await getHostTree({});
hostTree.value = res.data || [];
};
const loadConnections = async () => {
loadingConnections.value = true;
try {
await Promise.allSettled([loadHosts(), loadNodes()]);
} finally {
loadingConnections.value = false;
}
};
watch([connectionFilter, connectionTree], async () => {
await nextTick();
treeRef.value?.filter(connectionFilter.value);
});
const filterConnection = (value: string, data: ConnectionTreeItem) => {
const filter = value.trim().toLowerCase();
return !filter || [data.label, data.node?.addr || ''].some((text) => text.toLowerCase().includes(filter));
};
const connectItem = (item: ConnectionTreeItem) => {
if (item.kind === 'node' && item.node) return connect(0, item.label, item.node.name);
if (item.kind === 'host' && item.wsID !== undefined) return connect(item.wsID, item.label);
};
const connect = async (wsID: number, title: string, nodeName?: string) => {
if (connecting.value || (wsID > 0 && isNodeAdmin.value)) return;
connecting.value = true;
visible.value = false;
const targetNode = nodeName || currentNode.value || 'local';
try {
if (wsID === 0) {
const res = await testLocalConn(targetNode);
if (!res.data) {
if (!nodeName) {
hostDialogRef.value?.acceptParams({ isLocal: true, nodeName: targetNode });
} else {
MsgError(`${title}: ${i18n.global.t('terminal.connLocalErr')}`);
}
return;
}
await props.openSession({
title: nodeName
? title
: `${title} (${targetNode === 'local' ? globalStore.getMasterAlias() : targetNode})`,
wsID,
nodeName: targetNode,
});
return;
}
const res = await testByID(wsID);
await props.openSession({
title,
wsID,
error: res.data ? '' : 'Authentication failed. Please check the host information!',
});
} finally {
connecting.value = false;
}
};
defineExpose({ connectLocal });
</script>
<style lang="scss">
.terminal-connection-popover {
max-width: calc(100vw - 24px);
box-sizing: border-box;
}
</style>
<style scoped lang="scss">
.terminal-connection-add {
width: 32px;
height: 32px;
margin: 0 4px;
padding: 0;
border-radius: 6px;
color: var(--el-text-color-regular);
&:hover {
color: var(--el-color-primary);
}
}
.terminal-connection-menu {
display: flex;
flex-direction: column;
gap: 8px;
}
.terminal-connection-actions {
display: flex;
gap: 8px;
}
.terminal-connection-action {
flex: 1;
min-width: 0;
height: 30px;
margin: 0;
padding: 0 6px;
font-size: 13px;
background-color: var(--el-fill-color-light);
.el-icon {
margin-right: 8px;
color: var(--el-text-color-secondary);
}
}
.terminal-connection-tree {
max-height: min(192px, 35vh);
min-height: 32px;
overflow: auto;
:deep(.el-tree-node__content) {
height: 32px;
border-radius: 4px;
}
}
.terminal-connection-group {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.terminal-connection-item {
flex: 1;
min-width: 0;
height: 30px;
margin: 0;
padding: 0 8px 0 0;
font-size: 13px;
font-weight: normal;
:deep(> span) {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
width: 100%;
}
}
.terminal-connection-label {
flex: 1;
text-align: left;
}
.terminal-connection-address {
flex: 0 1 45%;
font-size: 12px;
text-align: right;
color: var(--el-text-color-secondary);
}
.terminal-connection-label,
.terminal-connection-address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,6 @@
export interface TerminalConnectionOptions {
title: string;
wsID: number;
nodeName?: string;
error?: string;
}

View File

@@ -1,5 +1,4 @@
<template>
<!-- Right edge handle: open a terminal from any page without leaving it. Hidden on the terminal page itself. -->
<div v-if="terminalStore.showTerminalButton && !onTerminalPage" class="terminal-dock-handle" @click="show">
<el-badge
:value="store.entries.length"
@@ -20,7 +19,6 @@
:modal="false"
@opened="claim"
>
<!-- minimize keeps sessions alive; X closes them all (with confirm) -->
<template #header>
<div class="flex items-center">
<div class="terminal-dock-title flex-1">
@@ -67,62 +65,7 @@
</template>
</el-tab-pane>
<template #add-icon>
<el-popover
v-model:visible="showConnections"
trigger="click"
placement="bottom-start"
width="280px"
@before-enter="loadHosts"
>
<template #reference>
<el-button
class="terminal-dock-add"
@click.stop
@keydown.stop
icon="Plus"
text
:aria-label="$t('terminal.createConn')"
/>
</template>
<el-button link class="w-full" @click="connect(0, $t('terminal.localhost'))">
<el-icon class="mr-1"><House /></el-icon>
{{ $t('terminal.localhost') }}
</el-button>
<template v-if="!isNodeAdmin">
<el-divider class="my-1" />
<el-input
v-model="hostFilter"
size="small"
clearable
:placeholder="$t('commons.button.search')"
class="mb-1"
/>
<el-tree
ref="treeRef"
node-key="id"
default-expand-all
:expand-on-click-node="false"
:data="hostTree"
:filter-node-method="filterHost"
:empty-text="$t('terminal.noHost')"
class="terminal-dock-tree"
>
<template #default="{ node, data }">
<span v-if="node.level === 1" class="text-xs font-medium">
{{ node.label === 'Default' ? $t('commons.table.default') : node.label }}
</span>
<a
v-else
class="text-xs hover:text-[var(--el-color-primary)] truncate"
:title="node.label"
@click="connect(data.id, node.label)"
>
{{ node.label }}
</a>
</template>
</el-tree>
</template>
</el-popover>
<ConnectionMenu v-model="showConnections" :open-session="openConnection" />
</template>
</el-tabs>
</div>
@@ -146,18 +89,14 @@
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import i18n from '@/lang';
import { ElTree } from 'element-plus';
import { TerminalSessionStore, TerminalStore } from '@/store';
import { getTerminalInfo } from '@/api/modules/setting';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { getHostTree, testByID, testLocalConn } from '@/api/modules/terminal';
import { MsgError } from '@/utils/message';
import { ElMessageBox } from 'element-plus';
import { Host } from '@/api/interface/host';
import ConnectionMenu from '@/components/terminal/connection-menu/index.vue';
import type { TerminalConnectionOptions } from '@/components/terminal/connection-menu/types';
const store = TerminalSessionStore();
const terminalStore = TerminalStore();
const { isNodeAdmin } = useGlobalStore();
const route = useRoute();
const onTerminalPage = computed(() => route.path.startsWith('/terminal'));
@@ -171,6 +110,10 @@ const active = ref('');
const showConnections = ref(false);
let timer: ReturnType<typeof setInterval> | null = null;
const openConnection = async (options: TerminalConnectionOptions) => {
active.value = await store.open(options);
};
const show = async () => {
if (!store.find(active.value)) active.value = store.entries[0]?.key || '';
open.value = true;
@@ -180,7 +123,6 @@ const show = async () => {
timer = setInterval(store.sync, 5000);
};
// park releases every slot so the Terminals go back to the off-screen host.
const park = () => {
showConnections.value = false;
if (timer) clearInterval(timer);
@@ -196,7 +138,6 @@ const onSlot = (key: string, el: HTMLElement | null) => {
if (el) slotEls[key] = el;
else delete slotEls[key];
};
// Slots not ours (the terminal page's) are left alone.
const claim = () => {
for (const item of store.entries) {
if (open.value && item.key === active.value) {
@@ -214,36 +155,6 @@ watch(
},
);
const hostTree = ref<Array<Host.HostTree>>([]);
const treeRef = ref<InstanceType<typeof ElTree>>();
const hostFilter = ref('');
const loadHosts = async () => {
if (isNodeAdmin.value) return;
const res = await getHostTree({});
hostTree.value = res.data;
};
watch(hostFilter, (v) => treeRef.value?.filter(v));
const filterHost = (value: string, data: any) => !value || data.label.toLowerCase().includes(value.toLowerCase());
const connect = async (wsID: number, title: string) => {
showConnections.value = false;
if (wsID === 0) {
const res = await testLocalConn();
if (!res.data) {
MsgError(i18n.global.t('terminal.connLocalErr'));
return;
}
active.value = await store.open({ title, wsID });
return;
}
const res = await testByID(wsID);
active.value = await store.open({
title,
wsID,
error: res.data ? '' : 'Authentication failed. Please check the host information!',
});
};
const closeAll = async () => {
if (store.entries.length > 0) {
await ElMessageBox.confirm(
@@ -260,7 +171,6 @@ const closeAll = async () => {
open.value = false;
};
// the terminal page claims the slots itself; give ours up when navigating there
watch(onTerminalPage, (v) => {
if (v) open.value = false;
});
@@ -379,19 +289,6 @@ watch(onTerminalPage, (v) => {
}
}
.terminal-dock-add {
width: 32px;
height: 32px;
margin: 0 4px;
padding: 0;
border-radius: 6px;
color: var(--el-text-color-regular);
&:hover {
color: var(--el-color-primary);
}
}
.terminal-status-dot {
width: 7px;
height: 7px;
@@ -414,11 +311,6 @@ watch(onTerminalPage, (v) => {
white-space: nowrap;
}
.terminal-dock-tree {
max-height: 40vh;
overflow: auto;
}
.terminal-dock-slot,
.terminal-dock-empty {
height: 60vh;

View File

@@ -78,6 +78,10 @@ import i18n from '@/lang';
import { reactive, ref } from 'vue';
import { MsgError, MsgSuccess } from '@/utils/message';
import { getAgentGroupList } from '@/api/modules/group';
import { useGlobalStore } from '@/composables/useGlobalStore';
const { currentNode } = useGlobalStore();
const targetNode = ref('local');
const dialogVisible = ref();
const isOK = ref(false);
@@ -114,9 +118,12 @@ const rules = reactive({
interface DialogProps {
isLocal: boolean;
nodeName?: string;
}
const acceptParams = (props: DialogProps) => {
isOK.value = false;
form.isLocal = props.isLocal;
targetNode.value = props.isLocal ? props.nodeName || currentNode.value || 'local' : 'local';
loadGroups();
dialogVisible.value = true;
};
@@ -128,7 +135,7 @@ const handleClose = () => {
const emit = defineEmits(['on-conn-terminal', 'on-new-local', 'load-host-tree']);
const loadGroups = async () => {
const res = await getAgentGroupList('host');
const res = await getAgentGroupList('host', targetNode.value);
groupList.value = res.data;
for (const item of groupList.value) {
if (item.isDefault) {
@@ -150,9 +157,12 @@ const loadLocal = async () => {
form.authMode = 'password';
form.password = '';
form.privateKey = '';
form.passPhrase = '';
form.rememberPassword = false;
};
const setDefault = () => {
form.id = 0;
form.addr = '';
form.name = '';
form.groupID = defaultGroup.value;
@@ -161,6 +171,8 @@ const setDefault = () => {
form.authMode = 'password';
form.password = '';
form.privateKey = '';
form.passPhrase = '';
form.rememberPassword = false;
form.description = '';
};
@@ -170,7 +182,7 @@ const submitAddHost = (formEl: FormInstance | undefined, ops: string) => {
if (!valid) return;
switch (ops) {
case 'testConn':
await testByInfo(form).then((res) => {
await testByInfo(form, targetNode.value).then((res) => {
if (res.data) {
isOK.value = true;
MsgSuccess(i18n.global.t('terminal.connTestOk'));
@@ -183,13 +195,13 @@ const submitAddHost = (formEl: FormInstance | undefined, ops: string) => {
case 'saveAndConn':
let res;
if (form.id == 0) {
res = await addHost(form);
res = await addHost(form, targetNode.value);
} else {
res = await editHost(form);
}
dialogVisible.value = false;
if (form.isLocal) {
emit('on-new-local');
emit('on-new-local', targetNode.value);
emit('load-host-tree');
return;
}

View File

@@ -5,22 +5,16 @@ import { searchTerminalSessions } from '@/api/modules/terminal';
import { useGlobalStore } from '@/composables/useGlobalStore';
import i18n from '@/lang';
// A web terminal session is bound to the browser tab, not to the route.
// Entries live here for as long as the SPA does; the Terminal components that
// own the websocket and xterm are rendered by components/terminal/host.vue and
// teleported into whichever slot (terminal page, floating dock) claims them.
// The agent keeps a session alive for a while after the websocket drops, so
// restore() can rebuild the entries after a page refresh or a closed browser tab.
export interface TerminalSessionEntry {
key: string;
title: string;
wsID: number; // 0 = local shell
wsID: number;
endpoint: string;
args: string;
sessionId: string; // agent side id, known once the hello arrived
sessionId: string;
status: 'online' | 'closed';
latency: number;
refresh: number; // bump to remount the Terminal component
refresh: number;
}
const localEndpoint = '/api/v2/hosts/terminal/local';
@@ -28,13 +22,11 @@ const sshEndpoint = '/api/v2/hosts/terminal/ssh';
const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
const entries = ref<TerminalSessionEntry[]>([]);
// Terminal component instances and slot elements, keyed by entry key.
const instances = reactive<Record<string, any>>({});
const slots = shallowReactive<Record<string, HTMLElement | undefined>>({});
const find = (key: string) => entries.value.find((e) => e.key === key);
// The Terminal components render in the layout level host; wait for it to render ours.
const instanceOf = async (key: string) => {
for (let i = 0; i < 5 && !instances[key]; i++) {
await nextTick();
@@ -42,20 +34,24 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
return instances[key];
};
const add = (init: { title: string; wsID: number; args?: string; status?: 'online' | 'closed' }) => {
const add = (init: {
title: string;
wsID: number;
nodeName?: string;
args?: string;
status?: 'online' | 'closed';
}) => {
const key = newUUID();
const q = `title=${encodeURIComponent(init.title)}`;
let title = init.title;
let args = init.args || '';
if (init.wsID === 0) {
// A local shell is pinned to the node it was opened on. Without this the
// websocket url would follow later node switches and a reconnect after a
// blip would silently open a shell on a different node. ssh shells (wsID > 0)
// always run on the master, see components/terminal/index.vue.
const { currentNode } = useGlobalStore();
const node = /operateNode=([^&]+)/.exec(args)?.[1] || encodeURIComponent(currentNode.value || 'local');
const node =
/operateNode=([^&]+)/.exec(args)?.[1] ||
encodeURIComponent(init.nodeName || currentNode.value || 'local');
if (!args.includes('operateNode=')) args = [args, `operateNode=${node}`].filter(Boolean).join('&');
if (node !== 'local') title = `${title} (${decodeURIComponent(node)})`;
if (node !== 'local' && !init.nodeName) title = `${title} (${decodeURIComponent(node)})`;
}
entries.value.push({
key,
@@ -71,8 +67,7 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
return key;
};
// open adds an entry and connects it. error is shown instead of connecting when set.
const open = async (init: { title: string; wsID: number; initCmd?: string; error?: string }) => {
const open = async (init: { title: string; wsID: number; nodeName?: string; initCmd?: string; error?: string }) => {
const key = add({ ...init, status: init.error ? 'closed' : 'online' });
const e = find(key)!;
const inst = await instanceOf(key);
@@ -85,7 +80,6 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
return key;
};
// reconnect remounts the Terminal; an entry that still has an agent session reattaches to it.
const reconnect = async (key: string, error = '', initCmd = '') => {
const e = find(key);
if (!e) return;
@@ -95,7 +89,6 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
inst?.acceptParams({ endpoint: e.endpoint, args: e.args, initCmd, sessionId: e.sessionId, error });
};
// restore rebuilds entries from the agent's session list and reattaches them.
const restore = async () => {
const { currentNode } = useGlobalStore();
const node = currentNode.value || 'local';
@@ -108,10 +101,8 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
const fromLocalNode = i === 1 || node === 'local';
for (const s of r.value.data || []) {
if (s.kind !== 'local' && s.kind !== 'ssh') continue;
// attached elsewhere = another browser tab is using it; do not steal it
if (s.attached || entries.value.some((e) => e.sessionId === s.id)) continue;
if (s.hostId > 0 && !fromLocalNode) continue; // ssh sessions are served by the local node
// a local shell found on the master while another node is selected must stay pinned to the master
if (s.hostId > 0 && !fromLocalNode) continue;
const key = add({
title: s.title || i18n.global.t('terminal.localhost'),
wsID: s.hostId,
@@ -125,7 +116,6 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
}
};
// remove drops entries; the host unmounts their Terminals, which closes the websockets with 1000.
const removeWhere = (match: (e: TerminalSessionEntry) => boolean) => {
for (const e of entries.value.filter(match)) {
delete instances[e.key];
@@ -134,7 +124,6 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
entries.value = entries.value.filter((e) => !match(e));
};
const remove = (key: string) => removeWhere((e) => e.key === key);
// closeAll runs on logout (or an expired login).
const closeAll = () => removeWhere(() => true);
const setSessionId = (key: string, id: string) => {
@@ -144,7 +133,6 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
e.status = 'online';
};
// onExpired: the agent no longer has the session; the next reconnect opens a fresh one.
const onExpired = (key: string) => {
const e = find(key);
if (!e) return;
@@ -164,7 +152,6 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
slots[key] = el || undefined;
};
// sync pulls status/latency from the live components.
const sync = () => {
for (const e of entries.value) {
const inst = instances[e.key];

View File

@@ -47,7 +47,6 @@
</span>
</el-tooltip>
</template>
<!-- The Terminal itself is rendered by components/terminal/host.vue and teleported here. -->
<div
class="terminal-slot"
:ref="(el: any) => onSlot(item.key, el)"
@@ -104,119 +103,7 @@
</div>
</el-tab-pane>
<template #add-icon>
<el-popover
v-model:visible="showConnections"
width="320px"
trigger="click"
placement="bottom-start"
persistent
>
<template #reference>
<el-button
class="terminal-action terminal-add"
@click.stop
@keydown.stop
icon="Plus"
text
:aria-label="$t('terminal.createConn')"
/>
</template>
<div class="p-2 space-y-2">
<div class="flex gap-2">
<button
v-if="!isNodeAdmin"
@click="onNewSsh"
class="flex-1 flex flex-col items-center justify-center px-3 py-2.5 bg-[var(--el-fill-color-light)] hover:bg-[var(--panel-main-bg-color-9)] rounded transition-colors duration-200 cursor-pointer group border-0 outline-none"
>
<el-icon
class="text-xl mb-1 text-[var(--el-text-color-primary)] group-hover:text-[var(--el-color-primary)] transition-colors"
>
<Plus />
</el-icon>
<span
class="text-xs text-[var(--el-text-color-primary)] group-hover:text-[var(--el-color-primary)] font-medium truncate w-full text-center transition-colors"
>
{{ $t('terminal.createConn') }}
</span>
</button>
<button
@click="onNewLocal"
class="flex-1 flex flex-col items-center justify-center px-3 py-2.5 bg-[var(--el-fill-color-light)] hover:bg-[var(--panel-main-bg-color-9)] rounded transition-colors duration-200 cursor-pointer group border-0 outline-none"
>
<el-icon
class="text-xl mb-1 text-[var(--el-text-color-primary)] group-hover:text-[var(--el-color-primary)] transition-colors"
>
<House />
</el-icon>
<span
class="text-xs text-[var(--el-text-color-primary)] group-hover:text-[var(--el-color-primary)] font-medium truncate w-full text-center transition-colors"
>
{{ $t('terminal.localhost') }}
</span>
</button>
</div>
<template v-if="!isNodeAdmin">
<el-divider class="my-0" />
<div class="search-container px-1 py-1 bg-[var(--el-fill-color-light)] rounded">
<el-input
v-model="hostFilterInfo"
class="w-full"
clearable
suffix-icon="Search"
:placeholder="$t('commons.button.search')"
size="small"
>
<template #prefix>
<el-icon class="el-input__icon"><Search /></el-icon>
</template>
</el-input>
</div>
<el-tree
ref="treeRef"
:expand-on-click-node="false"
node-key="id"
:default-expand-all="true"
:data="hostTree"
:props="defaultProps"
:filter-node-method="filterHost"
:empty-text="$t('terminal.noHost')"
class="host-tree"
>
<template #default="{ node, data }">
<span class="custom-tree-node w-full">
<span
v-if="node.label === 'Default'"
class="text-xs font-medium text-[var(--el-text-color-primary)]"
>
{{ $t('commons.table.default') }}
</span>
<div v-else class="w-full min-w-0">
<span v-if="node.label.length <= 22">
<a
@click="onClickConn(node, data)"
class="text-xs text-[var(--el-text-color-primary)] hover:text-[var(--el-color-primary)] transition-colors cursor-pointer block truncate"
>
{{ node.label }}
</a>
</span>
<el-tooltip v-else :content="node.label" placement="right">
<span>
<a
@click="onClickConn(node, data)"
class="text-xs text-[var(--el-text-color-primary)] hover:text-[var(--el-color-primary)] transition-colors cursor-pointer block truncate"
>
{{ node.label.substring(0, 30) }}...
</a>
</span>
</el-tooltip>
</div>
</span>
</template>
</el-tree>
</template>
</div>
</el-popover>
<ConnectionMenu ref="connectionMenuRef" v-model="showConnections" :open-session="openConnection" />
</template>
<div v-if="store.entries.length === 0">
<el-empty
@@ -236,37 +123,27 @@
/>
</el-tooltip>
</div>
<HostDialog
ref="dialogRef"
@on-conn-terminal="onConnTerminal"
@on-new-local="onNewLocal"
@load-host-tree="loadHostTree"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue';
import HostDialog from '@/views/terminal/terminal/host-create.vue';
import type Node from 'element-plus/es/components/tree/src/model/node';
import { ElTree } from 'element-plus';
import screenfull from 'screenfull';
import i18n from '@/lang';
import { Host } from '@/api/interface/host';
import { getHostTree, testByID, testLocalConn } from '@/api/modules/terminal';
import { testByID, testLocalConn } from '@/api/modules/terminal';
import { useGlobalStore } from '@/composables/useGlobalStore';
import router from '@/routers';
import { getCommandTree } from '@/api/modules/command';
import { getAgentSettingInfo } from '@/api/modules/setting';
import AiSetting from '@/views/terminal/setting/ai/index.vue';
import { MsgWarning } from '@/utils/message';
import { TerminalSessionStore } from '@/store';
import ConnectionMenu from '@/components/terminal/connection-menu/index.vue';
import type { TerminalConnectionOptions } from '@/components/terminal/connection-menu/types';
const { isFullScreen, isMobile, isNodeAdmin, openMenuTabs } = useGlobalStore();
const store = TerminalSessionStore();
const dialogRef = ref();
const connectionMenuRef = ref<InstanceType<typeof ConnectionMenu>>();
const toggleFullscreen = () => {
if (screenfull.isEnabled) {
@@ -288,33 +165,15 @@ let quickCmd = ref();
let batchVal = ref();
let isBatch = ref<boolean>(false);
const hostFilterInfo = ref('');
const showConnections = ref(false);
const hostTree = ref<Array<Host.HostTree>>();
const treeRef = ref<InstanceType<typeof ElTree>>();
const defaultProps = {
label: 'label',
children: 'children',
};
interface Tree {
id: number;
label: string;
children?: Tree[];
}
const initCmd = ref('');
const acceptParams = async () => {
isFullScreen.value = false;
loadCommandTree();
if (!isNodeAdmin.value) {
loadHostTree();
} else {
hostTree.value = [];
}
if (store.entries.length === 0) {
await openDefaultLocalConn();
} else {
// sessions kept alive while we were away: show them and re-fit to this container
if (!store.find(terminalValue.value)) {
terminalValue.value = store.entries[0].key;
}
@@ -330,27 +189,23 @@ const acceptParams = async () => {
};
const openDefaultLocalConn = async () => {
await nextTick();
if (isNodeAdmin.value) {
onNewLocal();
await connectionMenuRef.value?.connectLocal();
return;
}
await getAgentSettingInfo().then((res) => {
await getAgentSettingInfo().then(async (res) => {
if (res.data?.localSSHConnShow === 'Enable') {
onNewLocal();
await connectionMenuRef.value?.connectLocal();
}
});
};
// Leaving the page keeps every session connected in the host; only the poll stops.
const cleanTimer = () => {
clearInterval(Number(timer));
timer = null;
};
// Slots are claimed explicitly, not from the ref callback: under a locked menu tab
// (keep-alive) the page keeps rendering while detached, and the dock takes the
// Terminals over meanwhile. Only a visible page owns its slots; release on leave
// and take them back on return, the same way the dock does.
const slotEls: Record<string, HTMLElement> = {};
const onSlot = (key: string, el: HTMLElement | null) => {
if (el) slotEls[key] = el;
@@ -414,21 +269,6 @@ const handleTabsRemove = async (targetName: string, action: 'remove' | 'add') =>
store.remove(targetName);
};
const loadHostTree = async () => {
if (isNodeAdmin.value) {
hostTree.value = [];
return;
}
const res = await getHostTree({});
hostTree.value = res.data;
};
watch(hostFilterInfo, (val: any) => {
treeRef.value!.filter(val);
});
const filterHost = (value: string, data: any) => {
if (!value) return true;
return data.label.includes(value);
};
const loadCommandTree = async () => {
const res = await getCommandTree('command');
commandTree.value = res.data || [];
@@ -462,58 +302,23 @@ function batchInput() {
batchVal.value = '';
}
const onNewSsh = () => {
showConnections.value = false;
if (isNodeAdmin.value) {
MsgWarning(i18n.global.t('terminal.nodeAdminLocalOnly'));
return;
}
dialogRef.value!.acceptParams({ isLocal: false });
};
const connectionError = 'Failed to set up the connection. Please check the host information';
const openTab = async (title: string, wsID: number, error: string) => {
const openConnection = async (options: TerminalConnectionOptions) => {
const cmd = initCmd.value;
initCmd.value = '';
terminalValue.value = await store.open({ title, wsID, initCmd: cmd, error });
};
const onNewLocal = async () => {
showConnections.value = false;
const res = await testLocalConn();
if (!res.data) {
dialogRef.value!.acceptParams({ isLocal: true });
return;
}
await openTab(i18n.global.t('terminal.localhost'), 0, '');
};
const onClickConn = (node: Node, data: Tree) => {
if (node.level === 1) {
return;
}
onConnTerminal(node.label, data.id);
terminalValue.value = await store.open({ ...options, initCmd: cmd });
};
const onReconnect = async (item: any) => {
const res = item.wsID === 0 ? await testLocalConn() : await testByID(item.wsID);
const nodeName = new URLSearchParams(item.args).get('operateNode') || undefined;
const res = item.wsID === 0 ? await testLocalConn(nodeName) : await testByID(item.wsID);
const cmd = initCmd.value;
initCmd.value = '';
await store.reconnect(item.key, res.data ? '' : connectionError, cmd);
store.sync();
};
const onConnTerminal = async (title: string, wsID: number) => {
showConnections.value = false;
if (isNodeAdmin.value) {
MsgWarning(i18n.global.t('terminal.nodeAdminLocalOnly'));
return;
}
const res = await testByID(wsID);
await openTab(title, wsID, res.data ? '' : 'Authentication failed. Please check the host information!');
};
const changeFullScreen = () => {
isFullScreen.value = screenfull.isFullscreen;
};
@@ -524,7 +329,6 @@ defineExpose({
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', changeFullScreen);
// parent refs are already null in the parent's onUnmounted, so leave-page cleanup lives here
cleanTimer();
pageVisible = false;
claim();
@@ -575,10 +379,6 @@ onMounted(() => {
}
}
.terminal-add {
margin: 0 4px;
}
.terminal-tabs {
:deep(.el-tabs__header) {
justify-content: flex-start;
@@ -699,26 +499,6 @@ onMounted(() => {
width: 100%;
}
.host-tree {
max-height: 300px;
overflow-y: auto;
}
.search-container {
:deep(.el-input__wrapper) {
border-radius: 6px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:hover {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
&.is-focus {
box-shadow: 0 0 0 2px var(--el-color-primary-light-3);
}
}
}
.vertical-tabs > .el-tabs__content {
padding: 32px;
color: #6b778c;