mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 21:10:46 +08:00
Let users pick and order their workbench module tiles.
Per-user layout lives in the new app_user.prefs JSON column, read via /v1/auth/me and written via PUT /v1/auth/me/prefs/workbench. The workbench gets an in-place edit mode (gear -> drag to reorder, x to remove, tray to add back, reset/done). Reset stores empty prefs so registry changes still apply; ids of modules the user can't currently see are kept on save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
313706595d
commit
2307f19779
16 changed files with 906 additions and 23 deletions
52
alembic/versions/20261013_user_prefs.py
Normal file
52
alembic/versions/20261013_user_prefs.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""Add app_user.prefs JSON for per-user UI preferences.
|
||||||
|
|
||||||
|
Revision ID: 20261013_user_prefs
|
||||||
|
Revises: 20260922_biz_state_queued
|
||||||
|
Create Date: 2026-10-13
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20261013_user_prefs"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "20260922_biz_state_queued"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
dialect = str(getattr(bind.dialect, "name", "") or "").lower()
|
||||||
|
if dialect.startswith("postgres"):
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS prefs JSON DEFAULT '{}'::json NOT NULL"
|
||||||
|
)
|
||||||
|
# Ensure NOT NULL + default for brownfield rows that already had a nullable column.
|
||||||
|
op.execute("UPDATE app_user SET prefs = '{}'::json WHERE prefs IS NULL")
|
||||||
|
op.execute("ALTER TABLE app_user ALTER COLUMN prefs SET DEFAULT '{}'::json")
|
||||||
|
op.execute("ALTER TABLE app_user ALTER COLUMN prefs SET NOT NULL")
|
||||||
|
else:
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if "app_user" in insp.get_table_names():
|
||||||
|
cols = {c["name"] for c in insp.get_columns("app_user")}
|
||||||
|
if "prefs" not in cols:
|
||||||
|
op.add_column(
|
||||||
|
"app_user",
|
||||||
|
sa.Column("prefs", sa.JSON(), nullable=False, server_default="{}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
dialect = str(getattr(bind.dialect, "name", "") or "").lower()
|
||||||
|
if dialect.startswith("postgres"):
|
||||||
|
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS prefs")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
op.drop_column("app_user", "prefs")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
112
docs/reviews/workbench-custom-modules-plan-2026-10-13.md
Normal file
112
docs/reviews/workbench-custom-modules-plan-2026-10-13.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# 工作台功能卡片自定义 — 实现方案(2026-10-13)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
工作台功能区(UME 同步、网元管理、网络管理、拓扑、WebCRT…)以后会越来越多,每个人关心的不一样。
|
||||||
|
让每个用户自己选择:**显示哪些卡片、按什么顺序**;设置跟着账号走(换电脑/换浏览器仍生效)。
|
||||||
|
|
||||||
|
## 现状
|
||||||
|
|
||||||
|
- 卡片注册表:`web/src/config/modules.ts:33` `MODULES`,按 `section` 分 `monitoring / operations / system`。
|
||||||
|
- 过滤逻辑:`web/src/features/workbench/WorkbenchPage.tsx:323` `visibleBySection` —— 只按权限(`adminOnly` / `requiredScope`)和 `workbenchHidden` 过滤。
|
||||||
|
- 渲染:`web/src/features/workbench/components/WorkbenchModules.tsx:23`,operations 是大磁贴网格,system 是底部一排小链接。
|
||||||
|
- 后端无任何“用户偏好”存储;`AppUser`(`netx_api/models/auth.py:13`)只有账号字段。
|
||||||
|
|
||||||
|
## 设计决定
|
||||||
|
|
||||||
|
| 项 | 决定 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| 可自定义范围 | 只做 **operations 区的磁贴**(含 monitoring 合并进来的 UME 同步)。system 区链接行保持固定 | 系统管理入口少且是管理类,隐藏后反而找不到;需求说的是“功能卡片” |
|
||||||
|
| 存储位置 | **服务端,按用户**:`app_user.prefs` JSON 列 | “每个人不一样”= 按账号;localStorage 只按浏览器,换机就丢 |
|
||||||
|
| 本地缓存 | 同时写一份 localStorage(key 带 user id),首屏先用缓存渲染,避免卡片闪烁 | 首屏体验 |
|
||||||
|
| 数据结构 | `{ order: string[], hidden: string[] }`,存的是“隐藏了哪些”而不是“显示哪些” | 以后新增模块默认对所有人出现,不需要每人手动开启 |
|
||||||
|
| 新模块默认隐藏 | `ModuleDefinition` 增加可选 `defaultHidden?: boolean`;不在 `order` 里的这类模块视为隐藏 | 给以后“小众功能”留开关 |
|
||||||
|
| 权限 | 没权限的模块永远不出现在卡片区,也不出现在“可添加”列表;偏好里残留的 id 静默忽略 | 偏好不能绕过权限 |
|
||||||
|
| 交互 | 卡片区右上角一个小齿轮 → 进入**原地编辑模式**:已显示卡片可拖拽排序、右上角 × 移除;下方出现“可添加”托盘(灰显卡片,点击加入);按钮只有「恢复默认」「完成」 | 和 iOS 主屏编辑一致,最少文案;不弹窗 |
|
||||||
|
| 拖拽实现 | HTML5 原生 drag & drop,不引入新依赖 | 卡片数量小(<20),原生够用;项目里没有 dnd 库 |
|
||||||
|
| 保存时机 | 点「完成」时一次 PUT;失败则 toast 并保留编辑状态 | 避免拖动过程中频繁请求 |
|
||||||
|
| 全部隐藏 | 允许,但功能区显示一个空态(只有齿轮 + 一句“添加功能”) | 不阻止用户,但要能找回 |
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
**B1 — `app_user.prefs` 列**
|
||||||
|
- `netx_api/models/auth.py:13` `AppUser` 加 `prefs: Mapped[dict] = mapped_column(_JsonType, default=dict)`。
|
||||||
|
- 新迁移 `alembic/versions/20261013_user_prefs.py`,`down_revision = "20260922_biz_state_queued"`,`add_column("app_user", sa.Column("prefs", JSON, nullable=False, server_default="{}"))`。
|
||||||
|
|
||||||
|
**B2 — 读写接口**(`netx_api/auth_router.py`,紧跟 `api_me` 之后,约 :273)
|
||||||
|
- `GET /v1/auth/me/prefs` → `{"prefs": {...}}`
|
||||||
|
- `PUT /v1/auth/me/prefs/{key}`,body 为该 key 的值;`key` 白名单,目前只允许 `workbench`。
|
||||||
|
- 校验 `workbench`:`{order: list[str], hidden: list[str]}`,每个列表 ≤ 64 项、每个 id ≤ 64 字符、只允许 `[a-z0-9-]`;其他字段丢弃。超限 422。
|
||||||
|
- 只改自己的偏好(`require_user`),不需要额外 scope;不写审计(纯 UI 偏好,避免刷屏)。
|
||||||
|
- `/v1/auth/me` 响应里顺带返回 `prefs`,前端登录后一次拿到,不多一次请求。
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
**F1 — 注册表扩展** `web/src/config/modules.ts`
|
||||||
|
- `ModuleDefinition` 加 `defaultHidden?: boolean`。
|
||||||
|
- 新增 `export function customizableModules()`:operations + monitoring 中非 `workbenchHidden` 的模块(即当前 `displaySections` 里 operations 那组的来源)。
|
||||||
|
|
||||||
|
**F2 — 偏好 hook** 新建 `web/src/features/workbench/useWorkbenchLayout.ts`
|
||||||
|
- 输入:权限过滤后的可用模块列表;输出:`{ shown: ModuleDefinition[], available: ModuleDefinition[], save(order, hidden), reset() }`。
|
||||||
|
- 解析规则(纯函数 `resolveLayout(all, prefs)`,单独导出便于测试):
|
||||||
|
1. 先按 `prefs.order` 排已知 id,丢弃不存在/无权限的 id;
|
||||||
|
2. 剩余模块按注册表顺序追加到末尾;
|
||||||
|
3. 在 `hidden` 中的、或 `defaultHidden` 且不在 `order` 中的,归入 `available`。
|
||||||
|
- 数据来源:`AuthContext` 的 `me.prefs.workbench` → 没有则 localStorage 缓存 `netx.wb.layout.<userId>` → 默认。
|
||||||
|
- 保存:PUT 成功后更新 AuthContext 中的 prefs 和 localStorage。localStorage 读写全部 try/catch。
|
||||||
|
|
||||||
|
**F3 — AuthContext** `web/src/features/auth/AuthContext.tsx:48`
|
||||||
|
- `refreshMe` / `login` 把响应里的 `prefs` 存进 state,并暴露 `prefs` 与 `setPrefs`。
|
||||||
|
|
||||||
|
**F4 — 编辑模式 UI** `web/src/features/workbench/components/WorkbenchModules.tsx`
|
||||||
|
- `WorkbenchPage.tsx:334` `displaySections` 的 operations 组改为用 `useWorkbenchLayout().shown`。
|
||||||
|
- 卡片区右上角齿轮按钮(`ConsoleIcon` 加一个 `settings` 图标),`aria-label` = “自定义功能区”。
|
||||||
|
- 编辑态:
|
||||||
|
- 磁贴轻微抖动或描边(遵循 glass 风格:**不发光**,只用 `--nx-line` 边框)、右上角 × 按钮;
|
||||||
|
- `draggable`,`onDragStart/onDragOver/onDrop` 重排本地草稿数组;
|
||||||
|
- 下方“可添加”托盘:灰显磁贴,点击加入末尾;托盘为空时不显示;
|
||||||
|
- 底部两个按钮:「恢复默认」(清空草稿为默认)、「完成」(保存);Esc = 放弃草稿退出。
|
||||||
|
- 编辑态下点击磁贴不打开模块。
|
||||||
|
- 非编辑态 shown 为空:显示空态 + “添加功能”按钮(直接进入编辑态)。
|
||||||
|
- 样式写在 `web/src/styles/pages/workbench.css`,颜色只用现有 token(蓝黑 200–215°,无紫色)。
|
||||||
|
|
||||||
|
**F5 — 文案** `web/src/i18n/locales/{zh,en}/workbench.ts`
|
||||||
|
- 新增:`customize`(自定义功能区)、`customizeDone`(完成)、`customizeReset`(恢复默认)、`customizeAvailable`(可添加)、`customizeEmpty`(添加功能)、`customizeSaveFailed`(保存失败)。只这些,不加说明文字。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
| ID | 场景 | 期望 |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 | 新用户首次登录 | 卡片与改动前完全一致(顺序、数量) |
|
||||||
|
| A2 | 隐藏「拓扑」、把「网元管理」拖到第一位,点完成 | 立即生效;刷新、换浏览器登录同一账号仍是该布局 |
|
||||||
|
| A3 | 用户 A 定制后,用户 B 登录 | B 不受影响 |
|
||||||
|
| A4 | 在 `MODULES` 新增一个模块 | 已定制过的用户自动在末尾看到它;若设了 `defaultHidden` 则出现在“可添加”里 |
|
||||||
|
| A5 | 偏好里包含无权限模块 id(如去掉用户 `ne:read` 后) | 不显示、不出现在可添加;不报错 |
|
||||||
|
| A6 | 全部移除后完成 | 显示空态,点“添加功能”可恢复 |
|
||||||
|
| A7 | 编辑中按 Esc / 保存失败(断网) | Esc 恢复原布局;失败提示并停留在编辑态 |
|
||||||
|
| A8 | `PUT /v1/auth/me/prefs/workbench` 传非法 key、超长列表、非法字符 | 404 / 422 |
|
||||||
|
| A9 | 视觉 | 编辑态无发光、无紫色,与现有 glass 风格一致 |
|
||||||
|
|
||||||
|
## 回归命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_auth.py tests/test_user_prefs.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm run lint && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- 新增 `tests/test_user_prefs.py`:覆盖 B2 的读写、白名单、校验、用户隔离(A3、A8)。
|
||||||
|
- `resolveLayout` 纯函数若项目有前端单测则补上(A1、A4、A5);没有则在验收时手测。
|
||||||
|
|
||||||
|
## 以后可扩展(本次不做)
|
||||||
|
|
||||||
|
- 同一机制用 `prefs.workbench.panels` 控制右侧任务栏、网元/拓扑/调度三张统计卡的显隐。
|
||||||
|
- 管理员给角色设默认布局(`role_default_prefs`),用户未定制时用角色默认。
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from .auth_cookies import clear_auth_cookies, read_refresh_cookie, set_auth_cookies
|
from .auth_cookies import clear_auth_cookies, read_refresh_cookie, set_auth_cookies
|
||||||
from .auth_deps import AuthContext, require_admin, require_user
|
from .auth_deps import AuthContext, require_admin, require_user
|
||||||
|
|
@ -36,6 +38,7 @@ from .auth_service import (
|
||||||
revoke_auth_sessions,
|
revoke_auth_sessions,
|
||||||
update_api_token,
|
update_api_token,
|
||||||
update_user,
|
update_user,
|
||||||
|
user_prefs,
|
||||||
user_public,
|
user_public,
|
||||||
write_audit,
|
write_audit,
|
||||||
)
|
)
|
||||||
|
|
@ -46,9 +49,14 @@ from .auth_rate_limit import (
|
||||||
)
|
)
|
||||||
from .client_ip import resolve_client_ip
|
from .client_ip import resolve_client_ip
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
|
from .models import AppUser
|
||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
_PREF_KEYS = frozenset({"workbench"})
|
||||||
|
_PREF_ID_RE = re.compile(r"^[a-z0-9-]{1,64}$")
|
||||||
|
_PREF_LIST_MAX = 64
|
||||||
|
|
||||||
|
|
||||||
def _client_meta(request: Request) -> tuple[str, str]:
|
def _client_meta(request: Request) -> tuple[str, str]:
|
||||||
ip = resolve_client_ip(request)
|
ip = resolve_client_ip(request)
|
||||||
|
|
@ -56,6 +64,34 @@ def _client_meta(request: Request) -> tuple[str, str]:
|
||||||
return ip, ua
|
return ip, ua
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_id_list(value: Any, *, field: str) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise HTTPException(status_code=422, detail=f"invalid_{field}")
|
||||||
|
if len(value) > _PREF_LIST_MAX:
|
||||||
|
raise HTTPException(status_code=422, detail=f"{field}_too_long")
|
||||||
|
out: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, str) or not _PREF_ID_RE.fullmatch(item):
|
||||||
|
raise HTTPException(status_code=422, detail=f"invalid_{field}_item")
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_workbench_prefs(value: Any) -> dict[str, list[str]]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise HTTPException(status_code=422, detail="invalid_workbench_prefs")
|
||||||
|
return {
|
||||||
|
"order": _validate_id_list(value.get("order", []), field="order"),
|
||||||
|
"hidden": _validate_id_list(value.get("hidden", []), field="hidden"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_pref_value(key: str, value: Any) -> Any:
|
||||||
|
if key == "workbench":
|
||||||
|
return _validate_workbench_prefs(value)
|
||||||
|
raise HTTPException(status_code=404, detail="unknown_pref_key")
|
||||||
|
|
||||||
|
|
||||||
def _token_response(response: Response, request: Request, out: dict[str, Any]) -> dict[str, Any]:
|
def _token_response(response: Response, request: Request, out: dict[str, Any]) -> dict[str, Any]:
|
||||||
access = str(out.get("access_token") or "")
|
access = str(out.get("access_token") or "")
|
||||||
refresh = str(out.get("refresh_token") or "")
|
refresh = str(out.get("refresh_token") or "")
|
||||||
|
|
@ -269,9 +305,39 @@ def api_me(ctx: Annotated[AuthContext, Depends(require_user)]) -> dict[str, Any]
|
||||||
"auth_via": ctx.auth_via,
|
"auth_via": ctx.auth_via,
|
||||||
"scopes": sorted(ctx.scopes),
|
"scopes": sorted(ctx.scopes),
|
||||||
"all_scopes": sorted(ALL_SCOPES),
|
"all_scopes": sorted(ALL_SCOPES),
|
||||||
|
"prefs": user_prefs(ctx.user),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/auth/me/prefs")
|
||||||
|
def api_get_prefs(ctx: Annotated[AuthContext, Depends(require_user)]) -> dict[str, Any]:
|
||||||
|
return {"prefs": user_prefs(ctx.user)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v1/auth/me/prefs/{key}")
|
||||||
|
def api_put_pref(
|
||||||
|
key: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if key not in _PREF_KEYS:
|
||||||
|
raise HTTPException(status_code=404, detail="unknown_pref_key")
|
||||||
|
validated = _validate_pref_value(key, body)
|
||||||
|
# Reload in this session: ctx.user may be the middleware's detached copy, or the
|
||||||
|
# synthetic "system" user when auth is disabled (no row — never insert it).
|
||||||
|
user = db.get(AppUser, ctx.user.id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=409, detail="prefs_unavailable")
|
||||||
|
prefs = user_prefs(user)
|
||||||
|
prefs[key] = validated
|
||||||
|
user.prefs = prefs
|
||||||
|
flag_modified(user, "prefs")
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return {"prefs": user_prefs(user)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/v1/auth/change-password")
|
@router.post("/v1/auth/change-password")
|
||||||
def api_change_password(
|
def api_change_password(
|
||||||
body: ChangePasswordRequest,
|
body: ChangePasswordRequest,
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,11 @@ def user_public(user: AppUser) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def user_prefs(user: AppUser) -> dict[str, Any]:
|
||||||
|
raw = getattr(user, "prefs", None)
|
||||||
|
return dict(raw) if isinstance(raw, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def sanitize_detail(detail: Any) -> Any:
|
def sanitize_detail(detail: Any) -> Any:
|
||||||
"""Recursively drop secret-looking keys from audit detail payloads."""
|
"""Recursively drop secret-looking keys from audit detail payloads."""
|
||||||
if isinstance(detail, dict):
|
if isinstance(detail, dict):
|
||||||
|
|
@ -431,6 +436,7 @@ def login_issue_token(
|
||||||
"expires_in": ttl,
|
"expires_in": ttl,
|
||||||
"refresh_expires_in": refresh_ttl,
|
"refresh_expires_in": refresh_ttl,
|
||||||
"user": user_public(user),
|
"user": user_public(user),
|
||||||
|
"prefs": user_prefs(user),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ class AppUser(Base):
|
||||||
role: Mapped[str] = mapped_column(String(32), default="user", index=True) # admin | user
|
role: Mapped[str] = mapped_column(String(32), default="user", index=True) # admin | user
|
||||||
# Optional capability override; empty => role defaults (see auth_scopes).
|
# Optional capability override; empty => role defaults (see auth_scopes).
|
||||||
scopes: Mapped[list] = mapped_column(_JsonType, default=list)
|
scopes: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||||
|
# Per-user UI preferences (e.g. workbench card layout). Pure client prefs; not audited.
|
||||||
|
prefs: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_by: Mapped[str] = mapped_column(String(64), default="")
|
created_by: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ def apply_auth_schema_patches(conn: Connection) -> None:
|
||||||
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE",
|
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE",
|
||||||
)
|
)
|
||||||
_run_sql(conn, "ALTER TABLE app_user ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'")
|
_run_sql(conn, "ALTER TABLE app_user ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'")
|
||||||
|
_run_sql(conn, "ALTER TABLE app_user ADD COLUMN IF NOT EXISTS prefs JSON DEFAULT '{}'")
|
||||||
_run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP")
|
_run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP")
|
||||||
_run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'")
|
_run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'")
|
||||||
_run_sql(
|
_run_sql(
|
||||||
|
|
@ -354,6 +355,7 @@ def apply_domain_schema_patches(conn: Connection) -> None:
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_history_host_name ON ume_alarms_history (host_name)",
|
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_history_host_name ON ume_alarms_history (host_name)",
|
||||||
"ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP",
|
"ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP",
|
||||||
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE",
|
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE",
|
||||||
|
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS prefs JSON DEFAULT '{}'",
|
||||||
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enabled BOOLEAN DEFAULT FALSE",
|
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enabled BOOLEAN DEFAULT FALSE",
|
||||||
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vendor VARCHAR(32) DEFAULT 'zte'",
|
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vendor VARCHAR(32) DEFAULT 'zte'",
|
||||||
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''",
|
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''",
|
||||||
|
|
|
||||||
193
tests/test_user_prefs.py
Normal file
193
tests/test_user_prefs.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Per-user prefs API: workbench layout read/write, whitelist, validation, isolation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from netx_api.auth_middleware import AuthAuditMiddleware
|
||||||
|
from netx_api.auth_router import router as auth_router
|
||||||
|
from netx_api.auth_service import bootstrap_admin_if_needed, create_user
|
||||||
|
from netx_api.db import Base, get_db
|
||||||
|
from netx_api.models import AppUser
|
||||||
|
|
||||||
|
|
||||||
|
class UserPrefsApiTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.add_middleware(AuthAuditMiddleware)
|
||||||
|
self.app.include_router(auth_router)
|
||||||
|
|
||||||
|
def _override_db():
|
||||||
|
db = self.Session()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
self.app.dependency_overrides[get_db] = _override_db
|
||||||
|
|
||||||
|
self._sess_patch = patch("netx_api.auth_middleware.SessionLocal", self.Session)
|
||||||
|
self._sess_patch.start()
|
||||||
|
self._settings_patches = [
|
||||||
|
patch("netx_api.auth_middleware.settings.auth_enabled", True),
|
||||||
|
patch("netx_api.audit_async.settings.audit_async", False),
|
||||||
|
patch("netx_api.auth_tokens.settings.auth_secret", "unit-test-auth-secret-32bytes!!"),
|
||||||
|
patch("netx_api.auth_tokens.settings.auth_token_ttl_sec", 3600),
|
||||||
|
patch("netx_api.auth_service.settings.bootstrap_admin_username", "admin"),
|
||||||
|
patch("netx_api.auth_service.settings.bootstrap_admin_password", "adminpass"),
|
||||||
|
patch("netx_api.auth_deps.settings.auth_enabled", True),
|
||||||
|
]
|
||||||
|
for p in self._settings_patches:
|
||||||
|
p.start()
|
||||||
|
|
||||||
|
db = self.Session()
|
||||||
|
try:
|
||||||
|
bootstrap_admin_if_needed(db)
|
||||||
|
admin = db.query(AppUser).filter(AppUser.username == "admin").one()
|
||||||
|
admin.must_change_password = False
|
||||||
|
create_user(db, username="alice", password="alice123", role="user", actor=admin)
|
||||||
|
create_user(db, username="bob", password="bob12345", role="user", actor=admin)
|
||||||
|
for name in ("alice", "bob"):
|
||||||
|
u = db.query(AppUser).filter(AppUser.username == name).one()
|
||||||
|
u.must_change_password = False
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._sess_patch.stop()
|
||||||
|
for p in self._settings_patches:
|
||||||
|
p.stop()
|
||||||
|
self.app.dependency_overrides.clear()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _login(self, username: str, password: str) -> str:
|
||||||
|
r = self.client.post("/v1/auth/login", json={"username": username, "password": password})
|
||||||
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
|
return str(r.json()["access_token"])
|
||||||
|
|
||||||
|
def test_me_includes_empty_prefs(self) -> None:
|
||||||
|
token = self._login("alice", "alice123")
|
||||||
|
me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
self.assertEqual(me.status_code, 200, me.text)
|
||||||
|
self.assertEqual(me.json().get("prefs"), {})
|
||||||
|
login = self.client.post("/v1/auth/login", json={"username": "alice", "password": "alice123"})
|
||||||
|
self.assertEqual(login.json().get("prefs"), {})
|
||||||
|
|
||||||
|
def test_get_and_put_workbench(self) -> None:
|
||||||
|
token = self._login("alice", "alice123")
|
||||||
|
empty = self.client.get("/v1/auth/me/prefs", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
self.assertEqual(empty.status_code, 200, empty.text)
|
||||||
|
self.assertEqual(empty.json(), {"prefs": {}})
|
||||||
|
|
||||||
|
body = {"order": ["ne", "topology"], "hidden": ["webcrt"]}
|
||||||
|
put = self.client.put(
|
||||||
|
"/v1/auth/me/prefs/workbench",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
self.assertEqual(put.status_code, 200, put.text)
|
||||||
|
self.assertEqual(put.json()["prefs"]["workbench"], body)
|
||||||
|
|
||||||
|
got = self.client.get("/v1/auth/me/prefs", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
self.assertEqual(got.json()["prefs"]["workbench"], body)
|
||||||
|
me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
self.assertEqual(me.json()["prefs"]["workbench"], body)
|
||||||
|
|
||||||
|
def test_put_strips_unknown_fields(self) -> None:
|
||||||
|
token = self._login("alice", "alice123")
|
||||||
|
put = self.client.put(
|
||||||
|
"/v1/auth/me/prefs/workbench",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"order": ["ne"], "hidden": [], "extra": True, "panels": {}},
|
||||||
|
)
|
||||||
|
self.assertEqual(put.status_code, 200, put.text)
|
||||||
|
self.assertEqual(put.json()["prefs"]["workbench"], {"order": ["ne"], "hidden": []})
|
||||||
|
|
||||||
|
def test_unknown_key_404(self) -> None:
|
||||||
|
token = self._login("alice", "alice123")
|
||||||
|
r = self.client.put(
|
||||||
|
"/v1/auth/me/prefs/panels",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"order": [], "hidden": []},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_validation_422(self) -> None:
|
||||||
|
token = self._login("alice", "alice123")
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
bad_char = self.client.put(
|
||||||
|
"/v1/auth/me/prefs/workbench",
|
||||||
|
headers=headers,
|
||||||
|
json={"order": ["NE_Admin"], "hidden": []},
|
||||||
|
)
|
||||||
|
self.assertEqual(bad_char.status_code, 422)
|
||||||
|
|
||||||
|
too_long = self.client.put(
|
||||||
|
"/v1/auth/me/prefs/workbench",
|
||||||
|
headers=headers,
|
||||||
|
json={"order": [f"m{i}" for i in range(65)], "hidden": []},
|
||||||
|
)
|
||||||
|
self.assertEqual(too_long.status_code, 422)
|
||||||
|
|
||||||
|
bad_type = self.client.put(
|
||||||
|
"/v1/auth/me/prefs/workbench",
|
||||||
|
headers=headers,
|
||||||
|
json={"order": "ne", "hidden": []},
|
||||||
|
)
|
||||||
|
self.assertEqual(bad_type.status_code, 422)
|
||||||
|
|
||||||
|
def test_user_isolation(self) -> None:
|
||||||
|
alice = self._login("alice", "alice123")
|
||||||
|
bob = self._login("bob", "bob12345")
|
||||||
|
layout = {"order": ["topology", "ne"], "hidden": ["ume"]}
|
||||||
|
put = self.client.put(
|
||||||
|
"/v1/auth/me/prefs/workbench",
|
||||||
|
headers={"Authorization": f"Bearer {alice}"},
|
||||||
|
json=layout,
|
||||||
|
)
|
||||||
|
self.assertEqual(put.status_code, 200, put.text)
|
||||||
|
|
||||||
|
bob_me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {bob}"})
|
||||||
|
self.assertEqual(bob_me.status_code, 200, bob_me.text)
|
||||||
|
self.assertEqual(bob_me.json().get("prefs"), {})
|
||||||
|
|
||||||
|
alice_me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {alice}"})
|
||||||
|
self.assertEqual(alice_me.json()["prefs"]["workbench"], layout)
|
||||||
|
|
||||||
|
def test_auth_disabled_does_not_insert_system_user(self) -> None:
|
||||||
|
with patch("netx_api.auth_deps.settings.auth_enabled", False), patch(
|
||||||
|
"netx_api.auth_middleware.settings.auth_enabled", False
|
||||||
|
):
|
||||||
|
r = self.client.put(
|
||||||
|
"/v1/auth/me/prefs/workbench",
|
||||||
|
json={"order": ["ne"], "hidden": []},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 409, r.text)
|
||||||
|
db = self.Session()
|
||||||
|
try:
|
||||||
|
self.assertIsNone(db.get(AppUser, "system"))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -28,6 +28,8 @@ export type ModuleDefinition = {
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean;
|
||||||
/** Hide from workbench launcher; still used for module window registration. */
|
/** Hide from workbench launcher; still used for module window registration. */
|
||||||
workbenchHidden?: boolean;
|
workbenchHidden?: boolean;
|
||||||
|
/** When true and not listed in the user's order, start in the "available" tray. */
|
||||||
|
defaultHidden?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MODULES: readonly ModuleDefinition[] = [
|
export const MODULES: readonly ModuleDefinition[] = [
|
||||||
|
|
@ -159,6 +161,13 @@ export function modulesInSection(section: WorkbenchSection): ModuleDefinition[]
|
||||||
return MODULES.filter((m) => m.section === section);
|
return MODULES.filter((m) => m.section === section);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Operations-area tiles users can show/hide/reorder (monitoring folded into ops). */
|
||||||
|
export function customizableModules(): ModuleDefinition[] {
|
||||||
|
return MODULES.filter(
|
||||||
|
(m) => (m.section === "operations" || m.section === "monitoring") && !m.workbenchHidden,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isWorkbenchPath(pathname: string): boolean {
|
export function isWorkbenchPath(pathname: string): boolean {
|
||||||
return pathname === "/" || pathname === "/workbench";
|
return pathname === "/" || pathname === "/workbench";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,15 @@ export type AuthUser = {
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserPrefs = Record<string, unknown>;
|
||||||
|
|
||||||
type AuthState = {
|
type AuthState = {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
|
prefs: UserPrefs;
|
||||||
|
setPrefs: (prefs: UserPrefs) => void;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refreshMe: () => Promise<void>;
|
refreshMe: () => Promise<void>;
|
||||||
|
|
@ -35,24 +39,33 @@ type AuthState = {
|
||||||
|
|
||||||
const AuthContext = createContext<AuthState | null>(null);
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
function asPrefs(value: unknown): UserPrefs {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as UserPrefs) : {};
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
// token is opaque for UI; cookie session means we only care about user presence.
|
// token is opaque for UI; cookie session means we only care about user presence.
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [scopes, setScopes] = useState<string[]>([]);
|
const [scopes, setScopes] = useState<string[]>([]);
|
||||||
|
const [prefs, setPrefs] = useState<UserPrefs>({});
|
||||||
|
|
||||||
const refreshMe = useCallback(async () => {
|
const refreshMe = useCallback(async () => {
|
||||||
clearAuthToken(); // drop any legacy localStorage tokens
|
clearAuthToken(); // drop any legacy localStorage tokens
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ user: AuthUser; scopes?: string[] }>("/v1/auth/me");
|
const data = await apiGet<{ user: AuthUser; scopes?: string[]; prefs?: UserPrefs }>(
|
||||||
|
"/v1/auth/me",
|
||||||
|
);
|
||||||
setToken("cookie");
|
setToken("cookie");
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
setScopes(data.scopes || data.user.scopes || []);
|
setScopes(data.scopes || data.user.scopes || []);
|
||||||
|
setPrefs(asPrefs(data.prefs));
|
||||||
} catch {
|
} catch {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setScopes([]);
|
setScopes([]);
|
||||||
|
setPrefs({});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -64,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}, [refreshMe]);
|
}, [refreshMe]);
|
||||||
|
|
||||||
const login = useCallback(async (username: string, password: string) => {
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
const data = await apiPost<{ user: AuthUser }>("/v1/auth/login", {
|
const data = await apiPost<{ user: AuthUser; prefs?: UserPrefs }>("/v1/auth/login", {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
@ -72,6 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setToken("cookie");
|
setToken("cookie");
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
setScopes(data.user.scopes || []);
|
setScopes(data.user.scopes || []);
|
||||||
|
setPrefs(asPrefs(data.prefs));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
|
@ -84,6 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setScopes([]);
|
setScopes([]);
|
||||||
|
setPrefs({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo<AuthState>(
|
const value = useMemo<AuthState>(
|
||||||
|
|
@ -92,13 +107,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
scopes,
|
scopes,
|
||||||
|
prefs,
|
||||||
|
setPrefs,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
refreshMe,
|
refreshMe,
|
||||||
isAdmin: user?.role === "admin",
|
isAdmin: user?.role === "admin",
|
||||||
hasScope: (scope: string) => scopes.includes(scope) || user?.role === "admin",
|
hasScope: (scope: string) => scopes.includes(scope) || user?.role === "admin",
|
||||||
}),
|
}),
|
||||||
[ready, token, user, scopes, login, logout, refreshMe],
|
[ready, token, user, scopes, prefs, login, logout, refreshMe],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useMemo, type ReactNode } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@heroui/react";
|
import { Button } from "@heroui/react";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
import { WorkbenchModules, type DisplaySection } from "./components/WorkbenchModules";
|
import { WorkbenchModules } from "./components/WorkbenchModules";
|
||||||
import { ConsoleIcon } from "./components/ConsoleIcon";
|
import { ConsoleIcon } from "./components/ConsoleIcon";
|
||||||
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules";
|
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules";
|
||||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||||
|
|
@ -331,15 +331,11 @@ export function WorkbenchPage() {
|
||||||
) as Record<WorkbenchSection, ModuleDefinition[]>;
|
) as Record<WorkbenchSection, ModuleDefinition[]>;
|
||||||
}, [isAdmin, hasScope]);
|
}, [isAdmin, hasScope]);
|
||||||
|
|
||||||
const displaySections = useMemo((): DisplaySection[] => {
|
const opsCandidates = useMemo(
|
||||||
const ops = [...visibleBySection.monitoring, ...visibleBySection.operations];
|
() => [...visibleBySection.monitoring, ...visibleBySection.operations],
|
||||||
const system = visibleBySection.system;
|
[visibleBySection],
|
||||||
const sections: DisplaySection[] = [
|
);
|
||||||
{ key: "operations", mods: ops },
|
const systemMods = visibleBySection.system;
|
||||||
{ key: "system", mods: system },
|
|
||||||
];
|
|
||||||
return sections.filter((s) => s.mods.length > 0);
|
|
||||||
}, [visibleBySection]);
|
|
||||||
|
|
||||||
const railTasks = useMemo(() => {
|
const railTasks = useMemo(() => {
|
||||||
const items = opsQuery.data?.items ?? [];
|
const items = opsQuery.data?.items ?? [];
|
||||||
|
|
@ -501,7 +497,7 @@ export function WorkbenchPage() {
|
||||||
<div className="wb-runtime__foot"><span>{t("workbench.kpi.uptime")}</span><strong>{metrics ? formatAgeSec(metrics.uptime_sec) : metricsPlaceholder}</strong></div>
|
<div className="wb-runtime__foot"><span>{t("workbench.kpi.uptime")}</span><strong>{metrics ? formatAgeSec(metrics.uptime_sec) : metricsPlaceholder}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
<div className="wb-body">
|
<div className="wb-body">
|
||||||
<WorkbenchModules sections={displaySections} />
|
<WorkbenchModules opsCandidates={opsCandidates} systemMods={systemMods} />
|
||||||
<aside className="wb-side" aria-label={t("workbench.sidePanel")}>
|
<aside className="wb-side" aria-label={t("workbench.sidePanel")}>
|
||||||
<section className="wb-rail wb-panel wb-rail--tasks">
|
<section className="wb-rail wb-panel wb-rail--tasks">
|
||||||
<div className="wb-rail__head">
|
<div className="wb-rail__head">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" };
|
type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" | "settings" };
|
||||||
|
|
||||||
export function ConsoleIcon({ kind }: Props) {
|
export function ConsoleIcon({ kind }: Props) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -9,6 +9,12 @@ export function ConsoleIcon({ kind }: Props) {
|
||||||
{kind === "activity" && <path d="M3 12h4l3-7 4 14 3-7h4" />}
|
{kind === "activity" && <path d="M3 12h4l3-7 4 14 3-7h4" />}
|
||||||
{kind === "check" && <><circle cx="12" cy="12" r="9" /><path d="m8 12 3 3 5-6" /></>}
|
{kind === "check" && <><circle cx="12" cy="12" r="9" /><path d="m8 12 3 3 5-6" /></>}
|
||||||
{kind === "close" && <path d="m6 6 12 12M6 18 18 6" />}
|
{kind === "close" && <path d="m6 6 12 12M6 18 18 6" />}
|
||||||
|
{kind === "settings" && (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.05 5.05l1.56 1.56M17.39 17.39l1.56 1.56M5.05 18.95l1.56-1.56M17.39 6.61l1.56-1.56" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,258 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type DragEvent } from "react";
|
||||||
import { Button } from "@heroui/react";
|
import { Button } from "@heroui/react";
|
||||||
import type { ModuleDefinition } from "../../../config/modules";
|
import type { ModuleDefinition } from "../../../config/modules";
|
||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
|
import { useToast } from "../../../hooks/useToast";
|
||||||
import { openOrFocusModule } from "../../../utils/moduleWindows";
|
import { openOrFocusModule } from "../../../utils/moduleWindows";
|
||||||
|
import { useWorkbenchLayout } from "../useWorkbenchLayout";
|
||||||
|
import { ConsoleIcon } from "./ConsoleIcon";
|
||||||
import { WorkbenchCardIcon } from "./WorkbenchCardIcon";
|
import { WorkbenchCardIcon } from "./WorkbenchCardIcon";
|
||||||
|
|
||||||
export type DisplaySection = { key: "operations" | "system"; mods: ModuleDefinition[] };
|
type Props = {
|
||||||
|
/** Permission-filtered customizable modules (monitoring + operations). */
|
||||||
|
opsCandidates: ModuleDefinition[];
|
||||||
|
systemMods: ModuleDefinition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function draftFromLayout(shown: ModuleDefinition[], available: ModuleDefinition[]) {
|
||||||
|
return {
|
||||||
|
order: shown.map((m) => m.moduleId),
|
||||||
|
hidden: available.map((m) => m.moduleId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Primary modules as a grid of tiles, system admin as a quiet link row. */
|
/** Primary modules as a grid of tiles, system admin as a quiet link row. */
|
||||||
export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) {
|
export function WorkbenchModules({ opsCandidates, systemMods }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { showError } = useToast();
|
||||||
|
const { shown, available, save, reset, layoutPrefs } = useWorkbenchLayout(opsCandidates);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draftOrder, setDraftOrder] = useState<string[]>([]);
|
||||||
|
const [draftHidden, setDraftHidden] = useState<string[]>([]);
|
||||||
|
const [dragId, setDragId] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const byId = useMemo(() => new Map(opsCandidates.map((m) => [m.moduleId, m])), [opsCandidates]);
|
||||||
|
|
||||||
|
const enterEdit = useCallback(() => {
|
||||||
|
const d = draftFromLayout(shown, available);
|
||||||
|
setDraftOrder(d.order);
|
||||||
|
setDraftHidden(d.hidden);
|
||||||
|
setEditing(true);
|
||||||
|
}, [shown, available]);
|
||||||
|
|
||||||
|
const exitEdit = useCallback(() => {
|
||||||
|
setEditing(false);
|
||||||
|
setDragId(null);
|
||||||
|
setDraftOrder([]);
|
||||||
|
setDraftHidden([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
exitEdit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [editing, exitEdit]);
|
||||||
|
|
||||||
|
const draftShown = useMemo(
|
||||||
|
() => draftOrder.map((id) => byId.get(id)).filter((m): m is ModuleDefinition => Boolean(m)),
|
||||||
|
[draftOrder, byId],
|
||||||
|
);
|
||||||
|
const draftAvailable = useMemo(
|
||||||
|
() => draftHidden.map((id) => byId.get(id)).filter((m): m is ModuleDefinition => Boolean(m)),
|
||||||
|
[draftHidden, byId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayOps = editing ? draftShown : shown;
|
||||||
|
const canCustomize = opsCandidates.length > 0;
|
||||||
|
|
||||||
|
const removeTile = (moduleId: string) => {
|
||||||
|
setDraftOrder((prev) => prev.filter((id) => id !== moduleId));
|
||||||
|
setDraftHidden((prev) => (prev.includes(moduleId) ? prev : [...prev, moduleId]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTile = (moduleId: string) => {
|
||||||
|
setDraftHidden((prev) => prev.filter((id) => id !== moduleId));
|
||||||
|
setDraftOrder((prev) => (prev.includes(moduleId) ? prev : [...prev, moduleId]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (moduleId: string) => (e: DragEvent) => {
|
||||||
|
setDragId(moduleId);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("text/plain", moduleId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (overId: string) => (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (!dragId || dragId === overId) return;
|
||||||
|
setDraftOrder((prev) => {
|
||||||
|
const from = prev.indexOf(dragId);
|
||||||
|
const to = prev.indexOf(overId);
|
||||||
|
if (from < 0 || to < 0 || from === to) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next.splice(from, 1);
|
||||||
|
next.splice(to, 0, dragId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaults = useMemo(
|
||||||
|
() => ({
|
||||||
|
order: opsCandidates.filter((m) => !m.defaultHidden).map((m) => m.moduleId),
|
||||||
|
hidden: opsCandidates.filter((m) => m.defaultHidden).map((m) => m.moduleId),
|
||||||
|
}),
|
||||||
|
[opsCandidates],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDone = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const sameAsDefault =
|
||||||
|
draftOrder.join(",") === defaults.order.join(",") &&
|
||||||
|
[...draftHidden].sort().join(",") === [...defaults.hidden].sort().join(",");
|
||||||
|
if (sameAsDefault) {
|
||||||
|
// Store nothing so later registry changes (new modules, reordering) still apply.
|
||||||
|
await reset();
|
||||||
|
} else {
|
||||||
|
// Keep entries for modules this user can't see right now (e.g. scope temporarily
|
||||||
|
// revoked) so their choice survives until the permission comes back.
|
||||||
|
const keepOrder = (layoutPrefs?.order ?? []).filter((id) => !byId.has(id));
|
||||||
|
const keepHidden = (layoutPrefs?.hidden ?? []).filter((id) => !byId.has(id));
|
||||||
|
await save([...draftOrder, ...keepOrder], [...draftHidden, ...keepHidden]);
|
||||||
|
}
|
||||||
|
exitEdit();
|
||||||
|
} catch {
|
||||||
|
showError(t("workbench.customizeSaveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
setDraftOrder(defaults.order);
|
||||||
|
setDraftHidden(defaults.hidden);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="wb-modules wb-panel" aria-label={t("workbench.modules")}>
|
<section
|
||||||
{sections.map((section) => (
|
className={`wb-modules wb-panel${editing ? " wb-modules--editing" : ""}`}
|
||||||
<div key={section.key} className={`wb-section wb-section--${section.key}`} role="group" aria-label={t(`workbench.${section.key}`)}>
|
aria-label={t("workbench.modules")}
|
||||||
{section.mods.map((mod) => (
|
>
|
||||||
|
{canCustomize ? (
|
||||||
|
<div className="wb-modules__toolbar">
|
||||||
|
{!editing ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="wb-modules__gear"
|
||||||
|
aria-label={t("workbench.customize")}
|
||||||
|
onPress={enterEdit}
|
||||||
|
>
|
||||||
|
<ConsoleIcon kind="settings" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canCustomize || displayOps.length > 0 ? (
|
||||||
|
<div className="wb-section wb-section--operations" role="group" aria-label={t("workbench.operations")}>
|
||||||
|
{displayOps.length === 0 && !editing ? (
|
||||||
|
<div className="wb-modules__empty">
|
||||||
|
<Button type="button" variant="ghost" className="wb-modules__empty-btn" onPress={enterEdit}>
|
||||||
|
{t("workbench.customizeEmpty")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
displayOps.map((mod) => (
|
||||||
|
<div
|
||||||
|
key={mod.moduleId}
|
||||||
|
className={`wb-tile-wrap${editing ? " wb-tile-wrap--edit" : ""}${dragId === mod.moduleId ? " is-dragging" : ""}`}
|
||||||
|
draggable={editing}
|
||||||
|
onDragStart={editing ? onDragStart(mod.moduleId) : undefined}
|
||||||
|
onDragOver={editing ? onDragOver(mod.moduleId) : undefined}
|
||||||
|
onDrop={editing ? onDrop : undefined}
|
||||||
|
onDragEnd={() => setDragId(null)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={`wb-tile wb-tile--${mod.iconTone}`}
|
||||||
|
aria-label={
|
||||||
|
editing
|
||||||
|
? t(mod.labelKey)
|
||||||
|
: `${t(mod.labelKey)} · ${t("workbench.openModule")}`
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
if (editing) return;
|
||||||
|
openOrFocusModule({ moduleId: mod.moduleId, path: mod.path });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
||||||
|
<span className="wb-tile__label">{t(mod.labelKey)}</span>
|
||||||
|
</Button>
|
||||||
|
{editing ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wb-tile__remove"
|
||||||
|
aria-label={`${t(mod.labelKey)} ×`}
|
||||||
|
onClick={() => removeTile(mod.moduleId)}
|
||||||
|
>
|
||||||
|
<ConsoleIcon kind="close" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editing && draftAvailable.length > 0 ? (
|
||||||
|
<div className="wb-modules__tray" role="group" aria-label={t("workbench.customizeAvailable")}>
|
||||||
|
<div className="wb-modules__tray-label">{t("workbench.customizeAvailable")}</div>
|
||||||
|
<div className="wb-modules__tray-grid">
|
||||||
|
{draftAvailable.map((mod) => (
|
||||||
|
<Button
|
||||||
|
key={mod.moduleId}
|
||||||
|
variant="ghost"
|
||||||
|
className={`wb-tile wb-tile--${mod.iconTone} wb-tile--available`}
|
||||||
|
aria-label={t(mod.labelKey)}
|
||||||
|
onPress={() => addTile(mod.moduleId)}
|
||||||
|
>
|
||||||
|
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
||||||
|
<span className="wb-tile__label">{t(mod.labelKey)}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="wb-modules__actions">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onPress={onReset} isDisabled={saving}>
|
||||||
|
{t("workbench.customizeReset")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="primary" size="sm" onPress={() => void onDone()} isDisabled={saving}>
|
||||||
|
{t("workbench.customizeDone")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{systemMods.length > 0 ? (
|
||||||
|
<div className="wb-section wb-section--system" role="group" aria-label={t("workbench.system")}>
|
||||||
|
{systemMods.map((mod) => (
|
||||||
<Button
|
<Button
|
||||||
key={mod.moduleId}
|
key={mod.moduleId}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -27,7 +265,7 @@ export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) {
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
web/src/features/workbench/useWorkbenchLayout.ts
Normal file
125
web/src/features/workbench/useWorkbenchLayout.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import type { ModuleDefinition } from "../../config/modules";
|
||||||
|
import { apiPut } from "../../services/api";
|
||||||
|
import { useAuth, type UserPrefs } from "../auth/AuthContext";
|
||||||
|
|
||||||
|
export type WorkbenchLayoutPrefs = {
|
||||||
|
order: string[];
|
||||||
|
hidden: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const LS_PREFIX = "netx.wb.layout.";
|
||||||
|
|
||||||
|
function readLocalLayout(userId: string): WorkbenchLayoutPrefs | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`${LS_PREFIX}${userId}`);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
const order = (parsed as { order?: unknown }).order;
|
||||||
|
const hidden = (parsed as { hidden?: unknown }).hidden;
|
||||||
|
if (!Array.isArray(order) || !Array.isArray(hidden)) return null;
|
||||||
|
return {
|
||||||
|
order: order.filter((x): x is string => typeof x === "string"),
|
||||||
|
hidden: hidden.filter((x): x is string => typeof x === "string"),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLocalLayout(userId: string, layout: WorkbenchLayoutPrefs): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`${LS_PREFIX}${userId}`, JSON.stringify(layout));
|
||||||
|
} catch {
|
||||||
|
// ignore quota / private mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asWorkbenchPrefs(value: unknown): WorkbenchLayoutPrefs | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const order = (value as { order?: unknown }).order;
|
||||||
|
const hidden = (value as { hidden?: unknown }).hidden;
|
||||||
|
if (!Array.isArray(order) && !Array.isArray(hidden)) return null;
|
||||||
|
return {
|
||||||
|
order: Array.isArray(order) ? order.filter((x): x is string => typeof x === "string") : [],
|
||||||
|
hidden: Array.isArray(hidden) ? hidden.filter((x): x is string => typeof x === "string") : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure layout resolver — permission-filtered `all` in registry order. */
|
||||||
|
export function resolveLayout(
|
||||||
|
all: ModuleDefinition[],
|
||||||
|
prefs: WorkbenchLayoutPrefs | null | undefined,
|
||||||
|
): { shown: ModuleDefinition[]; available: ModuleDefinition[] } {
|
||||||
|
const byId = new Map(all.map((m) => [m.moduleId, m]));
|
||||||
|
const prefOrder = prefs?.order ?? [];
|
||||||
|
const hiddenSet = new Set((prefs?.hidden ?? []).filter((id) => byId.has(id)));
|
||||||
|
const orderIds = prefOrder.filter((id) => byId.has(id));
|
||||||
|
const inOrder = new Set(orderIds);
|
||||||
|
|
||||||
|
const ranked: ModuleDefinition[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const id of orderIds) {
|
||||||
|
const mod = byId.get(id);
|
||||||
|
if (!mod || seen.has(id)) continue;
|
||||||
|
ranked.push(mod);
|
||||||
|
seen.add(id);
|
||||||
|
}
|
||||||
|
for (const mod of all) {
|
||||||
|
if (seen.has(mod.moduleId)) continue;
|
||||||
|
ranked.push(mod);
|
||||||
|
seen.add(mod.moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown: ModuleDefinition[] = [];
|
||||||
|
const available: ModuleDefinition[] = [];
|
||||||
|
for (const mod of ranked) {
|
||||||
|
const hide =
|
||||||
|
hiddenSet.has(mod.moduleId) || (Boolean(mod.defaultHidden) && !inOrder.has(mod.moduleId));
|
||||||
|
if (hide) available.push(mod);
|
||||||
|
else shown.push(mod);
|
||||||
|
}
|
||||||
|
return { shown, available };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkbenchLayout(all: ModuleDefinition[]) {
|
||||||
|
const { user, prefs, setPrefs, ready } = useAuth();
|
||||||
|
const userId = user?.id ?? "";
|
||||||
|
|
||||||
|
const layoutPrefs = useMemo(() => {
|
||||||
|
// After auth is ready, server prefs win (missing workbench => default).
|
||||||
|
// Before ready, fall back to localStorage to avoid a layout flash.
|
||||||
|
if (ready) return asWorkbenchPrefs(prefs?.workbench);
|
||||||
|
return userId ? readLocalLayout(userId) : null;
|
||||||
|
}, [ready, prefs, userId]);
|
||||||
|
|
||||||
|
const { shown, available } = useMemo(
|
||||||
|
() => resolveLayout(all, layoutPrefs),
|
||||||
|
[all, layoutPrefs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persist = useCallback(
|
||||||
|
async (next: WorkbenchLayoutPrefs) => {
|
||||||
|
const res = await apiPut<{ prefs: UserPrefs }>("/v1/auth/me/prefs/workbench", next);
|
||||||
|
const merged =
|
||||||
|
res.prefs && typeof res.prefs === "object" ? res.prefs : { ...prefs, workbench: next };
|
||||||
|
setPrefs(merged);
|
||||||
|
if (userId) writeLocalLayout(userId, next);
|
||||||
|
},
|
||||||
|
[prefs, setPrefs, userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
async (order: string[], hidden: string[]) => {
|
||||||
|
await persist({ order, hidden });
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(async () => {
|
||||||
|
await persist({ order: [], hidden: [] });
|
||||||
|
}, [persist]);
|
||||||
|
|
||||||
|
return { shown, available, save, reset, layoutPrefs };
|
||||||
|
}
|
||||||
|
|
@ -149,6 +149,12 @@ const workbench = {
|
||||||
apiKeys: "API Keys",
|
apiKeys: "API Keys",
|
||||||
sessions: "Login sessions",
|
sessions: "Login sessions",
|
||||||
},
|
},
|
||||||
|
customize: "Customize modules",
|
||||||
|
customizeDone: "Done",
|
||||||
|
customizeReset: "Reset",
|
||||||
|
customizeAvailable: "Available",
|
||||||
|
customizeEmpty: "Add modules",
|
||||||
|
customizeSaveFailed: "Save failed",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default workbench;
|
export default workbench;
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,12 @@ const workbench = {
|
||||||
apiKeys: "API Key",
|
apiKeys: "API Key",
|
||||||
sessions: "登录会话",
|
sessions: "登录会话",
|
||||||
},
|
},
|
||||||
|
customize: "自定义功能区",
|
||||||
|
customizeDone: "完成",
|
||||||
|
customizeReset: "恢复默认",
|
||||||
|
customizeAvailable: "可添加",
|
||||||
|
customizeEmpty: "添加功能",
|
||||||
|
customizeSaveFailed: "保存失败",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default workbench;
|
export default workbench;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,53 @@
|
||||||
|
|
||||||
/* Modules: one row of primary tiles, then a quiet row of admin links. */
|
/* Modules: one row of primary tiles, then a quiet row of admin links. */
|
||||||
.wb-modules { padding: var(--wb-pad); display: flex; flex-direction: column; gap: 14px; }
|
.wb-modules { padding: var(--wb-pad); display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.wb-modules__toolbar { display: flex; justify-content: flex-end; margin: -4px 0 -6px; min-height: 28px; }
|
||||||
|
.app--shell .app-main .wb-modules__gear {
|
||||||
|
width: 28px; height: 28px; min-height: 0; padding: 0; margin: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: 0; border-radius: var(--nx-radius-sm); background: transparent;
|
||||||
|
color: var(--nx-text-faint); box-shadow: none;
|
||||||
|
}
|
||||||
|
.app--shell .app-main .wb-modules__gear:hover { background: rgb(255 255 255 / .06); color: var(--nx-text); }
|
||||||
|
.wb-modules__gear svg { width: 16px; height: 16px; }
|
||||||
|
.wb-modules__empty {
|
||||||
|
grid-column: 1 / -1; min-height: var(--wb-tile-h);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.app--shell .app-main .wb-modules__empty-btn {
|
||||||
|
height: auto; min-height: 40px; padding: 8px 16px; border: 1px dashed var(--nx-line);
|
||||||
|
border-radius: 12px; background: transparent; color: var(--nx-text-muted); box-shadow: none;
|
||||||
|
}
|
||||||
|
.app--shell .app-main .wb-modules__empty-btn:hover { background: rgb(255 255 255 / .04); color: var(--nx-text); border-color: var(--nx-line-strong); }
|
||||||
|
.wb-tile-wrap { position: relative; min-width: 0; }
|
||||||
|
.wb-modules--editing .wb-tile-wrap--edit .wb-tile { border-color: var(--nx-line); cursor: grab; }
|
||||||
|
.wb-modules--editing .wb-tile-wrap--edit.is-dragging .wb-tile { opacity: .55; cursor: grabbing; }
|
||||||
|
.wb-modules--editing .wb-tile-wrap--edit .wb-tile:hover { transform: none; }
|
||||||
|
.app--shell .app-main .wb-tile__remove {
|
||||||
|
position: absolute; top: 4px; right: 4px; z-index: 2;
|
||||||
|
width: 22px; height: 22px; padding: 0; margin: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: 1px solid var(--nx-line); border-radius: 999px;
|
||||||
|
background: rgb(15 23 42 / .72); color: var(--nx-text-muted); cursor: pointer;
|
||||||
|
}
|
||||||
|
.app--shell .app-main .wb-tile__remove:hover { color: var(--nx-text); border-color: var(--nx-line-strong); }
|
||||||
|
.wb-tile__remove svg { width: 12px; height: 12px; }
|
||||||
|
.wb-modules__tray { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
||||||
|
.wb-modules__tray-label { font-size: 12px; font-weight: 500; color: var(--nx-text-muted); }
|
||||||
|
.wb-modules__tray-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr));
|
||||||
|
grid-auto-rows: calc(var(--wb-tile-h) * .85); gap: 10px;
|
||||||
|
}
|
||||||
|
.app--shell .app-main .wb-tile--available {
|
||||||
|
opacity: .55; border-style: dashed; border-color: var(--nx-line); min-height: 0;
|
||||||
|
background: rgb(255 255 255 / .02);
|
||||||
|
}
|
||||||
|
.app--shell .app-main .wb-tile--available:hover {
|
||||||
|
opacity: .85; transform: none;
|
||||||
|
border-color: var(--nx-line-strong);
|
||||||
|
background: rgb(255 255 255 / .05);
|
||||||
|
}
|
||||||
|
.wb-modules__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; }
|
||||||
/* Fixed-size tiles fill rows in order; the panel's spare height is room for new modules. */
|
/* Fixed-size tiles fill rows in order; the panel's spare height is room for new modules. */
|
||||||
.wb-section--operations { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr)); grid-auto-rows: var(--wb-tile-h); gap: 12px; }
|
.wb-section--operations { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr)); grid-auto-rows: var(--wb-tile-h); gap: 12px; }
|
||||||
.app--shell .app-main .wb-tile {
|
.app--shell .app-main .wb-tile {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue