mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/1Panel-dev/1Panel.git
synced 2026-09-20 08:03:55 +08:00
feat(terminal): share connection menu and add node quick connect (#13787)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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`)
|
||||
|
||||
342
frontend/src/components/terminal/connection-menu/index.vue
Normal file
342
frontend/src/components/terminal/connection-menu/index.vue
Normal 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>
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface TerminalConnectionOptions {
|
||||
title: string;
|
||||
wsID: number;
|
||||
nodeName?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user