fix(terminal): isolate persistent shortcut sessions (#13810)

This commit is contained in:
ssongliu
2026-09-15 10:05:16 +08:00
committed by GitHub
parent 005f240fb7
commit 89bd32b6d4
22 changed files with 132 additions and 99 deletions
+10 -7
View File
@@ -29,6 +29,7 @@ import (
// @Summary Ws local terminal
// @Param command query string false "command"
// @Param session query string false "session id to reattach"
// @Param terminalPersistent query boolean false "allow recovery after an unexpected disconnect"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
@@ -43,6 +44,7 @@ func (b *BaseApi) WsLocalTerminal(c *gin.Context) {
// @Param command query string false "command"
// @Param session query string false "session id to reattach"
// @Param title query string false "session title shown in the session list"
// @Param terminalPersistent query boolean false "allow recovery after an unexpected disconnect"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
@@ -146,13 +148,14 @@ func (b *BaseApi) runSSHSession(c *gin.Context, kind string, connect func() (*ss
hostID, _ = strconv.Atoi(c.DefaultQuery("id", "0"))
}
opts := terminal.SessionOptions{
Identity: identity,
Kind: kind,
Title: sanitizeTerminalTitle(c.Query("title")),
HostID: uint(max(hostID, 0)),
Cols: cols,
Rows: rows,
InitCmd: command,
Identity: identity,
Kind: kind,
Title: sanitizeTerminalTitle(c.Query("title")),
Persistent: c.Query("terminalPersistent") == "true",
HostID: uint(max(hostID, 0)),
Cols: cols,
Rows: rows,
InitCmd: command,
}
err := terminal.Serve(wsConn, strings.TrimSpace(c.Query("session")), opts, func() (*gossh.Client, error) {
client, err := connect()
+18 -42
View File
@@ -16,12 +16,6 @@ import (
gossh "golang.org/x/crypto/ssh"
)
// Lifetime rules. A session outlives its websocket:
// - the client closes its websocket with 1000 -> the shell is closed at once
// - the websocket drops any other way (browser tab closed, network) -> the
// shell waits graceTimeout for a reattach, then is closed
//
// ponytail: all fixed; promote to settings only if someone asks.
const (
graceTimeout = 30 * time.Minute
revalidateInterval = 60 * time.Second
@@ -31,41 +25,37 @@ const (
pumpInterval = 60 * time.Millisecond
)
// Websocket close codes of the session protocol; the frontend switches on them.
const (
// CloseCodeSessionNotFound: the session is gone or not the caller's; do not retry.
CloseCodeSessionNotFound = 4404
// CloseCodeAttachedElsewhere: a newer websocket took over the session.
CloseCodeSessionNotFound = 4404
CloseCodeAttachedElsewhere = 4409
CloseCodeRevalidate = 4410
)
var errSessionClosed = errors.New("terminal session is closed")
// SessionOptions describes a session that is about to be created.
type SessionOptions struct {
Identity Identity
Kind string
Target string
Title string
HostID uint // 0 = local shell
Cols int
Rows int
InitCmd string
Identity Identity
Kind string
Target string
Title string
Persistent bool
HostID uint // 0 = local shell
Cols int
Rows int
InitCmd string
}
// Info is the client visible snapshot of a session.
type Info struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Persistent bool `json:"persistent"`
HostID uint `json:"hostId"`
Attached bool `json:"attached"`
CreatedAt time.Time `json:"createdAt"`
DetachedAt time.Time `json:"detachedAt"` // zero while attached
}
// Session owns one shell; a websocket is only a detachable attachment.
type Session struct {
ID string
UserID string
@@ -73,6 +63,7 @@ type Session struct {
Kind string
Target string
Title string
Persistent bool
HostID uint
CreatedAt time.Time
@@ -104,9 +95,6 @@ type sessionBackend interface {
Close() error
}
// Serve drives ws until it ends: it reattaches to sessionID when given, and
// otherwise opens a fresh shell on the client that connect returns. A returned
// error has not been reported to the client yet.
func Serve(ws *websocket.Conn, sessionID string, opts SessionOptions, connect func() (*gossh.Client, error)) error {
return serve(ws, sessionID, opts, func() (*Session, error) {
client, err := connect()
@@ -138,7 +126,7 @@ func ServeCommand(ws *websocket.Conn, sessionID string, opts SessionOptions, con
func serve(ws *websocket.Conn, sessionID string, opts SessionOptions, open func() (*Session, error)) error {
if sessionID != "" {
sess, ok := Lookup(sessionID, opts.Identity)
if ok && sess.Kind == opts.Kind && sess.Target == opts.Target && sess.HostID == opts.HostID {
if ok && sess.Kind == opts.Kind && sess.Target == opts.Target && sess.Persistent == opts.Persistent && sess.HostID == opts.HostID {
att, err := sess.Attach(ws, opts.Cols, opts.Rows)
if err == nil {
att.Run()
@@ -154,7 +142,6 @@ func serve(ws *websocket.Conn, sessionID string, opts SessionOptions, open func(
if err != nil {
return err
}
// no sess.Close() on return: a dirty disconnect leaves the shell alive for a reattach
att, err := sess.Attach(ws, opts.Cols, opts.Rows)
if err != nil {
sess.Close()
@@ -164,7 +151,6 @@ func serve(ws *websocket.Conn, sessionID string, opts SessionOptions, open func(
return nil
}
// Open starts a shell on client and registers the session.
func Open(client *gossh.Client, opts SessionOptions) (*Session, error) {
if err := validateSessionOptions(opts); err != nil {
return nil, err
@@ -197,6 +183,7 @@ func openBackend(backend sessionBackend, ring *ringBuffer, opts SessionOptions)
Kind: opts.Kind,
Target: opts.Target,
Title: opts.Title,
Persistent: opts.Persistent,
HostID: opts.HostID,
CreatedAt: time.Now(),
cols: opts.Cols,
@@ -229,8 +216,6 @@ func validateSessionOptions(opts SessionOptions) error {
return nil
}
// Attach binds ws to the session, kicking any previous attachment, and replays
// the retained output tail before any live output.
func (s *Session) Attach(ws *websocket.Conn, cols, rows int) (*attachment, error) {
if ws == nil {
return nil, errors.New("nil websocket connection")
@@ -299,7 +284,6 @@ func (s *Session) Attach(ws *websocket.Conn, cols, rows int) (*attachment, error
return att, nil
}
// detach unbinds a. A clean detach closes the shell; a dirty one arms the grace timer.
func (s *Session) detach(a *attachment, clean, revalidate bool, cursor uint64) {
s.mu.Lock()
if s.attached != a {
@@ -312,7 +296,8 @@ func (s *Session) detach(a *attachment, clean, revalidate bool, cursor uint64) {
if revalidate {
s.revalidateCursor = cursor
}
if !clean {
shouldClose := clean || (!s.Persistent && !revalidate)
if !shouldClose {
timeout := graceTimeout
if revalidate {
timeout = revalidateGrace
@@ -320,7 +305,7 @@ func (s *Session) detach(a *attachment, clean, revalidate bool, cursor uint64) {
s.grace = time.AfterFunc(timeout, s.Close)
}
s.mu.Unlock()
if clean {
if shouldClose {
s.Close()
}
}
@@ -356,7 +341,6 @@ func (s *Session) doClose() {
}
}
// Info snapshots the session for listing.
func (s *Session) Info() Info {
s.mu.Lock()
defer s.mu.Unlock()
@@ -364,6 +348,7 @@ func (s *Session) Info() Info {
ID: s.ID,
Kind: s.Kind,
Title: s.Title,
Persistent: s.Persistent,
HostID: s.HostID,
Attached: s.attached != nil,
CreatedAt: s.CreatedAt,
@@ -371,7 +356,6 @@ func (s *Session) Info() Info {
}
}
// resize forwards a window size change to the shell.
func (s *Session) resize(cols, rows int) {
s.mu.Lock()
s.cols, s.rows = cols, rows
@@ -381,14 +365,12 @@ func (s *Session) resize(cols, rows int) {
}
}
// writeInput forwards client input to the shell stdin.
func (s *Session) writeInput(data []byte) {
if _, err := s.backend.Write(data); err != nil {
global.LOG.Errorf("ws cmd bytes write to ssh.stdin pipe failed, err: %v", err)
}
}
// ensureAIInterceptor rebuilds the interceptor when AI runtime settings change.
func (s *Session) ensureAIInterceptor() *aiInputInterceptor {
s.mu.Lock()
defer s.mu.Unlock()
@@ -399,7 +381,6 @@ func (s *Session) ensureAIInterceptor() *aiInputInterceptor {
return s.aiInterceptor
}
// pump forwards new ring output to the current attachment.
func (s *Session) pump() {
defer func() {
if r := recover(); r != nil {
@@ -418,8 +399,6 @@ func (s *Session) pump() {
}
}
// flush sends everything the attachment has not seen yet. A client that fell
// behind the ring skips ahead and is told so; output is never queued unbounded.
func (s *Session) flush() {
s.mu.Lock()
att := s.attached
@@ -448,7 +427,6 @@ func (s *Session) flush() {
att.cursor = next
}
// keepaliveLoop probes the shell connection; a failed or stuck probe closes the session.
func (s *Session) keepaliveLoop() {
tick := time.NewTicker(keepaliveInterval)
defer tick.Stop()
@@ -477,7 +455,6 @@ func (s *Session) keepaliveLoop() {
}
}
// waitBackend closes the session once the shell exits, after a last flush.
func (s *Session) waitBackend() {
_ = s.backend.Wait()
s.flush()
@@ -489,7 +466,6 @@ func cmdMessage(data []byte) []byte {
return msg
}
// sendClose writes a close frame with code and reason, best effort.
func sendClose(ws *websocket.Conn, code int, reason string) {
_ = ws.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason), time.Now().Add(time.Second))
}
+1
View File
@@ -13,6 +13,7 @@ export interface TerminalSession {
kind: 'local' | 'ssh' | 'container';
title: string;
hostId: number;
persistent: boolean;
attached: boolean;
createdAt: string;
detachedAt: string;
+12 -21
View File
@@ -1,9 +1,5 @@
<template>
<div
v-if="isAdmin && terminalStore.showTerminalButton && !onTerminalPage"
class="terminal-dock-handle"
@click="show"
>
<div v-if="isAdmin && terminalStore.showTerminalButton" class="terminal-dock-handle" @click="show">
<el-badge
:value="store.entries.length"
:hidden="store.entries.length === 0"
@@ -38,6 +34,7 @@
<li>{{ $t('terminal.sessionRuleDisconnect') }}</li>
<li>{{ $t('terminal.sessionRuleRevalidate') }}</li>
<li>{{ $t('terminal.sessionRuleResources') }}</li>
<li>{{ $t('terminal.sessionRuleDisableShortcut') }}</li>
</ul>
</el-popover>
</div>
@@ -91,26 +88,17 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import i18n from '@/lang';
import { TerminalSessionStore, TerminalStore } from '@/store';
import { getTerminalInfo } from '@/api/modules/setting';
import { TerminalDockSessionStore, TerminalStore } from '@/store';
import { ElMessageBox } from 'element-plus';
import ConnectionMenu from '@/components/terminal/connection-menu/index.vue';
import type { TerminalConnectionOptions } from '@/components/terminal/connection-menu/types';
import { useGlobalStore } from '@/composables/useGlobalStore';
const store = TerminalSessionStore();
const store = TerminalDockSessionStore();
const terminalStore = TerminalStore();
const { isAdmin } = useGlobalStore();
const route = useRoute();
const onTerminalPage = computed(() => route.path.startsWith('/terminal'));
onMounted(async () => {
const res = await getTerminalInfo();
terminalStore.showTerminalButton = res.data.showTerminalButton !== 'Disable';
});
const open = ref(false);
const active = ref('');
@@ -130,6 +118,7 @@ const show = async () => {
if (!open.value || !isAdmin.value) return;
claim();
store.sync();
if (timer) clearInterval(timer);
timer = setInterval(store.sync, 5000);
};
@@ -145,6 +134,12 @@ watch(open, (value) => {
watch(isAdmin, (allowed) => {
if (!allowed) open.value = false;
});
watch(
() => terminalStore.showTerminalButton,
(visible) => {
if (!visible) open.value = false;
},
);
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
@@ -189,10 +184,6 @@ const closeAll = async () => {
}
open.value = false;
};
watch(onTerminalPage, (v) => {
if (v) open.value = false;
});
</script>
<style scoped lang="scss">
+25 -17
View File
@@ -1,16 +1,15 @@
<template>
<!-- Lives in the layout so terminal sessions outlive the terminal route.
Each Terminal is teleported into the page's slot while the page is mounted
and parked off-screen (still connected, still receiving output) otherwise. -->
<div class="terminal-host" aria-hidden="true">
<template v-for="item in store.entries" :key="item.key + ':' + item.refresh">
<Teleport :to="store.slots[item.key] || 'body'" :disabled="!store.slots[item.key]">
<Terminal
:ref="(el: any) => store.setInstance(item.key, el)"
@session="(id: string) => store.setSessionId(item.key, id)"
@expired="store.onExpired(item.key)"
/>
</Teleport>
<template v-for="store in stores" :key="store.$id">
<template v-for="item in store.entries" :key="item.key + ':' + item.refresh">
<Teleport :to="store.slots[item.key] || 'body'" :disabled="!store.slots[item.key]">
<Terminal
:ref="(el: any) => store.setInstance(item.key, el)"
@session="(id: string) => store.setSessionId(item.key, id)"
@expired="store.onExpired(item.key)"
/>
</Teleport>
</template>
</template>
</div>
</template>
@@ -18,17 +17,26 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import Terminal from '@/components/terminal/index.vue';
import { TerminalSessionStore } from '@/store';
import { TerminalDockSessionStore, TerminalSessionStore, TerminalStore } from '@/store';
import { getTerminalInfo } from '@/api/modules/setting';
import { useGlobalStore } from '@/composables/useGlobalStore';
const store = TerminalSessionStore();
const pageStore = TerminalSessionStore();
const dockStore = TerminalDockSessionStore();
const terminalStore = TerminalStore();
const stores = [pageStore, dockStore];
const { isAdmin } = useGlobalStore();
// Sessions the agent still holds (page refresh, closed browser tab) are
// reattached right away, without waiting for the terminal page.
onMounted(() => store.restore());
onMounted(async () => {
try {
const res = await getTerminalInfo();
terminalStore.showTerminalButton = res.data.showTerminalButton !== 'Disable';
} catch {}
if (isAdmin.value && terminalStore.showTerminalButton) await dockStore.restore();
});
</script>
<style scoped>
/* Off-screen but sized, so xterm can measure and keep rendering while parked. */
.terminal-host {
position: fixed;
left: -10000px;
+2
View File
@@ -2120,9 +2120,11 @@ const message = {
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.',
sessionRuleDisableShortcut: 'Disable the terminal shortcut in Terminal - Settings.',
minimize: 'Minimize',
closeAllSessions: 'Close all sessions',
closeAllConfirm: 'All terminal sessions will be disconnected and cannot be recovered. Continue?',
disableShortcutConfirm: 'Disabling the terminal shortcut will disconnect all of its sessions. Continue?',
lineHeight: 'Line Height',
letterSpacing: 'Letter Spacing',
fontSize: 'Font Size',
+3
View File
@@ -2163,9 +2163,12 @@ const message = {
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.',
sessionRuleDisableShortcut: 'Desactiva el acceso rápido al terminal en Terminal - Configuración.',
minimize: 'Minimizar',
closeAllSessions: 'Cerrar todas las sesiones',
closeAllConfirm: 'Se desconectarán todas las sesiones de terminal y no se podrán recuperar. ¿Continuar?',
disableShortcutConfirm:
'Al desactivar el acceso rápido al terminal se desconectarán todas sus sesiones. ¿Continuar?',
lineHeight: 'Altura de línea',
letterSpacing: 'Espaciado de letras',
fontSize: 'Tamaño de fuente',
+2
View File
@@ -2099,9 +2099,11 @@ const message = {
sessionRuleDisconnect: 'تا ۳۰ دقیقه پس از بازخوانی صفحه، بستن مرورگر یا قطع شبکه قابل بازیابی است.',
sessionRuleRevalidate: 'نشست با نامعتبر شدن ورود یا پایان مهلت تأیید خاتمه می‌یابد.',
sessionRuleResources: 'ترمینال‌های بیشتر منابع بیشتری مصرف می‌کنند. ترمینال‌های غیرضروری را ببندید.',
sessionRuleDisableShortcut: 'میانبر ترمینال را می‌توانید در «ترمینال - تنظیمات» غیرفعال کنید.',
minimize: 'کوچک‌سازی',
closeAllSessions: 'بستن همه نشست‌ها',
closeAllConfirm: 'همه نشست‌های ترمینال قطع می‌شوند و قابل بازیابی نیستند. ادامه می‌دهید؟',
disableShortcutConfirm: 'غیرفعال کردن میانبر ترمینال، همه نشست‌های مربوط به آن را قطع می‌کند. ادامه می‌دهید؟',
lineHeight: 'ارتفاع خط',
letterSpacing: 'فاصله بین حروف',
fontSize: 'اندازه قلم',
+3
View File
@@ -2110,9 +2110,12 @@ const message = {
sessionRuleDisconnect: '再読み込みブラウザー終了通信切断後は30分以内に復元できます',
sessionRuleRevalidate: 'ログインが無効または認証がタイムアウトするとセッションは終了します',
sessionRuleResources: '端末が増えるほどリソース消費も増えます不要な端末は閉じてください',
sessionRuleDisableShortcut: 'ターミナル - 設定でターミナルショートカットを無効にできます',
minimize: '最小化',
closeAllSessions: 'すべてのセッションを閉じる',
closeAllConfirm: 'すべてのターミナルセッションが切断され復元できません続行しますか',
disableShortcutConfirm:
'ターミナルショートカットを無効にするとそのすべてのセッションが切断されます続行しますか',
lineHeight: '行の高さ',
letterSpacing: '文字間隔',
fontSize: 'フォントサイズ',
+2
View File
@@ -2078,9 +2078,11 @@ const message = {
sessionRuleDisconnect: '새로고침, 브라우저 종료, 네트워크 끊김 30 이내에 복구할 있습니다.',
sessionRuleRevalidate: '로그인이 무효이거나 인증 시간이 초과되면 세션이 종료됩니다.',
sessionRuleResources: '터미널이 많을수록 리소스 사용량이 늘어납니다. 사용하지 않는 터미널은 닫아 주세요.',
sessionRuleDisableShortcut: '터미널 - 설정에서 터미널 바로가기를 비활성화할 있습니다.',
minimize: '최소화',
closeAllSessions: '모든 세션 닫기',
closeAllConfirm: '모든 터미널 세션이 끊기며 복구할 없습니다. 계속하시겠습니까?',
disableShortcutConfirm: '터미널 바로가기를 비활성화하면 해당 세션이 모두 연결 해제됩니다. 계속하시겠습니까?',
lineHeight: ' 높이',
letterSpacing: '자간',
fontSize: '글꼴 크기',
+2
View File
@@ -2064,9 +2064,11 @@ const message = {
sessionRuleDisconnect: 'ກູ້ຄືນໄດ້ພາຍໃນ 30 ນາທີ ຫຼັງໂຫຼດໜ້າໃໝ່, ປິດບຣາວເຊີ ຫຼື ເຄືອຂ່າຍຂາດ.',
sessionRuleRevalidate: 'ເຊສຊັນສິ້ນສຸດເມື່ອການເຂົ້າລະບົບບໍ່ຖືກຕ້ອງ ຫຼື ການຢືນຢັນໝົດເວລາ.',
sessionRuleResources: 'ເປີດເທີມິນອລຫຼາຍຍິ່ງໃຊ້ຊັບພະຍາກອນຫຼາຍ. ກະລຸນາປິດເທີມິນອລທີ່ບໍ່ໄດ້ໃຊ້.',
sessionRuleDisableShortcut: 'ສາມາດປິດທາງລັດ Terminal ໄດ້ທີ່ Terminal - ການຕັ້ງຄ່າ.',
minimize: 'ຫຍໍ້ລົງ',
closeAllSessions: 'ປິດທຸກເຊສຊັນ',
closeAllConfirm: 'ເຊສຊັນເທີມິນອລທັງໝົດຈະຖືກຕັດການເຊື່ອມຕໍ່ ແລະ ບໍ່ສາມາດກູ້ຄືນໄດ້. ສືບຕໍ່ບໍ?',
disableShortcutConfirm: 'ການປິດທາງລັດ Terminal ຈະຕັດການເຊື່ອມຕໍ່ເຊສຊັນທັງໝົດຂອງມັນ. ສືບຕໍ່ບໍ?',
lineHeight: 'ຄວາມສູງຂອງແຖວ',
letterSpacing: 'ໄລຍະຫ່າງຕົວອັກສອນ',
fontSize: 'ຂະໜາດຕົວອັກສອນ',
+2
View File
@@ -2149,9 +2149,11 @@ const message = {
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.',
sessionRuleDisableShortcut: 'Lumpuhkan pintasan terminal di Terminal - Tetapan.',
minimize: 'Minimumkan',
closeAllSessions: 'Tutup semua sesi',
closeAllConfirm: 'Semua sesi terminal akan diputuskan dan tidak boleh dipulihkan. Teruskan?',
disableShortcutConfirm: 'Melumpuhkan pintasan terminal akan memutuskan semua sesi yang berkaitan. Teruskan?',
lineHeight: 'Ketinggian baris',
letterSpacing: 'Jarak huruf',
fontSize: 'Saiz fon',
+2
View File
@@ -2155,9 +2155,11 @@ const message = {
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.',
sessionRuleDisableShortcut: 'Desative o atalho do terminal em Terminal - Configurações.',
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?',
disableShortcutConfirm: 'Desativar o atalho do terminal desconectará todas as sessões associadas. Continuar?',
lineHeight: 'Altura da linha',
letterSpacing: 'Espaçamento entre letras',
fontSize: 'Tamanho da fonte',
+2
View File
@@ -2135,9 +2135,11 @@ const message = {
'После обновления страницы, закрытия браузера или потери сети восстановление доступно 30 минут.',
sessionRuleRevalidate: 'Сессия завершается при недействительном входе или тайм-ауте проверки.',
sessionRuleResources: 'Чем больше терминалов, тем выше расход ресурсов. Закрывайте ненужные терминалы.',
sessionRuleDisableShortcut: 'Отключить быстрый доступ можно в разделе «Терминал Настройки».',
minimize: 'Свернуть',
closeAllSessions: 'Закрыть все сессии',
closeAllConfirm: 'Все сессии терминала будут отключены без возможности восстановления. Продолжить?',
disableShortcutConfirm: 'Отключение быстрого доступа к терминалу завершит все его сессии. Продолжить?',
lineHeight: 'Высота строки',
letterSpacing: 'Межбуквенный интервал',
fontSize: 'Размер шрифта',
+3
View File
@@ -2142,9 +2142,12 @@ const message = {
'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.',
sessionRuleDisableShortcut: 'Terminal kısayolunu Terminal - Ayarlar bölümünden devre dışı bırakabilirsiniz.',
minimize: 'Küçült',
closeAllSessions: 'Tüm oturumları kapat',
closeAllConfirm: 'Tüm terminal oturumları kesilecek ve geri alınamayacak. Devam edilsin mi?',
disableShortcutConfirm:
'Terminal kısayolunu devre dışı bırakmak, tüm ilişkili oturumların bağlantısını kesecektir. Devam edilsin mi?',
lineHeight: 'Satır Yüksekliği',
letterSpacing: 'Harf Aralığı',
fontSize: 'Font Boyutu',
+2
View File
@@ -2001,9 +2001,11 @@ const message = {
sessionRuleDisconnect: '重新整理關閉瀏覽器或斷網後30 分鐘內可恢復',
sessionRuleRevalidate: '登入失效或驗證逾時會話自動結束',
sessionRuleResources: '終端數量越多資源占用越高請及時關閉不用的終端',
sessionRuleDisableShortcut: '可在終端 - 設定中關閉終端捷徑',
minimize: '最小化',
closeAllSessions: '關閉所有會話',
closeAllConfirm: '將斷開全部終端會話且無法恢復是否繼續',
disableShortcutConfirm: '關閉終端捷徑將同時斷開其中的全部會話是否繼續',
lineHeight: '字體行高',
letterSpacing: '字體間距',
fontSize: '字體大小',
+2
View File
@@ -2031,9 +2031,11 @@ const message = {
sessionRuleDisconnect: '刷新关闭浏览器或断网后30 分钟内可恢复',
sessionRuleRevalidate: '登录失效或校验超时会话自动结束',
sessionRuleResources: '终端数量越多资源占用越高请及时关闭不用的终端',
sessionRuleDisableShortcut: '可在终端 - 设置中关闭终端快捷入口',
minimize: '最小化',
closeAllSessions: '关闭所有会话',
closeAllConfirm: '将断开全部终端会话且无法恢复是否继续',
disableShortcutConfirm: '关闭终端快捷入口将同时断开其中的全部会话是否继续',
lineHeight: '字体行高',
letterSpacing: '字体间距',
fontSize: '字体大小',
+2 -1
View File
@@ -6,7 +6,7 @@ import { hasRouteAccess } from '@/utils/rbac';
import { loadProductProFromDB } from '@/utils/xpack';
import i18n from '@/lang';
import { MsgError } from '@/utils/message';
import { TerminalSessionStore } from '@/store';
import { TerminalDockSessionStore, TerminalSessionStore } from '@/store';
const axiosCanceler = new AxiosCanceler();
@@ -26,6 +26,7 @@ const clearLoginStatus = () => {
globalStore.clearAuthInfo();
clearLicenseStatus();
TerminalSessionStore().closeAll();
TerminalDockSessionStore().closeAll();
};
router.beforeEach(async (to, from) => {
+10 -2
View File
@@ -4,12 +4,20 @@ import GlobalStore from './modules/global';
import MenuStore from './modules/menu';
import TabsStore from './modules/tabs';
import TerminalStore from './modules/terminal';
import TerminalSessionStore from './modules/terminal-session';
import TerminalSessionStore, { TerminalDockSessionStore } from './modules/terminal-session';
import ProcessStore from './modules/process';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
export { GlobalStore, MenuStore, TabsStore, TerminalStore, TerminalSessionStore, ProcessStore };
export {
GlobalStore,
MenuStore,
TabsStore,
TerminalStore,
TerminalSessionStore,
TerminalDockSessionStore,
ProcessStore,
};
export default pinia;
+10 -3
View File
@@ -20,7 +20,7 @@ export interface TerminalSessionEntry {
const localEndpoint = '/api/v2/hosts/terminal/local';
const sshEndpoint = '/api/v2/hosts/terminal/ssh';
const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
const createTerminalSessionState = (persistent: boolean) => {
const entries = ref<TerminalSessionEntry[]>([]);
const instances = reactive<Record<string, any>>({});
const slots = shallowReactive<Record<string, HTMLElement | undefined>>({});
@@ -58,7 +58,9 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
title,
wsID: init.wsID,
endpoint: init.wsID === 0 ? localEndpoint : sshEndpoint,
args: [init.wsID === 0 ? '' : `id=${init.wsID}`, args, q].filter(Boolean).join('&'),
args: [persistent ? 'terminalPersistent=true' : '', init.wsID === 0 ? '' : `id=${init.wsID}`, args, q]
.filter(Boolean)
.join('&'),
sessionId: '',
status: init.status || 'online',
latency: 0,
@@ -90,6 +92,7 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
};
const restore = async () => {
if (!persistent) return;
const { currentNode } = useGlobalStore();
const node = currentNode.value || 'local';
const results = await Promise.allSettled([
@@ -101,6 +104,7 @@ 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;
if (!s.persistent) continue;
if (s.attached || entries.value.some((e) => e.sessionId === s.id)) continue;
if (s.hostId > 0 && !fromLocalNode) continue;
const key = add({
@@ -177,6 +181,9 @@ const TerminalSessionStore = defineStore('TerminalSessionStore', () => {
setSlot,
sync,
};
});
};
const TerminalSessionStore = defineStore('TerminalSessionStore', () => createTerminalSessionState(false));
export const TerminalDockSessionStore = defineStore('TerminalDockSessionStore', () => createTerminalSessionState(true));
export default TerminalSessionStore;
+15 -1
View File
@@ -161,11 +161,13 @@ import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { TerminalStore } from '@/store';
import { TerminalDockSessionStore, TerminalStore } from '@/store';
import { loadLocalConn, updateLocalConn } from '@/api/modules/terminal';
import { ElMessageBox } from 'element-plus';
const loading = ref(false);
const terminalStore = TerminalStore();
const dockSessions = TerminalDockSessionStore();
const dialogRef = ref();
const terminalElement = ref<HTMLDivElement | null>(null);
@@ -289,10 +291,22 @@ const changeTerminalButton = async () => {
const showTerminalButton = form.showTerminalButton;
loading.value = true;
try {
if (!showTerminalButton && dockSessions.entries.length > 0) {
await ElMessageBox.confirm(
i18n.global.t('terminal.disableShortcutConfirm'),
i18n.global.t('terminal.showTerminalButton'),
{
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'warning',
},
);
}
await UpdateTerminalInfo({
showTerminalButton: showTerminalButton ? 'Enable' : 'Disable',
});
terminalStore.showTerminalButton = showTerminalButton;
if (!showTerminalButton) dockSessions.closeAll();
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
} catch {
form.showTerminalButton = !showTerminalButton;
@@ -180,12 +180,8 @@ const acceptParams = async () => {
await claim();
store.sync();
}
cleanTimer();
timer = setInterval(store.sync, 1000 * 5);
if (!isMobile.value) {
screenfull.on('change', () => {
isFullScreen.value = screenfull.isFullscreen;
});
}
};
const openDefaultLocalConn = async () => {
@@ -332,6 +328,7 @@ onBeforeUnmount(() => {
cleanTimer();
pageVisible = false;
claim();
store.closeAll();
});
onMounted(() => {