fix(ui): 服务器/终端/推送搜索历史互不共享
终端独立 terminal_search 上下文(10条),与 servers_search、push_search 隔离。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# 审计 — 各页搜索历史互不共享
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-12-search-history-isolated-scopes.md`
|
||||
|
||||
## 范围(文件清单)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/utils/server_search_history.py` | terminal 上下文,上限 10 |
|
||||
| `server/api/servers.py` | terminal-search-history API |
|
||||
| `frontend/src/composables/useServerSearchHistory.ts` | terminal scope |
|
||||
| `frontend/src/composables/terminal/useTerminalSessions.ts` | 独立历史 |
|
||||
| `tests/test_server_search_history.py` | 隔离与 API 测试 |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 安全 | PASS — admin 隔离 |
|
||||
| H2 | 无静默吞错 | PASS |
|
||||
| H3 | 无新密钥 | PASS |
|
||||
| H4 | CUD | N/A |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | PASS | admin_ui_preferences 分 context |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] type-check
|
||||
- [x] pytest test_server_search_history
|
||||
- [ ] 生产三页互不串历史验证
|
||||
|
||||
## 验证
|
||||
|
||||
servers / terminal / push 各搜一词,下拉互不可见。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 各页搜索历史互不共享
|
||||
|
||||
**日期**:2026-06-12
|
||||
|
||||
## 动机
|
||||
|
||||
服务器页、终端页、推送页的搜索历史应各自独立,避免在一页搜索后其他页面下拉出现相同记录。
|
||||
|
||||
## 变更
|
||||
|
||||
- **终端**:`GET/PUT /api/servers/terminal-search-history`,上下文 `terminal_search`,上限 10 条
|
||||
- **推送**:已有 `push_search`(5 条),不变
|
||||
- **服务器页**:仍为 `servers_search`(12 条),不变
|
||||
- 前端 `useServerSearchHistory('terminal')` 替代原先与服务器页共用的默认 scope
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/utils/server_search_history.py`
|
||||
- `server/api/servers.py`
|
||||
- `frontend/src/composables/useServerSearchHistory.ts`
|
||||
- `frontend/src/composables/terminal/useTerminalSessions.ts`
|
||||
- `tests/test_server_search_history.py`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npm run type-check
|
||||
.venv/bin/pytest tests/test_server_search_history.py -q
|
||||
```
|
||||
|
||||
三页分别搜索后,各自下拉仅见本页历史。
|
||||
|
||||
## 迁移
|
||||
|
||||
无新表;终端首次写入创建 `terminal_search` 偏好。原写入 `servers_search` 的终端记录不再显示(需用户在终端重新搜索)。
|
||||
@@ -32,7 +32,6 @@ import type { ServerApiItem } from '@/types/api'
|
||||
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
|
||||
|
||||
const MAX_TABS = 10
|
||||
const TERMINAL_SEARCH_HISTORY = 10
|
||||
|
||||
export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
||||
const route = useRoute()
|
||||
@@ -49,10 +48,8 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
||||
const serversLoading = ref(false)
|
||||
const serverSearch = ref('')
|
||||
const quickCmds = ref<QuickCmdItem[]>([])
|
||||
const { items: serverSearchHistory, loadHistoryOnly, record: recordServerSearch } =
|
||||
useServerSearchHistory()
|
||||
|
||||
const terminalSearchHistory = computed(() => serverSearchHistory.value.slice(0, TERMINAL_SEARCH_HISTORY))
|
||||
const { items: terminalSearchHistory, loadHistoryOnly, record: recordTerminalSearch } =
|
||||
useServerSearchHistory('terminal')
|
||||
|
||||
const activeSession = computed(
|
||||
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
|
||||
@@ -726,19 +723,19 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
||||
}
|
||||
|
||||
function commitServerSearch() {
|
||||
void recordServerSearch(serverSearch.value)
|
||||
void recordTerminalSearch(serverSearch.value)
|
||||
}
|
||||
|
||||
function onServerSearchUpdate(val: string | null) {
|
||||
if (val == null || val === '') {
|
||||
serverSearch.value = ''
|
||||
void recordServerSearch('')
|
||||
void recordTerminalSearch('')
|
||||
return
|
||||
}
|
||||
const picked = String(val).trim()
|
||||
serverSearch.value = picked
|
||||
if (serverSearchHistory.value.includes(picked)) {
|
||||
void recordServerSearch(picked)
|
||||
if (terminalSearchHistory.value.includes(picked)) {
|
||||
void recordTerminalSearch(picked)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,12 @@ function clearLegacyLocal() {
|
||||
}
|
||||
|
||||
/** Servers / push page search history — persisted per admin in MySQL. */
|
||||
export type ServerSearchHistoryScope = 'servers' | 'push'
|
||||
export type ServerSearchHistoryScope = 'servers' | 'push' | 'terminal'
|
||||
|
||||
const HISTORY_ENDPOINT: Record<ServerSearchHistoryScope, string> = {
|
||||
servers: '/servers/search-history',
|
||||
push: '/servers/push-search-history',
|
||||
terminal: '/servers/terminal-search-history',
|
||||
}
|
||||
|
||||
/** Per-admin search history in MySQL (servers or push scope). */
|
||||
|
||||
@@ -399,6 +399,61 @@ async def update_push_search_history(
|
||||
return state
|
||||
|
||||
|
||||
@router.get("/terminal-search-history", response_model=dict)
|
||||
async def get_terminal_search_history(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return terminal page server search history (max 10) for the current admin."""
|
||||
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
|
||||
from server.utils.server_search_history import (
|
||||
MAX_TERMINAL_SEARCH_HISTORY,
|
||||
TERMINAL_SEARCH_CONTEXT,
|
||||
parse_search_state,
|
||||
)
|
||||
|
||||
repo = AdminUiPreferenceRepositoryImpl(db)
|
||||
raw = await repo.get(admin.id, TERMINAL_SEARCH_CONTEXT)
|
||||
return parse_search_state(raw, max_history=MAX_TERMINAL_SEARCH_HISTORY)
|
||||
|
||||
|
||||
@router.put("/terminal-search-history", response_model=dict)
|
||||
async def update_terminal_search_history(
|
||||
payload: ServerSearchHistoryUpdate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Record a terminal page search query or replace full history (max 10)."""
|
||||
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
|
||||
from server.utils.server_search_history import (
|
||||
MAX_TERMINAL_SEARCH_HISTORY,
|
||||
TERMINAL_SEARCH_CONTEXT,
|
||||
apply_search_record,
|
||||
apply_search_replace,
|
||||
parse_search_state,
|
||||
)
|
||||
|
||||
repo = AdminUiPreferenceRepositoryImpl(db)
|
||||
state = parse_search_state(
|
||||
await repo.get(admin.id, TERMINAL_SEARCH_CONTEXT),
|
||||
max_history=MAX_TERMINAL_SEARCH_HISTORY,
|
||||
)
|
||||
if payload.history is not None:
|
||||
state = apply_search_replace(
|
||||
history=payload.history,
|
||||
last=payload.last,
|
||||
max_history=MAX_TERMINAL_SEARCH_HISTORY,
|
||||
)
|
||||
else:
|
||||
state = apply_search_record(
|
||||
state,
|
||||
payload.query or "",
|
||||
max_history=MAX_TERMINAL_SEARCH_HISTORY,
|
||||
)
|
||||
await repo.upsert(admin.id, TERMINAL_SEARCH_CONTEXT, state)
|
||||
return state
|
||||
|
||||
|
||||
@router.get("/logs", response_model=dict)
|
||||
async def get_all_sync_logs(
|
||||
server_id: Optional[int] = Query(None, description="Filter by server ID"),
|
||||
|
||||
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
MAX_SERVER_SEARCH_HISTORY = 12
|
||||
MAX_PUSH_SEARCH_HISTORY = 5
|
||||
MAX_TERMINAL_SEARCH_HISTORY = 10
|
||||
MAX_SERVER_SEARCH_QUERY_LEN = 200
|
||||
SERVERS_SEARCH_CONTEXT = "servers_search"
|
||||
PUSH_SEARCH_CONTEXT = "push_search"
|
||||
TERMINAL_SEARCH_CONTEXT = "terminal_search"
|
||||
|
||||
|
||||
def normalize_search_query(query: str | None) -> str:
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from server.utils.server_search_history import (
|
||||
MAX_PUSH_SEARCH_HISTORY,
|
||||
MAX_SERVER_SEARCH_HISTORY,
|
||||
MAX_TERMINAL_SEARCH_HISTORY,
|
||||
apply_search_record,
|
||||
parse_search_state,
|
||||
)
|
||||
@@ -68,6 +69,52 @@ async def test_push_search_history_api_roundtrip(integration_env, api_client, au
|
||||
assert body["history"][0] == "push-5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_search_history_api_roundtrip(integration_env, api_client, auth_headers):
|
||||
r = await api_client.get("/api/servers/terminal-search-history", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"history": [], "last": None}
|
||||
|
||||
for i in range(12):
|
||||
r = await api_client.put(
|
||||
"/api/servers/terminal-search-history",
|
||||
headers=auth_headers,
|
||||
json={"query": f"term-{i}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
body = r.json()
|
||||
assert len(body["history"]) == MAX_TERMINAL_SEARCH_HISTORY
|
||||
assert body["history"][0] == "term-11"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_scopes_do_not_share(integration_env, api_client, auth_headers):
|
||||
await api_client.put(
|
||||
"/api/servers/search-history",
|
||||
headers=auth_headers,
|
||||
json={"query": "servers-only"},
|
||||
)
|
||||
await api_client.put(
|
||||
"/api/servers/terminal-search-history",
|
||||
headers=auth_headers,
|
||||
json={"query": "terminal-only"},
|
||||
)
|
||||
await api_client.put(
|
||||
"/api/servers/push-search-history",
|
||||
headers=auth_headers,
|
||||
json={"query": "push-only"},
|
||||
)
|
||||
|
||||
servers = (await api_client.get("/api/servers/search-history", headers=auth_headers)).json()
|
||||
terminal = (await api_client.get("/api/servers/terminal-search-history", headers=auth_headers)).json()
|
||||
push = (await api_client.get("/api/servers/push-search-history", headers=auth_headers)).json()
|
||||
|
||||
assert servers["history"] == ["servers-only"]
|
||||
assert terminal["history"] == ["terminal-only"]
|
||||
assert push["history"] == ["push-only"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_history_api_roundtrip(integration_env, api_client, auth_headers):
|
||||
r = await api_client.get("/api/servers/search-history", headers=auth_headers)
|
||||
|
||||
Reference in New Issue
Block a user