feat: opsx-loki 全面升级 — 前端重构/用户与LDAP/部署向导/日志查询

基于 WEB-UI-Development-Guide 标准的整体升级,并更名 LokiDeploy → opsx-loki。

前端(Gin+React+shadcn/ui):
- 按 02/07 章重构:页面域化拆分(无超 400 行文件)、PageContainer/ListQuery、
  反馈分级(成功静默/失败 toast/危险确认)、401 单飞刷新+队列重放
- 暗色模式(next-themes)+ 语义 Token(--primary 品牌色/success/warning/info)
- 部署向导(状态推导四步:环境→存储→Loki→采集,内嵌表单闭环)
- 日志查询页:LogQL 标签构建器/拖拽直方图/日志上下文/Live Tail/查询历史
- 按用户+标签策略的查询授权(服务端强制合并策略)

后端(Go+Gin+JSON DB):
- 简单用户管理(仅 admin,无 RBAC)+ TokenVersion 会话吊销 + 登录 IP 限流
- LDAP 接入:配置(AES 加密存储/hasPassword 脱敏)/测试连接/批量导入/登录
- 存储配置测试连接(SigV4 零依赖)+ 桶不存在自动创建
- Loki 三模式部署修复:SSH 密钥认证、3.x 配置模板、ruler 对象存储嵌套块、
  凭证解密、孤儿 deploying 状态恢复、Grafana 部署(可选默认关)+面板导入
- DaemonSet 采集纯 YAML(去 helm 依赖)+ DNS 自愈回退 ClusterIP + 集群名标签
- 日志查询代理:cluster-svc 经 kube-apiserver proxy/url 直连 + cursor 翻页
- 统一 log 命名空间;部署就绪探活;Grafana 访问地址自动记录

修复:run.sh 增量编译检测、Sanitized 脱敏补全、LokiDeployment Update 白名单、
健康检查 CreatedAt 零值等 10+ 存量 bug。

数据兼容:DB 沿用旧 lokideploy.json(新名优先);localStorage 一次性迁移;
HKDF 加密前缀保留(已加密凭证不受影响)。
This commit is contained in:
logan committed 2026-10-24 13:20:12 +08:00
1 parent 327e282356
commit f9c6c1d826
204 files changed
+13985 -8981

No files matched your search

