mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 00:00:50 +00:00
fix(terminal): isolate persistent shortcut sessions (#13810)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface TerminalSession {
|
||||
kind: 'local' | 'ssh' | 'container';
|
||||
title: string;
|
||||
hostId: number;
|
||||
persistent: boolean;
|
||||
attached: boolean;
|
||||
createdAt: string;
|
||||
detachedAt: string;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2099,9 +2099,11 @@ const message = {
|
||||
sessionRuleDisconnect: 'تا ۳۰ دقیقه پس از بازخوانی صفحه، بستن مرورگر یا قطع شبکه قابل بازیابی است.',
|
||||
sessionRuleRevalidate: 'نشست با نامعتبر شدن ورود یا پایان مهلت تأیید خاتمه مییابد.',
|
||||
sessionRuleResources: 'ترمینالهای بیشتر منابع بیشتری مصرف میکنند. ترمینالهای غیرضروری را ببندید.',
|
||||
sessionRuleDisableShortcut: 'میانبر ترمینال را میتوانید در «ترمینال - تنظیمات» غیرفعال کنید.',
|
||||
minimize: 'کوچکسازی',
|
||||
closeAllSessions: 'بستن همه نشستها',
|
||||
closeAllConfirm: 'همه نشستهای ترمینال قطع میشوند و قابل بازیابی نیستند. ادامه میدهید؟',
|
||||
disableShortcutConfirm: 'غیرفعال کردن میانبر ترمینال، همه نشستهای مربوط به آن را قطع میکند. ادامه میدهید؟',
|
||||
lineHeight: 'ارتفاع خط',
|
||||
letterSpacing: 'فاصله بین حروف',
|
||||
fontSize: 'اندازه قلم',
|
||||
|
||||
@@ -2110,9 +2110,12 @@ const message = {
|
||||
sessionRuleDisconnect: '再読み込み、ブラウザー終了、通信切断後は30分以内に復元できます。',
|
||||
sessionRuleRevalidate: 'ログインが無効、または認証がタイムアウトするとセッションは終了します。',
|
||||
sessionRuleResources: '端末が増えるほどリソース消費も増えます。不要な端末は閉じてください。',
|
||||
sessionRuleDisableShortcut: '「ターミナル - 設定」でターミナルショートカットを無効にできます。',
|
||||
minimize: '最小化',
|
||||
closeAllSessions: 'すべてのセッションを閉じる',
|
||||
closeAllConfirm: 'すべてのターミナルセッションが切断され、復元できません。続行しますか?',
|
||||
disableShortcutConfirm:
|
||||
'ターミナルショートカットを無効にすると、そのすべてのセッションが切断されます。続行しますか?',
|
||||
lineHeight: '行の高さ',
|
||||
letterSpacing: '文字間隔',
|
||||
fontSize: 'フォントサイズ',
|
||||
|
||||
@@ -2078,9 +2078,11 @@ const message = {
|
||||
sessionRuleDisconnect: '새로고침, 브라우저 종료, 네트워크 끊김 후 30분 이내에 복구할 수 있습니다.',
|
||||
sessionRuleRevalidate: '로그인이 무효이거나 인증 시간이 초과되면 세션이 종료됩니다.',
|
||||
sessionRuleResources: '터미널이 많을수록 리소스 사용량이 늘어납니다. 사용하지 않는 터미널은 닫아 주세요.',
|
||||
sessionRuleDisableShortcut: '「터미널 - 설정」에서 터미널 바로가기를 비활성화할 수 있습니다.',
|
||||
minimize: '최소화',
|
||||
closeAllSessions: '모든 세션 닫기',
|
||||
closeAllConfirm: '모든 터미널 세션이 끊기며 복구할 수 없습니다. 계속하시겠습니까?',
|
||||
disableShortcutConfirm: '터미널 바로가기를 비활성화하면 해당 세션이 모두 연결 해제됩니다. 계속하시겠습니까?',
|
||||
lineHeight: '줄 높이',
|
||||
letterSpacing: '자간',
|
||||
fontSize: '글꼴 크기',
|
||||
|
||||
@@ -2064,9 +2064,11 @@ const message = {
|
||||
sessionRuleDisconnect: 'ກູ້ຄືນໄດ້ພາຍໃນ 30 ນາທີ ຫຼັງໂຫຼດໜ້າໃໝ່, ປິດບຣາວເຊີ ຫຼື ເຄືອຂ່າຍຂາດ.',
|
||||
sessionRuleRevalidate: 'ເຊສຊັນສິ້ນສຸດເມື່ອການເຂົ້າລະບົບບໍ່ຖືກຕ້ອງ ຫຼື ການຢືນຢັນໝົດເວລາ.',
|
||||
sessionRuleResources: 'ເປີດເທີມິນອລຫຼາຍຍິ່ງໃຊ້ຊັບພະຍາກອນຫຼາຍ. ກະລຸນາປິດເທີມິນອລທີ່ບໍ່ໄດ້ໃຊ້.',
|
||||
sessionRuleDisableShortcut: 'ສາມາດປິດທາງລັດ Terminal ໄດ້ທີ່ Terminal - ການຕັ້ງຄ່າ.',
|
||||
minimize: 'ຫຍໍ້ລົງ',
|
||||
closeAllSessions: 'ປິດທຸກເຊສຊັນ',
|
||||
closeAllConfirm: 'ເຊສຊັນເທີມິນອລທັງໝົດຈະຖືກຕັດການເຊື່ອມຕໍ່ ແລະ ບໍ່ສາມາດກູ້ຄືນໄດ້. ສືບຕໍ່ບໍ?',
|
||||
disableShortcutConfirm: 'ການປິດທາງລັດ Terminal ຈະຕັດການເຊື່ອມຕໍ່ເຊສຊັນທັງໝົດຂອງມັນ. ສືບຕໍ່ບໍ?',
|
||||
lineHeight: 'ຄວາມສູງຂອງແຖວ',
|
||||
letterSpacing: 'ໄລຍະຫ່າງຕົວອັກສອນ',
|
||||
fontSize: 'ຂະໜາດຕົວອັກສອນ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2135,9 +2135,11 @@ const message = {
|
||||
'После обновления страницы, закрытия браузера или потери сети восстановление доступно 30 минут.',
|
||||
sessionRuleRevalidate: 'Сессия завершается при недействительном входе или тайм-ауте проверки.',
|
||||
sessionRuleResources: 'Чем больше терминалов, тем выше расход ресурсов. Закрывайте ненужные терминалы.',
|
||||
sessionRuleDisableShortcut: 'Отключить быстрый доступ можно в разделе «Терминал — Настройки».',
|
||||
minimize: 'Свернуть',
|
||||
closeAllSessions: 'Закрыть все сессии',
|
||||
closeAllConfirm: 'Все сессии терминала будут отключены без возможности восстановления. Продолжить?',
|
||||
disableShortcutConfirm: 'Отключение быстрого доступа к терминалу завершит все его сессии. Продолжить?',
|
||||
lineHeight: 'Высота строки',
|
||||
letterSpacing: 'Межбуквенный интервал',
|
||||
fontSize: 'Размер шрифта',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2001,9 +2001,11 @@ const message = {
|
||||
sessionRuleDisconnect: '重新整理、關閉瀏覽器或斷網後,30 分鐘內可恢復。',
|
||||
sessionRuleRevalidate: '登入失效或驗證逾時,會話自動結束。',
|
||||
sessionRuleResources: '終端數量越多,資源占用越高,請及時關閉不用的終端。',
|
||||
sessionRuleDisableShortcut: '可在「終端 - 設定」中關閉終端捷徑。',
|
||||
minimize: '最小化',
|
||||
closeAllSessions: '關閉所有會話',
|
||||
closeAllConfirm: '將斷開全部終端會話且無法恢復,是否繼續?',
|
||||
disableShortcutConfirm: '關閉終端捷徑將同時斷開其中的全部會話,是否繼續?',
|
||||
lineHeight: '字體行高',
|
||||
letterSpacing: '字體間距',
|
||||
fontSize: '字體大小',
|
||||
|
||||
@@ -2031,9 +2031,11 @@ const message = {
|
||||
sessionRuleDisconnect: '刷新、关闭浏览器或断网后,30 分钟内可恢复。',
|
||||
sessionRuleRevalidate: '登录失效或校验超时,会话自动结束。',
|
||||
sessionRuleResources: '终端数量越多,资源占用越高,请及时关闭不用的终端。',
|
||||
sessionRuleDisableShortcut: '可在「终端 - 设置」中关闭终端快捷入口。',
|
||||
minimize: '最小化',
|
||||
closeAllSessions: '关闭所有会话',
|
||||
closeAllConfirm: '将断开全部终端会话且无法恢复,是否继续?',
|
||||
disableShortcutConfirm: '关闭终端快捷入口将同时断开其中的全部会话,是否继续?',
|
||||
lineHeight: '字体行高',
|
||||
letterSpacing: '字体间距',
|
||||
fontSize: '字体大小',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user