feat: add terminal session rules hint (#13744)

This commit is contained in:
ssongliu
2026-09-08 16:58:54 +08:00
committed by GitHub
parent 30dc36b95d
commit 671f781564
17 changed files with 532 additions and 262 deletions

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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 = () => {

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: 'بررسی سلامت',

View File

@@ -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: 'ヘルスチェック',

View File

@@ -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: '상태 점검',

View File

@@ -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: 'ກວດສອບສຸຂະພາບ',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: 'Проверка состояния',

View File

@@ -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ü',

View File

@@ -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: '健康檢查',

View File

@@ -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: '健康检查',

View File

@@ -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>&nbsp;{{ item.latency }}&nbsp;ms&nbsp;</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">&nbsp;{{ item.title }}&nbsp;</span>
<el-tooltip v-else :content="item.title" placement="top-start">
<span>&nbsp;{{ item.title.substring(0, 17) }}...&nbsp;</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;
}