+1 -1
View File
@@ -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
+3
View File
@@ -27,3 +27,6 @@ secrets/
# OS
Thumbs.db
# ZCode 会话目录
.zcode/
+44 -12
View File
@@ -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 <name>`)、react-router 7、react-hook-form + zod、
sonner(通知)、next-themes(暗色)。
- **页面按域组织**(`src/pages/<domain>/`):`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);
+41 -12
View File
@@ -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 <name>`)、react-router 7、react-hook-form + zod、
sonner(通知)、next-themes(暗色)。
- **页面按域组织**(`src/pages/<domain>/`):`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);
+17 -17
View File
@@ -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"]
+10 -10
View File
@@ -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..."
+12 -12
View File
@@ -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
+14 -1
View File
@@ -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
}
+22 -22
View File
@@ -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 {
+4 -4
View File
@@ -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"
)
+4 -4
View File
@@ -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"
)
+36 -4
View File
@@ -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)
+5 -11
View File
@@ -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
+81
View File
@@ -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)
}
+59
View File
@@ -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"})
}
+280
View File
@@ -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)
}
+23 -4
View File
@@ -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)
}
+110
View File
@@ -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)
}
+2
View File
@@ -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"`
}
+34
View File
@@ -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"`
}
+39
View File
@@ -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"`
}
+12
View File
@@ -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"`
}
+13
View File
@@ -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"`
}
+4 -1
View File
@@ -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
+22
View File
@@ -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=
+9 -5
View File
@@ -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) {
+31 -5
View File
@@ -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()
}
}
+84
View File
@@ -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()
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package models
import (
"time"
"lokideploy/pkg/crypto"
"opsxloki/pkg/crypto"
)
type Cluster struct {
+8
View File
@@ -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,
+3 -1
View File
@@ -3,7 +3,7 @@ package models
import (
"time"
"lokideploy/pkg/crypto"
"opsxloki/pkg/crypto"
)
// SSHAuthConfig SSH认证配置(定义在models包避免循环导入)
@@ -31,6 +31,7 @@ type Host struct {
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"`
@@ -47,6 +48,7 @@ func (h *Host) Sanitized() map[string]interface{} {
"os": h.OS,
"arch": h.Arch,
"status": h.Status,
"statusMessage": h.StatusMessage,
"labels": h.Labels,
"createdAt": h.CreatedAt,
"updatedAt": h.UpdatedAt,
+22
View File
@@ -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,
}
}
+56
View File
@@ -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"`
}
+6
View File
@@ -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 {
+5
View File
@@ -2,12 +2,16 @@ 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"`
AuthSource string `json:"authSource"` // local | ldap
TokenVersion int `json:"tokenVersion"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -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,
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"io"
"strings"
"lokideploy/config"
"opsxloki/config"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/hkdf"
+1 -1
View File
@@ -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())
}
+11 -8
View File
@@ -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,7 +38,7 @@ 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()
@@ -46,6 +48,7 @@ func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
Username: username,
Role: role,
TokenType: "access",
TokenVersion: tokenVersion,
RegisteredClaims: jwtv5.RegisteredClaims{
ExpiresAt: jwtv5.NewNumericDate(now.Add(accessTokenExpiry)),
IssuedAt: jwtv5.NewNumericDate(now),
@@ -65,6 +68,7 @@ func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
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) {
+12 -9
View File
@@ -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)
}
}
+96 -3
View File
@@ -8,8 +8,8 @@ import (
"sync"
"time"
"lokideploy/config"
"lokideploy/models"
"opsxloki/config"
"opsxloki/models"
)
// saveRequest 表示一个保存请求,通过channel序列化
@@ -30,6 +30,8 @@ type JsonDB struct {
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
// 防抖保存机制
@@ -60,6 +62,8 @@ func InitDB() error {
Collectors: []models.Collector{},
Logs: []models.DeploymentLog{},
Settings: []models.SystemSetting{},
LogSources: []models.LogSource{},
LogPolicyGroups: []models.LogPolicyGroup{},
sequences: map[string]uint{
"users": 0,
"clusters": 0,
@@ -68,6 +72,7 @@ func InitDB() error {
"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()
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sync"
"testing"
"lokideploy/models"
"opsxloki/models"
)
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
+26
View File
@@ -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)
}
}
+2 -2
View File
@@ -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)
}
+50 -5
View File
@@ -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)
+3 -3
View File
@@ -1,9 +1,9 @@
package seed
import (
"lokideploy/models"
"lokideploy/pkg/crypto"
"lokideploy/repository"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
)
func Seed() {
+184 -9
View File
@@ -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) {
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
}
tokenPair, err := jwt.GenerateTokenPair(db.Users[i].ID, db.Users[i].Username, db.Users[i].Role)
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
}
+4 -4
View File
@@ -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{}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"os/exec"
"strings"
"lokideploy/models"
"opsxloki/models"
)
type ClusterInfo struct {
+9 -5
View File
@@ -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
+100 -30
View File
@@ -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
+182 -5
View File
@@ -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)
+201 -3
View File
@@ -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})
}
}
+438
View File
@@ -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}`
}
+10 -18
View File
@@ -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
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os"
"regexp"
"lokideploy/models"
"opsxloki/models"
)
// resolveKubeconfig 取集群最终的 kubeconfig 文本(优先内存字段,其次磁盘文件)
+330
View File
@@ -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
}
+108
View File
@@ -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
}
+173
View File
@@ -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
}
+780
View File
@@ -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,
}
}
+42
View File
@@ -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")
}
}
+93 -38
View File
@@ -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")
+16 -3
View File
@@ -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{
+4 -4
View File
@@ -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{}
+216
View File
@@ -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 为 "/" 或 "/<bucket>"
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
}
+3
View File
@@ -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("没有操作权限")
+7 -7
View File
@@ -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"
+26 -26
View File
@@ -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:
+15 -15
View File
@@ -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=<value>}"
# 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"
+3 -3
View File
@@ -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}"
+169
View File
@@ -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 / 权限码 / 响应格式,两端同名
(推荐命名空间:<app>.auth.token / <app>.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 仅作功能对照。**
@@ -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、模块开关键、权限码、响应格式——迁移开始前就统一命名(推荐命名空间 `<app>.auth.token` / `<app>.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 字符串即可 `<component :is>` 渲染;插件图标加 `插件名-` 前缀防冲突。适合「菜单图标由后端配置下发」的场景。
- **路由声明模式(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`,用**路由配置数组 + `<Route>` 声明式**或 `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<API.RuleList>(...)` 收口
- 页面私有类型随页面 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) —— 目录是架构的最后陈述。
@@ -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/<mod>/ # 每模块 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/<name>/ # 业务插件 = 自带 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 ...
│ └── <domain>/ # 业务域
├── views/ # ★ 域目录与 api/ 一一对应
│ ├── user/ # Users.vue + components/XxxFormDialog.vue ...
│ ├── <domain>/
│ └── _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/<domain>/<Page>.vue # 页面骨架:容器 + 列表组件 + 子组件编排(≤400 行)
views/<domain>/components/ # 本页私有组件(表单弹窗、详情抽屉、单元格渲染器)
views/<domain>/composables/ # 本页私有逻辑(可选,超过 2 个页面组件共享时建)
views/<domain>/utils/ # 本页 hook/rule/types(pure 范式,可选)
views/<domain>/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) —— 前端工程的血管系统。
@@ -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<ResponseData, ApiData> {
transform: (res) => ApiData // 解包
onRequest: (config) => config // 注入 token
isBackendSuccess: (res) => boolean // 业务 code 判定
onBackendFail: (res, instance) => Promise<AxiosResponse | null> // 失败处理(可重发)
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 | 两端同名(推荐 `<app>.auth.token`;迁移期「读旧写新」一个版本) |
| refresh 机制 | 两端同构(单飞 + 队列重放),副端不允许退化成「401 直接跳登录」 |
| 响应契约 | 同一份 code/data/msg 约定与错误分级 |
| mock | **显式模式**(dev 开关/页面级声明),**禁止**运行时因网络错误静默回退假数据(adp:mock 是构建模式不是运行时兜底) |
---
## 八、最佳实践清单
✅ **要做**
1. 组件永远 import `api/<域>` 函数,不直接碰 axios 实例(**禁止** store 绕过 API 层裸调)
2. 每个请求函数带注释(用途/参数/返回)+ 类型(`request<Api.Xxx>` 或 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 是路由的灵魂。
@@ -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 渲染)结构正确即可
- 路由渲染**必须**声明式(配置数组 + `<Route>`),权限字段语义与 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()` |
| 组件 `<AccessControl>` | 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) —— 布局是业务的舞台,不是业务本身。
@@ -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) │ <router-view> │
│ │ 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 中 <keep-alive :include="cachedNames">
→ 刷新: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) —— 换肤只改变量,不引第二套样式。
@@ -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 **必须**两端同名(推荐 `<app>.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) —— 组件是设计的词汇表。
@@ -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/<domain>/components/ ─────────────┤
│ XxxFormDialog 表单弹窗 │
│ XxxDetail 详情抽屉 │
│ 单元格渲染器(状态 tag、操作按钮组) │
├─ 页面骨架 views/<domain>/<Page>.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 —— 调用方零状态
<UpdateForm
trigger={<Button type="link">编辑</Button>} // 传入触发器
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 标准列表组件协议(强制走它)
```
<ListPage
:fetcher="fetchUsers" ← 函数:params → {results, count}
v-model:loading ← 内部管理
#filters(筛选 slot:关键字/状态/日期)
#default(el-table 列定义)
/>
内部:「取消 + 版本号」双重防护(见 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. 不要在页面里写第二个 `<el-table>`(复用列配置或拆子组件)
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) —— 让规范自动执行。
@@ -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 | `<app>.auth.token` | 迁移期「读旧写新」一个版本 |
| 暗色 key | `<app>.theme.mode` | 同上 |
| 模块开关键 | `<app>.modules.<name>.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 检查清单」逐项对比目标工程输出优化任务。
+11
View File
@@ -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',
},
},
])
+2 -2
View File
@@ -1,11 +1,11 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN" suppressHydrationWarning>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" href="/favicon.ico" />
<title>LokiDeploy</title>
<title>opsx-loki</title>
</head>
<body>
<div id="root"></div>
+84 -9
View File
@@ -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",
+2 -2
View File
@@ -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",
-42
View File
@@ -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;
}
+32 -50
View File
@@ -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 <Navigate to="/login" replace />
const location = useLocation()
if (!isLoggedIn()) {
const from = location.pathname + location.search
return <Navigate to={`/login?redirect=${encodeURIComponent(from)}`} replace />
}
if (getUser()?.role !== 'admin' && location.pathname !== '/logs') return <Navigate to="/logs" replace />
// 动态导入Layout以避免在登录页面加载它
return <LazyLayout>{children}</LazyLayout>
})
// 懒加载页面组件 - 实现代码分割
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 (
<Suspense fallback={<PageLoadingFallback />}>
<Suspense fallback={<AppLoadingFallback />}>
<Layout>{children}</Layout>
</Suspense>
)
}
// 页面加载中占位
function PageLoadingFallback() {
// 整屏加载占位
function AppLoadingFallback() {
return (
<div className="h-screen w-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<LoadingSpinner size="lg" text="页面加载中..." />
<div className="h-screen w-screen flex items-center justify-center bg-muted dark:bg-slate-950">
<div className="w-6 h-6 border-2 border-slate-300 border-t-primary rounded-full animate-spin" />
</div>
)
}
// 路由配置 - 集中管理路由定义
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: <Login />, auth: false },
{ path: '/', element: <Dashboard />, auth: true },
{ path: '/clusters', element: <Clusters />, auth: true },
{ path: '/hosts', element: <Hosts />, auth: true },
{ path: '/storage', element: <Storage />, auth: true },
{ path: '/deployments', element: <Deployments />, auth: true },
{ path: '/collectors', element: <Collectors />, auth: true },
{ path: '/settings', element: <Settings />, 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 (
<>
<Routes>
@@ -79,16 +62,15 @@ const App = memo(function App() {
path={route.path}
element={
route.auth ? (
<RequireAuth>{route.element}</RequireAuth>
<RequireAuth>{<route.component />}</RequireAuth>
) : (
<Suspense fallback={<PageLoadingFallback />}>
{route.element}
<Suspense fallback={<AppLoadingFallback />}>
<route.component />
</Suspense>
)
}
/>
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster />
</>
+6 -9
View File
@@ -11,19 +11,16 @@ export interface LoginResponse {
}
export const authApi = {
// silent:错误密码返回 401 时不触发全局清凭证/跳登录
login: (username: string, password: string) =>
http.post<LoginResponse>('/auth/login', { username, password }),
register: (username: string, password: string, nickname: string, role?: string) =>
http.post<User>('/auth/register', {
username,
password,
nickname,
role: role || 'user',
}),
http.post<LoginResponse>('/auth/login', { username, password }, { silent: true }),
getCurrentUser: () => http.get<User>('/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 }),
};
+17 -8
View File
@@ -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<ClusterCreateInput>;
export const clusterApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<Cluster>>(`/clusters?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<Cluster>>(`/clusters${buildQuery(params)}`, options),
get: (id: number) => http.get<Cluster>(`/clusters/${id}`),
create: (data: {
name: string; description?: string; configType?: string;
apiServer?: string; kubeconfig?: string; token?: string; context?: string;
}) => http.post<Cluster>('/clusters', data),
create: (data: ClusterCreateInput) => http.post<Cluster>('/clusters', data),
update: (id: number, data: Record<string, unknown>) =>
update: (id: number, data: ClusterUpdateInput) =>
http.put<{ message: string }>(`/clusters/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/clusters/${id}`),
+3 -3
View File
@@ -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<PageData<Collector>>(`/collectors?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<Collector>>(`/collectors${buildQuery(params)}`, options),
get: (id: number) => http.get<Collector>(`/collectors/${id}`),
+35 -6
View File
@@ -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<CreateDeploymentRequest>;
export const deploymentApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<LokiDeployment>>(`/deployments?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<LokiDeployment>>(`/deployments${buildQuery(params)}`, options),
get: (id: number) => http.get<LokiDeployment>(`/deployments/${id}`),
create: (data: CreateDeploymentRequest) => http.post<LokiDeployment>('/deployments', data),
update: (id: number, data: Record<string, unknown>) =>
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<PageData<DeploymentLog>>(
`/deployments/${id}/logs?page=${page}&pageSize=${pageSize}`
`/deployments/${id}/logs${buildQuery({ page: 1, pageSize: 50, ...params })}`
),
stats: () => http.get<DashboardStats>('/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'),
};
+21 -9
View File
@@ -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<HostCreateInput>;
export const hostApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<Host>>(`/hosts?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<Host>>(`/hosts${buildQuery(params)}`, options),
get: (id: number) => http.get<Host>(`/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<Host>('/hosts', data),
create: (data: HostCreateInput) => http.post<Host>('/hosts', data),
update: (id: number, data: Record<string, unknown>) =>
update: (id: number, data: HostUpdateInput) =>
http.put<{ message: string }>(`/hosts/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/hosts/${id}`),
+175 -99
View File
@@ -1,51 +1,108 @@
// HTTP 请求引擎 — 统一的 fetch 封装,提供 request<T> 与 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 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<T>(
url: string,
options: RequestOptions = {}
): Promise<T> {
// —— 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<string | null> {
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<T>(url: string, options: RequestOptions): Promise<T> {
const { timeout = DEFAULT_TIMEOUT, signal, ...fetchOptions } = options;
const token = getToken();
const token = getAccessToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((fetchOptions.headers as Record<string, string>) || {}),
};
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<T>(
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<T>;
const body: unknown = await response.json();
// 多数接口走 {code, data, message} 包装;/health 等基础端点直接返回裸 JSON
if (
body !== null &&
typeof body === 'object' &&
'code' in body &&
typeof (body as ApiResponse<T>).code === 'number'
) {
const data = body as ApiResponse<T>;
if (data.code !== 0) {
throw new Error(data.message || 'Request failed');
throw new ApiError(data.message || '请求失败', response.status, data.code);
}
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`);
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 Error('Request was cancelled');
throw new ApiError('请求已取消');
}
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 TypeError) {
throw new ApiError('无法连接到后端服务,请确认 API 服务已启动');
}
}
throw err;
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<T>(
url: string,
options: RequestOptions = {}
): Promise<T> {
try {
return await doRequest<T>(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<T>((resolve, reject) => {
refreshQueue.push((token) => {
if (!token) {
reject(new ApiError('登录已过期,请重新登录', 401));
return;
}
request<T>(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<T>(url, { ...options, retried: true });
}
}
/**
* 语义化 HTTP 助手:自动处理 method 与 JSON 序列化,减少调用样板。
* @example
@@ -129,57 +242,20 @@ export const http = {
request<T>(url, { ...options, method: 'DELETE' }),
};
/**
* 创建可取消的请求辅助函数
*/
export function createCancelableRequest() {
const controller = new AbortController();
return {
request: async <T>(url: string, options: Omit<RequestOptions, 'signal'> = {}) => {
return request<T>(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<T>(
url: string,
options: RequestOptions = {},
retries = 3,
delay = 1000
): Promise<T> {
let lastError: Error | unknown;
for (let i = 0; i < retries; i++) {
try {
return await request<T>(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;
/** 列表查询参数:统一编码拼接,避免模板字符串拼 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));
}
}
if (i === retries - 1) {
throw err;
}
await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)));
}
}
throw lastError;
const qs = search.toString();
return qs ? `?${qs}` : '';
}
+1
View File
@@ -7,3 +7,4 @@ export * from './host';
export * from './storage';
export * from './deployment';
export * from './collector';
export * from './user';
+55
View File
@@ -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<LdapConfig>('/settings/ldap'),
saveConfig: (data: LdapConfig) => http.put<LdapConfig>('/settings/ldap', data),
// 用表单参数测试(未保存也可测;密码空回退已存)
test: (data: LdapConfig & { testUsername?: string; testPassword?: string }) =>
http.post<LdapTestResult>('/settings/ldap/test', data),
listUsers: (ouDN?: string) => http.post<LdapUserPreview[]>('/settings/ldap/users', { ouDN: ouDN || '' }),
batchImport: (users: Array<{ dn: string; username: string; nickname: string; email: string }>) =>
http.post<LdapImportResult>('/users/batch-import-ldap', { users }),
}
+41 -10
View File
@@ -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<StorageCreateInput>;
export const storageApi = {
list: (page = 1, pageSize = 10) =>
http.get<PageData<StorageConfig>>(`/storages?page=${page}&pageSize=${pageSize}`),
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<StorageConfig>>(`/storages${buildQuery(params)}`, options),
get: (id: number) => http.get<StorageConfig>(`/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<StorageConfig>('/storages', data),
create: (data: StorageCreateInput) => http.post<StorageConfig>('/storages', data),
update: (id: number, data: Record<string, unknown>) =>
update: (id: number, data: StorageUpdateInput) =>
http.put<{ message: string }>(`/storages/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/storages/${id}`),
getDefault: () => http.get<StorageConfig>('/storages/default'),
// 测试连接(不落库):可达性/凭证/桶存在性,createIfMissing 时自动建桶
test: (data: {
storageType: StorageCreateInput['storageType'];
endpoint?: string;
bucket: string;
region?: string;
accessKey?: string;
secretKey?: string;
insecure?: boolean;
createIfMissing?: boolean;
}) =>
http.post<StorageTestResult>('/storages/test', data),
};
export interface StorageTestResult {
reachable: boolean;
authenticated: boolean;
bucketExists: boolean;
bucketCreated: boolean;
message: string;
}
+23
View File
@@ -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<PageData<User>>(`/users${buildQuery(params)}`, options),
create: (data: UserCreateInput) => http.post<User>('/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 }),
};
+71
View File
@@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{desc}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
<AlertDialogAction
disabled={loading}
onClick={(e) => {
// 阻止默认关闭,等异步完成由调用方控制
e.preventDefault()
onConfirm()
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading ? '处理中...' : confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
})
export default ConfirmDialog
+4 -4
View File
@@ -24,12 +24,12 @@ export const EmptyState = memo(function EmptyState({
className,
}: EmptyStateProps) {
return (
<Card className={cn('border-dashed border-slate-300 dark:border-slate-700', className)}>
<Card className={cn('border-dashed border-border', className)}>
<CardContent className="p-12 text-center">
<Icon className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500 font-medium">{title}</p>
<Icon className="w-12 h-12 text-muted-foreground/60 mx-auto mb-4" />
<p className="text-muted-foreground font-medium">{title}</p>
{description && (
<p className="text-sm text-slate-400 mt-1">{description}</p>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</CardContent>
+35 -15
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-4">
<div className="max-w-lg w-full bg-white rounded-2xl shadow-lg border border-red-200 p-6">
<h2 className="text-lg font-semibold text-red-600 mb-2">页面渲染出错</h2>
<p className="text-sm text-slate-600 mb-3">
请把下方错误信息反馈给开发者,或尝试刷新页面:
<div className="min-h-screen flex items-center justify-center bg-muted dark:bg-slate-950 p-4">
<div className="max-w-lg w-full bg-card rounded-2xl shadow-lg border border-destructive/30 p-6">
<h2 className="text-lg font-semibold text-destructive mb-2">页面渲染出错</h2>
<p className="text-sm text-muted-foreground mb-3">
页面遇到异常,可尝试刷新;若持续出现请把下方信息反馈给开发者。
</p>
<pre className="text-xs bg-slate-900 text-red-300 p-3 rounded-lg overflow-auto max-h-60 font-mono whitespace-pre-wrap">
{this.state.error?.message || '未知错误'}
{'\n\n'}
{this.state.error?.stack}
<pre className="text-xs bg-muted font-mono text-destructive p-3 rounded-lg overflow-auto max-h-40 font-mono whitespace-pre-wrap">
{this.state.message || '未知错误'}
</pre>
<div className="flex gap-2 mt-4">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm hover:bg-emerald-700"
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90"
>
刷新页面
</button>
<button
onClick={this.handleReset}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg text-sm hover:bg-slate-200"
className="px-4 py-2 bg-muted text-muted-foreground rounded-lg text-sm hover:bg-muted"
>
重置组件
</button>
+41
View File
@@ -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 (
<Card className="border-destructive/30 border-dashed">
<CardContent className={className ?? 'p-12 text-center'}>
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
<p className="text-muted-foreground font-medium">{message}</p>
<p className="text-sm text-muted-foreground mt-1">请检查网络或后端服务状态后重试</p>
{onRetry && (
<div className="mt-4">
<Button variant="outline" size="sm" onClick={onRetry}>
<RotateCw className="w-3.5 h-3.5 mr-1" />
重试
</Button>
</div>
)}
</CardContent>
</Card>
)
})
export default ErrorState
+27
View File
@@ -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 (
<div className="flex justify-between">
<span>{label}</span>
<span className={cn('font-medium text-foreground', valueClass)}>
{value}
</span>
</div>
)
})
export default InfoRow
+173 -122
View File
@@ -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'
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: '系统设置' },
]
/**
* Layout 组件(React.memo优化)
*
* 优化点:
* 1. 使用 React.memo 避免不必要的重渲染
* 2. menuItems 使用 useMemo 缓存
* 3. handleLogout 使用 useCallback 缓存
* 4. sidebar 状态使用 useIsMobile 自动适配移动端
* 5. 导航链接使用 useMemo 缓存避免重复计算
*/
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)
// 侧栏导航 — 桌面 aside 与移动端抽屉共用;菜单数据来自路由配置单源派生
function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
const location = useLocation()
const navigate = useNavigate()
// 当移动端状态变化时,自动调整侧边栏
useEffect(() => {
setSidebarOpen(!isMobile)
}, [isMobile])
// 从localStorage加载用户信息
useEffect(() => {
const stored = localStorage.getItem('user')
if (stored) {
try {
setUser(JSON.parse(stored))
} catch {
// 解析失败静默处理
}
}
}, [])
// 缓存当前页面标题
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')
}, [navigate])
// 缓存侧边栏切换函数
const toggleSidebar = useCallback(() => {
setSidebarOpen((prev) => !prev)
}, [])
const isAdmin = getUser()?.role === 'admin'
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
return (
<div className="flex h-screen bg-slate-50 dark:bg-slate-950">
{/* Sidebar */}
<aside
className={cn(
'bg-slate-900 text-white transition-all duration-300 flex flex-col flex-shrink-0',
sidebarOpen ? 'w-64' : 'w-16'
)}
>
{/* Logo */}
<div className="h-16 flex items-center px-4 border-b border-slate-700 flex-shrink-0">
{sidebarOpen ? (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-emerald-500 flex items-center justify-center">
<Activity className="w-5 h-5 text-white" />
</div>
<span className="font-bold text-lg tracking-tight">LokiDeploy</span>
</div>
) : (
<div className="w-8 h-8 rounded-lg bg-emerald-500 flex items-center justify-center mx-auto">
<Activity className="w-5 h-5 text-white" />
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto">
{MENU_ITEMS.map((item) => {
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto" aria-label="主导航">
{visibleItems.map((item) => {
const isActive = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
onClick={onNavigate}
aria-label={item.label}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all',
isActive
? 'bg-emerald-600 text-white'
? 'bg-primary text-primary-foreground'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
)}
title={!sidebarOpen ? item.label : undefined}
title={!collapsed ? item.label : undefined}
>
<item.icon className="w-5 h-5 flex-shrink-0" />
{sidebarOpen && (
<span className="text-sm font-medium">{item.label}</span>
)}
{sidebarOpen && isActive && (
{item.icon && <item.icon className="w-5 h-5 flex-shrink-0" />}
{!collapsed && <span className="text-sm font-medium">{item.label}</span>}
{!collapsed && isActive && (
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
)}
</Link>
)
})}
</nav>
)
}
{/* Bottom Actions */}
// 侧栏 Logo 区
function SidebarLogo({ collapsed }: { collapsed: boolean }) {
return (
<div className="h-16 flex items-center px-4 border-b border-slate-700 flex-shrink-0">
{collapsed ? (
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center mx-auto">
<Activity className="w-5 h-5 text-white" />
</div>
) : (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Activity className="w-5 h-5 text-white" />
</div>
<span className="font-bold text-lg tracking-tight">opsx-loki</span>
</div>
)}
</div>
)
}
// 主题三态切换:浅色 → 深色 → 跟随系统(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 (
<button
onClick={() => setTheme(next)}
aria-label={label}
title={label}
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
>
{theme === 'light' ? (
<Sun className="w-4 h-4" />
) : theme === 'dark' ? (
<Moon className="w-4 h-4" />
) : (
<Monitor className="w-4 h-4" />
)}
</button>
)
}
// 页面级加载骨架(挂在 main 内,切页时布局不闪)
function PageSkeleton() {
return (
<div className="space-y-6" aria-busy="true" aria-label="页面加载中">
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-40 rounded-xl" />
))}
</div>
</div>
)
}
/**
* Layout 组件 — 布局骨架,业务页只挂在内容区
* - 菜单/顶栏标题来自路由 meta 单源(router/routes.tsx)
* - 桌面端折叠状态持久化;移动端(<768px)侧栏为抽屉 + 遮罩
* - 页面懒加载的 Suspense 边界在 main 内部
*/
const Layout = memo(function Layout({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile()
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(LAYOUT_COLLAPSED_KEY) === '1')
const [mobileOpen, setMobileOpen] = useState(false)
const location = useLocation()
const navigate = useNavigate()
const user = getUser()
// 顶栏标题来自路由 meta
const pageTitle = findRouteMeta(location.pathname)?.title || 'opsx-loki'
// 折叠状态持久化(仅桌面端语义)
useEffect(() => {
localStorage.setItem(LAYOUT_COLLAPSED_KEY, collapsed ? '1' : '0')
}, [collapsed])
const toggleSidebar = useCallback(() => {
setCollapsed((prev) => !prev)
}, [])
const handleLogout = useCallback(() => {
clearAuth()
navigate('/login', { replace: true })
}, [navigate])
return (
<div className="flex h-screen bg-background">
{/* 桌面侧栏 */}
{!isMobile && (
<aside
className={cn(
'bg-slate-900 text-white transition-all duration-300 flex flex-col flex-shrink-0',
collapsed ? 'w-16' : 'w-64'
)}
>
<SidebarLogo collapsed={collapsed} />
<SidebarNav collapsed={collapsed} />
<div className="p-2 border-t border-slate-700 flex-shrink-0">
<button
onClick={toggleSidebar}
title={sidebarOpen ? '收起菜单' : '展开菜单'}
aria-label={collapsed ? '展开菜单' : '收起菜单'}
title={collapsed ? '展开菜单' : '收起菜单'}
className="flex items-center justify-center w-full py-2.5 rounded-lg text-slate-400 hover:bg-slate-800 hover:text-white transition-all"
>
{sidebarOpen ? (
<PanelLeftClose className="w-5 h-5" />
) : (
{collapsed ? (
<PanelLeftOpen className="w-5 h-5" />
) : (
<PanelLeftClose className="w-5 h-5" />
)}
</button>
</div>
</aside>
)}
{/* 移动端抽屉侧栏 */}
{isMobile && (
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="bg-slate-900 text-white border-none p-0 w-72">
<SheetHeader className="sr-only">
<SheetTitle className="text-white">opsx-loki 导航</SheetTitle>
</SheetHeader>
<div className="flex flex-col h-full">
<SidebarLogo collapsed={false} />
<SidebarNav collapsed={false} onNavigate={() => setMobileOpen(false)} />
</div>
</SheetContent>
</Sheet>
)}
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Top Bar */}
<header className="h-16 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 flex-shrink-0">
<h1 className="text-xl font-semibold text-slate-800 dark:text-white truncate">
<header className="h-16 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-4 md:px-6 flex-shrink-0 gap-3">
<div className="flex items-center gap-3 min-w-0">
{isMobile && (
<button
onClick={() => setMobileOpen(true)}
aria-label="打开导航菜单"
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Menu className="w-5 h-5" />
</button>
)}
<h1 className="text-lg md:text-xl font-semibold text-slate-800 dark:text-white truncate">
{pageTitle}
</h1>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<ThemeToggle />
{user && (
<div className="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>{user.username}</span>
<span className="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-medium">
<span className="px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium">
{user.role === 'admin' ? '管理员' : user.role}
</span>
</div>
<button
onClick={handleLogout}
title="退出登录"
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-slate-500 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/30 transition-all"
aria-label="退出登录"
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-all"
>
<LogOut className="w-4 h-4" />
<span>退出</span>
<span className="hidden md:inline">退出</span>
</button>
</div>
)}
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-auto p-6">
{children}
{/* Page Content — 页面级 Suspense 边界,懒加载时不闪布局 */}
<main className="flex-1 overflow-auto p-4 md:p-6">
<Suspense fallback={<PageSkeleton />}>{children}</Suspense>
</main>
</div>
</div>
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more