diff --git a/.env.example b/.env.example index 627d6d2..9b33f39 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ============================================================ -# LokiDeploy - Environment Variables Example +# opsx-loki - Environment Variables Example # ============================================================ # Copy this file to .env and fill in your values: # cp .env.example .env diff --git a/.gitignore b/.gitignore index 27172e9..d713647 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ secrets/ # OS Thumbs.db + +# ZCode 会话目录 +.zcode/ diff --git a/AGENTS.md b/AGENTS.md index fa90510..f76865b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ ## 项目概览 -LokiDeploy 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana +opsx-loki 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。 Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。 @@ -57,7 +57,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret ## 架构 -### 后端分层(`backend/`,模块路径 `lokideploy`,Go 1.24) +### 后端分层(`backend/`,模块路径 `opsxloki`,Go 1.24) 请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。 - `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。 @@ -71,12 +71,16 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret (`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。 其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`, 以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。 -- `middleware/jwt.go` — `JWTAuth()`(`/authorized` 路由组必用)、`AdminAuth()`。 +- `middleware/jwt.go` — `JWTAuth()`(解析后对照 JsonDB 校验 `TokenVersion`,并以数据库最新 + role 为准)、`AdminAuth()`;`ratelimit.go` — 登录接口 IP 滑动窗口限流(20 次/分钟)。 +- 用户管理(简单两级,无 RBAC):`GET/POST /users`、`DELETE /users/:id`、 + `POST /users/:id/reset-password`,全部仅 admin。改密/重置密码会递增用户 `TokenVersion`, + 旧 access+refresh token 立即失效(会话吊销)。 - `utils/` — `response.go`(响应封装助手)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。 - `seed/` — 数据库为空时写入默认 admin 用户。 ### 数据层 — JSON 文件数据库(唯一数据源) -持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokideploy.json`(默认 `./data/`)。 +持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/opsxloki.json(兼容加载旧 lokideploy.json)`(默认 `./data/`)。 **没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构: - **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个 @@ -109,14 +113,42 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret ## 前端(`frontend/`,React 19 + Vite 7 + TypeScript) -- 技术栈:Tailwind、**shadcn/ui**(Radix 基础组件在 `src/components/ui/`)、react-router 7、 - react-hook-form + zod、recharts、sonner(通知)。 -- `src/api/` 是 **当前在用** 的 API 客户端(取代了已删除的 `src/srv/api/`)。`http.ts` 是 - fetch 封装:注入 `Authorization: Bearer`、30s 超时、401 → 清除 token 并跳转 `/login`、可选重试。 - 每个资源一个模块(`auth`、`cluster` ……)。 -- `src/App.tsx` — 路由通过 `React.lazy` 懒加载,`RequireAuth` 守卫在 localStorage 无 `token` - 时重定向到 `/login`。`@` → `src/` 别名(tsconfig + vite)。 -- 开发服务器跑在 `:3000`,把 `/api` 代理到 `:8080`;生产环境下由 Go 二进制直接提供 `dist/` 静态服务。 +> 前端开发标准:`docs/web-ui-guide/`(9 章,含分章 AI 检查清单)。编码、Review、重构 +> 时以该标准为基准;与标准冲突的既有代码即为待改进项。 + +- 技术栈:Tailwind、**shadcn/ui**(Radix 基础组件在 `src/components/ui/`,只保留实际 + 使用的,需要新组件 `npx shadcn add `)、react-router 7、react-hook-form + zod、 + sonner(通知)、next-themes(暗色)。 +- **页面按域组织**(`src/pages//`):`index.tsx` 编排(≤400 行)+ + `components/`(表单弹窗/卡片/详情)+ `hooks/`(私有数据逻辑)。跨页复用第三次才晋升 + 到 `src/components/`。 +- `src/api/` — API 客户端,一域一模块。`http.ts` 提供:结构化 `ApiError`(status/code)、 + 30s 超时、`silent` 请求选项、**401 单飞刷新 + 队列重放**(refreshToken 走裸 fetch, + 登录/轮询接口静默)、`buildQuery` 编码 query。组件**禁止**绕过 api 层直接 fetch。 +- 凭证存取统一走 `src/lib/auth-storage.ts`,key 带命名空间(`lokideploy.auth.*`); + redirect 回跳校验在 `src/lib/redirect.ts`(防开放重定向)。 +- 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与 + `document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。 +- 部署向导(`/wizard`):状态推导式步骤地图(preflight 端点聚合各资源状态),四步:环境与 + 目标 → 存储 → Loki(+Grafana)→ 采集;k8s 资源统一默认 `log` 命名空间(可改)。 + Grafana 为**可选**(默认关闭):k8s 用 NodePort(自动分配)+数据源 ConfigMap,主机用 tarball+systemd;平台已内置日志查询,通常无需 Grafana。 +- **LDAP 接入**(参照 opsx-web-lite):设置页配置(SystemSetting 表 JSON,Bind 密码 AES 加密、 + 对外只回 `hasPassword`);启用后 ①登录支持 LDAP 用户验证并自动建号(authSource=ldap)②用户页 + 出现「从 LDAP 导入」(已导入置灰;本地同名转 LDAP);`/settings/ldap/{test,users}` 端点。 +- **日志查询**(`/logs`):数据源 `LogSources`(cluster-svc=经 kubectl --raw service proxy 查集群内 + Loki,不依赖 ClusterIP/DNS;url=直连;部署成功自动注册);`/logs/{query,labels,label/*/values,histogram}` + 代理端点(cursor 翻页);前端 LogQL 高亮补全 + 直方图 + 标签点击过滤 + Live Tail(3s 轮询)。 +- 用户管理页(`/users`,meta `adminOnly: true` — 菜单按角色过滤,后端 AdminAuth 兜底); + 部署表单的 Loki 版本下拉来自 `GET /deployments/versions`(失败回退兜底列表)。 +- 列表数据一律 `useListQuery`(AbortController 取消 + 版本号防错序 + 分页);反馈分级: + 成功静默(禁 toast.success)、失败 toast.error、危险操作 ConfirmDialog、加载失败渲染 + ErrorState + 重试(与空态严格区分)。 +- 主题:语义 Token 单源(`src/index.css`,品牌色 = `--primary`,状态色 = + success/warning/destructive/info);暗色 `.dark` class 由 next-themes 驱动 + (key `lokideploy.theme.mode`),禁止硬编码调色板类(如 `bg-emerald-600`)。 +- 开发服务器跑在 `:3000`(strictPort),把 `/api` 代理到 `:8080`;生产环境下由 Go + 二进制直接提供 `dist/` 静态服务。构建指纹(`__APP_VERSION__`/`__COMMIT_HASH__`) + 在 Settings「关于」展示。 ## Loki 部署相关细节 - 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig); diff --git a/CLAUDE.md b/CLAUDE.md index 5d78a39..280840d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 项目概览 -LokiDeploy 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana +opsx-loki 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。 Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。 @@ -56,7 +56,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret ## 架构 -### 后端分层(`backend/`,模块路径 `lokideploy`,Go 1.24) +### 后端分层(`backend/`,模块路径 `opsxloki`,Go 1.24) 请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。 - `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。 @@ -70,12 +70,17 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret (`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。 其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`, 以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。 -- `middleware/jwt.go` — `JWTAuth()`(`/authorized` 路由组必用)、`AdminAuth()`。 +- `middleware/jwt.go` — `JWTAuth()`(解析后对照 JsonDB 校验 `TokenVersion`,并以数据库最新 + role 为准)、`AdminAuth()`;`ratelimit.go` — 登录接口 IP 滑动窗口限流(20 次/分钟)。 +- 用户管理(简单两级,无 RBAC):`GET/POST /users`、`DELETE /users/:id`、 + `POST /users/:id/reset-password`,全部仅 admin。改密/重置密码会递增用户 `TokenVersion`, + 旧 access+refresh token 立即失效(会话吊销)。 + - `utils/` — `response.go`(响应封装助手)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。 - `seed/` — 数据库为空时写入默认 admin 用户。 ### 数据层 — JSON 文件数据库(唯一数据源) -持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokideploy.json`(默认 `./data/`)。 +持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/opsxloki.json(兼容加载旧 lokideploy.json)`(默认 `./data/`)。 **没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构: - **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个 @@ -108,14 +113,38 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret ## 前端(`frontend/`,React 19 + Vite 7 + TypeScript) -- 技术栈:Tailwind、**shadcn/ui**(Radix 基础组件在 `src/components/ui/`)、react-router 7、 - react-hook-form + zod、recharts、sonner(通知)。 -- `src/api/` 是 **当前在用** 的 API 客户端(取代了已删除的 `src/srv/api/`)。`http.ts` 是 - fetch 封装:注入 `Authorization: Bearer`、30s 超时、401 → 清除 token 并跳转 `/login`、可选重试。 - 每个资源一个模块(`auth`、`cluster` ……)。 -- `src/App.tsx` — 路由通过 `React.lazy` 懒加载,`RequireAuth` 守卫在 localStorage 无 `token` - 时重定向到 `/login`。`@` → `src/` 别名(tsconfig + vite)。 -- 开发服务器跑在 `:3000`,把 `/api` 代理到 `:8080`;生产环境下由 Go 二进制直接提供 `dist/` 静态服务。 +> 前端开发标准:`docs/web-ui-guide/`(9 章,含分章 AI 检查清单)。编码、Review、重构 +> 时以该标准为基准;与标准冲突的既有代码即为待改进项。 + +- 技术栈:Tailwind、**shadcn/ui**(Radix 基础组件在 `src/components/ui/`,只保留实际 + 使用的,需要新组件 `npx shadcn add `)、react-router 7、react-hook-form + zod、 + sonner(通知)、next-themes(暗色)。 +- **页面按域组织**(`src/pages//`):`index.tsx` 编排(≤400 行)+ + `components/`(表单弹窗/卡片/详情)+ `hooks/`(私有数据逻辑)。跨页复用第三次才晋升 + 到 `src/components/`。 +- `src/api/` — API 客户端,一域一模块。`http.ts` 提供:结构化 `ApiError`(status/code)、 + 30s 超时、`silent` 请求选项、**401 单飞刷新 + 队列重放**(refreshToken 走裸 fetch, + 登录/轮询接口静默)、`buildQuery` 编码 query。组件**禁止**绕过 api 层直接 fetch。 +- 凭证存取统一走 `src/lib/auth-storage.ts`,key 带命名空间(`lokideploy.auth.*`); + redirect 回跳校验在 `src/lib/redirect.ts`(防开放重定向)。 +- 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与 + `document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。 +- 部署向导 `/wizard`(preflight 状态推导 + Grafana 同环境部署 + 统一 log 命名空间)。 +- LDAP 接入(`/settings` 配置+`/users` 批量导入+LDAP 登录)与日志查询(`/logs`,数据源自动注册+kube proxy)。 +- 用户管理页(`/users`,meta `adminOnly: true` — 菜单按角色过滤,后端 AdminAuth 兜底); + 部署表单的 Loki 版本下拉来自 `GET /deployments/versions`(失败回退兜底列表)。 +- 列表数据一律 `useListQuery`(AbortController 取消 + 版本号防错序 + 分页);反馈分级: + 成功静默(禁 toast.success)、失败 toast.error、危险操作 ConfirmDialog、加载失败渲染 + ErrorState + 重试(与空态严格区分)。 +- 列表数据一律 `useListQuery`(AbortController 取消 + 版本号防错序 + 分页);反馈分级: + 成功静默(禁 toast.success)、失败 toast.error、危险操作 ConfirmDialog、加载失败渲染 + ErrorState + 重试(与空态严格区分)。 +- 主题:语义 Token 单源(`src/index.css`,品牌色 = `--primary`,状态色 = + success/warning/destructive/info);暗色 `.dark` class 由 next-themes 驱动 + (key `lokideploy.theme.mode`),禁止硬编码调色板类(如 `bg-emerald-600`)。 +- 开发服务器跑在 `:3000`(strictPort),把 `/api` 代理到 `:8080`;生产环境下由 Go + 二进制直接提供 `dist/` 静态服务。构建指纹(`__APP_VERSION__`/`__COMMIT_HASH__`) + 在 Settings「关于」展示。 ## Loki 部署相关细节 - 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig); diff --git a/Dockerfile b/Dockerfile index f9fb274..3de2197 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # ============================================================ -# LokiDeploy - Secure Multi-Stage Dockerfile +# opsx-loki - Secure Multi-Stage Dockerfile # ============================================================ # P0 Fixes: -# - Non-root user (lokideploy:1000) +# - Non-root user (opsxloki:1000) # - No hardcoded JWT_SECRET (validated at runtime) # - HEALTHCHECK instruction added # - Binary installation verification @@ -45,7 +45,7 @@ COPY backend/ ./ RUN CGO_ENABLED=0 GOOS=linux go build \ -a -installsuffix cgo \ -ldflags="-s -w -extldflags '-static'" \ - -o bin/lokideploy . + -o bin/opsxloki . # --------------------------------------------------------------- # Stage 3: Final Runtime Image @@ -119,26 +119,26 @@ RUN set -eux; \ # --------------------------------------------------------------- # P0: Create non-root user and group # --------------------------------------------------------------- -RUN addgroup -g 1000 lokideploy && \ - adduser -u 1000 -G lokideploy -s /bin/sh -D lokideploy +RUN addgroup -g 1000 opsxloki && \ + adduser -u 1000 -G opsxloki -s /bin/sh -D opsxloki # --------------------------------------------------------------- # Working directory with proper ownership # --------------------------------------------------------------- WORKDIR /app -RUN chown lokideploy:lokideploy /app +RUN chown opsxloki:opsxloki /app # ---- Copy application files with correct ownership (P1) ---- -COPY --from=frontend-builder --chown=lokideploy:lokideploy /app/frontend/dist ./dist -COPY --from=backend-builder --chown=lokideploy:lokideploy /app/backend/bin/lokideploy ./lokideploy +COPY --from=frontend-builder --chown=opsxloki:opsxloki /app/frontend/dist ./dist +COPY --from=backend-builder --chown=opsxloki:opsxloki /app/backend/bin/opsxloki ./opsxloki # Create data directory with non-root ownership -RUN mkdir -p /app/data && chown -R lokideploy:lokideploy /app/data +RUN mkdir -p /app/data && chown -R opsxloki:opsxloki /app/data # Create .kube directory for non-root user (P0: not /root/.kube) -RUN mkdir -p /home/lokideploy/.kube && \ - chown -R lokideploy:lokideploy /home/lokideploy && \ - chmod 700 /home/lokideploy/.kube +RUN mkdir -p /home/opsxloki/.kube && \ + chown -R opsxloki:opsxloki /home/opsxloki && \ + chmod 700 /home/opsxloki/.kube # ---- Security hardening: remove unnecessary tools ---- RUN rm -rf /tmp/* /var/cache/apk/* @@ -148,16 +148,16 @@ RUN rm -rf /tmp/* /var/cache/apk/* # --------------------------------------------------------------- ENV DATA_DIR=/app/data ENV SERVER_PORT=8080 -ENV KUBECONFIG=/home/lokideploy/.kube/config +ENV KUBECONFIG=/home/opsxloki/.kube/config # HOME must be set for kubectl/helm to find config -ENV HOME=/home/lokideploy +ENV HOME=/home/opsxloki # NOTE: JWT_SECRET and ENCRYPTION_KEY are NOT set here. # They MUST be provided at runtime via docker-compose env or secrets. # The entrypoint script will enforce this requirement. # ---- Copy and configure entrypoint script ---- -COPY --chown=lokideploy:lokideploy docker-entrypoint.sh /usr/local/bin/ +COPY --chown=opsxloki:opsxloki docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh EXPOSE 8080 @@ -172,7 +172,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ # --------------------------------------------------------------- # P0: Switch to non-root user # --------------------------------------------------------------- -USER lokideploy +USER opsxloki ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] -CMD ["./lokideploy"] +CMD ["./opsxloki"] diff --git a/Makefile b/Makefile index 9eff6c3..f5e174f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # ============================================================ -# LokiDeploy - Makefile +# opsx-loki - Makefile # Convenient shortcuts for common operations # ============================================================ @@ -7,7 +7,7 @@ # Default target help: - @echo "LokiDeploy v2 - Available Commands:" + @echo "opsx-loki v2 - Available Commands:" @echo "" @echo " make build - Build Docker image" @echo " make start - Start with docker-compose (dev)" @@ -24,12 +24,12 @@ help: # Build Docker image build: - @echo "Building LokiDeploy v2 image..." + @echo "Building opsx-loki v2 image..." @docker compose build --no-cache # Development start: - @echo "Starting LokiDeploy (dev mode)..." + @echo "Starting opsx-loki (dev mode)..." @echo "" @if [ -z "$${JWT_SECRET:-}" ]; then \ echo "WARNING: JWT_SECRET not set!"; \ @@ -41,12 +41,12 @@ start: fi @docker compose up -d @echo "" - @echo "LokiDeploy started!" + @echo "opsx-loki started!" @echo " URL: http://localhost:$${SERVER_PORT:-8080}" @echo " Check status: make status" stop: - @echo "Stopping LokiDeploy..." + @echo "Stopping opsx-loki..." @docker compose down --timeout 15 status: @@ -54,10 +54,10 @@ status: @docker compose ps @echo "" @echo "=== Health Checks ===" - @docker inspect --format='{{.Name}}: {{.State.Health.Status}}' lokideploy 2>/dev/null || echo "lokideploy: container not found" + @docker inspect --format='{{.Name}}: {{.State.Health.Status}}' opsxloki 2>/dev/null || echo "opsxloki: container not found" @echo "" @echo "=== Resource Usage ===" - @docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}" lokideploy 2>/dev/null || true + @docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}" opsxloki 2>/dev/null || true logs: @docker compose logs -f --tail=50 @@ -69,7 +69,7 @@ prod-setup: secrets @echo "Production secrets created in ./secrets/" prod: - @echo "Starting LokiDeploy (production mode)..." + @echo "Starting opsx-loki (production mode)..." @if [ ! -f secrets/jwt_secret.txt ] || [ ! -f secrets/encryption_key.txt ]; then \ echo "ERROR: Secrets not found. Run 'make secrets' first."; \ exit 1; \ @@ -99,7 +99,7 @@ secrets: # Maintenance shell: - @docker compose exec lokideploy /bin/sh + @docker compose exec opsxloki /bin/sh clean: @echo "Cleaning up..." diff --git a/README.md b/README.md index 29cf963..65cb18d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# LokiDeploy v2 — 安全的 K8s Loki 部署平台 +# opsx-loki v2 — 安全的 K8s Loki 部署平台 一个轻量级的 **Gin + React + JSON 文件存储** 平台,用于通过 SSH 或 kubeconfig 把 Grafana Loki 日志聚合系统部署到 Kubernetes 集群或裸金属主机上。 @@ -50,8 +50,8 @@ echo "export ENCRYPTION_KEY=$ENCRYPTION_KEY" >> ~/.bashrc ### 第 2 步:用 Docker Compose 启动(推荐) ```bash -git clone https://github.com/your/lokideploy.git -cd lokideploy +git clone https://github.com/your/opsx-loki.git +cd opsx-loki # 设置密钥(见第 1 步) export JWT_SECRET=$(openssl rand -hex 32) @@ -72,7 +72,7 @@ docker compose logs -f ```bash # 容器级健康检查(内部) -docker inspect --format='{{.State.Health.Status}}' lokideploy +docker inspect --format='{{.State.Health.Status}}' opsxloki # 期望输出:"healthy" @@ -104,11 +104,11 @@ docker compose -f docker-compose.prod.yml up -d docker swarm init # 创建 secrets -echo "$(openssl rand -hex 32)" | docker secret create lokideploy_jwt - -echo "$(openssl rand -hex 32)" | docker secret create lokideploy_key - +echo "$(openssl rand -hex 32)" | docker secret create opsxloki_jwt - +echo "$(openssl rand -hex 32)" | docker secret create opsxloki_key - # 部署 -docker stack deploy -c docker-compose.prod.yml lokideploy +docker stack deploy -c docker-compose.prod.yml opsxloki ``` ### 安全清单 @@ -171,7 +171,7 @@ cd frontend && npm run lint # ESLint v 后端(Go + Gin) [:8080] | - +---> JSON 文件数据库(data/lokideploy.json) + +---> JSON 文件数据库(data/opsxloki.json(兼容加载旧 lokideploy.json)) +---> K8s CLI(kubectl / helm) +---> SSH 客户端(golang.org/x/crypto/ssh) +---> Loki 部署器(Helm Chart / YAML / Binary) @@ -196,7 +196,7 @@ cd frontend && npm run lint # ESLint ### 数据层 — JSON 文件数据库 -持久化是 **单个 JSON 文件** `$DATA_DIR/lokideploy.json`(默认 `./data/`),**没有 SQL 数据库**。 +持久化是 **单个 JSON 文件** `$DATA_DIR/opsxloki.json(兼容加载旧 lokideploy.json)`(默认 `./data/`),**没有 SQL 数据库**。 `repository.JsonDB` 是与文件对应的内存结构: - 写操作由 `Lock()/RLock()` 保护,`RequestSave()` 触发 **防抖(500ms)原子写**(临时文件 → @@ -228,7 +228,7 @@ cd frontend && npm run lint # ESLint | | | ~/.kube (只读) | | +-------------+ | | ~/.ssh (只读) | | | 容器 | | | ./data (读写) | -| | (lokideploy | | +---------------------+ +| | (opsxloki | | +---------------------+ | | UID:1000) | | | | no-new-privs| | | | cap_drop:ALL| | @@ -270,7 +270,7 @@ wget --spider http://localhost:8080/index.html curl http://localhost:8080/index.html # Docker inspect -docker inspect --format='{{.State.Health}}' lokideploy +docker inspect --format='{{.State.Health}}' opsxloki ``` 健康检查参数: @@ -423,7 +423,7 @@ docker compose up -d ```bash # 查看容器状态 -docker inspect --format='{{.State.Health}}' lokideploy +docker inspect --format='{{.State.Health}}' opsxloki # 查看日志 docker compose logs --tail=50 diff --git a/backend/config/config.go b/backend/config/config.go index 39250cb..0e35d86 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -46,7 +46,7 @@ func GetConfig() *Config { instance = &Config{ ServerPort: getEnv("SERVER_PORT", "8080"), - DatabasePath: filepath.Join(dataDir, "lokideploy.json"), + DatabasePath: databasePath(dataDir), JWTSecret: jwtSecret, EncryptionKey: encKey, DataDir: dataDir, @@ -67,3 +67,16 @@ func getEnv(key, defaultVal string) string { } return defaultVal } + +// databasePath 新名优先;旧 lokideploy.json 存在且新文件不存在时沿用旧文件(数据平滑迁移) +func databasePath(dataDir string) string { + newPath := filepath.Join(dataDir, "opsxloki.json") + if _, err := os.Stat(newPath); err == nil { + return newPath + } + oldPath := filepath.Join(dataDir, "lokideploy.json") + if _, err := os.Stat(oldPath); err == nil { + return oldPath + } + return newPath +} diff --git a/backend/controllers/auth.go b/backend/controllers/auth.go index 5b976f9..596760e 100644 --- a/backend/controllers/auth.go +++ b/backend/controllers/auth.go @@ -1,11 +1,11 @@ package controllers import ( - "lokideploy/dto" - "lokideploy/models" - "lokideploy/pkg/jwt" - "lokideploy/services" - "lokideploy/utils" + "opsxloki/dto" + "opsxloki/models" + "opsxloki/pkg/jwt" + "opsxloki/services" + "opsxloki/utils" "github.com/gin-gonic/gin" ) @@ -41,11 +41,27 @@ func (ctrl *AuthController) RefreshToken(c *gin.Context) { if !utils.BindAndValidate(c, &req) { return } - tokenPair, err := jwt.RefreshAccessToken(req.RefreshToken) + claims, err := jwt.ParseRefreshToken(req.RefreshToken) if err != nil { utils.Unauthorized(c, "invalid or expired refresh token") return } + // 对照数据库校验用户存在性与会话版本:改密/重置密码后旧 refresh token 立即失效 + user, err := ctrl.service.GetUserByID(claims.UserID) + if err != nil { + utils.Unauthorized(c, "user no longer exists") + return + } + if claims.TokenVersion != user.TokenVersion { + utils.Unauthorized(c, "session revoked, please login again") + return + } + // 以用户当前 role 与版本签发,角色变更在刷新时即时生效 + tokenPair, err := jwt.GenerateTokenPair(user.ID, user.Username, user.Role, user.TokenVersion) + if err != nil { + utils.Error(c, 500, err.Error()) + return + } utils.Success(c, gin.H{ "accessToken": tokenPair.AccessToken, "refreshToken": tokenPair.RefreshToken, @@ -53,22 +69,6 @@ func (ctrl *AuthController) RefreshToken(c *gin.Context) { }) } -func (ctrl *AuthController) Register(c *gin.Context) { - var req dto.RegisterRequest - if !utils.BindAndValidate(c, &req) { - return - } - if req.Role == "" { - req.Role = "user" - } - user, err := ctrl.service.Register(req.Username, req.Password, req.Nickname, req.Role) - if err != nil { - utils.Error(c, 400, err.Error()) - return - } - utils.Success(c, models.SanitizedJSON(user)) -} - func (ctrl *AuthController) GetUser(c *gin.Context) { claims, ok := jwt.GetUserFromContext(c) if !ok { diff --git a/backend/controllers/cluster.go b/backend/controllers/cluster.go index e19fbb5..d3853bf 100644 --- a/backend/controllers/cluster.go +++ b/backend/controllers/cluster.go @@ -1,10 +1,10 @@ package controllers import ( - "lokideploy/dto" - "lokideploy/models" - "lokideploy/services" - "lokideploy/utils" + "opsxloki/dto" + "opsxloki/models" + "opsxloki/services" + "opsxloki/utils" "github.com/gin-gonic/gin" ) diff --git a/backend/controllers/collector.go b/backend/controllers/collector.go index 714de27..0cfd812 100644 --- a/backend/controllers/collector.go +++ b/backend/controllers/collector.go @@ -1,10 +1,10 @@ package controllers import ( - "lokideploy/dto" - "lokideploy/models" - "lokideploy/services" - "lokideploy/utils" + "opsxloki/dto" + "opsxloki/models" + "opsxloki/services" + "opsxloki/utils" "github.com/gin-gonic/gin" ) diff --git a/backend/controllers/deployment.go b/backend/controllers/deployment.go index 36e0777..6e808dd 100644 --- a/backend/controllers/deployment.go +++ b/backend/controllers/deployment.go @@ -1,10 +1,12 @@ package controllers import ( - "lokideploy/dto" - "lokideploy/models" - "lokideploy/services" - "lokideploy/utils" + "context" + "time" + "opsxloki/dto" + "opsxloki/models" + "opsxloki/services" + "opsxloki/utils" "github.com/gin-gonic/gin" ) @@ -37,6 +39,8 @@ func (ctrl *DeploymentController) Create(c *gin.Context) { ResourcesCPU: req.ResourcesCPU, ResourcesMem: req.ResourcesMem, StorageSize: req.StorageSize, + GrafanaEnabled: req.GrafanaEnabled, + GrafanaVersion: req.GrafanaVersion, }) if err != nil { utils.Error(c, 400, err.Error()) @@ -59,6 +63,34 @@ func (ctrl *DeploymentController) Get(c *gin.Context) { utils.Success(c, models.SanitizedJSON(deployment)) } +// Ready 部署就绪探测(经自动注册的数据源访问 Loki /ready) +func (ctrl *DeploymentController) Ready(c *gin.Context) { + id, err := utils.IntParam(c, "id") + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + d, err := ctrl.service.Get(uint(id)) + if err != nil { + utils.NotFound(c, "部署不存在") + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + ready, msg := services.DeploymentReady(ctx, d.Name) + utils.Success(c, gin.H{"ready": ready, "message": msg}) +} + +// Preflight 向导预检:聚合存储/集群/主机/Loki/采集器状态 +func (ctrl *DeploymentController) Preflight(c *gin.Context) { + utils.Success(c, ctrl.service.Preflight()) +} + +// GetVersions 返回支持的 Loki 版本列表(前端部署表单版本下拉数据源) +func (ctrl *DeploymentController) GetVersions(c *gin.Context) { + utils.Success(c, gin.H{"versions": services.LokiVersionList}) +} + func (ctrl *DeploymentController) List(c *gin.Context) { page, pageSize := utils.Pagination(c) deployments, total, err := ctrl.service.List(page, pageSize) diff --git a/backend/controllers/host.go b/backend/controllers/host.go index 1238d25..c6162e7 100644 --- a/backend/controllers/host.go +++ b/backend/controllers/host.go @@ -1,11 +1,10 @@ package controllers import ( - "lokideploy/dto" - "lokideploy/models" - "lokideploy/pkg/crypto" - "lokideploy/services" - "lokideploy/utils" + "opsxloki/dto" + "opsxloki/models" + "opsxloki/services" + "opsxloki/utils" "github.com/gin-gonic/gin" ) @@ -111,12 +110,7 @@ func (ctrl *HostController) GetInfo(c *gin.Context) { return } - password := host.Password - if len(password) > 4 && password[:4] == "ENC:" { - password, _ = crypto.Decrypt(password[4:]) - } - - client, err := services.NewSSHClient(host.HostIP, host.Port, host.User, password) + client, err := services.NewSSHClientWithAuth(services.HostSSHAuth(host)) if err != nil { utils.Error(c, 500, err.Error()) return diff --git a/backend/controllers/ldap.go b/backend/controllers/ldap.go new file mode 100644 index 0000000..a1802d8 --- /dev/null +++ b/backend/controllers/ldap.go @@ -0,0 +1,81 @@ +package controllers + +import ( + "opsxloki/dto" + "opsxloki/services" + "opsxloki/utils" + + "github.com/gin-gonic/gin" +) + +// LdapController LDAP 配置与用户导入(配置读写仅 admin) +type LdapController struct{} + +func NewLdapController() *LdapController { + return &LdapController{} +} + +// GetConfig LDAP 配置(密码剔除,附 hasPassword/configured) +func (ctrl *LdapController) GetConfig(c *gin.Context) { + utils.Success(c, services.GetLDAPPublicConfig()) +} + +// UpdateConfig 保存配置(bindPassword 空则保留旧值) +func (ctrl *LdapController) UpdateConfig(c *gin.Context) { + var req dto.LdapConfigRequest + if !utils.BindAndValidate(c, &req) { + return + } + cfg := &services.LDAPConfig{ + Enabled: req.Enabled, Host: req.Host, Port: req.Port, UseSSL: req.UseSSL, + BindDN: req.BindDN, BindPassword: req.BindPassword, BaseDN: req.BaseDN, + UserFilter: req.UserFilter, UsernameAttr: req.UsernameAttr, + NameAttr: req.NameAttr, EmailAttr: req.EmailAttr, + } + if err := services.SaveLDAPConfig(cfg); err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, services.GetLDAPPublicConfig()) +} + +// TestConnection 用表单参数测试(未保存也可测;密码空回退已存;可选用户级验证) +func (ctrl *LdapController) TestConnection(c *gin.Context) { + var req dto.LdapConfigRequest + if !utils.BindAndValidate(c, &req) { + return + } + cfg := &services.LDAPConfig{ + Enabled: req.Enabled, Host: req.Host, Port: req.Port, UseSSL: req.UseSSL, + BindDN: req.BindDN, BindPassword: req.BindPassword, BaseDN: req.BaseDN, + UserFilter: req.UserFilter, UsernameAttr: req.UsernameAttr, + NameAttr: req.NameAttr, EmailAttr: req.EmailAttr, + } + if cfg.Port == 0 { + cfg.Port = 389 + } + result := services.TestLDAPConnection(cfg, req.TestUsername, req.TestPassword) + utils.Success(c, result) +} + +// ListUsers 导入预览:按 ouDN(空用 baseDN)搜索用户 +func (ctrl *LdapController) ListUsers(c *gin.Context) { + var req dto.LdapUsersRequest + if !utils.BindAndValidate(c, &req) { + return + } + cfg := services.GetLDAPConfig() + if !cfg.Enabled || cfg.Host == "" { + utils.Error(c, 400, "LDAP 未启用或未配置") + return + } + if req.BindPassword != "" { + cfg.BindPassword = req.BindPassword + } + users, err := services.SearchLDAPUsersForImport(cfg, req.OUDN) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, users) +} diff --git a/backend/controllers/log_policy.go b/backend/controllers/log_policy.go new file mode 100644 index 0000000..a665f5b --- /dev/null +++ b/backend/controllers/log_policy.go @@ -0,0 +1,59 @@ +package controllers + +import ( + "strconv" + + "opsxloki/models" + "opsxloki/services" + "opsxloki/utils" + + "github.com/gin-gonic/gin" +) + +func (ctrl *LogsController) ListPolicyGroups(c *gin.Context) { + utils.Success(c, models.SanitizedJSON(services.ListLogPolicyGroups())) +} + +func (ctrl *LogsController) CreatePolicyGroup(c *gin.Context) { + var group models.LogPolicyGroup + if !utils.BindAndValidate(c, &group) { + return + } + created, err := services.CreateLogPolicyGroup(group) + if err != nil { + utils.BadRequest(c, err.Error()) + return + } + utils.Success(c, models.SanitizedJSON(created)) +} + +func (ctrl *LogsController) UpdatePolicyGroup(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(c, "无效的策略组 ID") + return + } + var group models.LogPolicyGroup + if !utils.BindAndValidate(c, &group) { + return + } + updated, err := services.UpdateLogPolicyGroup(uint(id), group) + if err != nil { + utils.BadRequest(c, err.Error()) + return + } + utils.Success(c, models.SanitizedJSON(updated)) +} + +func (ctrl *LogsController) DeletePolicyGroup(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(c, "无效的策略组 ID") + return + } + if err := services.DeleteLogPolicyGroup(uint(id)); err != nil { + utils.BadRequest(c, err.Error()) + return + } + utils.Success(c, gin.H{"message": "deleted"}) +} diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go new file mode 100644 index 0000000..6b58607 --- /dev/null +++ b/backend/controllers/logs.go @@ -0,0 +1,280 @@ +package controllers + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" + + "opsxloki/dto" + "opsxloki/models" + "opsxloki/services" + "opsxloki/utils" + + "github.com/gin-gonic/gin" +) + +func (ctrl *LogsController) Context(c *gin.Context) { + var req dto.LogContextRequest + if !utils.BindAndValidate(c, &req) { + return + } + src := services.GetLogSource(req.SourceID) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + ts, err := strconv.ParseInt(req.TimestampNs, 10, 64) + if err != nil || ts < 1 { + utils.BadRequest(c, "时间戳无效") + return + } + parts := make([]string, 0, len(req.Labels)) + for key, value := range req.Labels { + if !services.ValidLogLabelName(key) { + utils.BadRequest(c, "标签名无效") + return + } + encoded, _ := json.Marshal(value) + parts = append(parts, key+"="+string(encoded)) + } + query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{"+strings.Join(parts, ",")+"}") + if err != nil { + utils.Forbidden(c, err.Error()) + return + } + span := int64(5 * time.Minute) + before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, 1, "backward", "") + if err != nil { + utils.Error(c, 502, err.Error()) + return + } + after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, 1, "forward", "") + if err != nil { + utils.Error(c, 502, err.Error()) + return + } + utils.Success(c, gin.H{"before": before.Entries, "after": after.Entries}) +} + +// LogsController 日志查询代理 + 数据源管理 +type LogsController struct{} + +func NewLogsController() *LogsController { + return &LogsController{} +} + +// ==================== 数据源 ==================== + +func (ctrl *LogsController) ListSources(c *gin.Context) { + list := services.ListVisibleLogSources(c.GetUint("userId"), c.GetString("role") == "admin") + utils.Success(c, utils.PageData{List: list, Total: int64(len(list)), Page: 1, PageSize: 100}) +} + +func (ctrl *LogsController) CreateSource(c *gin.Context) { + var req dto.LogSourceRequest + if !utils.BindAndValidate(c, &req) { + return + } + if req.Name == "" || req.TargetType == "" { + utils.BadRequest(c, "名称与类型必填") + return + } + if err := services.ValidateSourceAccess(req.Access); err != nil { + utils.BadRequest(c, err.Error()) + return + } + src := &models.LogSource{ + Name: req.Name, TargetType: req.TargetType, + Namespace: req.Namespace, Service: req.Service, Port: req.Port, + URL: req.URL, AuthType: req.AuthType, BasicUser: req.BasicUser, + BasicPass: req.BasicPass, BearerToken: req.BearerToken, Headers: req.Headers, SkipTLSVerify: req.SkipTLSVerify, IsDefault: req.IsDefault, Access: req.Access, + } + if req.TargetType == "cluster-svc" { + if req.ClusterID > 0 { + id := req.ClusterID + src.ClusterID = &id + } else { + utils.BadRequest(c, "cluster-svc 类型必须选择集群") + return + } + } + created, err := services.CreateLogSource(src) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, created.Sanitized()) +} + +func (ctrl *LogsController) UpdateSource(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + var updates map[string]interface{} + if !utils.BindAndValidate(c, &updates) { + return + } + if err := services.UpdateLogSource(uint(id), updates); err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"message": "updated"}) +} + +func (ctrl *LogsController) DeleteSource(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + if err := services.DeleteLogSource(uint(id)); err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"message": "deleted"}) +} + +// TestSource 数据源连通性测试(按 id 或表单参数) +func (ctrl *LogsController) TestSource(c *gin.Context) { + var req dto.LogSourceRequest + if !utils.BindAndValidate(c, &req) { + return + } + var src *models.LogSource + if req.ID > 0 { + src = services.GetLogSource(req.ID) + if src == nil { + utils.NotFound(c, "数据源不存在") + return + } + } else { + src = &models.LogSource{ + TargetType: req.TargetType, Namespace: req.Namespace, Service: req.Service, + Port: req.Port, URL: req.URL, AuthType: req.AuthType, + BasicUser: req.BasicUser, BasicPass: req.BasicPass, BearerToken: req.BearerToken, Headers: req.Headers, SkipTLSVerify: req.SkipTLSVerify, + } + if req.TargetType == "cluster-svc" && req.ClusterID > 0 { + id := req.ClusterID + src.ClusterID = &id + } + } + utils.Success(c, services.TestLogSource(c.Request.Context(), src)) +} + +// ==================== 查询代理 ==================== + +func (ctrl *LogsController) Query(c *gin.Context) { + var req dto.LogQueryRequest + if !utils.BindAndValidate(c, &req) { + return + } + src := services.GetLogSource(req.SourceID) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", req.Query) + if err != nil { + utils.Forbidden(c, err.Error()) + return + } + result, err := services.QueryLokiLogs(c.Request.Context(), src, query, + req.Start, req.End, req.Limit, req.Direction, req.Cursor) + if err != nil { + utils.Error(c, 502, err.Error()) + return + } + utils.Success(c, result) +} + +func (ctrl *LogsController) Labels(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64) + src := services.GetLogSource(uint(id)) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + if _, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{}"); err != nil { + utils.Forbidden(c, err.Error()) + return + } + start, _ := strconv.ParseInt(c.Query("start"), 10, 64) + end, _ := strconv.ParseInt(c.Query("end"), 10, 64) + labels, err := services.LokiLabels(c.Request.Context(), src, start, end) + if err != nil { + utils.Error(c, 502, err.Error()) + return + } + utils.Success(c, labels) +} + +func (ctrl *LogsController) LabelValues(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64) + src := services.GetLogSource(uint(id)) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + start, _ := strconv.ParseInt(c.Query("start"), 10, 64) + end, _ := strconv.ParseInt(c.Query("end"), 10, 64) + requestedQuery := c.Query("query") + if strings.TrimSpace(requestedQuery) == "" { + requestedQuery = "{}" + } + query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", requestedQuery) + if err != nil { + utils.Forbidden(c, err.Error()) + return + } + values, err := services.LokiLabelValues(c.Request.Context(), src, + c.Param("name"), query, start, end) + if err != nil { + utils.Error(c, 502, err.Error()) + return + } + utils.Success(c, values) +} + +// Health Loki /ready 探活(查询页状态点) +func (ctrl *LogsController) Health(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("sourceId"), 10, 64) + src := services.GetLogSource(uint(id)) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + if _, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{}"); err != nil { + utils.Forbidden(c, err.Error()) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + utils.Success(c, gin.H{"ready": services.LokiReady(ctx, src)}) +} + +func (ctrl *LogsController) Histogram(c *gin.Context) { + var req dto.LogQueryRequest + if !utils.BindAndValidate(c, &req) { + return + } + src := services.GetLogSource(req.SourceID) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", req.Query) + if err != nil { + utils.Forbidden(c, err.Error()) + return + } + buckets, err := services.LokiHistogram(c.Request.Context(), src, query, req.Start, req.End) + if err != nil { + utils.Error(c, 502, err.Error()) + return + } + utils.Success(c, buckets) +} diff --git a/backend/controllers/storage.go b/backend/controllers/storage.go index 8028512..afcce7c 100644 --- a/backend/controllers/storage.go +++ b/backend/controllers/storage.go @@ -1,10 +1,10 @@ package controllers import ( - "lokideploy/dto" - "lokideploy/models" - "lokideploy/services" - "lokideploy/utils" + "opsxloki/dto" + "opsxloki/models" + "opsxloki/services" + "opsxloki/utils" "github.com/gin-gonic/gin" ) @@ -96,3 +96,22 @@ func (ctrl *StorageController) GetDefault(c *gin.Context) { } utils.Success(c, models.SanitizedJSON(storage)) } + +// TestConnection 测试对象存储连接(凭证 + 桶存在性,可选自动创建;不落库) +func (ctrl *StorageController) TestConnection(c *gin.Context) { + var req dto.StorageTestRequest + if !utils.BindAndValidate(c, &req) { + return + } + result := services.TestStorageConnection(services.StorageTestParams{ + StorageType: req.StorageType, + Endpoint: req.Endpoint, + Bucket: req.Bucket, + Region: req.Region, + AccessKey: req.AccessKey, + SecretKey: req.SecretKey, + Insecure: req.Insecure, + CreateIfMissing: req.CreateIfMissing, + }) + utils.Success(c, result) +} diff --git a/backend/controllers/user.go b/backend/controllers/user.go new file mode 100644 index 0000000..c791437 --- /dev/null +++ b/backend/controllers/user.go @@ -0,0 +1,110 @@ +package controllers + +import ( + "strconv" + + "opsxloki/dto" + "opsxloki/models" + "opsxloki/pkg/jwt" + "opsxloki/services" + "opsxloki/utils" + + "github.com/gin-gonic/gin" +) + +// UserController 用户管理(全部端点仅 admin 可用,无 RBAC) +type UserController struct { + service *services.AuthService +} + +func NewUserController() *UserController { + return &UserController{service: services.NewAuthService()} +} + +// List 分页返回用户列表(Sanitized 输出,密码哈希不出库) +func (ctrl *UserController) List(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + users, total := ctrl.service.ListUsers(page, pageSize) + list := make([]map[string]interface{}, len(users)) + for i := range users { + list[i] = users[i].Sanitized() + } + utils.Success(c, utils.PageData{List: list, Total: total, Page: page, PageSize: pageSize}) +} + +// Create 创建用户(原 /auth/register 逻辑收编至此) +func (ctrl *UserController) Create(c *gin.Context) { + var req dto.UserCreateRequest + if !utils.BindAndValidate(c, &req) { + return + } + role := req.Role + if role == "" { + role = "user" + } + user, err := ctrl.service.Register(req.Username, req.Password, req.Nickname, role) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, models.SanitizedJSON(user)) +} + +// Delete 删除用户(守卫:不能删自己 / 最后一个 admin) +func (ctrl *UserController) Delete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(c, "无效的用户 ID") + return + } + claims, ok := jwt.GetUserFromContext(c) + if !ok { + utils.Unauthorized(c, "") + return + } + if err := ctrl.service.DeleteUser(uint(id), claims.UserID); err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"message": "user deleted"}) +} + +// ResetPassword 管理员重置密码(递增 TokenVersion,目标用户旧会话全部失效) +func (ctrl *UserController) ResetPassword(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(c, "无效的用户 ID") + return + } + var req dto.ResetPasswordRequest + if !utils.BindAndValidate(c, &req) { + return + } + if err := ctrl.service.ResetPassword(uint(id), req.NewPassword); err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"message": "password reset"}) +} + +// BatchImportLDAP 从 LDAP 批量导入用户(冲突策略见 services.BatchImportLDAPUsers) +func (ctrl *UserController) BatchImportLDAP(c *gin.Context) { + var req dto.LdapImportRequest + if !utils.BindAndValidate(c, &req) { + return + } + users := make([]services.LDAPImportUser, 0, len(req.Users)) + for _, u := range req.Users { + users = append(users, services.LDAPImportUser{DN: u.DN, Username: u.Username, Nickname: u.Nickname, Email: u.Email}) + } + result := services.BatchImportLDAPUsers(users) + utils.Success(c, result) +} diff --git a/backend/dto/deployment.go b/backend/dto/deployment.go index 6c1c695..f6dff96 100644 --- a/backend/dto/deployment.go +++ b/backend/dto/deployment.go @@ -16,4 +16,6 @@ type CreateDeploymentRequest struct { ResourcesCPU string `json:"resourcesCPU"` ResourcesMem string `json:"resourcesMem"` StorageSize string `json:"storageSize"` + GrafanaEnabled bool `json:"grafanaEnabled"` + GrafanaVersion string `json:"grafanaVersion"` } diff --git a/backend/dto/ldap.go b/backend/dto/ldap.go new file mode 100644 index 0000000..ec5848b --- /dev/null +++ b/backend/dto/ldap.go @@ -0,0 +1,34 @@ +package dto + +// LDAP 域 +type LdapConfigRequest struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + UseSSL bool `json:"useSSL"` + BindDN string `json:"bindDN"` + BindPassword string `json:"bindPassword"` + BaseDN string `json:"baseDN"` + UserFilter string `json:"userFilter"` + UsernameAttr string `json:"usernameAttr"` + NameAttr string `json:"nameAttr"` + EmailAttr string `json:"emailAttr"` + TestUsername string `json:"testUsername"` + TestPassword string `json:"testPassword"` +} + +type LdapUsersRequest struct { + OUDN string `json:"ouDN"` + BindPassword string `json:"bindPassword"` +} + +type LdapImportRequest struct { + Users []LdapImportUser `json:"users" binding:"required,min=1"` +} + +type LdapImportUser struct { + DN string `json:"dn"` + Username string `json:"username" binding:"required"` + Nickname string `json:"nickname"` + Email string `json:"email"` +} diff --git a/backend/dto/logs.go b/backend/dto/logs.go new file mode 100644 index 0000000..99bd3f0 --- /dev/null +++ b/backend/dto/logs.go @@ -0,0 +1,39 @@ +package dto + +import "opsxloki/models" + +// 日志查询域 +type LogSourceRequest struct { + ID uint `json:"id"` + Name string `json:"name"` + TargetType string `json:"targetType" binding:"omitempty,oneof=cluster-svc url"` + ClusterID uint `json:"clusterID"` + Namespace string `json:"namespace"` + Service string `json:"service"` + Port int `json:"port"` + URL string `json:"url"` + AuthType string `json:"authType"` + BasicUser string `json:"basicUser"` + BasicPass string `json:"basicPass"` + BearerToken string `json:"bearerToken"` + Headers map[string]string `json:"headers"` + SkipTLSVerify bool `json:"skipTLSVerify"` + IsDefault bool `json:"isDefault"` + Access []models.LogSourceAccess `json:"access"` +} + +type LogQueryRequest struct { + SourceID uint `json:"sourceId" binding:"required"` + Query string `json:"query" binding:"required"` + Start int64 `json:"start"` // epoch 纳秒 + End int64 `json:"end"` + Limit int `json:"limit"` + Direction string `json:"direction"` + Cursor string `json:"cursor"` +} + +type LogContextRequest struct { + SourceID uint `json:"sourceId" binding:"required"` + TimestampNs string `json:"timestampNs" binding:"required"` + Labels map[string]string `json:"labels" binding:"required"` +} diff --git a/backend/dto/storage.go b/backend/dto/storage.go index 52de35d..4411082 100644 --- a/backend/dto/storage.go +++ b/backend/dto/storage.go @@ -13,3 +13,15 @@ type CreateStorageRequest struct { Insecure bool `json:"insecure"` IsDefault bool `json:"isDefault"` } + +// StorageTestRequest 测试连接(不落库;createIfMissing 时桶不存在则自动创建) +type StorageTestRequest struct { + StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"` + Endpoint string `json:"endpoint" binding:"required"` + Bucket string `json:"bucket" binding:"required"` + Region string `json:"region"` + AccessKey string `json:"accessKey" binding:"required"` + SecretKey string `json:"secretKey" binding:"required"` + Insecure bool `json:"insecure"` + CreateIfMissing bool `json:"createIfMissing"` +} diff --git a/backend/dto/user.go b/backend/dto/user.go new file mode 100644 index 0000000..c8bfee7 --- /dev/null +++ b/backend/dto/user.go @@ -0,0 +1,13 @@ +package dto + +// 用户管理域(admin 专用) +type UserCreateRequest struct { + Username string `json:"username" binding:"required,min=2,max=32"` + Password string `json:"password" binding:"required,min=6"` + Nickname string `json:"nickname"` + Role string `json:"role" binding:"omitempty,oneof=admin user"` +} + +type ResetPasswordRequest struct { + NewPassword string `json:"newPassword" binding:"required,min=6"` +} diff --git a/backend/go.mod b/backend/go.mod index a820ac2..c0e4843 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,4 +1,4 @@ -module lokideploy +module opsxloki go 1.24.0 @@ -7,12 +7,14 @@ toolchain go1.24.3 require ( github.com/gin-contrib/cors v1.7.3 github.com/gin-gonic/gin v1.10.0 + github.com/go-ldap/ldap/v3 v3.4.12 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 golang.org/x/crypto v0.47.0 ) require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/bytedance/sonic v1.12.6 // indirect github.com/bytedance/sonic/loader v0.2.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect @@ -20,6 +22,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/gabriel-vasile/mimetype v1.4.7 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.23.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 69cc5f0..aebc5e7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk= github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -20,6 +24,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -37,6 +45,20 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= diff --git a/backend/main.go b/backend/main.go index 5edd4a6..3c98e82 100644 --- a/backend/main.go +++ b/backend/main.go @@ -9,10 +9,10 @@ import ( "syscall" "time" - "lokideploy/config" - "lokideploy/repository" - "lokideploy/routers" - "lokideploy/seed" + "opsxloki/config" + "opsxloki/repository" + "opsxloki/routers" + "opsxloki/seed" ) func main() { @@ -26,6 +26,10 @@ func main() { log.Println("Seeding default data...") seed.Seed() + // 孤儿部署状态恢复:进程重启会杀掉进行中的部署 goroutine, + // 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。 + repository.GetDB().RecoverOrphanDeployments() + r := routers.SetupRouter() srv := &http.Server{ Addr: ":" + cfg.ServerPort, @@ -41,7 +45,7 @@ func main() { defer stop() go func() { - log.Printf("LokiDeploy starting on port %s", cfg.ServerPort) + log.Printf("opsx-loki starting on port %s", cfg.ServerPort) log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort) log.Printf("Default login: admin / admin123") if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { diff --git a/backend/middleware/jwt.go b/backend/middleware/jwt.go index d489402..961fa48 100644 --- a/backend/middleware/jwt.go +++ b/backend/middleware/jwt.go @@ -3,8 +3,10 @@ package middleware import ( "strings" - "lokideploy/pkg/jwt" - "lokideploy/utils" + "opsxloki/models" + "opsxloki/pkg/jwt" + "opsxloki/repository" + "opsxloki/utils" "github.com/gin-gonic/gin" ) @@ -39,10 +41,34 @@ func JWTAuth() gin.HandlerFunc { return } + // 会话版本校验:改密/重置密码后 TokenVersion 递增,旧 token 立即失效; + // 同时以数据库中的最新 role 为准(角色变更即时生效,不依赖旧 claims)。 + db := repository.GetDB() + db.RLock() + var current *models.User + for i := range db.Users { + if db.Users[i].ID == claims.UserID { + u := db.Users[i] + current = &u + break + } + } + db.RUnlock() + if current == nil { + utils.Unauthorized(c, "user no longer exists") + c.Abort() + return + } + if claims.TokenVersion != current.TokenVersion { + utils.Unauthorized(c, "session revoked, please login again") + c.Abort() + return + } + c.Set("user", claims) - c.Set("userId", claims.UserID) - c.Set("username", claims.Username) - c.Set("role", claims.Role) + c.Set("userId", current.ID) + c.Set("username", current.Username) + c.Set("role", current.Role) c.Next() } } diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go new file mode 100644 index 0000000..23d652c --- /dev/null +++ b/backend/middleware/ratelimit.go @@ -0,0 +1,84 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "opsxloki/utils" + + "github.com/gin-gonic/gin" +) + +// ipLimiter 内存滑动窗口 IP 限流(借鉴 opsx-web-lite rate_limit.go 的简化版)。 +// 用于登录防爆破:超限返回 429。单实例部署下内存实现即可,无需外部存储。 +type ipLimiter struct { + mu sync.Mutex + window time.Duration + max int + hits map[string][]time.Time + lastSweep time.Time +} + +func newIPLimiter(max int, window time.Duration) *ipLimiter { + return &ipLimiter{ + window: window, + max: max, + hits: make(map[string][]time.Time), + } +} + +// Allow 记录一次命中并判断是否放行 +func (l *ipLimiter) Allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + // 惰性清理过期记录,防止 map 无限增长 + if now.Sub(l.lastSweep) > time.Minute { + for k, v := range l.hits { + kept := v[:0] + for _, t := range v { + if now.Sub(t) < l.window { + kept = append(kept, t) + } + } + if len(kept) == 0 { + delete(l.hits, k) + } else { + l.hits[k] = kept + } + } + l.lastSweep = now + } + + history := l.hits[ip] + kept := history[:0] + for _, t := range history { + if now.Sub(t) < l.window { + kept = append(kept, t) + } + } + if len(kept) >= l.max { + l.hits[ip] = kept + return false + } + l.hits[ip] = append(kept, now) + return true +} + +// LoginRateLimit 登录接口 IP 限流:max 次 / window 时长 +func LoginRateLimit(max int, window time.Duration) gin.HandlerFunc { + limiter := newIPLimiter(max, window) + return func(c *gin.Context) { + if !limiter.Allow(c.ClientIP()) { + c.JSON(http.StatusTooManyRequests, utils.Response{ + Code: http.StatusTooManyRequests, + Message: "登录尝试过于频繁,请稍后再试", + }) + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/models/cluster.go b/backend/models/cluster.go index 6d2694a..537eafc 100644 --- a/backend/models/cluster.go +++ b/backend/models/cluster.go @@ -3,7 +3,7 @@ package models import ( "time" - "lokideploy/pkg/crypto" + "opsxloki/pkg/crypto" ) type Cluster struct { diff --git a/backend/models/deployment.go b/backend/models/deployment.go index d54c007..bf57c6a 100644 --- a/backend/models/deployment.go +++ b/backend/models/deployment.go @@ -26,6 +26,10 @@ type LokiDeployment struct { ResourcesCPU string `json:"resourcesCPU"` ResourcesMem string `json:"resourcesMem"` StorageSize string `json:"storageSize"` + GrafanaEnabled bool `json:"grafanaEnabled"` + GrafanaVersion string `json:"grafanaVersion"` + GrafanaPort int `json:"grafanaPort"` + GrafanaEndpoint string `json:"grafanaEndpoint"` Status string `json:"status"` StatusMessage string `json:"statusMessage"` HelmValues string `json:"helmValues"` @@ -62,6 +66,10 @@ func (d *LokiDeployment) Sanitized() map[string]interface{} { "resourcesCPU": d.ResourcesCPU, "resourcesMem": d.ResourcesMem, "storageSize": d.StorageSize, + "grafanaEnabled": d.GrafanaEnabled, + "grafanaVersion": d.GrafanaVersion, + "grafanaPort": d.GrafanaPort, + "grafanaEndpoint": d.GrafanaEndpoint, "status": d.Status, "statusMessage": d.StatusMessage, "helmValues": d.HelmValues, diff --git a/backend/models/host.go b/backend/models/host.go index bee615b..d365e26 100644 --- a/backend/models/host.go +++ b/backend/models/host.go @@ -3,7 +3,7 @@ package models import ( "time" - "lokideploy/pkg/crypto" + "opsxloki/pkg/crypto" ) // SSHAuthConfig SSH认证配置(定义在models包避免循环导入) @@ -19,37 +19,39 @@ type SSHAuthConfig struct { // Host 主机模型,敏感字段(Password/PrivateKey/KeyPhrase)加密存储 // 加密格式: ENC: type Host struct { - ID uint `json:"id"` - Name string `json:"name"` - HostIP string `json:"hostIP"` - Port int `json:"port"` - User string `json:"user"` - AuthType string `json:"authType"` - Password string `json:"password"` // 加密存储(ENC:前缀) - PrivateKey string `json:"privateKey"` // 加密存储(ENC:前缀) - KeyPhrase string `json:"keyPhrase"` // 加密存储(ENC:前缀) - OS string `json:"os"` - Arch string `json:"arch"` - Status string `json:"status"` - Labels string `json:"labels"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `json:"id"` + Name string `json:"name"` + HostIP string `json:"hostIP"` + Port int `json:"port"` + User string `json:"user"` + AuthType string `json:"authType"` + Password string `json:"password"` // 加密存储(ENC:前缀) + PrivateKey string `json:"privateKey"` // 加密存储(ENC:前缀) + KeyPhrase string `json:"keyPhrase"` // 加密存储(ENC:前缀) + OS string `json:"os"` + Arch string `json:"arch"` + Status string `json:"status"` + StatusMessage string `json:"statusMessage,omitempty"` + Labels string `json:"labels"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (h *Host) Sanitized() map[string]interface{} { return map[string]interface{}{ - "id": h.ID, - "name": h.Name, - "hostIP": h.HostIP, - "port": h.Port, - "user": h.User, - "authType": h.AuthType, - "os": h.OS, - "arch": h.Arch, - "status": h.Status, - "labels": h.Labels, - "createdAt": h.CreatedAt, - "updatedAt": h.UpdatedAt, + "id": h.ID, + "name": h.Name, + "hostIP": h.HostIP, + "port": h.Port, + "user": h.User, + "authType": h.AuthType, + "os": h.OS, + "arch": h.Arch, + "status": h.Status, + "statusMessage": h.StatusMessage, + "labels": h.Labels, + "createdAt": h.CreatedAt, + "updatedAt": h.UpdatedAt, } } diff --git a/backend/models/log_policy.go b/backend/models/log_policy.go new file mode 100644 index 0000000..d741fde --- /dev/null +++ b/backend/models/log_policy.go @@ -0,0 +1,22 @@ +package models + +import "time" + +// LogPolicyGroup grants one exact stream-label scope on one source to multiple users. +type LogPolicyGroup struct { + ID uint `json:"id"` + Name string `json:"name"` + SourceID uint `json:"sourceId"` + Labels map[string]string `json:"labels"` + UserIDs []uint `json:"userIds"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (g *LogPolicyGroup) Sanitized() map[string]interface{} { + return map[string]interface{}{ + "id": g.ID, "name": g.Name, "sourceId": g.SourceID, + "labels": g.Labels, "userIds": g.UserIDs, + "createdAt": g.CreatedAt, "updatedAt": g.UpdatedAt, + } +} diff --git a/backend/models/log_source.go b/backend/models/log_source.go new file mode 100644 index 0000000..76e835b --- /dev/null +++ b/backend/models/log_source.go @@ -0,0 +1,56 @@ +package models + +import "time" + +// LogSource 日志查询数据源。 +// cluster-svc: 经目标集群 kube-apiserver 的 service proxy 访问(无需直通 ClusterIP/DNS), +// +// 覆盖「本平台部署的 Loki」与「接入集群中已有的外部 Loki」两类场景; +// +// url: 直连地址(host-binary 部署或网络可达的 Loki)。 +type LogSource struct { + ID uint `json:"id"` + Name string `json:"name"` + TargetType string `json:"targetType"` // cluster-svc | url + ClusterID *uint `json:"clusterID,omitempty"` + Namespace string `json:"namespace"` + Service string `json:"service"` + Port int `json:"port"` + URL string `json:"url"` + AuthType string `json:"authType"` // none | basic | bearer + BasicUser string `json:"basicUser"` + BasicPass string `json:"basicPass"` // ENC: 加密存储,不外发 + BearerToken string `json:"bearerToken"` + Headers map[string]string `json:"headers,omitempty"` + SkipTLSVerify bool `json:"skipTLSVerify"` + IsDefault bool `json:"isDefault"` + Access []LogSourceAccess `json:"access,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (s *LogSource) Sanitized() map[string]interface{} { + return map[string]interface{}{ + "id": s.ID, "name": s.Name, "targetType": s.TargetType, + "clusterID": s.ClusterID, "namespace": s.Namespace, "service": s.Service, "port": s.Port, + "url": s.URL, "authType": s.AuthType, "basicUser": s.BasicUser, + "hasBasicPass": s.BasicPass != "", "isDefault": s.IsDefault, + "hasBearerToken": s.BearerToken != "", "headerNames": headerNames(s.Headers), "skipTLSVerify": s.SkipTLSVerify, + "access": s.Access, + "createdAt": s.CreatedAt, "updatedAt": s.UpdatedAt, + } +} + +func headerNames(headers map[string]string) []string { + names := make([]string, 0, len(headers)) + for name := range headers { + names = append(names, name) + } + return names +} + +// LogSourceAccess grants one user access to a source, optionally restricted to exact stream labels. +type LogSourceAccess struct { + UserID uint `json:"userId"` + Labels map[string]string `json:"labels,omitempty"` +} diff --git a/backend/models/sanitizer.go b/backend/models/sanitizer.go index 5152a4b..8d67200 100644 --- a/backend/models/sanitizer.go +++ b/backend/models/sanitizer.go @@ -80,6 +80,12 @@ func SanitizedJSON(v any) any { result[i] = item.Sanitized() } return result + case []LogPolicyGroup: + result := make([]map[string]interface{}, len(val)) + for i := range val { + result[i] = val[i].Sanitized() + } + return result case []Sanitizer: result := make([]map[string]interface{}, len(val)) for i, item := range val { diff --git a/backend/models/user.go b/backend/models/user.go index ee193b2..04ed929 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -2,14 +2,18 @@ package models import "time" +// User TokenVersion 用于服务端会话吊销:改密/管理员重置密码时递增, +// 旧 token(access 与 refresh)随版本不匹配立即失效(借鉴 opsx-web-lite)。 type User struct { - ID uint `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - Nickname string `json:"nickname"` - Role string `json:"role"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + Nickname string `json:"nickname"` + Role string `json:"role"` + AuthSource string `json:"authSource"` // local | ldap + TokenVersion int `json:"tokenVersion"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (u *User) Sanitized() map[string]interface{} { @@ -18,6 +22,7 @@ func (u *User) Sanitized() map[string]interface{} { "username": u.Username, "nickname": u.Nickname, "role": u.Role, + "authSource": u.AuthSource, "createdAt": u.CreatedAt, "updatedAt": u.UpdatedAt, } diff --git a/backend/pkg/crypto/crypto.go b/backend/pkg/crypto/crypto.go index b9b81d3..d272af7 100644 --- a/backend/pkg/crypto/crypto.go +++ b/backend/pkg/crypto/crypto.go @@ -14,7 +14,7 @@ import ( "io" "strings" - "lokideploy/config" + "opsxloki/config" "golang.org/x/crypto/argon2" "golang.org/x/crypto/hkdf" diff --git a/backend/pkg/crypto/main_test.go b/backend/pkg/crypto/main_test.go index a9b556c..563c77b 100644 --- a/backend/pkg/crypto/main_test.go +++ b/backend/pkg/crypto/main_test.go @@ -10,6 +10,6 @@ import ( func TestMain(m *testing.M) { os.Setenv("JWT_SECRET", "crypto-pkg-test-secret-32chars!!") os.Setenv("ENCRYPTION_KEY", "crypto-pkg-test-encryption-32ch!!") - os.Setenv("DATA_DIR", os.TempDir()+"/lokideploy-crypto-pkg-test") + os.Setenv("DATA_DIR", os.TempDir()+"/opsxloki-crypto-pkg-test") os.Exit(m.Run()) } diff --git a/backend/pkg/jwt/jwt.go b/backend/pkg/jwt/jwt.go index 5552ccb..43cf5ec 100644 --- a/backend/pkg/jwt/jwt.go +++ b/backend/pkg/jwt/jwt.go @@ -8,7 +8,7 @@ import ( "strconv" "time" - "lokideploy/config" + "opsxloki/config" "github.com/gin-gonic/gin" jwtv5 "github.com/golang-jwt/jwt/v5" @@ -26,6 +26,8 @@ type Claims struct { Username string `json:"username"` Role string `json:"role"` TokenType string `json:"tokenType"` // access | refresh + // TokenVersion 签发时的用户会话版本;改密/重置密码后递增,旧 token 立即失效 + TokenVersion int `json:"ver,omitempty"` jwtv5.RegisteredClaims } @@ -36,16 +38,17 @@ type TokenPair struct { ExpiresAt time.Time `json:"expiresAt"` } -func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) { +func GenerateTokenPair(userID uint, username, role string, tokenVersion int) (*TokenPair, error) { cfg := config.GetConfig() now := time.Now() // Access Token accessClaims := Claims{ - UserID: userID, - Username: username, - Role: role, - TokenType: "access", + UserID: userID, + Username: username, + Role: role, + TokenType: "access", + TokenVersion: tokenVersion, RegisteredClaims: jwtv5.RegisteredClaims{ ExpiresAt: jwtv5.NewNumericDate(now.Add(accessTokenExpiry)), IssuedAt: jwtv5.NewNumericDate(now), @@ -61,10 +64,11 @@ func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) { // Refresh Token refreshClaims := Claims{ - UserID: userID, - Username: username, - Role: role, - TokenType: "refresh", + UserID: userID, + Username: username, + Role: role, + TokenType: "refresh", + TokenVersion: tokenVersion, RegisteredClaims: jwtv5.RegisteredClaims{ ExpiresAt: jwtv5.NewNumericDate(now.Add(refreshTokenExpiry)), IssuedAt: jwtv5.NewNumericDate(now), @@ -85,19 +89,18 @@ func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) { }, nil } -// RefreshAccessToken 使用refresh token换取新的access token -func RefreshAccessToken(refreshTokenString string) (*TokenPair, error) { +// ParseRefreshToken 解析并校验 refresh token,返回 claims。 +// 用户存在性与 TokenVersion 校验由调用方(controller)对照数据库完成, +// 通过后用用户当前 role/版本调 GenerateTokenPair 签发新 token 对。 +func ParseRefreshToken(refreshTokenString string) (*Claims, error) { claims, err := ParseToken(refreshTokenString) if err != nil { return nil, err } - - // 验证token类型必须是refresh if claims.TokenType != "refresh" { return nil, errors.New("invalid token type") } - - return GenerateTokenPair(claims.UserID, claims.Username, claims.Role) + return claims, nil } func ParseToken(tokenString string) (*Claims, error) { diff --git a/backend/pkg/jwt/jwt_test.go b/backend/pkg/jwt/jwt_test.go index 1324cf5..3ccb81c 100644 --- a/backend/pkg/jwt/jwt_test.go +++ b/backend/pkg/jwt/jwt_test.go @@ -8,7 +8,7 @@ import ( // TestTokenPairRoundTrip 覆盖签发 + 解析,并校验 access token 类型。 func TestTokenPairRoundTrip(t *testing.T) { - pair, err := GenerateTokenPair(42, "alice", "admin") + pair, err := GenerateTokenPair(42, "alice", "admin", 3) if err != nil { t.Fatalf("GenerateTokenPair: %v", err) } @@ -25,22 +25,25 @@ func TestTokenPairRoundTrip(t *testing.T) { if claims.TokenType != "access" { t.Fatalf("access token should have tokenType=access, got %q", claims.TokenType) } + if claims.TokenVersion != 3 { + t.Fatalf("access token should carry tokenVersion=3, got %d", claims.TokenVersion) + } } // TestRefreshRequiresRefreshToken 覆盖 token 类型强制: -// 用 access token 刷新必须被拒;用 refresh token 刷新应成功。 +// 用 access token 刷新必须被拒;refresh token 应解析成功并携带版本。 func TestRefreshRequiresRefreshToken(t *testing.T) { - pair, _ := GenerateTokenPair(1, "bob", "user") + pair, _ := GenerateTokenPair(1, "bob", "user", 0) - if _, err := RefreshAccessToken(pair.AccessToken); err == nil { - t.Fatal("RefreshAccessToken must reject access tokens") + if _, err := ParseRefreshToken(pair.AccessToken); err == nil { + t.Fatal("ParseRefreshToken must reject access tokens") } - newPair, err := RefreshAccessToken(pair.RefreshToken) + claims, err := ParseRefreshToken(pair.RefreshToken) if err != nil { - t.Fatalf("RefreshAccessToken(refresh): %v", err) + t.Fatalf("ParseRefreshToken(refresh): %v", err) } - if newPair.AccessToken == "" { - t.Fatal("new access token must not be empty") + if claims.UserID != 1 || claims.TokenType != "refresh" { + t.Fatalf("unexpected refresh claims: %+v", claims) } } diff --git a/backend/repository/db.go b/backend/repository/db.go index adfdd4d..41b6eb7 100644 --- a/backend/repository/db.go +++ b/backend/repository/db.go @@ -8,8 +8,8 @@ import ( "sync" "time" - "lokideploy/config" - "lokideploy/models" + "opsxloki/config" + "opsxloki/models" ) // saveRequest 表示一个保存请求,通过channel序列化 @@ -20,17 +20,19 @@ type saveRequest struct { // JsonDB 使用基于channel的序列化保存机制,防止并发数据丢失。 // 该包只负责持久化(落盘 + 并发安全),领域实体定义在 models 包。 type JsonDB struct { - mu sync.RWMutex - dataFile string - Users []models.User `json:"users"` - Clusters []models.Cluster `json:"clusters"` - Hosts []models.Host `json:"hosts"` - Storages []models.StorageConfig `json:"storages"` - Deployments []models.LokiDeployment `json:"deployments"` - Collectors []models.Collector `json:"collectors"` - Logs []models.DeploymentLog `json:"logs"` - Settings []models.SystemSetting `json:"settings"` - sequences map[string]uint + mu sync.RWMutex + dataFile string + Users []models.User `json:"users"` + Clusters []models.Cluster `json:"clusters"` + Hosts []models.Host `json:"hosts"` + Storages []models.StorageConfig `json:"storages"` + Deployments []models.LokiDeployment `json:"deployments"` + Collectors []models.Collector `json:"collectors"` + Logs []models.DeploymentLog `json:"logs"` + Settings []models.SystemSetting `json:"settings"` + LogSources []models.LogSource `json:"logSources"` + LogPolicyGroups []models.LogPolicyGroup `json:"logPolicyGroups"` + sequences map[string]uint // 防抖保存机制 saveTimer *time.Timer @@ -51,23 +53,26 @@ func InitDB() error { _ = os.MkdirAll(cfg.DataDir, 0755) d := &JsonDB{ - dataFile: cfg.DatabasePath, - Users: []models.User{}, - Clusters: []models.Cluster{}, - Hosts: []models.Host{}, - Storages: []models.StorageConfig{}, - Deployments: []models.LokiDeployment{}, - Collectors: []models.Collector{}, - Logs: []models.DeploymentLog{}, - Settings: []models.SystemSetting{}, + dataFile: cfg.DatabasePath, + Users: []models.User{}, + Clusters: []models.Cluster{}, + Hosts: []models.Host{}, + Storages: []models.StorageConfig{}, + Deployments: []models.LokiDeployment{}, + Collectors: []models.Collector{}, + Logs: []models.DeploymentLog{}, + Settings: []models.SystemSetting{}, + LogSources: []models.LogSource{}, + LogPolicyGroups: []models.LogPolicyGroup{}, sequences: map[string]uint{ - "users": 0, - "clusters": 0, - "hosts": 0, - "storages": 0, - "deployments": 0, - "collectors": 0, - "logs": 0, + "users": 0, + "clusters": 0, + "hosts": 0, + "storages": 0, + "deployments": 0, + "collectors": 0, + "logs": 0, + "logPolicyGroups": 0, }, saveChan: make(chan saveRequest, 10), stopChan: make(chan struct{}), @@ -111,17 +116,81 @@ func InitDB() error { d.sequences["logs"] = l.ID } } + for _, group := range d.LogPolicyGroups { + if group.ID > d.sequences["logPolicyGroups"] { + d.sequences["logPolicyGroups"] = group.ID + } + } } } + migratedLogAccess := migrateLogAccessToGroups(d) // 启动序列化保存goroutine d.wg.Add(1) go d.saveWorker() db = d + if migratedLogAccess { + d.RequestSave() + } return nil } +// Legacy per-user grants are grouped by identical source and label scope. +func migrateLogAccessToGroups(d *JsonDB) bool { + changed := false + for i := range d.LogSources { + source := &d.LogSources[i] + if len(source.Access) == 0 { + continue + } + byLabels := map[string]*models.LogPolicyGroup{} + for _, rule := range source.Access { + validUser := false + for _, user := range d.Users { + if user.ID == rule.UserID && user.Role != "admin" { + validUser = true + break + } + } + if !validUser { + continue + } + alreadyGrouped := false + for _, group := range d.LogPolicyGroups { + if group.SourceID != source.ID { + continue + } + for _, id := range group.UserIDs { + if id == rule.UserID { + alreadyGrouped = true + break + } + } + } + if alreadyGrouped { + continue + } + keyBytes, _ := json.Marshal(rule.Labels) + key := string(keyBytes) + group := byLabels[key] + if group == nil { + d.sequences["logPolicyGroups"]++ + id := d.sequences["logPolicyGroups"] + group = &models.LogPolicyGroup{ID: id, Name: fmt.Sprintf("旧授权 · %s · %d", source.Name, id), SourceID: source.ID, Labels: rule.Labels, CreatedAt: time.Now(), UpdatedAt: time.Now()} + byLabels[key] = group + } + group.UserIDs = append(group.UserIDs, rule.UserID) + } + for _, group := range byLabels { + d.LogPolicyGroups = append(d.LogPolicyGroups, *group) + } + source.Access = nil + changed = true + } + return changed +} + // Shutdown 优雅关闭数据库,确保所有数据已落盘。 // 顺序很关键:先停防抖定时器(不再产生新请求)→ 关闭 worker 并等其退出 // (worker 可能正在处理已入队的请求)→ 此时已无并发,做最后一次 doSave 把 @@ -181,7 +250,7 @@ func (d *JsonDB) doSave() error { // 原子写入:先写入临时文件,再重命名 dir := filepath.Dir(d.dataFile) - tmpFile := filepath.Join(dir, fmt.Sprintf(".lokideploy-%d.tmp", time.Now().UnixNano())) + tmpFile := filepath.Join(dir, fmt.Sprintf(".opsxloki-%d.tmp", time.Now().UnixNano())) if err := os.WriteFile(tmpFile, data, 0644); err != nil { return err @@ -275,7 +344,31 @@ func (d *JsonDB) CreateUser(user *models.User) error { user.ID = d.sequences["users"] + 1 d.sequences["users"] = user.ID } + if user.CreatedAt.IsZero() { + now := time.Now() + user.CreatedAt = now + user.UpdatedAt = now + } d.Users = append(d.Users, *user) d.RequestSave() return nil } + +// RecoverOrphanDeployments 启动时把残留的 deploying 状态部署标记为失败: +// 部署 goroutine 随进程消亡,不恢复会永久卡在「部署中」(UI 无停止入口)。 +func (d *JsonDB) RecoverOrphanDeployments() { + d.Lock() + defer d.Unlock() + recovered := 0 + for i := range d.Deployments { + if d.Deployments[i].Status == "deploying" { + d.Deployments[i].Status = "failed" + d.Deployments[i].StatusMessage = "服务重启导致部署中断,请重新部署" + d.Deployments[i].UpdatedAt = time.Now() + recovered++ + } + } + if recovered > 0 { + d.RequestSave() + } +} diff --git a/backend/repository/db_test.go b/backend/repository/db_test.go index 28667c0..c0143f9 100644 --- a/backend/repository/db_test.go +++ b/backend/repository/db_test.go @@ -6,7 +6,7 @@ import ( "sync" "testing" - "lokideploy/models" + "opsxloki/models" ) // TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试: diff --git a/backend/repository/log_policy_test.go b/backend/repository/log_policy_test.go new file mode 100644 index 0000000..9700f94 --- /dev/null +++ b/backend/repository/log_policy_test.go @@ -0,0 +1,26 @@ +package repository + +import ( + "opsxloki/models" + "testing" +) + +func TestMigrateLogAccessToGroups(t *testing.T) { + d := &JsonDB{ + Users: []models.User{{ID: 1, Role: "user"}, {ID: 2, Role: "user"}}, + LogSources: []models.LogSource{{ID: 9, Name: "loki", Access: []models.LogSourceAccess{ + {UserID: 1, Labels: map[string]string{"namespace": "log"}}, + {UserID: 2, Labels: map[string]string{"namespace": "log"}}, + }}}, + sequences: map[string]uint{"logPolicyGroups": 0}, + } + if !migrateLogAccessToGroups(d) { + t.Fatal("expected migration") + } + if len(d.LogSources[0].Access) != 0 { + t.Fatal("legacy grants remain") + } + if len(d.LogPolicyGroups) != 1 || len(d.LogPolicyGroups[0].UserIDs) != 2 { + t.Fatalf("expected one shared group, got %#v", d.LogPolicyGroups) + } +} diff --git a/backend/repository/main_test.go b/backend/repository/main_test.go index 509be08..72bb981 100644 --- a/backend/repository/main_test.go +++ b/backend/repository/main_test.go @@ -4,13 +4,13 @@ import ( "os" "testing" - "lokideploy/config" + "opsxloki/config" ) // TestMain 为 repository 包预置安全环境变量与临时 DATA_DIR,并管理 JsonDB 生命周期。 // config.GetConfig() 在 JWT_SECRET/ENCRYPTION_KEY 缺失时会 log.Fatal,故须先设置。 func TestMain(m *testing.M) { - dir, err := os.MkdirTemp("", "lokideploy-repository-test") + dir, err := os.MkdirTemp("", "opsxloki-repository-test") if err != nil { panic(err) } diff --git a/backend/routers/router.go b/backend/routers/router.go index 4e87aef..d750da2 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "lokideploy/controllers" - "lokideploy/middleware" + "opsxloki/controllers" + "opsxloki/middleware" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -31,6 +31,9 @@ func SetupRouter() *gin.Engine { storageCtrl := controllers.NewStorageController() deploymentCtrl := controllers.NewDeploymentController() collectorCtrl := controllers.NewCollectorController() + userCtrl := controllers.NewUserController() + ldapCtrl := controllers.NewLdapController() + logsCtrl := controllers.NewLogsController() // API v1 api := r.Group("/api/v1") @@ -51,8 +54,8 @@ func SetupRouter() *gin.Engine { c.String(http.StatusOK, "# LokiDeploy Metrics\n") }) - // Public - api.POST("/auth/login", authCtrl.Login) + // Public(登录接口挂 IP 限流防爆破:20 次/分钟) + api.POST("/auth/login", middleware.LoginRateLimit(20, time.Minute), authCtrl.Login) api.POST("/auth/refresh", authCtrl.RefreshToken) // Auth required @@ -62,7 +65,45 @@ func SetupRouter() *gin.Engine { // Auth routes authorized.GET("/auth/user", authCtrl.GetUser) authorized.POST("/auth/password", authCtrl.ChangePassword) - authorized.POST("/auth/register", middleware.AdminAuth(), authCtrl.Register) + + // 日志查询:数据源管理 + Loki 代理查询 + logs := authorized.Group("/logs") + { + logs.GET("/sources", logsCtrl.ListSources) + logs.POST("/sources", middleware.AdminAuth(), logsCtrl.CreateSource) + logs.PUT("/sources/:id", middleware.AdminAuth(), logsCtrl.UpdateSource) + logs.DELETE("/sources/:id", middleware.AdminAuth(), logsCtrl.DeleteSource) + logs.POST("/sources/test", middleware.AdminAuth(), logsCtrl.TestSource) + logs.GET("/policy-groups", middleware.AdminAuth(), logsCtrl.ListPolicyGroups) + logs.POST("/policy-groups", middleware.AdminAuth(), logsCtrl.CreatePolicyGroup) + logs.PUT("/policy-groups/:id", middleware.AdminAuth(), logsCtrl.UpdatePolicyGroup) + logs.DELETE("/policy-groups/:id", middleware.AdminAuth(), logsCtrl.DeletePolicyGroup) + logs.POST("/query", logsCtrl.Query) + logs.POST("/context", logsCtrl.Context) + logs.GET("/labels", logsCtrl.Labels) + logs.GET("/label/:name/values", logsCtrl.LabelValues) + logs.POST("/histogram", logsCtrl.Histogram) + logs.GET("/health", logsCtrl.Health) + } + + // LDAP 配置(admin 读写;查询供用户页判断导入按钮显隐,登录用户即可读) + authorized.GET("/settings/ldap", middleware.AdminAuth(), ldapCtrl.GetConfig) + authorized.PUT("/settings/ldap", middleware.AdminAuth(), ldapCtrl.UpdateConfig) + authorized.POST("/settings/ldap/test", middleware.AdminAuth(), ldapCtrl.TestConnection) + authorized.POST("/settings/ldap/users", middleware.AdminAuth(), ldapCtrl.ListUsers) + + // Users(admin 专用) + users := authorized.Group("/users") + users.Use(middleware.AdminAuth()) + { + users.GET("", userCtrl.List) + users.POST("", userCtrl.Create) + users.DELETE("/:id", userCtrl.Delete) + users.POST("/:id/reset-password", userCtrl.ResetPassword) + users.POST("/batch-import-ldap", userCtrl.BatchImportLDAP) + } + // 普通用户仅可访问日志查询及自己的账号接口;管理资源统一在服务端保护。 + authorized.Use(middleware.AdminAuth()) // Clusters authorized.GET("/clusters", clusterCtrl.List) @@ -87,6 +128,7 @@ func SetupRouter() *gin.Engine { authorized.GET("/storages", storageCtrl.List) authorized.GET("/storages/:id", storageCtrl.Get) authorized.POST("/storages", storageCtrl.Create) + authorized.POST("/storages/test", storageCtrl.TestConnection) authorized.PUT("/storages/:id", storageCtrl.Update) authorized.DELETE("/storages/:id", storageCtrl.Delete) authorized.GET("/storages/default", storageCtrl.GetDefault) @@ -102,6 +144,9 @@ func SetupRouter() *gin.Engine { authorized.POST("/deployments/:id/restart", deploymentCtrl.Restart) authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs) authorized.GET("/deployments/stats", deploymentCtrl.GetStats) + authorized.GET("/deployments/preflight", deploymentCtrl.Preflight) + authorized.GET("/deployments/:id/ready", deploymentCtrl.Ready) + authorized.GET("/deployments/versions", deploymentCtrl.GetVersions) // Collectors authorized.GET("/collectors", collectorCtrl.List) diff --git a/backend/seed/seed.go b/backend/seed/seed.go index c604612..920d35b 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -1,9 +1,9 @@ package seed import ( - "lokideploy/models" - "lokideploy/pkg/crypto" - "lokideploy/repository" + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/repository" ) func Seed() { diff --git a/backend/services/auth.go b/backend/services/auth.go index c7dbccf..70c3253 100644 --- a/backend/services/auth.go +++ b/backend/services/auth.go @@ -1,11 +1,12 @@ package services import ( - "lokideploy/models" - "lokideploy/pkg/crypto" - "lokideploy/pkg/jwt" - "lokideploy/repository" - "lokideploy/utils" + "fmt" + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/pkg/jwt" + "opsxloki/repository" + "opsxloki/utils" ) type AuthService struct{} @@ -17,23 +18,89 @@ func NewAuthService() *AuthService { func (s *AuthService) Login(username, password string) (*models.User, *jwt.TokenPair, error) { db := repository.GetDB() db.RLock() - defer db.RUnlock() + var local *models.User for i := range db.Users { if db.Users[i].Username == username { - if !crypto.VerifyPassword(password, db.Users[i].Password) { - return nil, nil, utils.ErrInvalidCredentials - } - tokenPair, err := jwt.GenerateTokenPair(db.Users[i].ID, db.Users[i].Username, db.Users[i].Role) + u := db.Users[i] + local = &u + break + } + } + db.RUnlock() + + // 本地用户密码校验(普通本地用户唯一路径;LDAP 用户不使用本地密码) + if local != nil && local.AuthSource != "ldap" { + if !crypto.VerifyPassword(password, local.Password) { + return nil, nil, utils.ErrInvalidCredentials + } + return s.issueTokenPair(local) + } + + // LDAP 用户(本地已导入或首次登录自动创建);enabled=false 或验证失败返回统一错误 + if ldapCfg := GetLDAPConfig(); ldapCfg.Enabled && ldapCfg.Host != "" { + if entry, err := verifyLDAPPassword(ldapCfg, username, password); err == nil { + user, err := s.syncLDAPUser(entry) if err != nil { return nil, nil, err } - return &db.Users[i], tokenPair, nil + return s.issueTokenPair(user) } } return nil, nil, utils.ErrInvalidCredentials } +func (s *AuthService) issueTokenPair(u *models.User) (*models.User, *jwt.TokenPair, error) { + pair, err := jwt.GenerateTokenPair(u.ID, u.Username, u.Role, u.TokenVersion) + if err != nil { + return nil, nil, err + } + return u, pair, nil +} + +// syncLDAPUser LDAP 验证通过后同步本地记录:不存在则创建(user 角色,密码占位不可本地登录), +// 存在则同步昵称/邮箱 +func (s *AuthService) syncLDAPUser(entry *ldapUserEntry) (*models.User, error) { + db := repository.GetDB() + // 已存在 → 同步昵称并标记 LDAP 来源 + db.Lock() + for i := range db.Users { + if db.Users[i].Username == entry.Username { + db.Users[i].Nickname = entry.DisplayName + db.Users[i].AuthSource = "ldap" + db.RequestSave() + u := db.Users[i] + db.Unlock() + return &u, nil + } + } + db.Unlock() + + // 不存在 → 创建(CreateUser 自管锁与 ID 分配) + hash, _ := crypto.HashPassword("ldap-user-" + entry.Username + "-no-local-login") + user := &models.User{ + Username: entry.Username, + Password: hash, + Nickname: entry.DisplayName, + Role: "user", + AuthSource: "ldap", + } + if err := db.CreateUser(user); err != nil { + return nil, err + } + return user, nil +} + func (s *AuthService) Register(username, password, nickname, role string) (*models.User, error) { + db := repository.GetDB() + db.RLock() + for i := range db.Users { + if db.Users[i].Username == username { + db.RUnlock() + return nil, utils.ErrUsernameExists + } + } + db.RUnlock() + hash, err := crypto.HashPassword(password) if err != nil { return nil, err @@ -70,6 +137,9 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin defer db.Unlock() for i := range db.Users { if db.Users[i].ID == userID { + if db.Users[i].AuthSource == "ldap" { + return fmt.Errorf("LDAP 用户请在 LDAP 目录中修改密码") + } if !crypto.VerifyPassword(oldPassword, db.Users[i].Password) { return utils.ErrInvalidCredentials } @@ -78,6 +148,111 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin return err } db.Users[i].Password = hash + db.Users[i].TokenVersion++ + db.RequestSave() + return nil + } + } + return utils.ErrNotFound +} + +// ListUsers 分页返回用户列表(按 ID 升序),pageSize 上限 100 +func (s *AuthService) ListUsers(page, pageSize int) ([]models.User, int64) { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + + total := int64(len(db.Users)) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + start := (page - 1) * pageSize + if start >= int(total) { + return []models.User{}, total + } + end := start + pageSize + if end > int(total) { + end = int(total) + } + users := make([]models.User, end-start) + copy(users, db.Users[start:end]) + return users, total +} + +// DeleteUser 删除用户;不能删除自己,不能删除最后一个管理员 +func (s *AuthService) DeleteUser(id, requesterID uint) error { + db := repository.GetDB() + db.Lock() + defer db.Unlock() + + idx := -1 + adminCount := 0 + for i := range db.Users { + if db.Users[i].ID == id { + idx = i + } + if db.Users[i].Role == "admin" { + adminCount++ + } + } + if idx == -1 { + return utils.ErrNotFound + } + if id == requesterID { + return utils.ErrCannotDeleteSelf + } + if db.Users[idx].Role == "admin" && adminCount <= 1 { + return utils.ErrLastAdmin + } + + db.Users = append(db.Users[:idx], db.Users[idx+1:]...) + for i := range db.LogSources { + kept := db.LogSources[i].Access[:0] + for _, rule := range db.LogSources[i].Access { + if rule.UserID != id { + kept = append(kept, rule) + } + } + db.LogSources[i].Access = kept + } + groups := db.LogPolicyGroups[:0] + for _, group := range db.LogPolicyGroups { + members := group.UserIDs[:0] + for _, member := range group.UserIDs { + if member != id { + members = append(members, member) + } + } + group.UserIDs = members + if len(members) > 0 { + groups = append(groups, group) + } + } + db.LogPolicyGroups = groups + db.RequestSave() + return nil +} + +// ResetPassword 管理员重置用户密码,并递增 TokenVersion 吊销其全部旧会话 +func (s *AuthService) ResetPassword(userID uint, newPassword string) error { + db := repository.GetDB() + db.Lock() + defer db.Unlock() + + for i := range db.Users { + if db.Users[i].ID == userID { + if db.Users[i].AuthSource == "ldap" { + return fmt.Errorf("LDAP 用户密码由目录服务管理,不能在此重置") + } + hash, err := crypto.HashPassword(newPassword) + if err != nil { + return err + } + db.Users[i].Password = hash + db.Users[i].TokenVersion++ db.RequestSave() return nil } diff --git a/backend/services/cluster.go b/backend/services/cluster.go index 2720b8c..3929d76 100644 --- a/backend/services/cluster.go +++ b/backend/services/cluster.go @@ -7,10 +7,10 @@ import ( "path/filepath" "time" - "lokideploy/config" - "lokideploy/models" - "lokideploy/repository" - "lokideploy/utils" + "opsxloki/config" + "opsxloki/models" + "opsxloki/repository" + "opsxloki/utils" ) type ClusterService struct{} diff --git a/backend/services/cluster_info.go b/backend/services/cluster_info.go index 79fed94..0aaddcb 100644 --- a/backend/services/cluster_info.go +++ b/backend/services/cluster_info.go @@ -8,7 +8,7 @@ import ( "os/exec" "strings" - "lokideploy/models" + "opsxloki/models" ) type ClusterInfo struct { diff --git a/backend/services/collector.go b/backend/services/collector.go index 932e95d..2c4631e 100644 --- a/backend/services/collector.go +++ b/backend/services/collector.go @@ -5,9 +5,9 @@ import ( "fmt" "time" - "lokideploy/models" - "lokideploy/repository" - "lokideploy/utils" + "opsxloki/models" + "opsxloki/repository" + "opsxloki/utils" ) type CollectorService struct{} @@ -66,11 +66,15 @@ func (s *CollectorService) generateConfigSnapshot(c *models.Collector) string { if c.LokiDeployment != nil { endpoint = c.LokiDeployment.Endpoint } + clusterName := "host" + if c.Cluster != nil && c.Cluster.Name != "" { + clusterName = c.Cluster.Name + } switch c.Type { case "host-promtail": - return PromtailConfig(endpoint, c.LogPath) + return PromtailConfig(endpoint, c.LogPath, "host", c.Labels) case "k8s-daemonset": - return PromtailHelmValues(endpoint) + return PromtailHelmValues(endpoint, clusterName, c.Labels) case "k8s-fluentbit-sidecar": host, port := parseLokiURL(endpoint) app := c.TargetDeployment diff --git a/backend/services/collector_deployer.go b/backend/services/collector_deployer.go index 4713f10..5930603 100644 --- a/backend/services/collector_deployer.go +++ b/backend/services/collector_deployer.go @@ -7,7 +7,7 @@ import ( "os/exec" "strings" - "lokideploy/models" + "opsxloki/models" ) // CollectorDeployer 采集器部署器,按 type 分发到三种安装/卸载流程 @@ -97,11 +97,7 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er if host == nil { return fmt.Errorf("主机采集器缺少目标主机") } - password, err := DecryptCred(host.Password) - if err != nil { - return err - } - client, err := NewSSHClient(host.HostIP, host.Port, host.User, password) + client, err := NewSSHClientWithAuth(HostSSHAuth(host)) if err != nil { return err } @@ -145,7 +141,7 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er if logPath == "" { logPath = "/var/log/*.log" } - conf := PromtailConfig(endpoint, logPath) + conf := PromtailConfig(endpoint, logPath, "host", d.collector.Labels) d.log("info", "上传 Promtail 配置", "") if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/promtail/promtail.yaml << 'EOF'\n%s\nEOF", conf)); err != nil { return fmt.Errorf("上传配置失败: %w", err) @@ -191,8 +187,7 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error { if host == nil { return nil } - password, _ := DecryptCred(host.Password) - client, err := NewSSHClient(host.HostIP, host.Port, host.User, password) + client, err := NewSSHClientWithAuth(HostSSHAuth(host)) if err != nil { return err } @@ -209,7 +204,7 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error { func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error { cluster := d.collector.Cluster if cluster == nil { - return fmt.Errorf("DaemonSet 采集器缺少目标集群") + return fmt.Errorf("集群采集器缺少目标集群") } env, cleanup, err := kubeconfigEnv(cluster) if err != nil { @@ -217,37 +212,106 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin } defer cleanup() + // 默认部署到关联 Loki 的命名空间(统一 log 命名空间策略) ns := d.collector.Namespace + if ns == "" && d.loki != nil { + ns = d.loki.Namespace + } if ns == "" { - ns = "logging" + ns = "log" } - values := PromtailHelmValues(endpoint) - valuesFile, valuesCleanup, err := writeTempFile("promtail-values-*.yaml", values, 0600) + clusterName := "unknown" + if cluster.Name != "" { + clusterName = cluster.Name + } + + // 幂等:先清掉同名残留(如历史失败安装留下的无注解资源) + del := exec.CommandContext(ctx, "kubectl", "delete", + "daemonset/promtail", "configmap/promtail", "serviceaccount/promtail", + "-n", ns, "--ignore-not-found=true", "--wait=false") + del.Env = env + del.CombinedOutput() + + // 纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet),不依赖后端安装 helm + manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels) + if err := d.applyYAML(ctx, env, manifest, "promtail"); err != nil { + return err + } + + d.log("info", fmt.Sprintf("等待 DaemonSet promtail 就绪(命名空间 %s)...", ns), "") + waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "daemonset/promtail", + "-n", ns, "--timeout=5m") + waitCmd.Env = env + waitOut, _ := waitCmd.CombinedOutput() + d.log("info", string(waitOut), "") + + // DNS 自愈:CoreDNS 异常的集群上服务名不可解析,回退 Loki ClusterIP 重建配置 + svcURL := endpoint + if strings.Contains(endpoint, ".svc") { + probe := exec.CommandContext(ctx, "kubectl", "exec", "-n", ns, + "daemonset/promtail", "--", "wget", "-q", "-O", "/dev/null", "-T", "5", endpoint+"/ready") + probe.Env = env + if pout, perr := probe.CombinedOutput(); perr != nil { + d.log("warn", fmt.Sprintf("promtail 无法解析 Loki 服务名(%s),回退 ClusterIP...", strings.TrimSpace(string(pout))), "") + ip := d.lokiClusterIP(ctx, env, endpoint) + if ip != "" { + // 只替换 host 部分,保留 scheme/port/path: http://loki.ns.svc:3100/... → http://IP:3100/... + afterScheme := strings.SplitN(endpoint, "//", 2) + if len(afterScheme) == 2 { + rest := afterScheme[1] + host := rest + if i := strings.IndexAny(rest, ":/"); i >= 0 { + host = rest[:i] + } + fallback := afterScheme[0] + "//" + strings.Replace(rest, host, ip, 1) + if err := d.applyYAML(ctx, env, PromtailDaemonsetYAML(ns, fallback, clusterName, d.collector.Labels), "promtail"); err == nil { + rs := exec.CommandContext(ctx, "kubectl", "rollout", "restart", "daemonset/promtail", "-n", ns) + rs.Env = env + rs.CombinedOutput() + d.log("info", fmt.Sprintf("promtail 推送地址已回退为 ClusterIP: %s", fallback), "") + svcURL = fallback + } + } + } + } + } + + d.collector.Config = svcURL + d.log("success", fmt.Sprintf("Promtail DaemonSet 已部署到 %s(推送至 %s)", ns, svcURL), "") + return nil +} + +// applyYAML kubectl apply 一个 manifest(临时文件) +func (d *CollectorDeployer) applyYAML(ctx context.Context, env []string, manifest, label string) error { + f, cleanup, err := writeTempFile("collector-*.yaml", manifest, 0600) if err != nil { return err } - defer valuesCleanup() - - d.log("info", fmt.Sprintf("helm install promtail(命名空间 %s)", ns), "") - // 添加 grafana repo(幂等) - repo := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts") - repo.Env = env - repo.CombinedOutput() - upd := exec.CommandContext(ctx, "helm", "repo", "update") - upd.Env = env - upd.CombinedOutput() - - cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", "promtail", "grafana/promtail", - "-f", valuesFile, "--namespace", ns, "--create-namespace", "--wait", "--timeout", "5m") + defer cleanup() + cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", f) cmd.Env = env out, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("helm 安装 Promtail 失败: %w\n%s", err, string(out)) + d.log("error", fmt.Sprintf("kubectl apply %s failed: %v", label, err), string(out)) + return fmt.Errorf("kubectl apply %s failed: %w\n%s", label, err, string(out)) } return nil } +// lokiClusterIP 从 endpoint 的命名空间取 loki Service 的 ClusterIP(不经 DNS) +func (d *CollectorDeployer) lokiClusterIP(ctx context.Context, env []string, endpoint string) string { + ns := "log" + if d.loki != nil && d.loki.Namespace != "" { + ns = d.loki.Namespace + } + cmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "loki", "-n", ns, + "-o", "jsonpath={.spec.clusterIP}") + cmd.Env = env + out, _ := cmd.CombinedOutput() + return strings.TrimSpace(string(out)) +} + func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error { cluster := d.collector.Cluster if cluster == nil { @@ -259,10 +323,16 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error { } defer cleanup() ns := d.collector.Namespace - if ns == "" { - ns = "logging" + if ns == "" && d.loki != nil { + ns = d.loki.Namespace } - cmd := exec.CommandContext(ctx, "helm", "uninstall", "promtail", "--namespace", ns) + if ns == "" { + ns = "log" + } + cmd := exec.CommandContext(ctx, "kubectl", "delete", + "daemonset/promtail", "configmap/promtail", "serviceaccount/promtail", + "clusterrole/opsxloki-promtail", "clusterrolebinding/opsxloki-promtail", + "-n", ns, "--ignore-not-found=true") cmd.Env = env cmd.CombinedOutput() return nil diff --git a/backend/services/collector_templates.go b/backend/services/collector_templates.go index 5f39212..46b73b9 100644 --- a/backend/services/collector_templates.go +++ b/backend/services/collector_templates.go @@ -1,14 +1,35 @@ package services import ( + "encoding/json" "fmt" + "sort" "strings" ) // PromtailConfig 主机 Promtail 配置(推送至 lokiEndpoint,采集 logPath) // 生产级配置:持久化位置文件、批处理、重试、CRI日志格式解析 -func PromtailConfig(lokiEndpoint, logPath string) string { - return fmt.Sprintf(`server: +// promtailExternalLabels 组并 cluster 标识与用户自定义标签(JSON)为 external_labels YAML 行 +func promtailExternalLabels(cluster string, labelsJSON string) string { + lines := []string{fmt.Sprintf(" cluster: %q", cluster)} + if labelsJSON != "" { + var m map[string]string + if err := json.Unmarshal([]byte(labelsJSON), &m); err == nil { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + lines = append(lines, fmt.Sprintf(" %s: %q", k, m[k])) + } + } + } + return strings.Join(lines, "\n") +} + +func PromtailConfig(lokiEndpoint, logPath, cluster, labelsJSON string) string { + conf := fmt.Sprintf(`server: http_listen_port: 9080 grpc_listen_port: 0 log_level: info @@ -29,6 +50,7 @@ clients: max_retries: 10 external_labels: job: host-logs +__EXT_LABELS__ limits_config: readline_rate_enabled: false @@ -57,12 +79,13 @@ scrape_configs: burst: 10000 drop: true `, lokiEndpoint, logPath) + return strings.Replace(conf, "__EXT_LABELS__", promtailExternalLabels(cluster, labelsJSON), 1) } // PromtailHelmValues DaemonSet 模式 Promtail Helm values(生产级) // 包含:cri解析、kubernetes标签、批处理配置、资源限制 -func PromtailHelmValues(lokiEndpoint string) string { - return fmt.Sprintf(`config: +func PromtailHelmValues(lokiEndpoint, cluster, labelsJSON string) string { + values := fmt.Sprintf(`config: clients: - url: %s/loki/api/v1/push batchwait: 1s @@ -73,7 +96,7 @@ func PromtailHelmValues(lokiEndpoint string) string { max_period: 5m max_retries: 10 external_labels: - cluster: ${CLUSTER_NAME:-unknown} +__EXT_LABELS__ positions: filename: /run/promtail/positions.yaml scrape_configs: @@ -164,6 +187,160 @@ extraVolumeMounts: - name: promtail-run mountPath: /run/promtail `, lokiEndpoint) + return strings.Replace(values, "__EXT_LABELS__", promtailExternalLabels(cluster, labelsJSON), 1) +} + +// PromtailDaemonsetYAML DaemonSet 模式纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet), +// 取代 helm 安装 —— 后端无需安装 helm 二进制。 +// relabel 体系与主流 helm chart 对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。 +func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string) string { + extLabels := promtailExternalLabels(clusterName, labelsJSON) + return fmt.Sprintf(`apiVersion: v1 +kind: ServiceAccount +metadata: + name: promtail + namespace: %s +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: opsxloki-promtail +rules: +- apiGroups: [""] + resources: ["pods", "nodes"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: opsxloki-promtail +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: opsxloki-promtail +subjects: +- kind: ServiceAccount + name: promtail + namespace: %s +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: promtail + namespace: %s +data: + promtail.yaml: | + server: + http_listen_port: 3101 + grpc_listen_port: 0 + positions: + filename: /run/promtail/positions.yaml + clients: + - url: %s/loki/api/v1/push + batchwait: 1s + batchsize: 1048576 + backoff_config: + min_period: 500ms + max_period: 5m + max_retries: 10 + external_labels: + job: promtail +%s + scrape_configs: + - job_name: kubernetes-pods + kubernetes_sd_configs: + - role: pod + relabel_configs: + - source_labels: [__meta_kubernetes_namespace] + target_label: namespace + - source_labels: [__meta_kubernetes_pod_name] + target_label: pod + - source_labels: [__meta_kubernetes_pod_container_name] + target_label: container + - source_labels: [__meta_kubernetes_pod_node_name] + target_label: node_name + - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name, __meta_kubernetes_pod_label_app] + regex: ^;*([^;]+)(;.*)?$ + target_label: app + - separator: / + source_labels: [__meta_kubernetes_namespace, __meta_kubernetes_pod_label_app_kubernetes_io_name] + target_label: job + - source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name] + separator: / + target_label: __path__ + replacement: /var/log/pods/*$1/*.log +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: promtail + namespace: %s + labels: + app: promtail +spec: + selector: + matchLabels: + app: promtail + template: + metadata: + labels: + app: promtail + spec: + serviceAccountName: promtail + tolerations: + - effect: NoSchedule + operator: Exists + containers: + - name: promtail + image: grafana/promtail:3.4.0 + imagePullPolicy: IfNotPresent + args: ["-config.file=/etc/promtail/promtail.yaml"] + env: + - name: HOSTNAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + ports: + - name: http-metrics + containerPort: 3101 + readinessProbe: + httpGet: + path: /ready + port: http-metrics + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + volumeMounts: + - name: config + mountPath: /etc/promtail + - name: run + mountPath: /run/promtail + - name: pods + mountPath: /var/log/pods + readOnly: true + - name: containers + mountPath: /var/lib/docker/containers + readOnly: true + volumes: + - name: config + configMap: + name: promtail + - name: run + hostPath: + path: /run/promtail + - name: pods + hostPath: + path: /var/log/pods + - name: containers + hostPath: + path: /var/lib/docker/containers +`, ns, ns, ns, lokiURL, extLabels, ns) } // FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort) diff --git a/backend/services/deployment.go b/backend/services/deployment.go index 15f45a9..341cdf7 100644 --- a/backend/services/deployment.go +++ b/backend/services/deployment.go @@ -1,6 +1,7 @@ package services import ( + "os/exec" "context" "fmt" "strconv" @@ -8,9 +9,9 @@ import ( "sync" "time" - "lokideploy/models" - "lokideploy/repository" - "lokideploy/utils" + "opsxloki/models" + "opsxloki/repository" + "opsxloki/utils" ) // LokiVersionList 支持的 Loki 版本列表(按推荐程度排序) @@ -50,6 +51,8 @@ type CreateDeploymentRequest struct { ResourcesCPU string `json:"resourcesCPU"` ResourcesMem string `json:"resourcesMem"` StorageSize string `json:"storageSize"` + GrafanaEnabled bool `json:"grafanaEnabled"` + GrafanaVersion string `json:"grafanaVersion"` } func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDeployment, error) { @@ -77,6 +80,8 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe ResourcesCPU: req.ResourcesCPU, ResourcesMem: req.ResourcesMem, StorageSize: req.StorageSize, + GrafanaEnabled: req.GrafanaEnabled, + GrafanaVersion: req.GrafanaVersion, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -106,6 +111,13 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe for i := range db.Storages { if db.Storages[i].ID == d.StorageConfigID { st := db.Storages[i] + // 模型内为 ENC: 密文,生成 Loki 配置前解密(否则密文进 ConfigMap 且无法通过对象存储认证) + if ak, err := DecryptCred(st.AccessKey); err == nil && ak != "" { + st.AccessKey = ak + } + if sk, err := DecryptCred(st.SecretKey); err == nil && sk != "" { + st.SecretKey = sk + } d.StorageConfig = &st break } @@ -171,6 +183,57 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro if v, ok := updates["endpoint"].(string); ok { db.Deployments[i].Endpoint = v } + // 业务字段(来自 PUT /deployments/:id;JSON 数字解码为 float64) + if v, ok := updates["name"].(string); ok && v != "" { + db.Deployments[i].Name = v + } + if v, ok := updates["description"].(string); ok { + db.Deployments[i].Description = v + } + if v, ok := updates["version"].(string); ok && v != "" { + db.Deployments[i].Version = v + } + if v, ok := updates["namespace"].(string); ok && v != "" { + db.Deployments[i].Namespace = v + } + if v, ok := updates["resourcesCPU"].(string); ok { + db.Deployments[i].ResourcesCPU = v + } + if v, ok := updates["resourcesMem"].(string); ok { + db.Deployments[i].ResourcesMem = v + } + if v, ok := updates["storageSize"].(string); ok && v != "" { + db.Deployments[i].StorageSize = v + } + if v, ok := updates["authEnabled"].(bool); ok { + db.Deployments[i].AuthEnabled = v + } + if v, ok := updates["grafanaEnabled"].(bool); ok { + db.Deployments[i].GrafanaEnabled = v + } + if v, ok := updates["grafanaVersion"].(string); ok && v != "" { + db.Deployments[i].GrafanaVersion = v + } + if v, ok := updates["grafanaEndpoint"].(string); ok { + db.Deployments[i].GrafanaEndpoint = v + } + if v, ok := updates["storageConfigID"].(float64); ok && v > 0 { + db.Deployments[i].StorageConfigID = uint(v) + } + if v, ok := updates["clusterID"].(float64); ok && v > 0 { + id := uint(v) + db.Deployments[i].ClusterID = &id + } + if v, ok := updates["hostID"].(float64); ok && v > 0 { + id := uint(v) + db.Deployments[i].HostID = &id + } + if v, ok := updates["retentionDays"].(float64); ok && v > 0 { + db.Deployments[i].RetentionDays = int(v) + } + if v, ok := updates["replicas"].(float64); ok && v > 0 { + db.Deployments[i].Replicas = int(v) + } db.Deployments[i].UpdatedAt = time.Now() db.RequestSave() return nil @@ -260,6 +323,11 @@ func (s *DeploymentService) StartDeploy(id uint) error { "statusMessage": "Deployment completed successfully", "endpoint": s.getEndpoint(deployment), }) + // 部署成功自动注册日志查询数据源 + 记录 Grafana 访问地址 + if fresh, gerr := s.Get(id); gerr == nil { + EnsureLogSourceForDeployment(fresh) + go s.recordGrafanaEndpoint(fresh) + } s.AddLog(id, "success", "Deployment completed successfully", "") } }() @@ -398,6 +466,13 @@ func (s *DeploymentService) loadRelations(d *models.LokiDeployment) { for i := range db.Storages { if db.Storages[i].ID == d.StorageConfigID { st := db.Storages[i] + // 模型内为 ENC: 密文,生成 Loki 配置前解密(否则密文进 ConfigMap 且无法通过对象存储认证) + if ak, err := DecryptCred(st.AccessKey); err == nil && ak != "" { + st.AccessKey = ak + } + if sk, err := DecryptCred(st.SecretKey); err == nil && sk != "" { + st.SecretKey = sk + } d.StorageConfig = &st break } @@ -472,3 +547,126 @@ func GetRecommendedVersions() []string { copy(result, LokiVersionList) return result } + +// Preflight 向导预检:聚合各资源真实状态,前端据此推导步骤完成态(状态推导式向导) +func (s *DeploymentService) Preflight() map[string]interface{} { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + + result := map[string]interface{}{ + "hasStorage": len(db.Storages) > 0, + "hasActiveCluster": false, + "clusterNames": []string{}, + "hasActiveHost": false, + "hostNames": []string{}, + "runningLoki": nil, + "activeCollectors": 0, + "grafanaReady": false, + } + + var defaultStorage string + for i := range db.Storages { + if db.Storages[i].IsDefault { + defaultStorage = db.Storages[i].Name + break + } + } + if defaultStorage == "" && len(db.Storages) > 0 { + defaultStorage = db.Storages[0].Name + } + result["defaultStorage"] = defaultStorage + + clusters := []string{} + for i := range db.Clusters { + clusters = append(clusters, db.Clusters[i].Name) + if db.Clusters[i].Status == "active" || db.Clusters[i].Status == "running" { + result["hasActiveCluster"] = true + } + } + result["clusterNames"] = clusters + + hosts := []string{} + for i := range db.Hosts { + hosts = append(hosts, db.Hosts[i].Name) + if db.Hosts[i].Status == "active" { + result["hasActiveHost"] = true + } + } + result["hostNames"] = hosts + + for i := range db.Deployments { + d := &db.Deployments[i] + if d.Status == "running" { + loki := map[string]interface{}{ + "id": d.ID, + "name": d.Name, + "mode": d.Mode, + "endpoint": d.Endpoint, + "namespace": d.Namespace, + "grafana": d.GrafanaEnabled, + "grafanaEndpoint": d.GrafanaEndpoint, + } + result["runningLoki"] = loki + if d.GrafanaEnabled { + result["grafanaReady"] = true + } + break + } + } + + for i := range db.Collectors { + if db.Collectors[i].Status == "active" { + result["activeCollectors"] = result["activeCollectors"].(int) + 1 + } + } + return result +} + +// recordGrafanaEndpoint 记录 Grafana 可点击访问地址(k8s: 首节点 IP + NodePort;host: 主机 IP:3000), +// 供部署卡片/向导直接展示,用户不用翻日志找端口。 +func (s *DeploymentService) recordGrafanaEndpoint(d *models.LokiDeployment) { + if d == nil || !d.GrafanaEnabled { + return + } + var endpoint string + switch d.Mode { + case "k8s-helm", "k8s-yaml": + if d.Cluster == nil { + s.AddLog(d.ID, "warn", "Grafana 地址记录跳过:未关联集群", "") + return + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + env, cleanup, err := kubeconfigEnv(d.Cluster) + if err != nil { + return + } + defer cleanup() + ns := d.Namespace + if ns == "" { + ns = "log" + } + ipCmd := exec.CommandContext(ctx, "kubectl", "get", "nodes", + "-o", "jsonpath={.items[0].status.addresses[0].address}") + ipCmd.Env = env + ipOut, _ := ipCmd.CombinedOutput() + portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns, + "-o", "jsonpath={.spec.ports[0].nodePort}") + portCmd.Env = env + portOut, _ := portCmd.CombinedOutput() + ip := strings.TrimSpace(string(ipOut)) + port := strings.TrimSpace(string(portOut)) + s.AddLog(d.ID, "info", fmt.Sprintf("Grafana 地址探测: node=%s port=%s", ip, port), "") + if ip != "" && port != "" { + endpoint = fmt.Sprintf("http://%s:%s", ip, port) + } + case "host-binary": + if d.Host != nil { + endpoint = fmt.Sprintf("http://%s:3000", d.Host.HostIP) + } + } + if endpoint != "" { + _ = s.Update(d.ID, map[string]interface{}{"grafanaEndpoint": endpoint}) + } +} diff --git a/backend/services/grafana_deployer.go b/backend/services/grafana_deployer.go new file mode 100644 index 0000000..a93ed8a --- /dev/null +++ b/backend/services/grafana_deployer.go @@ -0,0 +1,438 @@ +package services + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "os/exec" + "strings" + "time" + + "opsxloki/models" +) + +// GrafanaDeployer 与 Loki 同环境部署 Grafana 并自动配置 Loki 数据源。 +// k8s 模式:manifest(Deployment + NodePort Service + 数据源 ConfigMap),与 Loki 同 namespace; +// host-binary 模式:tarball + systemd,与 Loki 同机。 +type GrafanaDeployer struct { + deployment *models.LokiDeployment + log func(level, message, detail string) +} + +const defaultGrafanaVersion = "11.3.0" + +func (d *GrafanaDeployer) version() string { + if d.deployment.GrafanaVersion != "" { + return d.deployment.GrafanaVersion + } + return defaultGrafanaVersion +} + +// lokiPushURL 数据源指向的 Loki 地址(k8s 走集群内服务名,主机走本机) +func (d *GrafanaDeployer) lokiPushURL() string { + if d.deployment.Mode == "host-binary" { + return "http://127.0.0.1:3100" + } + ns := d.deployment.Namespace + if ns == "" { + ns = "log" + } + return fmt.Sprintf("http://loki.%s.svc.cluster.local:3100", ns) +} + +// Deploy 部署入口:按模式分发 +func (d *GrafanaDeployer) Deploy(ctx context.Context) error { + d.log("info", fmt.Sprintf("Deploying Grafana %s (mode: %s)...", d.version(), d.deployment.Mode), "") + switch d.deployment.Mode { + case "k8s-helm", "k8s-yaml": + return d.deployK8s(ctx) + case "host-binary": + return d.deployHost(ctx) + default: + return fmt.Errorf("unsupported mode for grafana: %s", d.deployment.Mode) + } +} + +// ==================== K8s manifest 部署 ==================== + +// grafanaManifest 生成 Grafana 资源;lokiURL 显式传入(默认服务名,DNS 异常的集群可回退 ClusterIP)。 +// 数据卷用 emptyDir:数据源/配置由 ConfigMap 供给,重启即恢复;避免无默认 +// StorageClass 的集群上 PVC Pending 阻塞(UI 内自建的面板需重新导入,见已知限制)。 +func (d *GrafanaDeployer) grafanaManifest(ns, lokiURL string) string { + version := d.version() + + datasources := fmt.Sprintf(`apiVersion: 1 +datasources: +- name: Loki + type: loki + uid: loki-ds + access: proxy + url: %s + isDefault: true + editable: false`, lokiURL) + + return fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-datasources + namespace: %s +data: + datasources.yaml: | +%s +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: %s + labels: + app: grafana +spec: + replicas: 1 + selector: + matchLabels: + app: grafana + template: + metadata: + labels: + app: grafana + spec: + containers: + - name: grafana + image: grafana/grafana:%s + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3000 + env: + - name: GF_SECURITY_ADMIN_USER + value: admin + - name: GF_SECURITY_ADMIN_PASSWORD + value: admin + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + volumeMounts: + - name: datasources + mountPath: /etc/grafana/provisioning/datasources + volumes: + - name: datasources + configMap: + name: grafana-datasources +--- +apiVersion: v1 +kind: Service +metadata: + name: grafana + namespace: %s + labels: + app: grafana +spec: + # NodePort 便于集群外直接访问(端口由集群自动分配,实际端口见部署日志) + type: NodePort + ports: + - name: http + port: 3000 + targetPort: 3000 + selector: + app: grafana +`, ns, indentBlock(datasources, " "), ns, version, ns) +} + +func (d *GrafanaDeployer) deployK8s(ctx context.Context) error { + if d.deployment.Cluster == nil { + return fmt.Errorf("please select target cluster") + } + env, cleanup, err := kubeconfigEnv(d.deployment.Cluster) + if err != nil { + return err + } + defer cleanup() + + ns := d.deployment.Namespace + if ns == "" { + ns = "log" + } + + lokiURL := d.lokiPushURL() + manifestFile, manifestCleanup, err := writeTempFile("grafana-*.yaml", d.grafanaManifest(ns, lokiURL), 0600) + if err != nil { + return err + } + defer manifestCleanup() + + d.log("info", "Applying Grafana manifest (Deployment + NodePort Service + datasource ConfigMap)...", "") + cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestFile) + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + d.log("error", fmt.Sprintf("kubectl apply grafana failed: %v", err), string(out)) + return fmt.Errorf("kubectl apply grafana failed: %w\n%s", err, string(out)) + } + + waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/grafana", + "-n", ns, "--timeout=5m") + waitCmd.Env = env + waitOut, _ := waitCmd.CombinedOutput() + d.log("info", string(waitOut), "") + + // DNS 自愈:部分集群 CoreDNS 对 Service 记录异常(如 watch 失效), + // 经 grafana 容器探测 Loki 服务名,不通则回退 ClusterIP 并热更新数据源。 + dnsProbe := exec.CommandContext(ctx, "kubectl", "exec", "-n", ns, "deploy/grafana", "--", + "wget", "-q", "-O", "/dev/null", "-T", "5", lokiURL+"/ready") + dnsProbe.Env = env + if probeOut, probeErr := dnsProbe.CombinedOutput(); probeErr != nil { + d.log("warn", fmt.Sprintf("Grafana 无法解析 Loki 服务名(%s),回退 ClusterIP…", strings.TrimSpace(string(probeOut))), "") + ipCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "loki", "-n", ns, + "-o", "jsonpath={.spec.clusterIP}") + ipCmd.Env = env + ipOut, ipErr := ipCmd.CombinedOutput() + clusterIP := strings.TrimSpace(string(ipOut)) + if ipErr != nil || clusterIP == "" { + d.log("error", "获取 Loki ClusterIP 失败,数据源保持服务名形式", "") + } else { + fallbackURL := fmt.Sprintf("http://%s:3100", clusterIP) + fbFile, fbCleanup, ferr := writeTempFile("grafana-fb-*.yaml", d.grafanaManifest(ns, fallbackURL), 0600) + if ferr == nil { + fbApply := exec.CommandContext(ctx, "kubectl", "apply", "-f", fbFile) + fbApply.Env = env + fbApply.CombinedOutput() + fbCleanup() + restart := exec.CommandContext(ctx, "kubectl", "rollout", "restart", "deployment/grafana", "-n", ns) + restart.Env = env + restart.CombinedOutput() + wait2 := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/grafana", + "-n", ns, "--timeout=5m") + wait2.Env = env + w2, _ := wait2.CombinedOutput() + d.log("info", string(w2), "") + d.log("info", fmt.Sprintf("数据源已回退为 ClusterIP: %s", fallbackURL), "") + } + } + } + + // 回读实际分配的 NodePort(固定端口易与集群已有服务冲突) + portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns, + "-o", "jsonpath={.spec.ports[0].nodePort}") + portCmd.Env = env + portOut, _ := portCmd.CombinedOutput() + nodePort := strings.TrimSpace(string(portOut)) + access := fmt.Sprintf("http://<节点IP>:%s", nodePort) + if nodePort == "" { + access = "kubectl get svc grafana -n " + ns + " 查看访问端口" + } + + d.log("success", fmt.Sprintf("Grafana deployed. 访问: %s (NodePort, 账号 admin/admin), 数据源已指向 %s", access, d.lokiPushURL()), "") + + // 自动导入 Loki 日志面板(打开即可看图;密码被用户修改过则跳过并提示) + if err := d.importDashboard(ctx, env, ns); err != nil { + d.log("warn", fmt.Sprintf("面板自动导入跳过: %v", err), "") + } else { + d.log("info", "已导入「Loki 日志」面板(Explore 可用,默认账号 admin/admin)", "") + } + return nil +} + +// importDashboard 通过 Grafana API 导入预置的 Loki 日志面板 +func (d *GrafanaDeployer) importDashboard(ctx context.Context, env []string, ns string) error { + payload := grafanaDashboardPayload() + + postDashboard := func(baseURL string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + strings.TrimSuffix(baseURL, "/")+"/api/dashboards/db", strings.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:admin"))) + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 300)) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return nil + } + + switch d.deployment.Mode { + case "k8s-helm", "k8s-yaml": + // 经 NodePort 直连(kube service proxy 的 POST 需额外 RBAC,直连最稳) + ipCmd := exec.CommandContext(ctx, "kubectl", "get", "nodes", + "-o", "jsonpath={.items[0].status.addresses[0].address}") + ipCmd.Env = env + ipOut, _ := ipCmd.CombinedOutput() + portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns, + "-o", "jsonpath={.spec.ports[0].nodePort}") + portCmd.Env = env + portOut, _ := portCmd.CombinedOutput() + ip, port := strings.TrimSpace(string(ipOut)), strings.TrimSpace(string(portOut)) + if ip == "" || port == "" { + return fmt.Errorf("获取 NodePort 失败") + } + return postDashboard(fmt.Sprintf("http://%s:%s", ip, port)) + case "host-binary": + if d.deployment.Host == nil { + return fmt.Errorf("no host") + } + return postDashboard(fmt.Sprintf("http://%s:3000", d.deployment.Host.HostIP)) + } + return fmt.Errorf("unsupported mode") +} + +// ==================== 主机 tarball 部署 ==================== + +func (d *GrafanaDeployer) deployHost(ctx context.Context) error { + host := d.deployment.Host + if host == nil { + return fmt.Errorf("please select target host") + } + client, err := NewSSHClientWithAuth(HostSSHAuth(host)) + if err != nil { + return err + } + defer client.Close() + + arch, _, _ := client.Execute(ctx, "uname -m") + grafanaArch := "amd64" + if strings.Contains(arch, "aarch64") || strings.Contains(arch, "arm64") { + grafanaArch = "arm64" + } + version := d.version() + + d.log("info", fmt.Sprintf("Downloading Grafana %s (%s)...", version, grafanaArch), "") + url := fmt.Sprintf("https://dl.grafana.com/oss/release/grafana-%s.linux-%s.tar.gz", version, grafanaArch) + dl := fmt.Sprintf("curl -fsSL -o /tmp/grafana.tar.gz %s && tar -xzf /tmp/grafana.tar.gz -C /opt/ && rm -rf /opt/grafana && ln -sfn /opt/grafana-v%s /opt/grafana && rm -f /tmp/grafana.tar.gz", url, version) + if out, _, err := client.Execute(ctx, dl); err != nil { + return fmt.Errorf("download grafana failed: %w\n%s", err, out) + } + + // 数据源 provisioning(指向同机 Loki) + dsYAML := fmt.Sprintf(`apiVersion: 1 +datasources: +- name: Loki + type: loki + access: proxy + url: %s + isDefault: true +`, d.lokiPushURL()) + client.Execute(ctx, "mkdir -p /etc/grafana/provisioning/datasources /var/lib/grafana /var/log/grafana") + writeCmd := fmt.Sprintf("cat > /etc/grafana/provisioning/datasources/loki.yaml <<'EOF'\n%s\nEOF", dsYAML) + if out, _, err := client.Execute(ctx, writeCmd); err != nil { + return fmt.Errorf("write datasource config failed: %w\n%s", err, out) + } + + unit := `[Unit] +Description=Grafana +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/grafana +ExecStart=/opt/grafana/bin/grafana server --homepath /opt/grafana --config /opt/grafana/conf/defaults.ini +Restart=always +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target` + writeUnit := fmt.Sprintf("cat > /etc/systemd/system/grafana.service <<'EOF'\n%s\nEOF", unit) + if out, _, err := client.Execute(ctx, writeUnit); err != nil { + return fmt.Errorf("write systemd unit failed: %w\n%s", err, out) + } + if out, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable grafana && systemctl restart grafana"); err != nil { + return fmt.Errorf("start grafana failed: %w\n%s", err, out) + } + + // 健康检查(最多 10 次) + for i := 0; i < 10; i++ { + out, _, _ := client.Execute(ctx, "curl -fsSL http://localhost:3000/api/health") + if strings.Contains(out, "\"version\"") { + d.log("success", fmt.Sprintf("Grafana running on %s:3000 (admin/admin), 数据源已指向本机 Loki", host.HostIP), "") + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + d.log("warn", "Grafana 服务已启动但健康检查未通过,请稍后确认 http://<主机IP>:3000", "") + return nil +} + +// CleanupGrafana 卸载 Grafana(停止部署时同步调用) +func CleanupGrafana(ctx context.Context, deployment *models.LokiDeployment) { + if deployment == nil { + return + } + switch deployment.Mode { + case "k8s-helm", "k8s-yaml": + if deployment.Cluster == nil { + return + } + env, cleanup, err := kubeconfigEnv(deployment.Cluster) + if err != nil { + return + } + defer cleanup() + ns := deployment.Namespace + if ns == "" { + ns = "log" + } + cmd := exec.CommandContext(ctx, "kubectl", "delete", + "deployment/grafana", "service/grafana", "configmap/grafana-datasources", + "-n", ns, "--ignore-not-found=true") + cmd.Env = env + cmd.CombinedOutput() + case "host-binary": + if deployment.Host == nil { + return + } + client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host)) + if err != nil { + return + } + defer client.Close() + client.Execute(ctx, "systemctl stop grafana 2>/dev/null; systemctl disable grafana 2>/dev/null; rm -f /etc/systemd/system/grafana.service; systemctl daemon-reload") + } +} + +// grafanaDashboardPayload 预置「Loki 日志」面板:日志量(按命名空间)/错误日志/最近日志 +func grafanaDashboardPayload() string { + return `{"dashboard":{ + "uid":"loki-logs","title":"Loki 日志","tags":["lokideploy"], + "timezone":"browser","schemaVersion":39,"refresh":"30s","version":1, + "time":{"from":"now-1h","to":"now"}, + "panels":[ + {"id":1,"type":"timeseries","title":"日志量(按命名空间)", + "datasource":{"type":"loki","uid":"loki-ds"}, + "gridPos":{"h":8,"w":12,"x":0,"y":0}, + "fieldConfig":{"defaults":{"unit":"short"},"overrides":[]}, + "targets":[{"refId":"A","expr":"sum by (namespace) (count_over_time({namespace=\".+\"}[5m]))","queryType":"range"}]}, + {"id":2,"type":"timeseries","title":"错误日志", + "datasource":{"type":"loki","uid":"loki-ds"}, + "gridPos":{"h":8,"w":12,"x":12,"y":0}, + "fieldConfig":{"defaults":{"unit":"short","color":{"mode":"palette-classic"}},"overrides":[]}, + "targets":[{"refId":"A","expr":"sum(count_over_time({namespace=\".+\"} |~ \"(?i)error\" [5m]))","queryType":"range"}]}, + {"id":3,"type":"logs","title":"最近日志", + "datasource":{"type":"loki","uid":"loki-ds"}, + "gridPos":{"h":10,"w":24,"x":0,"y":8}, + "targets":[{"refId":"A","expr":"{namespace=\".+\"}","queryType":"range"}]} + ] + },"overwrite":true}` +} diff --git a/backend/services/host.go b/backend/services/host.go index 389fb3b..1a31a11 100644 --- a/backend/services/host.go +++ b/backend/services/host.go @@ -3,13 +3,12 @@ package services import ( "context" "fmt" - "strings" "time" - "lokideploy/models" - "lokideploy/pkg/crypto" - "lokideploy/repository" - "lokideploy/utils" + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/repository" + "opsxloki/utils" ) type HostService struct{} @@ -112,6 +111,9 @@ func (s *HostService) Update(id uint, updates map[string]interface{}) error { if v, ok := updates["status"].(string); ok { db.Hosts[i].Status = v } + if v, ok := updates["statusMessage"].(string); ok { + db.Hosts[i].StatusMessage = v + } if v, ok := updates["os"].(string); ok { db.Hosts[i].OS = v } @@ -148,14 +150,9 @@ func (s *HostService) Validate(id uint) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - password := host.Password - if strings.HasPrefix(password, "ENC:") { - password, _ = crypto.Decrypt(password[4:]) - } - - client, err := NewSSHClient(host.HostIP, host.Port, host.User, password) + client, err := NewSSHClientWithAuth(HostSSHAuth(host)) if err != nil { - s.Update(id, map[string]interface{}{"status": "error"}) + s.Update(id, map[string]interface{}{"status": "error", "statusMessage": err.Error()}) return } defer client.Close() @@ -175,12 +172,7 @@ func (s *HostService) ExecuteCommand(id uint, command string) (string, string, e ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - password := host.Password - if strings.HasPrefix(password, "ENC:") { - password, _ = crypto.Decrypt(password[4:]) - } - - client, err := NewSSHClient(host.HostIP, host.Port, host.User, password) + client, err := NewSSHClientWithAuth(HostSSHAuth(host)) if err != nil { return "", "", err } diff --git a/backend/services/k8s_client.go b/backend/services/k8s_client.go index 24c707b..cd2b8c5 100644 --- a/backend/services/k8s_client.go +++ b/backend/services/k8s_client.go @@ -6,7 +6,7 @@ import ( "os" "regexp" - "lokideploy/models" + "opsxloki/models" ) // resolveKubeconfig 取集群最终的 kubeconfig 文本(优先内存字段,其次磁盘文件) diff --git a/backend/services/ldap.go b/backend/services/ldap.go new file mode 100644 index 0000000..c470519 --- /dev/null +++ b/backend/services/ldap.go @@ -0,0 +1,330 @@ +// Package services — LDAP 接入:配置存储(SystemSetting 表 JSON)、连接测试、 +// 用户搜索预览、批量导入与登录验证共用同一套连接/搜索代码。 +// 设计参照 opsx-web-lite(has_password 脱敏/空密码保留旧值/批量导入冲突策略)。 +package services + +import ( + "encoding/json" + "fmt" + "net" + "strings" + "time" + + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/repository" + "opsxloki/utils" + + ldap "github.com/go-ldap/ldap/v3" +) + +const ldapSettingKey = "ldap_settings" + +// netDialer LDAP 拨号器(5s 超时,参照 aiops 的超时处理,避免连接挂起) +func netDialer() *net.Dialer { + return &net.Dialer{Timeout: 5 * time.Second} +} + +// LDAPConfig LDAP 配置(存储形态;BindPassword 以 ENC: 前缀 AES-GCM 加密落库) +type LDAPConfig struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + UseSSL bool `json:"useSSL"` + BindDN string `json:"bindDN"` + BindPassword string `json:"bindPassword"` // 存储为 ENC:密文;对外输出时剔除 + BaseDN string `json:"baseDN"` + UserFilter string `json:"userFilter"` + UsernameAttr string `json:"usernameAttr"` + NameAttr string `json:"nameAttr"` + EmailAttr string `json:"emailAttr"` +} + +// LDAPPublicConfig 对外输出形态(密码剔除,附 hasPassword) +type LDAPPublicConfig struct { + LDAPConfig + BindPassword string `json:"bindPassword"` // 恒为空 + HasPassword bool `json:"hasPassword"` + Configured bool `json:"configured"` // host+bindDN+baseDN 齐备(控制用户页导入按钮显隐) +} + +func defaultLDAPConfig() *LDAPConfig { + return &LDAPConfig{ + Port: 389, + UserFilter: "(objectClass=inetOrgPerson)", + UsernameAttr: "uid", + NameAttr: "displayName", + EmailAttr: "mail", + } +} + +// GetLDAPConfig 读取配置(解密密码);未配置时返回默认值 +func GetLDAPConfig() *LDAPConfig { + db := repository.GetDB() + db.RLock() + var raw string + for i := range db.Settings { + if db.Settings[i].Key == ldapSettingKey { + raw = db.Settings[i].Value + break + } + } + db.RUnlock() + + cfg := defaultLDAPConfig() + if raw != "" { + _ = json.Unmarshal([]byte(raw), cfg) + } + if cfg.Port == 0 { + cfg.Port = 389 + } + if cfg.BindPassword != "" { + if plain, err := DecryptCred(cfg.BindPassword); err == nil { + cfg.BindPassword = plain + } + } + return cfg +} + +func GetLDAPPublicConfig() *LDAPPublicConfig { + cfg := GetLDAPConfig() + hasPwd := cfg.BindPassword != "" + return &LDAPPublicConfig{ + LDAPConfig: LDAPConfig{ + Enabled: cfg.Enabled, + Host: cfg.Host, + Port: cfg.Port, + UseSSL: cfg.UseSSL, + BindDN: cfg.BindDN, + BaseDN: cfg.BaseDN, + UserFilter: cfg.UserFilter, + UsernameAttr: cfg.UsernameAttr, + NameAttr: cfg.NameAttr, + EmailAttr: cfg.EmailAttr, + }, + HasPassword: hasPwd, + Configured: cfg.Host != "" && cfg.BindDN != "" && cfg.BaseDN != "", + } +} + +// SaveLDAPConfig 保存;bindPassword 为空且已有存量 → 保留旧密文 +func SaveLDAPConfig(in *LDAPConfig) error { + old := GetLDAPConfig() + if in.Port == 0 { + in.Port = 389 + } + if in.UserFilter == "" { + in.UserFilter = old.UserFilter + } + if in.UsernameAttr == "" { + in.UsernameAttr = "uid" + } + if in.NameAttr == "" { + in.NameAttr = "displayName" + } + if in.EmailAttr == "" { + in.EmailAttr = "mail" + } + if in.BindPassword == "" { + in.BindPassword = old.BindPassword // 已是明文(GetLDAPConfig 解密过) + } else if enc, err := crypto.EncryptField(in.BindPassword); err == nil { + in.BindPassword = enc + } + + data, _ := json.Marshal(in) + db := repository.GetDB() + // NextID/CreateUser 均自拿锁,禁止在持有 db.Lock() 时调用(JsonDB 锁不可重入) + newID := db.NextID("settings") + db.Lock() + defer db.Unlock() + for i := range db.Settings { + if db.Settings[i].Key == ldapSettingKey { + db.Settings[i].Value = string(data) + db.RequestSave() + return nil + } + } + db.Settings = append(db.Settings, models.SystemSetting{ + ID: newID, Key: ldapSettingKey, Value: string(data), + }) + db.RequestSave() + return nil +} + +// ==================== 连接与搜索 ==================== + +// dialLDAP 建立连接(5s 拨号超时,防参照项目无超时挂起的问题) +func dialLDAP(cfg *LDAPConfig) (*ldap.Conn, error) { + scheme := "ldap" + if cfg.UseSSL { + scheme = "ldaps" + } + url := fmt.Sprintf("%s://%s:%d", scheme, cfg.Host, cfg.Port) + conn, err := ldap.DialURL(url, ldap.DialWithDialer(netDialer())) + if err != nil { + return nil, fmt.Errorf("无法连接 LDAP 服务器 %s: %w", url, err) + } + conn.SetTimeout(10 * time.Second) + if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil { + conn.Close() + return nil, fmt.Errorf("管理员绑定失败(检查 Bind DN / 密码): %w", err) + } + return conn, nil +} + +// ldapUserEntry LDAP 用户条目(导入预览与登录共用) +type ldapUserEntry struct { + DN string + Username string + DisplayName string + Email string +} + +// searchLDAPUsers 按 ouDN(空用 baseDN)WholeSubtree 搜索用户,sizeLimit 上限 +func searchLDAPUsers(cfg *LDAPConfig, ouDN string, sizeLimit int) ([]ldapUserEntry, error) { + if ouDN == "" { + ouDN = cfg.BaseDN + } + conn, err := dialLDAP(cfg) + if err != nil { + return nil, err + } + defer conn.Close() + + filter := cfg.UserFilter + if !strings.HasPrefix(filter, "(") { + filter = "(" + filter + ")" + } + req := ldap.NewSearchRequest(ouDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + sizeLimit, 0, false, filter, + []string{"dn", cfg.UsernameAttr, cfg.NameAttr, "cn", cfg.EmailAttr}, nil) + res, err := conn.Search(req) + if err != nil { + return nil, fmt.Errorf("搜索失败(BaseDN/过滤器可能有误): %w", err) + } + + users := make([]ldapUserEntry, 0, len(res.Entries)) + seen := map[string]bool{} + for _, e := range res.Entries { + u := ldapUserEntry{ + DN: e.DN, + Username: e.GetAttributeValue(cfg.UsernameAttr), + DisplayName: e.GetAttributeValue(cfg.NameAttr), + Email: e.GetAttributeValue(cfg.EmailAttr), + } + if u.DisplayName == "" { + u.DisplayName = e.GetAttributeValue("cn") + } + if u.Username == "" || seen[u.DN] { + continue // 无用户名属性的条目(如 ou 本身)跳过 + } + seen[u.DN] = true + users = append(users, u) + } + return users, nil +} + +// searchLDAPUserByUsername 登录用:按用户名搜索单个条目 +func searchLDAPUserByUsername(cfg *LDAPConfig, username string) (*ldapUserEntry, error) { + conn, err := dialLDAP(cfg) + if err != nil { + return nil, err + } + defer conn.Close() + + filter := fmt.Sprintf("(&%s(%s=%s))", normalizeFilter(cfg.UserFilter), + cfg.UsernameAttr, ldap.EscapeFilter(username)) + req := ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 2, 0, false, filter, + []string{"dn", cfg.UsernameAttr, cfg.NameAttr, "cn", cfg.EmailAttr}, nil) + res, err := conn.Search(req) + if err != nil { + return nil, err + } + if len(res.Entries) == 0 { + return nil, utils.ErrInvalidCredentials + } + if len(res.Entries) > 1 { + return nil, fmt.Errorf("LDAP 中存在多个匹配 %s 的用户", username) + } + e := res.Entries[0] + return &ldapUserEntry{ + DN: e.DN, + Username: e.GetAttributeValue(cfg.UsernameAttr), + DisplayName: e.GetAttributeValue(cfg.NameAttr), + Email: e.GetAttributeValue(cfg.EmailAttr), + }, nil +} + +// verifyLDAPPassword 用户级验证:bind 到用户 DN 校验密码 +func verifyLDAPPassword(cfg *LDAPConfig, username, password string) (*ldapUserEntry, error) { + entry, err := searchLDAPUserByUsername(cfg, username) + if err != nil { + return nil, err + } + scheme := "ldap" + if cfg.UseSSL { + scheme = "ldaps" + } + conn, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", scheme, cfg.Host, cfg.Port), + ldap.DialWithDialer(netDialer())) + if err != nil { + return nil, err + } + defer conn.Close() + if err := conn.Bind(entry.DN, password); err != nil { + return nil, utils.ErrInvalidCredentials + } + return entry, nil +} + +func normalizeFilter(f string) string { + if !strings.HasPrefix(f, "(") { + return "(" + f + ")" + } + return f +} + +// TestLDAPConnection 测试连接:管理 bind + baseDN 校验 + 可选用户级验证 +func TestLDAPConnection(cfg *LDAPConfig, testUsername, testPassword string) map[string]interface{} { + if cfg.Host == "" || cfg.BindDN == "" || cfg.BaseDN == "" { + return map[string]interface{}{"success": false, "message": "请先填写服务器地址 / Bind DN / Base DN"} + } + // 表单密码为空回退已存密码 + if cfg.BindPassword == "" { + cfg.BindPassword = GetLDAPConfig().BindPassword + } + + conn, err := dialLDAP(cfg) + if err != nil { + return map[string]interface{}{"success": false, "message": err.Error()} + } + defer conn.Close() + + // baseDN 可读性校验 + req := ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, + 1, 0, false, "(objectClass=*)", []string{"dn"}, nil) + if _, err := conn.Search(req); err != nil { + return map[string]interface{}{"success": false, "message": fmt.Sprintf("服务器连通,但 Base DN 搜索失败: %v", err)} + } + + result := map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("连接成功:服务器可达,管理员绑定与 Base DN 校验通过 (%s)", cfg.BaseDN), + } + if testUsername != "" { + if testPassword == "" { + return map[string]interface{}{"success": false, "message": "请填写测试账号的密码"} + } + entry, err := verifyLDAPPassword(cfg, testUsername, testPassword) + if err != nil { + return map[string]interface{}{"success": false, "message": fmt.Sprintf("服务器连接正常,但用户「%s」验证失败: %v", testUsername, err)} + } + result["message"] = fmt.Sprintf("连接成功,用户「%s」验证通过 (DN: %s)", testUsername, entry.DN) + result["userInfo"] = map[string]string{ + "dn": entry.DN, "username": entry.Username, + "displayName": entry.DisplayName, "email": entry.Email, + } + } + return result +} diff --git a/backend/services/ldap_import.go b/backend/services/ldap_import.go new file mode 100644 index 0000000..9a6c13c --- /dev/null +++ b/backend/services/ldap_import.go @@ -0,0 +1,108 @@ +package services + +import ( + "time" + + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/repository" +) + +// LDAPImportUser 批量导入请求中的用户条目(数据来自预览接口) +type LDAPImportUser struct { + DN string `json:"dn"` + Username string `json:"username" binding:"required"` + Nickname string `json:"nickname"` + Email string `json:"email"` +} + +// LDAPImportResult 导入结果(对齐 opsx-web-lite 契约) +type LDAPImportResult struct { + SuccessCount int `json:"successCount"` + SkippedCount int `json:"skippedCount"` + UpdatedCount int `json:"updatedCount"` + FailedCount int `json:"failedCount"` + FailedUsers []map[string]interface{} `json:"failedUsers"` +} + +// BatchImportLDAPUsers 批量导入 LDAP 用户: +// 已存在且 LDAP 用户 → 跳过;本地同名 → 更新昵称并转 LDAP;否则新建(user 角色,密码占位)。 +func BatchImportLDAPUsers(users []LDAPImportUser) *LDAPImportResult { + result := &LDAPImportResult{FailedUsers: []map[string]interface{}{}} + db := repository.GetDB() + + for _, in := range users { + if in.Username == "" { + continue + } + // 查找本地同名用户(读锁短持有;NextID/CreateUser 不可在持锁时调用) + db.RLock() + idx := -1 + for i := range db.Users { + if db.Users[i].Username == in.Username { + idx = i + break + } + } + db.RUnlock() + + if idx >= 0 { + db.Lock() + if db.Users[idx].AuthSource == "ldap" { + db.Unlock() + result.SkippedCount++ + continue + } + db.Users[idx].AuthSource = "ldap" // 本地同名 → 转 LDAP 并同步昵称 + if in.Nickname != "" { + db.Users[idx].Nickname = in.Nickname + } + db.Users[idx].UpdatedAt = time.Now() + db.RequestSave() + db.Unlock() + result.UpdatedCount++ + continue + } + + hash, err := crypto.HashPassword("ldap-user-" + in.Username + "-no-local-login") + if err != nil { + result.FailedCount++ + result.FailedUsers = append(result.FailedUsers, map[string]interface{}{ + "username": in.Username, "error": err.Error(), + }) + continue + } + u := &models.User{ + Username: in.Username, + Password: hash, + Nickname: in.Nickname, + Role: "user", + AuthSource: "ldap", + } + if err := db.CreateUser(u); err != nil { + result.FailedCount++ + result.FailedUsers = append(result.FailedUsers, map[string]interface{}{ + "username": in.Username, "error": err.Error(), + }) + continue + } + result.SuccessCount++ + } + return result +} + +// SearchLDAPUsersForImport 预览入口(服务层薄封装,便于 controller 调用) +func SearchLDAPUsersForImport(cfg *LDAPConfig, ouDN string) ([]map[string]interface{}, error) { + users, err := searchLDAPUsers(cfg, ouDN, 500) + if err != nil { + return nil, err + } + out := make([]map[string]interface{}, 0, len(users)) + for _, u := range users { + out = append(out, map[string]interface{}{ + "dn": u.DN, "username": u.Username, + "displayName": u.DisplayName, "email": u.Email, + }) + } + return out, nil +} diff --git a/backend/services/log_policy.go b/backend/services/log_policy.go new file mode 100644 index 0000000..9362d95 --- /dev/null +++ b/backend/services/log_policy.go @@ -0,0 +1,173 @@ +package services + +import ( + "fmt" + "strings" + "time" + + "opsxloki/models" + "opsxloki/repository" +) + +func ListLogPolicyGroups() []models.LogPolicyGroup { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + groups := make([]models.LogPolicyGroup, len(db.LogPolicyGroups)) + for i, group := range db.LogPolicyGroups { + groups[i] = group + groups[i].UserIDs = append([]uint(nil), group.UserIDs...) + groups[i].Labels = make(map[string]string, len(group.Labels)) + for key, value := range group.Labels { groups[i].Labels[key] = value } + } + return groups +} + +func validatePolicyGroup(db *repository.JsonDB, group models.LogPolicyGroup, excludeID uint) error { + group.Name = strings.TrimSpace(group.Name) + if group.Name == "" { + return fmt.Errorf("策略组名称必填") + } + if group.SourceID == 0 { + return fmt.Errorf("请选择数据源") + } + foundSource := false + for _, source := range db.LogSources { + if source.ID == group.SourceID { + foundSource = true + break + } + } + if !foundSource { + return fmt.Errorf("数据源不存在") + } + if len(group.UserIDs) == 0 { + return fmt.Errorf("请选择至少一个用户") + } + for key := range group.Labels { + if !ValidLogLabelName(key) { + return fmt.Errorf("无效标签名: %s", key) + } + } + seen := map[uint]bool{} + for _, id := range group.UserIDs { + if id == 0 || seen[id] { + return fmt.Errorf("用户 ID 无效或重复") + } + seen[id] = true + found := false + for _, user := range db.Users { + if user.ID == id && user.Role != "admin" { + found = true + break + } + } + if !found { + return fmt.Errorf("用户 %d 不存在或不是普通用户", id) + } + } + for _, other := range db.LogPolicyGroups { + if other.ID == excludeID || other.SourceID != group.SourceID { + continue + } + if other.Name == group.Name { + return fmt.Errorf("此数据源已有同名策略组") + } + for _, id := range other.UserIDs { + if seen[id] { + return fmt.Errorf("用户 %d 已属于此数据源的另一个策略组", id) + } + } + } + return nil +} + +// Group membership replaces legacy per-user grants for the same source. +func removeDirectGrants(db *repository.JsonDB, sourceID uint, userIDs []uint) { + members := map[uint]bool{} + for _, id := range userIDs { + members[id] = true + } + for i := range db.LogSources { + if db.LogSources[i].ID != sourceID { + continue + } + kept := db.LogSources[i].Access[:0] + for _, rule := range db.LogSources[i].Access { + if !members[rule.UserID] { + kept = append(kept, rule) + } + } + db.LogSources[i].Access = kept + return + } +} + +func CreateLogPolicyGroup(group models.LogPolicyGroup) (*models.LogPolicyGroup, error) { + db := repository.GetDB() + id := db.NextID("logPolicyGroups") + db.Lock() + defer db.Unlock() + if err := validatePolicyGroup(db, group, 0); err != nil { + return nil, err + } + group.ID = id + group.Name = strings.TrimSpace(group.Name) + group.CreatedAt = time.Now() + group.UpdatedAt = group.CreatedAt + removeDirectGrants(db, group.SourceID, group.UserIDs) + db.LogPolicyGroups = append(db.LogPolicyGroups, group) + db.RequestSave() + return &group, nil +} + +func UpdateLogPolicyGroup(id uint, group models.LogPolicyGroup) (*models.LogPolicyGroup, error) { + db := repository.GetDB() + db.Lock() + defer db.Unlock() + for i := range db.LogPolicyGroups { + if db.LogPolicyGroups[i].ID != id { + continue + } + if err := validatePolicyGroup(db, group, id); err != nil { + return nil, err + } + group.ID = id + group.Name = strings.TrimSpace(group.Name) + group.CreatedAt = db.LogPolicyGroups[i].CreatedAt + group.UpdatedAt = time.Now() + removeDirectGrants(db, group.SourceID, group.UserIDs) + db.LogPolicyGroups[i] = group + db.RequestSave() + return &group, nil + } + return nil, fmt.Errorf("策略组不存在") +} + +func DeleteLogPolicyGroup(id uint) error { + db := repository.GetDB() + db.Lock() + defer db.Unlock() + for i := range db.LogPolicyGroups { + if db.LogPolicyGroups[i].ID == id { + db.LogPolicyGroups = append(db.LogPolicyGroups[:i], db.LogPolicyGroups[i+1:]...) + db.RequestSave() + return nil + } + } + return fmt.Errorf("策略组不存在") +} + +func policyGroupAccess(groups []models.LogPolicyGroup, sourceID, userID uint) *models.LogSourceAccess { + for _, group := range groups { + if group.SourceID != sourceID { + continue + } + for _, id := range group.UserIDs { + if id == userID { + return &models.LogSourceAccess{UserID: userID, Labels: group.Labels} + } + } + } + return nil +} diff --git a/backend/services/log_query.go b/backend/services/log_query.go new file mode 100644 index 0000000..b05005c --- /dev/null +++ b/backend/services/log_query.go @@ -0,0 +1,780 @@ +package services + +// 日志查询代理 — 后端代表前端访问 Loki HTTP API。 +// cluster-svc 型经 kube-apiserver service proxy(不依赖 ClusterIP 直通与集群 DNS); +// url 型直连。契约参照 aiops 日志中心(query/labels/values/histogram + cursor 翻页)。 +import ( + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/repository" +) + +const maxLogQueryLimit = 1000 + +// ==================== 数据源 CRUD ==================== + +func GetLogSource(id uint) *models.LogSource { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + for i := range db.LogSources { + if db.LogSources[i].ID == id { + s := db.LogSources[i] + return &s + } + } + return nil +} + +func ListLogSources() []map[string]interface{} { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + out := make([]map[string]interface{}, 0, len(db.LogSources)) + for i := range db.LogSources { + out = append(out, db.LogSources[i].Sanitized()) + } + return out +} + +func ListVisibleLogSources(userID uint, admin bool) []map[string]interface{} { + db := repository.GetDB() + db.RLock() + defer db.RUnlock() + out := make([]map[string]interface{}, 0) + for i := range db.LogSources { + s := &db.LogSources[i] + if admin || policyGroupAccess(db.LogPolicyGroups, s.ID, userID) != nil || sourceAccess(s, userID) != nil { + item := s.Sanitized() + if !admin { + item = map[string]interface{}{"id": s.ID, "name": s.Name, "isDefault": s.IsDefault} + } + out = append(out, item) + } + } + return out +} + +func sourceAccess(src *models.LogSource, userID uint) *models.LogSourceAccess { + for i := range src.Access { + if src.Access[i].UserID == userID { + return &src.Access[i] + } + } + return nil +} + +var labelNamePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +func ValidLogLabelName(name string) bool { return labelNamePattern.MatchString(name) } + +// AuthorizedLogQuery constrains the Loki stream selector server-side. Non-admins must be explicitly granted access. +func AuthorizedLogQuery(src *models.LogSource, userID uint, admin bool, query string) (string, error) { + if admin { + return query, nil + } + db := repository.GetDB() + var access *models.LogSourceAccess + if db != nil { + db.RLock() + access = policyGroupAccess(db.LogPolicyGroups, src.ID, userID) + db.RUnlock() + } + if access == nil { + access = sourceAccess(src, userID) + } + if access == nil { + return "", fmt.Errorf("无权查询此数据源") + } + q := strings.TrimSpace(query) + if !strings.HasPrefix(q, "{") { + return "", fmt.Errorf("普通用户查询必须以日志流选择器开头") + } + close := strings.IndexByte(q, '}') + if close < 0 || strings.Contains(q[close+1:], "{") { + return "", fmt.Errorf("查询只能包含一个日志流选择器") + } + if len(access.Labels) == 0 { + return q, nil + } + keys := make([]string, 0, len(access.Labels)) + for k := range access.Labels { + if !labelNamePattern.MatchString(k) { + return "", fmt.Errorf("访问策略包含无效标签名") + } + keys = append(keys, k) + } + sort.Strings(keys) + matchers := make([]string, 0, len(keys)) + for _, k := range keys { + v, _ := json.Marshal(access.Labels[k]) + matchers = append(matchers, k+"="+string(v)) + } + inner := strings.TrimSpace(q[1:close]) + if inner != "" { + inner += "," + } + return "{" + inner + strings.Join(matchers, ",") + "}" + q[close+1:], nil +} + +func CreateLogSource(s *models.LogSource) (*models.LogSource, error) { + if err := encryptSourceCredentials(s); err != nil { + return nil, err + } + db := repository.GetDB() + newID := db.NextID("logSources") + db.Lock() + defer db.Unlock() + s.ID = newID + if s.IsDefault { + for i := range db.LogSources { + db.LogSources[i].IsDefault = false + } + } + // 首个数据源自动设默认 + if len(db.LogSources) == 0 { + s.IsDefault = true + } + db.LogSources = append(db.LogSources, *s) + db.RequestSave() + return s, nil +} + +func encryptSourceCredentials(s *models.LogSource) error { + if s.BasicPass != "" { + v, err := crypto.EncryptField(s.BasicPass) + if err != nil { + return err + } + s.BasicPass = v + } + if s.BearerToken != "" { + v, err := crypto.EncryptField(s.BearerToken) + if err != nil { + return err + } + s.BearerToken = v + } + for name, value := range s.Headers { + if !validSourceHeader(name) { + return fmt.Errorf("自定义 Header 名称无效: %s", name) + } + v, err := crypto.EncryptField(value) + if err != nil { + return err + } + s.Headers[name] = v + } + return nil +} + +func validSourceHeader(name string) bool { + if name == "" || strings.EqualFold(name, "Host") || strings.EqualFold(name, "Authorization") { + return false + } + for _, c := range name { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { + return false + } + } + return true +} + +func UpdateLogSource(id uint, patch map[string]interface{}) error { + db := repository.GetDB() + db.Lock() + defer db.Unlock() + for i := range db.LogSources { + if db.LogSources[i].ID != id { + continue + } + if v, ok := patch["name"].(string); ok && v != "" { + db.LogSources[i].Name = v + } + if v, ok := patch["namespace"].(string); ok { + db.LogSources[i].Namespace = v + } + if v, ok := patch["service"].(string); ok { + db.LogSources[i].Service = v + } + if v, ok := patch["port"].(float64); ok { + db.LogSources[i].Port = int(v) + } + if v, ok := patch["url"].(string); ok { + db.LogSources[i].URL = v + } + if v, ok := patch["authType"].(string); ok { + db.LogSources[i].AuthType = v + } + if v, ok := patch["basicUser"].(string); ok { + db.LogSources[i].BasicUser = v + } + if v, ok := patch["bearerToken"].(string); ok { + if v != "" { + enc, err := crypto.EncryptField(v) + if err != nil { + return err + } + db.LogSources[i].BearerToken = enc + } + } + if v, ok := patch["skipTLSVerify"].(bool); ok { + db.LogSources[i].SkipTLSVerify = v + } + if raw, ok := patch["headers"]; ok { + data, err := json.Marshal(raw) + if err != nil { + return err + } + var headers map[string]string + if err := json.Unmarshal(data, &headers); err != nil { + return err + } + for name, value := range headers { + if !validSourceHeader(name) { + return fmt.Errorf("自定义 Header 名称无效: %s", name) + } + if value == "" { + if old, ok := db.LogSources[i].Headers[name]; ok { + headers[name] = old + } + continue + } + enc, err := crypto.EncryptField(value) + if err != nil { + return err + } + headers[name] = enc + } + db.LogSources[i].Headers = headers + } + if v, ok := patch["basicPass"].(string); ok && v != "" { + if enc, err := crypto.EncryptField(v); err == nil { + db.LogSources[i].BasicPass = enc + } + } + if v, ok := patch["isDefault"].(bool); ok && v { + for j := range db.LogSources { + db.LogSources[j].IsDefault = db.LogSources[j].ID == id + } + } + if raw, ok := patch["access"]; ok { + data, err := json.Marshal(raw) + if err != nil { + return err + } + var access []models.LogSourceAccess + if err := json.Unmarshal(data, &access); err != nil { + return err + } + if err := ValidateSourceAccess(access); err != nil { + return err + } + db.LogSources[i].Access = access + } + db.LogSources[i].UpdatedAt = time.Now() + db.RequestSave() + return nil + } + return fmt.Errorf("数据源不存在") +} + +func ValidateSourceAccess(access []models.LogSourceAccess) error { + seen := map[uint]bool{} + for _, rule := range access { + if rule.UserID == 0 || seen[rule.UserID] { + return fmt.Errorf("访问策略的用户 ID 无效或重复") + } + seen[rule.UserID] = true + for k := range rule.Labels { + if !labelNamePattern.MatchString(k) { + return fmt.Errorf("访问策略包含无效标签名: %s", k) + } + } + } + return nil +} + +func DeleteLogSource(id uint) error { + db := repository.GetDB() + db.Lock() + defer db.Unlock() + for i := range db.LogSources { + if db.LogSources[i].ID == id { + db.LogSources = append(db.LogSources[:i], db.LogSources[i+1:]...) + groups := db.LogPolicyGroups[:0] + for _, group := range db.LogPolicyGroups { + if group.SourceID != id { + groups = append(groups, group) + } + } + db.LogPolicyGroups = groups + db.RequestSave() + return nil + } + } + return fmt.Errorf("数据源不存在") +} + +// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host-binary→url;k8s→cluster-svc) +func EnsureLogSourceForDeployment(d *models.LokiDeployment) { + if d == nil || d.Endpoint == "" { + return + } + db := repository.GetDB() + db.RLock() + for i := range db.LogSources { + if db.LogSources[i].Name == d.Name { + db.RUnlock() + return + } + } + db.RUnlock() + + svc := "loki" + port := 3100 + src := &models.LogSource{ + Name: d.Name, TargetType: "cluster-svc", + ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port, + AuthType: "none", + } + if d.Mode == "host-binary" { + src.TargetType = "url" + src.URL = d.Endpoint + src.ClusterID = nil + } + _, _ = CreateLogSource(src) +} + +// ==================== Loki HTTP 访问 ==================== + +// lokiProxyGET 对数据源执行 GET;cluster-svc 走 kubectl --raw service proxy。 +func lokiProxyGET(ctx context.Context, src *models.LogSource, path string, query url.Values) ([]byte, int, error) { + // cluster-svc:kubectl get --raw /api/v1/namespaces/{ns}/services/{svc}:{port}/proxy{path}?q + if src.TargetType == "cluster-svc" { + db := repository.GetDB() + db.RLock() + var cluster *models.Cluster + for i := range db.Clusters { + if src.ClusterID != nil && db.Clusters[i].ID == *src.ClusterID { + c := db.Clusters[i] + cluster = &c + break + } + } + db.RUnlock() + if cluster == nil { + return nil, 500, fmt.Errorf("数据源关联的集群不存在") + } + env, cleanup, err := kubeconfigEnv(cluster) + if err != nil { + return nil, 500, err + } + defer cleanup() + + proxyPath := fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%d/proxy%s", + src.Namespace, src.Service, src.Port, path) + full := proxyPath + if qs := query.Encode(); qs != "" { + full += "?" + qs + } + cmd := exec.CommandContext(ctx, "kubectl", "get", "--raw", full) + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + return nil, 502, fmt.Errorf("kubectl 代理查询失败: %v\n%s", err, truncateStr(string(out), 300)) + } + return out, 200, nil + } + + // url 直连 + if src.URL == "" { + return nil, 500, fmt.Errorf("数据源未配置访问地址") + } + full := strings.TrimSuffix(src.URL, "/") + path + if qs := query.Encode(); qs != "" { + full += "?" + qs + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) + if err != nil { + return nil, 500, err + } + applyLokiAuth(req, src) + client := &http.Client{Timeout: 60 * time.Second, Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: src.SkipTLSVerify}, + }} + resp, err := client.Do(req) + if err != nil { + return nil, 502, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, 502, err + } + if resp.StatusCode >= 400 { + return body, resp.StatusCode, fmt.Errorf("Loki 返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 300)) + } + return body, resp.StatusCode, nil +} + +func applyLokiAuth(req *http.Request, src *models.LogSource) { + switch src.AuthType { + case "basic": + if pass, err := DecryptCred(src.BasicPass); err == nil { + req.Header.Set("Authorization", "Basic "+ + base64.StdEncoding.EncodeToString([]byte(src.BasicUser+":"+pass))) + } + case "bearer": + if token, err := DecryptCred(src.BearerToken); err == nil { + req.Header.Set("Authorization", "Bearer "+token) + } + } + for name, encrypted := range src.Headers { + if validSourceHeader(name) { + if value, err := DecryptCred(encrypted); err == nil { + req.Header.Set(name, value) + } + } + } +} + +func truncateStr(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// ==================== 查询实现(契约对齐 aiops) ==================== + +type LogEntryRow struct { + Timestamp string `json:"timestamp"` + TimestampNs string `json:"timestampNs"` + Level string `json:"level"` + Message string `json:"message"` + Labels map[string]string `json:"labels"` +} + +type LogQueryResult struct { + Entries []LogEntryRow `json:"entries"` + HasMore bool `json:"hasMore"` + NextCursor string `json:"nextCursor"` +} + +// QueryLokiLogs 日志查询:flatten streams → 统一行;cursor(最后一条纳秒时间戳)向后翻页 +func QueryLokiLogs(ctx context.Context, src *models.LogSource, query string, start, end int64, limit int, direction, cursor string) (*LogQueryResult, error) { + if limit <= 0 { + limit = 200 + } + if limit > maxLogQueryLimit { + limit = maxLogQueryLimit + } + if direction == "" { + direction = "backward" + } + if end <= 0 { + end = time.Now().UnixNano() + } + if start <= 0 { + start = end - int64(time.Hour) // 默认最近 1 小时 + } + if cursor != "" { + if c, err := strconv.ParseInt(cursor, 10, 64); err == nil { + if direction == "forward" { + start = c + 1 + } else { + end = c - 1 + } + } + } + + q := url.Values{} + q.Set("query", query) + q.Set("start", strconv.FormatInt(start, 10)) + q.Set("end", strconv.FormatInt(end, 10)) + q.Set("limit", strconv.Itoa(limit)) + q.Set("direction", direction) + + body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query_range", q) + if err != nil { + return nil, err + } + var resp struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []struct { + Stream map[string]string `json:"stream"` + Values [][2]string `json:"values"` + } `json:"result"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("解析 Loki 响应失败: %w", err) + } + + entries := make([]LogEntryRow, 0, limit) + var lastTs string + for _, stream := range resp.Data.Result { + for _, v := range stream.Values { + entries = append(entries, LogEntryRow{ + Timestamp: formatLokiTimestamp(v[0]), + TimestampNs: v[0], + Level: detectLevel(stream.Stream, v[1]), + Message: v[1], + Labels: stream.Stream, + }) + } + } + sort.SliceStable(entries, func(i, j int) bool { + a, _ := strconv.ParseInt(entries[i].TimestampNs, 10, 64) + b, _ := strconv.ParseInt(entries[j].TimestampNs, 10, 64) + if direction == "forward" { + return a < b + } + return a > b + }) + if len(entries) > limit { + entries = entries[:limit] + } + if len(entries) > 0 { + lastTs = entries[len(entries)-1].TimestampNs + } + + nextCursor := "" + if len(entries) >= limit && lastTs != "" { + if ts, err := strconv.ParseInt(strings.Split(lastTs, ".")[0], 10, 64); err == nil { + nextCursor = strconv.FormatInt(ts, 10) + } + } + return &LogQueryResult{Entries: entries, HasMore: nextCursor != "", NextCursor: nextCursor}, nil +} + +// formatLokiTimestamp Loki 时间串(纳秒字符串/科学计数法)→ RFC3339 +func formatLokiTimestamp(ts string) string { + if i := strings.IndexAny(ts, ".eE"); i > 0 { + ts = ts[:i] + } + n, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return ts + } + // 自适应纳秒/微秒/毫秒/秒 + switch { + case n > 1e18: + return time.Unix(0, n).Format(time.RFC3339) + case n > 1e15: + return time.Unix(0, n*1e3).Format(time.RFC3339) + case n > 1e12: + return time.Unix(0, n*1e6).Format(time.RFC3339) + case n > 1e9: + return time.Unix(0, n*1e9).Format(time.RFC3339) + default: + return time.Unix(n, 0).Format(time.RFC3339) + } +} + +// detectLevel 优先取 level 标签;否则按行内容推断 +func detectLevel(labels map[string]string, line string) string { + if lv, ok := labels["level"]; ok && lv != "" { + return strings.ToLower(lv) + } + l := strings.ToLower(line) + for _, p := range []string{"error", "err", "fatal", "panic"} { + if strings.Contains(l, p) { + return "error" + } + } + if strings.Contains(l, "warn") { + return "warn" + } + return "info" +} + +// LokiLabels 标签名列表 +func LokiLabels(ctx context.Context, src *models.LogSource, start, end int64) ([]string, error) { + q := url.Values{} + if end > 0 { + q.Set("end", strconv.FormatInt(end, 10)) + } + if start > 0 { + q.Set("start", strconv.FormatInt(start, 10)) + } + body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/labels", q) + if err != nil { + return nil, err + } + var resp struct { + Data []string `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +// LokiLabelValues 标签值列表(query 只取 {...} selector 部分) +func LokiLabelValues(ctx context.Context, src *models.LogSource, name, selector string, start, end int64) ([]string, error) { + q := url.Values{} + if s := extractStreamSelector(selector); s != "" && s != "{}" { + q.Set("query", s) + } + if end > 0 { + q.Set("end", strconv.FormatInt(end, 10)) + } + if start > 0 { + q.Set("start", strconv.FormatInt(start, 10)) + } + body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/label/"+url.PathEscape(name)+"/values", q) + if err != nil { + return nil, err + } + var resp struct { + Data []string `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +// extractStreamSelector 从 LogQL 中提取 {...} 部分(labels API 仅接受 stream selector) +func extractStreamSelector(query string) string { + i := strings.Index(query, "{") + if i < 0 { + return "" + } + j := strings.Index(query[i:], "}") + if j < 0 { + return "" + } + return query[i : i+j+1] +} + +// LokiHistogram 日志量直方图:服务端包 sum(count_over_time(q[step])) +func LokiHistogram(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]map[string]interface{}, error) { + if end <= 0 { + end = time.Now().UnixNano() + } + if start <= 0 { + start = end - int64(time.Hour) + } + span := end - start + step := pickHistogramStep(span) + wrapped := fmt.Sprintf("sum(count_over_time(%s[%s]))", query, step) + + q := url.Values{} + q.Set("query", wrapped) + q.Set("start", strconv.FormatInt(start, 10)) + q.Set("end", strconv.FormatInt(end, 10)) + q.Set("step", step) + body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query_range", q) + if err != nil { + return nil, err + } + var resp struct { + Data struct { + Result []struct { + Values [][2]interface{} `json:"values"` + } `json:"result"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + buckets := []map[string]interface{}{} + if len(resp.Data.Result) > 0 { + for _, v := range resp.Data.Result[0].Values { + // metric API 的 ts 为秒级浮点数(如 1.79e+09),query 日志的才是纳秒字符串 + tsf, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[0]), 64) + cnt, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[1]), 64) + buckets = append(buckets, map[string]interface{}{ + "timestamp": time.Unix(int64(tsf), 0).Format(time.RFC3339), + "count": int64(cnt), + }) + } + } + return buckets, nil +} + +// pickHistogramStep 按时间跨度自适应步长(aiops 同款) +func pickHistogramStep(spanNano int64) string { + span := time.Duration(spanNano) + switch { + case span <= 15*time.Minute: + return "30s" + case span <= time.Hour: + return "1m" + case span <= 6*time.Hour: + return "5m" + case span <= 24*time.Hour: + return "15m" + case span <= 7*24*time.Hour: + return "1h" + default: + return "6h" + } +} + +// DeploymentReady 部署级就绪探测:按部署名匹配自动注册的数据源,经其访问 Loki /ready +func DeploymentReady(ctx context.Context, deploymentName string) (bool, string) { + if deploymentName == "" { + return false, "" + } + db := repository.GetDB() + db.RLock() + var src *models.LogSource + for i := range db.LogSources { + if db.LogSources[i].Name == deploymentName { + s := db.LogSources[i] + src = &s + break + } + } + db.RUnlock() + if src == nil { + return false, "未注册数据源" + } + if LokiReady(ctx, src) { + return true, "" + } + return false, "Loki 未就绪" +} + +// LokiReady Loki /ready 探活(部署卡片/查询页状态点) +func LokiReady(ctx context.Context, src *models.LogSource) bool { + body, code, err := lokiProxyGET(ctx, src, "/ready", nil) + if err != nil || code != 200 { + return false + } + return strings.Contains(strings.ToLower(string(body)), "ready") +} + +// TestLogSource 数据源连通性(/loki/api/v1/labels 探活) +func TestLogSource(ctx context.Context, src *models.LogSource) map[string]interface{} { + labels, err := LokiLabels(ctx, src, 0, 0) + if err != nil { + return map[string]interface{}{"success": false, "message": err.Error()} + } + return map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("连接成功,发现 %d 个标签", len(labels)), + "labels": labels, + } +} diff --git a/backend/services/log_query_test.go b/backend/services/log_query_test.go new file mode 100644 index 0000000..3fee85b --- /dev/null +++ b/backend/services/log_query_test.go @@ -0,0 +1,42 @@ +package services + +import ( + "opsxloki/models" + "strings" + "testing" +) + +func TestAuthorizedLogQuery(t *testing.T) { + src := &models.LogSource{Access: []models.LogSourceAccess{{UserID: 7, Labels: map[string]string{"namespace": "prod", "pod": "loki-0"}}}} + if _, err := AuthorizedLogQuery(src, 8, false, `{app="api"}`); err == nil { + t.Fatal("ungranted user queried source") + } + q, err := AuthorizedLogQuery(src, 7, false, `{app="api"} |= "error"`) + if err != nil { + t.Fatal(err) + } + for _, part := range []string{`app="api"`, `namespace="prod"`, `pod="loki-0"`, `|= "error"`} { + if !strings.Contains(q, part) { + t.Fatalf("missing %q in %q", part, q) + } + } + if _, err := AuthorizedLogQuery(src, 7, false, `sum(count_over_time({app="api"}[1m]))`); err == nil { + t.Fatal("metric query bypassed stream restriction") + } + if _, err := AuthorizedLogQuery(src, 7, false, `{app="api"} or {app="other"}`); err == nil { + t.Fatal("second selector bypassed stream restriction") + } +} + +func TestPolicyGroupAccess(t *testing.T) { + groups := []models.LogPolicyGroup{{SourceID: 2, Labels: map[string]string{"namespace": "log", "app": "api"}, UserIDs: []uint{7, 8}}} + if got := policyGroupAccess(groups, 2, 8); got == nil || got.Labels["app"] != "api" { + t.Fatal("group member should inherit exact label scope") + } + if got := policyGroupAccess(groups, 3, 8); got != nil { + t.Fatal("group must not grant access to another source") + } + if got := policyGroupAccess(groups, 2, 9); got != nil { + t.Fatal("nonmember gained access") + } +} diff --git a/backend/services/loki_deployer.go b/backend/services/loki_deployer.go index 66ad316..0aed55c 100644 --- a/backend/services/loki_deployer.go +++ b/backend/services/loki_deployer.go @@ -10,8 +10,8 @@ import ( "strings" "text/template" - "lokideploy/models" - "lokideploy/pkg/crypto" + "opsxloki/models" + "opsxloki/pkg/crypto" ) // LokiDeployer 负责 Loki 的三种部署模式:k8s-helm / k8s-yaml / host-binary @@ -34,16 +34,30 @@ func (d *LokiDeployer) Deploy(ctx context.Context) error { d.log("warn", err.Error(), "") } + var err error switch d.deployment.Mode { case "k8s-helm": - return d.deployHelm(ctx) + err = d.deployHelm(ctx) case "k8s-yaml": - return d.deployYaml(ctx) + err = d.deployYaml(ctx) case "host-binary": - return d.deployBinary(ctx) + err = d.deployBinary(ctx) default: return fmt.Errorf("unsupported deployment mode: %s", d.deployment.Mode) } + if err != nil { + return err + } + + // Loki 就绪后,按需同环境部署 Grafana(数据源自动指向本 Loki) + if d.deployment.GrafanaEnabled { + g := &GrafanaDeployer{deployment: d.deployment, log: d.log} + if gerr := g.Deploy(ctx); gerr != nil { + d.log("error", fmt.Sprintf("Grafana deployment failed: %v", gerr), "") + return fmt.Errorf("grafana deployment failed: %w", gerr) + } + } + return nil } // ==================== Helm 部署 (grafana/loki v6+) ==================== @@ -169,12 +183,7 @@ func (d *LokiDeployer) deployBinary(ctx context.Context) error { return fmt.Errorf("please select target host") } host := d.deployment.Host - password := host.Password - if strings.HasPrefix(password, "ENC:") { - password, _ = DecryptCred(password) - } - - client, err := NewSSHClient(host.HostIP, host.Port, host.User, password) + client, err := NewSSHClientWithAuth(HostSSHAuth(host)) if err != nil { return err } @@ -696,7 +705,6 @@ common: filesystem: chunks_directory: /var/lib/loki/chunks rules_directory: /var/lib/loki/rules - admin_api_directory: /var/lib/loki/admin replication_factor: {{ .ReplicationFactor }} ring: instance_addr: {{ .RingInstanceAddr }} @@ -730,20 +738,11 @@ ingester: chunk_target_size: 1048576 chunk_encoding: snappy max_chunk_age: 2h - max_transfer_retries: 0 autoforget_unhealthy: true querier: max_concurrent: 10 query_ingesters_within: 2h - engine: - timeout: 3m - max_look_back_period: 30s - -query_frontend: - max_outstanding_per_tenant: 4096 - compress_responses: true - scheduler_address: "" query_scheduler: max_outstanding_requests_per_tenant: 4096 @@ -786,6 +785,9 @@ schema_config: ruler: storage: type: {{ .RulerStoreType }} +{{- if .RulerStorageBlock }} +{{ .RulerStorageBlock }} +{{- end }} {{- if eq .RulerStoreType "local" }} local: directory: /var/lib/loki/rules @@ -802,12 +804,7 @@ frontend: max_outstanding_per_tenant: 4096 compress_responses: true -frontend_worker: - parallelism: 10 - -table_manager: - retention_deletes_enabled: true - retention_period: {{ .RetentionPeriod }}h +frontend_worker: {} chunk_store_config: chunk_cache_config: @@ -831,6 +828,7 @@ type lokiConfigData struct { RingInstanceAddr string // 单副本 127.0.0.1;多副本 0.0.0.0(须可被对端寻址) RingKVStore string // 单副本 inmemory;多副本 memberlist MemberlistBlock string // 多副本时非空,注入 memberlist 顶级配置 + RulerStorageBlock string // 对象存储时非空,注入 ruler.storage.s3/gcs/azure 嵌套配置 } func (d *LokiDeployer) generateLokiConfig() string { @@ -870,7 +868,7 @@ func (d *LokiDeployer) generateLokiConfig() string { ringStore = "memberlist" ns := d.deployment.Namespace if ns == "" { - ns = "loki" + ns = "log" } memberlistBlock = fmt.Sprintf(`memberlist: join_members: @@ -892,6 +890,7 @@ func (d *LokiDeployer) generateLokiConfig() string { RingInstanceAddr: ringAddr, RingKVStore: ringStore, MemberlistBlock: memberlistBlock, + RulerStorageBlock: d.buildRulerStorageBlock(), } tmpl, err := template.New("loki-config").Parse(lokiConfigTemplate) @@ -962,6 +961,63 @@ func (d *LokiDeployer) buildStorageConfigBlock() string { } } +// buildRulerStorageBlock 生成 ruler.storage 下与存储类型同名的嵌套配置 +// (Loki 3.x 的 ruler 存储客户端不回退读取 storage_config,缺少此块会以空 bucket 启动失败) +func (d *LokiDeployer) buildRulerStorageBlock() string { + if d.deployment.StorageConfig == nil { + return "" + } + sc := d.deployment.StorageConfig + switch sc.StorageType { + case "s3": + endpoint := sc.Endpoint + if endpoint == "" { + endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region) + } + insecure := "false" + if sc.Insecure { + insecure = "true" + } + return fmt.Sprintf(` s3: + s3: s3://%s/%s + endpoint: %s + region: %s + access_key_id: %s + secret_access_key: %s + insecure: %s + s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, endpoint, sc.Region, sc.AccessKey, sc.SecretKey, insecure) + case "oss": + return fmt.Sprintf(` s3: + s3: s3://%s/%s + endpoint: %s + access_key_id: %s + secret_access_key: %s + s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey) + case "minio": + insecure := "false" + if sc.Insecure { + insecure = "true" + } + return fmt.Sprintf(` s3: + s3: s3://%s/%s + endpoint: %s + access_key_id: %s + secret_access_key: %s + insecure: %s + s3forcepathstyle: true`, sc.Bucket, sc.PathPrefix, sc.Endpoint, sc.AccessKey, sc.SecretKey, insecure) + case "gcs": + return fmt.Sprintf(` gcs: + bucket_name: %s`, sc.Bucket) + case "azure": + return fmt.Sprintf(` azure: + container_name: %s + account_name: %s + account_key: %s`, sc.Bucket, sc.AccessKey, sc.SecretKey) + default: + return "" + } +} + // generateLokiConfigFallback 当模板解析失败时的降级配置 func (d *LokiDeployer) generateLokiConfigFallback(retentionHours int) string { return fmt.Sprintf(`auth_enabled: %v @@ -996,8 +1052,6 @@ ingester: max_chunk_age: 2h querier: max_concurrent: 10 -query_frontend: - max_outstanding_per_tenant: 4096 compactor: working_directory: /var/lib/loki/compactor retention_enabled: true @@ -1033,7 +1087,7 @@ func (d *LokiDeployer) effectiveNamespace() string { if d.deployment.Namespace != "" { return d.deployment.Namespace } - return "loki" + return "log" } func (d *LokiDeployer) effectiveReleaseName() string { @@ -1075,6 +1129,11 @@ func (d *LokiDeployer) log(level, message, detail string) { // ==================== 清理 / 卸载 ==================== func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) { + // 同环境部署的 Grafana 一并清理 + if deployment.GrafanaEnabled { + CleanupGrafana(ctx, deployment) + } + if deployment.Mode == "k8s-helm" && deployment.Cluster != nil { kubeconfig, err := resolveKubeconfig(deployment.Cluster) if err != nil { @@ -1089,7 +1148,7 @@ func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) { env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) ns := deployment.Namespace if ns == "" { - ns = "loki" + ns = "log" } name := deployment.Name if name == "" { @@ -1121,7 +1180,7 @@ func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) { env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile)) ns := deployment.Namespace if ns == "" { - ns = "loki" + ns = "log" } // 删除所有相关资源 @@ -1140,11 +1199,7 @@ func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) { if deployment.Mode == "host-binary" && deployment.Host != nil { host := deployment.Host - password := host.Password - if strings.HasPrefix(password, "ENC:") { - password, _ = DecryptCred(password) - } - client, _ := NewSSHClient(host.HostIP, host.Port, host.User, password) + client, _ := NewSSHClientWithAuth(HostSSHAuth(host)) if client != nil { defer client.Close() client.Execute(ctx, "systemctl stop loki 2>/dev/null || true") diff --git a/backend/services/ssh.go b/backend/services/ssh.go index 89fb7d0..a2e75f7 100644 --- a/backend/services/ssh.go +++ b/backend/services/ssh.go @@ -14,9 +14,9 @@ import ( "strings" "time" - "lokideploy/config" - "lokideploy/models" - "lokideploy/pkg/crypto" + "opsxloki/config" + "opsxloki/models" + "opsxloki/pkg/crypto" "golang.org/x/crypto/ssh" ) @@ -160,6 +160,19 @@ func buildAuthMethods(auth *models.SSHAuthConfig) []ssh.AuthMethod { return methods } +// HostSSHAuth 从 Host 模型构建完整 SSH 认证配置(密钥优先、密码后备)。 +// 凭证保持 ENC: 原样传入,由 buildAuthMethods 统一解密。 +func HostSSHAuth(host *models.Host) *models.SSHAuthConfig { + return &models.SSHAuthConfig{ + Host: host.HostIP, + Port: host.Port, + User: host.User, + Password: host.Password, + PrivateKey: host.PrivateKey, + KeyPhrase: host.KeyPhrase, + } +} + // NewSSHClient 创建SSH客户端(支持密钥+密码认证,TOFU主机密钥验证) func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) { auth := &SSHAuthConfig{ diff --git a/backend/services/storage.go b/backend/services/storage.go index 9925040..8c3045f 100644 --- a/backend/services/storage.go +++ b/backend/services/storage.go @@ -4,10 +4,10 @@ import ( "fmt" "time" - "lokideploy/models" - "lokideploy/pkg/crypto" - "lokideploy/repository" - "lokideploy/utils" + "opsxloki/models" + "opsxloki/pkg/crypto" + "opsxloki/repository" + "opsxloki/utils" ) type StorageService struct{} diff --git a/backend/services/storage_check.go b/backend/services/storage_check.go new file mode 100644 index 0000000..8d2a050 --- /dev/null +++ b/backend/services/storage_check.go @@ -0,0 +1,216 @@ +package services + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// StorageTestResult 存储连接测试结果(逐级:可达 → 认证 → 桶) +type StorageTestResult struct { + Reachable bool `json:"reachable"` + Authenticated bool `json:"authenticated"` + BucketExists bool `json:"bucketExists"` + BucketCreated bool `json:"bucketCreated"` + Message string `json:"message"` +} + +// StorageTestParams 测试参数(与创建存储配置一致,额外支持自动建桶) +type StorageTestParams struct { + StorageType string + Endpoint string + Bucket string + Region string + AccessKey string + SecretKey string + Insecure bool + CreateIfMissing bool +} + +// ==================== S3 SigV4(零依赖实现,兼容 MinIO/RustFS/S3/OSS S3 兼容端点) ==================== + +func hmacSHA256(key []byte, data string) []byte { + h := hmac.New(sha256.New, key) + h.Write([]byte(data)) + return h.Sum(nil) +} + +func sha256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// s3Request 发起一次 SigV4 签名的 S3 请求(路径风格),path 为 "/" 或 "/" +func s3Request(method, endpoint, region, accessKey, secretKey, path string, insecure bool) (*http.Response, error) { + // endpoint 解析:带 scheme 用之;裸主机名默认 https,insecure 时 http + base := strings.TrimSpace(endpoint) + scheme := "" + if strings.HasPrefix(base, "http://") { + scheme, base = "http", strings.TrimPrefix(base, "http://") + } else if strings.HasPrefix(base, "https://") { + scheme, base = "https", strings.TrimPrefix(base, "https://") + } else { + if insecure { + scheme = "http" + } else { + scheme = "https" + } + } + base = strings.TrimSuffix(base, "/") + if region == "" { + region = "us-east-1" + } + + now := time.Now().UTC() + amzDate := now.Format("20060102T150405Z") + dateStamp := now.Format("20060102") + payloadHash := sha256Hex("") // 空 body + + // 规范请求(SigV4: host/x-amz-content-sha256/x-amz-date 参与签名) + canonicalURI := path + if canonicalURI == "" { + canonicalURI = "/" + } + canonicalRequest := strings.Join([]string{ + method, + canonicalURI, + "", // query + "host:" + base + "\n" + + "x-amz-content-sha256:" + payloadHash + "\n" + + "x-amz-date:" + amzDate + "\n", + "host;x-amz-content-sha256;x-amz-date", + payloadHash, + }, "\n") + + scope := dateStamp + "/" + region + "/s3/aws4_request" + stringToSign := strings.Join([]string{ + "AWS4-HMAC-SHA256", + amzDate, + scope, + sha256Hex(canonicalRequest), + }, "\n") + + kDate := hmacSHA256([]byte("AWS4"+secretKey), dateStamp) + kRegion := hmacSHA256(kDate, region) + kService := hmacSHA256(kRegion, "s3") + kSigning := hmacSHA256(kService, "aws4_request") + signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign)) + + req, err := http.NewRequest(method, scheme+"://"+base+canonicalURI, nil) + if err != nil { + return nil, err + } + req.Host = base + req.Header.Set("x-amz-date", amzDate) + req.Header.Set("x-amz-content-sha256", payloadHash) + req.Header.Set("Authorization", fmt.Sprintf( + "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=%s", + accessKey, scope, signature)) + + client := &http.Client{Timeout: 10 * time.Second} + return client.Do(req) +} + +// s3ErrBrief 从 S3 错误响应 XML 中提取 Code/Message,避免把整段 XML 暴露给用户 +func s3ErrBrief(body []byte) string { + var e struct { + Code string `xml:"Code"` + Message string `xml:"Message"` + } + if err := xml.Unmarshal(body, &e); err == nil && (e.Code != "" || e.Message != "") { + if e.Code != "" && e.Message != "" { + return e.Code + ": " + e.Message + } + return e.Code + e.Message + } + brief := strings.TrimSpace(string(body)) + if len(brief) > 120 { + brief = brief[:120] + "..." + } + return brief +} + +// ==================== 测试连接 ==================== + +// TestStorageConnection 逐级测试对象存储:可达性 → 凭证 → 桶存在性(可选自动创建)。 +// OSS 走其 S3 兼容端点;不覆盖 OSS 原生协议的独有能力。 +func TestStorageConnection(p StorageTestParams) StorageTestResult { + // ① ListBuckets:同时验证 网络可达 + AK/SK 有效 + resp, err := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure) + if err != nil { + return StorageTestResult{Message: fmt.Sprintf("无法连接对象存储:%v", err)} + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return StorageTestResult{ + Reachable: true, + Message: fmt.Sprintf("连接成功但认证失败(HTTP %d),请检查 Access Key / Secret Key。%s", resp.StatusCode, s3ErrBrief(body)), + } + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return StorageTestResult{ + Reachable: true, + Message: fmt.Sprintf("对象存储返回异常(HTTP %d):%s", resp.StatusCode, s3ErrBrief(body)), + } + } + + // 解析 ListBuckets,顺带确认目标桶是否在列表中 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var listResp struct { + Buckets struct { + Bucket []struct { + Name string `xml:"Name"` + } `xml:"Bucket"` + } `xml:"Buckets"` + } + _ = xml.Unmarshal(body, &listResp) + found := false + for _, b := range listResp.Buckets.Bucket { + if b.Name == p.Bucket { + found = true + break + } + } + + result := StorageTestResult{Reachable: true, Authenticated: true, BucketExists: found} + + if found { + result.Message = "连接成功,凭证有效,桶已存在" + return result + } + + // ② 桶不存在:按需创建 + if !p.CreateIfMissing { + result.Message = fmt.Sprintf("连接成功,凭证有效,但桶「%s」不存在;勾选「不存在时自动创建」可一键创建", p.Bucket) + return result + } + + putResp, err := s3Request("PUT", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/"+url.PathEscape(p.Bucket), p.Insecure) + if err != nil { + result.Message = fmt.Sprintf("创建桶失败:%v", err) + return result + } + defer putResp.Body.Close() + + // 200=创建成功;409 BucketAlreadyOwnedByYou 视为已存在 + if putResp.StatusCode == http.StatusOK || putResp.StatusCode == http.StatusConflict { + result.BucketExists = true + result.BucketCreated = true + result.Message = fmt.Sprintf("连接成功,桶「%s」不存在,已自动创建 ✓", p.Bucket) + return result + } + + putBody, _ := io.ReadAll(io.LimitReader(putResp.Body, 512)) + result.Message = fmt.Sprintf("凭证有效,但创建桶失败(HTTP %d):%s", putResp.StatusCode, s3ErrBrief(putBody)) + return result +} diff --git a/backend/utils/errors.go b/backend/utils/errors.go index 5d294a3..7086533 100644 --- a/backend/utils/errors.go +++ b/backend/utils/errors.go @@ -12,6 +12,9 @@ var ( // 资源相关错误 ErrNotFound = errors.New("资源不存在") + ErrUsernameExists = errors.New("用户名已存在") + ErrCannotDeleteSelf = errors.New("不能删除当前登录的账号") + ErrLastAdmin = errors.New("不能删除最后一个管理员账号") ErrAlreadyExists = errors.New("资源已存在") ErrUnauthorized = errors.New("未登录或登录已过期") ErrForbidden = errors.New("没有操作权限") diff --git a/build.sh b/build.sh index 63f119f..2422b24 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,6 @@ #!/bin/bash # ============================================================ -# LokiDeploy Build Script v2 +# opsx-loki Build Script v2 # ============================================================ # Builds frontend and backend into a single deployable package # with security hardening and integrity checks. @@ -16,7 +16,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" echo "========================================" -echo " LokiDeploy Build Script v2" +echo " opsx-loki Build Script v2" echo " Security-hardened build pipeline" echo "========================================" @@ -104,10 +104,10 @@ GIT_COMMIT=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "un $GO_CMD build \ -ldflags="-s -w -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT} -extldflags '-static'" \ - -o bin/lokideploy . + -o bin/opsxloki . -echo " Backend built: backend/bin/lokideploy" -echo " Binary size: $(du -h bin/lokideploy | cut -f1)" +echo " Backend built: backend/bin/opsxloki" +echo " Binary size: $(du -h bin/opsxloki | cut -f1)" echo " Build time: $BUILD_TIME" echo " Git commit: $GIT_COMMIT" @@ -124,8 +124,8 @@ echo "========================================" echo " Build Complete!" echo "========================================" echo " Frontend: backend/dist/" -echo " Backend: backend/bin/lokideploy" -echo " Size: $(du -h backend/bin/lokideploy | cut -f1)" +echo " Backend: backend/bin/opsxloki" +echo " Size: $(du -h backend/bin/opsxloki | cut -f1)" echo "" echo "========================================" echo " SECURITY REMINDERS" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b133a40..3c55938 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,5 @@ # ============================================================ -# LokiDeploy - Production Docker Compose Configuration +# opsx-loki - Production Docker Compose Configuration # ============================================================ # Features: # - Multi-instance deployment with load balancing @@ -15,14 +15,14 @@ services: # ---- Nginx Load Balancer ---- nginx: image: nginx:1.27-alpine - container_name: lokideploy-lb + container_name: opsxloki-lb restart: unless-stopped ports: - "8080:8080" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro networks: - - lokideploy-net + - opsxloki-net healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] interval: 30s @@ -52,21 +52,21 @@ services: - /var/cache/nginx:noexec,nosuid,size=10m - /var/run:noexec,nosuid,size=5m depends_on: - lokideploy-1: + opsxloki-1: condition: service_healthy - lokideploy-2: + opsxloki-2: condition: service_healthy # ---- Application Instance 1 ---- - lokideploy-1: + opsxloki-1: build: context: . dockerfile: Dockerfile args: TARGETARCH: ${TARGETARCH:-amd64} - image: lokideploy:v2 - container_name: lokideploy-1 - hostname: lokideploy-1 + image: opsxloki:v2 + container_name: opsxloki-1 + hostname: opsxloki-1 restart: unless-stopped security_opt: @@ -85,16 +85,16 @@ services: volumes: # Named volume for data persistence - - lokideploy-data-1:/app/data + - opsxloki-data-1:/app/data # kubeconfig read-only (P0: non-root path) - - ${KUBECONFIG_PATH:-~/.kube}:/home/lokideploy/.kube:ro + - ${KUBECONFIG_PATH:-~/.kube}:/home/opsxloki/.kube:ro # SSH keys read-only - - ${SSH_KEY_PATH:-~/.ssh}:/home/lokideploy/.ssh:ro + - ${SSH_KEY_PATH:-~/.ssh}:/home/opsxloki/.ssh:ro environment: - SERVER_PORT=8080 - DATA_DIR=/app/data - - KUBECONFIG=/home/lokideploy/.kube/config + - KUBECONFIG=/home/opsxloki/.kube/config # Secrets via Docker Secrets file injection - JWT_SECRET_FILE=${JWT_SECRET_FILE:-/run/secrets/jwt_secret} - ENCRYPTION_KEY_FILE=${ENCRYPTION_KEY_FILE:-/run/secrets/encryption_key} @@ -136,18 +136,18 @@ services: stop_grace_period: 30s networks: - - lokideploy-net + - opsxloki-net # ---- Application Instance 2 ---- - lokideploy-2: + opsxloki-2: build: context: . dockerfile: Dockerfile args: TARGETARCH: ${TARGETARCH:-amd64} - image: lokideploy:v2 - container_name: lokideploy-2 - hostname: lokideploy-2 + image: opsxloki:v2 + container_name: opsxloki-2 + hostname: opsxloki-2 restart: unless-stopped security_opt: @@ -163,14 +163,14 @@ services: - /tmp:noexec,nosuid,size=50m,uid=1000,gid=1000 volumes: - - lokideploy-data-2:/app/data - - ${KUBECONFIG_PATH:-~/.kube}:/home/lokideploy/.kube:ro - - ${SSH_KEY_PATH:-~/.ssh}:/home/lokideploy/.ssh:ro + - opsxloki-data-2:/app/data + - ${KUBECONFIG_PATH:-~/.kube}:/home/opsxloki/.kube:ro + - ${SSH_KEY_PATH:-~/.ssh}:/home/opsxloki/.ssh:ro environment: - SERVER_PORT=8080 - DATA_DIR=/app/data - - KUBECONFIG=/home/lokideploy/.kube/config + - KUBECONFIG=/home/opsxloki/.kube/config - JWT_SECRET_FILE=${JWT_SECRET_FILE:-/run/secrets/jwt_secret} - ENCRYPTION_KEY_FILE=${ENCRYPTION_KEY_FILE:-/run/secrets/encryption_key} @@ -209,13 +209,13 @@ services: stop_grace_period: 30s networks: - - lokideploy-net + - opsxloki-net # ---- Named Volumes ---- volumes: - lokideploy-data-1: + opsxloki-data-1: driver: local - lokideploy-data-2: + opsxloki-data-2: driver: local # ---- Docker Secrets ---- @@ -227,7 +227,7 @@ secrets: # ---- Network ---- networks: - lokideploy-net: + opsxloki-net: driver: bridge ipam: config: diff --git a/docker-compose.yml b/docker-compose.yml index 8f44371..a221195 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ # ============================================================ -# LokiDeploy - Development Docker Compose Configuration +# opsx-loki - Development Docker Compose Configuration # ============================================================ # P0 Fixes: -# - Non-root kubeconfig mount (/home/lokideploy/.kube) +# - Non-root kubeconfig mount (/home/opsxloki/.kube) # - Healthcheck for orchestration visibility # - Resource limits to prevent noisy-neighbor # - security_opt: no-new-privileges @@ -14,15 +14,15 @@ # ============================================================ services: - lokideploy: + opsxloki: build: context: . dockerfile: Dockerfile args: TARGETARCH: ${TARGETARCH:-amd64} - image: lokideploy:v2 - container_name: lokideploy - hostname: lokideploy + image: opsxloki:v2 + container_name: opsxloki + hostname: opsxloki # ---- P0: Security options ---- security_opt: @@ -43,11 +43,11 @@ services: # ---- P0: Named volumes for data persistence ---- volumes: - - lokideploy-data:/app/data + - opsxloki-data:/app/data # P0: Mount kubeconfig to non-root user directory - - ${KUBECONFIG_PATH:-${HOME}/.kube}:/home/lokideploy/.kube:ro + - ${KUBECONFIG_PATH:-${HOME}/.kube}:/home/opsxloki/.kube:ro # SSH keys for host-based deployments - - ${SSH_KEY_PATH:-${HOME}/.ssh}:/home/lokideploy/.ssh:ro + - ${SSH_KEY_PATH:-${HOME}/.ssh}:/home/opsxloki/.ssh:ro # ---- P0: Environment variables (no default secrets) ---- environment: @@ -60,7 +60,7 @@ services: # P0: ENCRYPTION_KEY must be provided externally (AES-256, >=32 字符) - "ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required. Generate: openssl rand -hex 32, then: export ENCRYPTION_KEY=}" # KUBECONFIG points to non-root user's .kube directory - - KUBECONFIG=/home/lokideploy/.kube/config + - KUBECONFIG=/home/opsxloki/.kube/config # ---- P0: Healthcheck ---- healthcheck: @@ -95,21 +95,21 @@ services: stop_grace_period: 15s networks: - - lokideploy-net + - opsxloki-net # ---- Named volumes ---- volumes: - lokideploy-data: + opsxloki-data: driver: local labels: - - "com.lokideploy.description=LokiDeploy data directory" + - "com.opsxloki.description=opsx-loki data directory" # ---- Network isolation ---- networks: - lokideploy-net: + opsxloki-net: driver: bridge ipam: config: - subnet: 172.28.0.0/16 labels: - - "com.lokideploy.description=LokiDeploy internal network" + - "com.opsxloki.description=opsx-loki internal network" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 9e5f7a1..895dd1b 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/bash # ============================================================ -# LokiDeploy Secure Entrypoint +# opsx-loki Secure Entrypoint # ============================================================ # Enforces required secrets at container startup # Supports Docker Secrets file-based injection @@ -43,7 +43,7 @@ if [ ${#MISSING_VARS[@]} -ne 0 ]; then echo "" >&2 echo "These MUST be provided at runtime. Examples:" >&2 echo "" >&2 - echo ' docker run -e JWT_SECRET="$(openssl rand -hex 32)" -e ENCRYPTION_KEY="$(openssl rand -hex 32)" lokideploy' >&2 + echo ' docker run -e JWT_SECRET="$(openssl rand -hex 32)" -e ENCRYPTION_KEY="$(openssl rand -hex 32)" opsxloki' >&2 echo "" >&2 echo "Or via docker-compose secrets (recommended for production):" >&2 echo ' secrets:' >&2 @@ -85,7 +85,7 @@ shutdown() { } trap shutdown SIGTERM SIGINT -echo "LokiDeploy starting with security configuration OK" +echo "opsx-loki starting with security configuration OK" echo "Data directory: ${DATA_DIR}" echo "Server port: ${SERVER_PORT}" diff --git a/docs/web-ui-guide/00-目录与概览.md b/docs/web-ui-guide/00-目录与概览.md new file mode 100644 index 0000000..236d517 --- /dev/null +++ b/docs/web-ui-guide/00-目录与概览.md @@ -0,0 +1,169 @@ +# 打造高质量 Web 管理端前端 — 开发标准 + +> 基于 vue-vben-admin(v5,★★★★★ Vue 标准标杆)、ant-design-pro(v6,★★★★★ React 标准标杆)、 +> soybean-admin(v2.3,★★★★☆ 强烈推荐)、vue-pure-admin(v7,★★★★☆ 推荐)四大纯前端标杆提炼, +> gin-vue-admin(★★☆☆☆ 仅作全栈功能对照)。 +> +> 生成时间:2026-10-22 | 修订:2026-10-22 定位为通用开发标准(去项目化) + +--- + +## 📖 文档定位(先读这一段) + +本文件是一份**前端开发标准**(Standard),不是教程: + +1. **用途**:放入新项目仓库(如 `docs/`,并被 AGENTS.md / CLAUDE.md 引用),供 **AI 编程助手与开发者**阅读——AI 在编码、Review、重构时以此为基准**对比并优化**目标代码;与本文冲突的既有代码即为待改进项。 +2. **范围**:Vue 3 与 React 的管理端 / 中后台 Web 前端(新项目优先);后端为 Gin / FastAPI 等任意 REST 服务均可——本标准只约定前端结构与 HTTP 契约,不绑定后端框架。 +3. **效力**:条款分「必须 / 禁止」「推荐 / 避免」「可选」三级(见规范用语约定)。 +4. **证据**:所有规范条目均标注出处(五项目的具体文件路径),不是凭空偏好。 +5. **用法**:每章末尾有「AI 检查清单」——对比目标工程时逐项核对,不合格项即为优化任务。 + +--- + +## 📚 文档目录 + +| 序号 | 文件名 | 主题 | 核心内容 | +| -- | ----------------- | ---- | -------------------------------------------- | +| 01 | `01-框架选型与技术栈.md` | 框架选型 | 默认技术栈、UI 库对比、TS 采用策略、何时允许偏离 | +| 02 | `02-项目架构与目录结构.md` | 项目架构 | 按功能域组织、页面 co-location、可裁剪分层、命名规范 | +| 03 | `03-请求层与数据流.md` | 通信架构 | 统一响应契约、拦截器链、refresh 单飞队列、401 三范式、状态分层 | +| 04 | `04-路由与权限控制.md` | 路由权限 | meta 规范、动态路由三模式、守卫链、RBAC 三粒度、菜单生成 | +| 05 | `05-布局系统与导航设计.md` | 布局导航 | 布局分层、侧栏/标签页/面包屑、Keep-Alive 链路、页面模板 | +| 06 | `06-主题系统与视觉设计.md` | 主题视觉 | 设计 Token、暗色模式、CSS 变量换肤、品牌定制、死 CSS 治理 | +| 07 | `07-组件设计与交互反馈.md` | 组件交互 | 组件分层与晋升、命令式弹层、表单/表格范式、反馈分级、巨型文件拆分 | +| 08 | `08-工程化与质量保障.md` | 工程实践 | Vite 配置、env 分层、lint 流水线、测试金字塔、性能、双端一致性、CI/CD | + +--- + +## 🏆 核心结论速览 + +### 默认技术栈(新项目从这里开始) + +| 端 | 默认栈 | 参照 | +| --------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| **Vue 3** | Vue 3 + Vite + TypeScript(允许渐进)+ Element Plus + Pinia + Vue Router | 同栈实操对照 vue-pure-admin;架构对照 vben;规范写法对照 soybean | +| **React** | React + Vite + TypeScript strict + antd(重 CRUD 加 ProComponents)或 shadcn/ui + Zustand + TanStack Query | 工程与组件对照 ant-design-pro | + +包管理器一律 **pnpm**(`preinstall: only-allow pnpm`)。偏离默认栈的条件与程序见 01 章 1.4。 + +### 单栈优先与双栈迁移策略 + +``` +新项目 ──▶ 必须单栈(Vue 或 React 二选一) + +迁移期(存量重构)──▶ 允许双栈并存,但必须遵守三条纪律: + 1. 功能以主端为准,副端只做「对齐」同步,不抢先实现新功能 + 2. 契约先统一命名:token key / 主题 key / 权限码 / 响应格式,两端同名 + (推荐命名空间:.auth.token / .theme.mode) + 3. 不共享代码,只共享一份契约文档——不为双栈引入 monorepo +``` + +(monorepo 的适用性判断见 01 章 1.3;双端一致性细则见 08 章第五节) + +### 参考项目定位(五项目三级) + +**首选参考 ★★★★★ —— Vue 看 vben,React 看 adp** + +| 项目 | 技术栈 | 定位 | 主要借鉴点 | +| ------------------ | -------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------ | +| **vue-vben-admin** | Vue 3 + TS monorepo(pnpm + Turbo) | **Vue 标准标杆** | RequestClient + 预设拦截器、refresh 单飞队列、HSL Token + Tailwind 桥接、Preferences 偏好系统、三层布局架构、Vite 配置工厂、VITE_GLOB 运行时配置 | +| **ant-design-pro** | Umi Max + React 19 + antd v6 + ProComponents | **React 标准标杆** | 响应契约与错误分级(ErrorShowType)、access 权限纯函数、页面 co-location 五件套、React Query 数据流、Biome + commitlint 工程底线、测试范式 | + +**强烈推荐 / 推荐 ★★★★☆ —— Vue 侧第二、第三参考** + +| 项目 | 技术栈 | 定位 | 主要借鉴点 | +| ------------------ | ----------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **soybean-admin** | Vue 3.5 + Naive UI + UnoCSS + Vite(轻 workspace) | 强烈推荐:**代码规范与架构清晰** | @elegant-router 文件路由(目录即路由、类型即生成物)、请求五钩子契约 + flat 返回、store「index + shared」纯函数分离、hasAuth 极简权限、主题三端同步、最严格的 Lint/Commit 体系 | +| **vue-pure-admin** | Vue 3.5 + Element Plus + Tailwind v4(单包) | 推荐:**干净轻量,有官方精简版 thin** | 页面内聚五件套(index + form + utils/hook)、PureHttp 196 行请求类、ReDialog 命令式弹窗、EP↔Tailwind 变量桥接、两级按钮权限(v-auth/v-perms)、中文注释密度极高 | + +**仅作对照 ★★☆☆☆ —— 功能参考,不作前端标准** + +| 项目 | 技术栈 | 定位 | 可借鉴的模式 | +| ----------------- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------- | +| **gin-vue-admin** | Vue 3 + Element Plus + Pinia + Vite(Gin 全栈) | Gin 全栈闭环 / 后端对接示例 | 后端菜单→`import.meta.glob` 动态路由、`defineOptions name` 与 keep-alive 链路、loading 防抖计数器 | + +> gva 的价值在于「Gin 后端 + Vue 前端」同仓配套的全栈闭环,适合做整体功能对照; +> 其前端工程化、代码规范整体弱于上面 4 个纯前端项目(拦截器 `return error` 未 reject、EP 全量引入、无测试)。 +> **前端实现分歧时以 vben(架构)/ soybean(规范)/ pure(同栈 EP 实操)为准,gva 只当功能实现方式对照,前端写法不照搬。** + +### 按维度查哪个项目 + +| 维度 | 优先看 | 说明 | +| ---------------- | --------------------- | ----------------------------------------------------------------------- | +| 目录结构与分层 | vben > soybean > pure | vben monorepo 分层最值得学;soybean 文件路由清晰;pure 传统但干净 | +| 请求封装 | vben / soybean / pure | 错误处理、Token 刷新、类型定义三家都成熟,见 03 章对照 | +| 权限系统 | vben > soybean > pure | vben 动态路由 + 细粒度最完整;soybean 静态/动态一键切换;pure 两级按钮权限 | +| Lint / Commit 规范 | vben / soybean | 配置最严格完整,直接抄它们的配置文件 | +| 组件二次封装 | vben / pure / adp | vben schema 驱动且多 UI;pure hook + 命令式弹窗实用;adp ProTable/ProForm 是 React 标杆 | +| TypeScript 类型组织 | vben / soybean / adp | 类型集中、接口返回类型完整;soybean 的全局 namespace + 键级类型最易迁移到 JS 工程 | + +### 管理端前端十大黄金法则 + +``` +┌────────────────────────────────────────────────────────────────┐ +│ 01. 一切 IO 走统一请求层 —— 组件不直接碰 axios/fetch │ +│ 02. 响应契约全局唯一 —— code/data/msg + 错误分级 │ +│ 03. 401 只有一个入口 —— 单飞刷新 + 队列重放,不竞态 │ +│ 04. meta 驱动一切 UI 行为 —— 菜单/缓存/权限/标题单源维护 │ +│ 05. 权限三态 —— 不可见 / 可见但 403 / 放行 │ +│ 06. 页面私有代码不出页面目录 —— 复用第三次才晋升公共组件 │ +│ 07. 反馈分级、少弹框 —— 成功静默、失败必现、危险确认 │ +│ 08. 换肤只改 CSS 变量 —— 不重编译、不引第二套样式系统 │ +│ 09. 列表/表单/详情有模板 —— 新页面从模板起步,不自由发挥 │ +│ 10. 双栈契约优先 —— 同产品双端实现时 key、权限码、 │ +│ 响应格式两端同名 │ +└────────────────────────────────────────────────────────────────┘ +``` + +### 质量红线(违反即技术债) + +- 单文件组件 ≤ **400 行**(视图)/ ≤ **300 行**(工具、请求层),超限必须拆分 +- 样式必须有唯一入口引用链,**无入口的 CSS 一律删除**(存量工程常见 2000+ 行死 CSS 的考古层) +- 生产构建**零 console.log**,尤其禁止打印 token 片段 +- 组件不绕过 API 层直接调用请求实例 +- 文档与实现同 PR 更新,禁止「描述已删除组件」的漂移文档 + +### 规范用语约定(必须遵守 vs 推荐) + +| 标记 | 含义 | 违反后果 | +| ----------- | ------------------------------------ | --------------------- | +| **必须 / 禁止** | 强制规范(MUST / MUST NOT) | Code review 拒绝,视同 bug | +| **推荐 / 避免** | 默认采纳(SHOULD / SHOULD NOT),允许有充分理由的例外 | 例外需在 PR 描述中说明原因 | +| **可选** | 按需采用(MAY) | 无 | + +正文中加粗的「必须 / 禁止」条款为强制项;各章「AI 检查清单」是带优先级(P0/P1/P2)的对比执行队列。 + +### 文档维护与更新规则 + +1. 实现与文档不一致 = 文档 bug,同一 PR 修复(详见 08 章文档治理) +2. 参考项目大版本升级(vben / soybean / pure / adp)后,复核对应章节引用的证据文件路径 +3. 新增规范先以「推荐」落地,被 ≥2 个模块实际验证后再升级为「必须」 +4. 修订历史以 git commit 为准 + +--- + +## 🎯 适用场景 + +- 🏗️ **可裁剪底座 / 脚手架** — 壳 / 系统 / 示例三层,fork 即用 +- 🖥️ **管理后台 / 中台** — 用户、RBAC、审计、系统设置等系统级页面 +- 📊 **运维监控平台** — 仪表盘、指标图表、实时日志(WebSocket/SSE) +- ⏰ **任务调度控制台** — 定时任务 CRUD、执行历史、手动触发 +- 🔄 **双栈迁移工程** — Vue→React 或反向迁移期间的对照参照 + +--- + +## 📐 与兄弟指南的关系 + +| 指南 | 领域 | 分界 | +| ----------------------- | -------------------- | ---------------------- | +| `GUI-Development-Guide` | Electron/Tauri 桌面端 | 窗口、标题栏、IPC、自动更新 | +| `CLI-Development-Guide` | Go 命令行 / TUI / Agent | Cobra、Bubble Tea、审批流 | +| **本指南** | **浏览器 Web 管理端** | **请求层、路由权限、布局主题、组件工程** | + +三份指南共享同一套写作范式:每个专题按「原理 → 标杆代码 → 最佳实践 → AI 检查清单」四层展开。 + +--- + +> 💡 **阅读建议**:开发者先通读本文件,写新页面前读 05(页面模板)和 07(组件规范);搭新模块前读 02(架构)和 04(路由权限);排查线上问题读 03(请求层)和 08(工程化)。 +> **AI 助手**:编码前读 02/04/05/07;Review 与重构时按各章末「AI 检查清单」逐项对比目标代码,不合格项即输出为优化建议。 +> 查阅标杆实现时:**Vue 架构问题对照 vben、规范写法对照 soybean、同栈(Element Plus)实操对照 pure、React 问题对照 adp,gva 仅作功能对照。** diff --git a/docs/web-ui-guide/01-框架选型与技术栈.md b/docs/web-ui-guide/01-框架选型与技术栈.md new file mode 100644 index 0000000..9c84a5c --- /dev/null +++ b/docs/web-ui-guide/01-框架选型与技术栈.md @@ -0,0 +1,217 @@ +# 01 — 框架选型与技术栈 + +> 参考:vue-vben-admin(★★★★★ Vue 标准标杆)、ant-design-pro(★★★★★ React 标准标杆)、soybean-admin(★★★★☆ 强烈推荐)、vue-pure-admin(★★★★☆ 推荐);对照:gin-vue-admin(★★☆☆☆ Gin 全栈) + +--- + +## 一、选型的第一性问题:单栈还是双栈 + +### 1.1 标杆项目的单栈现实 + +| 项目 | 框架 | 为什么不双栈 | +|------|------|--------------| +| ant-design-pro | React 19 | 生态深度绑定 Umi Max 运行时(约定式路由、运行时配置、插件体系) | +| gin-vue-admin | Vue 3 | Element Plus + 国内管理端生态,Go 后端配套脚手架默认 Vue(★★☆ 仅对照) | +| vue-vben-admin | Vue 3 | 用 monorepo + adapter 层支持 5 个 UI 库,但**框架始终只有 Vue** | +| soybean-admin | Vue 3 | 文件路由 + 生成物策略,规范优先(★★★★☆ 强烈推荐) | +| vue-pure-admin | Vue 3 | 单包直出、轻量实用,官方精简版 thin(★★★★☆ 推荐) | + +> **结论**:标杆项目全部单栈。多 UI 库共存靠的是「适配器层」(vben 的 `effects/*` adapter),而不是多框架。**框架级双栈的成本(请求层、状态、权限、构建、测试全部 ×2)只有迁移期才值得付。** +> +> 参考优先级:**Vue 架构看 vben、规范写法看 soybean、同栈 EP 实操看 pure、React 看 adp**;gva 仅作 Gin 全栈视角补充,不作前端代码质量的首选参考。 + +### 1.2 双栈只在迁移期 + +``` + ┌─────────────────────────────────────┐ + │ 后端 REST API(Gin / FastAPI ...) │ + │ 单一契约:/api/* │ + └───────────┬─────────────┬───────────┘ + │ │ + ┌───────────▼────┐ ┌─────▼──────────────┐ + │ 主端(功能源) │ │ 副端(迁移参照) │ + │ 承接全部新功能 │ │ 只对齐、不抢先 │ + │ 进入产物 │ │ 迁移完成即退役 │ + └────────────────┘ └────────────────────┘ +``` + +迁移期三条纪律(**必须**): + +1. **功能以主端为准**:副端只做「对齐」同步,不抢先实现。 +2. **契约以两端共同名为准**:token key、主题 key、模块开关键、权限码、响应格式——迁移开始前就统一命名(推荐命名空间 `.auth.token` / `.theme.mode`),而不是迁移后再改。 +3. **不共享代码,只共享约定**:两端各自实现同一份「契约文档」(见 08 章),不引入 monorepo 这种为双栈兜底的重型方案。 + +### 1.3 何时值得上 monorepo(vben 模式) + +vben 用 pnpm workspace + Turbo 支撑 5 个 UI 库 app + 30+ 框架包,代价是: + +- `pnpm-workspace.yaml` 维护 300+ 依赖 catalog,`overrides` 强制统一版本 +- 循环依赖检查(`check:circular`)、依赖合规检查(`check:dep`)成为必跑任务 +- 构建配置收敛为内部包 `internal/vite-config`,app 配置才能薄到 15 行 + +**判断标准**:当「框架层代码」(布局引擎、请求客户端、权限系统)需要被 ≥3 个应用复用时,才值得付 monorepo 成本。普通单产品工程(1-2 个前端)——**不值得**,独立 Vite 工程 + 统一 Makefile 编排即可。soybean 的「轻 workspace」(单应用 + 8 个工具包)是中间形态,适合工具链需要沉淀的团队。 + +### 1.4 何时允许偏离默认栈 + +**必须**:新项目 / 新模块使用本章默认栈(见 00 章「默认技术栈」);存量代码**不因「不合推荐」而大规模重写**——渐进迁移:新文件执行新标准,旧文件随功能迭代就近升级。 + +**允许偏离**的场景(需在 PR / ADR 中说明理由):组件库确缺关键能力(虚拟表格、复杂甘特等);性能热点需要更轻方案;与后端生态强绑定(如脚手架生成的默认产物)。 + +**禁止**:同一工程内并存两套 UI 库、两套状态库、两种 CSS 方案——偏离只允许发生在「整工程」粒度。 + +--- + +## 二、UI 组件库选型 + +### 2.1 三种流派对照 + +| 维度 | Element Plus(本标准 Vue 主推;gva 补充) | Ant Design + ProComponents(adp) | shadcn/ui(React 品牌差异化) | +|------|--------------------------|-----------------------------------|-----------------------------| +| 形态 | 传统全量组件库 | 组件库 + 中后台增强层 | 源码复制型(Radix + Tailwind) | +| 中后台生产力 | 高(表格/表单/弹层齐全) | **极高**(ProTable/ProForm 声明式 CRUD) | 中(原子件全,业务件自建) | +| 主题定制 | CSS 变量 + SCSS 双通道 | Design Token(configProvider) | **完全可控**(Token 就是你的 CSS 变量) | +| 暗色模式 | `dark/css-vars.css` 一行接入 | `data-theme="dark"` + token | `.dark` class 天然支持 | +| TS 支持 | 一般(类型宽松) | 好 | 极好(TSX 源码进仓库) | +| 体积 | 全量较大,靠 unplugin 按需 | 较大 | 用多少复制多少 | +| 适合谁 | Vue 主栈、快速交付 | React 中后台重 CRUD | React、要品牌差异化 | + +### 2.2 选型决策树 + +``` +需要管理端全家桶、快速交付? +├─ Vue 栈 ──────────▶ Element Plus(+ unplugin 按需引入) +├─ React 栈、CRUD 为主 ▶ Ant Design + ProComponents +└─ React 栈、要品牌感 ─▶ shadcn/ui + Tailwind + +双栈迁移期? +└─▶ 主端选全量组件库保效率(EP), + 副端选 shadcn 保可读性 —— 常见组合 +``` + +两个 ★★★★☆ 参考的选型启示:soybean-admin(Naive UI + UnoCSS)证明「hook 优先 + 组件薄壳」也能撑起完整中后台;vue-pure-admin 证明了 **Tailwind v4 `@theme` 把 `--el-*` 变量桥接为原子色 token** 是 EP 工程引入原子类的最短路径(详见 06 章)。 + +### 2.3 图标体系(易被忽视的选型) + +- **glob 注册模式(gva,补充)**:`import.meta.glob` 自动收集 `assets/icons/**/*.svg` 注册为全局组件,菜单存 icon 字符串即可 `` 渲染;插件图标加 `插件名-` 前缀防冲突。适合「菜单图标由后端配置下发」的场景。 +- **路由声明模式(adp)**:路由 `icon: 'crown'` 字符串 + 图标映射表,与路由集中声明天然契合。 +- **按需编译模式(pure/soybean)**:unplugin-icons 虚拟模块(`import Upload from '~icons/ri/upload-line'`)+ Iconify 离线/在线双模式。 +- **通用规则**:**图标名是数据(存在菜单表里),渲染是前端映射**——不要把图标组件硬编码进菜单模板。 + +--- + +## 三、构建工具与运行时 + +### 3.1 Vite 是唯一现实选择(2026) + +| 能力 | 说明 | 标杆用法 | +|------|------|----------| +| 按需引入 | `unplugin-auto-import` + `unplugin-vue-components` | EP 组件零 import(vben/soybean 系均如此) | +| `import.meta.glob` | 动态路由的组件映射基石 | gva/vben/pure 的 backend 菜单模式 | +| env 分层 | `.env.development` / `.env.production` + `import.meta.env` | 全部项目;vben 另分 `VITE_GLOB_*` 运行时可改(见 08 章) | +| proxy | dev 反代 `/api` 到后端端口,避免 CORS 配置漂移 | `'/api' → 后端端口`(含 `ws: true` 供 WebSocket) | +| 构建指纹 | `define` 注入版本/commit hash | adp:`__APP_VERSION__` + git rev-parse 兜底 | + +**必须**按需引入,**禁止**全量 `app.use(组件库)` + 全量 CSS(gva 全量引入是反面教材,首屏体积大)。 + +### 3.2 路由库 + +- Vue:`vue-router@4`,`createWebHistory`(hash 模式是历史包袱,新工程用 history) +- React:`react-router@7`,用**路由配置数组 + `` 声明式**或 `createBrowserRouter`;**禁止**用 `Object.entries().map` + 三元链选页面渲染 + +### 3.3 HTTP 客户端 + +| 库 | 使用方 | 要点 | +|----|--------|------| +| axios | vben / soybean / pure / gva | 拦截器、取消(AbortController)生态成熟 | +| umi-request / axios | adp | 错误契约挂载在 request config 上(skipErrorHandler 等) | +| fetch | — | 零依赖,但拦截器/超时/取消需自建,**不推荐**作为管理端基础 | + +> **必须**统一 axios:拦截器结构、错误对象形态在全员脑中保持一致,排障不用切换心智模型。 + +--- + +## 四、TypeScript 采用策略 + +### 4.1 三档策略 + +| 档位 | 适用 | 做法 | 标杆 | +|------|------|------|------| +| **纯 JS + JSDoc** | 快速交付的存量 Vue 主端 | 关键模块(request/permission/stores)写 JSDoc 类型注释 | gva | +| **渐进 TS** | 存量 JS 工程演进 | 新文件 `.ts`,老文件不动;`allowJs: true`;先建 `typings/` 写 `.d.ts` | soybean 路线 | +| **TS strict** | 新工程(**默认**) | `strict: true`,类型即文档 | adp、vben、soybean、pure | + +**新项目必须 TS strict**;存量工程按上表渐进,但接口函数**不允许**「既无类型注释也无类型定义」。 + +### 4.2 类型放哪里(adp/soybean 范式) + +- 全局契约类型集中声明:`declare namespace API { interface RuleListItem {...} }`,服务层函数签名 `request(...)` 收口 +- 页面私有类型随页面 co-location(`data.d.ts` / `utils/types.ts`) +- 路由 meta 用 `vue-router.d.ts` 模块扩充强类型化(vben 30+ 字段全 JSDoc;soybean 17 字段同款) +- soybean 补充:全局类型按 namespace 组织(`typings/` 下 Api/App/Storage/Env 四区,storage 与 env 均有**键级类型**,i18n key 递归派生)——**JS 工程渐进 TS 的最低成本路径**:先建 `typings/` 写 `.d.ts`,不动业务代码 + +--- + +## 五、状态管理选型 + +### 5.1 先分类再选库 + +``` +状态三类: +├─ 客户端全局状态(token、用户信息、菜单、UI 偏好)→ Pinia / Zustand +├─ 服务端状态(列表、详情、缓存失效) → TanStack Query / ProTable request / 自研 usePagedQuery +└─ URL 状态(筛选、分页、Tab) → 路由 query,永远不要复制进 store +``` + +### 5.2 三种落法 + +| 落法 | 出处 | 说明 | +|------|------|------| +| adp 三层 | initialState(useModel)+ React Query + ProTable request | 服务端状态交给 Query:`queryKey` 按资源命名,mutation 成功后 `invalidateQueries` | +| vben 分域 store | Pinia setup 风格,按域拆 modules(gva 同构,补充参考) | token/user/router/app/dictionary 各一,持久化手动分层 | +| pure StoreHook | `useXxxStoreHook()` 包装 store + pinia 实例 | 在 setup 外(拦截器、守卫、工具函数)也能安全取 store | +| soybean 模块化 | 每模块一目录 `index.ts`(状态编排)+ `shared.ts`(可单测纯函数) | 纯逻辑与响应式状态强制分离 | + +**经验值**:列表页优先「组件内管理 + 请求取消」(AbortController + 版本号方案,见 03 章),只有跨页面共享的数据才进 store。**不要为了「规范」把每个列表都塞进全局 store。** + +--- + +## 六、技术栈对照总表 + +| 维度 | ant-design-pro | vue-vben-admin | soybean-admin | vue-pure-admin | gin-vue-admin | 本标准推荐值 | +| ---- | -------------- | -------------- | ------------- | -------------- | ------------- | ------------------ | +| 框架 | React 19 | Vue 3 | Vue 3.5 | Vue 3.5 | Vue 3 | Vue 3 或 React(单栈) | +| 语言 | TS strict | TS strict | TS strict(namespace 组织) | TS(strict 关闭) | JS | 新项目 TS strict;存量渐进 | +| UI 库 | antd v6 + Pro | antd-vue/EP/naive 适配 | Naive UI + UnoCSS | Element Plus + TW v4 | Element Plus | Vue: EP;React: antd 或 shadcn | +| 构建 | Umi Max(utoopack) | Vite + Turbo monorepo | Vite(轻 workspace) | Vite 8/rolldown 单包 | Vite | Vite + pnpm | +| 状态 | useModel + RQ | Pinia + 持久化插件 | Pinia(index+shared) | Pinia(StoreHook 模式) | Pinia 分域 | Pinia(Vue)/ Zustand+RQ(React) | +| 请求 | umi-request + 错误契约 | RequestClient 类 | 五钩子工厂 + flat 返回 | PureHttp 类(196 行) | axios 单例 | axios 封装(见 03 章) | +| 样式 | Tailwind v4 + antd-style | HSL Token + TW v4 | ThemeSetting 三端同步 | EP↔TW v4 变量桥接 | SCSS + UnoCSS + EP 变量 | 语义 Token 单源(见 06 章) | +| 规范 | Biome + commitlint | oxlint + eslint + lefthook | soybeanjs/eslint-config + sa CLI | eslint + stylelint + husky | ESLint flat + Prettier | ESLint flat + Prettier + commitlint | +| 测试 | Vitest + Playwright | Vitest(内核组件全覆盖) | —(未见体系化) | —(未见体系化) | 无(短板) | Vitest + Playwright(见 08 章) | + +--- + +## 七、AI 检查清单(对比目标工程逐项核对) + +**P0(结构性的,立即改)** + +1. 工程是否单栈?若双栈并存,是否有明确的「主端/副端 + 退役计划」与统一契约命名? +2. 是否存在同工程两套 UI 库 / 两套状态库 / 两种 CSS 方案? +3. 组件库是否按需引入(unplugin 或手动)?全量 `app.use()` 即不合格 +4. 包管理器是否 pnpm?`preinstall: only-allow pnpm` 是否配置? + +**P1(质量项,排期改)** + +5. 新文件是否 TS / 存量是否补 JSDoc?接口函数是否存在「无类型也无注释」 +6. 路由渲染是否声明式(禁止 entries+三元链)? +7. HTTP 客户端是否统一 axios? +8. 状态三分法是否被遵守(URL 状态不进 store、列表不进全局 store)? + +**P2(演进项)** + +9. 是否需要 `typings/` 目录承接全局契约类型(soybean 四区范式)? +10. 构建指纹(版本/commit 注入)是否配置? + +--- + +> 下一篇:[02-项目架构与目录结构](02-项目架构与目录结构.md) —— 目录是架构的最后陈述。 diff --git a/docs/web-ui-guide/02-项目架构与目录结构.md b/docs/web-ui-guide/02-项目架构与目录结构.md new file mode 100644 index 0000000..0795ee8 --- /dev/null +++ b/docs/web-ui-guide/02-项目架构与目录结构.md @@ -0,0 +1,231 @@ +# 02 — 项目架构与目录结构 + +> 参考:vue-vben-admin(三层包划分)、soybean-admin(文件路由 + store 纯函数分离)、ant-design-pro(页面 co-location)、vue-pure-admin(页面内聚五件套);补充:gin-vue-admin(分域 api/view) + +--- + +## 一、组织目录的三种流派 + +### 1.1 流派对照 + +| 流派 | 代表 | 结构 | 优点 | 代价 | +|------|------|------|------|------| +| **按层分**(layer-first) | 早期中台 | `components/ views/ api/ stores/` 每层内平铺全部业务 | 上手简单 | 业务散在 5 个目录,删模块要全局找 | +| **按域分**(domain-first) | **本标准(Vue 单包)/ gva** | 层目录保留,但 `api/user/`、`views/user/`、`stores/` 域内聚合 | 删域=删目录,心智清晰 | 需要约定维持层内域目录一致 | +| **按功能分包**(feature-first) | adp 页面级、vben 包级 | 页面目录自带 service/mock/类型/样式;框架能力独立成包 | 高内聚,可整体搬移 | 需要晋升机制防止复制泛滥 | + +**结论:中小工程用「按域分 + 页面 co-location」混合式**——全局按域(api/views 域目录对齐),页面私有代码留在页面目录内。 + +### 1.2 四标杆的目录精髓 + +``` +vue-vben-admin(依赖方向三层,分层标杆) +apps/* ──▶ packages/effects/* ──▶ packages/@core/* +(app 是唯一允许直接依赖具体 UI 库的层; + @core 零 UI 依赖零业务;effects 通过 adapter 适配 UI 生态) + +ant-design-pro(页面五件套 co-location) +src/pages/table-list/ +├── index.tsx # 页面(ProTable 声明式列配置) +├── service.ts # 本页接口(手写层) +├── data.d.ts # 本页类型 +├── _mock.ts # 本页 mock(config 显式 include) +└── components/ + ├── CreateForm.tsx # 新建弹窗(trigger 模式) + └── UpdateForm.tsx # 编辑弹窗(StepsForm 进 Modal) + +soybean-admin(生成物 + store 纯函数分离,★★★★☆) +src/ +├── router/elegant/ # 文件路由生成物(routes/transform/imports + RouteKey 类型 d.ts) +├── service/{api,request}/ # api=纯请求函数、request=实例拦截器,两层分离 +├── store/modules// # 每模块 index.ts(状态编排) + shared.ts(可单测纯函数) +├── typings/{api,...} # 全局 namespace 类型(Api/App/Storage/Env) +└── views/_builtin/ # 403/404/500/login 内置页统一收纳,业务页平级 + +vue-pure-admin(页面内聚五件套,★★★★☆) +src/views/system/user/ +├── index.vue # 纯模板(零逻辑,只解构 hook) +├── form/index.vue # 弹窗表单 +├── tree.vue # 左树 +└── utils/{hook.tsx,rule.ts,types.ts} # 逻辑/校验/类型全在本域 + +gin-vue-admin(补充:全局分域 + 指令/钩子独立) +src/ +├── api/ # 一域一文件:user.js menu.js ... +├── pinia/modules/ # 一域一 store +├── view/superAdmin/ view/example/ # 按业务分组 +├── directive/auth.js # v-auth 指令独立目录 +├── hooks/responsive.js # 组合式函数独立目录 +└── plugin// # 业务插件 = 自带 view/api/assets 的小应用 +``` + +--- + +## 二、标准目录结构(Vue 工程) + +### 2.1 目标结构 + +``` +src/ +├── main.js # 入口:仅装配(EP按需、pinia、router、指令、全局组件) +├── App.vue +├── api/ # ★ 按功能域分目录,与 views 域目录同名 +│ ├── auth/ # captcha.js mfa.js +│ ├── user/ # users.js roles.js permissions.js +│ ├── rbac/ # 域小可单文件,大了再拆 +│ ├── system/ # menu.js modules.js logs.js ... +│ └── / # 业务域 +├── views/ # ★ 域目录与 api/ 一一对应 +│ ├── user/ # Users.vue + components/XxxFormDialog.vue ... +│ ├── / +│ └── _builtin/ # 403/404/500/login 内置页统一收纳(soybean 范式) +├── stores/ # Pinia:一域一文件(auth.js menu.js site.js ...) +├── composables/ # useXxx(依赖业务上下文的钩子) +├── components/ # ★ 全局组件(需晋升,见第四章) +├── layout/ # 布局骨架(业务页只挂内容区,禁止重写布局) +├── router/ # index.js(含守卫) modules/(路由分文件) +├── config/ # theme.js modules.js(纯配置,无副作用导入) +├── typings/ # 全局契约类型(TS 渐进路线,soybean 四区) +├── utils/ # request.js permission.js(无状态工具) +└── styles/ # 全部文件必须有入口引用链 +``` + +React 工程同构映射:`views→pages`、`composables→hooks`、`stores→store`(zustand slices)、`components→components`(含 barrel 出口)。 + +### 2.2 层内规则(必须) + +1. **api 域目录与 views 域目录强制同名**(`api/cron/` ↔ `views/cron/`);**禁止**在 api 根级别放散文件 +2. **store 文件名 = 导出名**(`stores/auth.js` → `useAuthStore`);单 store ≤300 行;推荐 soybean 的「index + shared」目录式(状态编排与纯函数分离) +3. **layout/ 是禁改区**:业务页通过页面容器组件的 header/slot 定制,不碰布局骨架(可裁剪底座的前提) +4. **components/ 只放「晋升组件」**(见第四章晋升机制),新页面私有组件一律 `views/<域>/components/` + +### 2.3 可裁剪分层与模块开关(底座/脚手架推荐) + +``` +┌─────────────────────────────────────────────────────┐ +│ 壳层 shell 登录/MFA/403/500 —— 不挂主布局 │ +├─────────────────────────────────────────────────────┤ +│ 系统层 system 用户/RBAC/审计/设置 —— 底座自带 │ +├─────────────────────────────────────────────────────┤ +│ 示例层 examples 演示模块 —— fork 后可整层删除 │ +└─────────────────────────────────────────────────────┘ +``` + +- 路由 `meta.layer: 'shell' | 'system' | 'examples'` 声明归属 +- 运行时开关:**源真相在后端**(如 `GET/PUT /api/system/modules`),localStorage 仅作缓存——**禁止**把 localStorage 当源真相 +- 构建期裁剪:环境变量(如 `VITE_ENABLE_EXAMPLES=false`)+ 路由中字面量比较,保证打包器 tree-shake 掉示例 chunk +- 示例层代码**不得 import 系统层**内聚单元,保证整层可摘除;示例域附删除指引文档(EXAMPLES.md 惯例) + +**分层是目录结构之外的第二根轴**:每个新模块必须先回答「我在哪一层」。 + +### 2.4 命名规范与类型目录 + +| 对象 | 规范 | 示例 | +|------|------|------| +| 组件文件 | PascalCase | `UserFormDialog.vue` | +| 工具 / composable | camelCase,composable 带 use 前缀 | `request.js`、`usePagedQuery.js` | +| 目录 | kebab-case(views 域目录用业务域名) | `user-management/`、`cron/` | +| store 文件 | 文件名与导出同名 | `stores/auth.js` → `useAuthStore` | +| API 文件 | 资源名复数 | `api/user/users.js` | +| 常量 | UPPER_SNAKE_CASE | `SYSTEM_ORDER` | + +类型目录(TS 渐进路线,soybean 范式):全局契约类型集中 `typings/`(Api / App / Storage / Env 四区 namespace,storage 与 env 均键级类型);页面私有类型随页面(`data.d.ts` / `utils/types.ts`);**禁止**同一类型定义在多个组件内重复出现。 + +--- + +## 三、页面 co-location(adp 五件套) + +### 3.1 什么时候页面自带 service + +``` +全局 api/<域>/xxx.js # 多页面复用、域级语义(users.js 的 list/create/delete) +页面内 service.ts # 只服务本页的接口(dashboard 聚合、报表导出) +``` + +判定:**接口第二个消费者出现时**,从页面 `service.ts` 提升到 `api/<域>/`。反向不成立——不要预防性提升。 + +### 3.2 页面目录模板 + +``` +views//.vue # 页面骨架:容器 + 列表组件 + 子组件编排(≤400 行) +views//components/ # 本页私有组件(表单弹窗、详情抽屉、单元格渲染器) +views//composables/ # 本页私有逻辑(可选,超过 2 个页面组件共享时建) +views//utils/ # 本页 hook/rule/types(pure 范式,可选) +views//EXAMPLES.md # 仅示例层:删除指引 +``` + +--- + +## 四、公共组件晋升机制 + +``` +第 1 次使用:写在页面 components/ 里 +第 2 次使用:复制一份,页面内注明来源 ← 允许的重复 +第 3 次使用:晋升 src/components/,删除两份复制 ← 必须执行 +``` + +晋升时同步做三件事: + +1. **去业务化**:props 收口(`fetcher`/`permissions` 进来,具体 API 调用出去) +2. **注册出口**:Vue 端全局组件在 `main.js` 注册;React 端进 `components/index.ts` barrel(adp:按「布局组件 / 业务组件」分组导出) +3. **写使用说明**:组件头部注释 3 行(用途/必需 props/已知限制) + +必备的两个全局页面组件(详见 05/07 章):**PageContainer**(页面容器:页头/面包屑/slot)与 **ListPage**(分页列表:fetcher + 筛选 slot + 表格透传 + 分页)。 + +--- + +## 五、架构守护:让目录约定不被腐蚀 + +| 守护手段 | 出处 | 可落地做法 | +|----------|------|------------| +| 循环依赖扫描 | vben `check:circular` | `npx madge --circular src/` 进 CI | +| 依赖方向检查 | vben `check:dep`(@core 禁依赖 UI 库) | 示例层禁 import 系统层:ESLint `no-restricted-imports` 按路径分组 | +| 死代码检测 | adp coverage 排除生成物 | CI 跑 `knip`/`depcheck`,styles 无入口文件直接报错 | +| 生成物隔离 | adp `services/ant-design-pro/`(OpenAPI 生成,禁手改) | 生成物独立目录 + lint ignore | +| 路由自动收集 | pure `router/modules/` + `import.meta.glob` | 新增路由文件零注册代码 | + +--- + +## 六、最佳实践清单 + +✅ **要做** + +1. api 域目录与 views 域目录同名;新域从脚手架起步(自动生成五处对照:菜单/路由/页面/API/权限码) +2. 页面私有组件不出页面目录;晋升走「三次法则」 +3. 每个新模块先声明 `meta.layer`,示例层保持可整层摘除 +4. store 按域拆分、文件名=导出名、单 store ≤300 行 +5. 目录结构变化(新增域/晋升组件/删示例)在同一 PR 内更新架构文档 + +❌ **不要** + +1. 不要在 api 根级别放散文件 +2. 不要把页面私有组件直接丢进 `src/components/` +3. 不要让业务页 import layout 内部组件(只准用 slot 协议) +4. 不要建「future/」「temp/」「new/」这类无语义目录 +5. 不要保留无入口引用的样式文件和空目录 + +--- + +## 七、AI 检查清单(对比目标工程逐项核对) + +**P0** + +1. api 与 views 域目录是否同名对齐?有无 api 根级散文件? +2. store 文件名与导出名是否一致?有无超 300 行的 store? +3. 有无空目录、无引用样式、描述已不存在目录的文档残留? + +**P1** + +4. 页面骨架是否 ≤400 行?超 400 行的视图列出拆分建议(参照 07 章) +5. 私有组件是否越界进全局 components/?全局组件是否都有注册出口与头注释? +6. 若为可裁剪底座:meta.layer 是否全覆盖?模块开关源真相是否在后端? +7. CI 是否有循环依赖与死代码检查? + +**P2** + +8. 是否值得引入路由文件自动收集(`router/modules/` + glob)或文件路由(soybean @elegant-router)? +9. `typings/` 全局类型目录是否建立(Api/App/Storage/Env 四区)? + +--- + +> 下一篇:[03-请求层与数据流](03-请求层与数据流.md) —— 前端工程的血管系统。 diff --git a/docs/web-ui-guide/03-请求层与数据流.md b/docs/web-ui-guide/03-请求层与数据流.md new file mode 100644 index 0000000..8b2f807 --- /dev/null +++ b/docs/web-ui-guide/03-请求层与数据流.md @@ -0,0 +1,284 @@ +# 03 — 请求层与数据流 + +> 参考:vue-vben-admin(RequestClient + refresh 队列)、soybean-admin(五钩子契约 + flat 返回)、vue-pure-admin(PureHttp 请求类)、ant-design-pro(ErrorShowType 契约);补充:gin-vue-admin(loading 防抖、401 事件总线) + +--- + +## 一、统一响应契约 + +### 1.1 契约即协议 + +请求层的第一件事不是封装 axios,而是**和后端签一份响应契约**。两种主流格式: + +```js +// 格式 A:Go Gin / 多数国内后端惯例(gva 等) +{ code: 0, data: {...}, msg: 'success' } // code === 0 成功 +// 细分扩展:0 成功 / 200 成功 / 2 MFA 待验证 / 3 强制改密 + +// 格式 B:adp(含错误展示声明) +{ success: true, data: {...}, + errorCode?: number, errorMessage?: string, showType?: ErrorShowType } +``` + +### 1.2 adp 的杀手锏:错误展示分级 + +```ts +// ant-design-pro: src/requestErrorConfig.ts +enum ErrorShowType { + SILENT = 0, // 静默:调用方自行处理 + WARN_MESSAGE = 1, // 轻提示 message.warning + ERROR_MESSAGE = 2, // 错误提示 message.error + NOTIFICATION = 3, // 通知栏 notification + REDIRECT = 9, // 重定向到错误页 +} +``` + +- 后端在响应里**声明这个错误该怎么展示**,前端按枚举分发——错误处理策略从「前端猜」变成「契约说」 +- 前端保留逃生口:`request(url, { skipErrorHandler: true })` 单请求关闭全局处理 + +> **推荐**:在 `{code, data, msg}` 上加可选字段 `show_type`,前端默认 `ERROR_MESSAGE`,登录页/轮询接口传 `SILENT`。改动只在请求层一处。 + +--- + +## 二、请求层结构:从单例到客户端类 + +### 2.1 三档演进 + +``` +档1 单例 + 双拦截器(gva,230 行单文件) + 适合:单应用、单一鉴权方式 +档2 工厂五钩子(soybean:RequestOption 契约 + createFlatRequest) + 适合:错误契约复杂、想要扁平返回与错误码 env 化 +档3 客户端类 + 预设拦截器(vben RequestClient / pure PureHttp) + 适合:多实例(业务 client / 裸 client)、上传下载 SSE 一等公民 +``` + +### 2.2 vben 的 RequestClient 精髓 + +```ts +// vben: packages/effects/request/src/request-client/request-client.ts +class RequestClient { + private instance: AxiosInstance + addRequestInterceptor / addResponseInterceptor // 拦截器不写死,外部组装 + upload / download / postSSE // 传输形态一等公民 +} + +// 预设拦截器(preset-interceptors.ts)按需装配: +defaultResponseInterceptor({ codeField, dataField, successCode }) // 解包策略可配置 +authenticateResponseInterceptor // 401 + refresh +errorMessageResponseInterceptor // status→文案 +``` + +应用组装处(`apps/web-antd/src/api/request.ts`)导出两个实例: + +- `requestClient`:`responseReturn: 'data'`,业务用(自动剥壳) +- `baseRequestClient`:`responseReturn: 'raw'`,刷新 token 等裸接口用(**刷新接口自己不能被自己的拦截器拦**) + +### 2.3 soybean 的五钩子契约与 flat 返回(★★★★☆ 工厂化范式) + +soybean 把请求协议下沉为 workspace 包 `packages/axios`,业务侧只实现五个钩子: + +```ts +interface RequestOption { + transform: (res) => ApiData // 解包 + onRequest: (config) => config // 注入 token + isBackendSuccess: (res) => boolean // 业务 code 判定 + onBackendFail: (res, instance) => Promise // 失败处理(可重发) + onError: (error) => void // 错误提示 +} +// createRequest:失败抛异常(传统风格) +// createFlatRequest:返回 { data, error, response },业务层 if (!error) —— Rust 风格扁平返回 +``` + +另两条可迁移细节:**错误码策略 env 化**(`VITE_SERVICE_LOGOUT_CODES` 直接登出 / `MODAL_LOGOUT_CODES` 模态登出 / `EXPIRED_TOKEN_CODES` 刷新重发,三级以配置区分);**错误消息栈去重**(errMsgStack 防并发轰炸)。pure 的 `PureHttp` 类(196 行单文件)则是轻量级范本:qs 序列化数组参数、请求前本地判断 expires **提前刷新**(而非 401 后补救)、per-request 回调可覆盖全局拦截器。 + +--- + +## 三、Token 与 401:单飞刷新是铁律 + +### 3.1 问题:并发 401 的竞态 + +``` +请求A ──401──▶ 刷新token(旧1) ──▶ 重放A ✔ +请求B ──401──▶ 刷新token(旧1) ──▶ 后端拒(旧1已消费)──▶ B 被踢出登录 ✘ +``` + +### 3.2 正确姿势:单飞标志 + 等待队列(vben) + +```ts +// vben: authenticateResponseInterceptor +if (client.isRefreshing) { + return new Promise((resolve) => client.refreshTokenQueue.push((token) => { + config.headers.Authorization = `Bearer ${token}` + resolve(client.instance.request(config)) // 拿新 token 后重放 + })) +} +client.isRefreshing = true +try { const newToken = await doRefresh(...) } finally { client.isRefreshing = false } +config.__isRetryRequest = true // 防无限循环 +``` + +pure 用同构的静态成员实现(`isRefreshing` + `requests[]` 队列,刷新成功 `requests.forEach(cb => cb(token))` 重放);soybean 用共享 Promise 单飞(`state.refreshTokenPromise`,1 秒后置 null)。**三家殊途同归:并发 N 个过期请求只触发一次刷新。** + +pure 的补充思路:**提前刷新**——token 三元组(accessToken/expires/refreshToken)存 Cookie,请求前本地判断 `expires - now <= 0` 主动刷新,把过期处理从「响应后补救」变成「请求前预防」(白名单短路 `/refresh-token`、`/login` 防死循环)。 + +### 3.3 刷新失败后的三种落法 + +| 落法 | 出处 | 适用 | +|------|------|------| +| 全局错误对话框确认后清 token 跳登录(事件总线单例防重复弹) | gva `emitter.emit('show-error')` | 多请求并发过期时体验最好(只弹一个框) | +| 登录过期 Modal(原页面保留,重登后继续) | vben `loginExpiredMode: 'modal'` | 长表单场景,避免填一半全丢 | +| 防抖单次跳转(最简可靠) | 常见轻实现 | 跳转加防抖标志;错误救援页(500/403)只清不跳,防按钮失效 | + +> 共同底线:**并发 N 个 401 只产生一次副作用**(一个弹窗/一次跳转);登录页与轮询接口静默(`SILENT`)。 + +--- + +## 四、全局 loading 防抖(补充模式:gva) + +```js +// gva: utils/request.js(要点提炼) +let activeAxios = 0 // 并发计数器 +let timer = null +const showLoading = (persist) => { + if (timer) clearTimeout(timer) + activeAxios++ + if (persist) { ... return } // 长任务:立即显示且不自动关 + timer = setTimeout(() => showFullScreenLoading(), 400) // 400ms 防闪烁 +} +const closeLoading = () => { + activeAxios-- + if (activeAxios <= 0) { clearTimeout(timer); hide(); } // 全部结束才隐藏 +} +// 兜底:30s 强制关闭;beforeunload 重置;单请求 donNotShowLoading 关闭 +``` + +三个数字都是经验值:**400ms**(快请求不闪 loading)、**30s**(loading 不可能永远转)、**计数器**(并发请求只显示一个 loading)。若引入全局 loading,按此方案。 + +--- + +## 五、取消与防错序 + +### 5.1 为什么列表页必须取消旧请求 + +``` +用户输入 "a" → 请求1发出 +用户补输入 "ab" → 请求2发出 +请求2先回 → 渲染 "ab" 结果 ✔ +请求1后回 → 覆盖渲染 "a" 结果 ✘ ← 错序覆盖(stale response) +``` + +### 5.2 双重防护(列表页标准实现) + +```js +// composables/usePagedQuery.js —— 取消 + 版本号双保险 +let abortController = null +let requestVersion = 0 + +const fetchList = async () => { + abortController?.abort() // ① AbortController 取消旧请求 + const currentVersion = ++requestVersion // ② 版本号令牌 + const res = await fetcher(params) + if (currentVersion !== requestVersion) return // 过期响应直接丢弃 + tableData.value = res.results +} +``` + +这是列表页的**强制标准**:所有带筛选/分页的列表页必须走「AbortController + 版本号」双重防护,**禁止**页面手写裸请求循环。 + +注:vben v5 并未内置请求去重(取消能力委托 axios 原生 config,`axios.isCancel` 静默透传)——**不要假设大而全的框架替你解决了这个问题**。soybean 另有 `cancelAllRequest`(nanoid 请求 ID + AbortControllerMap)作页面级批量取消。 + +### 5.3 重试与缓存策略 + +- **重试**:仅幂等 GET 且网络类错误(timeout / network error)自动重试,1-2 次指数退避(soybean 内置 axios-retry;需要时引入 axios-retry,**禁止**手写循环重试)。**禁止**对 POST / PUT / DELETE 自动重试 +- **缓存**:字典 / 枚举类走缓存型 store(gva `dictionary.js` 范式:缓存 key 生成 + 参数校验,避免重复请求);列表页交给组件态 + 取消,不做全局缓存;React 端用 React Query 的 `queryKey` 失效模型(adp 范式:mutation 成功 → `invalidateQueries`) +- **判定**:同一数据被两个页面重复请求同一接口 = 该进缓存层,或该提升到全局 store 了 + +--- + +## 六、状态分层:什么数据进 store + +### 6.1 判定表 + +| 数据 | 归属 | 标杆范式 | +|------|------|----------| +| token / 用户信息 / 权限码 | 全局 store,**持久化 pick 白名单**(只挑必要字段) | vben `persist.pick`;pure Cookie 三元组 + localStorage 用户信息 | +| 服务端菜单 / 动态路由表 | store(内存),**不落盘**(重登重拉,防止旧菜单闪现) | vben access store;pure 可选缓存需显式配置 | +| 列表数据 / 详情 | **不进全局 store**:组件内 usePagedQuery / React Query | adp:`queryKey:[资源名]`,mutation 成功 `invalidateQueries` | +| 字典 / 短枚举 | 缓存型 store,带缓存 key 去重请求 | gva `dictionary.js`、soybean dict | +| 主题 / UI 偏好 | 独立于业务 store 的偏好系统 | vben PreferenceManager(见 06 章) | +| 筛选 / 分页 / Tab 状态 | **URL query**,永远不复制进 store | 通用 | + +### 6.2 登录编排(vben 范式) + +```ts +// store/auth.ts —— 登录是一个编排函数,不是散落的事件 +async function authLogin(params) { + const { accessToken } = await loginApi(params) + setAccessToken(accessToken) + const [userInfo, accessCodes] = await Promise.all([fetchUserInfo(), getAccessCodesApi()]) + router.push(homePath) +} +``` + +配套要求:**禁止**在生产日志打印 token 片段;`isLoggedIn` 以内存态 + token 存在性为准,不依赖 localStorage 残留字段。pure 的 `useXxxStoreHook()` 模式保证拦截器/守卫等 setup 外代码也能安全取 store。 + +--- + +## 七、双端一致性要求(迁移期) + +同产品双栈并存时,请求层**必须**满足: + +| 项 | 要求 | +|----|------| +| token key | 两端同名(推荐 `.auth.token`;迁移期「读旧写新」一个版本) | +| refresh 机制 | 两端同构(单飞 + 队列重放),副端不允许退化成「401 直接跳登录」 | +| 响应契约 | 同一份 code/data/msg 约定与错误分级 | +| mock | **显式模式**(dev 开关/页面级声明),**禁止**运行时因网络错误静默回退假数据(adp:mock 是构建模式不是运行时兜底) | + +--- + +## 八、最佳实践清单 + +✅ **要做** + +1. 组件永远 import `api/<域>` 函数,不直接碰 axios 实例(**禁止** store 绕过 API 层裸调) +2. 每个请求函数带注释(用途/参数/返回)+ 类型(`request` 或 JSDoc) +3. 刷新 token 的请求用裸 client,绕过业务拦截器 +4. 401 副作用全局单例(一个弹窗/一次跳转),登录页与轮询接口静默 +5. 列表请求一律可取消(AbortController)+ 版本号防错序 + +❌ **不要** + +1. 不要在拦截器里 `return error` 而非 `Promise.reject(error)`(gva 现存 bug,调用方 catch 不到) +2. 不要在组件里 try/catch 后吞错不提示(错误要么展示要么显式静默) +3. 不要把 token 存两套 key +4. 不要在请求层 import 具体页面组件(保持依赖单向) +5. 不要对同一资源做无缓存的重复并发请求 + +--- + +## 九、AI 检查清单(对比目标工程逐项核对) + +**P0** + +1. 是否存在组件/store 绕过 API 层直接调 axios 实例的代码? +2. 401 刷新是否单飞(isRefreshing/队列/共享 Promise 任一实现)?并发过期是否只产生一次副作用? +3. 是否有「网络错误静默回退 mock」类逻辑在生产路径上? +4. 拦截器错误分支是否 `Promise.reject`?是否有 token 打印类调试日志残留? + +**P1** + +5. 列表页是否全部走「取消 + 版本号」方案?手写裸请求循环的页面列出迁移清单 +6. 错误提示是否分级(showType / silent 逃生口)?登录页与轮询是否静默? +7. token/用户信息持久化是否 pick 白名单?菜单/路由是否落盘(不应落盘)? +8. 字典/枚举是否有缓存层?重复并发请求同一资源的情况列出 + +**P2** + +9. 是否值得升级到档 2/3(五钩子工厂 / RequestClient 类)?判断标准:多实例需求或错误契约复杂度 +10. 是否引入 axios-retry(幂等 GET)与错误码 env 化? + +--- + +> 下一篇:[04-路由与权限控制](04-路由与权限控制.md) —— meta 是路由的灵魂。 diff --git a/docs/web-ui-guide/04-路由与权限控制.md b/docs/web-ui-guide/04-路由与权限控制.md new file mode 100644 index 0000000..fbb6ce8 --- /dev/null +++ b/docs/web-ui-guide/04-路由与权限控制.md @@ -0,0 +1,236 @@ +# 04 — 路由与权限控制 + +> 参考:vue-vben-admin(RouteMeta 30+ 字段、三种 access 模式)、soybean-admin(文件路由 + static/dynamic 一键切换)、ant-design-pro(access 纯函数)、vue-pure-admin(两级按钮权限);补充:gin-vue-admin(动态路由链路) + +--- + +## 一、路由组织:静态、动态与混合 + +### 1.1 三种模式 + +``` +frontend 模式:前端静态全量路由 + 按角色过滤 + 优点 简单、无接口依赖 缺点 菜单改动要发版 +backend 模式:后端下发菜单树 + import.meta.glob 组件映射(vben / pure / gva) + 优点 运营可配菜单 缺点 链路长、需处理守卫竞态 +mixed 模式:本地静态路由 + 按 name 合并后端菜单(vben mergeRoutesByName) + 优点 开发体验与可配置性兼得 缺点 命名纪律要求高 +``` + +soybean 的成熟做法:用 `.env` 的 `VITE_AUTH_ROUTE_MODE=static|dynamic` **一键切换**两种模式(静态按 `meta.roles` 过滤本地路由 / 动态接收后端下发 ElegantConstRoute),两态殊途同归后统一装配菜单——**模式切换是配置而非代码**,底座工程的成熟标志。 + +**推荐**(多数中后台):「**路由静态、菜单动态**」的轻混合——路由决定 chunk 划分与守卫行为,宜稳;菜单决定导航展示,宜活。需要运营完全可配时再上 backend 模式。 + +### 1.2 backend 模式的实现链路(补充:gva 全链路;vben generateAccessible 同类实现更工程化) + +``` +POST /menu/getMenu(菜单树,component 为字符串路径 'view/layout/index.vue') + → import.meta.glob('../view/**/*.vue') 按路径字符串匹配懒加载组件(pure 同款) + → 守卫 addRoute(asyncRouterFlag 防重复注册) + → 多级菜单递归扁平化为「layout + 二级路由」(pure:keep-alive 只支持二级) + → routeMap(name→route)支撑菜单/keep-alive/参数查询 +``` + +三个易踩坑,标杆都给了答案: + +1. **守卫竞态**:动态 addRoute 后当前导航拿不到新路由 → 注册后需 `return to.fullPath` 重新 resolve 一次(vben:`generateAccess` 后 re-resolve) +2. **防重复注册**:`asyncRouterFlag` / `isAccessChecked` 标志位(pure:刷新时 `wholeMenus.length === 0` 才重建) +3. **对象冻结**:`to.meta.matched = [...to.matched]` 浅拷贝进 meta 供面包屑使用(直接引用会被冻结) + +soybean 的差异化:**文件路由自动化**(@elegant-router)——`src/views` 目录即路由,生成物含 `RouteKey`/`RouteMap` 字面量联合类型,`routerPush({ name: 'user-center' })` 全程类型安全。开发者不写路由文件,新增页面自动获得路由与菜单。 + +--- + +## 二、RouteMeta 规范:meta 驱动一切 UI 行为 + +### 2.1 vben 的启示 + +vben 给 RouteMeta 定义了 30+ 强类型字段(全 JSDoc 注释),菜单/标签页/面包屑/缓存/权限行为**全部由 meta 声明**,布局组件只消费 meta 不写死逻辑: + +```ts +authority?: string[] // 允许的角色 +ignoreAccess?: boolean // 免鉴权(如关于页) +menuVisibleWithForbidden?: boolean // 菜单可见但访问 403(三态权限关键) +hideInMenu / hideInTab / hideInBreadcrumb / hideChildrenInMenu +keepAlive?: boolean // 组件缓存 +order?: number // 菜单排序 +affixTab?: boolean // 标签页固定 +iframeSrc / link / activePath / noBasicLayout +``` + +soybean 同款(17 字段强类型扩展 `declare module 'vue-router'`),pure 用 `CustomizeRouteMeta` + 逐字段中文注释。 + +### 2.2 标准 meta 集(必须的最小集) + +```js +// 每条路由必须声明的能力面 +{ + path: '/users', + component: () => import('@/views/user/Users.vue'), + meta: { + title: '用户管理', // document.title 与面包屑用 + layer: 'system', // shell | system | examples(可裁剪分层归属,底座工程必须) + icon: 'user', // 菜单图标(图标名是数据) + permissions: ['user.view'],// 命中任一即放行;'*'/admin/超管角色 短路 + roles: ['admin'], // 可选,角色级收紧 + keepAlive: false, // 需要缓存的页面必须同时写 defineOptions name + hideInMenu: false, + } +} +``` + +**纪律**:meta 是路由的**唯一**能力声明处,且**必须有类型契约**(`router.d.ts` 模块扩充)。**禁止**「路由里一个 permission、菜单表里又一个 permission」双源人工同步。 + +--- + +## 三、守卫链设计 + +### 3.1 标准守卫流水线 + +``` +① 白名单直通(login/mfa/403/500 等 shell 路由) +② token 检查(无 token → login?redirect=encodeURIComponent(fullPath)) +③ 已登录访问 login → 重定向首页 +④ 权限检查(meta.permissions 任一命中 / meta.roles) +⑤ NProgress + document.title + 主区滚动复位 +⑥ onError:chunk 加载失败 → reload 一次(防发版后旧 chunk 404) +``` + +第 ⑥ 条与 adp 的 `ErrorBoundary 识别 ChunkLoadError → retryCount 作 key 强制重挂载 lazy` 异曲同工(一个在路由层救,一个在组件层救)——发版后旧 hash chunk 404 是必现事故,**必须**有恢复机制。 + +### 3.2 防开放重定向(安全底线,必须) + +```ts +// adp: src/pages/user/login —— redirect 只允许同源相对路径 +function getSafeRedirectUrl(redirect?: string) { + if (!redirect || /^https?:\/\//i.test(redirect)) return '/' + return redirect.startsWith('/') ? redirect : `/${redirect}` +} +``` + +redirect 参数编码传输 + 回跳侧**协议黑名单校验**(禁止 `//evil.com`、`https://evil.com`)。 + +### 3.3 React 端对照 + +- `RequireAuth`(未登录跳转 + 403 渲染)结构正确即可 +- 路由渲染**必须**声明式(配置数组 + ``),权限字段语义与 Vue 端 meta 同名对齐 + +--- + +## 四、权限三粒度 + +### 4.1 粒度模型 + +``` +路由级(页面能否访问) + meta.permissions / meta.roles —— 守卫拦截,403 兜底页 +菜单级(导航显不显示) + 服务端菜单 + 前端模块开关兜底过滤 +按钮级(操作能不能点) + 指令 / 组件 / 函数 —— 至少支持一种,语义全站统一 +``` + +### 4.2 权限三态(vben 的关键设计) + +| 态 | 行为 | meta | +|----|------|------| +| 不可见 | 菜单过滤 + 路由拒绝 | 默认(authority 不命中) | +| 可见但禁止 | 菜单显示,点进去 403 | `menuVisibleWithForbidden: true` | +| 放行 | 全通过 | authority 命中 / `ignoreAccess` | + +无权限但想让用户知道功能存在的运营场景,用态 2(前端只需 403 页 + 菜单过滤豁免,成本低)。 + +### 4.3 按钮级实现对照 + +| 实现 | 出处 | 语义 | 失败行为 | +|------|------|------|----------| +| 指令 `v-auth` | gva `directive/auth.js` | 角色串比对,`.not` 修饰符 | `el.remove()`(DOM 摘除) | +| 指令 `v-access:code` | vben | 权限码/角色双模式 | `el.remove()` | +| 函数 `hasAuth()` | soybean | 权限码,一个 hook 覆盖 90% 场景(不造指令/组件) | `v-if` 不渲染(纯函数可单测) | +| 双轨 `v-auth`/`v-perms` | pure | 路由 meta.auths(功能级)与登录 permissions(接口级)两级,`*:*:*` 超管通配 | `el.remove()` | +| 组件 `` | vben | 包裹式 | 插槽不渲染(可配 fallback) | + +**规范**:指令用于「无权就不存在」;需要「无权置灰 + tooltip 解释」时用组件包裹。**禁止**用 `v-if + 手写权限字符串比较` 绕开统一入口(权限字符串散落是审计灾难)。soybean 的取向值得参考:**权限判断优先做成可组合函数**(逻辑可单测、不依赖模板编译),指令只是函数的语法糖。 + +### 4.4 权限计算要纯函数化(adp) + +```ts +// src/access.ts —— 权限是 initialState 的纯函数,可单测穷举 +export default function access(initialState) { + const { currentUser } = initialState ?? {} + return { + canAdmin: currentUser && currentUser.access === 'admin', + canUser: (code: string) => currentUser?.permissions?.includes(code), + } +} +``` + +**禁止**在前端硬编码「角色→权限」映射表——后端 RBAC 是唯一源真相,前端硬编码映射必然脱节,属死代码。 + +--- + +## 五、菜单生成 + +### 5.1 两种数据流 + +``` +A. 路由驱动(adp):config/routes.ts 集中声明 → ProLayout 自动生成菜单 + name 字段即 i18n key(menu.xxx),菜单名与路由单源 +B. 接口驱动(vben/pure/gva):GET /auth/menu → 前端自建树 → 递归渲染 + 后端可配(hidden/icon/order/外链),前端兜底过滤 +``` + +### 5.2 菜单组件规范(必须) + +- 前端按**分组规则**(如 workspace→business→system 三段)建树,`SYSTEM_ORDER` 类常量定序 +- **模块开关兜底过滤**:菜单接口没过滤掉的,前端再筛一次(后端是源真相,前端是最后防线) +- **加载失败显示重试按钮**,不留空白侧栏(菜单是导航生命线) +- 高亮:外链/iframe 路由用 `meta.activePath` 指回正确菜单项 +- 菜单数据不持久化(重登重拉,防旧菜单闪现) + +--- + +## 六、最佳实践清单 + +✅ **要做** + +1. 新路由必须写全 meta 最低三件:title / layer(底座工程)/ permissions +2. keepAlive 路由的页面组件**必须** `defineOptions({ name })`(keep-alive include 按组件名匹配;pure/gva 以 vite 插件扫描校验) +3. redirect 参数编码传输 + 回跳侧同源校验 +4. 权限判断统一走 permission 工具层,语义双端同名 +5. 路由 meta 有类型契约(`router.d.ts` 模块扩充) + +❌ **不要** + +1. 不要在前端硬编码角色→权限映射(后端 RBAC 是唯一源真相) +2. 不要用 v-if 手写权限字符串比较 +3. 不要在守卫里做异步重活(拉菜单/用户信息放登录编排或首屏初始化,守卫只做检查) +4. 不要让示例层路由 import 系统层组件(破坏可裁剪性) +5. 不要权限配置文件双份并存(漂移源头) + +--- + +## 七、AI 检查清单(对比目标工程逐项核对) + +**P0** + +1. 路由 meta 是否有类型契约?是否存在「路由权限 + 菜单权限」双源人工同步? +2. redirect 回跳是否有同源校验(防开放重定向)? +3. 是否存在前端硬编码「角色→权限」映射(应删)? +4. chunk 加载失败是否有恢复机制(onError reload / ErrorBoundary)? + +**P1** + +5. keepAlive 链路是否完整(meta.keepAlive + defineOptions name + include 匹配)?缺 name 的缓存页面列出 +6. 按钮权限是否统一入口(指令/组件/函数三选一,禁止散落 v-if 比较字符串)? +7. 菜单失败是否有重试 UI?菜单是否被错误地持久化? +8. 路由渲染(React 端)是否声明式? + +**P2** + +9. 是否值得引入文件路由自动化(soybean @elegant-router / pure `router/modules/` glob 收集)? +10. 是否需要权限三态中的「可见但 403」(运营诉求)? + +--- + +> 下一篇:[05-布局系统与导航设计](05-布局系统与导航设计.md) —— 布局是业务的舞台,不是业务本身。 diff --git a/docs/web-ui-guide/05-布局系统与导航设计.md b/docs/web-ui-guide/05-布局系统与导航设计.md new file mode 100644 index 0000000..b4e25fe --- /dev/null +++ b/docs/web-ui-guide/05-布局系统与导航设计.md @@ -0,0 +1,234 @@ +# 05 — 布局系统与导航设计 + +> 参考:vue-vben-admin(三层布局架构)、soybean-admin(layouts/modules 按功能拆分)、ant-design-pro(ProLayout 运行时配置)、vue-pure-admin(布局三模式);补充:gin-vue-admin(侧栏模式/标签页/水印) + +--- + +## 一、布局的分层架构 + +### 1.1 vben 三层模型(理解布局复杂度的标尺) + +``` +┌─ 内核层 @core/ui-kit/layout-ui ──────────────────────┐ +│ headless 布局引擎:40 个 props + defineModel │ +│ (布局模式/头部模式/侧栏宽度折叠悬停),不认识 store │ +├─ 业务层 effects/layouts ─────────────────────────────┤ +│ 把 preferences 逐项映射为内核 props; │ +│ 组装 15 个可插拔部件(面包屑/主题切换/搜索/锁屏…) │ +├─ App 层 layouts/basic.vue ───────────────────────────┤ +│ 只用插槽填内容(#user-dropdown #notification …) │ +└──────────────────────────────────────────────────────┘ +``` + +soybean 的同构做法:布局引擎抽成 `packages/materials`(AdminLayout/PageTab/SimpleScrollbar),`src/layouts/base-layout/` 只是配置消费者,`layouts/modules/global-*` 按功能拆分(header/sider/tab/breadcrumb/search/footer/theme-drawer)。pure 提供 vertical/horizontal/mix 三种布局模式运行时可切。 + +**设计要义**:布局引擎与业务解耦(内核不依赖状态库),布局模式是**运行时偏好**而非构建期选择。 + +### 1.2 单文件 Layout 的取舍(中小工程) + +300 行左右的单文件 Layout(el-container 侧栏 + 顶栏 + main)对单布局产品是**正确选择**——不要为了架构美引入三层。分层触发条件: + +- 出现第 2 种布局模式(如「无侧栏全屏监控大屏」) +- 布局配置需要用户可调(折叠宽度/头部固定/水印) +- 布局代码超过 500 行开始挤压可读性 + +无论哪种形态:**业务页挂在内容区(页面容器组件协议),禁止重写布局层**。 + +--- + +## 二、布局骨架与尺寸规范 + +### 2.1 管理端标准骨架 + +``` +┌──────────────────────────────────────────────────────┐ +│ Topbar 48px │ 页标题 · 面包屑 │ 暗色切换 · 用户下拉 │ +├──────────┬───────────────────────────────────────────┤ +│ Sidebar │ │ +│ 220px │ main │ +│ (折叠64) │ │ +│ │ PageContainer │ +│ 分组: │ │ +│ 按层定序 │ 页头(标题/描述/操作按钮) │ +│ │ 内容卡片(24px 内边距) │ +└──────────┴───────────────────────────────────────────┘ +``` + +### 2.2 尺寸规范表(推荐值) + +``` +侧栏宽度 220px(折叠 64px,<1200px 自动折叠) +顶栏高度 48-56px +页面内边距 24px(紧凑模式 16px) +卡片间距 16px +卡片圆角 8px(--el-border-radius-base 级) +按钮/输入高度 32px(default size) +图标 16px 主图标 / 12px 辅助 +正文字号 14px;辅助 12px;页面标题 18-20px +移动端断点 <768px 侧栏变覆盖式抽屉 + backdrop +``` + +### 2.3 卡片构成规范(视觉一致性的最小单位) + +页面 = 卡片的组合,卡片写法全站统一: + +``` +构成 背景 bg-card + 描边 border-base 或 阴影 shadow-1(全站二选一,禁止混用) +内边距 20-24px;卡片内区块间距 16px;卡片之间间距 16px +标题区 卡片标题 16px/600 + 右侧操作区(同一基线对齐) +页头 页面标题 18-20px/600 + 副标题 13px 次要色;页头与内容区间距 16px +指标卡 数字 24-28px/600 + tabular-nums + 环比箭头(涨跌语义色,方向全站统一) +``` + +--- + +## 三、侧栏与导航 + +### 3.1 侧栏必备能力清单 + +| 能力 | 实现要点 | 出处 | +|------|----------|------| +| 折叠 | 图标态切换;`<1200px` 自动折叠;移动端强制抽屉 | gva/pure | +| 高亮 | 路由激活态;外链/iframe 用 `meta.activePath` 指回 | vben `activePath` | +| 分组 | 前端按 layer 分组 + 常量定序,服务端菜单为准 | 常见底座实现 | +| 图标 | icon 名是数据(菜单表字段),前端映射渲染 | gva/pure/soybean | +| 失败重试 | 菜单接口失败显示重试入口,不留空白 | 通行要求 | +| 外链 | `window.open` 新窗;不注册路由 | gva `link` | + +### 3.2 可选演进:标签页(tabs) + +功能完整参照(gva 280 行 / pure multiTags / vben tabs-ui):sessionStorage 持久化、tab key = `name + query + params`(同路由不同参数多开)、右键菜单(关左/关右/关其他)、中键关闭、首页不可关、`meta.closeTab` 不留痕;vben 再加 affixTab 固定、maxNumOfOpenTab 上限、拖拽排序。 + +**引入判断**:运维/排障类平台(多工位切换频繁)收益大;配置型后台收益小。做了标签页**必须**解决 keep-alive 联动(否则切 tab 丢状态)。 + +--- + +## 四、Keep-Alive 链路(做对了很隐形,做错了很诡异) + +``` +路由 meta.keepAlive: true + → 页面组件 defineOptions({ name: 'UserList' }) ← 强约定! + → layout 中 + → 刷新:keepAlive 页销毁重建 toggle;非缓存页跳隐藏 Reload 路由(gva 双轨) +``` + +**为什么必须 defineOptions name**:keep-alive 的 include 匹配的是**组件名**而非路由名。script setup 组件默认匿名,不写 name 缓存必然失效。gva/pure 以 Vite 插件扫描生成「路径→组件名」映射做校验——**推荐**「组件 name 与路由 name 同名」并用 lint 守住(`vue/name` 规则)。 + +pure 的补充经验:**三级以上路由拍平成二级**再注册(keep-alive 只对二级稳定生效,社区已知问题)。 + +--- + +## 五、页面模板(新页面从这里复制起步) + +### 5.1 列表页(最高频模板) + +``` +┌─────────────────────────────────────────────────┐ +│ PageContainer #header │ +│ 批量删除 · 导入 · [+ 新增] ← 按钮级权限 │ +├─────────────────────────────────────────────────┤ +│ ListPage │ +│ ├ filters slot:关键字/状态/日期筛选 [查询][重置] │ +│ ├ el-table(操作列 fixed right,状态列 tag 化) │ +│ └ el-pagination(10/30/50/100) │ +├─────────────────────────────────────────────────┤ +│ 对话框子组件:XxxFormDialog / ResetDialog / … │ +└─────────────────────────────────────────────────┘ +``` + +范式要点: + +1. 页面只做「编排」:容器 + 列表组件 + 子组件组合,本身 ≤400 行 +2. 数据获取走列表组件的 fetcher(内嵌「取消 + 版本号」双重防护,见 03 章) +3. 表单弹窗独立成 `components/XxxFormDialog.vue`,通过 props/emit 通信 +4. 操作按钮逐个挂权限控制 + +### 5.2 详情/编辑页 + +``` +┌─────────────────────────────────────────────┐ +│ ← 返回 编辑用户 [保存] │ +├─────────────────────────────────────────────┤ +│ ┌─ 基本信息 ────────────────────────────┐ │ +│ │ 分组卡片:descriptions 只读态 │ │ +│ └───────────────────────────────────────┘ │ +│ ┌─ 高级设置 ────────────────────────────┐ │ +│ │ 表单区:危险操作独立分区(红色警示卡) │ │ +│ └───────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +### 5.3 仪表盘页 + +``` +┌─────────────────────────────────────────────┐ +│ 4-6 张指标卡(数字 + 环比箭头) │ +├──────────────────┬──────────────────────────┤ +│ 折线图(趋势) │ 饼图/柱状(分布) │ +├──────────────────┴──────────────────────────┤ +│ 最近活动列表 / 快捷入口 │ +└─────────────────────────────────────────────┘ +``` + +图表规范:ECharts **按需引入**(`echarts/core` + 按需 register,**禁止**全量 import echarts);图表区懒加载 + loading 占位;WebSocket 实时图必须处理「组件卸载断开连接 + 重连退避」;图表色板从主题变量读取(见 06 章)。 + +### 5.4 空态页 + +图标 + 一句话 + 引导动作(如「模块未启用 → 查看启用步骤」)是「功能未开启」的统一形态。 + +--- + +## 六、响应式策略 + +| 断点 | 行为 | +|------|------| +| ≥1200px | 完整布局 | +| 768-1200px | 侧栏自动折叠为图标态 | +| <768px | 侧栏变覆盖式抽屉 + backdrop;表格横向滚动;弹窗宽度 90% | + +实现参照(gva `hooks/responsive.js`、soybean 断点写回 store):resize 监听 + **100ms debounce** 写入全局 device 状态(`desktop | mobile`),布局组件只读状态不各自监听。弹窗移动端宽度走全局配置统一控制。 + +--- + +## 七、最佳实践清单 + +✅ **要做** + +1. 业务页挂内容区(页面容器协议),**禁止重写 layout/** +2. 页面标题来自 `route.meta.title`,不硬编码 +3. 可访问性:折叠钮/菜单项带 aria-label +4. 布局状态(折叠)持久化,key 纳入全局命名空间(见 08 章双端一致) +5. 路由切换动画统一一种(fade 150ms),`transition` 包 `router-view` + +❌ **不要** + +1. 不要在业务页写 fixed 定位的伪头部(与 Topbar 层级打架) +2. 不要侧栏菜单硬编码(数据源走菜单 store) +3. 不要绕过页面容器自造页面容器(内边距/面包屑/页头规范会失控) +4. 不要在移动端保留 hover-only 交互(用点击展开兜底) +5. 不要保留空目录和过期布局文档 + +--- + +## 八、AI 检查清单(对比目标工程逐项核对) + +**P0** + +1. 业务页面是否有绕过页面容器、直接改写布局层的代码? +2. 页面标题是否单源(meta.title)?硬编码标题清单列出 + +**P1** + +3. 超过 500 行的 Layout / 超过 400 行的视图页面列出,并给拆分建议(参照 07 章) +4. keepAlive 链路是否完整(meta + defineOptions name + include)? +5. 响应式三断点行为是否齐全?弹窗移动端宽度是否有适配? +6. ECharts 是否按需引入?WebSocket 是否有卸载断开与重连退避? + +**P2** + +7. 是否值得引入标签页(多工位场景)?若引入,keep-alive 联动方案确认 +8. 是否值得布局引擎化(出现第二种布局模式时) + +--- + +> 下一篇:[06-主题系统与视觉设计](06-主题系统与视觉设计.md) —— 换肤只改变量,不引第二套样式。 diff --git a/docs/web-ui-guide/06-主题系统与视觉设计.md b/docs/web-ui-guide/06-主题系统与视觉设计.md new file mode 100644 index 0000000..d74a4b8 --- /dev/null +++ b/docs/web-ui-guide/06-主题系统与视觉设计.md @@ -0,0 +1,316 @@ +# 06 — 主题系统与视觉设计 + +> 参考:vue-vben-admin(HSL Token + Tailwind 桥接 + CSS layer + EP 适配器)、soybean-admin(单一 ThemeSetting 类型 + 三端同步)、vue-pure-admin(EP↔Tailwind v4 变量桥接)、ant-design-pro(antd token + data-theme);补充:gin-vue-admin(EP SCSS 定制三层法) + +--- + +## 一、设计 Token:单一事实源 + +### 1.1 Token 分层模型 + +``` +语义层(唯一手写处) + --background --foreground --primary --card --border --radius ... + ↓ 派生(工具类桥接) +Tailwind 层 bg-background text-foreground(@theme 映射) + ↓ 派生(适配器) +组件库层 --el-color-primary / antd token —— 从语义层变量计算得出 + ↓ 派生(算法) +色阶层 primary-50 … primary-950 / light-3 … light-9 +``` + +**铁律:语义 Token 是唯一手写源,其余全部派生。** 任何地方再出现硬编码色值(`#409eff`、`rgb(64,158,255)`)都是违规。 + +### 1.2 vben 的 Token 定义(shadcn 范式) + +```css +/* vben: packages/@core/base/design/src/design-tokens/default.css */ +:root { + --background: 0 0% 100%; /* HSL 三元组,不带 hsl() 包装 */ + --primary: 211 91% 39%; + --border: 214 32% 91%; + --radius: 0.5rem; +} +.dark { /* 整组覆盖,暗色即换一套值 */ + --background: 224 71% 4%; + --primary: 210 98% 56%; +} +``` + +两个细节值千金: + +1. **裸 HSL 值不带 `hsl()` 包装**——这样 `hsl(var(--primary) / 0.5)` 可直接拼透明度(soybean 同款:RGB 拆三段 `r g b` 供 `rgb(var(--x) / 0.5)`) +2. **暗色 = `.dark` class 整组覆盖**,而非媒体查询——用户偏好优先于系统 + +### 1.3 Tailwind v4 桥接(vben) + +```css +/* internal/tailwind-config/src/theme.css */ +@custom-variant dark (&:is(.dark *)); /* class 策略暗色 */ +@theme inline { + --color-background: hsl(var(--background)); /* 语义 Token → 工具类 */ + --color-primary: hsl(var(--primary)); +} +@layer properties, theme, base, components, utilities; /* 级联层声明 */ +``` + +**CSS layer 声明是组件库共存的关键**:把组件库样式压在低层,工具类天然可覆盖,避免 `!important` 军备竞赛。 + +### 1.4 pure 的 EP↔Tailwind 桥接与 soybean 的三端同步(★★★★☆) + +**EP 工程引入原子类的最短路径(pure):** + +```css +/* vue-pure-admin: src/style/tailwind.css —— Tailwind v4 CSS-first 写法 */ +@custom-variant dark (&:is(.dark *)); /* class 策略暗色 */ +@theme { + --color-bg_color: var(--el-bg-color); /* EP 变量 → 原子色 token */ + --color-primary: var(--el-color-primary); /* 于是 bg-bg_color / text-primary 与组件库同色 */ +} +``` + +soybean 则是「单一强类型配置对象」路线:`themeSettings`(`App.Theme.ThemeSetting` 一份 schema 即文档)驱动 **CSS 变量 + NaiveUI GlobalThemeOverrides + UnoCSS theme 三端同步**,且用 `BUILD_TIME` 作覆盖标志——**发版自动淘汰用户本地旧主题缓存**(防「新版本旧皮肤」)。两案共同点:换肤 = 改上游变量,组件库与原子类自动跟随。 + +--- + +## 二、暗色模式 + +### 2.1 实现要点(三标杆殊途同归) + +| 环节 | 做法 | 反面 | +|------|------|------| +| 切换机制 | html 挂 class(vben/pure `.dark`)或 `data-theme="dark"`(adp) | `prefers-color-scheme` 媒体查询直连(用户无法覆盖系统) | +| 跟随系统 | watch `prefers-color-scheme` 作为 **auto 模式初值**,用户手动选择后以用户为准 | — | +| 持久化 | localStorage 单 key;**读取要在 app 挂载前**(防白闪,必要时内联脚本) | 挂载后再初始化(首屏闪白) | +| 组件库 | EP:引 `dark/css-vars.css` 一行;antd:token 切换 | 自写暗色样式覆盖组件库 | + +三态标准:`light | dark | auto`(auto 跟随系统)。附加无障碍模式(可选):灰度 / 色弱滤镜(`html-grey` / `html-weakness`,一行 filter + class 切换;值班大屏场景有真实需求)。 + +**暗色的分层策略与浅色不同**:阴影在深底上几乎不可见——暗色模式靠**边框 + 表面亮度差**分层(卡片 = 页面底色提亮一档),不要只调阴影。 + +### 2.2 双端 key 一致(迁移期,必须) + +同产品双栈并存的工程,暗色 key **必须**两端同名(推荐 `.theme.mode`),切换机制统一(class + data-theme 双挂成本最低)。key 不一致 = 同浏览器两端口偏好不互通 + 迁移时暗色选择器要全量改。 + +--- + +## 三、组件库主题定制(Element Plus) + +标杆做法(vben):语义 Token **派生**组件库变量——`useElementPlusDesignTokens` 监听主题变化,把语义 Token 实时映射为 `--el-color-primary` 全系(暗色下自动反转色阶方向)。**换肤不碰组件库,只改上游 Token。** + +补充范式(gva 的 EP 专属三层法): + +``` +第 1 层 编译期:SCSS 定制色板 + @forward 'element-plus/theme-chalk/src/common/var.scss' + with ($colors: ('primary': ('base': #409EFF), ...)); + +第 2 层 运行时:JS 生成 --el-color-primary 及 light-1..10 / dark-1..2 / -bg + 色阶写入 documentElement —— 换主色不重新编译 + (gva setBodyPrimaryColor:light/dark 两套明度算法) + +第 3 层 暗色变量:element-plus/theme-chalk/dark/css-vars.css + + html.dark { --el-bg-color: ... } 局部覆盖 +``` + +pure 的 `setEpThemeColor()`(`useDataThemeChange.ts`)是第 2 层的更简实现:`documentElement.style.setProperty` + 循环生成 light-1~9/dark-1~2,且暗色下**色阶方向反转**。品牌主色运行时换肤(站点名/Logo/主色/favicon 联动)是底座「品牌可配」能力的核心——主色、圆角(`--el-border-radius-base`)、基础字号纳入同一变量体系。 + +--- + +## 四、视觉执行规范(写出好看代码的数值层) + +> 本章前三节解决「主题怎么搭」,本节解决「页面怎么写才好看」。所有数值**必须**落为 CSS 变量后使用——AI/开发者写页面时只允许引用变量,不允许现场发明数值。 + +### 4.1 品牌可配项 + +| 配置项 | 生效点 | 存储 | +|--------|--------|------| +| 站点名称 | 侧栏 logo 文案 / document.title 后缀 | 后端配置 | +| Logo | 侧栏 + favicon | 后端配置 | +| 主色 | `--el-color-primary` 系列运行时混色 | 后端配置或用户偏好 | +| 暗色 | 用户偏好 | localStorage(统一命名空间) | + +pure 的补充范式:**运行时平台配置**(`public/platform-config.json`,Title/Layout/Theme/DarkMode 等 30+ 项,启动拉取注入)——改配置不发版(详见 08 章 env 分层)。 + +### 4.2 基础 Token 全表(新工程第一天就建) + +```css +:root { + /* —— 背景层次(浅色值示例;暗色整组覆盖,靠亮度差分层) —— */ + --bg-page: #f5f6f8; /* 页面底 */ + --bg-card: #ffffff; /* 卡片 / 弹层 */ + --bg-hover: rgba(0, 0, 0, 0.04); /* 行/项 hover */ + --bg-muted: #f5f7fa; /* 次级区块 / 代码块 */ + + /* —— 文字层次(四档灰,禁用两档灰打天下) —— */ + --text-primary: rgba(0, 0, 0, 0.88); /* 正文 */ + --text-secondary: rgba(0, 0, 0, 0.65); /* 次要说明、表头 */ + --text-placeholder: rgba(0, 0, 0, 0.38); /* 占位、禁用标签 */ + --text-disabled: rgba(0, 0, 0, 0.25); + --text-inverse: #ffffff; /* 深底上的文字 */ + + /* —— 边框(三级) —— */ + --border-strong: rgba(0, 0, 0, 0.26); /* 输入框 */ + --border-base: rgba(0, 0, 0, 0.15); /* 卡片描边 */ + --border-light: rgba(0, 0, 0, 0.08); /* 分割线 */ + + /* —— 语义色(对齐组件库 success/warning/danger/info) —— */ + + /* —— 间距(4px 基数,全站只用这六档) —— */ + --space-1: 4px; --space-2: 8px; --space-3: 12px; + --space-4: 16px; --space-6: 24px; --space-8: 32px; + + /* —— 圆角(三档) —— */ + --radius-sm: 4px; /* 标签、小按钮 */ + --radius: 8px; /* 卡片、按钮、输入框(主值) */ + --radius-lg: 12px; /* 弹窗、大图 */ + + /* —— 阴影(卡片最多 2 级;弹层用组件库默认) —— */ + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-2: 0 4px 12px rgba(0, 0, 0, 0.08); + + /* —— 层级(防 z-index 军备竞赛) —— */ + --z-dropdown: 100; --z-drawer: 200; + --z-modal: 300; --z-message: 400; +} +``` + +项目可从组件库变量派生(EP:`--bg-card: var(--el-bg-color)`),重点是**层次齐全、命名统一**。卡片风格全站二选一:**描边(border-base)或阴影(shadow-1)**,不许混用。 + +### 4.3 字体与排版 + +| 用途 | 字号 / 行高 | 字重 | +|------|------------|------| +| 页面标题 | 18-20 / 28 | 600 | +| 卡片标题 | 16 / 24 | 600 | +| 正文 / 表格 | 14 / 22 | 400 | +| 次要说明 | 13 / 20 | 400 | +| 辅助 / 标签 | 12 / 20 | 400 | + +规则:**正文最小 12px**(再小不可读);同一页面标题层级 ≤2 级;数字用 `font-variant-numeric: tabular-nums`;中英文与数字混排加空格(「共 12 条」);长文本省略必须带 title 提示;字体栈取系统栈 + 组件库默认,**禁止**引入额外 webfont 除非品牌需要。 + +### 4.4 图标规范 + +- **一套图标集**(线性或面性统一,**禁止**混搭两套风格;推荐 Iconify 单集或组件库图标) +- 尺寸四档:12(辅助)/ 16(按钮、菜单、表格行内 **主值**)/ 20(页头)/ 24(空态插图位) +- 图标与文字并排间距 6px;按钮内图标与文字同色同级 +- 纯图标按钮**必须**带 tooltip 与 aria-label + +### 4.5 过渡与微交互 + +| 场景 | 时长 / 缓动 | +|------|-------------| +| 颜色 / hover 变化 | 150ms ease | +| 展开 / 抽屉 / 弹层 | 200-300ms `cubic-bezier(0.4, 0, 0.2, 1)` | +| 页面切换 | fade 150ms | + +规则:同类交互全站**同时长同缓动**;**禁止**超过 400ms 的阻塞动画;按钮提交必须有 loading 态(防重复触发);自定义交互组件四态齐全——**hover / active / focus-visible / disabled** 缺一不可(focus 可见性是可访问性底线)。 + +### 4.6 对齐与栅格 + +- 表格:数字列右对齐 + tabular-nums,文本列左对齐,**居中对齐只用于状态标签/操作列**(全列居中是典型反模式) +- 表单:标签右对齐或上对齐全站统一一档;输入宽度按内容语义定(手机号 11 位就别 100% 宽) +- 卡片间距统一 `--space-4`(16px);页面内边距 `--space-6`(24px) + +--- + +## 五、好看反模式清单(出现即改) + +1. 硬编码色值(`#333`、`rgba(0,0,0,.5)` 散落模板)——不走 Token +2. 一页混用多种圆角 / 多种阴影 / 非 4px 基数间距(8 和 10 混用) +3. 全列居中对齐的表格(数字该右对齐、文本该左对齐) +4. 图标混用两套风格(线性 + 面性、单色 + 彩色) +5. 一页多个 primary 按钮(主操作**只能有一个**,其余 default/text) +6. 只有两种灰的文字层次(该用四档文字 Token) +7. 弹窗套弹窗再套抽屉(层级超过两层改抽屉分流) +8. 满屏成功 toast(见 07 章反馈分级:成功静默) +9. 骨架屏与真实内容尺寸不符(loading 结束页面跳动) +10. 表格列被挤压换行(缺 min-width / 未开横向滚动) +11. 危险操作无确认且无 danger 语义色 +12. 空页面白板(empty 必须带引导动作) +13. 阴影当装饰乱叠(卡片最多 shadow-2,层级感来自留白而非阴影堆叠) +14. 暗色模式直接套浅色阴影(暗色靠边框 + 亮度差分层) + +--- + +## 六、死 CSS 治理 + +存量工程常见病灶:styles/ 下积累多代无入口引用的样式文件(曾有工程沉淀约 2000 行死 CSS,且「设计系统」存在 v1/v2 两代并存——换了两茬样式系统、旧的没删的考古层)。 + +### 6.1 治理方案(四步) + +``` +第 1 步 验死:对每个文件跑全仓引用检查(import/@import/url()), + 确认零引用后直接删除(git 里有历史,不建备份目录) +第 2 步 收敛:保留三件套 = variables(语义 Token)+ 栅格 + 基础元素; + 组件样式回归 scoped / 组件库 +第 3 步 立规:CI 检查 styles/ 下每个 .css 必须被入口链引用, + 新文件无引用直接 fail +第 4 步 单源:设计规范类文档只留一份(docs),代码里不留第二套实现 +``` + +**规则**:CSS 文件必须有入口引用链,如同 JS 文件必须有 import——「无引用样式」=「无主代码」,删除是唯一正确处理。 + +--- + +## 七、样式写法优先级(adp 明文范式,直接抄用) + +``` +1. 组件库自带能力(EP 的 type/size/token props) ← 优先 +2. 语义工具类 / Tailwind 原子类(布局、间距) ← 其次 +3. scoped 样式 + CSS 变量(主题相关) ← 业务组件 +4. 深度选择器 :deep()(覆盖组件库,须注释原因) ← 最后手段 +5. 全局覆盖(唯一收口文件,如 element 覆盖表) ← 禁散落 +``` + +gva 的补充范式:语义化页面类(`gva-search-box / gva-table-box / gva-btn-list`)。与「页面容器 + 列表组件」的组件路线二选一——**推荐组件路线**(类型提示与 props 约束更强),不要并行引入两套体系。 + +--- + +## 八、最佳实践清单 + +✅ **要做** + +1. 新颜色/间距/圆角一律引用 Token 变量,组件里禁止现场发明数值 +2. 透明度拼接用 `hsl(var(--x) / 0.5)` 裸值模式 +3. 暗色切换在 app 挂载前完成(必要时 index.html 内联首帧脚本) +4. 图表颜色从主题变量读取(初始化注入当前主题色板,暗色切换重建实例) +5. 新全局覆盖样式集中一个文件并注释原因 + +❌ **不要** + +1. 不要保留 v1/v2 并存的设计系统文件 +2. 不要用 `!important` 解决覆盖问题(用 CSS layer 或提高选择器语义) +3. 不要在组件里写 `@media (prefers-color-scheme)` 直连暗色(绕过用户选择) +4. 不要硬编码暗色背景色值(读组件库 bg 变量) +5. 不要双端各存一套主题 key(迁移期统一命名) + +--- + +## 九、AI 检查清单(对比目标工程逐项核对) + +**P0(结构)** + +1. styles/ 下是否有零入口引用的样式文件?列出并标记删除(统计行数) +2. 是否存在硬编码色值 / 非 Token 间距圆角出现在模板与 scoped 样式?抽样列出 +3. 是否有多套并行样式系统 / 两代设计系统文件并存? + +**P1(视觉执行)** + +4. 基础 Token 全表(背景四档/文字四档/边框三级/间距六档/圆角三档)是否建立且被引用? +5. 排版抽检:正文 <12px?数字列未右对齐/未 tabular-nums?长文本省略无 title? +6. 图标抽检:是否混用两套风格?纯图标按钮是否缺 tooltip/aria-label? +7. 过渡抽检:同类交互时长是否统一?提交按钮是否都有 loading?四态是否齐全? +8. 对照「好看反模式清单」14 条逐条扫描,命中项列出 +9. 暗色抽检:分层是否靠边框/亮度差?图表色板是否随主题重建? + +**P2(体验升级)** + +10. 对比度达标:正文 ≥ 4.5:1、大字 ≥ 3:1(WCAG AA)——用工具抽查文字层次 Token +11. Tailwind 是否与组件库变量打通(pure `@theme` 桥接)? +12. 是否需要 BUILD_TIME 发版覆盖用户旧主题缓存(soybean 方案)? +13. 灰度/色弱无障碍模式是否值得启用(值班场景)? + +--- + +> 下一篇:[07-组件设计与交互反馈](07-组件设计与交互反馈.md) —— 组件是设计的词汇表。 diff --git a/docs/web-ui-guide/07-组件设计与交互反馈.md b/docs/web-ui-guide/07-组件设计与交互反馈.md new file mode 100644 index 0000000..186fb85 --- /dev/null +++ b/docs/web-ui-guide/07-组件设计与交互反馈.md @@ -0,0 +1,253 @@ +# 07 — 组件设计与交互反馈 + +> 参考:vue-vben-admin(Api 类命令式组件)、soybean-admin(hook 优先 + 组件薄壳)、vue-pure-admin(页面内聚五件套 + ReDialog)、ant-design-pro(trigger 弹窗 + ProTable 声明式) + +--- + +## 一、组件分层与词汇表 + +``` +┌─ 全局组件 src/components/ ──────────────────────┐ +│ PageContainer 页面容器(页头/面包屑/slot) │ +│ ListPage 分页列表(fetcher+筛选slot+表格) │ +│ 权限组件 / 图标选择 / 主题切换 / 动态菜单 │ +├─ 域组件 views//components/ ─────────────┤ +│ XxxFormDialog 表单弹窗 │ +│ XxxDetail 详情抽屉 │ +│ 单元格渲染器(状态 tag、操作按钮组) │ +├─ 页面骨架 views//.vue ────────────┤ +│ 只做编排:组合上面两层,≤400 行 │ +└─────────────────────────────────────────────────┘ +``` + +**页面骨架的行数上限是硬约束**(400 行)。pure 的同构落地是「页面内聚五件套」:`index.vue`(纯模板,只解构 hook)+ `form/index.vue`(弹窗表单)+ 左树/右表子组件 + `utils/{hook.tsx, rule.ts, types.ts}`(逻辑/校验/类型全在本域)——`index.vue` 零逻辑。 + +两种封装哲学(按团队选择,**不混用**): + +| 哲学 | 代表 | 做法 | +|------|------|------| +| schema 驱动重封装 | vben | FormApi/ModalApi 类 + schema 数据生成表单/弹层 | +| hook 优先薄壳 | soybean | 逻辑进 UI 无关 hook(useTable/useFormRules),组件只是组件库薄壳 | + +--- + +## 二、弹层设计:trigger 模式与命令式 Api + +### 2.1 trigger 模式(adp,声明式首选) + +```tsx +// UpdateForm.tsx —— 调用方零状态 +编辑} // 传入触发器 + onFinished={async.value => { actionRef.current?.reload() }} +/> +// 内部 cloneElement(trigger, { onClick: onOpen }) 注入打开事件 +// 表单本体 StepsForm + render 进 Modal(destroyOnHidden) +``` + +Vue 对应写法:dialog 组件接 `trigger` slot,或保持「页面持 visible + 子组件 emit close」的朴素方案(够用)。**关键是弹窗组件自包含表单逻辑**,页面只传初始数据和回调。 + +### 2.2 命令式 Api 类(vben,复杂弹层) + +```ts +// useVbenModal 返回 [Modal, api];ModalApi 持有独立响应式 store +const [Modal, modalApi] = useVbenModal({ connectedComponent: MyModal }) +modalApi.open({ payload: { userId: 1 } }) // 命令式打开并传参 +modalApi.onConfirm(async () => { ... }) // 生命周期钩子 +``` + +适用:全局单例弹层(登录过期框、错误确认框——gva 的 ErrorPreview 是此模式的事件总线版)。普通 CRUD 弹窗**不必上这套**,props/emit 足矣。 + +Vue 侧最易落地的命令式弹窗是 pure 的 **ReDialog**:模块级 `dialogStore` + `addDialog({ title, contentRenderer, beforeSure: done => {...} })`,`done()` 关闭——无 provide/inject、无工厂,App.vue 挂载一次全局可用。表单弹窗若要从 props/emit 升级命令式,ReDialog 是阻力最小路线。 + +--- + +## 三、表单范式:手写、配置驱动与 schema 驱动 + +| 范式 | 适用 | 代表 | 成本 | +|------|------|------|------| +| 手写 el-form + rules | 表单结构常变、交互复杂(联动/自定义校验) | 常见 Vue 工程 | 低门槛高纪律 | +| hook 优先(不封表单组件) | 逻辑可测、UI 薄壳 | soybean `useFormRules`(`satisfies` 约束规则表) | 中 | +| 表单 SFC + 弹窗 contentRenderer | 弹窗内容即组件 | pure `form/index.vue` | 低 | +| 配置驱动 columns | 「一列三态」:同一份列配置生成表格列+搜索项+表单项 | adp ProTable(`valueType/valueEnum/hideInForm/formItemRender`) | 学习成本中,CRUD 提速极高 | +| schema 驱动 FormApi | 表单完全由 schema 数据生成,动态增删字段 | vben useVbenForm(zod 校验、响应式 schema) | 高,适合平台型产品 | +| RHF + zod | React 端 | react-hook-form + zod(schema 即校验源) | 中 | + +**表单通用规则(必须)**: + +1. 提交按钮 loading 态(防双击重复提交) +2. 校验消息中文、具体(「请输入用户名」而非「username 不能为空」) +3. 编辑态回填后 reset 时机明确(打开时回填,关闭时重置) +4. 危险输入(密码)用 `show-password`;密钥类只写不读(placeholder 提示「留空则不修改」) + +--- + +## 四、表格范式:列表组件是资产 + +### 4.1 标准列表组件协议(强制走它) + +``` + +内部:「取消 + 版本号」双重防护(见 03 章)+ 分页三件套 +``` + +pure 的三层表格范式同构,可作演进对照:`@pureadmin/table`(全局表格组件,列用 tsx cellRenderer)+ `RePureTableBar`(刷新/列设置拖拽排序/密度/全屏工具条,作用域插槽透传 columns)+ 业务 `useUser()` hook(form/dataList/pagination/CRUD 全收编)——`index.vue` 只剩模板解构,**页面零逻辑**。 + +### 4.2 表格规范 + +``` +列设计 操作列 fixed right;状态列 tag 化;时间列统一格式化函数 +宽表 超 6 列启用横向滚动而非挤压;关键列 min-width 而非 width +空数据 empty 占位 + 「新增第一条」引导(不是裸的「暂无数据」) +加载 表格 v-loading(不用全局遮罩) +批量 多选 + 批量操作按钮常驻 disabled(选中方可点,视觉自说明) +行操作 ≤3 个平铺;>3 个收进更多下拉 +数字列 右对齐 + tabular-nums +密度 行高全站统一一档(default 48px 或 compact 40px,不混用) +图标 行内图标按钮 16px,与文字间距 6px,风格全站统一(见 06 章 4.4) +``` + +### 4.3 详情复用列配置(adp 思路) + +ProTable 的 columns 同时喂给 `ProDescriptions`(详情抽屉)——**列定义是数据**,表格、搜索、详情三处消费同一份。Vue 端可低成本借鉴:列数组中带 `formatter/render` 的描述导出给详情组件。 + +--- + +## 五、交互反馈分级(全站强制) + +### 5.1 反馈决策表 + +| 场景 | 反馈 | 理由 | +|------|------|------| +| 操作成功(删除/保存) | ❌ 不弹框——界面已更新 | 降噪 | +| 操作失败 | ✅ 错误提示(原因必带) | 用户要知情 | +| 危险操作(删除/批量/不可逆) | ✅ 确认框(对象名进文案) | 防误操作 | +| 恢复默认/静默保存 | ❌ 不弹框 | 降噪 | +| 按钮已禁用 | ❌ 不弹框,视觉已说明 | 降噪 | +| 长任务(>1s) | ✅ 按钮 loading;>3s 进度条 | 可感知 | +| 登录/MFA 失败 | ✅ 表单内联错误(不 toast) | 上下文相关 | +| 401 过期 | ✅ 单例弹窗/单次跳转 | 防并发轰炸 | + +```js +// ❌ 冗余 —— 成功也弹 +await deleteMenu(id); ElMessage.success('删除成功') +// ✅ 专业 —— 静默,界面即反馈 +await deleteMenu(id); loadMenus() +``` + +### 5.2 三态完整性检查清单 + +每个数据展示组件必须显式处理三态,缺一即 bug: + +``` +加载态 skeleton(首屏)/ loading(刷新) +空态 empty + 引导动作 +错误态 错误占位 + 重试按钮(不是 console.error 完事) +``` + +--- + +## 六、Composables / Hooks 规范 + +### 6.1 命名与分层 + +``` +composables/hooks 依赖业务上下文(store/请求层):usePagedQuery、useTable +utils 纯函数无依赖:confirm、格式化函数 +``` + +规则:**composable 内禁止直接操作 DOM**(返回响应式状态);一个 composable 只做一件事;返回值用对象而非数组(追加字段不破坏调用方)。 + +soybean 分层补充:框架无关的 hook(useTable 泛型四参)与 UI 适配 hook(useNaiveTable)分层——换 UI 库只动适配层。 + +### 6.2 props 与事件命名规范 + +- props 用名词(`fetcher`、`initialData`、`permissions`),事件用动词(`@close`、`@submit`、`@success`) +- 双向绑定统一 `v-model:xxx` 约定(如 `v-model:visible`),**禁止**拆成 `visible` + `visibleChange` 两个接口 +- 布尔 props 必须显式声明默认值;透传型组件(ListPage)用 `attrs` 透传 UI 库 props,并在组件头注释列出可透传项 +- **避免过度封装**:props + slots 能解决的扩展,不引入 render 函数 / schema;封装层数超过两层时先考虑是否该拆组件 + +--- + +## 七、巨型单文件拆分实战 + +### 7.1 典型场景:定时任务管理页(约 1400 行单文件) + +``` +views/cron/ +├── Cron.vue # ~120 行:页面容器 + 子组件编排 +├── components/ +│ ├── TaskTable.vue # 任务列表(列定义 + 批量选择) +│ ├── TaskFormDialog.vue # 新建/编辑任务(cron 表达式编辑器独立) +│ ├── CronExprInput.vue # cron 表达式输入 + 下次执行时间预览 +│ ├── ExecutionHistory.vue# 执行记录(独立 tab/抽屉) +│ └── ExecutionLogDrawer.vue # 日志查看(自动刷新控制) +└── composables/ + └── useCronTasks.js # CRUD 编排(复用列表 hook) +``` + +### 7.2 典型场景:监控大屏页(约 900 行单文件) + +``` +views/monitor/ +├── Monitor.vue # ~100 行:单机/集群切换 + 布局 +├── components/ +│ ├── MetricCard.vue # 指标卡(CPU/内存/磁盘/网络 通用) +│ ├── TrendChart.vue # 趋势图(echarts 按需引入收口在此) +│ └── HostSelector.vue # 主机选择 +└── composables/ + └── useMetricsStream.js # WebSocket 连接/重连退避/卸载断开 +``` + +拆分判据:**一个文件出现 ≥3 个「功能岛」**(各自的数据/状态/UI 块)即拆;拆出的单元可在测试里独立渲染。组织法参照 pure 页面内聚模式(index 纯模板 + form/ 子组件 + utils/hook 收编逻辑)。 + +--- + +## 八、最佳实践清单 + +✅ **要做** + +1. 新列表页一律「页面容器 + 列表组件」起步(模板见 05 章) +2. 表单弹窗独立组件,页面只传数据与回调 +3. 每个数据组件三态齐全(加载/空/错误) +4. composable 返回对象、单职责、无直接 DOM 操作 +5. 组件 props 加类型与默认值;emit 声明 events + +❌ **不要** + +1. 不要在页面里写第二个 ``(复用列配置或拆子组件) +2. 不要跨层 emit 链超过两级(改用 provide/inject 或提升状态) +3. 不要在组件内散落成功类 toast(见反馈决策表) +4. 不要用 index 作 v-for key(列表刷新错位之源) +5. 不要复制粘贴超过两次的 UI 块不抽组件(三次法则) + +--- + +## 九、AI 检查清单(对比目标工程逐项核对) + +**P0** + +1. 超过 400 行的视图文件全部列出,按第七节方案给出拆分目录树 +2. 成功类 toast 违反反馈决策表的调用点列出(应删) +3. v-for 是否有用 index 作 key 的列表(存在动态增删即不合格) + +**P1** + +4. 列表页是否统一走列表组件协议?手写分页/裸请求的页面列出迁移清单 +5. 表单弹窗自查三件套:提交 loading、校验消息中文、关闭重置 +6. 数据组件三态是否齐全(抽检空态与错误态渲染) +7. 权限控制是否逐按钮挂载(对照权限码清单) + +**P2** + +8. 是否值得引入列配置「一列三态」复用(adp 思路) +9. 弹窗是否需要升级命令式(全局单例场景用 ReDialog/ModalApi) + +--- + +> 下一篇:[08-工程化与质量保障](08-工程化与质量保障.md) —— 让规范自动执行。 diff --git a/docs/web-ui-guide/08-工程化与质量保障.md b/docs/web-ui-guide/08-工程化与质量保障.md new file mode 100644 index 0000000..8572359 --- /dev/null +++ b/docs/web-ui-guide/08-工程化与质量保障.md @@ -0,0 +1,256 @@ +# 08 — 工程化与质量保障 + +> 参考:vue-vben-admin(Vite 配置工厂 + lefthook 流水线)、soybean-admin(eslint-config 工厂 + sa CLI)、vue-pure-admin(build/ 拆分 + env 开关能力)、ant-design-pro(Biome + 测试范式 + CI 矩阵) + +--- + +## 一、构建配置组织 + +### 1.1 配置组织范式 + +``` +vben:配置即内部包 +internal/vite-config/ 提供 defineConfig({ application | library }) 工厂, +app 的 vite.config.ts 只剩 15 行(proxy 声明) + +pure:根目录 build/ 拆分 +build/{plugins,cdn,compress,optimize,utils,info}.ts,vite.config.ts 只做组装 +(env 用 wrapperEnv 类型化读取) + +soybean:build/plugins/ 按域拆文件 +{router,unplugin,html,unocss,devtools}.ts 一文件一插件域 +``` + +单工程标准:**必须**把 vite 配置按职责拆文件(plugins/env/proxy/compress),主配置只做组装;双栈工程的两个 vite 配置**结构镜像**(插件列表、alias、产物命名、proxy 规则逐项对齐)。 + +### 1.2 env 分层(三层结构) + +| 层 | 形态 | 语义 | 说明 | +|----|------|------|------| +| 构建期烧录 | `VITE_*` | 打进产物,改了要重新构建 | API 前缀、功能开关 | +| 运行时可改 | `VITE_GLOB_*`(vben) | 构建时抽成 `_app-config-{hash}.js` 挂 window | **改 API 地址无需重构建** | +| 运行时 JSON | `public/platform-config.json`(pure) | 构建产物旁的配置文件,启动拉取注入 | 免重构建、免 window 挂载 | +| 纯 dev | proxy 端口等 | 只在 dev server 用 | — | + +env 类型契约:每个 `VITE_*` 变量在 `vite-env.d.ts` 有类型与注释(soybean:联合类型直接收窄如 `'static' | 'dynamic'`)。 + +### 1.3 dev server 要点(必须) + +``` +strictPort: true # 端口被占直接报错,不悄悄漂移 +proxy '/api' → 后端端口 + ws: true # 供 WebSocket(必须显式开) + changeOrigin: true +``` + +### 1.4 产物与指纹 + +- 构建注入版本:`define: { __APP_VERSION__, __COMMIT_HASH__ }`(adp 用 git rev-parse 兜底)——设置页「关于」直接展示,排障救命 +- 手工分包保留(radix/icons/charts/motion 等);新大依赖(图表/编辑器)**必须**评估独立 chunk + 懒加载 +- 可选:构建压缩 env 开关(pure `VITE_COMPRESSION = none|gzip|brotli|both`)、CDN 开关(`VITE_CDN`)、构建时间戳 banner + +--- + +## 二、代码规范流水线 + +### 2.1 分层流水线(vben lefthook 范式) + +``` +pre-commit(仅 staged 文件): + 快修复:oxlint --fix → oxfmt → eslint --fix → stylelint --fix + (stage_fixed 回填暂存区) +commit-msg:commitlint(conventional) +push/CI:慢检查全量 vue-tsc/tsc --noEmit + lint + madge 循环依赖 +``` + +原则:**快的在本地 pre-commit 自动修,慢的在 CI 卡**。 + +soybean 的同构版本:`@soybeanjs/eslint-config-vue` 工厂(eslint.config.mjs 仅 14 行)+ `vp lint`(oxc 快扫)→ `eslint`(精修)双层;commit 由自研 `sa` CLI 交互生成并校验,scope 白名单单一数据源。pure 再补两块:stylelint(recess-order 属性排序)与 eslint-plugin-better-tailwindcss(校验 class 书写)。 + +### 2.2 必配清单(必须) + +| 工具 | 要求 | +|------|------| +| ESLint(flat)+ Prettier | 双端同大版本、规则集可复制 | +| Stylelint | standard-scss + recommended-vue + 属性排序 | +| commitlint + husky + lint-staged | pre-commit 快修复、commit-msg 校验 | +| `preinstall: only-allow pnpm` | 锁包管理器 | + +**Conventional Commits 类型集**(commitlint type-enum,统一):`feat / fix / docs / style / refactor / perf / test / chore / revert`;scope 用功能域(`user`、`rbac`、`cron`、`react`……)。 + +**any 管控**:TS strict 端**禁止裸 any**——`as any` 需同行注释原因与消除计划;JS 端以 JSDoc `@param/@returns` 过渡,**不允许**出现「既无类型注释也无类型定义」的接口函数。 + +### 2.3 项目级 lint 增强(把各章规则自动化) + +```js +// eslint 配置片段(示意) +'no-console': ['warn', { allow: ['warn', 'error'] }] // 生产禁 console.log +'no-restricted-imports': // 示例层禁 import 系统层内聚单元 + { patterns: [{ group: ['@/views/system/*'], message: '示例层不得依赖系统层' }] } +vue/name: 'error' // 页面组件必须 defineOptions name(keep-alive 前置) +import/no-cycle: 'error' // 循环依赖 +``` + +--- + +## 三、测试策略 + +### 3.1 管理端测试金字塔(务实版) + +``` + ▲ E2E 冒烟(Playwright) + │ 登录→进列表→CRUD→登出 3-5 条主线,别贪多 + ├─ 组件/页面测试(Vitest + Testing Library) + │ 列表组件三态、表单弹窗校验与提交、菜单失败重试 + ├─ 组合函数测试(重点投入区,性价比最高) + │ usePagedQuery(取消/错序/分页边界) + │ permission 纯函数穷举 + │ 请求层拦截器分支(401刷新/重放/静默) + └─ 纯函数单测 + 格式化、confirm、色阶算法 +``` + +### 3.2 adp 测试范式(直接照抄) + +```ts +// 重依赖 mock + 动态 import(vi.mock 提升到顶部,import 在 test 内) +vi.mock('@/services/user', () => ({ queryCurrentUser: vi.fn() })) + +test('未登录访问受保护页重定向 login 并保留 redirect', async () => { + queryCurrentUser.mockRejectedValueOnce({ /* 401 */ }) + const { getInitialState } = await import('@/app') + const redirect = await getInitialState() + expect(redirect).toContain('/user/login?redirect=') +}) +``` + +关键点:**纯函数穷举测试**(permission/access 这类零成本高回报)、**拦截器分支测试**(请求层是事故高发区,mock adapter 驱动各分支)。 + +### 3.3 覆盖要求 + +- coverage 阈值必须配置(如 lines 70 / statements 70 / functions 60 / branches 70),排除生成代码 +- E2E 至少覆盖:登录主线、一条 CRUD 主线、模块开关生效(若为底座) +- vben 是组件测试深度的标杆:连 Modal 的 keep-alive 场景都有单测 + +--- + +## 四、性能优化清单 + +| 手段 | 做法 | 出处 | +|------|------|------| +| 路由懒加载 | 全部页面 `() => import()`;示例层可构建期 tree-shake | 通行 | +| 首屏占位 | index.html 注入 loading HTML(#root 空时先渲染骨架) | adp | +| 路由预取 | 菜单 hover prefetch / vite `server.warmup` 预热核心目录 | adp/vben/pure | +| 图表懒加载 | Suspense/条件 import 包裹 ECharts 区块 | 通行 | +| chunk 失效恢复 | ChunkLoadError → 提示/自动 reload 一次(发版后旧 hash 404) | adp + 路由 onError | +| 请求防抖取消 | 列表页「取消 + 版本号」(见 03 章) | 本标准 | +| 大日期库治理 | moment→dayjs / 按需 locale | adp | +| WebSocket | 卸载断开 + 重连指数退避 + 心跳 | 监控类页面 | +| 长列表 | 虚拟滚动(日志类页面 >1000 行时) | 按需 | + +**度量先行**:CI 加 `rollup-plugin-visualizer` 产物报告,chunk 超阈值(如 500KB)报警。 + +--- + +## 五、双端一致性规范(迁移期) + +### 5.1 契约清单(迁移开始前统一,不等迁移完成) + +| 项 | 统一值(推荐) | 说明 | +|----|----------------|------| +| token key | `.auth.token` | 迁移期「读旧写新」一个版本 | +| 暗色 key | `.theme.mode` | 同上 | +| 模块开关键 | `.modules..enabled` | 常量两端同名,注释指向后端 API | +| 会话存储结构 | 字段名一致 | localStorage 布局对齐 | +| 响应契约 | 同一份 code/data/msg + 错误分级 | 见 03 章 | +| 权限语义 | 权限码清单同源(单一文档维护) | 见 04 章 | +| 路由路径 | 两端一致 | 互通书签 | +| ESLint / Prettier | 同大版本、同规则 | 配置可复制 | + +**执行方式**:契约清单进 `docs/`(一页表),双端各引用;改任何一项 = 双端 PR 同步。副端纪律:不抢先实现主端没有的功能;mock 显式开关、生产禁用;每对齐一个域删除对应 mock(向真实收敛)。 + +--- + +## 六、CI/CD + +### 6.1 标准流水线(adp 范式裁剪) + +``` +PR 触发: + job lint eslint + prettier --check + vue-tsc/tsc + job test vitest --coverage(阈值卡点) + job build vite build + job e2e 主分支 PR:playwright(webServer 自动拉起前后端) +main 合并: + 构建产物部署(嵌入后端二进制 / 静态托管) +``` + +CI 增强项:madge 循环依赖检查、styles 入口引用检查、产物体积报告。**CI 与本地命令同源**(CI 里跑 make 目标,不另写一套脚本)。 + +--- + +## 七、文档与可观测性 + +### 7.1 文档治理(制度) + +| 文档 | 责任 | +|------|------| +| 新模块指南 | 新模块五列表(菜单/路由/页面/API/权限码)同步 | +| 信息架构文档 | 分组/菜单行为描述与实现同步 | +| 删除性重构 | 过期段落归档,不留「描述已删除组件」的漂移文档 | +| CHANGELOG | 只描述现存功能 | + +**规则**:实现与文档不一致 = 文档 bug,同一 PR 修复。 + +### 7.2 版本可观测 + +- 设置页「关于」展示 `__APP_VERSION__` / commit hash / 构建时间(构建指纹注入) +- 全局错误上报预留钩子(gva `core/error-handel.js` 式),先 console.error 收口,接 Sentry 留接口 +- 可选:页面版本更新提示(pure 的 version-rocket:新版本发布后提醒用户刷新) + +--- + +## 八、最佳实践清单 + +✅ **要做** + +1. pre-commit 快修复 + CI 慢检查的分层流水线 +2. 请求层与权限纯函数优先补测试 +3. 双端 vite 配置结构镜像;契约清单一页表维护 +4. CI 与本地同源(make 目标) +5. 每次构建带版本指纹,关于页可见 + +❌ **不要** + +1. 不要双份 e2e/eslint 配置文件并存(配置漂移源头) +2. 不要 CI 跑本地跑不通的命令(或反之) +3. 不要 console.log 进生产(尤其 token 片段) +4. 不要在 mock 数据里放真实用户名/密钥样式的假数据 +5. 不要为「以后可能用到」保留死配置、死样式、空目录 + +--- + +## 九、AI 检查清单(对比目标工程逐项核对) + +**P0(安全与一致性)** + +1. 生产路径上是否有 console.log(尤其 token 片段打印)? +2. 双端(若有)token/主题 key 是否同名?配置文件是否双份并存? +3. mock 是否显式开关(是否存在网络错误静默回退假数据)? + +**P1(质量基建)** + +4. lint 流水线是否分层(pre-commit 快修 + CI 慢查)?stylelint/commitlint 是否齐配? +5. 请求层与权限纯函数是否有测试?coverage 阈值是否配置? +6. CI 是否有循环依赖检查、产物体积报告? +7. 构建指纹是否注入并在关于页展示? + +**P2(体验与治理)** + +8. 文档漂移清单(描述已不存在实现的文档段落)列出并归档 +9. 运行时配置(VITE_GLOB / platform-config.json)是否值得引入 +10. 错误上报钩子是否预留 + +--- + +> 至此八章完毕。回到 [00-目录与概览](00-目录与概览.md) 复查十大黄金法则,或按各章「AI 检查清单」逐项对比目标工程输出优化任务。 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..399c021 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -19,5 +19,16 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + // 生产禁 console.log(错误收口走 console.error / 上报钩子,见开发标准 08 章) + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, + }, + { + // shadcn/ui 生成组件(buttonVariants 等具名导出是官方模式),放宽 fast-refresh 规则 + files: ['src/components/ui/**'], + rules: { + 'react-refresh/only-export-components': 'off', + }, }, ]) diff --git a/frontend/index.html b/frontend/index.html index d2e66e2..00352bb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,11 +1,11 @@ - + - LokiDeploy + opsx-loki
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9da042c..5b3e8ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { - "name": "my-app", - "version": "0.0.0", + "name": "lokideploy-frontend", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "my-app", - "version": "0.0.0", + "name": "lokideploy-frontend", + "version": "1.0.0", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", @@ -301,7 +301,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2951,18 +2953,26 @@ }, "node_modules/@types/d3-array": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, "node_modules/@types/d3-color": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, "node_modules/@types/d3-ease": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -2970,17 +2980,23 @@ }, "node_modules/@types/d3-path": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", "license": "MIT", "dependencies": { "@types/d3-time": "*" } }, "node_modules/@types/d3-shape": { - "version": "3.1.7", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -2988,10 +3004,14 @@ }, "node_modules/@types/d3-time": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, "node_modules/@types/d3-timer": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, "node_modules/@types/estree": { @@ -3685,6 +3705,8 @@ }, "node_modules/d3-array": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -3695,6 +3717,8 @@ }, "node_modules/d3-color": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", "license": "ISC", "engines": { "node": ">=12" @@ -3702,13 +3726,17 @@ }, "node_modules/d3-ease": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", "license": "BSD-3-Clause", "engines": { "node": ">=12" } }, "node_modules/d3-format": { - "version": "3.1.0", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -3716,6 +3744,8 @@ }, "node_modules/d3-interpolate": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -3726,6 +3756,8 @@ }, "node_modules/d3-path": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", "license": "ISC", "engines": { "node": ">=12" @@ -3733,6 +3765,8 @@ }, "node_modules/d3-scale": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", @@ -3747,6 +3781,8 @@ }, "node_modules/d3-shape": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "license": "ISC", "dependencies": { "d3-path": "^3.1.0" @@ -3757,6 +3793,8 @@ }, "node_modules/d3-time": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", "license": "ISC", "dependencies": { "d3-array": "2 - 3" @@ -3767,6 +3805,8 @@ }, "node_modules/d3-time-format": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "license": "ISC", "dependencies": { "d3-time": "1 - 3" @@ -3777,6 +3817,8 @@ }, "node_modules/d3-timer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", "license": "ISC", "engines": { "node": ">=12" @@ -3812,6 +3854,8 @@ }, "node_modules/decimal.js-light": { "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, "node_modules/deep-is": { @@ -3835,6 +3879,8 @@ }, "node_modules/dom-helpers": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", @@ -4093,6 +4139,8 @@ }, "node_modules/eventemitter3": { "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -4101,7 +4149,9 @@ "license": "MIT" }, "node_modules/fast-equals": { - "version": "5.4.0", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.5.tgz", + "integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -4363,6 +4413,8 @@ }, "node_modules/internmap": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", "license": "ISC", "engines": { "node": ">=12" @@ -4536,7 +4588,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -4546,6 +4600,8 @@ }, "node_modules/loose-envify": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -4951,6 +5007,8 @@ }, "node_modules/prop-types": { "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -4960,6 +5018,8 @@ }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/punycode": { @@ -5041,6 +5101,8 @@ }, "node_modules/react-is": { "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, "node_modules/react-refresh": { @@ -5126,6 +5188,8 @@ }, "node_modules/react-smooth": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", "license": "MIT", "dependencies": { "fast-equals": "^5.0.1", @@ -5159,6 +5223,8 @@ }, "node_modules/react-transition-group": { "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", @@ -5203,6 +5269,9 @@ }, "node_modules/recharts": { "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", "license": "MIT", "dependencies": { "clsx": "^2.0.0", @@ -5224,6 +5293,8 @@ }, "node_modules/recharts-scale": { "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", "license": "MIT", "dependencies": { "decimal.js-light": "^2.4.1" @@ -5508,6 +5579,8 @@ }, "node_modules/tiny-invariant": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, "node_modules/tinyglobby": { @@ -5715,6 +5788,8 @@ }, "node_modules/victory-vendor": { "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", diff --git a/frontend/package.json b/frontend/package.json index 869ab4c..1dbb0a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { - "name": "my-app", + "name": "opsx-loki-frontend", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d2a0ba9..b23182d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,75 +1,58 @@ -import { lazy, Suspense, memo } from 'react' -import { Routes, Route, Navigate } from 'react-router' +import { lazy, Suspense, memo, useEffect } from 'react' +import { Routes, Route, Navigate, useLocation } from 'react-router' import { Toaster } from '@/components/ui/sonner' -import { LoadingSpinner } from '@/components/LoadingSpinner' +import { isLoggedIn, getUser } from '@/lib/auth-storage' +import { routes, findRouteMeta } from '@/router/routes' -// 路由守卫组件 - 独立定义,便于复用 +const Layout = lazy(() => import('@/components/Layout')) + +// 路由守卫 - 未登录跳登录页并携带来源路径(登录成功后回跳,见 lib/redirect.ts) const RequireAuth = memo(function RequireAuth({ children }: { children: React.ReactNode }) { - const token = localStorage.getItem('token') - if (!token) { - return + const location = useLocation() + if (!isLoggedIn()) { + const from = location.pathname + location.search + return } + if (getUser()?.role !== 'admin' && location.pathname !== '/logs') return // 动态导入Layout以避免在登录页面加载它 return {children} }) -// 懒加载页面组件 - 实现代码分割 -const Login = lazy(() => import('@/pages/Login')) -const Dashboard = lazy(() => import('@/pages/Dashboard')) -const Clusters = lazy(() => import('@/pages/Clusters')) -const Hosts = lazy(() => import('@/pages/Hosts')) -const Storage = lazy(() => import('@/pages/Storage')) -const Deployments = lazy(() => import('@/pages/Deployments')) -const Collectors = lazy(() => import('@/pages/Collectors')) -const Settings = lazy(() => import('@/pages/Settings')) -const Layout = lazy(() => import('@/components/Layout')) - -// 懒加载布局包装器 +// 懒加载布局包装器(Layout chunk 加载期间的整屏占位) function LazyLayout({ children }: { children: React.ReactNode }) { return ( - }> + }> {children} ) } -// 页面加载中占位 -function PageLoadingFallback() { +// 整屏加载占位 +function AppLoadingFallback() { return ( -
- +
+
) } -// 路由配置 - 集中管理路由定义 -interface RouteConfig { - path: string - element: React.ReactNode - auth?: boolean +// document.title 由路由 meta 单源驱动 +function useDocumentTitle() { + const location = useLocation() + useEffect(() => { + const title = findRouteMeta(location.pathname)?.title + document.title = title && location.pathname !== '/login' ? `${title} · opsx-loki` : 'opsx-loki' + }, [location.pathname]) } -const routes: RouteConfig[] = [ - { path: '/login', element: , auth: false }, - { path: '/', element: , auth: true }, - { path: '/clusters', element: , auth: true }, - { path: '/hosts', element: , auth: true }, - { path: '/storage', element: , auth: true }, - { path: '/deployments', element: , auth: true }, - { path: '/collectors', element: , auth: true }, - { path: '/settings', element: , auth: true }, -] - /** * App 根组件 - * - * 优化点: - * 1. 所有页面使用 React.lazy 懒加载,实现代码分割 - * 2. 路由守卫独立为 memo 组件 - * 3. Suspense 统一处理加载状态 - * 4. 路由配置集中管理 + * - 路由配置集中在 router/routes.tsx(meta 驱动标题/菜单) + * - 页面全部 React.lazy 懒加载实现代码分割 + * - 页面级 Suspense 在 Layout 的内容区,切换页面时布局不闪 */ const App = memo(function App() { + useDocumentTitle() return ( <> @@ -79,16 +62,15 @@ const App = memo(function App() { path={route.path} element={ route.auth ? ( - {route.element} + {} ) : ( - }> - {route.element} + }> + ) } /> ))} - } /> diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index 9598e03..f137348 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -11,19 +11,16 @@ export interface LoginResponse { } export const authApi = { + // silent:错误密码返回 401 时不触发全局清凭证/跳登录 login: (username: string, password: string) => - http.post('/auth/login', { username, password }), - - register: (username: string, password: string, nickname: string, role?: string) => - http.post('/auth/register', { - username, - password, - nickname, - role: role || 'user', - }), + http.post('/auth/login', { username, password }, { silent: true }), getCurrentUser: () => http.get('/auth/user'), changePassword: (oldPassword: string, newPassword: string) => http.post<{ message: string }>('/auth/password', { oldPassword, newPassword }), + + // 后端探活(登录页检测 API 服务是否可达);short timeout + silent + health: () => + http.get<{ status: string }>('/health', { timeout: 3000, silent: true }), }; diff --git a/frontend/src/api/cluster/index.ts b/frontend/src/api/cluster/index.ts index 766a329..af57b51 100644 --- a/frontend/src/api/cluster/index.ts +++ b/frontend/src/api/cluster/index.ts @@ -1,19 +1,28 @@ // 集群域 API -import { http } from '../http'; +import { http, buildQuery, type ListParams, type RequestOptions } from '../http'; import type { PageData, Cluster, ClusterInfo } from '@/types'; +export interface ClusterCreateInput { + name: string; + description?: string; + configType?: string; + apiServer?: string; + kubeconfig?: string; + token?: string; + context?: string; +} + +export type ClusterUpdateInput = Partial; + export const clusterApi = { - list: (page = 1, pageSize = 10) => - http.get>(`/clusters?page=${page}&pageSize=${pageSize}`), + list: (params: ListParams = {}, options?: RequestOptions) => + http.get>(`/clusters${buildQuery(params)}`, options), get: (id: number) => http.get(`/clusters/${id}`), - create: (data: { - name: string; description?: string; configType?: string; - apiServer?: string; kubeconfig?: string; token?: string; context?: string; - }) => http.post('/clusters', data), + create: (data: ClusterCreateInput) => http.post('/clusters', data), - update: (id: number, data: Record) => + update: (id: number, data: ClusterUpdateInput) => http.put<{ message: string }>(`/clusters/${id}`, data), remove: (id: number) => http.delete<{ message: string }>(`/clusters/${id}`), diff --git a/frontend/src/api/collector/index.ts b/frontend/src/api/collector/index.ts index f125122..1abab56 100644 --- a/frontend/src/api/collector/index.ts +++ b/frontend/src/api/collector/index.ts @@ -1,10 +1,10 @@ // 采集器域 API -import { http } from '../http'; +import { http, buildQuery, type ListParams, type RequestOptions } from '../http'; import type { PageData, Collector, CreateCollectorRequest } from '@/types'; export const collectorApi = { - list: (page = 1, pageSize = 100) => - http.get>(`/collectors?page=${page}&pageSize=${pageSize}`), + list: (params: ListParams = {}, options?: RequestOptions) => + http.get>(`/collectors${buildQuery(params)}`, options), get: (id: number) => http.get(`/collectors/${id}`), diff --git a/frontend/src/api/deployment/index.ts b/frontend/src/api/deployment/index.ts index 09f37cf..3c9f75c 100644 --- a/frontend/src/api/deployment/index.ts +++ b/frontend/src/api/deployment/index.ts @@ -1,5 +1,5 @@ // Loki 部署域 API -import { http } from '../http'; +import { http, buildQuery, type ListParams, type RequestOptions } from '../http'; import type { PageData, LokiDeployment, @@ -8,15 +8,17 @@ import type { CreateDeploymentRequest, } from '@/types'; +export type DeploymentUpdateInput = Partial; + export const deploymentApi = { - list: (page = 1, pageSize = 10) => - http.get>(`/deployments?page=${page}&pageSize=${pageSize}`), + list: (params: ListParams = {}, options?: RequestOptions) => + http.get>(`/deployments${buildQuery(params)}`, options), get: (id: number) => http.get(`/deployments/${id}`), create: (data: CreateDeploymentRequest) => http.post('/deployments', data), - update: (id: number, data: Record) => + update: (id: number, data: DeploymentUpdateInput) => http.put<{ message: string }>(`/deployments/${id}`, data), remove: (id: number) => http.delete<{ message: string }>(`/deployments/${id}`), @@ -27,10 +29,37 @@ export const deploymentApi = { restart: (id: number) => http.post<{ message: string }>(`/deployments/${id}/restart`), - logs: (id: number, page = 1, pageSize = 50) => + logs: (id: number, params: ListParams = {}) => http.get>( - `/deployments/${id}/logs?page=${page}&pageSize=${pageSize}` + `/deployments/${id}/logs${buildQuery({ page: 1, pageSize: 50, ...params })}` ), stats: () => http.get('/deployments/stats'), + + // 部署就绪探测(经自动注册数据源访问 Loki /ready) + ready: (id: number) => http.get<{ ready: boolean; message: string }>(`/deployments/${id}/ready`), + + // 支持的 Loki 版本列表(部署表单版本下拉数据源) + versions: () => http.get<{ versions: string[] }>('/deployments/versions'), + + // 向导预检:聚合存储/集群/主机/Loki/采集器状态(状态推导式向导数据源) + preflight: () => + http.get<{ + hasStorage: boolean + defaultStorage: string + hasActiveCluster: boolean + clusterNames: string[] + hasActiveHost: boolean + hostNames: string[] + runningLoki: null | { + id: number + name: string + mode: string + endpoint: string + namespace: string + grafana: boolean + } + activeCollectors: number + grafanaReady: boolean + }>('/deployments/preflight'), }; diff --git a/frontend/src/api/host/index.ts b/frontend/src/api/host/index.ts index d8f6c11..011f523 100644 --- a/frontend/src/api/host/index.ts +++ b/frontend/src/api/host/index.ts @@ -1,20 +1,32 @@ // 主机域 API -import { http } from '../http'; +import { http, buildQuery, type ListParams, type RequestOptions } from '../http'; import type { PageData, Host, HostInfo } from '@/types'; +export interface HostCreateInput { + name: string; + hostIP: string; + port?: number; + user: string; + authType: 'password' | 'key'; + password?: string; + privateKey?: string; + keyPhrase?: string; + os?: string; + arch?: string; + labels?: string; +} + +export type HostUpdateInput = Partial; + export const hostApi = { - list: (page = 1, pageSize = 10) => - http.get>(`/hosts?page=${page}&pageSize=${pageSize}`), + list: (params: ListParams = {}, options?: RequestOptions) => + http.get>(`/hosts${buildQuery(params)}`, options), get: (id: number) => http.get(`/hosts/${id}`), - create: (data: { - name: string; hostIP: string; port?: number; user: string; - authType: 'password' | 'key'; password?: string; privateKey?: string; - keyPhrase?: string; os?: string; arch?: string; labels?: string; - }) => http.post('/hosts', data), + create: (data: HostCreateInput) => http.post('/hosts', data), - update: (id: number, data: Record) => + update: (id: number, data: HostUpdateInput) => http.put<{ message: string }>(`/hosts/${id}`, data), remove: (id: number) => http.delete<{ message: string }>(`/hosts/${id}`), diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 6867dea..c972f85 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -1,51 +1,108 @@ -// HTTP 请求引擎 — 统一的 fetch 封装,提供 request 与 http.get/post/put/delete 两种用法。 -// 参考 aiops 的 @/utils/request 模式:超时/取消、token 注入、401 自动登出、统一错误处理。 +// HTTP 请求引擎 — 统一 fetch 封装:超时/取消、token 注入、结构化错误、 +// 401 单飞刷新 + 队列重放(见开发标准 03 章)。组件一律通过 api 层调用,禁止直接 fetch。 import type { ApiResponse } from '@/types'; +import { + getAccessToken, + getRefreshToken, + saveTokenPair, + clearAuth, +} from '@/lib/auth-storage'; const API_BASE = import.meta.env.VITE_API_URL || ''; -const DEFAULT_TIMEOUT = 30000; // 30秒默认超时 +const DEFAULT_TIMEOUT = 30000; // 30 秒默认超时 +const REFRESH_TIMEOUT = 10000; // 刷新 token 接口独立超时 -function getToken(): string | null { - return localStorage.getItem('token'); +/** 结构化 API 错误:status 为 HTTP 状态码(网络/超时等非 HTTP 错误为 0),code 为后端业务码 */ +export class ApiError extends Error { + readonly status: number; + readonly code?: number; + + constructor(message: string, status = 0, code?: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + } } -interface RequestOptions extends RequestInit { +export interface RequestOptions extends RequestInit { timeout?: number; - signal?: AbortSignal; + /** 静默模式:401 失效不触发清凭证/跳登录的全局副作用,仅抛错(登录、轮询接口用) */ + silent?: boolean; + /** 内部标记:刷新 token 后的重放请求,防止 401 无限循环 */ + retried?: boolean; } -/** - * 核心请求函数:带超时与取消支持,自动注入 Authorization,统一处理 401。 - */ -export async function request( - url: string, - options: RequestOptions = {} -): Promise { +// —— 401 单飞刷新状态 —— +// 并发 N 个 401 只触发一次刷新;刷新期间的请求进入队列,拿到新 token 后统一重放。 +let isRefreshing = false; +let refreshQueue: Array<(token: string | null) => void> = []; + +// 这些端点自身的 401 不触发登出跳转(错误密码、refresh 失效等场景) +const AUTH_ENDPOINTS = ['/auth/login', '/auth/refresh']; + +async function doRefresh(): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken) return null; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT); + const response = await fetch(`${API_BASE}/api/v1/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + signal: controller.signal, + }); + clearTimeout(timer); + if (!response.ok) return null; + const body = (await response.json()) as ApiResponse<{ + accessToken: string; + refreshToken: string; + expiresAt: number; + }>; + if (body.code !== 0 || !body.data?.accessToken) return null; + saveTokenPair(body.data); + return body.data.accessToken; + } catch { + return null; + } +} + +let redirecting = false; + +/** 清凭证并防抖跳转登录(并发失效只产生一次跳转,携带当前路径供回跳) */ +function redirectToLogin(): void { + clearAuth(); + if (redirecting) return; + redirecting = true; + const current = window.location.pathname + window.location.search; + const target = + current.startsWith('/login') + ? '/login' + : `/login?redirect=${encodeURIComponent(current)}`; + window.location.href = target; +} + +/** 单次 HTTP 往返:组头、超时、取消、解包。401 以 ApiError(status=401) 抛出,由上层统一处理 */ +async function doRequest(url: string, options: RequestOptions): Promise { const { timeout = DEFAULT_TIMEOUT, signal, ...fetchOptions } = options; - const token = getToken(); + const token = getAccessToken(); const headers: Record = { 'Content-Type': 'application/json', ...((fetchOptions.headers as Record) || {}), }; - if (token) { headers['Authorization'] = `Bearer ${token}`; } const fullUrl = `${API_BASE}/api/v1${url}`; - // 超时控制 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); - - // 外部 signal 联动 - const externalSignal = signal; - const onExternalAbort = () => { - controller.abort(); - }; - if (externalSignal) { - externalSignal.addEventListener('abort', onExternalAbort); + const onExternalAbort = () => controller.abort(); + if (signal) { + signal.addEventListener('abort', onExternalAbort); } try { @@ -55,53 +112,109 @@ export async function request( signal: controller.signal, }); - clearTimeout(timeoutId); - if (response.status === 401) { - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); - window.location.href = '/login'; - throw new Error('Unauthorized'); + throw new ApiError('未授权或登录已过期', 401); } const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { const text = await response.text(); - throw new Error( - `Server returned non-JSON (${response.status}): ${text.substring(0, 200)}` + throw new ApiError( + `服务端返回了非 JSON 响应(${response.status}):${text.substring(0, 200)}`, + response.status ); } - const data = (await response.json()) as ApiResponse; - if (data.code !== 0) { - throw new Error(data.message || 'Request failed'); + const body: unknown = await response.json(); + // 多数接口走 {code, data, message} 包装;/health 等基础端点直接返回裸 JSON + if ( + body !== null && + typeof body === 'object' && + 'code' in body && + typeof (body as ApiResponse).code === 'number' + ) { + const data = body as ApiResponse; + if (data.code !== 0) { + throw new ApiError(data.message || '请求失败', response.status, data.code); + } + return data.data; } - return data.data; + return body as T; } catch (err) { - clearTimeout(timeoutId); - - if (err instanceof Error) { - if (err.name === 'AbortError') { - if (controller.signal.aborted && !externalSignal?.aborted) { - throw new Error(`Request timeout after ${timeout}ms`); - } - throw new Error('Request was cancelled'); - } - if (err instanceof TypeError && err.message.includes('fetch')) { - throw new Error( - 'Cannot connect to backend server. Please ensure the API server is running.' - ); + if (err instanceof ApiError) throw err; + if (err instanceof Error && err.name === 'AbortError') { + if (controller.signal.aborted && !signal?.aborted) { + throw new ApiError(`请求超时(${timeout}ms)`); } + throw new ApiError('请求已取消'); } - throw err; + if (err instanceof TypeError) { + throw new ApiError('无法连接到后端服务,请确认 API 服务已启动'); + } + throw err instanceof Error ? new ApiError(err.message) : new ApiError(String(err)); } finally { - if (externalSignal) { - externalSignal.removeEventListener('abort', onExternalAbort); + clearTimeout(timeoutId); + if (signal) { + signal.removeEventListener('abort', onExternalAbort); } } } +/** + * 核心请求函数:在 doRequest 之上处理 401 —— 有 refreshToken 时单飞刷新并重放; + * 刷新失败或无 refreshToken 时清凭证跳登录(silent 请求除外)。 + */ +export async function request( + url: string, + options: RequestOptions = {} +): Promise { + try { + return await doRequest(url, options); + } catch (err) { + if (!(err instanceof ApiError) || err.status !== 401) throw err; + + // 静默请求 / 鉴权端点自身 / 已重放过:不再刷新、不再跳转,直接失败 + if (options.silent || options.retried || AUTH_ENDPOINTS.some((p) => url.startsWith(p))) { + throw err; + } + + if (!getRefreshToken()) { + redirectToLogin(); + throw new ApiError('登录已过期,请重新登录', 401); + } + + // 已有刷新在进行中:挂入队列,等新 token 下发后重放本请求 + if (isRefreshing) { + return new Promise((resolve, reject) => { + refreshQueue.push((token) => { + if (!token) { + reject(new ApiError('登录已过期,请重新登录', 401)); + return; + } + request(url, { ...options, retried: true }).then(resolve, reject); + }); + }); + } + + isRefreshing = true; + let newToken: string | null = null; + try { + newToken = await doRefresh(); + } finally { + isRefreshing = false; + const queue = refreshQueue; + refreshQueue = []; + queue.forEach((cb) => cb(newToken)); + } + + if (!newToken) { + redirectToLogin(); + throw new ApiError('登录已过期,请重新登录', 401); + } + return request(url, { ...options, retried: true }); + } +} + /** * 语义化 HTTP 助手:自动处理 method 与 JSON 序列化,减少调用样板。 * @example @@ -129,57 +242,20 @@ export const http = { request(url, { ...options, method: 'DELETE' }), }; -/** - * 创建可取消的请求辅助函数 - */ -export function createCancelableRequest() { - const controller = new AbortController(); - - return { - request: async (url: string, options: Omit = {}) => { - return request(url, { ...options, signal: controller.signal }); - }, - cancel: () => controller.abort(), - get signal() { - return controller.signal; - }, - }; +/** 列表查询参数(分页) */ +export interface ListParams { + page?: number; + pageSize?: number; } -/** - * 带自动重试的请求函数(不重试 401/取消/超时) - */ -export async function requestWithRetry( - url: string, - options: RequestOptions = {}, - retries = 3, - delay = 1000 -): Promise { - let lastError: Error | unknown; - - for (let i = 0; i < retries; i++) { - try { - return await request(url, options); - } catch (err) { - lastError = err; - - if (err instanceof Error) { - if ( - err.message === 'Unauthorized' || - err.message === 'Request was cancelled' || - err.message.startsWith('Request timeout') - ) { - throw err; - } - } - - if (i === retries - 1) { - throw err; - } - - await new Promise((resolve) => setTimeout(resolve, delay * (i + 1))); +/** 列表查询参数:统一编码拼接,避免模板字符串拼 query 无转义 */ +export function buildQuery(params: ListParams): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== '') { + search.set(key, String(value)); } } - - throw lastError; + const qs = search.toString(); + return qs ? `?${qs}` : ''; } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 66366c1..1cde07e 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -7,3 +7,4 @@ export * from './host'; export * from './storage'; export * from './deployment'; export * from './collector'; +export * from './user'; diff --git a/frontend/src/api/ldap/index.ts b/frontend/src/api/ldap/index.ts new file mode 100644 index 0000000..e090b87 --- /dev/null +++ b/frontend/src/api/ldap/index.ts @@ -0,0 +1,55 @@ +// LDAP 域 API — 配置(密码脱敏)/测试/用户预览/批量导入 +import { http } from '../http'; + +export interface LdapConfig { + enabled: boolean + host: string + port: number + useSSL: boolean + bindDN: string + bindPassword: string // 提交时传空 = 保留已存密码;响应恒为空 + baseDN: string + userFilter: string + usernameAttr: string + nameAttr: string + emailAttr: string + // 仅响应 + hasPassword?: boolean + configured?: boolean +} + +export interface LdapTestResult { + success: boolean + message: string + userInfo?: { dn: string; username: string; displayName: string; email: string } +} + +export interface LdapUserPreview { + dn: string + username: string + displayName: string + email: string +} + +export interface LdapImportResult { + successCount: number + skippedCount: number + updatedCount: number + failedCount: number + failedUsers: Array<{ username: string; error: string }> +} + +export const ldapApi = { + getConfig: () => http.get('/settings/ldap'), + + saveConfig: (data: LdapConfig) => http.put('/settings/ldap', data), + + // 用表单参数测试(未保存也可测;密码空回退已存) + test: (data: LdapConfig & { testUsername?: string; testPassword?: string }) => + http.post('/settings/ldap/test', data), + + listUsers: (ouDN?: string) => http.post('/settings/ldap/users', { ouDN: ouDN || '' }), + + batchImport: (users: Array<{ dn: string; username: string; nickname: string; email: string }>) => + http.post('/users/batch-import-ldap', { users }), +} diff --git a/frontend/src/api/storage/index.ts b/frontend/src/api/storage/index.ts index 97fa55f..d9ec422 100644 --- a/frontend/src/api/storage/index.ts +++ b/frontend/src/api/storage/index.ts @@ -1,24 +1,55 @@ // 存储域 API -import { http } from '../http'; +import { http, buildQuery, type ListParams, type RequestOptions } from '../http'; import type { PageData, StorageConfig } from '@/types'; +export interface StorageCreateInput { + name: string; + storageType: 's3' | 'oss' | 'minio' | 'rustfs'; + endpoint?: string; + bucket: string; + region?: string; + accessKey?: string; + secretKey?: string; + pathPrefix?: string; + insecure?: boolean; + isDefault?: boolean; +} + +export type StorageUpdateInput = Partial; + export const storageApi = { - list: (page = 1, pageSize = 10) => - http.get>(`/storages?page=${page}&pageSize=${pageSize}`), + list: (params: ListParams = {}, options?: RequestOptions) => + http.get>(`/storages${buildQuery(params)}`, options), get: (id: number) => http.get(`/storages/${id}`), - create: (data: { - name: string; storageType: 's3' | 'oss' | 'minio' | 'rustfs'; - endpoint?: string; bucket: string; region?: string; - accessKey?: string; secretKey?: string; pathPrefix?: string; - insecure?: boolean; isDefault?: boolean; - }) => http.post('/storages', data), + create: (data: StorageCreateInput) => http.post('/storages', data), - update: (id: number, data: Record) => + update: (id: number, data: StorageUpdateInput) => http.put<{ message: string }>(`/storages/${id}`, data), remove: (id: number) => http.delete<{ message: string }>(`/storages/${id}`), getDefault: () => http.get('/storages/default'), + + // 测试连接(不落库):可达性/凭证/桶存在性,createIfMissing 时自动建桶 + test: (data: { + storageType: StorageCreateInput['storageType']; + endpoint?: string; + bucket: string; + region?: string; + accessKey?: string; + secretKey?: string; + insecure?: boolean; + createIfMissing?: boolean; + }) => + http.post('/storages/test', data), }; + +export interface StorageTestResult { + reachable: boolean; + authenticated: boolean; + bucketExists: boolean; + bucketCreated: boolean; + message: string; +} diff --git a/frontend/src/api/user/index.ts b/frontend/src/api/user/index.ts new file mode 100644 index 0000000..f34c998 --- /dev/null +++ b/frontend/src/api/user/index.ts @@ -0,0 +1,23 @@ +// 用户管理域 API(全部端点仅 admin 可用,后端 AdminAuth 兜底) +import { http, buildQuery, type ListParams, type RequestOptions } from '../http'; +import type { PageData, User } from '@/types'; + +export interface UserCreateInput { + username: string; + password: string; + nickname?: string; + role?: 'admin' | 'user'; +} + +export const userApi = { + list: (params: ListParams = {}, options?: RequestOptions) => + http.get>(`/users${buildQuery(params)}`, options), + + create: (data: UserCreateInput) => http.post('/users', data), + + remove: (id: number) => http.delete<{ message: string }>(`/users/${id}`), + + // 管理员重置密码(后端会吊销目标用户全部旧会话) + resetPassword: (id: number, newPassword: string) => + http.post<{ message: string }>(`/users/${id}/reset-password`, { newPassword }), +}; diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..e25cfe5 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,71 @@ +import { memo } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' + +interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** 危险操作对象名进文案(如集群名),防误删 */ + name?: string + title?: string + description?: string + confirmText?: string + /** 确认中(提交 loading 态防双击) */ + loading?: boolean + onConfirm: () => void +} + +/** + * 危险操作确认弹窗 — 替代原生 window.confirm()(阻塞主线程且样式与组件体系脱节)。 + * 默认为删除语义(红色确认钮);文案必须带操作对象名。 + */ +export const ConfirmDialog = memo(function ConfirmDialog({ + open, + onOpenChange, + name, + title = '确认删除', + description, + confirmText = '删除', + loading = false, + onConfirm, +}: ConfirmDialogProps) { + const desc = + description ?? + (name + ? `确定要删除「${name}」吗?此操作不可恢复。` + : '确定要执行该操作吗?此操作不可恢复。') + return ( + + + + {title} + {desc} + + + 取消 + { + // 阻止默认关闭,等异步完成由调用方控制 + e.preventDefault() + onConfirm() + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {loading ? '处理中...' : confirmText} + + + + + ) +}) + +export default ConfirmDialog diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx index 5251650..774c927 100644 --- a/frontend/src/components/EmptyState.tsx +++ b/frontend/src/components/EmptyState.tsx @@ -24,12 +24,12 @@ export const EmptyState = memo(function EmptyState({ className, }: EmptyStateProps) { return ( - + - -

{title}

+ +

{title}

{description && ( -

{description}

+

{description}

)} {action &&
{action}
}
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 083b702..5ab5c0b 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -2,49 +2,69 @@ import { Component, type ReactNode } from 'react' interface State { hasError: boolean - error?: Error + message?: string } -// 捕获子树渲染错误,避免整页白屏;显示错误信息便于排查 +// 动态 import / chunk 加载失败特征(发版后旧 hash chunk 404 是最常见场景) +function isChunkLoadError(error: Error): boolean { + const msg = error.message || '' + return ( + msg.includes('Failed to fetch dynamically imported module') || + msg.includes('Importing a module script failed') || + msg.includes('Loading chunk') || + msg.includes('Loading CSS chunk') + ) +} + +const CHUNK_RELOADED_FLAG = 'opsxloki.chunk-reloaded' + +/** + * 全局错误边界 + * - chunk 加载失败自动刷新一次(每个会话最多一次,防循环),二次失败才显示错误卡片 + * - 不向用户暴露原始堆栈,仅显示错误消息摘要 + */ export default class ErrorBoundary extends Component<{ children: ReactNode }, State> { state: State = { hasError: false } static getDerivedStateFromError(error: Error): State { - return { hasError: true, error } + if (isChunkLoadError(error) && !sessionStorage.getItem(CHUNK_RELOADED_FLAG)) { + sessionStorage.setItem(CHUNK_RELOADED_FLAG, '1') + window.location.reload() + } + return { hasError: true, message: error.message } } componentDidCatch(error: Error, info: { componentStack: string }) { + // 错误上报收口:先 console,后续可接 Sentry 等远端上报 console.error('页面渲染错误:', error, info) } handleReset = () => { - this.setState({ hasError: false, error: undefined }) + this.setState({ hasError: false, message: undefined }) } render() { if (this.state.hasError) { return ( -
-
-

页面渲染出错

-

- 请把下方错误信息反馈给开发者,或尝试刷新页面: +

+
+

页面渲染出错

+

+ 页面遇到异常,可尝试刷新;若持续出现请把下方信息反馈给开发者。

-
-              {this.state.error?.message || '未知错误'}
-              {'\n\n'}
-              {this.state.error?.stack}
+            
+              {this.state.message || '未知错误'}
             
diff --git a/frontend/src/components/ErrorState.tsx b/frontend/src/components/ErrorState.tsx new file mode 100644 index 0000000..f6fb2c3 --- /dev/null +++ b/frontend/src/components/ErrorState.tsx @@ -0,0 +1,41 @@ +import { memo } from 'react' +import { AlertCircle, RotateCw } from 'lucide-react' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' + +interface ErrorStateProps { + /** 错误消息(原因必带,用户要知情) */ + message?: string + onRetry?: () => void + className?: string +} + +/** + * 错误占位态 — 与空态(EmptyState)严格区分:加载失败必须可见且可重试, + * 禁止把失败吞掉后渲染成「暂无数据」(数据组件三态之一,见开发标准 07 章)。 + */ +export const ErrorState = memo(function ErrorState({ + message = '加载失败', + onRetry, + className, +}: ErrorStateProps) { + return ( + + + +

{message}

+

请检查网络或后端服务状态后重试

+ {onRetry && ( +
+ +
+ )} +
+
+ ) +}) + +export default ErrorState diff --git a/frontend/src/components/InfoRow.tsx b/frontend/src/components/InfoRow.tsx new file mode 100644 index 0000000..891d337 --- /dev/null +++ b/frontend/src/components/InfoRow.tsx @@ -0,0 +1,27 @@ +import { memo } from 'react' +import { cn } from '@/lib/utils' + +interface InfoRowProps { + label: string + value: string + /** 追加到 value 上的类(如截断宽度) */ + valueClass?: string +} + +/** + * 信息行 — 标签/值 两栏展示。 + * 原先在 Clusters/Hosts/Deployments/Collectors/Storage 五个页面各复制一份, + * 第三次复用时晋升为全局组件(三次法则,见开发标准 02 章)。 + */ +export const InfoRow = memo(function InfoRow({ label, value, valueClass }: InfoRowProps) { + return ( +
+ {label} + + {value} + +
+ ) +}) + +export default InfoRow diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9681f10..74c4bf2 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -3,198 +3,249 @@ import { useState, useEffect, useCallback, - useMemo, + Suspense, } from 'react' import { Link, useLocation, useNavigate } from 'react-router' import { - LayoutDashboard, - Server, - Monitor, - HardDrive, - Rocket, - RadioTower, - Settings, LogOut, PanelLeftClose, PanelLeftOpen, Shield, ChevronRight, Activity, + Menu, + Sun, + Moon, + Monitor, } from 'lucide-react' +import { useTheme } from 'next-themes' import { cn } from '@/lib/utils' import { useIsMobile } from '@/hooks/use-mobile' +import { menuItems, findRouteMeta } from '@/router/routes' +import { getUser, clearAuth } from '@/lib/auth-storage' +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { Skeleton } from '@/components/ui/skeleton' -interface MenuItem { - path: string - icon: typeof LayoutDashboard - label: string +// 侧栏折叠状态持久化 key(命名空间见开发标准 08 章) +const LAYOUT_COLLAPSED_KEY = 'opsxloki.layout.collapsed' + +// 侧栏导航 — 桌面 aside 与移动端抽屉共用;菜单数据来自路由配置单源派生 +function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) { + const location = useLocation() + const isAdmin = getUser()?.role === 'admin' + // adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底 + const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin) + return ( + + ) } -const MENU_ITEMS: MenuItem[] = [ - { path: '/', icon: LayoutDashboard, label: '仪表盘' }, - { path: '/clusters', icon: Server, label: '集群管理' }, - { path: '/hosts', icon: Monitor, label: '主机管理' }, - { path: '/storage', icon: HardDrive, label: '存储配置' }, - { path: '/deployments', icon: Rocket, label: '部署Loki' }, - { path: '/collectors', icon: RadioTower, label: '部署客户端' }, - { path: '/settings', icon: Settings, label: '系统设置' }, -] +// 侧栏 Logo 区 +function SidebarLogo({ collapsed }: { collapsed: boolean }) { + return ( +
+ {collapsed ? ( +
+ +
+ ) : ( +
+
+ +
+ opsx-loki +
+ )} +
+ ) +} + +// 主题三态切换:浅色 → 深色 → 跟随系统(key: opsxloki.theme.mode) +function ThemeToggle() { + const { theme, setTheme } = useTheme() + const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light' + const label = next === 'light' ? '切换为浅色' : next === 'dark' ? '切换为深色' : '切换为跟随系统' + return ( + + ) +} + +// 页面级加载骨架(挂在 main 内,切页时布局不闪) +function PageSkeleton() { + return ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ) +} /** - * Layout 组件(React.memo优化) - * - * 优化点: - * 1. 使用 React.memo 避免不必要的重渲染 - * 2. menuItems 使用 useMemo 缓存 - * 3. handleLogout 使用 useCallback 缓存 - * 4. sidebar 状态使用 useIsMobile 自动适配移动端 - * 5. 导航链接使用 useMemo 缓存避免重复计算 + * Layout 组件 — 布局骨架,业务页只挂在内容区 + * - 菜单/顶栏标题来自路由 meta 单源(router/routes.tsx) + * - 桌面端折叠状态持久化;移动端(<768px)侧栏为抽屉 + 遮罩 + * - 页面懒加载的 Suspense 边界在 main 内部 */ const Layout = memo(function Layout({ children }: { children: React.ReactNode }) { const isMobile = useIsMobile() - const [sidebarOpen, setSidebarOpen] = useState(!isMobile) - const [user, setUser] = useState<{ username: string; role: string } | null>(null) + const [collapsed, setCollapsed] = useState(() => localStorage.getItem(LAYOUT_COLLAPSED_KEY) === '1') + const [mobileOpen, setMobileOpen] = useState(false) const location = useLocation() const navigate = useNavigate() + const user = getUser() - // 当移动端状态变化时,自动调整侧边栏 - useEffect(() => { - setSidebarOpen(!isMobile) - }, [isMobile]) + // 顶栏标题来自路由 meta + const pageTitle = findRouteMeta(location.pathname)?.title || 'opsx-loki' - // 从localStorage加载用户信息 + // 折叠状态持久化(仅桌面端语义) useEffect(() => { - const stored = localStorage.getItem('user') - if (stored) { - try { - setUser(JSON.parse(stored)) - } catch { - // 解析失败静默处理 - } - } + localStorage.setItem(LAYOUT_COLLAPSED_KEY, collapsed ? '1' : '0') + }, [collapsed]) + + const toggleSidebar = useCallback(() => { + setCollapsed((prev) => !prev) }, []) - // 缓存当前页面标题 - const pageTitle = useMemo( - () => MENU_ITEMS.find((item) => item.path === location.pathname)?.label || '仪表盘', - [location.pathname] - ) - - // 缓存登出处理函数 const handleLogout = useCallback(() => { - localStorage.removeItem('token') - localStorage.removeItem('refreshToken') - localStorage.removeItem('user') - navigate('/login') + clearAuth() + navigate('/login', { replace: true }) }, [navigate]) - // 缓存侧边栏切换函数 - const toggleSidebar = useCallback(() => { - setSidebarOpen((prev) => !prev) - }, []) - return ( -
- {/* Sidebar */} - + {/* 移动端抽屉侧栏 */} + {isMobile && ( + + + + opsx-loki 导航 + +
+ + setMobileOpen(false)} /> +
+
+
+ )} {/* Main Content */}
{/* Top Bar */} -
-

- {pageTitle} -

+
+
+ {isMobile && ( + + )} +

+ {pageTitle} +

+
+ {user && (
{user.username} - + {user.role === 'admin' ? '管理员' : user.role}
)}
- {/* Page Content */} -
- {children} + {/* Page Content — 页面级 Suspense 边界,懒加载时不闪布局 */} +
+ }>{children}
diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx index 35ab11b..f572733 100644 --- a/frontend/src/components/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner.tsx @@ -26,9 +26,9 @@ export const LoadingSpinner = memo(function LoadingSpinner({ return (
- {text &&

{text}

} + {text &&

{text}

}
) }) diff --git a/frontend/src/components/PageContainer.tsx b/frontend/src/components/PageContainer.tsx new file mode 100644 index 0000000..8c7f0ad --- /dev/null +++ b/frontend/src/components/PageContainer.tsx @@ -0,0 +1,34 @@ +import { memo } from 'react' +import { cn } from '@/lib/utils' +import { PageHeader } from '@/components/PageHeader' + +interface PageContainerProps { + /** 页头标题;缺省则不渲染页头(如仪表盘总览页) */ + title?: string + description?: string + /** 页头右侧操作区(主按钮等) */ + action?: React.ReactNode + className?: string + children: React.ReactNode +} + +/** + * 页面容器 — 统一页面骨架(页头 + 内容区间距)。 + * 业务页一律以此为容器,不自行手写布局外壳(见开发标准 05 章)。 + */ +export const PageContainer = memo(function PageContainer({ + title, + description, + action, + className, + children, +}: PageContainerProps) { + return ( +
+ {title && } + {children} +
+ ) +}) + +export default PageContainer diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index d985dfd..64bb34c 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -22,11 +22,11 @@ export const PageHeader = memo(function PageHeader({ return (
-

+

{title}

{description && ( -

{description}

+

{description}

)}
{action &&
{action}
} diff --git a/frontend/src/components/PaginationBar.tsx b/frontend/src/components/PaginationBar.tsx new file mode 100644 index 0000000..f18668c --- /dev/null +++ b/frontend/src/components/PaginationBar.tsx @@ -0,0 +1,111 @@ +import { memo, useMemo } from 'react' +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, + PaginationEllipsis, +} from '@/components/ui/pagination' + +interface PaginationBarProps { + page: number + pageSize: number + total: number + onPageChange: (page: number) => void + className?: string +} + +/** 计算要展示的页码(当前页居中,最多 5 个,超界用省略号) */ +function pageNumbers(current: number, totalPages: number): Array { + if (totalPages <= 5) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + if (current <= 3) { + return [1, 2, 3, 4, 'ellipsis-r', totalPages] + } + if (current >= totalPages - 2) { + return [1, 'ellipsis-l', totalPages - 3, totalPages - 2, totalPages - 1, totalPages] + } + return [1, 'ellipsis-l', current - 1, current, current + 1, 'ellipsis-r', totalPages] +} + +/** + * 分页条 — 列表页统一分页 UI,消费后端 PageData.total。 + * (此前各页固定拉 100 条且丢弃 total,超量数据被静默截断。) + */ +export const PaginationBar = memo(function PaginationBar({ + page, + pageSize, + total, + onPageChange, + className, +}: PaginationBarProps) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + const pages = useMemo(() => pageNumbers(page, totalPages), [page, totalPages]) + + if (total <= pageSize) { + return ( +
+

共 {total} 条

+
+ ) + } + + return ( +
+ + + + { + e.preventDefault() + if (page > 1) onPageChange(page - 1) + }} + /> + + {pages.map((p, idx) => + typeof p === 'number' ? ( + + { + e.preventDefault() + onPageChange(p) + }} + > + {p} + + + ) : ( + + + + ) + )} + + = totalPages} + className={page >= totalPages ? 'pointer-events-none opacity-50' : ''} + onClick={(e) => { + e.preventDefault() + if (page < totalPages) onPageChange(page + 1) + }} + /> + + + +

+ 共 {total} 条 · 第 {page}/{totalPages} 页 +

+
+ ) +}) + +export default PaginationBar diff --git a/frontend/src/components/PasswordInput.tsx b/frontend/src/components/PasswordInput.tsx new file mode 100644 index 0000000..a14c994 --- /dev/null +++ b/frontend/src/components/PasswordInput.tsx @@ -0,0 +1,38 @@ +import { memo, useState } from 'react' +import { Eye, EyeOff } from 'lucide-react' +import { Input } from '@/components/ui/input' + +interface PasswordInputProps extends Omit, 'type'> { + /** 明文切换按钮(密钥类字段默认开启) */ + toggleable?: boolean +} + +/** + * 密码/密钥输入框 — 带明文切换。 + * 供 Hosts(SSH 密码/密钥口令)、Storage(secretKey)等敏感字段复用。 + */ +export const PasswordInput = memo(function PasswordInput({ + toggleable = true, + className, + ...props +}: PasswordInputProps) { + const [visible, setVisible] = useState(false) + return ( +
+ + {toggleable && ( + + )} +
+ ) +}) + +export default PasswordInput diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index 55b6389..65c7ec0 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,14 +1,8 @@ import { memo, useMemo } from 'react' -import { - CheckCircle, - AlertTriangle, - Clock, - PauseCircle, - Activity, - type LucideIcon, -} from 'lucide-react' +import { Clock } from 'lucide-react' import { cn } from '@/lib/utils' import { statusLabel } from '@/lib/utils' +import { STATUS_CONFIG } from './status-config' export type StatusSize = 'sm' | 'md' @@ -19,61 +13,11 @@ interface StatusBadgeProps { className?: string } -interface StatusConfig { - icon: LucideIcon - color: string - bg: string - label: string -} - -const STATUS_CONFIG: Record> = { - running: { - icon: CheckCircle, - color: 'text-emerald-500', - bg: 'bg-emerald-100 text-emerald-700', - }, - active: { - icon: CheckCircle, - color: 'text-emerald-500', - bg: 'bg-emerald-100 text-emerald-700', - }, - failed: { - icon: AlertTriangle, - color: 'text-red-500', - bg: 'bg-red-100 text-red-700', - }, - error: { - icon: AlertTriangle, - color: 'text-red-500', - bg: 'bg-red-100 text-red-700', - }, - pending: { - icon: Clock, - color: 'text-amber-500', - bg: 'bg-amber-100 text-amber-700', - }, - stopped: { - icon: PauseCircle, - color: 'text-slate-400', - bg: 'bg-slate-100 text-slate-600', - }, - inactive: { - icon: PauseCircle, - color: 'text-slate-400', - bg: 'bg-slate-100 text-slate-600', - }, - deploying: { - icon: Activity, - color: 'text-blue-500', - bg: 'bg-blue-100 text-blue-700', - }, -} - -function getStatusConfig(status: string): StatusConfig { +function getStatusConfig(status: string): { icon: typeof Clock; color: string; bg: string; label: string } { const config = STATUS_CONFIG[status] || { icon: Clock, - color: 'text-slate-400', - bg: 'bg-slate-100 text-slate-600', + color: 'text-muted-foreground', + bg: 'bg-muted text-muted-foreground', } return { ...config, @@ -144,9 +88,4 @@ export const StatusIcon = memo(function StatusIcon({ ) }) -/** 独立状态颜色工具函数(用于需要className的场景) */ -export function getStatusColorClass(status: string): string { - return STATUS_CONFIG[status]?.bg || 'bg-slate-100 text-slate-600' -} - export default StatusBadge diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 0000000..2347855 --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,14 @@ +// 全局组件出口(barrel)— 页面统一从这里 import 共享组件。 +// 页面私有组件放各自 pages//components/,晋升三次后再进这里。 +export { default as PageContainer } from './PageContainer'; +export { default as PageHeader } from './PageHeader'; +export { default as InfoRow } from './InfoRow'; +export { default as ConfirmDialog } from './ConfirmDialog'; +export { default as ErrorState } from './ErrorState'; +export { default as EmptyState } from './EmptyState'; +export { default as LoadingSpinner, FullScreenLoader } from './LoadingSpinner'; +export { default as PaginationBar } from './PaginationBar'; +export { default as PasswordInput } from './PasswordInput'; +export { default as StatusBadge, StatusIcon } from './StatusBadge'; +export { getStatusColorClass } from './status-config'; +export { default as ErrorBoundary } from './ErrorBoundary'; diff --git a/frontend/src/components/status-config.ts b/frontend/src/components/status-config.ts new file mode 100644 index 0000000..c5a7c3f --- /dev/null +++ b/frontend/src/components/status-config.ts @@ -0,0 +1,64 @@ +import { + CheckCircle, + AlertTriangle, + Clock, + PauseCircle, + Activity, + type LucideIcon, +} from 'lucide-react' + +export interface StatusConfig { + icon: LucideIcon + color: string + bg: string + label: string +} + +// 状态色全部走语义 Token(success/warning/destructive/info/muted),禁止硬编码调色板 +export const STATUS_CONFIG: Record> = { + running: { + icon: CheckCircle, + color: 'text-success', + bg: 'bg-success/10 text-success', + }, + active: { + icon: CheckCircle, + color: 'text-success', + bg: 'bg-success/10 text-success', + }, + failed: { + icon: AlertTriangle, + color: 'text-destructive', + bg: 'bg-destructive/10 text-destructive', + }, + error: { + icon: AlertTriangle, + color: 'text-destructive', + bg: 'bg-destructive/10 text-destructive', + }, + pending: { + icon: Clock, + color: 'text-warning', + bg: 'bg-warning/10 text-warning', + }, + stopped: { + icon: PauseCircle, + color: 'text-muted-foreground', + bg: 'bg-muted text-muted-foreground', + }, + inactive: { + icon: PauseCircle, + color: 'text-muted-foreground', + bg: 'bg-muted text-muted-foreground', + }, + deploying: { + icon: Activity, + color: 'text-info', + bg: 'bg-info/10 text-info', + }, +} + +/** 状态颜色工具(用于需要 className 的场景,与 StatusBadge 同源) */ +export function getStatusColorClass(status: string): string { + return STATUS_CONFIG[status]?.bg || 'bg-muted text-muted-foreground' +} diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx deleted file mode 100644 index d21b65f..0000000 --- a/frontend/src/components/ui/accordion.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import * as React from "react" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDownIcon } from "lucide-react" - -import { cn } from "@/lib/utils" - -function Accordion({ - ...props -}: React.ComponentProps) { - return -} - -function AccordionItem({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AccordionTrigger({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - - ) -} - -function AccordionContent({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - -
{children}
-
- ) -} - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx deleted file mode 100644 index 1421354..0000000 --- a/frontend/src/components/ui/alert.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const alertVariants = cva( - "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", - { - variants: { - variant: { - default: "bg-card text-card-foreground", - destructive: - "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Alert({ - className, - variant, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) -} - -function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertDescription({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { Alert, AlertTitle, AlertDescription } diff --git a/frontend/src/components/ui/aspect-ratio.tsx b/frontend/src/components/ui/aspect-ratio.tsx deleted file mode 100644 index 3df3fd0..0000000 --- a/frontend/src/components/ui/aspect-ratio.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client" - -import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" - -function AspectRatio({ - ...props -}: React.ComponentProps) { - return -} - -export { AspectRatio } diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx deleted file mode 100644 index b7224f0..0000000 --- a/frontend/src/components/ui/avatar.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react" -import * as AvatarPrimitive from "@radix-ui/react-avatar" - -import { cn } from "@/lib/utils" - -function Avatar({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AvatarImage({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AvatarFallback({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { Avatar, AvatarImage, AvatarFallback } diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx deleted file mode 100644 index fd3a406..0000000 --- a/frontend/src/components/ui/badge.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", - secondary: - "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", - destructive: - "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Badge({ - className, - variant, - asChild = false, - ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" - - return ( - - ) -} - -export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx deleted file mode 100644 index eb88f32..0000000 --- a/frontend/src/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { ChevronRight, MoreHorizontal } from "lucide-react" - -import { cn } from "@/lib/utils" - -function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { - return