08a0157c95
调度页执行周期可视化/单次执行/分类选机/推送源对齐;登录 IP 门控与无缝导航; 服务器批量后台任务、执行记录、凭据合并、各页激活刷新与错误提示修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
10 KiB
10 KiB
登录 IP 门禁 + SPA 无感切换 — 设计文档
日期: 2026-06-08
状态: 待评审
关联: 功能指南 §5.6 登录 IP 白名单 · 前端 Hash 路由 14 页
1. 背景与目标
1.1 用户诉求
| 诉求 | 说明 |
|---|---|
| IP 白名单门禁 | 白名单开启且当前 IP 不在名单时,不展示登录表单(用户名/密码/TOTP),避免误导用户尝试登录后才看到拒绝 |
| 无感切换 | 登录成功进入后台、以及左侧菜单在各功能页之间切换时,减少「整页白屏 / 重新加载 / 闪烁」感 |
1.2 非目标(本设计不做)
- 不改 Agent API Key、安装向导流程
- 不做 SSR / 微前端拆分
- 不要求所有 14 页零请求(实时运维数据仍需刷新,但应 静默、局部)
2. 现状分析
2.1 IP 白名单
sequenceDiagram
participant Browser
participant LoginPage
participant API as POST_auth_login
Browser->>LoginPage: 打开 #/login
LoginPage->>LoginPage: 展示完整表单
Browser->>API: 提交账号密码
API->>API: auth_service 校验 IP
API-->>Browser: 403 ip_blocked + 当前 IP
- 校验位置:
auth_service.login(LOGIN_ALLOWLIST_ENABLED+ 手动/订阅 IP) - 客户端 IP:
get_client_ip(反代信任头) - 缺口:登录页无预检;非白名单用户仍看到表单,体验差且暴露登录入口
2.2 菜单切换「像刷新」的原因
| 因素 | 位置 | 影响 |
|---|---|---|
router-view 强制 remount |
App.vue :key="route.fullPath" |
一切换路由(含 query)销毁组件、重建 DOM |
每页 onMounted 拉数 |
各 *Page.vue |
每次进入白屏 loading |
| 路由懒加载 | router/index.ts import() |
首次进入某页需下载 chunk |
| 无共享缓存 | 无 Pinia 页面缓存层 | 回到上一页重复请求 |
| 登录后跳转 | LoginPage router.push(redirect) |
整页从 login 布局切到 app 布局,侧栏/顶栏一次性挂载 |
终端页、脚本看板等已有 WebSocket / 自动刷新,不适合与列表页同一套 keep-alive 策略,需单独约定。
3. 方案 A — 登录 IP 门禁(小工程,优先)
3.1 API:公开预检端点
GET /api/auth/login-access(无需 JWT,加入 PUBLIC_EXACT_PATHS)
响应示例:
{
"allowlist_enabled": true,
"allowed": false,
"client_ip": "203.0.113.10",
"message": "当前 IP 不在登录白名单中,请联系管理员"
}
| 字段 | 说明 |
|---|---|
allowlist_enabled |
与 LOGIN_ALLOWLIST_ENABLED 一致 |
allowed |
未开启白名单 → 恒 true;开启后按 _ip_in_allowlist |
client_ip |
get_client_ip(request),与登录审计一致 |
message |
仅 allowed=false 时返回,不泄露白名单列表 |
实现要点:
- 复用
_ip_in_allowlist,抽到server/utils/login_allowlist.py供 auth_service 与 auth 路由共用 - 不写入登录失败计数(仅浏览门禁页,未提交凭证)
- 可选审计:
login_access_denied(低频,仅预检allowed=false)— 默认 关闭,避免扫描噪音;若开启需限流
安全:
- 不返回白名单条目、订阅 URL
- 与
POST /login相同 IP 解析逻辑,避免「预检通过、登录拒绝」不一致 - 限流:同 IP 60s 内最多 30 次(复用现有 middleware 或轻量 Redis 计数)
3.2 前端:LoginPage 门禁 UI
LoginPage.vue 挂载流程:
stateDiagram-v2
[*] --> Checking: onMounted
Checking --> ShowForm: allowed_or_disabled
Checking --> Blocked: not_allowed
ShowForm --> [*]: 现有表单
Blocked --> [*]: 静态提示卡_无输入框
Blocked 态 UI(保留壁纸背景,与现有一致):
- 标题:「无法访问管理后台」
- 文案:
message+ 显示client_ip - 无用户名/密码/TOTP/登录按钮
- 可选:「返回」无意义,不提供跳转服务器列表(未登录)
ShowForm 态:与现网一致;allowlist_enabled=true 且 allowed=true 时表单正常。
加载态:预检完成前显示 v-progress-circular,避免表单闪现(FOUC)。
3.3 验收(A)
- 白名单关闭:预检
allowed=true,表单正常 - 白名单开启 + IP 不在名单:仅提示卡,无登录输入框
- 白名单开启 + IP 在名单:表单正常,登录成功
- 反代后
client_ip与登录失败提示 IP 一致 - 单测:
test_login_access.py覆盖开/关/命中/未命中
4. 方案 B — SPA 无感切换(大工程,分阶段)
4.1 设计原则
- 壳不动、内容换:
v-app-bar+v-navigation-drawer常驻,只换router-view区域 - 先显后刷:有缓存则立即展示,后台静默
silent刷新(与现有usePageAutoRefresh一致) - 重页隔离:终端(WebSocket/xterm)、安装向导不参与 keep-alive
- 可度量:首屏切换 < 100ms 感知(无全屏 loading);数据刷新不阻断操作
4.2 分阶段路线图
flowchart LR
P0[P0_登录门禁] --> P1[P1_路由壳优化]
P1 --> P2[P2_keep_alive]
P2 --> P3[P3_数据缓存层]
P3 --> P4[P4_预取与骨架屏]
P0 — 登录 IP 门禁(见方案 A)
工作量:约 0.5~1 人日。
P1 — 路由与登录过渡(小)
| 项 | 做法 |
|---|---|
| 去掉过激 remount | App.vue 将 :key="route.fullPath" 改为 :key="route.name" 或移除 key;query 变化由页内 watch(route.query) 处理 |
| 登录成功过渡 | router.replace(redirect);auth.login 成功后预 import('@/pages/DashboardPage.vue') |
| 路由守卫 | beforeEach 已 tryRestoreSession;登录页在 P0 预检后再渲染表单 |
风险:ScriptRunDetail 与 /script-runs 同组件 — 用 route.name 作 key 时详情与列表可能共享实例,需在 ScriptRunsPage 内 watch(() => route.params.id) 处理。
P2 — Keep-alive 页面缓存(中)
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedPageNames" :max="10">
<component :is="Component" :key="route.name" />
</keep-alive>
</router-view>
| 策略 | 页面 |
|---|---|
| 缓存 | Dashboard, Servers, Scripts, ScriptRuns, Push, Files, Credentials, Schedules, Retries, Commands, Alerts, Audit, Settings |
| 不缓存 | Terminal(xterm 会话), Login |
| 激活钩子 | 缓存页实现 onActivated → loadXxx({ silent: true }) 替代仅 onMounted |
需为各页 defineOptions({ name: 'ServersPage' }) 与 include 字符串对齐。
P3 — Pinia 数据缓存层(大)
新增 composable useCachedQuery(key, fetcher, options):
- 内存 TTL(如 30s)+
staleWhileRevalidate - 多页共享:
useServerStatsStore、useServerCategoriesStore(部分已有,需统一) - 列表页离开再回来:先展示 cache,再静默刷新
优先接入:仪表盘统计、服务器分类 Chip、侧栏角标(在线数/告警数)。
P4 — 预取与骨架屏(中)
- 侧栏
v-list-item@mouseenter→ 预加载 route chunk + 预取关键 API(debounce 200ms) - 各页
loading时显示 骨架屏(v-skeleton-loader)替代空白v-main - 全局 不 使用全屏 blocking overlay
4.3 登录成功「无感」定义
| 阶段 | 体验 |
|---|---|
| 当前 | Login 全屏 → 跳转后 App 栏 + 侧栏 + Dashboard 依次出现 + 表格 loading |
| P1 后 | 同一 v-app 下 isLoggedIn 切换;侧栏/顶栏 CSS 过渡 150ms |
| P2+P3 后 | Dashboard 若曾访问过则瞬间展示缓存数字,后台刷新 |
4.4 不宜无感化的场景
- 终端:必须保留独立布局与会话生命周期
- 强制登出 / token 失效:仍跳转
#/login并清缓存 - 部署后 chunk 失效:保留现有
router.onError一次 reload 策略
5. 安全与性能约束
| 项 | 约束 |
|---|---|
| 预检 API | 不泄露白名单内容;限流 |
| keep-alive | 登出时 clearCache() 或重置 Pinia,防下一用户见上一用户数据 |
| 缓存 TTL | 运维数据不宜过长(建议 30~60s);敏感页(凭据)可缩短或不缓存列表明文 |
| 终端 | 禁止 keep-alive;避免 WebSocket 与 DOM 泄漏 |
6. 涉及文件(预估)
P0
| 层 | 文件 |
|---|---|
| 后端 | server/utils/login_allowlist.py(新)、server/api/auth.py、server/api/auth_jwt.py、server/application/services/auth_service.py |
| 前端 | frontend/src/pages/LoginPage.vue |
| 测试 | tests/test_login_access.py |
P1~P4
| 层 | 文件 |
|---|---|
| 壳 | frontend/src/App.vue、frontend/src/router/index.ts |
| 缓存 | frontend/src/composables/useCachedQuery.ts(新)、各 *Page.vue、Pinia stores |
| 文档 | 本文件 + 各阶段 changelog |
7. 验收标准(整体)
P0 必须
- 非白名单 IP 看不到登录框
- 白名单 IP / 关闭白名单行为与现网一致
P1~P2 建议
- 菜单切换 5 个高频页(仪表盘/服务器/脚本/推送/设置)无白屏 remount
- 返回上一页表格滚动位置可选保留(keep-alive 自带)
P3~P4 可选增强
- 重复进入服务器列表 30s 内无全屏 loading
- 侧栏悬停预取后首次进入该页 chunk 等待 < 200ms(弱网除外)
8. 推荐实施顺序
- 先做 P0(独立、低风险、用户明确诉求)→ 可单独部署
- P1 与终端/脚本看板小修复并行时注意
route.namekey 回归 - P2 逐页启用 keep-alive +
onActivated,每页验证 - P3/P4 按仪表盘 → 服务器列表 → 其余页推广
9. 回滚
| 阶段 | 回滚 |
|---|---|
| P0 | 删除 login-access 端点;LoginPage 恢复常显表单 |
| P1 | 恢复 route.fullPath key |
| P2 | 移除 keep-alive 包装 |
| P3 | 停用 cached stores,页内仍 onMounted 拉数 |