mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/1Panel-dev/1Panel.git
synced 2026-09-20 08:03:55 +08:00
feat: add terminal session rules hint (#13744)
This commit is contained in:
@@ -2,7 +2,6 @@ package terminal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
@@ -11,13 +10,6 @@ import (
|
||||
// Open stores, Close deletes.
|
||||
var sessions sync.Map
|
||||
|
||||
const maxSessionsPerIdentity = 10
|
||||
|
||||
var (
|
||||
sessionSlotsMu sync.Mutex
|
||||
sessionSlots = make(map[Identity]int)
|
||||
)
|
||||
|
||||
var errSessionNotFound = errors.New("terminal session not found")
|
||||
|
||||
const (
|
||||
@@ -34,47 +26,12 @@ func (i Identity) Valid() bool {
|
||||
return i.UserID != "" && i.AuthSessionID != ""
|
||||
}
|
||||
|
||||
func reserveSessionSlot(identity Identity) error {
|
||||
if !identity.Valid() {
|
||||
return errors.New("missing terminal identity")
|
||||
}
|
||||
sessionSlotsMu.Lock()
|
||||
defer sessionSlotsMu.Unlock()
|
||||
if sessionSlots[identity] >= maxSessionsPerIdentity {
|
||||
return fmt.Errorf("terminal session limit reached (maximum %d)", maxSessionsPerIdentity)
|
||||
}
|
||||
sessionSlots[identity]++
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseSessionSlot(identity Identity) {
|
||||
sessionSlotsMu.Lock()
|
||||
defer sessionSlotsMu.Unlock()
|
||||
releaseSessionSlotLocked(identity)
|
||||
}
|
||||
|
||||
func releaseSessionSlotLocked(identity Identity) {
|
||||
remaining := sessionSlots[identity] - 1
|
||||
if remaining <= 0 {
|
||||
delete(sessionSlots, identity)
|
||||
return
|
||||
}
|
||||
sessionSlots[identity] = remaining
|
||||
}
|
||||
|
||||
func registerReservedSession(s *Session) {
|
||||
func registerSession(s *Session) {
|
||||
sessions.Store(s.ID, s)
|
||||
}
|
||||
|
||||
func unregisterSession(s *Session) {
|
||||
sessionSlotsMu.Lock()
|
||||
defer sessionSlotsMu.Unlock()
|
||||
current, ok := sessions.Load(s.ID)
|
||||
if !ok || current != s {
|
||||
return
|
||||
}
|
||||
sessions.Delete(s.ID)
|
||||
releaseSessionSlotLocked(Identity{UserID: s.UserID, AuthSessionID: s.AuthSessionID})
|
||||
sessions.CompareAndDelete(s.ID, s)
|
||||
}
|
||||
|
||||
func Lookup(id string, identity Identity) (*Session, bool) {
|
||||
|
||||
@@ -37,7 +37,7 @@ const (
|
||||
CloseCodeSessionNotFound = 4404
|
||||
// CloseCodeAttachedElsewhere: a newer websocket took over the session.
|
||||
CloseCodeAttachedElsewhere = 4409
|
||||
CloseCodeRevalidate = 4410
|
||||
CloseCodeRevalidate = 4410
|
||||
)
|
||||
|
||||
var errSessionClosed = errors.New("terminal session is closed")
|
||||
@@ -169,13 +169,9 @@ func Open(client *gossh.Client, opts SessionOptions) (*Session, error) {
|
||||
if err := validateSessionOptions(opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := reserveSessionSlot(opts.Identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ring := newRingBuffer()
|
||||
backend, err := newSSHBackend(client, opts.Cols, opts.Rows, opts.InitCmd, ring)
|
||||
if err != nil {
|
||||
releaseSessionSlot(opts.Identity)
|
||||
return nil, err
|
||||
}
|
||||
return openBackend(backend, ring, opts), nil
|
||||
@@ -188,9 +184,6 @@ func OpenCommand(command *LocalCommand, opts SessionOptions) (*Session, error) {
|
||||
if command == nil {
|
||||
return nil, errors.New("nil terminal command")
|
||||
}
|
||||
if err := reserveSessionSlot(opts.Identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ring := newRingBuffer()
|
||||
return openBackend(newCommandBackend(command, ring), ring, opts), nil
|
||||
}
|
||||
@@ -216,7 +209,7 @@ func openBackend(backend sessionBackend, ring *ringBuffer, opts SessionOptions)
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
s.closeFn = sync.OnceFunc(s.doClose)
|
||||
registerReservedSession(s)
|
||||
registerSession(s)
|
||||
go s.pump()
|
||||
go s.keepaliveLoop()
|
||||
go s.waitBackend()
|
||||
|
||||
@@ -23,7 +23,21 @@
|
||||
<!-- minimize keeps sessions alive; X closes them all (with confirm) -->
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<span class="el-dialog__title flex-1">{{ $t('menu.terminal') }}</span>
|
||||
<div class="terminal-dock-title flex-1">
|
||||
<span class="el-dialog__title">{{ $t('menu.terminal') }}</span>
|
||||
<el-popover placement="bottom-start" :width="380" trigger="hover" :show-after="200">
|
||||
<template #reference>
|
||||
<el-icon class="terminal-rules-icon" tabindex="0"><InfoFilled /></el-icon>
|
||||
</template>
|
||||
<div class="terminal-rules-title">{{ $t('terminal.sessionRules') }}</div>
|
||||
<ul class="terminal-rules-list">
|
||||
<li>{{ $t('terminal.sessionRuleClose') }}</li>
|
||||
<li>{{ $t('terminal.sessionRuleDisconnect') }}</li>
|
||||
<li>{{ $t('terminal.sessionRuleRevalidate') }}</li>
|
||||
<li>{{ $t('terminal.sessionRuleResources') }}</li>
|
||||
</ul>
|
||||
</el-popover>
|
||||
</div>
|
||||
<el-tooltip :content="$t('terminal.minimize')" placement="top">
|
||||
<el-button link icon="Minus" @click="open = false" />
|
||||
</el-tooltip>
|
||||
@@ -35,8 +49,15 @@
|
||||
|
||||
<template #content>
|
||||
<div class="terminal-dock-toolbar">
|
||||
<el-tabs v-model="active" type="card" closable class="terminal-dock-tabs" @tab-remove="store.remove">
|
||||
<el-tab-pane v-for="item in store.entries" :key="item.key" :name="item.key">
|
||||
<el-tabs
|
||||
v-model="active"
|
||||
type="card"
|
||||
class="terminal-dock-tabs"
|
||||
addable
|
||||
@tab-add="showConnections = !showConnections"
|
||||
@tab-remove="store.remove"
|
||||
>
|
||||
<el-tab-pane v-for="item in store.entries" :key="item.key" :name="item.key" closable>
|
||||
<template #label>
|
||||
<span
|
||||
class="terminal-status-dot"
|
||||
@@ -45,53 +66,65 @@
|
||||
<span class="terminal-tab-title" :title="item.title">{{ item.title }}</span>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-popover trigger="click" width="280px" @before-enter="loadHosts">
|
||||
<template #reference>
|
||||
<el-button type="primary" plain icon="Plus" size="small">
|
||||
{{ $t('terminal.createConn') }}
|
||||
</el-button>
|
||||
</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 #add-icon>
|
||||
<el-popover
|
||||
v-model:visible="showConnections"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
width="280px"
|
||||
@before-enter="loadHosts"
|
||||
>
|
||||
<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 #reference>
|
||||
<el-button
|
||||
class="terminal-dock-add"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
icon="Plus"
|
||||
text
|
||||
:aria-label="$t('terminal.createConn')"
|
||||
/>
|
||||
</template>
|
||||
</el-tree>
|
||||
<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>
|
||||
</template>
|
||||
</el-popover>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<div v-if="store.entries.length === 0" class="terminal-dock-empty">
|
||||
@@ -128,6 +161,7 @@ const onTerminalPage = computed(() => route.path.startsWith('/terminal'));
|
||||
|
||||
const open = ref(false);
|
||||
const active = ref('');
|
||||
const showConnections = ref(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const show = async () => {
|
||||
@@ -141,6 +175,7 @@ const show = async () => {
|
||||
|
||||
// park releases every slot so the Terminals go back to the off-screen host.
|
||||
const park = () => {
|
||||
showConnections.value = false;
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
claim();
|
||||
@@ -184,6 +219,7 @@ 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) {
|
||||
@@ -265,6 +301,42 @@ watch(onTerminalPage, (v) => {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.terminal-dock-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-rules-icon {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 15px;
|
||||
cursor: help;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--el-color-primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-rules-title {
|
||||
margin-bottom: 6px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.terminal-rules-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.7;
|
||||
|
||||
li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-dock-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -277,6 +349,7 @@ watch(onTerminalPage, (v) => {
|
||||
flex: 1;
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -285,6 +358,31 @@ watch(onTerminalPage, (v) => {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap) {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__new-tab) {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -54,6 +54,7 @@ let wsEndpoint = '';
|
||||
let wsArgs = '';
|
||||
let closing = false;
|
||||
let reconnecting = false;
|
||||
let reconnectNoticeShown = false;
|
||||
let revalidating = false;
|
||||
let reconnectStartedAt = 0;
|
||||
let reconnectDelay = 1000;
|
||||
@@ -491,7 +492,7 @@ const closeRealTerminal = (ev: CloseEvent) => {
|
||||
return;
|
||||
case CLOSE_REVALIDATE:
|
||||
revalidating = true;
|
||||
scheduleReconnect();
|
||||
scheduleReconnect(true);
|
||||
return;
|
||||
default:
|
||||
scheduleReconnect();
|
||||
@@ -503,13 +504,13 @@ const writeNotice = (color: string, message: string) => {
|
||||
};
|
||||
|
||||
// scheduleReconnect retries with backoff for as long as the agent keeps a detached session.
|
||||
const scheduleReconnect = () => {
|
||||
const scheduleReconnect = (forRevalidation = false) => {
|
||||
const now = Date.now();
|
||||
if (!reconnecting) {
|
||||
reconnecting = true;
|
||||
reconnectStartedAt = now;
|
||||
reconnectDelay = 1000;
|
||||
writeNotice('33', i18n.global.t('terminal.sessionReconnecting'));
|
||||
reconnectNoticeShown = false;
|
||||
} else if (now - reconnectStartedAt > reconnectWindow) {
|
||||
reconnecting = false;
|
||||
sessionId.value = '';
|
||||
@@ -517,12 +518,21 @@ const scheduleReconnect = () => {
|
||||
emit('expired');
|
||||
return;
|
||||
}
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (closing || !sessionId.value) return;
|
||||
initWebSocket(wsEndpoint, wsArgs);
|
||||
}, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 8000);
|
||||
if (!forRevalidation && !reconnectNoticeShown) {
|
||||
writeNotice('33', i18n.global.t('terminal.sessionReconnecting'));
|
||||
reconnectNoticeShown = true;
|
||||
}
|
||||
reconnectTimer = setTimeout(
|
||||
() => {
|
||||
reconnectTimer = null;
|
||||
if (closing || !sessionId.value) return;
|
||||
initWebSocket(wsEndpoint, wsArgs);
|
||||
},
|
||||
forRevalidation ? 0 : reconnectDelay,
|
||||
);
|
||||
if (!forRevalidation) {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 8000);
|
||||
}
|
||||
};
|
||||
|
||||
const stopReconnect = () => {
|
||||
|
||||
@@ -2112,6 +2112,11 @@ const message = {
|
||||
sessionExpired: 'Session is no longer available, press Enter or click reconnect to open a new one',
|
||||
sessionKicked: 'This session was opened in another window',
|
||||
sessionCount: '{0} sessions',
|
||||
sessionRules: 'Terminal session rules',
|
||||
sessionRuleClose: 'Closing a terminal ends its session immediately.',
|
||||
sessionRuleDisconnect: 'Recover within 30 minutes after a refresh, browser closure, or network loss.',
|
||||
sessionRuleRevalidate: 'Sessions end if login is invalid or verification times out.',
|
||||
sessionRuleResources: 'More terminals use more resources. Close terminals you no longer need.',
|
||||
minimize: 'Minimize',
|
||||
closeAllSessions: 'Close all sessions',
|
||||
closeAllConfirm: 'All terminal sessions will be disconnected and cannot be recovered. Continue?',
|
||||
@@ -6846,6 +6851,8 @@ const message = {
|
||||
submitSuccess: 'Sync task submitted.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'The Professional edition supports up to {0} virtual machines. Enterprise has no VM count limit.',
|
||||
vm: 'VM',
|
||||
title: 'Virtual Machines',
|
||||
healthCheck: 'Health Check',
|
||||
|
||||
@@ -2153,6 +2153,13 @@ const message = {
|
||||
sessionExpired: 'La sesión ya no está disponible, pulse Enter o haga clic en reconectar para abrir una nueva',
|
||||
sessionKicked: 'Esta sesión se abrió en otra ventana',
|
||||
sessionCount: '{0} sesiones',
|
||||
sessionRules: 'Reglas de la sesión de terminal',
|
||||
sessionRuleClose: 'Cerrar el terminal finaliza la sesión de inmediato.',
|
||||
sessionRuleDisconnect:
|
||||
'Recuperable durante 30 minutos tras recargar, cerrar el navegador o perder la conexión.',
|
||||
sessionRuleRevalidate:
|
||||
'La sesión termina si el inicio de sesión no es válido o la verificación tarda demasiado.',
|
||||
sessionRuleResources: 'Más terminales consumen más recursos. Cierra los que ya no necesites.',
|
||||
minimize: 'Minimizar',
|
||||
closeAllSessions: 'Cerrar todas las sesiones',
|
||||
closeAllConfirm: 'Se desconectarán todas las sesiones de terminal y no se podrán recuperar. ¿Continuar?',
|
||||
@@ -6951,6 +6958,8 @@ const message = {
|
||||
submitSuccess: 'La tarea de sincronización se ha enviado correctamente.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'La edición Professional permite crear hasta {0} máquinas virtuales. Enterprise no limita la cantidad.',
|
||||
vm: 'Máquina virtual',
|
||||
title: 'Gestión de máquinas virtuales',
|
||||
healthCheck: 'Comprobación de estado',
|
||||
|
||||
@@ -2091,6 +2091,11 @@ const message = {
|
||||
sessionExpired: 'نشست دیگر در دسترس نیست، برای باز کردن نشست جدید Enter را بزنید یا روی اتصال مجدد کلیک کنید',
|
||||
sessionKicked: 'این نشست در پنجره دیگری باز شده است',
|
||||
sessionCount: '{0} نشست',
|
||||
sessionRules: 'قوانین نشست ترمینال',
|
||||
sessionRuleClose: 'بستن ترمینال، نشست را فوراً پایان میدهد.',
|
||||
sessionRuleDisconnect: 'تا ۳۰ دقیقه پس از بازخوانی صفحه، بستن مرورگر یا قطع شبکه قابل بازیابی است.',
|
||||
sessionRuleRevalidate: 'نشست با نامعتبر شدن ورود یا پایان مهلت تأیید خاتمه مییابد.',
|
||||
sessionRuleResources: 'ترمینالهای بیشتر منابع بیشتری مصرف میکنند. ترمینالهای غیرضروری را ببندید.',
|
||||
minimize: 'کوچکسازی',
|
||||
closeAllSessions: 'بستن همه نشستها',
|
||||
closeAllConfirm: 'همه نشستهای ترمینال قطع میشوند و قابل بازیابی نیستند. ادامه میدهید؟',
|
||||
@@ -6800,6 +6805,8 @@ const message = {
|
||||
submitSuccess: 'وظیفه همگامسازی ارسال شد.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'در نسخه Professional میتوانید حداکثر {0} ماشین مجازی ایجاد کنید. نسخه Enterprise محدودیتی در تعداد ندارد.',
|
||||
vm: 'ماشین مجازی',
|
||||
title: 'مدیریت ماشین مجازی',
|
||||
healthCheck: 'بررسی سلامت',
|
||||
|
||||
@@ -2103,6 +2103,11 @@ const message = {
|
||||
'セッションは無効になりました。Enter キーまたは再接続をクリックして新しいセッションを開いてください',
|
||||
sessionKicked: 'このセッションは別のウィンドウで開かれました',
|
||||
sessionCount: '{0} セッション',
|
||||
sessionRules: 'ターミナルセッションのルール',
|
||||
sessionRuleClose: 'ターミナルを閉じると、セッションは即座に終了します。',
|
||||
sessionRuleDisconnect: '再読み込み、ブラウザー終了、通信切断後は30分以内に復元できます。',
|
||||
sessionRuleRevalidate: 'ログインが無効、または認証がタイムアウトするとセッションは終了します。',
|
||||
sessionRuleResources: '端末が増えるほどリソース消費も増えます。不要な端末は閉じてください。',
|
||||
minimize: '最小化',
|
||||
closeAllSessions: 'すべてのセッションを閉じる',
|
||||
closeAllConfirm: 'すべてのターミナルセッションが切断され、復元できません。続行しますか?',
|
||||
@@ -6837,6 +6842,8 @@ const message = {
|
||||
submitSuccess: '同期タスクが送信されました。',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'Professional 版では最大 {0} 台の仮想マシンを作成できます。Enterprise 版には台数制限がありません。',
|
||||
vm: '仮想マシン',
|
||||
title: '仮想マシン管理',
|
||||
healthCheck: 'ヘルスチェック',
|
||||
|
||||
@@ -2071,6 +2071,11 @@ const message = {
|
||||
sessionExpired: '세션을 더 이상 사용할 수 없습니다. Enter 키를 누르거나 다시 연결을 클릭하여 새 세션을 여세요',
|
||||
sessionKicked: '이 세션은 다른 창에서 열렸습니다',
|
||||
sessionCount: '세션 {0}개',
|
||||
sessionRules: '터미널 세션 규칙',
|
||||
sessionRuleClose: '터미널을 닫으면 세션이 즉시 종료됩니다.',
|
||||
sessionRuleDisconnect: '새로고침, 브라우저 종료, 네트워크 끊김 후 30분 이내에 복구할 수 있습니다.',
|
||||
sessionRuleRevalidate: '로그인이 무효이거나 인증 시간이 초과되면 세션이 종료됩니다.',
|
||||
sessionRuleResources: '터미널이 많을수록 리소스 사용량이 늘어납니다. 사용하지 않는 터미널은 닫아 주세요.',
|
||||
minimize: '최소화',
|
||||
closeAllSessions: '모든 세션 닫기',
|
||||
closeAllConfirm: '모든 터미널 세션이 끊기며 복구할 수 없습니다. 계속하시겠습니까?',
|
||||
@@ -6700,6 +6705,8 @@ const message = {
|
||||
submitSuccess: '동기화 작업이 제출되었습니다.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'Professional 버전에서는 가상 머신을 최대 {0}대까지 생성할 수 있습니다. Enterprise 버전은 대수 제한이 없습니다.',
|
||||
vm: '가상 머신',
|
||||
title: '가상 머신 관리',
|
||||
healthCheck: '상태 점검',
|
||||
|
||||
@@ -2057,6 +2057,11 @@ const message = {
|
||||
sessionExpired: 'ເຊສຊັນບໍ່ສາມາດໃຊ້ໄດ້ອີກ, ກົດ Enter ຫຼືຄລິກເຊື່ອມຕໍ່ຄືນເພື່ອເປີດເຊສຊັນໃໝ່',
|
||||
sessionKicked: 'ເຊສຊັນນີ້ຖືກເປີດຢູ່ໃນໜ້າຕ່າງອື່ນ',
|
||||
sessionCount: '{0} ເຊສຊັນ',
|
||||
sessionRules: 'ກົດລະບຽບເຊສຊັນເທີມິນອລ',
|
||||
sessionRuleClose: 'ປິດເທີມິນອລແລ້ວ ເຊສຊັນຈະສິ້ນສຸດທັນທີ.',
|
||||
sessionRuleDisconnect: 'ກູ້ຄືນໄດ້ພາຍໃນ 30 ນາທີ ຫຼັງໂຫຼດໜ້າໃໝ່, ປິດບຣາວເຊີ ຫຼື ເຄືອຂ່າຍຂາດ.',
|
||||
sessionRuleRevalidate: 'ເຊສຊັນສິ້ນສຸດເມື່ອການເຂົ້າລະບົບບໍ່ຖືກຕ້ອງ ຫຼື ການຢືນຢັນໝົດເວລາ.',
|
||||
sessionRuleResources: 'ເປີດເທີມິນອລຫຼາຍຍິ່ງໃຊ້ຊັບພະຍາກອນຫຼາຍ. ກະລຸນາປິດເທີມິນອລທີ່ບໍ່ໄດ້ໃຊ້.',
|
||||
minimize: 'ຫຍໍ້ລົງ',
|
||||
closeAllSessions: 'ປິດທຸກເຊສຊັນ',
|
||||
closeAllConfirm: 'ເຊສຊັນເທີມິນອລທັງໝົດຈະຖືກຕັດການເຊື່ອມຕໍ່ ແລະ ບໍ່ສາມາດກູ້ຄືນໄດ້. ສືບຕໍ່ບໍ?',
|
||||
@@ -6616,6 +6621,8 @@ const message = {
|
||||
submitSuccess: 'ສົ່ງງານຊິງໂຄຣໄນສຳເລັດ.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'ລຸ້ນ Professional ສາມາດສ້າງເຄື່ອງສະເໝືອນໄດ້ສູງສຸດ {0} ເຄື່ອງ. ລຸ້ນ Enterprise ບໍ່ຈຳກັດຈຳນວນ.',
|
||||
vm: 'VM',
|
||||
title: 'ເຄື່ອງສະເໝືອນ',
|
||||
healthCheck: 'ກວດສອບສຸຂະພາບ',
|
||||
|
||||
@@ -2139,6 +2139,13 @@ const message = {
|
||||
sessionExpired: 'Sesi tidak lagi tersedia, tekan Enter atau klik sambung semula untuk membuka sesi baharu',
|
||||
sessionKicked: 'Sesi ini telah dibuka di tetingkap lain',
|
||||
sessionCount: '{0} sesi',
|
||||
sessionRules: 'Peraturan sesi terminal',
|
||||
sessionRuleClose: 'Menutup terminal menamatkan sesi serta-merta.',
|
||||
sessionRuleDisconnect:
|
||||
'Boleh dipulihkan dalam 30 minit selepas muat semula, pelayar ditutup atau rangkaian terputus.',
|
||||
sessionRuleRevalidate: 'Sesi tamat jika log masuk tidak sah atau pengesahan melebihi had masa.',
|
||||
sessionRuleResources:
|
||||
'Lebih banyak terminal menggunakan lebih banyak sumber. Tutup terminal yang tidak lagi diperlukan.',
|
||||
minimize: 'Minimumkan',
|
||||
closeAllSessions: 'Tutup semua sesi',
|
||||
closeAllConfirm: 'Semua sesi terminal akan diputuskan dan tidak boleh dipulihkan. Teruskan?',
|
||||
@@ -6949,6 +6956,8 @@ const message = {
|
||||
submitSuccess: 'Tugas penyegerakan berjaya dihantar.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'Edisi Professional membenarkan penciptaan sehingga {0} mesin maya. Edisi Enterprise tidak mengehadkan bilangannya.',
|
||||
vm: 'Mesin Maya',
|
||||
title: 'Pengurusan Mesin Maya',
|
||||
healthCheck: 'Pemeriksaan kesihatan',
|
||||
|
||||
@@ -2147,6 +2147,11 @@ const message = {
|
||||
'A sessão não está mais disponível, pressione Enter ou clique em reconectar para abrir uma nova',
|
||||
sessionKicked: 'Esta sessão foi aberta em outra janela',
|
||||
sessionCount: '{0} sessões',
|
||||
sessionRules: 'Regras da sessão de terminal',
|
||||
sessionRuleClose: 'Fechar o terminal encerra a sessão imediatamente.',
|
||||
sessionRuleDisconnect: 'Recupere em até 30 minutos após atualizar, fechar o navegador ou perder a conexão.',
|
||||
sessionRuleRevalidate: 'A sessão termina se o login for inválido ou a verificação exceder o tempo limite.',
|
||||
sessionRuleResources: 'Mais terminais consomem mais recursos. Feche os que não estiver usando.',
|
||||
minimize: 'Minimizar',
|
||||
closeAllSessions: 'Fechar todas as sessões',
|
||||
closeAllConfirm: 'Todas as sessões de terminal serão desconectadas e não poderão ser recuperadas. Continuar?',
|
||||
@@ -6989,6 +6994,8 @@ const message = {
|
||||
submitSuccess: 'Tarefa de sincronização enviada com sucesso.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'A edição Professional permite criar até {0} máquinas virtuais. A edição Enterprise não limita a quantidade.',
|
||||
vm: 'Máquina virtual',
|
||||
title: 'Gerenciamento de Máquinas Virtuais',
|
||||
healthCheck: 'Verificação de integridade',
|
||||
|
||||
@@ -2126,6 +2126,12 @@ const message = {
|
||||
sessionExpired: 'Сессия больше недоступна, нажмите Enter или «Переподключить», чтобы открыть новую',
|
||||
sessionKicked: 'Эта сессия была открыта в другом окне',
|
||||
sessionCount: 'сессий: {0}',
|
||||
sessionRules: 'Правила сессии терминала',
|
||||
sessionRuleClose: 'Закрытие терминала сразу завершает сессию.',
|
||||
sessionRuleDisconnect:
|
||||
'После обновления страницы, закрытия браузера или потери сети восстановление доступно 30 минут.',
|
||||
sessionRuleRevalidate: 'Сессия завершается при недействительном входе или тайм-ауте проверки.',
|
||||
sessionRuleResources: 'Чем больше терминалов, тем выше расход ресурсов. Закрывайте ненужные терминалы.',
|
||||
minimize: 'Свернуть',
|
||||
closeAllSessions: 'Закрыть все сессии',
|
||||
closeAllConfirm: 'Все сессии терминала будут отключены без возможности восстановления. Продолжить?',
|
||||
@@ -6957,6 +6963,8 @@ const message = {
|
||||
submitSuccess: 'Задача синхронизации успешно отправлена.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'В редакции Professional можно создать не более {0} виртуальных машин. В Enterprise количество не ограничено.',
|
||||
vm: 'Виртуальная машина',
|
||||
title: 'Управление виртуальными машинами',
|
||||
healthCheck: 'Проверка состояния',
|
||||
|
||||
@@ -2133,6 +2133,12 @@ const message = {
|
||||
"Oturum artık kullanılamıyor, yeni bir oturum açmak için Enter'a basın veya yeniden bağlan'a tıklayın",
|
||||
sessionKicked: 'Bu oturum başka bir pencerede açıldı',
|
||||
sessionCount: '{0} oturum',
|
||||
sessionRules: 'Terminal oturumu kuralları',
|
||||
sessionRuleClose: 'Terminali kapatmak oturumu hemen sonlandırır.',
|
||||
sessionRuleDisconnect:
|
||||
'Yenileme, tarayıcı kapanması veya bağlantı kaybından sonra 30 dakika içinde kurtarılabilir.',
|
||||
sessionRuleRevalidate: 'Giriş geçersizse veya doğrulama zaman aşımına uğrarsa oturum sona erer.',
|
||||
sessionRuleResources: 'Daha fazla terminal daha fazla kaynak tüketir. Kullanmadığınız terminalleri kapatın.',
|
||||
minimize: 'Küçült',
|
||||
closeAllSessions: 'Tüm oturumları kapat',
|
||||
closeAllConfirm: 'Tüm terminal oturumları kesilecek ve geri alınamayacak. Devam edilsin mi?',
|
||||
@@ -6956,6 +6962,8 @@ const message = {
|
||||
submitSuccess: 'Senkronizasyon görevi başarıyla gönderildi.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'Professional sürümü en fazla {0} sanal makine oluşturulmasına izin verir. Enterprise sürümünde sayı sınırı yoktur.',
|
||||
vm: 'Sanal Makine',
|
||||
title: 'Sanal Makine Yönetimi',
|
||||
healthCheck: 'Sağlık kontrolü',
|
||||
|
||||
@@ -1994,6 +1994,11 @@ const message = {
|
||||
sessionExpired: '會話已失效,按 Enter 或點擊重新連線以新建會話',
|
||||
sessionKicked: '該會話已在其他視窗開啟',
|
||||
sessionCount: '{0} 會話',
|
||||
sessionRules: '終端會話規則',
|
||||
sessionRuleClose: '主動關閉終端將立即結束會話。',
|
||||
sessionRuleDisconnect: '重新整理、關閉瀏覽器或斷網後,30 分鐘內可恢復。',
|
||||
sessionRuleRevalidate: '登入失效或驗證逾時,會話自動結束。',
|
||||
sessionRuleResources: '終端數量越多,資源占用越高,請及時關閉不用的終端。',
|
||||
minimize: '最小化',
|
||||
closeAllSessions: '關閉所有會話',
|
||||
closeAllConfirm: '將斷開全部終端會話且無法恢復,是否繼續?',
|
||||
@@ -6371,6 +6376,7 @@ const message = {
|
||||
submitSuccess: '同步任務已提交。',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper: '專業版最多可建立 {0} 台虛擬機,企業版不限制數量。',
|
||||
vm: '虛擬機',
|
||||
title: '虛擬機管理',
|
||||
healthCheck: '健康檢查',
|
||||
|
||||
@@ -2024,6 +2024,11 @@ const message = {
|
||||
sessionExpired: '会话已失效,按回车或点击重连以新建会话',
|
||||
sessionKicked: '该会话已在其他窗口打开',
|
||||
sessionCount: '{0} 会话',
|
||||
sessionRules: '终端会话规则',
|
||||
sessionRuleClose: '主动关闭终端将立即结束会话。',
|
||||
sessionRuleDisconnect: '刷新、关闭浏览器或断网后,30 分钟内可恢复。',
|
||||
sessionRuleRevalidate: '登录失效或校验超时,会话自动结束。',
|
||||
sessionRuleResources: '终端数量越多,资源占用越高,请及时关闭不用的终端。',
|
||||
minimize: '最小化',
|
||||
closeAllSessions: '关闭所有会话',
|
||||
closeAllConfirm: '将断开全部终端会话且无法恢复,是否继续?',
|
||||
@@ -6435,6 +6440,7 @@ const message = {
|
||||
submitSuccess: '同步任务已提交。',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper: '专业版最多可创建 {0} 台虚拟机,企业版不限制数量。',
|
||||
vm: '虚拟机',
|
||||
title: '虚拟机管理',
|
||||
healthCheck: '健康检查',
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="terminal-page" :class="{ 'is-mobile': isMobile }">
|
||||
<el-tabs
|
||||
type="card"
|
||||
class="terminal-tabs card-interval"
|
||||
class="terminal-tabs"
|
||||
style="background-color: var(--panel-terminal-tag-bg-color)"
|
||||
v-model="terminalValue"
|
||||
:before-leave="beforeLeave"
|
||||
addable
|
||||
@tab-add="showConnections = !showConnections"
|
||||
@tab-change="quickCmd = ''"
|
||||
@edit="handleTabsRemove"
|
||||
>
|
||||
@@ -17,31 +18,34 @@
|
||||
:name="item.key"
|
||||
>
|
||||
<template #label>
|
||||
<span class="custom-tabs-label">
|
||||
<span
|
||||
v-if="item.status === 'online'"
|
||||
:style="`color: ${
|
||||
item.latency < 100 ? '#69db7c' : item.latency < 300 ? '#f59f00' : '#d9480f'
|
||||
}; display: inline-flex; align-items: center`"
|
||||
>
|
||||
<span> {{ item.latency }} ms </span>
|
||||
<el-icon>
|
||||
<circleCheck />
|
||||
</el-icon>
|
||||
<el-tooltip
|
||||
:content="`${item.title} · ${
|
||||
item.status === 'online' ? `${item.latency} ms` : $t('commons.button.reconnect')
|
||||
}`"
|
||||
placement="top-start"
|
||||
:show-after="300"
|
||||
>
|
||||
<span class="terminal-tab-label">
|
||||
<span v-if="item.status === 'online'" class="terminal-tab-status" aria-hidden="true">
|
||||
<span class="terminal-status-dot"></span>
|
||||
</span>
|
||||
<el-button
|
||||
v-else
|
||||
icon="Refresh"
|
||||
class="terminal-tab-reconnect"
|
||||
:aria-label="$t('commons.button.reconnect')"
|
||||
link
|
||||
@click.stop="onReconnect(item)"
|
||||
/>
|
||||
<span class="terminal-tab-title">{{ item.title }}</span>
|
||||
<span
|
||||
v-if="item.key === terminalValue && item.status === 'online'"
|
||||
class="terminal-tab-latency"
|
||||
>
|
||||
{{ item.latency }} ms
|
||||
</span>
|
||||
</span>
|
||||
<el-button
|
||||
v-if="item.status === 'closed'"
|
||||
icon="Refresh"
|
||||
class="text-white"
|
||||
size="default"
|
||||
link
|
||||
@click="onReconnect(item)"
|
||||
/>
|
||||
<span v-if="item.title.length <= 20"> {{ item.title }} </span>
|
||||
<el-tooltip v-else :content="item.title" placement="top-start">
|
||||
<span> {{ item.title.substring(0, 17) }}... </span>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<!-- The Terminal itself is rendered by components/terminal/host.vue and teleported here. -->
|
||||
<div
|
||||
@@ -99,115 +103,121 @@
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :closable="false" name="newTabs">
|
||||
<template #label>
|
||||
<el-button v-popover="popoverRef" class="tagButton" icon="Plus"></el-button>
|
||||
<el-popover
|
||||
ref="popoverRef"
|
||||
width="320px"
|
||||
trigger="hover"
|
||||
virtual-triggering
|
||||
persistent
|
||||
:offset="-4"
|
||||
>
|
||||
<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"
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
{{ $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"
|
||||
<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 #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') }}
|
||||
<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>
|
||||
<div v-else class="w-full min-w-0">
|
||||
<span v-if="node.label.length <= 22">
|
||||
<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 }}
|
||||
{{ node.label.substring(0, 30) }}...
|
||||
</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>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</template>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
<div v-if="store.entries.length === 0">
|
||||
<el-empty
|
||||
:style="{ height: `calc(100vh - ${loadEmptyHeight()})`, 'background-color': '#000' }"
|
||||
@@ -215,15 +225,17 @@
|
||||
></el-empty>
|
||||
</div>
|
||||
</el-tabs>
|
||||
<el-tooltip :content="loadTooltip()" placement="top">
|
||||
<el-button
|
||||
@click="toggleFullscreen"
|
||||
v-if="!isMobile"
|
||||
class="bg-transparent border-0 absolute right-[50px] font-semibold text-sm"
|
||||
:style="{ top: loadFullScreenHeight() }"
|
||||
icon="FullScreen"
|
||||
></el-button>
|
||||
</el-tooltip>
|
||||
<div v-if="!isMobile" class="terminal-actions">
|
||||
<el-tooltip :content="loadTooltip()" placement="top">
|
||||
<el-button
|
||||
class="terminal-action"
|
||||
icon="FullScreen"
|
||||
text
|
||||
:aria-label="loadTooltip()"
|
||||
@click="toggleFullscreen"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<HostDialog
|
||||
ref="dialogRef"
|
||||
@@ -276,9 +288,8 @@ let quickCmd = ref();
|
||||
let batchVal = ref();
|
||||
let isBatch = ref<boolean>(false);
|
||||
|
||||
const popoverRef = ref();
|
||||
|
||||
const hostFilterInfo = ref('');
|
||||
const showConnections = ref(false);
|
||||
const hostTree = ref<Array<Host.HostTree>>();
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const defaultProps = {
|
||||
@@ -379,9 +390,6 @@ const loadHeight = () => {
|
||||
const loadEmptyHeight = () => {
|
||||
return openMenuTabs.value ? '201px' : '156px';
|
||||
};
|
||||
const loadFullScreenHeight = () => {
|
||||
return openMenuTabs.value ? '105px' : '60px';
|
||||
};
|
||||
|
||||
const handleTabsRemove = async (targetName: string, action: 'remove' | 'add') => {
|
||||
if (action !== 'remove') {
|
||||
@@ -454,13 +462,8 @@ function batchInput() {
|
||||
batchVal.value = '';
|
||||
}
|
||||
|
||||
function beforeLeave(activeName: string) {
|
||||
if (activeName === 'newTabs') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const onNewSsh = () => {
|
||||
showConnections.value = false;
|
||||
if (isNodeAdmin.value) {
|
||||
MsgWarning(i18n.global.t('terminal.nodeAdminLocalOnly'));
|
||||
return;
|
||||
@@ -477,6 +480,7 @@ const openTab = async (title: string, wsID: number, error: string) => {
|
||||
};
|
||||
|
||||
const onNewLocal = async () => {
|
||||
showConnections.value = false;
|
||||
const res = await testLocalConn();
|
||||
if (!res.data) {
|
||||
dialogRef.value!.acceptParams({ isLocal: true });
|
||||
@@ -501,6 +505,7 @@ const onReconnect = async (item: any) => {
|
||||
};
|
||||
|
||||
const onConnTerminal = async (title: string, wsID: number) => {
|
||||
showConnections.value = false;
|
||||
if (isNodeAdmin.value) {
|
||||
MsgWarning(i18n.global.t('terminal.nodeAdminLocalOnly'));
|
||||
return;
|
||||
@@ -535,9 +540,50 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.terminal-page {
|
||||
--terminal-actions-width: 40px;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding-top: 7px;
|
||||
|
||||
&.is-mobile {
|
||||
--terminal-actions-width: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-actions {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: var(--terminal-actions-width);
|
||||
height: var(--el-tabs-header-height, 40px);
|
||||
}
|
||||
|
||||
.terminal-action {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-add {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.terminal-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
padding: 0;
|
||||
justify-content: flex-start;
|
||||
padding: 0 var(--terminal-actions-width) 0 0;
|
||||
min-height: var(--el-tabs-header-height);
|
||||
position: relative;
|
||||
margin: 0 0 3px 0;
|
||||
}
|
||||
@@ -551,6 +597,17 @@ onMounted(() => {
|
||||
:deep(.el-tabs__item) {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.el-tabs__nav-wrap) {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__new-tab) {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
:deep(.el-tabs__item.is-active) {
|
||||
color: var(--panel-terminal-tag-active-text-color);
|
||||
background-color: var(--panel-terminal-tag-active-bg-color);
|
||||
@@ -561,11 +618,81 @@ onMounted(() => {
|
||||
:deep(.el-tabs__item.is-active:hover) {
|
||||
color: var(--panel-terminal-tag-active-text-color);
|
||||
}
|
||||
:deep(.el-tabs__header .el-tabs__item.is-closable) {
|
||||
padding: 0 12px;
|
||||
|
||||
.is-icon-close {
|
||||
width: 14px;
|
||||
margin-left: 6px;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--el-transition-duration);
|
||||
}
|
||||
|
||||
&.is-active,
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
.is-icon-close {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.is-icon-close {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tagButton {
|
||||
border: 0;
|
||||
background-color: var(--el-tabs__item);
|
||||
.terminal-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.terminal-tab-status,
|
||||
.terminal-tab-reconnect {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.terminal-tab-reconnect {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.terminal-tab-title {
|
||||
min-width: 0;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-tab-latency {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.terminal-slot {
|
||||
@@ -598,9 +725,6 @@ onMounted(() => {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.el-tabs--top.el-tabs--card > .el-tabs__header .el-tabs__item:last-child {
|
||||
padding-right: 0px;
|
||||
}
|
||||
.el-input__wrapper {
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user