feat: 终端路由保持会话 + 服务器列表按创建时间倒序
keep-alive 下终端切换不 dispose SSH;列表/推送/终端默认 newest first。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
# 终端页切换路由保持 SSH 会话
|
||||||
|
|
||||||
|
**日期**:2026-06-18
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
用户从「终端」页导航至其他 SPA 页面时,WebSSH 会话不再被主动断开;返回终端页可继续操作同一标签与会话。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
此前 `TerminalPage` 被刻意排除在 `keep-alive` 之外,且 `onBeforeUnmount` 中调用 `disposeAllSessions()`,路由切换即卸载组件并关闭全部 WebSocket/SSH 连接,与运维「开终端查问题、切页看列表、再回来继续敲命令」的工作流冲突。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 将 `TerminalPage` 加入 `CACHED_PAGE_NAMES`,由 `App.vue` 现有 `<keep-alive>` 缓存实例。
|
||||||
|
2. 生命周期调整:
|
||||||
|
- `onDeactivated`:仅移除全局快捷键监听,**不** dispose 会话。
|
||||||
|
- `onActivated`:恢复快捷键、refit xterm、聚焦当前标签。
|
||||||
|
- `onBeforeUnmount`:仅在缓存淘汰或组件真正销毁时 dispose(与 `keep-alive max=12` 一致)。
|
||||||
|
- 监听 `auth.isLoggedIn`:登出时 dispose,避免缓存中残留 SSH 连接。
|
||||||
|
|
||||||
|
未将会话提升到全局 store:`termNativeStore` 已是模块级单例,配合 keep-alive 即可,改动最小。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `frontend/src/constants/cachedPages.ts`
|
||||||
|
- `frontend/src/pages/TerminalPage.vue`
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
无。仅前端构建/热更新。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build
|
||||||
|
pytest tests/test_webssh_terminal_payload.py tests/test_terminal_quick_commands.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
手动:登录 → 终端连接一台服务器 → 侧栏切「服务器」→ 再切回「终端」→ 会话仍 connected,可继续输入;登出后重进终端应为空会话。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 服务器列表默认按创建时间倒序
|
||||||
|
|
||||||
|
**日期**:2026-06-21
|
||||||
|
|
||||||
|
## 变更摘要
|
||||||
|
|
||||||
|
服务器列表、推送/终端全量拉取默认 `sort_by=created_at&sort_order=desc`;后端 `server_repo` 无排序参数时同样默认最新在前。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
新机入库后应出现在列表顶部,便于运维发现与操作。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `server/infrastructure/database/server_repo.py`
|
||||||
|
- `frontend/src/composables/useServerPagination.ts`
|
||||||
|
- `frontend/src/composables/push/usePushServers.ts`
|
||||||
|
- `frontend/src/composables/terminal/useTerminalSessions.ts`
|
||||||
|
- `frontend/src/utils/serverTableSort.ts`
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
需 API 重启(后端排序逻辑)。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
打开 `#/servers`,新加服务器应排在前列;终端/推送页服务器下拉顺序一致。
|
||||||
@@ -45,7 +45,12 @@ export function usePushServers(pushStatus: Ref<Record<number, string>>) {
|
|||||||
async function loadServers(silent = false) {
|
async function loadServers(silent = false) {
|
||||||
if (!silent) serversLoading.value = true
|
if (!silent) serversLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetchPagePerPage<PushServerItem>('/servers/', {}, 1, -1)
|
const res = await fetchPagePerPage<PushServerItem>(
|
||||||
|
'/servers/',
|
||||||
|
{ sort_by: 'created_at', sort_order: 'desc' },
|
||||||
|
1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
servers.value = res.items
|
servers.value = res.items
|
||||||
} catch {
|
} catch {
|
||||||
servers.value = []
|
servers.value = []
|
||||||
|
|||||||
@@ -729,7 +729,12 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
|||||||
serversLoading.value = true
|
serversLoading.value = true
|
||||||
try {
|
try {
|
||||||
await loadHistoryOnly()
|
await loadHistoryOnly()
|
||||||
const { items } = await fetchPagePerPage<ServerApiItem>('/servers/', {}, 1, -1)
|
const { items } = await fetchPagePerPage<ServerApiItem>(
|
||||||
|
'/servers/',
|
||||||
|
{ sort_by: 'created_at', sort_order: 'desc' },
|
||||||
|
1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
servers.value = items.map(toTerminalServerItem)
|
servers.value = items.map(toTerminalServerItem)
|
||||||
} catch {
|
} catch {
|
||||||
servers.value = []
|
servers.value = []
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
|
|||||||
const categoryFilter = ref('')
|
const categoryFilter = ref('')
|
||||||
/** null = all; true = online only; false = offline only */
|
/** null = all; true = online only; false = offline only */
|
||||||
const isOnlineFilter = ref<boolean | null>(null)
|
const isOnlineFilter = ref<boolean | null>(null)
|
||||||
const sortBy = ref<TableSortItem[]>([{ key: 'status', order: 'asc' }])
|
const sortBy = ref<TableSortItem[]>([{ key: 'created_at', order: 'desc' }])
|
||||||
|
|
||||||
function listQueryParams(): Record<string, unknown> {
|
function listQueryParams(): Record<string, unknown> {
|
||||||
const q = String(search.value ?? '').trim()
|
const q = String(search.value ?? '').trim()
|
||||||
|
|||||||
@@ -115,8 +115,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, watch, inject, nextTick, type Ref } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, inject, nextTick, type Ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import { useTerminalSessions } from '@/composables/terminal/useTerminalSessions'
|
import { useTerminalSessions } from '@/composables/terminal/useTerminalSessions'
|
||||||
@@ -131,6 +132,7 @@ import TerminalContextMenu from '@/components/terminal/TerminalContextMenu.vue'
|
|||||||
defineOptions({ name: 'TerminalPage' })
|
defineOptions({ name: 'TerminalPage' })
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
const snackbar = useSnackbar()
|
const snackbar = useSnackbar()
|
||||||
const nexusDrawer = inject<Ref<boolean>>('nexusDrawer', ref(true))
|
const nexusDrawer = inject<Ref<boolean>>('nexusDrawer', ref(true))
|
||||||
|
|
||||||
@@ -303,20 +305,50 @@ function onTermMenuKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function attachTerminalKeydown() {
|
||||||
document.addEventListener('keydown', onGlobalKeydown)
|
document.addEventListener('keydown', onGlobalKeydown)
|
||||||
document.addEventListener('keydown', onTermMenuKeydown)
|
document.addEventListener('keydown', onTermMenuKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachTerminalKeydown() {
|
||||||
|
document.removeEventListener('keydown', onGlobalKeydown)
|
||||||
|
document.removeEventListener('keydown', onTermMenuKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
await loadServers()
|
await loadServers()
|
||||||
await loadQuickCmds()
|
await loadQuickCmds()
|
||||||
await applyRouteQuery()
|
await applyRouteQuery()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
attachTerminalKeydown()
|
||||||
|
showTermMenu.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
refitAllTerminals()
|
||||||
|
setTimeout(refitAllTerminals, 220)
|
||||||
|
const s = activeSession.value
|
||||||
|
if (s) getNative(s.id)?.term?.focus()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
detachTerminalKeydown()
|
||||||
|
showTermMenu.value = false
|
||||||
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', onGlobalKeydown)
|
detachTerminalKeydown()
|
||||||
document.removeEventListener('keydown', onTermMenuKeydown)
|
|
||||||
showTermMenu.value = false
|
showTermMenu.value = false
|
||||||
disposeAllSessions()
|
disposeAllSessions()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => auth.isLoggedIn,
|
||||||
|
(loggedIn) => {
|
||||||
|
if (!loggedIn) disposeAllSessions()
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const SERVER_COLUMN_TO_API: Record<string, string> = {
|
|||||||
agent: 'agent_version',
|
agent: 'agent_version',
|
||||||
last_heartbeat: 'heartbeat',
|
last_heartbeat: 'heartbeat',
|
||||||
heartbeat: 'heartbeat',
|
heartbeat: 'heartbeat',
|
||||||
|
created_at: 'created_at',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toServerApiSort(sortBy: TableSortItem[]): { sort_by?: string; sort_order?: 'asc' | 'desc' } {
|
export function toServerApiSort(sortBy: TableSortItem[]): { sort_by?: string; sort_order?: 'asc' | 'desc' } {
|
||||||
|
|||||||
@@ -64,9 +64,23 @@ _SORT_COLUMNS = {
|
|||||||
"agent_version": Server.agent_version,
|
"agent_version": Server.agent_version,
|
||||||
"category": Server.category,
|
"category": Server.category,
|
||||||
"target_path": Server.target_path,
|
"target_path": Server.target_path,
|
||||||
|
"created_at": Server.created_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _server_list_order(sort_by: Optional[str], sort_order: Optional[str]):
|
||||||
|
"""Default: newest created first."""
|
||||||
|
if not sort_by:
|
||||||
|
return desc(Server.created_at), desc(Server.id)
|
||||||
|
sort_col = _SORT_COLUMNS.get(sort_by)
|
||||||
|
if sort_col is None:
|
||||||
|
return desc(Server.created_at), desc(Server.id)
|
||||||
|
reverse = sort_order == "desc"
|
||||||
|
primary = desc(sort_col) if reverse else asc(sort_col)
|
||||||
|
secondary = desc(Server.id) if reverse else asc(Server.id)
|
||||||
|
return primary, secondary
|
||||||
|
|
||||||
|
|
||||||
class ServerRepositoryImpl:
|
class ServerRepositoryImpl:
|
||||||
"""Async SQLAlchemy implementation of Server data access"""
|
"""Async SQLAlchemy implementation of Server data access"""
|
||||||
|
|
||||||
@@ -141,10 +155,8 @@ class ServerRepositoryImpl:
|
|||||||
for f in filters:
|
for f in filters:
|
||||||
data_query = data_query.where(f)
|
data_query = data_query.where(f)
|
||||||
|
|
||||||
# Sorting (whitelist-validated)
|
primary, secondary = _server_list_order(sort_by, sort_order)
|
||||||
sort_col = _SORT_COLUMNS.get(sort_by, Server.id)
|
data_query = data_query.order_by(primary, secondary)
|
||||||
order_func = desc if sort_order == "desc" else asc
|
|
||||||
data_query = data_query.order_by(order_func(sort_col), Server.id)
|
|
||||||
data_query = data_query.offset(offset).limit(fetch_limit)
|
data_query = data_query.offset(offset).limit(fetch_limit)
|
||||||
result = await self.session.execute(data_query)
|
result = await self.session.execute(data_query)
|
||||||
servers = list(result.scalars().all())
|
servers = list(result.scalars().all())
|
||||||
@@ -188,7 +200,7 @@ class ServerRepositoryImpl:
|
|||||||
data_query = select(Server)
|
data_query = select(Server)
|
||||||
for f in filters:
|
for f in filters:
|
||||||
data_query = data_query.where(f)
|
data_query = data_query.where(f)
|
||||||
data_query = data_query.order_by(Server.id).limit(max_rows)
|
data_query = data_query.order_by(desc(Server.created_at), desc(Server.id)).limit(max_rows)
|
||||||
result = await self.session.execute(data_query)
|
result = await self.session.execute(data_query)
|
||||||
servers = list(result.scalars().all())
|
servers = list(result.scalars().all())
|
||||||
return servers, total
|
return servers, total
|
||||||
|
|||||||
Reference in New Issue
Block a user