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:
1 parent
327e282356
commit
f9c6c1d826
204 files changed
+14106
-9102
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -27,3 +27,6 @@ secrets/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
|
||||
# ZCode 会话目录
|
||||
.zcode/
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package models
|
||||
import (
|
||||
"time"
|
||||
|
||||
"lokideploy/pkg/crypto"
|
||||
"opsxloki/pkg/crypto"
|
||||
)
|
||||
|
||||
type Cluster struct {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+30
-28
@@ -3,7 +3,7 @@ package models
|
||||
import (
|
||||
"time"
|
||||
|
||||
"lokideploy/pkg/crypto"
|
||||
"opsxloki/pkg/crypto"
|
||||
)
|
||||
|
||||
// SSHAuthConfig SSH认证配置(定义在models包避免循环导入)
|
||||
@@ -19,37 +19,39 @@ type SSHAuthConfig struct {
|
||||
// Host 主机模型,敏感字段(Password/PrivateKey/KeyPhrase)加密存储
|
||||
// 加密格式: ENC:<base64(AES-GCM(ciphertext))>
|
||||
type Host struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HostIP string `json:"hostIP"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
AuthType string `json:"authType"`
|
||||
Password string `json:"password"` // 加密存储(ENC:前缀)
|
||||
PrivateKey string `json:"privateKey"` // 加密存储(ENC:前缀)
|
||||
KeyPhrase string `json:"keyPhrase"` // 加密存储(ENC:前缀)
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Status string `json:"status"`
|
||||
Labels string `json:"labels"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HostIP string `json:"hostIP"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
AuthType string `json:"authType"`
|
||||
Password string `json:"password"` // 加密存储(ENC:前缀)
|
||||
PrivateKey string `json:"privateKey"` // 加密存储(ENC:前缀)
|
||||
KeyPhrase string `json:"keyPhrase"` // 加密存储(ENC:前缀)
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Status string `json:"status"`
|
||||
StatusMessage string `json:"statusMessage,omitempty"`
|
||||
Labels string `json:"labels"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (h *Host) Sanitized() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": h.ID,
|
||||
"name": h.Name,
|
||||
"hostIP": h.HostIP,
|
||||
"port": h.Port,
|
||||
"user": h.User,
|
||||
"authType": h.AuthType,
|
||||
"os": h.OS,
|
||||
"arch": h.Arch,
|
||||
"status": h.Status,
|
||||
"labels": h.Labels,
|
||||
"createdAt": h.CreatedAt,
|
||||
"updatedAt": h.UpdatedAt,
|
||||
"id": h.ID,
|
||||
"name": h.Name,
|
||||
"hostIP": h.HostIP,
|
||||
"port": h.Port,
|
||||
"user": h.User,
|
||||
"authType": h.AuthType,
|
||||
"os": h.OS,
|
||||
"arch": h.Arch,
|
||||
"status": h.Status,
|
||||
"statusMessage": h.StatusMessage,
|
||||
"labels": h.Labels,
|
||||
"createdAt": h.CreatedAt,
|
||||
"updatedAt": h.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+12
-7
@@ -2,14 +2,18 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// User TokenVersion 用于服务端会话吊销:改密/管理员重置密码时递增,
|
||||
// 旧 token(access 与 refresh)随版本不匹配立即失效(借鉴 opsx-web-lite)。
|
||||
type User struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Nickname string `json:"nickname"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Nickname string `json:"nickname"`
|
||||
Role string `json:"role"`
|
||||
AuthSource string `json:"authSource"` // local | ldap
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (u *User) Sanitized() map[string]interface{} {
|
||||
@@ -18,6 +22,7 @@ func (u *User) Sanitized() map[string]interface{} {
|
||||
"username": u.Username,
|
||||
"nickname": u.Nickname,
|
||||
"role": u.Role,
|
||||
"authSource": u.AuthSource,
|
||||
"createdAt": u.CreatedAt,
|
||||
"updatedAt": u.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"lokideploy/config"
|
||||
"opsxloki/config"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
+19
-16
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"lokideploy/config"
|
||||
"opsxloki/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwtv5 "github.com/golang-jwt/jwt/v5"
|
||||
@@ -26,6 +26,8 @@ type Claims struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
TokenType string `json:"tokenType"` // access | refresh
|
||||
// TokenVersion 签发时的用户会话版本;改密/重置密码后递增,旧 token 立即失效
|
||||
TokenVersion int `json:"ver,omitempty"`
|
||||
jwtv5.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -36,16 +38,17 @@ type TokenPair struct {
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
|
||||
func GenerateTokenPair(userID uint, username, role string, tokenVersion int) (*TokenPair, error) {
|
||||
cfg := config.GetConfig()
|
||||
now := time.Now()
|
||||
|
||||
// Access Token
|
||||
accessClaims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
TokenType: "access",
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
TokenType: "access",
|
||||
TokenVersion: tokenVersion,
|
||||
RegisteredClaims: jwtv5.RegisteredClaims{
|
||||
ExpiresAt: jwtv5.NewNumericDate(now.Add(accessTokenExpiry)),
|
||||
IssuedAt: jwtv5.NewNumericDate(now),
|
||||
@@ -61,10 +64,11 @@ func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
|
||||
|
||||
// Refresh Token
|
||||
refreshClaims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
TokenType: "refresh",
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
TokenType: "refresh",
|
||||
TokenVersion: tokenVersion,
|
||||
RegisteredClaims: jwtv5.RegisteredClaims{
|
||||
ExpiresAt: jwtv5.NewNumericDate(now.Add(refreshTokenExpiry)),
|
||||
IssuedAt: jwtv5.NewNumericDate(now),
|
||||
@@ -85,19 +89,18 @@ func GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken 使用refresh token换取新的access token
|
||||
func RefreshAccessToken(refreshTokenString string) (*TokenPair, error) {
|
||||
// ParseRefreshToken 解析并校验 refresh token,返回 claims。
|
||||
// 用户存在性与 TokenVersion 校验由调用方(controller)对照数据库完成,
|
||||
// 通过后用用户当前 role/版本调 GenerateTokenPair 签发新 token 对。
|
||||
func ParseRefreshToken(refreshTokenString string) (*Claims, error) {
|
||||
claims, err := ParseToken(refreshTokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证token类型必须是refresh
|
||||
if claims.TokenType != "refresh" {
|
||||
return nil, errors.New("invalid token type")
|
||||
}
|
||||
|
||||
return GenerateTokenPair(claims.UserID, claims.Username, claims.Role)
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+123
-30
@@ -8,8 +8,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lokideploy/config"
|
||||
"lokideploy/models"
|
||||
"opsxloki/config"
|
||||
"opsxloki/models"
|
||||
)
|
||||
|
||||
// saveRequest 表示一个保存请求,通过channel序列化
|
||||
@@ -20,17 +20,19 @@ type saveRequest struct {
|
||||
// JsonDB 使用基于channel的序列化保存机制,防止并发数据丢失。
|
||||
// 该包只负责持久化(落盘 + 并发安全),领域实体定义在 models 包。
|
||||
type JsonDB struct {
|
||||
mu sync.RWMutex
|
||||
dataFile string
|
||||
Users []models.User `json:"users"`
|
||||
Clusters []models.Cluster `json:"clusters"`
|
||||
Hosts []models.Host `json:"hosts"`
|
||||
Storages []models.StorageConfig `json:"storages"`
|
||||
Deployments []models.LokiDeployment `json:"deployments"`
|
||||
Collectors []models.Collector `json:"collectors"`
|
||||
Logs []models.DeploymentLog `json:"logs"`
|
||||
Settings []models.SystemSetting `json:"settings"`
|
||||
sequences map[string]uint
|
||||
mu sync.RWMutex
|
||||
dataFile string
|
||||
Users []models.User `json:"users"`
|
||||
Clusters []models.Cluster `json:"clusters"`
|
||||
Hosts []models.Host `json:"hosts"`
|
||||
Storages []models.StorageConfig `json:"storages"`
|
||||
Deployments []models.LokiDeployment `json:"deployments"`
|
||||
Collectors []models.Collector `json:"collectors"`
|
||||
Logs []models.DeploymentLog `json:"logs"`
|
||||
Settings []models.SystemSetting `json:"settings"`
|
||||
LogSources []models.LogSource `json:"logSources"`
|
||||
LogPolicyGroups []models.LogPolicyGroup `json:"logPolicyGroups"`
|
||||
sequences map[string]uint
|
||||
|
||||
// 防抖保存机制
|
||||
saveTimer *time.Timer
|
||||
@@ -51,23 +53,26 @@ func InitDB() error {
|
||||
_ = os.MkdirAll(cfg.DataDir, 0755)
|
||||
|
||||
d := &JsonDB{
|
||||
dataFile: cfg.DatabasePath,
|
||||
Users: []models.User{},
|
||||
Clusters: []models.Cluster{},
|
||||
Hosts: []models.Host{},
|
||||
Storages: []models.StorageConfig{},
|
||||
Deployments: []models.LokiDeployment{},
|
||||
Collectors: []models.Collector{},
|
||||
Logs: []models.DeploymentLog{},
|
||||
Settings: []models.SystemSetting{},
|
||||
dataFile: cfg.DatabasePath,
|
||||
Users: []models.User{},
|
||||
Clusters: []models.Cluster{},
|
||||
Hosts: []models.Host{},
|
||||
Storages: []models.StorageConfig{},
|
||||
Deployments: []models.LokiDeployment{},
|
||||
Collectors: []models.Collector{},
|
||||
Logs: []models.DeploymentLog{},
|
||||
Settings: []models.SystemSetting{},
|
||||
LogSources: []models.LogSource{},
|
||||
LogPolicyGroups: []models.LogPolicyGroup{},
|
||||
sequences: map[string]uint{
|
||||
"users": 0,
|
||||
"clusters": 0,
|
||||
"hosts": 0,
|
||||
"storages": 0,
|
||||
"deployments": 0,
|
||||
"collectors": 0,
|
||||
"logs": 0,
|
||||
"users": 0,
|
||||
"clusters": 0,
|
||||
"hosts": 0,
|
||||
"storages": 0,
|
||||
"deployments": 0,
|
||||
"collectors": 0,
|
||||
"logs": 0,
|
||||
"logPolicyGroups": 0,
|
||||
},
|
||||
saveChan: make(chan saveRequest, 10),
|
||||
stopChan: make(chan struct{}),
|
||||
@@ -111,17 +116,81 @@ func InitDB() error {
|
||||
d.sequences["logs"] = l.ID
|
||||
}
|
||||
}
|
||||
for _, group := range d.LogPolicyGroups {
|
||||
if group.ID > d.sequences["logPolicyGroups"] {
|
||||
d.sequences["logPolicyGroups"] = group.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migratedLogAccess := migrateLogAccessToGroups(d)
|
||||
// 启动序列化保存goroutine
|
||||
d.wg.Add(1)
|
||||
go d.saveWorker()
|
||||
|
||||
db = d
|
||||
if migratedLogAccess {
|
||||
d.RequestSave()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Legacy per-user grants are grouped by identical source and label scope.
|
||||
func migrateLogAccessToGroups(d *JsonDB) bool {
|
||||
changed := false
|
||||
for i := range d.LogSources {
|
||||
source := &d.LogSources[i]
|
||||
if len(source.Access) == 0 {
|
||||
continue
|
||||
}
|
||||
byLabels := map[string]*models.LogPolicyGroup{}
|
||||
for _, rule := range source.Access {
|
||||
validUser := false
|
||||
for _, user := range d.Users {
|
||||
if user.ID == rule.UserID && user.Role != "admin" {
|
||||
validUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validUser {
|
||||
continue
|
||||
}
|
||||
alreadyGrouped := false
|
||||
for _, group := range d.LogPolicyGroups {
|
||||
if group.SourceID != source.ID {
|
||||
continue
|
||||
}
|
||||
for _, id := range group.UserIDs {
|
||||
if id == rule.UserID {
|
||||
alreadyGrouped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if alreadyGrouped {
|
||||
continue
|
||||
}
|
||||
keyBytes, _ := json.Marshal(rule.Labels)
|
||||
key := string(keyBytes)
|
||||
group := byLabels[key]
|
||||
if group == nil {
|
||||
d.sequences["logPolicyGroups"]++
|
||||
id := d.sequences["logPolicyGroups"]
|
||||
group = &models.LogPolicyGroup{ID: id, Name: fmt.Sprintf("旧授权 · %s · %d", source.Name, id), SourceID: source.ID, Labels: rule.Labels, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
byLabels[key] = group
|
||||
}
|
||||
group.UserIDs = append(group.UserIDs, rule.UserID)
|
||||
}
|
||||
for _, group := range byLabels {
|
||||
d.LogPolicyGroups = append(d.LogPolicyGroups, *group)
|
||||
}
|
||||
source.Access = nil
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭数据库,确保所有数据已落盘。
|
||||
// 顺序很关键:先停防抖定时器(不再产生新请求)→ 关闭 worker 并等其退出
|
||||
// (worker 可能正在处理已入队的请求)→ 此时已无并发,做最后一次 doSave 把
|
||||
@@ -181,7 +250,7 @@ func (d *JsonDB) doSave() error {
|
||||
|
||||
// 原子写入:先写入临时文件,再重命名
|
||||
dir := filepath.Dir(d.dataFile)
|
||||
tmpFile := filepath.Join(dir, fmt.Sprintf(".lokideploy-%d.tmp", time.Now().UnixNano()))
|
||||
tmpFile := filepath.Join(dir, fmt.Sprintf(".opsxloki-%d.tmp", time.Now().UnixNano()))
|
||||
|
||||
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
|
||||
return err
|
||||
@@ -275,7 +344,31 @@ func (d *JsonDB) CreateUser(user *models.User) error {
|
||||
user.ID = d.sequences["users"] + 1
|
||||
d.sequences["users"] = user.ID
|
||||
}
|
||||
if user.CreatedAt.IsZero() {
|
||||
now := time.Now()
|
||||
user.CreatedAt = now
|
||||
user.UpdatedAt = now
|
||||
}
|
||||
d.Users = append(d.Users, *user)
|
||||
d.RequestSave()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoverOrphanDeployments 启动时把残留的 deploying 状态部署标记为失败:
|
||||
// 部署 goroutine 随进程消亡,不恢复会永久卡在「部署中」(UI 无停止入口)。
|
||||
func (d *JsonDB) RecoverOrphanDeployments() {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
recovered := 0
|
||||
for i := range d.Deployments {
|
||||
if d.Deployments[i].Status == "deploying" {
|
||||
d.Deployments[i].Status = "failed"
|
||||
d.Deployments[i].StatusMessage = "服务重启导致部署中断,请重新部署"
|
||||
d.Deployments[i].UpdatedAt = time.Now()
|
||||
recovered++
|
||||
}
|
||||
}
|
||||
if recovered > 0 {
|
||||
d.RequestSave()
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"lokideploy/models"
|
||||
"opsxloki/models"
|
||||
)
|
||||
|
||||
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"lokideploy/models"
|
||||
"lokideploy/pkg/crypto"
|
||||
"lokideploy/repository"
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/repository"
|
||||
)
|
||||
|
||||
func Seed() {
|
||||
|
||||
+186
-11
@@ -1,11 +1,12 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"lokideploy/models"
|
||||
"lokideploy/pkg/crypto"
|
||||
"lokideploy/pkg/jwt"
|
||||
"lokideploy/repository"
|
||||
"lokideploy/utils"
|
||||
"fmt"
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/pkg/jwt"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/utils"
|
||||
)
|
||||
|
||||
type AuthService struct{}
|
||||
@@ -17,23 +18,89 @@ func NewAuthService() *AuthService {
|
||||
func (s *AuthService) Login(username, password string) (*models.User, *jwt.TokenPair, error) {
|
||||
db := repository.GetDB()
|
||||
db.RLock()
|
||||
defer db.RUnlock()
|
||||
var local *models.User
|
||||
for i := range db.Users {
|
||||
if db.Users[i].Username == username {
|
||||
if !crypto.VerifyPassword(password, db.Users[i].Password) {
|
||||
return nil, nil, utils.ErrInvalidCredentials
|
||||
}
|
||||
tokenPair, err := jwt.GenerateTokenPair(db.Users[i].ID, db.Users[i].Username, db.Users[i].Role)
|
||||
u := db.Users[i]
|
||||
local = &u
|
||||
break
|
||||
}
|
||||
}
|
||||
db.RUnlock()
|
||||
|
||||
// 本地用户密码校验(普通本地用户唯一路径;LDAP 用户不使用本地密码)
|
||||
if local != nil && local.AuthSource != "ldap" {
|
||||
if !crypto.VerifyPassword(password, local.Password) {
|
||||
return nil, nil, utils.ErrInvalidCredentials
|
||||
}
|
||||
return s.issueTokenPair(local)
|
||||
}
|
||||
|
||||
// LDAP 用户(本地已导入或首次登录自动创建);enabled=false 或验证失败返回统一错误
|
||||
if ldapCfg := GetLDAPConfig(); ldapCfg.Enabled && ldapCfg.Host != "" {
|
||||
if entry, err := verifyLDAPPassword(ldapCfg, username, password); err == nil {
|
||||
user, err := s.syncLDAPUser(entry)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &db.Users[i], tokenPair, nil
|
||||
return s.issueTokenPair(user)
|
||||
}
|
||||
}
|
||||
return nil, nil, utils.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTokenPair(u *models.User) (*models.User, *jwt.TokenPair, error) {
|
||||
pair, err := jwt.GenerateTokenPair(u.ID, u.Username, u.Role, u.TokenVersion)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return u, pair, nil
|
||||
}
|
||||
|
||||
// syncLDAPUser LDAP 验证通过后同步本地记录:不存在则创建(user 角色,密码占位不可本地登录),
|
||||
// 存在则同步昵称/邮箱
|
||||
func (s *AuthService) syncLDAPUser(entry *ldapUserEntry) (*models.User, error) {
|
||||
db := repository.GetDB()
|
||||
// 已存在 → 同步昵称并标记 LDAP 来源
|
||||
db.Lock()
|
||||
for i := range db.Users {
|
||||
if db.Users[i].Username == entry.Username {
|
||||
db.Users[i].Nickname = entry.DisplayName
|
||||
db.Users[i].AuthSource = "ldap"
|
||||
db.RequestSave()
|
||||
u := db.Users[i]
|
||||
db.Unlock()
|
||||
return &u, nil
|
||||
}
|
||||
}
|
||||
db.Unlock()
|
||||
|
||||
// 不存在 → 创建(CreateUser 自管锁与 ID 分配)
|
||||
hash, _ := crypto.HashPassword("ldap-user-" + entry.Username + "-no-local-login")
|
||||
user := &models.User{
|
||||
Username: entry.Username,
|
||||
Password: hash,
|
||||
Nickname: entry.DisplayName,
|
||||
Role: "user",
|
||||
AuthSource: "ldap",
|
||||
}
|
||||
if err := db.CreateUser(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(username, password, nickname, role string) (*models.User, error) {
|
||||
db := repository.GetDB()
|
||||
db.RLock()
|
||||
for i := range db.Users {
|
||||
if db.Users[i].Username == username {
|
||||
db.RUnlock()
|
||||
return nil, utils.ErrUsernameExists
|
||||
}
|
||||
}
|
||||
db.RUnlock()
|
||||
|
||||
hash, err := crypto.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,6 +137,9 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
|
||||
defer db.Unlock()
|
||||
for i := range db.Users {
|
||||
if db.Users[i].ID == userID {
|
||||
if db.Users[i].AuthSource == "ldap" {
|
||||
return fmt.Errorf("LDAP 用户请在 LDAP 目录中修改密码")
|
||||
}
|
||||
if !crypto.VerifyPassword(oldPassword, db.Users[i].Password) {
|
||||
return utils.ErrInvalidCredentials
|
||||
}
|
||||
@@ -78,6 +148,111 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
|
||||
return err
|
||||
}
|
||||
db.Users[i].Password = hash
|
||||
db.Users[i].TokenVersion++
|
||||
db.RequestSave()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return utils.ErrNotFound
|
||||
}
|
||||
|
||||
// ListUsers 分页返回用户列表(按 ID 升序),pageSize 上限 100
|
||||
func (s *AuthService) ListUsers(page, pageSize int) ([]models.User, int64) {
|
||||
db := repository.GetDB()
|
||||
db.RLock()
|
||||
defer db.RUnlock()
|
||||
|
||||
total := int64(len(db.Users))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start >= int(total) {
|
||||
return []models.User{}, total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
users := make([]models.User, end-start)
|
||||
copy(users, db.Users[start:end])
|
||||
return users, total
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户;不能删除自己,不能删除最后一个管理员
|
||||
func (s *AuthService) DeleteUser(id, requesterID uint) error {
|
||||
db := repository.GetDB()
|
||||
db.Lock()
|
||||
defer db.Unlock()
|
||||
|
||||
idx := -1
|
||||
adminCount := 0
|
||||
for i := range db.Users {
|
||||
if db.Users[i].ID == id {
|
||||
idx = i
|
||||
}
|
||||
if db.Users[i].Role == "admin" {
|
||||
adminCount++
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
return utils.ErrNotFound
|
||||
}
|
||||
if id == requesterID {
|
||||
return utils.ErrCannotDeleteSelf
|
||||
}
|
||||
if db.Users[idx].Role == "admin" && adminCount <= 1 {
|
||||
return utils.ErrLastAdmin
|
||||
}
|
||||
|
||||
db.Users = append(db.Users[:idx], db.Users[idx+1:]...)
|
||||
for i := range db.LogSources {
|
||||
kept := db.LogSources[i].Access[:0]
|
||||
for _, rule := range db.LogSources[i].Access {
|
||||
if rule.UserID != id {
|
||||
kept = append(kept, rule)
|
||||
}
|
||||
}
|
||||
db.LogSources[i].Access = kept
|
||||
}
|
||||
groups := db.LogPolicyGroups[:0]
|
||||
for _, group := range db.LogPolicyGroups {
|
||||
members := group.UserIDs[:0]
|
||||
for _, member := range group.UserIDs {
|
||||
if member != id {
|
||||
members = append(members, member)
|
||||
}
|
||||
}
|
||||
group.UserIDs = members
|
||||
if len(members) > 0 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
db.LogPolicyGroups = groups
|
||||
db.RequestSave()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword 管理员重置用户密码,并递增 TokenVersion 吊销其全部旧会话
|
||||
func (s *AuthService) ResetPassword(userID uint, newPassword string) error {
|
||||
db := repository.GetDB()
|
||||
db.Lock()
|
||||
defer db.Unlock()
|
||||
|
||||
for i := range db.Users {
|
||||
if db.Users[i].ID == userID {
|
||||
if db.Users[i].AuthSource == "ldap" {
|
||||
return fmt.Errorf("LDAP 用户密码由目录服务管理,不能在此重置")
|
||||
}
|
||||
hash, err := crypto.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db.Users[i].Password = hash
|
||||
db.Users[i].TokenVersion++
|
||||
db.RequestSave()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"lokideploy/models"
|
||||
"opsxloki/models"
|
||||
)
|
||||
|
||||
type ClusterInfo struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"lokideploy/models"
|
||||
"opsxloki/models"
|
||||
)
|
||||
|
||||
// resolveKubeconfig 取集群最终的 kubeconfig 文本(优先内存字段,其次磁盘文件)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("没有操作权限")
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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 检查清单」逐项对比目标工程输出优化任务。
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+84
-9
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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}`),
|
||||
|
||||
+179
-103
@@ -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 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>;
|
||||
if (data.code !== 0) {
|
||||
throw new Error(data.message || 'Request failed');
|
||||
const body: unknown = await response.json();
|
||||
// 多数接口走 {code, data, message} 包装;/health 等基础端点直接返回裸 JSON
|
||||
if (
|
||||
body !== null &&
|
||||
typeof body === 'object' &&
|
||||
'code' in body &&
|
||||
typeof (body as ApiResponse<T>).code === 'number'
|
||||
) {
|
||||
const data = body as ApiResponse<T>;
|
||||
if (data.code !== 0) {
|
||||
throw new ApiError(data.message || '请求失败', response.status, data.code);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
return data.data;
|
||||
return body as T;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
if (controller.signal.aborted && !externalSignal?.aborted) {
|
||||
throw new Error(`Request timeout after ${timeout}ms`);
|
||||
}
|
||||
throw new Error('Request was cancelled');
|
||||
}
|
||||
if (err instanceof TypeError && err.message.includes('fetch')) {
|
||||
throw new Error(
|
||||
'Cannot connect to backend server. Please ensure the API server is running.'
|
||||
);
|
||||
if (err instanceof ApiError) throw err;
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
if (controller.signal.aborted && !signal?.aborted) {
|
||||
throw new ApiError(`请求超时(${timeout}ms)`);
|
||||
}
|
||||
throw new ApiError('请求已取消');
|
||||
}
|
||||
throw err;
|
||||
if (err instanceof TypeError) {
|
||||
throw new ApiError('无法连接到后端服务,请确认 API 服务已启动');
|
||||
}
|
||||
throw err instanceof Error ? new ApiError(err.message) : new ApiError(String(err));
|
||||
} finally {
|
||||
if (externalSignal) {
|
||||
externalSignal.removeEventListener('abort', onExternalAbort);
|
||||
clearTimeout(timeoutId);
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心请求函数:在 doRequest 之上处理 401 —— 有 refreshToken 时单飞刷新并重放;
|
||||
* 刷新失败或无 refreshToken 时清凭证跳登录(silent 请求除外)。
|
||||
*/
|
||||
export async function request<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;
|
||||
}
|
||||
}
|
||||
|
||||
if (i === retries - 1) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)));
|
||||
/** 列表查询参数:统一编码拼接,避免模板字符串拼 query 无转义 */
|
||||
export function buildQuery(params: ListParams): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== '') {
|
||||
search.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './host';
|
||||
export * from './storage';
|
||||
export * from './deployment';
|
||||
export * from './collector';
|
||||
export * from './user';
|
||||
@@ -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 }),
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+186
-135
@@ -3,198 +3,249 @@ import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
Suspense,
|
||||
} from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Monitor,
|
||||
HardDrive,
|
||||
Rocket,
|
||||
RadioTower,
|
||||
Settings,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Shield,
|
||||
ChevronRight,
|
||||
Activity,
|
||||
Menu,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { menuItems, findRouteMeta } from '@/router/routes'
|
||||
import { getUser, clearAuth } from '@/lib/auth-storage'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface MenuItem {
|
||||
path: string
|
||||
icon: typeof LayoutDashboard
|
||||
label: string
|
||||
// 侧栏折叠状态持久化 key(命名空间见开发标准 08 章)
|
||||
const LAYOUT_COLLAPSED_KEY = 'opsxloki.layout.collapsed'
|
||||
|
||||
// 侧栏导航 — 桌面 aside 与移动端抽屉共用;菜单数据来自路由配置单源派生
|
||||
function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
|
||||
const location = useLocation()
|
||||
const isAdmin = getUser()?.role === 'admin'
|
||||
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
|
||||
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
|
||||
return (
|
||||
<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-primary text-primary-foreground'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||
)}
|
||||
title={!collapsed ? item.label : undefined}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
const MENU_ITEMS: MenuItem[] = [
|
||||
{ path: '/', icon: LayoutDashboard, label: '仪表盘' },
|
||||
{ path: '/clusters', icon: Server, label: '集群管理' },
|
||||
{ path: '/hosts', icon: Monitor, label: '主机管理' },
|
||||
{ path: '/storage', icon: HardDrive, label: '存储配置' },
|
||||
{ path: '/deployments', icon: Rocket, label: '部署Loki' },
|
||||
{ path: '/collectors', icon: RadioTower, label: '部署客户端' },
|
||||
{ path: '/settings', icon: Settings, label: '系统设置' },
|
||||
]
|
||||
// 侧栏 Logo 区
|
||||
function SidebarLogo({ collapsed }: { collapsed: boolean }) {
|
||||
return (
|
||||
<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 组件(React.memo优化)
|
||||
*
|
||||
* 优化点:
|
||||
* 1. 使用 React.memo 避免不必要的重渲染
|
||||
* 2. menuItems 使用 useMemo 缓存
|
||||
* 3. handleLogout 使用 useCallback 缓存
|
||||
* 4. sidebar 状态使用 useIsMobile 自动适配移动端
|
||||
* 5. 导航链接使用 useMemo 缓存避免重复计算
|
||||
* Layout 组件 — 布局骨架,业务页只挂在内容区
|
||||
* - 菜单/顶栏标题来自路由 meta 单源(router/routes.tsx)
|
||||
* - 桌面端折叠状态持久化;移动端(<768px)侧栏为抽屉 + 遮罩
|
||||
* - 页面懒加载的 Suspense 边界在 main 内部
|
||||
*/
|
||||
const Layout = memo(function Layout({ children }: { children: React.ReactNode }) {
|
||||
const isMobile = useIsMobile()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(!isMobile)
|
||||
const [user, setUser] = useState<{ username: string; role: string } | null>(null)
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(LAYOUT_COLLAPSED_KEY) === '1')
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const user = getUser()
|
||||
|
||||
// 当移动端状态变化时,自动调整侧边栏
|
||||
useEffect(() => {
|
||||
setSidebarOpen(!isMobile)
|
||||
}, [isMobile])
|
||||
// 顶栏标题来自路由 meta
|
||||
const pageTitle = findRouteMeta(location.pathname)?.title || 'opsx-loki'
|
||||
|
||||
// 从localStorage加载用户信息
|
||||
// 折叠状态持久化(仅桌面端语义)
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('user')
|
||||
if (stored) {
|
||||
try {
|
||||
setUser(JSON.parse(stored))
|
||||
} catch {
|
||||
// 解析失败静默处理
|
||||
}
|
||||
}
|
||||
localStorage.setItem(LAYOUT_COLLAPSED_KEY, collapsed ? '1' : '0')
|
||||
}, [collapsed])
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setCollapsed((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
// 缓存当前页面标题
|
||||
const pageTitle = useMemo(
|
||||
() => MENU_ITEMS.find((item) => item.path === location.pathname)?.label || '仪表盘',
|
||||
[location.pathname]
|
||||
)
|
||||
|
||||
// 缓存登出处理函数
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('user')
|
||||
navigate('/login')
|
||||
clearAuth()
|
||||
navigate('/login', { replace: true })
|
||||
}, [navigate])
|
||||
|
||||
// 缓存侧边栏切换函数
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<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 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'
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<SidebarLogo collapsed={collapsed} />
|
||||
<SidebarNav collapsed={collapsed} />
|
||||
<div className="p-2 border-t border-slate-700 flex-shrink-0">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
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"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="w-5 h-5" />
|
||||
) : (
|
||||
<PanelLeftClose className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto">
|
||||
{MENU_ITEMS.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all',
|
||||
isActive
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||
)}
|
||||
title={!sidebarOpen ? 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 && (
|
||||
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="p-2 border-t border-slate-700 flex-shrink-0">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
title={sidebarOpen ? '收起菜单' : '展开菜单'}
|
||||
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" />
|
||||
) : (
|
||||
<PanelLeftOpen 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">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<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
Reference in new issue
Block a user