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'
|
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
|
||||||
|
|
||||||
const MAX_TABS = 10
|
const MAX_TABS = 10
|
||||||
const TERMINAL_SEARCH_HISTORY = 10
|
|
||||||
|
|
||||||
export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -49,10 +48,8 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
|||||||
const serversLoading = ref(false)
|
const serversLoading = ref(false)
|
||||||
const serverSearch = ref('')
|
const serverSearch = ref('')
|
||||||
const quickCmds = ref<QuickCmdItem[]>([])
|
const quickCmds = ref<QuickCmdItem[]>([])
|
||||||
const { items: serverSearchHistory, loadHistoryOnly, record: recordServerSearch } =
|
const { items: terminalSearchHistory, loadHistoryOnly, record: recordTerminalSearch } =
|
||||||
useServerSearchHistory()
|
useServerSearchHistory('terminal')
|
||||||
|
|
||||||
const terminalSearchHistory = computed(() => serverSearchHistory.value.slice(0, TERMINAL_SEARCH_HISTORY))
|
|
||||||
|
|
||||||
const activeSession = computed(
|
const activeSession = computed(
|
||||||
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
|
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
|
||||||
@@ -726,19 +723,19 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitServerSearch() {
|
function commitServerSearch() {
|
||||||
void recordServerSearch(serverSearch.value)
|
void recordTerminalSearch(serverSearch.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerSearchUpdate(val: string | null) {
|
function onServerSearchUpdate(val: string | null) {
|
||||||
if (val == null || val === '') {
|
if (val == null || val === '') {
|
||||||
serverSearch.value = ''
|
serverSearch.value = ''
|
||||||
void recordServerSearch('')
|
void recordTerminalSearch('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const picked = String(val).trim()
|
const picked = String(val).trim()
|
||||||
serverSearch.value = picked
|
serverSearch.value = picked
|
||||||
if (serverSearchHistory.value.includes(picked)) {
|
if (terminalSearchHistory.value.includes(picked)) {
|
||||||
void recordServerSearch(picked)
|
void recordTerminalSearch(picked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,11 +34,12 @@ function clearLegacyLocal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Servers / push page search history — persisted per admin in MySQL. */
|
/** 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> = {
|
const HISTORY_ENDPOINT: Record<ServerSearchHistoryScope, string> = {
|
||||||
servers: '/servers/search-history',
|
servers: '/servers/search-history',
|
||||||
push: '/servers/push-search-history',
|
push: '/servers/push-search-history',
|
||||||
|
terminal: '/servers/terminal-search-history',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-admin search history in MySQL (servers or push scope). */
|
/** Per-admin search history in MySQL (servers or push scope). */
|
||||||
|
|||||||
@@ -399,6 +399,61 @@ async def update_push_search_history(
|
|||||||
return state
|
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)
|
@router.get("/logs", response_model=dict)
|
||||||
async def get_all_sync_logs(
|
async def get_all_sync_logs(
|
||||||
server_id: Optional[int] = Query(None, description="Filter by server ID"),
|
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_SERVER_SEARCH_HISTORY = 12
|
||||||
MAX_PUSH_SEARCH_HISTORY = 5
|
MAX_PUSH_SEARCH_HISTORY = 5
|
||||||
|
MAX_TERMINAL_SEARCH_HISTORY = 10
|
||||||
MAX_SERVER_SEARCH_QUERY_LEN = 200
|
MAX_SERVER_SEARCH_QUERY_LEN = 200
|
||||||
SERVERS_SEARCH_CONTEXT = "servers_search"
|
SERVERS_SEARCH_CONTEXT = "servers_search"
|
||||||
PUSH_SEARCH_CONTEXT = "push_search"
|
PUSH_SEARCH_CONTEXT = "push_search"
|
||||||
|
TERMINAL_SEARCH_CONTEXT = "terminal_search"
|
||||||
|
|
||||||
|
|
||||||
def normalize_search_query(query: str | None) -> str:
|
def normalize_search_query(query: str | None) -> str:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
from server.utils.server_search_history import (
|
from server.utils.server_search_history import (
|
||||||
MAX_PUSH_SEARCH_HISTORY,
|
MAX_PUSH_SEARCH_HISTORY,
|
||||||
MAX_SERVER_SEARCH_HISTORY,
|
MAX_SERVER_SEARCH_HISTORY,
|
||||||
|
MAX_TERMINAL_SEARCH_HISTORY,
|
||||||
apply_search_record,
|
apply_search_record,
|
||||||
parse_search_state,
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_search_history_api_roundtrip(integration_env, api_client, auth_headers):
|
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)
|
r = await api_client.get("/api/servers/search-history", headers=auth_headers)
|
||||||
|
|||||||
Reference in New Issue
Block a user