feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
This commit is contained in:
1 parent
02d1bd87a7
commit
f3733f0bfc
201 files changed
+5438
-2388
No files matched your search
@@ -45,6 +45,7 @@ secrets/
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
|
lokistack.json
|
||||||
opsxloki.json
|
opsxloki.json
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# opsx-loki - Environment Variables Example
|
# lokistack - Environment Variables Example
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Copy this file to .env and fill in your values:
|
# Copy this file to .env and fill in your values:
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
|
|
||||||
opsx-loki 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana
|
LokiStack 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana
|
||||||
Loki 日志聚合系统部署到 Kubernetes 集群(通过 client-go 直连 Kubernetes API Server)或裸金属 Linux 主机(通过 SSH)。
|
Loki 日志聚合系统部署到 Kubernetes 集群(通过 client-go 直连 Kubernetes API Server)或裸金属 Linux 主机(通过 SSH)。
|
||||||
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
|
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
|
||||||
|
|
||||||
@@ -34,8 +34,8 @@ cd frontend && npm install && npm run dev
|
|||||||
|
|
||||||
### 构建单体二进制(前端 + 后端)
|
### 构建单体二进制(前端 + 后端)
|
||||||
```bash
|
```bash
|
||||||
bash build.sh # 产物:backend/bin/lokideploy,并把 frontend/dist 复制到 backend/dist
|
bash build.sh # 产物:backend/bin/lokistack,并把 frontend/dist 复制到 backend/dist
|
||||||
cd backend && go build -o bin/lokideploy . # 仅构建后端
|
cd backend && go build -o bin/lokistack . # 仅构建后端
|
||||||
cd frontend && npm run build # 仅构建前端(tsc -b && vite build)
|
cd frontend && npm run build # 仅构建前端(tsc -b && vite build)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
|||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
### 后端分层(`backend/`,模块路径 `opsxloki`,Go 1.24)
|
### 后端分层(`backend/`,模块路径 `lokistack`,Go 1.24)
|
||||||
请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。
|
请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。
|
||||||
|
|
||||||
- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。
|
- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。
|
||||||
@@ -80,7 +80,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
|||||||
- `seed/` — 数据库为空时写入默认 admin 用户。
|
- `seed/` — 数据库为空时写入默认 admin 用户。
|
||||||
|
|
||||||
### 数据层 — JSON 文件数据库(唯一数据源)
|
### 数据层 — JSON 文件数据库(唯一数据源)
|
||||||
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/opsxloki.json(兼容加载旧 lokideploy.json)`(默认 `./data/`)。
|
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokistack.json(自动兼容历史版本数据文件)`(默认 `./data/`)。
|
||||||
**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构:
|
**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构:
|
||||||
|
|
||||||
- **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个
|
- **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个
|
||||||
@@ -125,14 +125,14 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
|||||||
- `src/api/` — API 客户端,一域一模块。`http.ts` 提供:结构化 `ApiError`(status/code)、
|
- `src/api/` — API 客户端,一域一模块。`http.ts` 提供:结构化 `ApiError`(status/code)、
|
||||||
30s 超时、`silent` 请求选项、**401 单飞刷新 + 队列重放**(refreshToken 走裸 fetch,
|
30s 超时、`silent` 请求选项、**401 单飞刷新 + 队列重放**(refreshToken 走裸 fetch,
|
||||||
登录/轮询接口静默)、`buildQuery` 编码 query。组件**禁止**绕过 api 层直接 fetch。
|
登录/轮询接口静默)、`buildQuery` 编码 query。组件**禁止**绕过 api 层直接 fetch。
|
||||||
- 凭证存取统一走 `src/lib/auth-storage.ts`,key 带命名空间(`lokideploy.auth.*`);
|
- 凭证存取统一走 `src/lib/auth-storage.ts`,key 带命名空间(`lokistack.auth.*`);
|
||||||
redirect 回跳校验在 `src/lib/redirect.ts`(防开放重定向)。
|
redirect 回跳校验在 `src/lib/redirect.ts`(防开放重定向)。
|
||||||
- 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与
|
- 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与
|
||||||
`document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。
|
`document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。
|
||||||
- 部署向导(`/wizard`):状态推导式步骤地图(preflight 端点聚合各资源状态),四步:环境与
|
- 部署向导(`/wizard`):状态推导式步骤地图(preflight 端点聚合各资源状态),四步:环境与
|
||||||
目标 → 存储 → Loki→ 采集;k8s 资源统一默认 `log` 命名空间(可改)。
|
目标 → 存储 → Loki→ 采集;Kubernetes 资源统一默认 `log` 命名空间(可改)。
|
||||||
Grafana 为**可选**(默认关闭):Grafana 自动部署已移除,平台内置日志查询。
|
Grafana 为**可选**(默认关闭):Grafana 自动部署已移除,平台内置日志查询。
|
||||||
- **LDAP 接入**(参照 opsx-web-lite):设置页配置(SystemSetting 表 JSON,Bind 密码 AES 加密、
|
- **LDAP 接入**:设置页配置(SystemSetting 表 JSON,Bind 密码 AES 加密、
|
||||||
对外只回 `hasPassword`);启用后 ①登录支持 LDAP 用户验证并自动建号(authSource=ldap)②用户页
|
对外只回 `hasPassword`);启用后 ①登录支持 LDAP 用户验证并自动建号(authSource=ldap)②用户页
|
||||||
出现「从 LDAP 导入」(已导入置灰;本地同名转 LDAP);`/settings/ldap/{test,users}` 端点。
|
出现「从 LDAP 导入」(已导入置灰;本地同名转 LDAP);`/settings/ldap/{test,users}` 端点。
|
||||||
- **日志查询**(`/logs`):数据源 `LogSources`(cluster-svc=经 client-go 认证连接 API Server Service Proxy 查集群内
|
- **日志查询**(`/logs`):数据源 `LogSources`(cluster-svc=经 client-go 认证连接 API Server Service Proxy 查集群内
|
||||||
@@ -145,13 +145,13 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
|||||||
ErrorState + 重试(与空态严格区分)。
|
ErrorState + 重试(与空态严格区分)。
|
||||||
- 主题:语义 Token 单源(`src/index.css`,品牌色 = `--primary`,状态色 =
|
- 主题:语义 Token 单源(`src/index.css`,品牌色 = `--primary`,状态色 =
|
||||||
success/warning/destructive/info);暗色 `.dark` class 由 next-themes 驱动
|
success/warning/destructive/info);暗色 `.dark` class 由 next-themes 驱动
|
||||||
(key `lokideploy.theme.mode`),禁止硬编码调色板类(如 `bg-emerald-600`)。
|
(key `lokistack.theme.mode`),禁止硬编码调色板类(如 `bg-emerald-600`)。
|
||||||
- 开发服务器跑在 `:3000`(strictPort),把 `/api` 代理到 `:8080`;生产环境下由 Go
|
- 开发服务器跑在 `:3000`(strictPort),把 `/api` 代理到 `:8080`;生产环境下由 Go
|
||||||
二进制直接提供 `dist/` 静态服务。构建指纹(`__APP_VERSION__`/`__COMMIT_HASH__`)
|
二进制直接提供 `dist/` 静态服务。构建指纹(`__APP_VERSION__`/`__COMMIT_HASH__`)
|
||||||
在 Settings「关于」展示。
|
在 Settings「关于」展示。
|
||||||
|
|
||||||
## Loki 部署相关细节
|
## Loki 部署相关细节
|
||||||
- 合法的部署模式:`k8s-yaml`、`host-binary`、`host-docker`。K8s 模式需要一个 Cluster(kubeconfig);
|
- 合法的部署模式:`k8s-yaml`、`host-binary`、`host-docker`。Kubernetes 模式需要一个 Cluster(kubeconfig);
|
||||||
`host-binary` 需要一个 SSH Host,会安装 Loki + systemd。
|
`host-binary` 需要一个 SSH Host,会安装 Loki + systemd。
|
||||||
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
|
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
|
||||||
见 `services/deployment.go` 中的 `ValidateLokiVersion`。
|
见 `services/deployment.go` 中的 `ValidateLokiVersion`。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
|
|
||||||
opsx-loki 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana
|
LokiStack 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana
|
||||||
Loki 日志聚合系统部署到 Kubernetes 集群(通过 client-go 直连 Kubernetes API Server)或裸金属 Linux 主机(通过 SSH)。
|
Loki 日志聚合系统部署到 Kubernetes 集群(通过 client-go 直连 Kubernetes API Server)或裸金属 Linux 主机(通过 SSH)。
|
||||||
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
|
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
|
||||||
|
|
||||||
@@ -33,8 +33,8 @@ cd frontend && npm install && npm run dev
|
|||||||
|
|
||||||
### 构建单体二进制(前端 + 后端)
|
### 构建单体二进制(前端 + 后端)
|
||||||
```bash
|
```bash
|
||||||
bash build.sh # 产物:backend/bin/lokideploy,并把 frontend/dist 复制到 backend/dist
|
bash build.sh # 产物:backend/bin/lokistack,并把 frontend/dist 复制到 backend/dist
|
||||||
cd backend && go build -o bin/lokideploy . # 仅构建后端
|
cd backend && go build -o bin/lokistack . # 仅构建后端
|
||||||
cd frontend && npm run build # 仅构建前端(tsc -b && vite build)
|
cd frontend && npm run build # 仅构建前端(tsc -b && vite build)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
|||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
### 后端分层(`backend/`,模块路径 `opsxloki`,Go 1.24)
|
### 后端分层(`backend/`,模块路径 `lokistack`,Go 1.24)
|
||||||
请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。
|
请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。
|
||||||
|
|
||||||
- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。
|
- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。
|
||||||
@@ -80,7 +80,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
|||||||
- `seed/` — 数据库为空时写入默认 admin 用户。
|
- `seed/` — 数据库为空时写入默认 admin 用户。
|
||||||
|
|
||||||
### 数据层 — JSON 文件数据库(唯一数据源)
|
### 数据层 — JSON 文件数据库(唯一数据源)
|
||||||
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/opsxloki.json(兼容加载旧 lokideploy.json)`(默认 `./data/`)。
|
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokistack.json(自动兼容历史版本数据文件)`(默认 `./data/`)。
|
||||||
**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构:
|
**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构:
|
||||||
|
|
||||||
- **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个
|
- **并发模型**(重要):写操作由 `Lock()/RLock()` 保护,随后 `RequestSave()` 会通过一个
|
||||||
@@ -125,7 +125,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
|||||||
- `src/api/` — API 客户端,一域一模块。`http.ts` 提供:结构化 `ApiError`(status/code)、
|
- `src/api/` — API 客户端,一域一模块。`http.ts` 提供:结构化 `ApiError`(status/code)、
|
||||||
30s 超时、`silent` 请求选项、**401 单飞刷新 + 队列重放**(refreshToken 走裸 fetch,
|
30s 超时、`silent` 请求选项、**401 单飞刷新 + 队列重放**(refreshToken 走裸 fetch,
|
||||||
登录/轮询接口静默)、`buildQuery` 编码 query。组件**禁止**绕过 api 层直接 fetch。
|
登录/轮询接口静默)、`buildQuery` 编码 query。组件**禁止**绕过 api 层直接 fetch。
|
||||||
- 凭证存取统一走 `src/lib/auth-storage.ts`,key 带命名空间(`lokideploy.auth.*`);
|
- 凭证存取统一走 `src/lib/auth-storage.ts`,key 带命名空间(`lokistack.auth.*`);
|
||||||
redirect 回跳校验在 `src/lib/redirect.ts`(防开放重定向)。
|
redirect 回跳校验在 `src/lib/redirect.ts`(防开放重定向)。
|
||||||
- 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与
|
- 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与
|
||||||
`document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。
|
`document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。
|
||||||
@@ -141,13 +141,13 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
|||||||
ErrorState + 重试(与空态严格区分)。
|
ErrorState + 重试(与空态严格区分)。
|
||||||
- 主题:语义 Token 单源(`src/index.css`,品牌色 = `--primary`,状态色 =
|
- 主题:语义 Token 单源(`src/index.css`,品牌色 = `--primary`,状态色 =
|
||||||
success/warning/destructive/info);暗色 `.dark` class 由 next-themes 驱动
|
success/warning/destructive/info);暗色 `.dark` class 由 next-themes 驱动
|
||||||
(key `lokideploy.theme.mode`),禁止硬编码调色板类(如 `bg-emerald-600`)。
|
(key `lokistack.theme.mode`),禁止硬编码调色板类(如 `bg-emerald-600`)。
|
||||||
- 开发服务器跑在 `:3000`(strictPort),把 `/api` 代理到 `:8080`;生产环境下由 Go
|
- 开发服务器跑在 `:3000`(strictPort),把 `/api` 代理到 `:8080`;生产环境下由 Go
|
||||||
二进制直接提供 `dist/` 静态服务。构建指纹(`__APP_VERSION__`/`__COMMIT_HASH__`)
|
二进制直接提供 `dist/` 静态服务。构建指纹(`__APP_VERSION__`/`__COMMIT_HASH__`)
|
||||||
在 Settings「关于」展示。
|
在 Settings「关于」展示。
|
||||||
|
|
||||||
## Loki 部署相关细节
|
## Loki 部署相关细节
|
||||||
- 合法的部署模式:`k8s-yaml`、`host-binary`、`host-docker`。K8s 模式需要一个 Cluster(kubeconfig);
|
- 合法的部署模式:`k8s-yaml`、`host-binary`、`host-docker`。Kubernetes 模式需要一个 Cluster(kubeconfig);
|
||||||
`host-binary` 需要一个 SSH Host,会安装 Loki + systemd。
|
`host-binary` 需要一个 SSH Host,会安装 Loki + systemd。
|
||||||
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
|
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
|
||||||
见 `services/deployment.go` 中的 `ValidateLokiVersion`。
|
见 `services/deployment.go` 中的 `ValidateLokiVersion`。
|
||||||
|
|||||||
+17
-17
@@ -1,8 +1,8 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# opsx-loki - Secure Multi-Stage Dockerfile
|
# lokistack - Secure Multi-Stage Dockerfile
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# P0 Fixes:
|
# P0 Fixes:
|
||||||
# - Non-root user (opsxloki:1000)
|
# - Non-root user (lokistack:1000)
|
||||||
# - No hardcoded JWT_SECRET (validated at runtime)
|
# - No hardcoded JWT_SECRET (validated at runtime)
|
||||||
# - HEALTHCHECK instruction added
|
# - HEALTHCHECK instruction added
|
||||||
# - Binary installation verification
|
# - Binary installation verification
|
||||||
@@ -47,7 +47,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
|||||||
-trimpath \
|
-trimpath \
|
||||||
-a -installsuffix cgo \
|
-a -installsuffix cgo \
|
||||||
-ldflags="-s -w -extldflags '-static'" \
|
-ldflags="-s -w -extldflags '-static'" \
|
||||||
-o bin/opsxloki .
|
-o bin/lokistack .
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Stage 3: Final Runtime Image
|
# Stage 3: Final Runtime Image
|
||||||
@@ -68,26 +68,26 @@ RUN apk --no-cache add \
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# P0: Create non-root user and group
|
# P0: Create non-root user and group
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
RUN addgroup -g 1000 opsxloki && \
|
RUN addgroup -g 1000 lokistack && \
|
||||||
adduser -u 1000 -G opsxloki -s /bin/sh -D opsxloki
|
adduser -u 1000 -G lokistack -s /bin/sh -D lokistack
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Working directory with proper ownership
|
# Working directory with proper ownership
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN chown opsxloki:opsxloki /app
|
RUN chown lokistack:lokistack /app
|
||||||
|
|
||||||
# ---- Copy application files with correct ownership (P1) ----
|
# ---- Copy application files with correct ownership (P1) ----
|
||||||
COPY --from=frontend-builder --chown=opsxloki:opsxloki /app/frontend/dist ./dist
|
COPY --from=frontend-builder --chown=lokistack:lokistack /app/frontend/dist ./dist
|
||||||
COPY --from=backend-builder --chown=opsxloki:opsxloki /app/backend/bin/opsxloki ./opsxloki
|
COPY --from=backend-builder --chown=lokistack:lokistack /app/backend/bin/lokistack ./lokistack
|
||||||
|
|
||||||
# Create data directory with non-root ownership
|
# Create data directory with non-root ownership
|
||||||
RUN mkdir -p /app/data && chown -R opsxloki:opsxloki /app/data
|
RUN mkdir -p /app/data && chown -R lokistack:lokistack /app/data
|
||||||
|
|
||||||
# Create .kube directory for non-root user (P0: not /root/.kube)
|
# Create .kube directory for non-root user (P0: not /root/.kube)
|
||||||
RUN mkdir -p /home/opsxloki/.kube && \
|
RUN mkdir -p /home/lokistack/.kube && \
|
||||||
chown -R opsxloki:opsxloki /home/opsxloki && \
|
chown -R lokistack:lokistack /home/lokistack && \
|
||||||
chmod 700 /home/opsxloki/.kube
|
chmod 700 /home/lokistack/.kube
|
||||||
|
|
||||||
# ---- Security hardening: remove unnecessary tools ----
|
# ---- Security hardening: remove unnecessary tools ----
|
||||||
RUN rm -rf /tmp/* /var/cache/apk/*
|
RUN rm -rf /tmp/* /var/cache/apk/*
|
||||||
@@ -97,15 +97,15 @@ RUN rm -rf /tmp/* /var/cache/apk/*
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
ENV DATA_DIR=/app/data
|
ENV DATA_DIR=/app/data
|
||||||
ENV SERVER_PORT=8080
|
ENV SERVER_PORT=8080
|
||||||
ENV KUBECONFIG=/home/opsxloki/.kube/config
|
ENV KUBECONFIG=/home/lokistack/.kube/config
|
||||||
ENV HOME=/home/opsxloki
|
ENV HOME=/home/lokistack
|
||||||
|
|
||||||
# NOTE: JWT_SECRET and ENCRYPTION_KEY are NOT set here.
|
# NOTE: JWT_SECRET and ENCRYPTION_KEY are NOT set here.
|
||||||
# They MUST be provided at runtime via docker-compose env or secrets.
|
# They MUST be provided at runtime via docker-compose env or secrets.
|
||||||
# The entrypoint script will enforce this requirement.
|
# The entrypoint script will enforce this requirement.
|
||||||
|
|
||||||
# ---- Copy and configure entrypoint script ----
|
# ---- Copy and configure entrypoint script ----
|
||||||
COPY --chown=opsxloki:opsxloki docker-entrypoint.sh /usr/local/bin/
|
COPY --chown=lokistack:lokistack docker-entrypoint.sh /usr/local/bin/
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
@@ -120,7 +120,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# P0: Switch to non-root user
|
# P0: Switch to non-root user
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
USER opsxloki
|
USER lokistack
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
CMD ["./opsxloki"]
|
CMD ["./lokistack"]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# opsx-loki - Makefile
|
# lokistack - Makefile
|
||||||
# Convenient shortcuts for common operations
|
# Convenient shortcuts for common operations
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
help:
|
help:
|
||||||
@echo "opsx-loki v2 - Available Commands:"
|
@echo "lokistack - Available Commands:"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " make build - Build Docker image"
|
@echo " make build - Build Docker image"
|
||||||
@echo " make start - Start with docker-compose (dev)"
|
@echo " make start - Start with docker-compose (dev)"
|
||||||
@@ -24,12 +24,12 @@ help:
|
|||||||
|
|
||||||
# Build Docker image
|
# Build Docker image
|
||||||
build:
|
build:
|
||||||
@echo "Building opsx-loki v2 image..."
|
@echo "Building lokistack image..."
|
||||||
@docker compose build --no-cache
|
@docker compose build --no-cache
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
start:
|
start:
|
||||||
@echo "Starting opsx-loki (dev mode)..."
|
@echo "Starting lokistack (dev mode)..."
|
||||||
@echo ""
|
@echo ""
|
||||||
@if [ -z "$${JWT_SECRET:-}" ]; then \
|
@if [ -z "$${JWT_SECRET:-}" ]; then \
|
||||||
echo "WARNING: JWT_SECRET not set!"; \
|
echo "WARNING: JWT_SECRET not set!"; \
|
||||||
@@ -41,12 +41,12 @@ start:
|
|||||||
fi
|
fi
|
||||||
@docker compose up -d
|
@docker compose up -d
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "opsx-loki started!"
|
@echo "lokistack started!"
|
||||||
@echo " URL: http://localhost:$${SERVER_PORT:-8080}"
|
@echo " URL: http://localhost:$${SERVER_PORT:-8080}"
|
||||||
@echo " Check status: make status"
|
@echo " Check status: make status"
|
||||||
|
|
||||||
stop:
|
stop:
|
||||||
@echo "Stopping opsx-loki..."
|
@echo "Stopping lokistack..."
|
||||||
@docker compose down --timeout 15
|
@docker compose down --timeout 15
|
||||||
|
|
||||||
status:
|
status:
|
||||||
@@ -54,10 +54,10 @@ status:
|
|||||||
@docker compose ps
|
@docker compose ps
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=== Health Checks ==="
|
@echo "=== Health Checks ==="
|
||||||
@docker inspect --format='{{.Name}}: {{.State.Health.Status}}' opsxloki 2>/dev/null || echo "opsxloki: container not found"
|
@docker inspect --format='{{.Name}}: {{.State.Health.Status}}' lokistack 2>/dev/null || echo "lokistack: container not found"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=== Resource Usage ==="
|
@echo "=== Resource Usage ==="
|
||||||
@docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}" opsxloki 2>/dev/null || true
|
@docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}" lokistack 2>/dev/null || true
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
@docker compose logs -f --tail=50
|
@docker compose logs -f --tail=50
|
||||||
@@ -69,7 +69,7 @@ prod-setup: secrets
|
|||||||
@echo "Production secrets created in ./secrets/"
|
@echo "Production secrets created in ./secrets/"
|
||||||
|
|
||||||
prod:
|
prod:
|
||||||
@echo "Starting opsx-loki (production mode)..."
|
@echo "Starting lokistack (production mode)..."
|
||||||
@if [ ! -f secrets/jwt_secret.txt ] || [ ! -f secrets/encryption_key.txt ]; then \
|
@if [ ! -f secrets/jwt_secret.txt ] || [ ! -f secrets/encryption_key.txt ]; then \
|
||||||
echo "ERROR: Secrets not found. Run 'make secrets' first."; \
|
echo "ERROR: Secrets not found. Run 'make secrets' first."; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
@@ -99,7 +99,7 @@ secrets:
|
|||||||
|
|
||||||
# Maintenance
|
# Maintenance
|
||||||
shell:
|
shell:
|
||||||
@docker compose exec opsxloki /bin/sh
|
@docker compose exec lokistack /bin/sh
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning up..."
|
@echo "Cleaning up..."
|
||||||
|
|||||||
@@ -1,101 +1,21 @@
|
|||||||
# lokistack v2 — 安全的 K8s Loki 部署平台
|
# LokiStack — Loki 日志管理平台
|
||||||
|
|
||||||
一个轻量级的 **Gin + React + JSON 文件存储** 平台,用于通过 SSH 或 kubeconfig 把 Grafana Loki
|
一个轻量级的 **Gin + React + JSON 文件存储** 单体平台:通过 kubeconfig 或 SSH 把 Grafana Loki
|
||||||
日志聚合系统部署到 Kubernetes 集群或裸金属主机上。
|
部署到 Kubernetes 集群或裸金属主机,并内置日志采集管理、LogQL 查询与告警。单体二进制约 13MB,零外部依赖。
|
||||||
|
|
||||||
> **v2 安全增强**:非 root 容器、运行时强制校验密钥、健康检查、资源限制、日志轮转、优雅关停。
|
> 面向 AI 编码代理:`CLAUDE.md` / `AGENTS.md` 记录了架构约定与常用命令,两份内容一致,修改其一请同步另一份。
|
||||||
>
|
|
||||||
> **面向 AI 编码代理**:本仓库的 `CLAUDE.md` 与 `AGENTS.md` 记录了架构约定与常用命令,供 Claude Code、
|
|
||||||
> Cursor、Aider 等工具参考。两份文件内容一致,修改其一请同步另一份。
|
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **K8s 集群管理** — 通过 kubeconfig 添加集群,自动校验连通性
|
- **Kubernetes 集群管理** — kubeconfig/Token 接入,自动校验连通性,节点与工作负载概览
|
||||||
- **SSH 主机管理** — 支持密码或密钥认证
|
- **SSH 主机管理** — 密码或密钥认证,主机信息探测
|
||||||
- **存储配置** — 支持 S3、OSS、MinIO、RustFS 等后端
|
- **存储配置** — S3、OSS、COS、OBS、MinIO、RustFS 等对象存储后端
|
||||||
- **一键部署 Loki** — Helm Chart / YAML 清单 / 二进制 三种模式
|
- **一键部署 Loki** — `k8s-yaml` / `host-binary` / `host-docker` 三种模式,部署日志实时查看
|
||||||
- **部署日志** — 实时日志流
|
- **日志采集器** — 主机 Promtail、Kubernetes DaemonSet、Fluent Bit Sidecar,采集命名空间与 app 标签可配
|
||||||
- **Web 终端** — 在浏览器中执行远程命令
|
- **内置日志查询** — LogQL 高亮补全、直方图、标签过滤、Live Tail,无需 Grafana
|
||||||
- **单体二进制** — 约 13MB,零外部依赖
|
- **日志告警** — 告警规则 + 飞书(Lark)卡片通知
|
||||||
- **安全** — Argon2 密码哈希、AES 凭证加密、JWT 鉴权、非 root 容器
|
- **用户与 LDAP** — 两级用户角色(admin/普通),支持 LDAP 对接与导入
|
||||||
|
- **安全** — Argon2 密码哈希、AES-GCM(HKDF 派生)凭证加密、JWT 鉴权、非 root 容器
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 从 opsx-loki 升级(资源更名说明)
|
|
||||||
|
|
||||||
本版本把所有资源名从 `opsx-loki` / `opsxloki` 统一改为 **`lokistack`**,包括镜像、Deployment、
|
|
||||||
Service、PVC、Secret、docker-compose 卷名。
|
|
||||||
|
|
||||||
### Kubernetes 用户
|
|
||||||
|
|
||||||
资源名已变,**不能直接 `kubectl apply` 覆盖**(会新建空 PVC)。按下面顺序迁移,**零数据丢失**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 0) 先备份(数据只有几十 KB)
|
|
||||||
kubectl -n log exec deploy/opsx-loki -- tar czf - -C /app/data . > /tmp/data.tar.gz
|
|
||||||
kubectl -n log get secret opsxloki-secrets -o yaml > /tmp/secret-old.yaml
|
|
||||||
kubectl -n log get pvc opsxloki-data -o yaml > /tmp/pvc-old.yaml
|
|
||||||
|
|
||||||
# 1) 停旧服务(Recreate + RWO,必须停)
|
|
||||||
kubectl -n log scale deploy opsx-loki --replicas=0
|
|
||||||
kubectl -n log wait --for=delete pod -l app=opsx-loki --timeout=120s
|
|
||||||
|
|
||||||
# 2) 复制 Secret(值不变,密钥才不会失效)
|
|
||||||
kubectl -n log get secret opsxloki-secrets -o yaml \
|
|
||||||
| sed 's/name: opsxloki-secrets/name: lokistack-secrets/' \
|
|
||||||
| kubectl apply -f -
|
|
||||||
|
|
||||||
# 3) 只建新 PVC
|
|
||||||
kubectl apply -f kubernetes/pvc.yaml
|
|
||||||
kubectl -n log wait --for=jsonpath='{.status.phase}'=Bound pvc/lokistack-data --timeout=180s
|
|
||||||
|
|
||||||
# 4) 恢复数据到新 PVC(起临时 Pod 挂载)
|
|
||||||
kubectl -n log run restore --image=alpine:3.20 --restart=Never \
|
|
||||||
--overrides='{"spec":{"containers":[{"name":"r","image":"alpine:3.20","command":["sleep","600"],"volumeMounts":[{"name":"d","mountPath":"/data"}]}],"volumes":[{"name":"d","persistentVolumeClaim":{"claimName":"lokistack-data"}}]}}'
|
|
||||||
kubectl -n log wait --for=condition=Ready pod/restore --timeout=120s
|
|
||||||
kubectl -n log cp /tmp/data.tar.gz restore:/tmp/
|
|
||||||
kubectl -n log exec restore -- sh -c 'cd /data && tar xzf /tmp/data.tar.gz && chown -R 1000:1000 /data'
|
|
||||||
kubectl -n log delete po restore
|
|
||||||
|
|
||||||
# 5) 启动新服务
|
|
||||||
kubectl apply -f kubernetes/deployment.yaml -f kubernetes/service.yaml -f kubernetes/service-nodeport.yaml
|
|
||||||
kubectl -n log rollout status deploy/lokistack --timeout=240s
|
|
||||||
|
|
||||||
# 6) 若有 Ingress,改后端(TKE qcloud 要 NodePort)
|
|
||||||
kubectl -n log patch ingress <你的ingress名> --type=json \
|
|
||||||
-p='[{"op":"replace","path":"/spec/rules/0/http/paths/0/backend/service/name","value":"lokistack-nodeport"}]'
|
|
||||||
|
|
||||||
# 7) 确认服务正常后再删旧资源
|
|
||||||
kubectl -n log delete deploy opsx-loki
|
|
||||||
kubectl -n log delete svc opsx-loki opsx-loki-nodeport
|
|
||||||
# 旧 PVC / Secret 建议保留 7 天做回滚兜底
|
|
||||||
# kubectl -n log delete pvc opsxloki-data
|
|
||||||
# kubectl -n log delete secret opsxloki-secrets
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose 用户
|
|
||||||
|
|
||||||
卷名 `opsxloki-data` → `lokistack-data`。**旧数据不会自动迁移**,需要手动拷:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 停服务
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# 创建新卷并迁移数据
|
|
||||||
docker volume create lokistack-data
|
|
||||||
docker run --rm -v opsxloki-data:/from -v lokistack-data:/to alpine \
|
|
||||||
sh -c 'cp -av /from/. /to/ && chown -R 1000:1000 /to'
|
|
||||||
|
|
||||||
# 重新启动(会用新卷)
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 验证数据在
|
|
||||||
docker compose exec lokistack cat /app/data/opsxloki.json | head -c 100
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**:数据文件名仍叫 `opsxloki.json`(代码里硬编码,不改),只是目录/卷名变了。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,200 +24,88 @@ docker compose exec lokistack cat /app/data/opsxloki.json | head -c 100
|
|||||||
### 前置条件
|
### 前置条件
|
||||||
|
|
||||||
- Docker Engine 24.0+ 与 Docker Compose v2+
|
- Docker Engine 24.0+ 与 Docker Compose v2+
|
||||||
- 宿主机上装有 `kubectl` 和 `helm`(用于向外部集群部署 K8s 资源)
|
- `openssl`(生成密钥)
|
||||||
- `openssl`(用于生成密钥)
|
|
||||||
|
|
||||||
### 第 1 步:生成密钥(必需)
|
> 镜像内置全部依赖(Kubernetes 操作走 client-go 直连 API Server),宿主机**无需**安装 `kubectl` / `helm`。
|
||||||
|
|
||||||
启动前必须生成所需的安全密钥:
|
### 1. 生成密钥并启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 必需:JWT 签名密钥(建议 >= 32 字符)
|
export JWT_SECRET=$(openssl rand -hex 32) # >= 32 字符
|
||||||
export JWT_SECRET=$(openssl rand -hex 32)
|
export ENCRYPTION_KEY=$(openssl rand -hex 32) # >= 32 字节
|
||||||
|
|
||||||
# 必需:用于凭证存储的 AES 加密密钥(必须 >= 32 字节)
|
|
||||||
export ENCRYPTION_KEY=$(openssl rand -hex 32)
|
|
||||||
|
|
||||||
# 可选:跨会话持久化
|
|
||||||
echo "export JWT_SECRET=$JWT_SECRET" >> ~/.bashrc
|
|
||||||
echo "export ENCRYPTION_KEY=$ENCRYPTION_KEY" >> ~/.bashrc
|
|
||||||
```
|
|
||||||
|
|
||||||
> **警告**:如果未设置 `JWT_SECRET` 或 `ENCRYPTION_KEY`,容器将 **拒绝启动**,以避免不安全的默认部署。
|
|
||||||
|
|
||||||
### 第 2 步:用 Docker Compose 启动(推荐)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/your/lokistack.git
|
|
||||||
cd lokistack
|
|
||||||
|
|
||||||
# 设置密钥(见第 1 步)
|
|
||||||
export JWT_SECRET=$(openssl rand -hex 32)
|
|
||||||
export ENCRYPTION_KEY=$(openssl rand -hex 32)
|
|
||||||
|
|
||||||
# 启动
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
docker compose ps # 等待 healthy
|
||||||
# 查看健康状态
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# 打开 http://localhost:8080
|
|
||||||
# 管理员密码见「第 4 步」(首次启动随机生成,不再是固定 admin123)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第 3 步:验证健康状态
|
未设置这两个变量时容器会**拒绝启动**,避免不安全的默认部署。
|
||||||
|
|
||||||
|
打开 `http://localhost:8080`,管理员初始密码见下一步。
|
||||||
|
|
||||||
|
### 2. 获取管理员初始密码
|
||||||
|
|
||||||
|
首次启动生成**随机密码**(不再是固定 `admin123`),同时打印到启动日志并写入数据卷
|
||||||
|
`/app/data/initial-admin-password.txt`(权限 `0600`)。**日志只打印一次,文件才是可靠来源**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 容器级健康检查(内部)
|
# Docker Compose
|
||||||
docker inspect --format='{{.State.Health.Status}}' lokistack
|
|
||||||
|
|
||||||
# 期望输出:"healthy"
|
|
||||||
|
|
||||||
# 手动检查
|
|
||||||
curl http://localhost:8080/index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第 4 步:获取管理员初始密码
|
|
||||||
|
|
||||||
首次启动会生成**随机管理员密码**(不再固定 `admin123`),同时:
|
|
||||||
|
|
||||||
- 打印到启动日志(**仅首次**)
|
|
||||||
- 写入数据卷 `/app/data/initial-admin-password.txt`(权限 `0600`)
|
|
||||||
|
|
||||||
**任选一种方式获取**(推荐方式 1 —— 日志会轮转丢失):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式 1(推荐):从容器内读文件 —— 日志轮转后依然可查
|
|
||||||
docker compose exec lokistack cat /app/data/initial-admin-password.txt
|
docker compose exec lokistack cat /app/data/initial-admin-password.txt
|
||||||
|
|
||||||
# 方式 2:首次启动日志(只打印一次,重启后不会再有)
|
# Kubernetes
|
||||||
docker compose logs lokistack | grep -A6 "已生成管理员账号"
|
|
||||||
|
|
||||||
# 方式 3:容器已停止时也能用(不要求 running)
|
|
||||||
docker cp lokistack:/app/data/initial-admin-password.txt ./
|
|
||||||
|
|
||||||
# 方式 4:直接读 named volume(宿主机 root)
|
|
||||||
sudo cat /var/lib/docker/volumes/lokistack_lokistack-data/_data/initial-admin-password.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **为什么不能只靠日志**:`docker logs` 只在**首次启动**打印一次;日志轮转或容器重启后,
|
|
||||||
> 数据库里已有用户,**不会再打印**。所以**文件才是可靠来源**。
|
|
||||||
>
|
|
||||||
> 💡 容器名说明:本 compose 指定了 `container_name: lokistack`,所以可直接用 `lokistack`。
|
|
||||||
> 若你的部署没有指定,用服务名形式更通用:`docker compose exec lokistack cat ...`
|
|
||||||
|
|
||||||
**Kubernetes 环境(同一个文件)**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 读文件(推荐)
|
|
||||||
kubectl -n log exec deploy/lokistack -- cat /app/data/initial-admin-password.txt
|
kubectl -n log exec deploy/lokistack -- cat /app/data/initial-admin-password.txt
|
||||||
|
|
||||||
# 首次启动日志
|
|
||||||
kubectl -n log logs deploy/lokistack | grep -A6 "已生成管理员账号"
|
|
||||||
|
|
||||||
# 拷到本地
|
|
||||||
kubectl -n log cp <pod-name>:/app/data/initial-admin-password.txt ./
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**想自己指定密码(跳过随机生成)**:
|
- admin 首次修改密码后,该文件**自动删除**。
|
||||||
|
- 想自己指定密码:启动前设置环境变量 `ADMIN_INIT_PASSWORD`(设置后不生成随机密码、不写文件)。
|
||||||
|
|
||||||
|
### 3. 健康检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Docker Compose:部署前设置环境变量
|
curl http://localhost:8080/api/v1/health # 应用级
|
||||||
export ADMIN_INIT_PASSWORD='your-strong-password'
|
docker inspect --format='{{.State.Health.Status}}' lokistack # 容器级,期望 healthy
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Kubernetes:在 deployment 的 env 中加入 ADMIN_INIT_PASSWORD
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**安全说明**:
|
|
||||||
|
|
||||||
| 项 | 说明 |
|
|
||||||
|----|------|
|
|
||||||
| 密码文件权限 | `0600`,属主 `lokistack` |
|
|
||||||
| 自动清理 | **admin 首次修改密码后,该文件自动删除**(见 `seed.CleanupInitPassword`) |
|
|
||||||
| 日志内容 | 仅在首次启动打印,且容器日志由运行时轮转 |
|
|
||||||
| 忘了密码 | 数据卷里文件仍在;若文件已删,用 `ADMIN_INIT_PASSWORD` 重新初始化(需清空数据卷) |
|
|
||||||
| 环境变量优先 | 设置了 `ADMIN_INIT_PASSWORD` 时不生成随机密码、不写文件 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 生产部署
|
## 生产部署
|
||||||
|
|
||||||
### 方式 A:带密钥文件的 Docker Compose(推荐)
|
推荐使用带密钥文件与 nginx 负载均衡的 `docker-compose.prod.yml`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建密钥文件
|
|
||||||
mkdir -p secrets
|
mkdir -p secrets
|
||||||
openssl rand -hex 32 > secrets/jwt_secret.txt
|
openssl rand -hex 32 > secrets/jwt_secret.txt
|
||||||
openssl rand -hex 32 > secrets/encryption_key.txt
|
openssl rand -hex 32 > secrets/encryption_key.txt
|
||||||
chmod 600 secrets/*.txt
|
chmod 600 secrets/*.txt
|
||||||
|
|
||||||
# 使用 docker-compose.prod.yml
|
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方式 B:Docker Swarm
|
**上线前清单**:
|
||||||
|
|
||||||
```bash
|
- [ ] 两个密钥均为密码学随机、>= 32 字节,存于 Docker Secrets 或外部保险柜(而非 env 文件)
|
||||||
# 初始化 swarm(如果尚未初始化)
|
|
||||||
docker swarm init
|
|
||||||
|
|
||||||
# 创建 secrets
|
|
||||||
echo "$(openssl rand -hex 32)" | docker secret create lokistack_jwt -
|
|
||||||
echo "$(openssl rand -hex 32)" | docker secret create lokistack_key -
|
|
||||||
|
|
||||||
# 部署
|
|
||||||
docker stack deploy -c docker-compose.prod.yml lokistack
|
|
||||||
```
|
|
||||||
|
|
||||||
### 安全清单
|
|
||||||
|
|
||||||
- [ ] `JWT_SECRET` 至少 32 字符且为密码学随机
|
|
||||||
- [ ] `ENCRYPTION_KEY` 至少 32 字节且为密码学随机
|
|
||||||
- [ ] 密钥存放在 Docker Secrets 或外部保险柜中(而非 env 文件)
|
|
||||||
- [ ] `.env` 与 `secrets/` 已加入 `.gitignore`
|
- [ ] `.env` 与 `secrets/` 已加入 `.gitignore`
|
||||||
- [ ] 启用了 `no-new-privileges:true`(docker-compose 默认启用)
|
- [ ] kubeconfig 与 SSH 密钥以只读方式挂载(`:ro`)
|
||||||
- [ ] kubeconfig 以只读方式挂载(`:ro`)
|
- [ ] 健康检查通过、日志轮转与资源限制已配置(compose 默认已含)
|
||||||
- [ ] SSH 密钥以只读方式挂载(`:ro`)
|
|
||||||
- [ ] 已合理配置资源限制
|
|
||||||
- [ ] 健康检查通过
|
|
||||||
- [ ] 已配置日志轮转
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 从源码构建
|
## 从源码构建与开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建前端 + 后端
|
bash build.sh # 构建单体二进制 frontend/dist + backend/bin/lokistack
|
||||||
bash build.sh
|
./run.sh # 本地一键起前后端(自动注入开发用密钥)
|
||||||
|
|
||||||
# 用本地脚本启动(开发)
|
|
||||||
./run.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 开发模式(热重载)
|
开发模式(热重载,两个终端):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 终端 1 — 后端
|
cd backend && go run . # :8080;直接 go run 需先 export 两个密钥,否则拒绝启动
|
||||||
cd backend
|
cd frontend && npm install && npm run dev # :3000,/api 代理到 :8080
|
||||||
go run . # 监听 :8080
|
|
||||||
|
|
||||||
# 终端 2 — 前端
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev # 打开 http://localhost:3000,API 代理到 localhost:8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 直接 `go run` 启动后端前,必须先 `export JWT_SECRET` 与 `ENCRYPTION_KEY`,否则
|
测试与 lint:
|
||||||
> `backend/config/config.go` 会 `log.Fatal` 拒绝启动。`./run.sh` 会自动注入开发用默认值。
|
|
||||||
|
|
||||||
### 测试与 lint
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && go test ./... # 所有 Go 测试(utils、repository)
|
cd backend && go test ./...
|
||||||
cd backend && go test ./utils/ -run TestName -v # 运行单个测试
|
cd frontend && npm run lint
|
||||||
cd frontend && npm run lint # ESLint
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -305,131 +113,32 @@ cd frontend && npm run lint # ESLint
|
|||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
前端(React + TypeScript + Tailwind CSS) [开发 :3000 / 生产 :8080]
|
前端(React 19 + TypeScript + Tailwind) [开发 :3000 / 生产由 Go 直接服务 :8080]
|
||||||
|
|
| HTTP/REST + JWT Bearer
|
||||||
| HTTP/REST + JWT Bearer Token
|
后端(Go + Gin)[:8080]
|
||||||
v
|
+---> JSON 文件数据库($DATA_DIR/lokistack.json,自动兼容历史数据文件)
|
||||||
后端(Go + Gin) [:8080]
|
+---> client-go 直连 Kubernetes API Server(kubeconfig 认证)
|
||||||
|
|
+---> SSH(golang.org/x/crypto/ssh)
|
||||||
+---> JSON 文件数据库(data/opsxloki.json(兼容加载旧 lokideploy.json))
|
+---> Loki 部署器(YAML 清单 / 二进制 + systemd / Docker)
|
||||||
+---> K8s CLI(kubectl / helm)
|
|
||||||
+---> SSH 客户端(golang.org/x/crypto/ssh)
|
|
||||||
+---> Loki 部署器(Helm Chart / YAML / Binary)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 后端分层
|
- 请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**。
|
||||||
|
- 数据层为单 JSON 文件 + 内存结构:防抖原子写(临时文件 → fsync → rename)、优雅关停按序落盘,
|
||||||
|
因此**只能单实例运行**。新增数据库代码写到 `repository`(旧副本 `models/db.go` 待清理)。
|
||||||
|
- 所有响应为 `{ code, message, data }`;**禁止**直接返回原始 model,必须经 `SanitizedJSON` 脱敏
|
||||||
|
(剔除密码、kubeconfig、私钥、token)。
|
||||||
|
- 密码 Argon2 哈希;凭证 AES-GCM 加密,密钥由 `ENCRYPTION_KEY` 经 HKDF 派生(>= 32 字节)。
|
||||||
|
- 更细的分层与约定见 `CLAUDE.md` / `AGENTS.md` 与 `docs/web-ui-guide/`。
|
||||||
|
|
||||||
请求流转:**router → middleware(JWT)→ controller → service → repository(JsonDB)**
|
### 部署模式
|
||||||
|
|
||||||
- `config/` — `GetConfig()` 单例,读取环境变量,密钥缺失或长度不足时直接 `log.Fatal`。
|
|
||||||
- `routers/router.go` — 所有路由注册在 `/api/v1`;注册 SPA 静态资源并用 `NoRoute` 回退到
|
|
||||||
`index.html` 支持前端路由。
|
|
||||||
- `controllers/` — 薄 Gin handler,每资源一个文件;绑定/校验入参、调用 service、用
|
|
||||||
`models.SanitizedJSON(...)` 包装返回。
|
|
||||||
- `services/` — 业务逻辑。部署流水线:
|
|
||||||
`DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按模式(`k8s-helm` / `k8s-yaml` /
|
|
||||||
`host-binary`)分发到 `kubectl`/`helm` 或 SSH。另有 `K8sClient`、`ssh`、`storage`、
|
|
||||||
`cluster_info` 与较新的 **collector(日志采集器)** 模块。
|
|
||||||
- `middleware/jwt.go` — `JWTAuth()`、`AdminAuth()`。
|
|
||||||
- `utils/` — `response.go`(响应封装)、`crypto.go`(Argon2 + AES-GCM)、`jwt.go`。
|
|
||||||
- `seed/` — 数据库为空时写入默认 admin 用户。
|
|
||||||
|
|
||||||
### 数据层 — JSON 文件数据库
|
|
||||||
|
|
||||||
持久化是 **单个 JSON 文件** `$DATA_DIR/opsxloki.json(兼容加载旧 lokideploy.json)`(默认 `./data/`),**没有 SQL 数据库**。
|
|
||||||
`repository.JsonDB` 是与文件对应的内存结构:
|
|
||||||
|
|
||||||
- 写操作由 `Lock()/RLock()` 保护,`RequestSave()` 触发 **防抖(500ms)原子写**(临时文件 →
|
|
||||||
`fsync` → rename),由单一串行化的 `saveWorker` goroutine 执行。常规写入用 `RequestSave()`,
|
|
||||||
不要在 worker 可能运行时直接 `Save()`。
|
|
||||||
- 优雅关停顺序很关键:停防抖定时器 → 关闭 worker 并等待 → 最后一次 `doSave`。顺序颠倒会丢更新。
|
|
||||||
- 因此应用 **只能单实例运行**,不可水平扩展。
|
|
||||||
- `NextID(table)` 为各集合分配单调递增的整数 ID。
|
|
||||||
|
|
||||||
> 注:`repository/db.go` 是当前在用的实现;`models/db.go` 是正在迁移、尚未清理的旧副本(仍会编译,
|
|
||||||
> 但已无人调用)。新增数据库代码请写到 `repository`。
|
|
||||||
|
|
||||||
### 响应封装与脱敏
|
|
||||||
|
|
||||||
- 所有 API 响应为 `{ code, message, data }`,`code == 0` 表示成功。
|
|
||||||
- **不要直接返回原始 model**,必须用 `models.SanitizedJSON(...)` 包装以剔除密码、kubeconfig、
|
|
||||||
私钥、token 等敏感字段。
|
|
||||||
|
|
||||||
### 凭证安全
|
|
||||||
|
|
||||||
- 密码用 **Argon2** 哈希;凭证用 **AES-GCM** 加密,AES 密钥由 `ENCRYPTION_KEY` 经 **HKDF** 派生。
|
|
||||||
- `ENCRYPTION_KEY` 必须 **>= 32 字节**(早期文档写 16,现在会导致启动失败)。
|
|
||||||
|
|
||||||
### 容器安全模型
|
|
||||||
|
|
||||||
```
|
|
||||||
+-------------------+ +---------------------+
|
|
||||||
| 宿主内核 | | 宿主文件系统 |
|
|
||||||
| | | ~/.kube (只读) |
|
|
||||||
| +-------------+ | | ~/.ssh (只读) |
|
|
||||||
| | 容器 | | | ./data (读写) |
|
|
||||||
| | (lokistack | | +---------------------+
|
|
||||||
| | UID:1000) | |
|
|
||||||
| | no-new-privs| |
|
|
||||||
| | cap_drop:ALL| |
|
|
||||||
| +-------------+ |
|
|
||||||
+-------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 接口
|
|
||||||
|
|
||||||
Base URL:`/api/v1`
|
|
||||||
|
|
||||||
| 方法 | 接口 | 鉴权 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| POST | `/auth/login` | 否 | 登录,获取 JWT token |
|
|
||||||
| GET | `/auth/user` | 是 | 当前用户信息 |
|
|
||||||
| POST | `/auth/password` | 是 | 修改密码 |
|
|
||||||
| GET/POST | `/clusters` | 是 | 集群增删改查 |
|
|
||||||
| POST | `/clusters/:id/validate` | 是 | 校验集群连通性 |
|
|
||||||
| GET/POST | `/hosts` | 是 | 主机增删改查 |
|
|
||||||
| POST | `/hosts/:id/execute` | 是 | 远程执行命令 |
|
|
||||||
| GET/POST | `/storages` | 是 | 存储配置增删改查 |
|
|
||||||
| GET/POST | `/deployments` | 是 | 部署增删改查 |
|
|
||||||
| POST | `/deployments/:id/deploy` | 是 | 启动部署 |
|
|
||||||
| POST | `/deployments/:id/stop` | 是 | 停止部署 |
|
|
||||||
| GET | `/deployments/:id/logs` | 是 | 部署日志 |
|
|
||||||
| GET | `/deployments/stats` | 是 | 统计信息 |
|
|
||||||
|
|
||||||
### 健康检查
|
|
||||||
|
|
||||||
容器对静态文件路径暴露健康检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 容器级(内部)
|
|
||||||
wget --spider http://localhost:8080/index.html
|
|
||||||
|
|
||||||
# 宿主级
|
|
||||||
curl http://localhost:8080/index.html
|
|
||||||
|
|
||||||
# Docker inspect
|
|
||||||
docker inspect --format='{{.State.Health}}' lokistack
|
|
||||||
```
|
|
||||||
|
|
||||||
健康检查参数:
|
|
||||||
- **间隔**:30 秒
|
|
||||||
- **超时**:5 秒
|
|
||||||
- **启动宽限期**:15 秒(允许启动)
|
|
||||||
- **重试次数**:3
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署模式
|
|
||||||
|
|
||||||
| 模式 | 目标 | 说明 |
|
| 模式 | 目标 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `k8s-helm` | K8s 集群 | 官方 Grafana Helm Chart |
|
| `k8s-yaml` | Kubernetes 集群 | 原生清单(StatefulSet/ConfigMap/Service,PVC 持久化) |
|
||||||
| `k8s-yaml` | K8s 集群 | 原生 Kubernetes 清单 |
|
| `host-binary` | Linux 主机 | SSH 部署二进制 + systemd |
|
||||||
| `host-binary` | Linux 主机 | 通过 SSH 部署二进制 + systemd |
|
| `host-docker` | Linux 主机 | SSH 拉起 Loki 容器,配置与数据落在主机固定目录 |
|
||||||
|
|
||||||
## 存储后端
|
### 存储后端
|
||||||
|
|
||||||
| 类型 | Endpoint 示例 |
|
| 类型 | Endpoint 示例 |
|
||||||
|------|---------------|
|
|------|---------------|
|
||||||
@@ -440,164 +149,70 @@ docker inspect --format='{{.State.Health}}' lokistack
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
Base URL `/api/v1`,除登录/健康外均需 JWT。代表性接口(完整列表见 `backend/routers/router.go`):
|
||||||
|
|
||||||
|
| 方法 | 接口 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/auth/login` | 登录,返回 access + refresh token |
|
||||||
|
| GET/POST | `/clusters`、`/hosts`、`/storages` | 集群 / 主机 / 存储配置管理(含 `:id` 增删改) |
|
||||||
|
| POST | `/clusters/:id/validate`、`/hosts/:id/validate` | 连通性校验 |
|
||||||
|
| GET/POST | `/deployments`、`/collectors` | Loki 部署与采集策略管理 |
|
||||||
|
| POST | `/deployments/:id/deploy|stop|restart` | 部署生命周期 |
|
||||||
|
| POST | `/deployments/:id/retention` | 修改日志保留期并应用 |
|
||||||
|
| GET | `/deployments/:id/logs`、`/deployments/stats` | 部署日志 / 统计 |
|
||||||
|
| POST | `/logs/query|histogram|labels` | 内置日志查询(代理关联的 Loki) |
|
||||||
|
| GET/POST | `/users`、`/settings/ldap/*` | 用户管理与 LDAP 对接(仅 admin) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
### 必需
|
### 必需
|
||||||
|
|
||||||
| 变量 | 示例 | 说明 |
|
| 变量 | 说明 |
|
||||||
|------|------|------|
|
|------|------|
|
||||||
| `JWT_SECRET` | `$(openssl rand -hex 32)` | JWT 签名密钥(**必需**,>= 32 字符) |
|
| `JWT_SECRET` | JWT 签名密钥,>= 32 字符 |
|
||||||
| `ENCRYPTION_KEY` | `$(openssl rand -hex 32)` | AES 凭证加密密钥(**必需**,>= 32 字节) |
|
| `ENCRYPTION_KEY` | AES 凭证加密密钥,>= 32 字节 |
|
||||||
|
|
||||||
### 可选
|
### 可选
|
||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `SERVER_PORT` | `8080` | HTTP 服务端口 |
|
| `SERVER_PORT` | `8080` | HTTP 端口 |
|
||||||
| `DATA_DIR` | `/app/data` | 数据存储目录 |
|
| `DATA_DIR` | `/app/data` | 数据目录 |
|
||||||
| `KUBECONFIG` | `/home/lokideploy/.kube/config` | kubeconfig 文件路径 |
|
| `ADMIN_INIT_PASSWORD` | 随机生成 | 指定管理员初始密码 |
|
||||||
| `KUBECONFIG_PATH` | `~/.kube` | 宿主机上 kubeconfig 的挂载源路径 |
|
| `KUBECONFIG` | `/home/lokistack/.kube/config` | 容器内 kubeconfig 路径 |
|
||||||
| `SSH_KEY_PATH` | `~/.ssh` | 宿主机上 SSH 密钥的挂载源路径 |
|
|
||||||
|
|
||||||
### Docker Secrets 支持
|
支持 Docker Secrets 文件注入:`JWT_SECRET_FILE` / `ENCRYPTION_KEY_FILE` 指向 secret 文件路径即可
|
||||||
|
(用法见 `docker-compose.prod.yml` 与 `docker-entrypoint.sh`)。
|
||||||
entrypoint 支持基于文件的 Docker Secrets 注入:
|
|
||||||
|
|
||||||
| 变量 | 文件变量 | 说明 |
|
|
||||||
|------|----------|------|
|
|
||||||
| `JWT_SECRET` | `JWT_SECRET_FILE` | 含 JWT 密钥的 secret 文件路径 |
|
|
||||||
| `ENCRYPTION_KEY` | `ENCRYPTION_KEY_FILE` | 含加密密钥的 secret 文件路径 |
|
|
||||||
|
|
||||||
配合 Docker Compose secrets 的示例:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
lokideploy:
|
|
||||||
environment:
|
|
||||||
JWT_SECRET_FILE: /run/secrets/jwt_secret
|
|
||||||
ENCRYPTION_KEY_FILE: /run/secrets/encryption_key
|
|
||||||
secrets:
|
|
||||||
- jwt_secret
|
|
||||||
- encryption_key
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
jwt_secret:
|
|
||||||
file: ./secrets/jwt_secret.txt
|
|
||||||
encryption_key:
|
|
||||||
file: ./secrets/encryption_key.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安全
|
## 安全
|
||||||
|
|
||||||
### 鉴权与授权
|
- **鉴权**:JWT(access 2h + refresh 7 天轮换);改密/重置密码递增 `TokenVersion`,旧 token 立即失效。
|
||||||
|
- **用户体系**:两级角色(admin/普通),用户管理仅 admin;登录接口带 IP 限流。
|
||||||
- **Argon2** 密码哈希
|
- **容器**:非 root 用户(`lokistack:1000`)、`no-new-privileges`、丢弃全部多余 capabilities、
|
||||||
- **AES-GCM** 凭证加密(密钥由 `ENCRYPTION_KEY` 派生)
|
资源限制(CPU 2 核 / 内存 512MB / PIDs 100 上限)与日志轮转。
|
||||||
- **JWT** token 鉴权
|
- **密钥**:绝不提交版本控制;生产用 Docker Secrets 或密钥保险柜;轮换用 `openssl rand -hex 32`。
|
||||||
- **RBAC** 基于角色的访问控制
|
|
||||||
- API 响应经过脱敏(不暴露密码、kubeconfig、密钥)
|
|
||||||
|
|
||||||
### 容器安全
|
|
||||||
|
|
||||||
- **非 root 用户**:`lokideploy:1000`
|
|
||||||
- **禁止提权**:`no-new-privileges:true`
|
|
||||||
- **丢弃能力**:除必需外丢弃所有 capabilities
|
|
||||||
- **只读挂载**:kubeconfig 与 SSH 密钥只读挂载
|
|
||||||
- **资源限制**:CPU 与内存受限
|
|
||||||
- **日志轮转**:防止磁盘写满
|
|
||||||
|
|
||||||
### 密钥管理最佳实践
|
|
||||||
|
|
||||||
1. **绝不** 把密钥提交到版本控制
|
|
||||||
2. 生产环境使用 **Docker Secrets**(Swarm/Kubernetes)
|
|
||||||
3. 定期 **轮换密钥**:`openssl rand -hex 32`
|
|
||||||
4. 企业部署可使用密钥保险柜(HashiCorp Vault、AWS Secrets Manager 等)
|
|
||||||
5. 设置文件权限:对密钥文件执行 `chmod 600`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 资源配置
|
|
||||||
|
|
||||||
### 默认限制(docker-compose.yml)
|
|
||||||
|
|
||||||
| 资源 | 上限 | 预留 |
|
|
||||||
|------|------|------|
|
|
||||||
| CPU | 2.0 核 | 0.25 核 |
|
|
||||||
| 内存 | 512 MB | 64 MB |
|
|
||||||
| PIDs | 100 | - |
|
|
||||||
|
|
||||||
### 按规模调整
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# 更大规模部署(10+ 集群)
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '4.0'
|
|
||||||
memory: 1G
|
|
||||||
reservations:
|
|
||||||
cpus: '1.0'
|
|
||||||
memory: 256M
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 故障排查
|
## 故障排查
|
||||||
|
|
||||||
### 容器立即退出
|
**容器立即退出** — 多为密钥缺失。`docker compose logs` 应有
|
||||||
|
`Missing required security environment variables`;设置两个密钥后重启。
|
||||||
|
|
||||||
```bash
|
**健康检查失败** — `docker inspect --format='{{.State.Health}}' lokistack` 查状态,
|
||||||
# 检查密钥是否已设置
|
`docker compose logs --tail=50` 查原因。
|
||||||
docker compose logs
|
|
||||||
# 期望错误信息:"Missing required security environment variables"
|
|
||||||
|
|
||||||
# 修复:
|
**kubeconfig 不可访问** — 核对宿主侧权限(`chmod 600 ~/.kube/config`)与挂载路径:
|
||||||
export JWT_SECRET=$(openssl rand -hex 32)
|
`docker exec lokistack ls -la /home/lokistack/.kube/`。
|
||||||
export ENCRYPTION_KEY=$(openssl rand -hex 32)
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 健康检查失败
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看容器状态
|
|
||||||
docker inspect --format='{{.State.Health}}' lokistack
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
docker compose logs --tail=50
|
|
||||||
```
|
|
||||||
|
|
||||||
### kubeconfig 不可访问
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 确保宿主侧权限正确
|
|
||||||
chmod 600 ~/.kube/config
|
|
||||||
|
|
||||||
# 核对挂载路径
|
|
||||||
docker exec lokideploy ls -la /home/lokideploy/.kube/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 更新日志
|
|
||||||
|
|
||||||
### v2.0.0
|
|
||||||
|
|
||||||
- **安全**:非 root 容器用户(`lokideploy:1000`)
|
|
||||||
- **安全**:运行时强制校验 `JWT_SECRET`/`ENCRYPTION_KEY`
|
|
||||||
- **安全**:`no-new-privileges` 与 capabilities 丢弃
|
|
||||||
- **安全**:kubectl/helm 的 SHA256 校验和验证
|
|
||||||
- **运维**:健康检查端点
|
|
||||||
- **运维**:资源限制与预留
|
|
||||||
- **运维**:日志轮转(json-file 驱动)
|
|
||||||
- **运维**:优雅关停(SIGTERM)
|
|
||||||
- **运维**:Docker Secrets 支持
|
|
||||||
- **运维**:带 nginx 负载均衡的生产 compose 文件
|
|
||||||
@@ -68,15 +68,18 @@ func getEnv(key, defaultVal string) string {
|
|||||||
return defaultVal
|
return defaultVal
|
||||||
}
|
}
|
||||||
|
|
||||||
// databasePath 新名优先;旧 lokideploy.json 存在且新文件不存在时沿用旧文件(数据平滑迁移)
|
// databasePath 新名优先;历史命名的数据文件存在且新文件不存在时沿用旧文件
|
||||||
|
// (数据平滑迁移,不搬移文件、零丢失风险)
|
||||||
func databasePath(dataDir string) string {
|
func databasePath(dataDir string) string {
|
||||||
newPath := filepath.Join(dataDir, "opsxloki.json")
|
newPath := filepath.Join(dataDir, "lokistack.json")
|
||||||
if _, err := os.Stat(newPath); err == nil {
|
if _, err := os.Stat(newPath); err == nil {
|
||||||
return newPath
|
return newPath
|
||||||
}
|
}
|
||||||
oldPath := filepath.Join(dataDir, "lokideploy.json")
|
for _, legacy := range []string{"opsxloki.json", "lokideploy.json"} {
|
||||||
if _, err := os.Stat(oldPath); err == nil {
|
legacyPath := filepath.Join(dataDir, legacy)
|
||||||
return oldPath
|
if _, err := os.Stat(legacyPath); err == nil {
|
||||||
|
return legacyPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return newPath
|
return newPath
|
||||||
}
|
}
|
||||||
@@ -4,16 +4,16 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertsController 告警规则 + 飞书通知配置(参照 opsx-sec 通知 API 设计)
|
// AlertsController 告警规则 + 飞书通知配置
|
||||||
type AlertsController struct{}
|
type AlertsController struct{}
|
||||||
|
|
||||||
func NewAlertsController() *AlertsController { return &AlertsController{} }
|
func NewAlertsController() *AlertsController { return &AlertsController{} }
|
||||||
@@ -48,7 +48,7 @@ func (ctrl *AlertsController) TestNotifyConfig(c *gin.Context) {
|
|||||||
utils.Error(c, 400, "请先保存并启用飞书通知")
|
utils.Error(c, 400, "请先保存并启用飞书通知")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := services.SendLarkCard("✅ opsx-loki 通知测试", "green", []string{
|
if err := services.SendLarkCard("✅ LokiStack 通知测试", "green", []string{
|
||||||
"**时间**: " + time.Now().Format("2006-01-02 15:04:05"),
|
"**时间**: " + time.Now().Format("2006-01-02 15:04:05"),
|
||||||
"**说明**: 管理员在设置页手动触发的连通性测试。",
|
"**说明**: 管理员在设置页手动触发的连通性测试。",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -272,7 +272,7 @@ func (ctrl *AlertsController) TestChannel(c *gin.Context) {
|
|||||||
utils.BadRequest(c, "请提供通道 ID 或 webhook 地址")
|
utils.BadRequest(c, "请提供通道 ID 或 webhook 地址")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := services.SendLarkCardToChannel(channelID, "✅ opsx-loki 通道测试", "green", []string{
|
if err := services.SendLarkCardToChannel(channelID, "✅ LokiStack 通道测试", "green", []string{
|
||||||
"**时间**: " + time.Now().Format("2006-01-02 15:04:05"),
|
"**时间**: " + time.Now().Format("2006-01-02 15:04:05"),
|
||||||
"**说明**: 通道连通性测试。",
|
"**说明**: 通道连通性测试。",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/jwt"
|
"lokistack/pkg/jwt"
|
||||||
"opsxloki/seed"
|
"lokistack/seed"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -124,23 +124,3 @@ func (ctrl *HostController) GetInfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
utils.Success(c, info)
|
utils.Success(c, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctrl *HostController) Execute(c *gin.Context) {
|
|
||||||
id, err := utils.IntParam(c, "id")
|
|
||||||
if err != nil {
|
|
||||||
utils.BadRequest(c, "ID 无效")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Command string `json:"command" binding:"required"`
|
|
||||||
}
|
|
||||||
if !utils.BindAndValidate(c, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stdout, stderr, err := ctrl.service.ExecuteCommand(uint(id), req.Command)
|
|
||||||
if err != nil {
|
|
||||||
utils.Error(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.Success(c, gin.H{"stdout": stdout, "stderr": stderr})
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"opsxloki/dto"
|
"lokistack/dto"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/jwt"
|
"lokistack/pkg/jwt"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -77,6 +77,21 @@ func (ctrl *UserController) Delete(c *gin.Context) {
|
|||||||
utils.Success(c, gin.H{"message": "user deleted"})
|
utils.Success(c, gin.H{"message": "user deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchDelete 批量删除用户;逐个应用守卫(不能删自己/最后一个 admin),失败项单独返回不阻断整体
|
||||||
|
func (ctrl *UserController) BatchDelete(c *gin.Context) {
|
||||||
|
var req dto.BatchDeleteUsersRequest
|
||||||
|
if !utils.BindAndValidate(c, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, ok := jwt.GetUserFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
utils.Unauthorized(c, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted, failures := ctrl.service.BatchDeleteUsers(req.IDs, claims.UserID)
|
||||||
|
utils.Success(c, gin.H{"deleted": deleted, "failures": failures})
|
||||||
|
}
|
||||||
|
|
||||||
// ResetPassword 管理员重置密码(递增 TokenVersion,目标用户旧会话全部失效)
|
// ResetPassword 管理员重置密码(递增 TokenVersion,目标用户旧会话全部失效)
|
||||||
func (ctrl *UserController) ResetPassword(c *gin.Context) {
|
func (ctrl *UserController) ResetPassword(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import "opsxloki/services"
|
import "lokistack/services"
|
||||||
|
|
||||||
type ImageRegistryRequest struct {
|
type ImageRegistryRequest struct {
|
||||||
Mode string `json:"mode" binding:"required,oneof=overseas cn custom"`
|
Mode string `json:"mode" binding:"required,oneof=overseas cn custom"`
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import "opsxloki/models"
|
import "lokistack/models"
|
||||||
|
|
||||||
// 日志查询域
|
// 日志查询域
|
||||||
type LogSourceRequest struct {
|
type LogSourceRequest struct {
|
||||||
|
|||||||
@@ -11,3 +11,7 @@ type UserCreateRequest struct {
|
|||||||
type ResetPasswordRequest struct {
|
type ResetPasswordRequest struct {
|
||||||
NewPassword string `json:"newPassword" binding:"required,min=6"`
|
NewPassword string `json:"newPassword" binding:"required,min=6"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BatchDeleteUsersRequest struct {
|
||||||
|
IDs []uint `json:"ids" binding:"required,min=1,dive"`
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
module opsxloki
|
module lokistack
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -9,11 +9,11 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/config"
|
"lokistack/config"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/routers"
|
"lokistack/routers"
|
||||||
"opsxloki/seed"
|
"lokistack/seed"
|
||||||
"opsxloki/services"
|
"lokistack/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -52,7 +52,7 @@ func main() {
|
|||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("opsx-loki starting on port %s", cfg.ServerPort)
|
log.Printf("lokistack starting on port %s", cfg.ServerPort)
|
||||||
log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort)
|
log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort)
|
||||||
// 初始密码:首次启动日志中的「已生成管理员账号」,或数据卷 initial-admin-password.txt
|
// 初始密码:首次启动日志中的「已生成管理员账号」,或数据卷 initial-admin-password.txt
|
||||||
log.Printf("首次部署请查看启动日志或 %s/initial-admin-password.txt 获取 admin 初始密码", cfg.DataDir)
|
log.Printf("首次部署请查看启动日志或 %s/initial-admin-password.txt 获取 admin 初始密码", cfg.DataDir)
|
||||||
@@ -74,5 +74,5 @@ func main() {
|
|||||||
if err := repository.Shutdown(); err != nil {
|
if err := repository.Shutdown(); err != nil {
|
||||||
log.Printf("DB shutdown error: %v", err)
|
log.Printf("DB shutdown error: %v", err)
|
||||||
}
|
}
|
||||||
log.Println("LokiDeploy stopped")
|
log.Println("lokistack stopped")
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,10 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/jwt"
|
"lokistack/pkg/jwt"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ipLimiter 内存滑动窗口 IP 限流(借鉴 opsx-web-lite rate_limit.go 的简化版)。
|
// ipLimiter 内存滑动窗口 IP 限流(简化实现)。
|
||||||
// 用于登录防爆破:超限返回 429。单实例部署下内存实现即可,无需外部存储。
|
// 用于登录防爆破:超限返回 429。单实例部署下内存实现即可,无需外部存储。
|
||||||
type ipLimiter struct {
|
type ipLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Cluster struct {
|
type Cluster struct {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ type LokiDeployment struct {
|
|||||||
GrafanaEndpoint string `json:"grafanaEndpoint"`
|
GrafanaEndpoint string `json:"grafanaEndpoint"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusMessage string `json:"statusMessage"`
|
StatusMessage string `json:"statusMessage"`
|
||||||
HelmValues string `json:"helmValues"`
|
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
AdminUser string `json:"adminUser"`
|
AdminUser string `json:"adminUser"`
|
||||||
AdminPassword string `json:"adminPassword"`
|
AdminPassword string `json:"adminPassword"`
|
||||||
@@ -76,7 +75,6 @@ func (d *LokiDeployment) Sanitized() map[string]interface{} {
|
|||||||
"grafanaEndpoint": d.GrafanaEndpoint,
|
"grafanaEndpoint": d.GrafanaEndpoint,
|
||||||
"status": d.Status,
|
"status": d.Status,
|
||||||
"statusMessage": d.StatusMessage,
|
"statusMessage": d.StatusMessage,
|
||||||
"helmValues": d.HelmValues,
|
|
||||||
"endpoint": d.Endpoint,
|
"endpoint": d.Endpoint,
|
||||||
"adminUser": d.AdminUser,
|
"adminUser": d.AdminUser,
|
||||||
// AdminPassword 故意省略
|
// AdminPassword 故意省略
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SSHAuthConfig SSH认证配置(定义在models包避免循环导入)
|
// SSHAuthConfig SSH认证配置(定义在models包避免循环导入)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// User TokenVersion 用于服务端会话吊销:改密/管理员重置密码时递增,
|
// User TokenVersion 用于服务端会话吊销:改密/管理员重置密码时递增,
|
||||||
// 旧 token(access 与 refresh)随版本不匹配立即失效(借鉴 opsx-web-lite)。
|
// 旧 token(access 与 refresh)随版本不匹配立即失效。
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"opsxloki/config"
|
"lokistack/config"
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
"golang.org/x/crypto/hkdf"
|
"golang.org/x/crypto/hkdf"
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ import (
|
|||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
os.Setenv("JWT_SECRET", "crypto-pkg-test-secret-32chars!!")
|
os.Setenv("JWT_SECRET", "crypto-pkg-test-secret-32chars!!")
|
||||||
os.Setenv("ENCRYPTION_KEY", "crypto-pkg-test-encryption-32ch!!")
|
os.Setenv("ENCRYPTION_KEY", "crypto-pkg-test-encryption-32ch!!")
|
||||||
os.Setenv("DATA_DIR", os.TempDir()+"/opsxloki-crypto-pkg-test")
|
os.Setenv("DATA_DIR", os.TempDir()+"/lokistack-crypto-pkg-test")
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/config"
|
"lokistack/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
jwtv5 "github.com/golang-jwt/jwt/v5"
|
jwtv5 "github.com/golang-jwt/jwt/v5"
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ import (
|
|||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
os.Setenv("JWT_SECRET", "jwt-pkg-test-secret-32chars-min!!")
|
os.Setenv("JWT_SECRET", "jwt-pkg-test-secret-32chars-min!!")
|
||||||
os.Setenv("ENCRYPTION_KEY", "jwt-pkg-test-encryption-32chars!!")
|
os.Setenv("ENCRYPTION_KEY", "jwt-pkg-test-encryption-32chars!!")
|
||||||
os.Setenv("DATA_DIR", os.TempDir()+"/lokideploy-jwt-pkg-test")
|
os.Setenv("DATA_DIR", os.TempDir()+"/lokistack-jwt-pkg-test")
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/config"
|
"lokistack/config"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// saveRequest 表示一个保存请求,通过channel序列化
|
// saveRequest 表示一个保存请求,通过channel序列化
|
||||||
@@ -256,7 +256,7 @@ func (d *JsonDB) doSave() error {
|
|||||||
|
|
||||||
// 原子写入:先写入临时文件,再重命名
|
// 原子写入:先写入临时文件,再重命名
|
||||||
dir := filepath.Dir(d.dataFile)
|
dir := filepath.Dir(d.dataFile)
|
||||||
tmpFile := filepath.Join(dir, fmt.Sprintf(".opsxloki-%d.tmp", time.Now().UnixNano()))
|
tmpFile := filepath.Join(dir, fmt.Sprintf(".lokistack-%d.tmp", time.Now().UnixNano()))
|
||||||
|
|
||||||
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
|
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
|
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"opsxloki/config"
|
"lokistack/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMain 为 repository 包预置安全环境变量与临时 DATA_DIR,并管理 JsonDB 生命周期。
|
// TestMain 为 repository 包预置安全环境变量与临时 DATA_DIR,并管理 JsonDB 生命周期。
|
||||||
// config.GetConfig() 在 JWT_SECRET/ENCRYPTION_KEY 缺失时会 log.Fatal,故须先设置。
|
// config.GetConfig() 在 JWT_SECRET/ENCRYPTION_KEY 缺失时会 log.Fatal,故须先设置。
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
dir, err := os.MkdirTemp("", "opsxloki-repository-test")
|
dir, err := os.MkdirTemp("", "lokistack-repository-test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/controllers"
|
"lokistack/controllers"
|
||||||
"opsxloki/middleware"
|
"lokistack/middleware"
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -53,7 +53,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
api.GET("/metrics", func(c *gin.Context) {
|
api.GET("/metrics", func(c *gin.Context) {
|
||||||
// 基础metrics输出(符合Prometheus文本格式)
|
// 基础metrics输出(符合Prometheus文本格式)
|
||||||
c.Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
c.Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
c.String(http.StatusOK, "# LokiDeploy Metrics\n")
|
c.String(http.StatusOK, "# LokiStack Metrics\n")
|
||||||
})
|
})
|
||||||
|
|
||||||
// Public(登录接口挂 IP 限流防爆破:20 次/分钟)
|
// Public(登录接口挂 IP 限流防爆破:20 次/分钟)
|
||||||
@@ -93,7 +93,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
authorized.GET("/settings/image-registry", registryCtrl.Get)
|
authorized.GET("/settings/image-registry", registryCtrl.Get)
|
||||||
authorized.PUT("/settings/image-registry", middleware.AdminAuth(), registryCtrl.Save)
|
authorized.PUT("/settings/image-registry", middleware.AdminAuth(), registryCtrl.Save)
|
||||||
|
|
||||||
// 告警:飞书通知配置 + 告警规则(参照 opsx-sec)
|
// 告警:飞书通知配置 + 告警规则
|
||||||
alertNotify := authorized.Group("/alerts")
|
alertNotify := authorized.Group("/alerts")
|
||||||
{
|
{
|
||||||
alertNotify.GET("/notify-config", alertsCtrl.GetNotifyConfig)
|
alertNotify.GET("/notify-config", alertsCtrl.GetNotifyConfig)
|
||||||
@@ -125,6 +125,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
users.GET("", userCtrl.List)
|
users.GET("", userCtrl.List)
|
||||||
users.POST("", userCtrl.Create)
|
users.POST("", userCtrl.Create)
|
||||||
users.DELETE("/:id", userCtrl.Delete)
|
users.DELETE("/:id", userCtrl.Delete)
|
||||||
|
users.POST("/batch-delete", userCtrl.BatchDelete)
|
||||||
users.POST("/:id/reset-password", userCtrl.ResetPassword)
|
users.POST("/:id/reset-password", userCtrl.ResetPassword)
|
||||||
users.POST("/batch-import-ldap", userCtrl.BatchImportLDAP)
|
users.POST("/batch-import-ldap", userCtrl.BatchImportLDAP)
|
||||||
}
|
}
|
||||||
@@ -150,7 +151,6 @@ func SetupRouter() *gin.Engine {
|
|||||||
authorized.DELETE("/hosts/:id", hostCtrl.Delete)
|
authorized.DELETE("/hosts/:id", hostCtrl.Delete)
|
||||||
authorized.POST("/hosts/:id/validate", hostCtrl.Validate)
|
authorized.POST("/hosts/:id/validate", hostCtrl.Validate)
|
||||||
authorized.GET("/hosts/:id/info", hostCtrl.GetInfo)
|
authorized.GET("/hosts/:id/info", hostCtrl.GetInfo)
|
||||||
authorized.POST("/hosts/:id/execute", hostCtrl.Execute)
|
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
authorized.GET("/storages", storageCtrl.List)
|
authorized.GET("/storages", storageCtrl.List)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ package seed
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type builtinRule struct {
|
type builtinRule struct {
|
||||||
@@ -26,7 +26,7 @@ var builtinRules = []builtinRule{
|
|||||||
{name: "Go panic", query: `{namespace=~".+"} |= "panic:"`, windowMin: 15, threshold: 0, cooldown: 60},
|
{name: "Go panic", query: `{namespace=~".+"} |= "panic:"`, windowMin: 15, threshold: 0, cooldown: 60},
|
||||||
{name: "JVM OutOfMemoryError", query: `{namespace=~".+"} |~ "(^|[^a-zA-Z])OutOfMemoryError"`, windowMin: 15, threshold: 0, cooldown: 60},
|
{name: "JVM OutOfMemoryError", query: `{namespace=~".+"} |~ "(^|[^a-zA-Z])OutOfMemoryError"`, windowMin: 15, threshold: 0, cooldown: 60},
|
||||||
{name: "StackOverflowError", query: `{namespace=~".+"} |= "StackOverflowError"`, windowMin: 15, threshold: 0, cooldown: 60},
|
{name: "StackOverflowError", query: `{namespace=~".+"} |= "StackOverflowError"`, windowMin: 15, threshold: 0, cooldown: 60},
|
||||||
{name: "K8s 节点 NotReady", query: `{app="kube-state-metrics"} |= "NotReady"`, windowMin: 15, threshold: 0, cooldown: 120},
|
{name: "Kubernetes 节点 NotReady", query: `{app="kube-state-metrics"} |= "NotReady"`, windowMin: 15, threshold: 0, cooldown: 120},
|
||||||
{name: "容器 OOMKilled", query: `{app=~"kube-state-metrics|event-exporter"} |= "OOMKilled"`, windowMin: 15, threshold: 0, cooldown: 120},
|
{name: "容器 OOMKilled", query: `{app=~"kube-state-metrics|event-exporter"} |= "OOMKilled"`, windowMin: 15, threshold: 0, cooldown: 120},
|
||||||
// —— 突增类:超过基线阈值才报 ——
|
// —— 突增类:超过基线阈值才报 ——
|
||||||
{name: "应用错误过多(error > 1000)", query: `{namespace=~".+"} |~ "(?i)\berror\b"`, windowMin: 15, threshold: 1000, cooldown: 60},
|
{name: "应用错误过多(error > 1000)", query: `{namespace=~".+"} |~ "(?i)\berror\b"`, windowMin: 15, threshold: 1000, cooldown: 60},
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitPasswordFile 首次启动生成的随机管理员密码,存放于数据卷根目录。
|
// InitPasswordFile 首次启动生成的随机管理员密码,存放于数据卷根目录。
|
||||||
@@ -75,7 +75,7 @@ func Seed() {
|
|||||||
log.Printf(" 已保存 : %s", path)
|
log.Printf(" 已保存 : %s", path)
|
||||||
log.Printf(" 获取方式:")
|
log.Printf(" 获取方式:")
|
||||||
log.Printf(" docker compose exec <服务名> cat %s", path)
|
log.Printf(" docker compose exec <服务名> cat %s", path)
|
||||||
log.Printf(" kubectl -n <ns> exec deploy/opsx-loki -- cat %s", path)
|
log.Printf(" kubectl -n <ns> exec deploy/lokistack -- cat %s", path)
|
||||||
log.Printf(" ⚠️ 首次登录后请立即修改密码(改密后本文件自动删除)")
|
log.Printf(" ⚠️ 首次登录后请立即修改密码(改密后本文件自动删除)")
|
||||||
log.Printf("==================================================")
|
log.Printf("==================================================")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
const alertCheckInterval = time.Minute
|
const alertCheckInterval = time.Minute
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/pkg/jwt"
|
"lokistack/pkg/jwt"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthService struct{}
|
type AuthService struct{}
|
||||||
@@ -182,6 +182,29 @@ func (s *AuthService) ListUsers(page, pageSize int) ([]models.User, int64) {
|
|||||||
return users, total
|
return users, total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchDeleteUsers 批量删除用户;逐个应用 DeleteUser 守卫(不能删自己/最后一个 admin),
|
||||||
|
// 返回成功数与失败明细(id + 原因),单个失败不阻断其余。
|
||||||
|
func (s *AuthService) BatchDeleteUsers(ids []uint, requesterID uint) (int, []map[string]interface{}) {
|
||||||
|
seen := make(map[uint]bool, len(ids))
|
||||||
|
deleted := 0
|
||||||
|
failures := make([]map[string]interface{}, 0)
|
||||||
|
for _, id := range ids {
|
||||||
|
if seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
if err := s.DeleteUser(id, requesterID); err != nil {
|
||||||
|
failures = append(failures, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"reason": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
return deleted, failures
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteUser 删除用户;不能删除自己,不能删除最后一个管理员
|
// DeleteUser 删除用户;不能删除自己,不能删除最后一个管理员
|
||||||
func (s *AuthService) DeleteUser(id, requesterID uint) error {
|
func (s *AuthService) DeleteUser(id, requesterID uint) error {
|
||||||
db := repository.GetDB()
|
db := repository.GetDB()
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/config"
|
"lokistack/config"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ClusterService struct{}
|
type ClusterService struct{}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ClusterDeploymentOption struct {
|
type ClusterDeploymentOption struct {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CollectorService struct{}
|
type CollectorService struct{}
|
||||||
@@ -75,7 +75,14 @@ func (s *CollectorService) generateConfigSnapshot(c *models.Collector) string {
|
|||||||
case "host-promtail":
|
case "host-promtail":
|
||||||
return PromtailConfig(endpoint, c.LogPath, "host", c.Labels)
|
return PromtailConfig(endpoint, c.LogPath, "host", c.Labels)
|
||||||
case "k8s-daemonset":
|
case "k8s-daemonset":
|
||||||
return PromtailHelmValues(endpoint, clusterName, c.Labels)
|
ns := c.Namespace
|
||||||
|
if ns == "" && c.LokiDeployment != nil {
|
||||||
|
ns = c.LokiDeployment.Namespace
|
||||||
|
}
|
||||||
|
if ns == "" {
|
||||||
|
ns = "log"
|
||||||
|
}
|
||||||
|
return PromtailDaemonsetYAML(ns, endpoint, clusterName, c.Labels, c.TargetNamespaces, c.AppLabelSource, c.AppInclude)
|
||||||
case "k8s-fluentbit-sidecar":
|
case "k8s-fluentbit-sidecar":
|
||||||
host, port := parseLokiURL(endpoint)
|
host, port := parseLokiURL(endpoint)
|
||||||
app := c.TargetDeployment
|
app := c.TargetDeployment
|
||||||
@@ -101,7 +108,7 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto
|
|||||||
return nil, fmt.Errorf("主机采集器需要选择目标主机")
|
return nil, fmt.Errorf("主机采集器需要选择目标主机")
|
||||||
}
|
}
|
||||||
if (req.Type == "k8s-daemonset" || req.Type == "k8s-fluentbit-sidecar") && (req.ClusterID == nil || *req.ClusterID == 0) {
|
if (req.Type == "k8s-daemonset" || req.Type == "k8s-fluentbit-sidecar") && (req.ClusterID == nil || *req.ClusterID == 0) {
|
||||||
return nil, fmt.Errorf("K8s 采集器需要选择目标集群")
|
return nil, fmt.Errorf("Kubernetes 采集器需要选择目标集群")
|
||||||
}
|
}
|
||||||
if req.Type == "k8s-fluentbit-sidecar" && req.TargetDeployment == "" {
|
if req.Type == "k8s-fluentbit-sidecar" && req.TargetDeployment == "" {
|
||||||
return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称")
|
return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CollectorDeployer 采集器部署器,按 type 分发到三种安装/卸载流程
|
// CollectorDeployer 采集器部署器,按 type 分发到三种安装/卸载流程
|
||||||
@@ -178,7 +178,7 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== k8s-daemonset:helm 安装 Promtail DaemonSet =====
|
// ===== k8s-daemonset:以原生清单安装 Promtail DaemonSet =====
|
||||||
|
|
||||||
func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error {
|
func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error {
|
||||||
cluster := d.collector.Cluster
|
cluster := d.collector.Cluster
|
||||||
@@ -254,7 +254,7 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
|
|||||||
ns = "log"
|
ns = "log"
|
||||||
}
|
}
|
||||||
// clusterrole / clusterrolebinding 用 loki-promtail;
|
// clusterrole / clusterrolebinding 用 loki-promtail;
|
||||||
// 旧版本曾用 opsxloki-promtail,这里一并清理以免升级后留下孤儿资源。
|
// 数组末两项是历史遗留命名,一并清理避免升级后留下孤儿资源。
|
||||||
for _, item := range [][2]string{
|
for _, item := range [][2]string{
|
||||||
{"daemonset", "promtail"}, {"configmap", "promtail"}, {"serviceaccount", "promtail"},
|
{"daemonset", "promtail"}, {"configmap", "promtail"}, {"serviceaccount", "promtail"},
|
||||||
{"clusterrole", "loki-promtail"}, {"clusterrolebinding", "loki-promtail"},
|
{"clusterrole", "loki-promtail"}, {"clusterrolebinding", "loki-promtail"},
|
||||||
|
|||||||
@@ -83,120 +83,9 @@ scrape_configs:
|
|||||||
return strings.Replace(conf, "__EXT_LABELS__", promtailExternalLabels(cluster, labelsJSON), 1)
|
return strings.Replace(conf, "__EXT_LABELS__", promtailExternalLabels(cluster, labelsJSON), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromtailHelmValues DaemonSet 模式 Promtail Helm values(生产级)
|
|
||||||
// 包含:cri解析、kubernetes标签、批处理配置、资源限制
|
|
||||||
func PromtailHelmValues(lokiEndpoint, cluster, labelsJSON string) string {
|
|
||||||
values := fmt.Sprintf(`config:
|
|
||||||
clients:
|
|
||||||
- url: %s/loki/api/v1/push
|
|
||||||
batchwait: 1s
|
|
||||||
batchsize: 1048576
|
|
||||||
timeout: 10s
|
|
||||||
backoff_config:
|
|
||||||
min_period: 500ms
|
|
||||||
max_period: 5m
|
|
||||||
max_retries: 10
|
|
||||||
external_labels:
|
|
||||||
__EXT_LABELS__
|
|
||||||
positions:
|
|
||||||
filename: /run/promtail/positions.yaml
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: kubernetes-pods
|
|
||||||
kubernetes_sd_configs:
|
|
||||||
- role: pod
|
|
||||||
pipeline_stages:
|
|
||||||
- cri: {}
|
|
||||||
- match:
|
|
||||||
selector: '{app!=""}'
|
|
||||||
stages:
|
|
||||||
- docker: {}
|
|
||||||
- match:
|
|
||||||
selector: '{app!=""}'
|
|
||||||
stages:
|
|
||||||
- labels:
|
|
||||||
app:
|
|
||||||
namespace:
|
|
||||||
pod:
|
|
||||||
container:
|
|
||||||
- limit:
|
|
||||||
rate: 1000
|
|
||||||
burst: 10000
|
|
||||||
drop: true
|
|
||||||
relabel_configs:
|
|
||||||
- source_labels: [__meta_kubernetes_pod_phase]
|
|
||||||
regex: (Succeeded|Failed)
|
|
||||||
action: drop
|
|
||||||
- source_labels: [__meta_kubernetes_pod_controller_name]
|
|
||||||
regex: ([0-9a-z-.]+?)(-[0-9a-f]{8,10})?
|
|
||||||
action: replace
|
|
||||||
target_label: __tmp_controller_name
|
|
||||||
- source_labels:
|
|
||||||
- __meta_kubernetes_pod_label_app_kubernetes_io_name
|
|
||||||
- __meta_kubernetes_pod_label_app
|
|
||||||
- __tmp_controller_name
|
|
||||||
- __meta_kubernetes_pod_name
|
|
||||||
regex: ^;*([^;]+)(;.*)?$
|
|
||||||
action: replace
|
|
||||||
target_label: app
|
|
||||||
- source_labels: [__meta_kubernetes_pod_annotation_kubernetes_io_config_hash]
|
|
||||||
regex: .+
|
|
||||||
action: replace
|
|
||||||
target_label: config_hash
|
|
||||||
- action: replace
|
|
||||||
source_labels: ['__meta_kubernetes_pod_name']
|
|
||||||
target_label: 'pod'
|
|
||||||
- action: replace
|
|
||||||
source_labels: ['__meta_kubernetes_namespace']
|
|
||||||
target_label: 'namespace'
|
|
||||||
- action: replace
|
|
||||||
source_labels: ['__meta_kubernetes_pod_container_name']
|
|
||||||
target_label: 'container'
|
|
||||||
- action: replace
|
|
||||||
source_labels: ['__meta_kubernetes_pod_node_name']
|
|
||||||
target_label: 'node_name'
|
|
||||||
- replacement: /var/log/pods/*$1/*.log
|
|
||||||
separator: /
|
|
||||||
source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name]
|
|
||||||
target_label: __path__
|
|
||||||
|
|
||||||
daemonset:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 512Mi
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 128Mi
|
|
||||||
|
|
||||||
extraVolumes:
|
|
||||||
- name: varlog
|
|
||||||
hostPath:
|
|
||||||
path: /var/log
|
|
||||||
- name: varlibdockercontainers
|
|
||||||
hostPath:
|
|
||||||
path: /var/lib/docker/containers
|
|
||||||
- name: promtail-run
|
|
||||||
hostPath:
|
|
||||||
path: /run/promtail
|
|
||||||
|
|
||||||
extraVolumeMounts:
|
|
||||||
- name: varlog
|
|
||||||
mountPath: /var/log
|
|
||||||
readOnly: true
|
|
||||||
- name: varlibdockercontainers
|
|
||||||
mountPath: /var/lib/docker/containers
|
|
||||||
readOnly: true
|
|
||||||
- name: promtail-run
|
|
||||||
mountPath: /run/promtail
|
|
||||||
`, lokiEndpoint)
|
|
||||||
return strings.Replace(values, "__EXT_LABELS__", promtailExternalLabels(cluster, labelsJSON), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PromtailDaemonsetYAML DaemonSet 模式纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet),
|
// PromtailDaemonsetYAML DaemonSet 模式纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet),
|
||||||
// 取代 helm 安装 —— 后端无需安装 helm 二进制。
|
// 经 Kubernetes API 直接提交,后端无需任何额外二进制。
|
||||||
// relabel 体系与主流 helm chart 对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。
|
// relabel 体系与主流 Promtail 部署惯例对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。
|
||||||
// sanitizePromLabel 把 K8s 标签名转成 Prometheus/Promtail 的 meta label 片段。
|
// sanitizePromLabel 把 K8s 标签名转成 Prometheus/Promtail 的 meta label 片段。
|
||||||
//
|
//
|
||||||
// k8s.kuboard.cn/name → k8s_kuboard_cn_name
|
// k8s.kuboard.cn/name → k8s_kuboard_cn_name
|
||||||
@@ -365,7 +254,7 @@ data:
|
|||||||
# 4) pod name ← 最后兜底,保证 app 永不为空
|
# 4) pod name ← 最后兜底,保证 app 永不为空
|
||||||
#
|
#
|
||||||
# 不使用 k8s.kuboard.cn/name 之类的平台私有标签:那只是 Kuboard 管理的集群才有,
|
# 不使用 k8s.kuboard.cn/name 之类的平台私有标签:那只是 Kuboard 管理的集群才有,
|
||||||
# 换 Rancher/Lens/Helm/kubectl 部署的集群就不存在,会破坏通用性。
|
# 换其他方式部署的集群就不存在,会破坏通用性。
|
||||||
# 如集群确实有私有规范标签,可在「采集器 → 编辑」里配置 AppLabelSource,
|
# 如集群确实有私有规范标签,可在「采集器 → 编辑」里配置 AppLabelSource,
|
||||||
# 它会作为最高优先级插到下面列表最前(其余仍兜底)。
|
# 它会作为最高优先级插到下面列表最前(其余仍兜底)。
|
||||||
- source_labels:
|
- source_labels:
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/seed"
|
"lokistack/seed"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LokiVersionList 支持的 Loki 版本列表(按推荐程度排序)
|
// LokiVersionList 支持的 Loki 版本列表(按推荐程度排序)
|
||||||
@@ -569,19 +569,14 @@ func (s *DeploymentService) loadRelations(d *models.LokiDeployment) {
|
|||||||
|
|
||||||
func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
|
func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
|
||||||
switch d.Mode {
|
switch d.Mode {
|
||||||
case "k8s-helm", "k8s-yaml":
|
case "k8s-yaml":
|
||||||
ns := d.Namespace
|
ns := d.Namespace
|
||||||
if ns == "" {
|
if ns == "" {
|
||||||
ns = "loki"
|
ns = "log"
|
||||||
}
|
}
|
||||||
// k8s-yaml manifest 固定创建名为 loki 的 Service;
|
// k8s-yaml manifest 固定创建名为 loki 的 Service。
|
||||||
// k8s-helm (grafana/loki) 以 release 名(=部署名,默认 loki)暴露 Service。
|
|
||||||
// 注意:不使用 loki-gateway,gateway 仅在 Distributed+gateway 启用时存在。
|
// 注意:不使用 loki-gateway,gateway 仅在 Distributed+gateway 启用时存在。
|
||||||
svc := "loki"
|
return fmt.Sprintf("http://loki.%s.svc.cluster.local:3100", ns)
|
||||||
if d.Mode == "k8s-helm" && d.Name != "" {
|
|
||||||
svc = d.Name
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("http://%s.%s.svc.cluster.local:3100", svc, ns)
|
|
||||||
case "host-binary":
|
case "host-binary":
|
||||||
if d.Host != nil {
|
if d.Host != nil {
|
||||||
return fmt.Sprintf("http://%s:3100", d.Host.HostIP)
|
return fmt.Sprintf("http://%s:3100", d.Host.HostIP)
|
||||||
@@ -720,7 +715,7 @@ func (s *DeploymentService) recordGrafanaEndpoint(d *models.LokiDeployment) {
|
|||||||
}
|
}
|
||||||
var endpoint string
|
var endpoint string
|
||||||
switch d.Mode {
|
switch d.Mode {
|
||||||
case "k8s-helm", "k8s-yaml":
|
case "k8s-yaml":
|
||||||
if d.Cluster == nil {
|
if d.Cluster == nil {
|
||||||
s.AddLog(d.ID, "warn", "Grafana 地址记录跳过:未关联集群", "")
|
s.AddLog(d.ID, "warn", "Grafana 地址记录跳过:未关联集群", "")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ func (s *DeploymentService) GetLiveConfig(ctx context.Context, id uint) (*LiveLo
|
|||||||
location = dockerConfigPath(d.ID)
|
location = dockerConfigPath(d.ID)
|
||||||
output, _, err := client.Execute(ctx, "cat "+location)
|
output, _, err := client.Execute(ctx, "cat "+location)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("无法读取主机上的 Loki 配置")
|
// 历史版本路径兜底(重部署一次后目录即迁移到新命名)
|
||||||
|
location = legacyDockerConfigDir(d.ID) + "/loki.yaml"
|
||||||
|
if output, _, err = client.Execute(ctx, "cat "+location); err != nil {
|
||||||
|
return nil, fmt.Errorf("无法读取主机上的 Loki 配置")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
raw = output
|
raw = output
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRedactLokiYAML(t *testing.T) {
|
func TestRedactLokiYAML(t *testing.T) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseRetentionYAML(t *testing.T) {
|
func TestParseRetentionYAML(t *testing.T) {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HostService struct{}
|
type HostService struct{}
|
||||||
@@ -190,19 +190,3 @@ func (s *HostService) Validate(id uint) {
|
|||||||
s.Update(id, map[string]interface{}{"status": "active"})
|
s.Update(id, map[string]interface{}{"status": "active"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HostService) ExecuteCommand(id uint, command string) (string, string, error) {
|
|
||||||
host, err := s.Get(id)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
return client.Execute(ctx, command)
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/util/intstr"
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ingressNamespace = "ingress-nginx"
|
const ingressNamespace = "ingress-nginx"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
@@ -54,7 +54,7 @@ func clusterRESTConfig(cluster *models.Cluster, timeout time.Duration) (*rest.Co
|
|||||||
return nil, fmt.Errorf("构造 Kubernetes 客户端失败: %w", err)
|
return nil, fmt.Errorf("构造 Kubernetes 客户端失败: %w", err)
|
||||||
}
|
}
|
||||||
result.Timeout = timeout
|
result.Timeout = timeout
|
||||||
result.UserAgent = "LokiDeploy"
|
result.UserAgent = "lokistack"
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
kcScheme "k8s.io/client-go/kubernetes/scheme"
|
kcScheme "k8s.io/client-go/kubernetes/scheme"
|
||||||
"k8s.io/client-go/restmapper"
|
"k8s.io/client-go/restmapper"
|
||||||
"k8s.io/client-go/tools/remotecommand"
|
"k8s.io/client-go/tools/remotecommand"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ensureNamespace(ctx context.Context, cluster *models.Cluster, namespace string) error {
|
func ensureNamespace(ctx context.Context, cluster *models.Cluster, namespace string) error {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package services — LDAP 接入:配置存储(SystemSetting 表 JSON)、连接测试、
|
// Package services — LDAP 接入:配置存储(SystemSetting 表 JSON)、连接测试、
|
||||||
// 用户搜索预览、批量导入与登录验证共用同一套连接/搜索代码。
|
// 用户搜索预览、批量导入与登录验证共用同一套连接/搜索代码。
|
||||||
// 设计参照 opsx-web-lite(has_password 脱敏/空密码保留旧值/批量导入冲突策略)。
|
// 约定:has_password 脱敏、空密码保留旧值、批量导入冲突策略。
|
||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
|
|
||||||
ldap "github.com/go-ldap/ldap/v3"
|
ldap "github.com/go-ldap/ldap/v3"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package services
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LDAPImportUser 批量导入请求中的用户条目(数据来自预览接口)
|
// LDAPImportUser 批量导入请求中的用户条目(数据来自预览接口)
|
||||||
@@ -16,7 +16,7 @@ type LDAPImportUser struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LDAPImportResult 导入结果(对齐 opsx-web-lite 契约)
|
// LDAPImportResult 导入结果(与前端展示契约对齐)
|
||||||
type LDAPImportResult struct {
|
type LDAPImportResult struct {
|
||||||
SuccessCount int `json:"successCount"`
|
SuccessCount int `json:"successCount"`
|
||||||
SkippedCount int `json:"skippedCount"`
|
SkippedCount int `json:"skippedCount"`
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ListLogPolicyGroups() []models.LogPolicyGroup {
|
func ListLogPolicyGroups() []models.LogPolicyGroup {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== 数据源 CRUD ====================
|
// ==================== 数据源 CRUD ====================
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }}
|
const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LokiDeployer 负责 Loki 的 Kubernetes、主机 Docker 及历史部署模式。
|
// LokiDeployer 负责 Loki 的 Kubernetes、主机 Docker 及历史部署模式。
|
||||||
@@ -169,7 +169,7 @@ WantedBy=multi-user.target
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Helm Values 生成 ====================
|
// ==================== 部署参数与版本校验 ====================
|
||||||
|
|
||||||
func (d *LokiDeployer) effectiveVersion() string {
|
func (d *LokiDeployer) effectiveVersion() string {
|
||||||
if d.deployment.Version != "" {
|
if d.deployment.Version != "" {
|
||||||
@@ -226,7 +226,7 @@ func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) {
|
|||||||
_ = deleteK8sResource(ctx, deployment.Cluster, item[0], ns, item[1])
|
_ = deleteK8sResource(ctx, deployment.Cluster, item[0], ns, item[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 历史 Helm release 不由此版本自动删除,避免误删旧数据。
|
// 仅清理本产品创建的资源;其他途径部署的遗留资源不自动删除,避免误删数据。
|
||||||
|
|
||||||
if deployment.Mode == "host-binary" && deployment.Host != nil {
|
if deployment.Mode == "host-binary" && deployment.Host != nil {
|
||||||
host := deployment.Host
|
host := deployment.Host
|
||||||
|
|||||||
@@ -7,13 +7,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func dockerContainerName(id uint) string { return fmt.Sprintf("lokideploy-loki-%d", id) }
|
func dockerContainerName(id uint) string { return fmt.Sprintf("lokistack-loki-%d", id) }
|
||||||
func dockerConfigDir(id uint) string { return fmt.Sprintf("/etc/lokideploy/loki-%d", id) }
|
func dockerConfigDir(id uint) string { return fmt.Sprintf("/etc/lokistack/loki-%d", id) }
|
||||||
func dockerConfigPath(id uint) string { return dockerConfigDir(id) + "/loki.yaml" }
|
func dockerConfigPath(id uint) string { return dockerConfigDir(id) + "/loki.yaml" }
|
||||||
func dockerDataDir(id uint) string { return fmt.Sprintf("/var/lib/lokideploy/loki-%d", id) }
|
func dockerDataDir(id uint) string { return fmt.Sprintf("/var/lib/lokistack/loki-%d", id) }
|
||||||
|
|
||||||
|
// 历史命名的容器与目录 — 仅用于迁移与清理,
|
||||||
|
// 避免已部署实例在升级后留下孤儿容器/目录。
|
||||||
|
func legacyDockerContainerName(id uint) string { return fmt.Sprintf("lokideploy-loki-%d", id) }
|
||||||
|
func legacyDockerConfigDir(id uint) string { return fmt.Sprintf("/etc/lokideploy/loki-%d", id) }
|
||||||
|
func legacyDockerDataDir(id uint) string { return fmt.Sprintf("/var/lib/lokideploy/loki-%d", id) }
|
||||||
|
|
||||||
func dockerLokiImage(version string) (string, error) {
|
func dockerLokiImage(version string) (string, error) {
|
||||||
if version == "" {
|
if version == "" {
|
||||||
@@ -39,6 +45,8 @@ func stopDockerLoki(ctx context.Context, deployment *models.LokiDeployment) erro
|
|||||||
if _, stderr, err := client.Execute(ctx, "docker stop "+dockerContainerName(deployment.ID)); err != nil {
|
if _, stderr, err := client.Execute(ctx, "docker stop "+dockerContainerName(deployment.ID)); err != nil {
|
||||||
return fmt.Errorf("停止 Loki 容器失败: %v: %s", err, strings.TrimSpace(stderr))
|
return fmt.Errorf("停止 Loki 容器失败: %v: %s", err, strings.TrimSpace(stderr))
|
||||||
}
|
}
|
||||||
|
// 历史命名的同名容器一并停掉(可能不存在,尽力而为)
|
||||||
|
client.Execute(ctx, "docker stop "+legacyDockerContainerName(deployment.ID)+" >/dev/null 2>&1 || true")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +60,13 @@ func deleteDockerLoki(ctx context.Context, deployment *models.LokiDeployment) er
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
name := dockerContainerName(deployment.ID)
|
name := dockerContainerName(deployment.ID)
|
||||||
|
legacyName := legacyDockerContainerName(deployment.ID)
|
||||||
if _, _, err := client.Execute(ctx, "docker info >/dev/null 2>&1"); err != nil {
|
if _, _, err := client.Execute(ctx, "docker info >/dev/null 2>&1"); err != nil {
|
||||||
return fmt.Errorf("目标主机 Docker 不可用,无法安全清理部署")
|
return fmt.Errorf("目标主机 Docker 不可用,无法安全清理部署")
|
||||||
}
|
}
|
||||||
// 仅删除该部署的容器和配置;日志数据目录保留,以便人工恢复。
|
// 仅删除该部署的容器和配置(含历史命名);日志数据目录保留,以便人工恢复。
|
||||||
cmd := "if docker container inspect " + name + " >/dev/null 2>&1; then docker rm -f " + name + " >/dev/null || exit 1; fi; rm -rf -- " + dockerConfigDir(deployment.ID)
|
cmd := "for n in " + name + " " + legacyName + "; do if docker container inspect $n >/dev/null 2>&1; then docker rm -f $n >/dev/null || exit 1; fi; done; rm -rf -- " +
|
||||||
|
dockerConfigDir(deployment.ID) + " " + legacyDockerConfigDir(deployment.ID)
|
||||||
if _, stderr, err := client.Execute(ctx, cmd); err != nil {
|
if _, stderr, err := client.Execute(ctx, cmd); err != nil {
|
||||||
return fmt.Errorf("清理 Loki 容器与配置失败: %v: %s", err, strings.TrimSpace(stderr))
|
return fmt.Errorf("清理 Loki 容器与配置失败: %v: %s", err, strings.TrimSpace(stderr))
|
||||||
}
|
}
|
||||||
@@ -94,6 +104,16 @@ func (d *LokiDeployer) deployDocker(ctx context.Context) error {
|
|||||||
name := dockerContainerName(deployment.ID)
|
name := dockerContainerName(deployment.ID)
|
||||||
configDir := dockerConfigDir(deployment.ID)
|
configDir := dockerConfigDir(deployment.ID)
|
||||||
dataDir := dockerDataDir(deployment.ID)
|
dataDir := dockerDataDir(deployment.ID)
|
||||||
|
// 历史命名目录原子迁移到新命名;存在才迁,不覆盖已有数据
|
||||||
|
migrate := fmt.Sprintf(
|
||||||
|
"if [ -d %s ] && [ ! -d %s ]; then mkdir -p /etc/lokistack && mv %s %s; fi; "+
|
||||||
|
"if [ -d %s ] && [ ! -d %s ]; then mkdir -p /var/lib/lokistack && mv %s %s; fi",
|
||||||
|
legacyDockerConfigDir(deployment.ID), configDir, legacyDockerConfigDir(deployment.ID), configDir,
|
||||||
|
legacyDockerDataDir(deployment.ID), dataDir, legacyDockerDataDir(deployment.ID), dataDir,
|
||||||
|
)
|
||||||
|
if _, stderr, err := client.Execute(ctx, migrate); err != nil {
|
||||||
|
return fmt.Errorf("迁移历史部署目录失败(可手动执行: %s): %v: %s", migrate, err, strings.TrimSpace(stderr))
|
||||||
|
}
|
||||||
if _, _, err := client.Execute(ctx, "mkdir -p "+configDir+" "+dataDir+" && chmod 755 "+configDir+" && chown 10001:10001 "+dataDir); err != nil {
|
if _, _, err := client.Execute(ctx, "mkdir -p "+configDir+" "+dataDir+" && chmod 755 "+configDir+" && chown 10001:10001 "+dataDir); err != nil {
|
||||||
return fmt.Errorf("创建主机持久化目录失败: %w", err)
|
return fmt.Errorf("创建主机持久化目录失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -118,8 +138,8 @@ func (d *LokiDeployer) deployDocker(ctx context.Context) error {
|
|||||||
if _, stderr, err := client.Execute(ctx, "docker pull "+image); err != nil {
|
if _, stderr, err := client.Execute(ctx, "docker pull "+image); err != nil {
|
||||||
return fmt.Errorf("拉取 Loki 镜像失败: %v: %s", err, strings.TrimSpace(stderr))
|
return fmt.Errorf("拉取 Loki 镜像失败: %v: %s", err, strings.TrimSpace(stderr))
|
||||||
}
|
}
|
||||||
// 同一部署 ID 的重部署只替换容器,配置和日志数据目录保留。
|
// 同一部署 ID 的重部署只替换容器(含历史命名),配置和日志数据目录保留。
|
||||||
client.Execute(ctx, "docker rm -f "+name+" >/dev/null 2>&1 || true")
|
client.Execute(ctx, "docker rm -f "+name+" "+legacyDockerContainerName(deployment.ID)+" >/dev/null 2>&1 || true")
|
||||||
port := deployment.ListenPort
|
port := deployment.ListenPort
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
port = 3100
|
port = 3100
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDockerLokiConfigUsesEnvironmentForStorageCredentials(t *testing.T) {
|
func TestDockerLokiConfigUsesEnvironmentForStorageCredentials(t *testing.T) {
|
||||||
@@ -24,7 +24,7 @@ func TestDockerLokiConfigUsesEnvironmentForStorageCredentials(t *testing.T) {
|
|||||||
t.Errorf("config missing %q", expected)
|
t.Errorf("config missing %q", expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := dockerContainerName(deployment.ID); got != "lokideploy-loki-7" {
|
if got := dockerContainerName(deployment.ID); got != "lokistack-loki-7" {
|
||||||
t.Fatalf("unexpected container name: %s", got)
|
t.Fatalf("unexpected container name: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (d *LokiDeployer) generateManifest(ns string) string {
|
func (d *LokiDeployer) generateManifest(ns string) string {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestObjectStoreConfigs 验证 OSS / COS / OBS 生成的 Loki 配置:
|
// TestObjectStoreConfigs 验证 OSS / COS / OBS 生成的 Loki 配置:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
// 飞书/Lark 群机器人通知 — 卡片消息(参照 opsx-sec notifier 与运维告警脚本的卡片结构),
|
// 飞书/Lark 群机器人通知 — 卡片消息,
|
||||||
// webhook 存 SystemSetting(AES 加密),对外只回 hasWebhook。
|
// webhook 存 SystemSetting(AES 加密),对外只回 hasWebhook。
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -13,9 +13,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -116,7 +116,7 @@ func SendLarkCard(title, color string, lines []string) error {
|
|||||||
},
|
},
|
||||||
"elements": []interface{}{
|
"elements": []interface{}{
|
||||||
map[string]interface{}{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": content}},
|
map[string]interface{}{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": content}},
|
||||||
map[string]interface{}{"tag": "note", "elements": []interface{}{map[string]string{"tag": "plain_text", "content": "opsx-loki 日志告警"}}},
|
map[string]interface{}{"tag": "note", "elements": []interface{}{map[string]string{"tag": "plain_text", "content": "LokiStack 日志告警"}}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
const channelHTTPTimeout = 10 * time.Second
|
const channelHTTPTimeout = 10 * time.Second
|
||||||
@@ -182,7 +182,7 @@ func SendLarkCardToChannel(channelID uint, title, color string, lines []string)
|
|||||||
},
|
},
|
||||||
"elements": []interface{}{
|
"elements": []interface{}{
|
||||||
map[string]interface{}{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": content}},
|
map[string]interface{}{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": content}},
|
||||||
map[string]interface{}{"tag": "note", "elements": []interface{}{map[string]string{"tag": "plain_text", "content": "opsx-loki 日志告警"}}},
|
map[string]interface{}{"tag": "note", "elements": []interface{}{map[string]string{"tag": "plain_text", "content": "LokiStack 日志告警"}}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/config"
|
"lokistack/config"
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"opsxloki/models"
|
"lokistack/models"
|
||||||
"opsxloki/pkg/crypto"
|
"lokistack/pkg/crypto"
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
"opsxloki/utils"
|
"lokistack/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StorageService struct{}
|
type StorageService struct{}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"opsxloki/repository"
|
"lokistack/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStorageUpdateReplacesBothCredentials(t *testing.T) {
|
func TestStorageUpdateReplacesBothCredentials(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# opsx-loki Build Script v2
|
# lokistack Build Script
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Builds frontend and backend into a single deployable package
|
# Builds frontend and backend into a single deployable package
|
||||||
# with security hardening and integrity checks.
|
# with security hardening and integrity checks.
|
||||||
@@ -16,7 +16,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " opsx-loki Build Script v2"
|
echo " lokistack Build Script"
|
||||||
echo " Security-hardened build pipeline"
|
echo " Security-hardened build pipeline"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|
||||||
@@ -104,10 +104,10 @@ GIT_COMMIT=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "un
|
|||||||
|
|
||||||
$GO_CMD build -trimpath \
|
$GO_CMD build -trimpath \
|
||||||
-ldflags="-s -w -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT} -extldflags '-static'" \
|
-ldflags="-s -w -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT} -extldflags '-static'" \
|
||||||
-o bin/opsxloki .
|
-o bin/lokistack .
|
||||||
|
|
||||||
echo " Backend built: backend/bin/opsxloki"
|
echo " Backend built: backend/bin/lokistack"
|
||||||
echo " Binary size: $(du -h bin/opsxloki | cut -f1)"
|
echo " Binary size: $(du -h bin/lokistack | cut -f1)"
|
||||||
echo " Build time: $BUILD_TIME"
|
echo " Build time: $BUILD_TIME"
|
||||||
echo " Git commit: $GIT_COMMIT"
|
echo " Git commit: $GIT_COMMIT"
|
||||||
|
|
||||||
@@ -124,8 +124,8 @@ echo "========================================"
|
|||||||
echo " Build Complete!"
|
echo " Build Complete!"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " Frontend: backend/dist/"
|
echo " Frontend: backend/dist/"
|
||||||
echo " Backend: backend/bin/opsxloki"
|
echo " Backend: backend/bin/lokistack"
|
||||||
echo " Size: $(du -h backend/bin/opsxloki | cut -f1)"
|
echo " Size: $(du -h backend/bin/lokistack | cut -f1)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " SECURITY REMINDERS"
|
echo " SECURITY REMINDERS"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# opsx-loki Secure Entrypoint
|
# lokistack Secure Entrypoint
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Enforces required secrets at container startup
|
# Enforces required secrets at container startup
|
||||||
# Supports Docker Secrets file-based injection
|
# Supports Docker Secrets file-based injection
|
||||||
@@ -43,7 +43,7 @@ if [ ${#MISSING_VARS[@]} -ne 0 ]; then
|
|||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "These MUST be provided at runtime. Examples:" >&2
|
echo "These MUST be provided at runtime. Examples:" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo ' docker run -e JWT_SECRET="$(openssl rand -hex 32)" -e ENCRYPTION_KEY="$(openssl rand -hex 32)" opsxloki' >&2
|
echo ' docker run -e JWT_SECRET="$(openssl rand -hex 32)" -e ENCRYPTION_KEY="$(openssl rand -hex 32)" lokistack' >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "Or via docker-compose secrets (recommended for production):" >&2
|
echo "Or via docker-compose secrets (recommended for production):" >&2
|
||||||
echo ' secrets:' >&2
|
echo ' secrets:' >&2
|
||||||
@@ -85,7 +85,7 @@ shutdown() {
|
|||||||
}
|
}
|
||||||
trap shutdown SIGTERM SIGINT
|
trap shutdown SIGTERM SIGINT
|
||||||
|
|
||||||
echo "opsx-loki starting with security configuration OK"
|
echo "lokistack starting with security configuration OK"
|
||||||
echo "Data directory: ${DATA_DIR}"
|
echo "Data directory: ${DATA_DIR}"
|
||||||
echo "Server port: ${SERVER_PORT}"
|
echo "Server port: ${SERVER_PORT}"
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ spec:
|
|||||||
persistentVolumeClaim: {claimName: lokistack-data}
|
persistentVolumeClaim: {claimName: lokistack-data}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
kubectl -n log cp ./opsxloki.json pvc-loader:/data/opsxloki.json
|
kubectl -n log cp ./lokistack.json pvc-loader:/data/lokistack.json
|
||||||
kubectl -n log cp ./known_hosts pvc-loader:/data/known_hosts
|
kubectl -n log cp ./known_hosts pvc-loader:/data/known_hosts
|
||||||
|
|
||||||
kubectl -n log delete pod pvc-loader
|
kubectl -n log delete pod pvc-loader
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="alternate icon" href="/favicon.ico" />
|
<link rel="alternate icon" href="/favicon.ico" />
|
||||||
<title>opsx-loki</title>
|
<title>LokiStack</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+69
-3
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "lokideploy-frontend",
|
"name": "lokistack-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lokideploy-frontend",
|
"name": "lokistack-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"i18next": "^26.5.11",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.70.0",
|
"react-hook-form": "^7.70.0",
|
||||||
|
"react-i18next": "^17.1.9",
|
||||||
"react-resizable-panels": "^4.2.2",
|
"react-resizable-panels": "^4.2.2",
|
||||||
"react-router": "^7.6.1",
|
"react-router": "^7.6.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -4372,6 +4374,43 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://locize.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "26.5.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
|
||||||
|
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com/i18next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5099,6 +5138,33 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "17.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||||
|
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.7",
|
||||||
|
"html-parse-stringify": "^4.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 26.2.0",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
@@ -5650,7 +5716,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "opsx-loki-frontend",
|
"name": "lokistack-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"i18next": "^26.5.11",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.70.0",
|
"react-hook-form": "^7.70.0",
|
||||||
|
"react-i18next": "^17.1.9",
|
||||||
"react-resizable-panels": "^4.2.2",
|
"react-resizable-panels": "^4.2.2",
|
||||||
"react-router": "^7.6.1",
|
"react-router": "^7.6.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router'
|
|||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { isLoggedIn, getUser } from '@/lib/auth-storage'
|
import { isLoggedIn, getUser } from '@/lib/auth-storage'
|
||||||
import { routes, findRouteMeta } from '@/router/routes'
|
import { routes, findRouteMeta } from '@/router/routes'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const Layout = lazy(() => import('@/components/Layout'))
|
const Layout = lazy(() => import('@/components/Layout'))
|
||||||
|
|
||||||
@@ -36,13 +37,15 @@ function AppLoadingFallback() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// document.title 由路由 meta 单源驱动
|
// document.title 由路由 meta 单源驱动(meta.title 为 i18n key,common 命名空间)
|
||||||
function useDocumentTitle() {
|
function useDocumentTitle() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const title = findRouteMeta(location.pathname)?.title
|
const titleKey = findRouteMeta(location.pathname)?.title
|
||||||
document.title = title && location.pathname !== '/login' ? `${title} · lokistack` : 'lokistack'
|
const title = titleKey ? t(titleKey) : ''
|
||||||
}, [location.pathname])
|
document.title = title && location.pathname !== '/login' ? `${title} · LokiStack` : 'LokiStack'
|
||||||
|
}, [location.pathname, t, i18n.language])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,7 +34,4 @@ export const hostApi = {
|
|||||||
validate: (id: number) => http.post<{ message: string }>(`/hosts/${id}/validate`),
|
validate: (id: number) => http.post<{ message: string }>(`/hosts/${id}/validate`),
|
||||||
|
|
||||||
getInfo: (id: number) => http.get<HostInfo>(`/hosts/${id}/info`),
|
getInfo: (id: number) => http.get<HostInfo>(`/hosts/${id}/info`),
|
||||||
|
|
||||||
execute: (id: number, command: string) =>
|
|
||||||
http.post<{ stdout: string; stderr: string }>(`/hosts/${id}/execute`, { command }),
|
|
||||||
};
|
};
|
||||||
@@ -17,6 +17,13 @@ export const userApi = {
|
|||||||
|
|
||||||
remove: (id: number) => http.delete<{ message: string }>(`/users/${id}`),
|
remove: (id: number) => http.delete<{ message: string }>(`/users/${id}`),
|
||||||
|
|
||||||
|
// 批量删除:逐个应用守卫(不能删自己/最后一个 admin),失败项单独返回
|
||||||
|
batchRemove: (ids: number[]) =>
|
||||||
|
http.post<{ deleted: number; failures: Array<{ id: number; reason: string }> }>(
|
||||||
|
'/users/batch-delete',
|
||||||
|
{ ids },
|
||||||
|
),
|
||||||
|
|
||||||
// 管理员重置密码(后端会吊销目标用户全部旧会话)
|
// 管理员重置密码(后端会吊销目标用户全部旧会话)
|
||||||
resetPassword: (id: number, newPassword: string) =>
|
resetPassword: (id: number, newPassword: string) =>
|
||||||
http.post<{ message: string }>(`/users/${id}/reset-password`, { newPassword }),
|
http.post<{ message: string }>(`/users/${id}/reset-password`, { newPassword }),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type CardActionButtonProps = {
|
||||||
|
/** 图标 */
|
||||||
|
icon: LucideIcon
|
||||||
|
/** 危险操作(删除)语义色 */
|
||||||
|
destructive?: boolean
|
||||||
|
/** 图标旋转(验证/部署等进行中态) */
|
||||||
|
spinning?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
/** 追加样式(如部署钮的 success 底色) */
|
||||||
|
className?: string
|
||||||
|
} & (
|
||||||
|
/** 文字钮:图标 + 文案,内容宽度,不拉伸 */
|
||||||
|
| { label: string; ariaLabel?: undefined }
|
||||||
|
/** 纯图标钮:必须提供可访问名,同时作为 title 悬浮提示 */
|
||||||
|
| { label?: undefined; ariaLabel: string }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源卡片操作钮 — 五大资源页(集群/主机/存储/部署/采集)统一形式:
|
||||||
|
* outline + sm + 内容宽度(不用 flex-1,换行交给 ResourceCard 操作区的 flex-wrap)。
|
||||||
|
*/
|
||||||
|
export const CardActionButton = memo(function CardActionButton({
|
||||||
|
icon: Icon,
|
||||||
|
destructive = false,
|
||||||
|
spinning = false,
|
||||||
|
disabled = false,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
ariaLabel,
|
||||||
|
}: CardActionButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn('text-xs', destructive && 'text-destructive hover:text-destructive hover:bg-destructive/10', className)}
|
||||||
|
aria-label={label ? undefined : ariaLabel}
|
||||||
|
title={label ? undefined : ariaLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Icon className={cn(label ? 'size-3 mr-1' : 'size-3.5', spinning && 'animate-spin')} />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -31,26 +32,29 @@ export const ConfirmDialog = memo(function ConfirmDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
name,
|
name,
|
||||||
title = '确认删除',
|
title,
|
||||||
description,
|
description,
|
||||||
confirmText = '删除',
|
confirmText,
|
||||||
loading = false,
|
loading = false,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const desc =
|
const desc =
|
||||||
description ??
|
description ??
|
||||||
(name
|
(name
|
||||||
? `确定要删除「${name}」吗?此操作不可恢复。`
|
? t('dialog.confirmDefault', { name })
|
||||||
: '确定要执行该操作吗?此操作不可恢复。')
|
: t('dialog.confirmIrreversible'))
|
||||||
|
const resolvedTitle = title ?? t('dialog.confirmTitle')
|
||||||
|
const resolvedConfirmText = confirmText ?? t('dialog.confirmText')
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
<AlertDialogTitle>{resolvedTitle}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>{desc}</AlertDialogDescription>
|
<AlertDialogDescription>{desc}</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
<AlertDialogCancel disabled={loading}>{t('dialog.cancel')}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -60,7 +64,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
|
|||||||
}}
|
}}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{loading ? '处理中...' : confirmText}
|
{loading ? t('dialog.processing') : resolvedConfirmText}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import i18n from '@/i18n'
|
||||||
import { Component, type ReactNode } from 'react'
|
import { Component, type ReactNode } from 'react'
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@@ -16,7 +17,7 @@ function isChunkLoadError(error: Error): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHUNK_RELOADED_FLAG = 'opsxloki.chunk-reloaded'
|
const CHUNK_RELOADED_FLAG = 'lokistack.chunk-reloaded'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局错误边界
|
* 全局错误边界
|
||||||
@@ -48,25 +49,25 @@ export default class ErrorBoundary extends Component<{ children: ReactNode }, St
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-muted dark:bg-slate-950 p-4">
|
<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">
|
<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>
|
<h2 className="text-lg font-semibold text-destructive mb-2">{i18n.t('boundary.title')}</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
页面遇到异常,可尝试刷新;若持续出现请把下方信息反馈给开发者。
|
{i18n.t('boundary.hint')}
|
||||||
</p>
|
</p>
|
||||||
<pre className="text-xs bg-muted font-mono text-destructive p-3 rounded-lg overflow-auto max-h-40 font-mono whitespace-pre-wrap">
|
<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 || '未知错误'}
|
{this.state.message || i18n.t('boundary.unknown')}
|
||||||
</pre>
|
</pre>
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90"
|
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
刷新页面
|
{i18n.t('boundary.reload')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={this.handleReset}
|
onClick={this.handleReset}
|
||||||
className="px-4 py-2 bg-muted text-muted-foreground rounded-lg text-sm hover:bg-muted"
|
className="px-4 py-2 bg-muted text-muted-foreground rounded-lg text-sm hover:bg-muted"
|
||||||
>
|
>
|
||||||
重置组件
|
{i18n.t('boundary.reset')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { AlertCircle, RotateCw } from 'lucide-react'
|
import { AlertCircle, RotateCw } from 'lucide-react'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -15,21 +16,23 @@ interface ErrorStateProps {
|
|||||||
* 禁止把失败吞掉后渲染成「暂无数据」(数据组件三态之一,见开发标准 07 章)。
|
* 禁止把失败吞掉后渲染成「暂无数据」(数据组件三态之一,见开发标准 07 章)。
|
||||||
*/
|
*/
|
||||||
export const ErrorState = memo(function ErrorState({
|
export const ErrorState = memo(function ErrorState({
|
||||||
message = '加载失败',
|
message,
|
||||||
onRetry,
|
onRetry,
|
||||||
className,
|
className,
|
||||||
}: ErrorStateProps) {
|
}: ErrorStateProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const resolvedMessage = message ?? t('error.title')
|
||||||
return (
|
return (
|
||||||
<Card className="border-destructive/30 border-dashed">
|
<Card className="border-destructive/30 border-dashed">
|
||||||
<CardContent className={className ?? 'p-12 text-center'}>
|
<CardContent className={className ?? 'p-12 text-center'}>
|
||||||
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
|
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
|
||||||
<p className="text-muted-foreground font-medium">{message}</p>
|
<p className="text-muted-foreground font-medium">{resolvedMessage}</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">请检查网络或后端服务状态后重试</p>
|
<p className="text-sm text-muted-foreground mt-1">{t('error.hint')}</p>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
<RotateCw className="w-3.5 h-3.5 mr-1" />
|
<RotateCw className="w-3.5 h-3.5 mr-1" />
|
||||||
重试
|
{t('error.retry')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,27 +16,30 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Monitor,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useIsMobile } from '@/hooks/use-mobile'
|
import { useIsMobile } from '@/hooks/use-mobile'
|
||||||
import { menuItems, findRouteMeta } from '@/router/routes'
|
import { menuItems, findRouteMeta } from '@/router/routes'
|
||||||
import { getUser, clearAuth } from '@/lib/auth-storage'
|
import { getUser, clearAuth } from '@/lib/auth-storage'
|
||||||
|
import { isThemeAuto, setThemeAuto } from '@/lib/theme-schedule'
|
||||||
|
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
// 侧栏折叠状态持久化 key(命名空间见开发标准 08 章)
|
// 侧栏折叠状态持久化 key(命名空间见开发标准 08 章)
|
||||||
const LAYOUT_COLLAPSED_KEY = 'opsxloki.layout.collapsed'
|
const LAYOUT_COLLAPSED_KEY = 'lokistack.layout.collapsed'
|
||||||
|
|
||||||
// 侧栏导航 — 桌面 aside 与移动端抽屉共用;菜单数据来自路由配置单源派生
|
// 侧栏导航 — 桌面 aside 与移动端抽屉共用;菜单数据来自路由配置单源派生
|
||||||
function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
|
function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const { t } = useTranslation()
|
||||||
const isAdmin = getUser()?.role === 'admin'
|
const isAdmin = getUser()?.role === 'admin'
|
||||||
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
|
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
|
||||||
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
|
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
|
||||||
return (
|
return (
|
||||||
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto" aria-label="主导航">
|
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto" aria-label={t('layout.nav')}>
|
||||||
{visibleItems.map((item) => {
|
{visibleItems.map((item) => {
|
||||||
const isActive = location.pathname === item.path
|
const isActive = location.pathname === item.path
|
||||||
return (
|
return (
|
||||||
@@ -44,17 +47,17 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
|
|||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
aria-label={item.label}
|
aria-label={t(item.label)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
'relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-sidebar-primary/15 text-sidebar-primary font-semibold before:absolute before:left-0 before:h-5 before:w-0.5 before:rounded-full before:bg-sidebar-primary'
|
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold before:absolute before:left-0 before:h-5 before:w-0.5 before:rounded-full before:bg-sidebar-accent-foreground/60'
|
||||||
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
|
: 'text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground'
|
||||||
)}
|
)}
|
||||||
title={!collapsed ? item.label : undefined}
|
title={!collapsed ? t(item.label) : undefined}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className="w-5 h-5 flex-shrink-0" />}
|
{item.icon && <item.icon className="w-5 h-5 flex-shrink-0" />}
|
||||||
{!collapsed && <span className="text-sm font-medium">{item.label}</span>}
|
{!collapsed && <span className="text-sm font-medium">{t(item.label)}</span>}
|
||||||
{!collapsed && isActive && (
|
{!collapsed && isActive && (
|
||||||
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
|
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
@@ -78,40 +81,44 @@ function SidebarLogo({ collapsed }: { collapsed: boolean }) {
|
|||||||
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center">
|
||||||
<Activity className="w-5 h-5 text-sidebar-primary" />
|
<Activity className="w-5 h-5 text-sidebar-primary" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-lg tracking-tight">lokistack</span>
|
<span className="font-bold text-xl tracking-tight">LokiStack</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主题三态切换:浅色 → 深色 → 跟随系统(key: opsxloki.theme.mode)
|
// 主题二态切换:浅色 ↔ 深色(key: lokistack.theme.mode;resolvedTheme 兼容"跟随系统/定时"偏好)
|
||||||
|
// 定时模式开启时点击 = 关闭定时并切到相反主题(用户手动覆盖调度)。
|
||||||
function ThemeToggle() {
|
function ThemeToggle() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'
|
const { t } = useTranslation()
|
||||||
const label = next === 'light' ? '切换为浅色' : next === 'dark' ? '切换为深色' : '切换为跟随系统'
|
const isDark = resolvedTheme === 'dark'
|
||||||
|
const label = isDark ? t('layout.toLight') : t('layout.toDark')
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme(next)}
|
onClick={() => {
|
||||||
|
if (isThemeAuto()) {
|
||||||
|
setThemeAuto(false)
|
||||||
|
setTheme(isDark ? 'light' : 'dark')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTheme(isDark ? 'light' : 'dark')
|
||||||
|
}}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
|
className="p-2 rounded-lg text-muted-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
{theme === 'light' ? (
|
{isDark ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
|
||||||
<Sun className="w-4 h-4" />
|
|
||||||
) : theme === 'dark' ? (
|
|
||||||
<Moon className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<Monitor className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面级加载骨架(挂在 main 内,切页时布局不闪)
|
// 页面级加载骨架(挂在 main 内,切页时布局不闪)
|
||||||
function PageSkeleton() {
|
function PageSkeleton() {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" aria-busy="true" aria-label="页面加载中">
|
<div className="space-y-6" aria-busy="true" aria-label={t('layout.loading')}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-7 w-48" />
|
<Skeleton className="h-7 w-48" />
|
||||||
<Skeleton className="h-4 w-72" />
|
<Skeleton className="h-4 w-72" />
|
||||||
@@ -137,10 +144,16 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
|
|||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { t } = useTranslation()
|
||||||
const user = getUser()
|
const user = getUser()
|
||||||
|
|
||||||
|
// 主题定时调度(「定时」模式开启时每分钟按时区解析浅/深色)
|
||||||
|
useScheduledTheme()
|
||||||
|
|
||||||
// 顶栏标题来自路由 meta
|
// 顶栏标题来自路由 meta
|
||||||
const pageTitle = findRouteMeta(location.pathname)?.title || 'lokistack'
|
// meta.title 为 i18n key(common 命名空间),展示前翻译
|
||||||
|
const pageTitleKey = findRouteMeta(location.pathname)?.title
|
||||||
|
const pageTitle = pageTitleKey ? t(pageTitleKey) : 'LokiStack'
|
||||||
|
|
||||||
// 折叠状态持久化(仅桌面端语义)
|
// 折叠状态持久化(仅桌面端语义)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,8 +184,8 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
|
|||||||
<div className="p-2 border-t border-sidebar-border flex-shrink-0">
|
<div className="p-2 border-t border-sidebar-border flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
aria-label={collapsed ? '展开菜单' : '收起菜单'}
|
aria-label={collapsed ? t('layout.expand') : t('layout.collapse')}
|
||||||
title={collapsed ? '展开菜单' : '收起菜单'}
|
title={collapsed ? t('layout.expand') : t('layout.collapse')}
|
||||||
className="flex items-center justify-center w-full py-2.5 rounded-lg text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
className="flex items-center justify-center w-full py-2.5 rounded-lg text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
@@ -190,7 +203,7 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
|
|||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
<SheetContent side="left" className="bg-sidebar text-sidebar-accent-foreground border-none p-0 w-72">
|
<SheetContent side="left" className="bg-sidebar text-sidebar-accent-foreground border-none p-0 w-72">
|
||||||
<SheetHeader className="sr-only">
|
<SheetHeader className="sr-only">
|
||||||
<SheetTitle className="text-white">lokistack 导航</SheetTitle>
|
<SheetTitle className="text-sidebar-accent-foreground">{t('layout.sheetTitle')}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<SidebarLogo collapsed={false} />
|
<SidebarLogo collapsed={false} />
|
||||||
@@ -203,40 +216,40 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
{/* Top Bar */}
|
{/* 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-4 md:px-6 flex-shrink-0 gap-3">
|
<header className="h-16 bg-card border-b border-border 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">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen(true)}
|
onClick={() => setMobileOpen(true)}
|
||||||
aria-label="打开导航菜单"
|
aria-label={t('layout.openNav')}
|
||||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
|
className="p-2 rounded-lg text-muted-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
<Menu className="w-5 h-5" />
|
<Menu className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h1 className="text-lg md:text-xl font-semibold text-slate-800 dark:text-white truncate">
|
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">
|
||||||
{pageTitle}
|
{pageTitle}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 flex-shrink-0">
|
<div className="flex items-center gap-4 flex-shrink-0">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300">
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
<span>{user.username}</span>
|
<span>{user.username}</span>
|
||||||
<span className="px-2 py-0.5 rounded-full bg-success/10 text-success 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}
|
{user.role === 'admin' ? t('layout.admin') : user.role}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
title="退出登录"
|
title={t('layout.logout')}
|
||||||
aria-label="退出登录"
|
aria-label={t('layout.logout')}
|
||||||
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"
|
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" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span className="hidden md:inline">退出</span>
|
<span className="hidden md:inline">{t('layout.logoutShort')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import i18n from '@/i18n'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Activity } from 'lucide-react'
|
import { Activity } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -23,12 +24,13 @@ export const LoadingSpinner = memo(function LoadingSpinner({
|
|||||||
className,
|
className,
|
||||||
text,
|
text,
|
||||||
}: LoadingSpinnerProps) {
|
}: LoadingSpinnerProps) {
|
||||||
|
const resolvedText = text ?? i18n.t('spinner.default')
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col items-center justify-center gap-3', className)}>
|
<div className={cn('flex flex-col items-center justify-center gap-3', className)}>
|
||||||
<Activity
|
<Activity
|
||||||
className={cn('text-primary animate-spin', SIZE_MAP[size])}
|
className={cn('text-primary animate-spin', SIZE_MAP[size])}
|
||||||
/>
|
/>
|
||||||
{text && <p className="text-sm text-muted-foreground">{text}</p>}
|
{resolvedText && <p className="text-sm text-muted-foreground">{resolvedText}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -37,7 +39,7 @@ export const LoadingSpinner = memo(function LoadingSpinner({
|
|||||||
* 全屏居中加载状态
|
* 全屏居中加载状态
|
||||||
*/
|
*/
|
||||||
export const FullScreenLoader = memo(function FullScreenLoader({
|
export const FullScreenLoader = memo(function FullScreenLoader({
|
||||||
text = '加载中...',
|
text,
|
||||||
}: Pick<LoadingSpinnerProps, 'text'>) {
|
}: Pick<LoadingSpinnerProps, 'text'>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useLocation } from 'react-router'
|
import { useLocation } from 'react-router'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
@@ -26,7 +27,8 @@ export const PageContainer = memo(function PageContainer({
|
|||||||
children,
|
children,
|
||||||
}: PageContainerProps) {
|
}: PageContainerProps) {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const routeTitle = findRouteMeta(pathname)?.title
|
const { t } = useTranslation()
|
||||||
|
const routeTitle = findRouteMeta(pathname)?.title ? t(findRouteMeta(pathname)!.title) : undefined
|
||||||
const repeatedTitle = Boolean(title && routeTitle && title.replace(/\s/g, '') === routeTitle.replace(/\s/g, ''))
|
const repeatedTitle = Boolean(title && routeTitle && title.replace(/\s/g, '') === routeTitle.replace(/\s/g, ''))
|
||||||
const showHeader = Boolean(title && (!repeatedTitle || description || action))
|
const showHeader = Boolean(title && (!repeatedTitle || description || action))
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { memo, useMemo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
@@ -42,13 +43,14 @@ export const PaginationBar = memo(function PaginationBar({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
className,
|
className,
|
||||||
}: PaginationBarProps) {
|
}: PaginationBarProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const pages = useMemo(() => pageNumbers(page, totalPages), [page, totalPages])
|
const pages = useMemo(() => pageNumbers(page, totalPages), [page, totalPages])
|
||||||
|
|
||||||
if (total <= pageSize) {
|
if (total <= pageSize) {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<p className="text-xs text-muted-foreground text-center">共 {total} 条</p>
|
<p className="text-xs text-muted-foreground text-center">{t('pagination.total', { count: total })}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -102,7 +104,7 @@ export const PaginationBar = memo(function PaginationBar({
|
|||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
<p className="text-xs text-muted-foreground text-center mt-1">
|
<p className="text-xs text-muted-foreground text-center mt-1">
|
||||||
共 {total} 条 · 第 {page}/{totalPages} 页
|
{t('pagination.summary', { count: total, page, pages: totalPages })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useState } from 'react'
|
import { memo, useState } from 'react'
|
||||||
import { Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
import i18n from '@/i18n'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
interface PasswordInputProps extends Omit<React.ComponentProps<typeof Input>, 'type'> {
|
interface PasswordInputProps extends Omit<React.ComponentProps<typeof Input>, 'type'> {
|
||||||
@@ -24,7 +25,7 @@ export const PasswordInput = memo(function PasswordInput({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={visible ? '隐藏密码' : '显示密码'}
|
aria-label={visible ? i18n.t('password.hide') : i18n.t('password.show')}
|
||||||
onClick={() => setVisible((prev) => !prev)}
|
onClick={() => setVisible((prev) => !prev)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-slate-600"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-slate-600"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo, useMemo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
import { Clock } from 'lucide-react'
|
import { Clock } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { statusLabel } from '@/lib/utils'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { STATUS_CONFIG } from './status-config'
|
import { STATUS_CONFIG } from './status-config'
|
||||||
|
|
||||||
export type StatusSize = 'sm' | 'md'
|
export type StatusSize = 'sm' | 'md'
|
||||||
@@ -13,16 +13,19 @@ interface StatusBadgeProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusConfig(status: string): { icon: typeof Clock; color: string; bg: string; label: string } {
|
function getStatusConfig(status: string): { icon: typeof Clock; color: string; bg: string } {
|
||||||
const config = STATUS_CONFIG[status] || {
|
return STATUS_CONFIG[status] || {
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: 'text-muted-foreground',
|
color: 'text-muted-foreground',
|
||||||
bg: 'bg-muted text-muted-foreground',
|
bg: 'bg-muted text-muted-foreground',
|
||||||
}
|
}
|
||||||
return {
|
}
|
||||||
...config,
|
|
||||||
label: statusLabel(status),
|
// 状态标签走 common.status.*;未收录的状态原样展示(数据层保留英文状态值)
|
||||||
}
|
function statusLabelOf(t: (k: string) => string, status: string): string {
|
||||||
|
const key = `status.${status}`
|
||||||
|
const label = t(key)
|
||||||
|
return label === key ? status : label
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE_MAP: Record<StatusSize, { icon: string; badge: string }> = {
|
const SIZE_MAP: Record<StatusSize, { icon: string; badge: string }> = {
|
||||||
@@ -41,7 +44,9 @@ export const StatusBadge = memo(function StatusBadge({
|
|||||||
size = 'sm',
|
size = 'sm',
|
||||||
className,
|
className,
|
||||||
}: StatusBadgeProps) {
|
}: StatusBadgeProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const config = useMemo(() => getStatusConfig(status), [status])
|
const config = useMemo(() => getStatusConfig(status), [status])
|
||||||
|
const label = statusLabelOf(t, status)
|
||||||
const sizeClasses = SIZE_MAP[size]
|
const sizeClasses = SIZE_MAP[size]
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
|
|
||||||
@@ -61,7 +66,7 @@ export const StatusBadge = memo(function StatusBadge({
|
|||||||
status === 'deploying' && 'animate-pulse'
|
status === 'deploying' && 'animate-pulse'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{showLabel && <span>{config.label}</span>}
|
{showLabel && <span>{label}</span>}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { default as PageContainer } from './PageContainer';
|
|||||||
export { default as PageHeader } from './PageHeader';
|
export { default as PageHeader } from './PageHeader';
|
||||||
export { default as InfoRow } from './InfoRow';
|
export { default as InfoRow } from './InfoRow';
|
||||||
export { ResourceCard } from './ResourceCard';
|
export { ResourceCard } from './ResourceCard';
|
||||||
|
export { CardActionButton } from './CardActionButton';
|
||||||
export { default as ConfirmDialog } from './ConfirmDialog';
|
export { default as ConfirmDialog } from './ConfirmDialog';
|
||||||
export { default as ErrorState } from './ErrorState';
|
export { default as ErrorState } from './ErrorState';
|
||||||
export { default as EmptyState } from './EmptyState';
|
export { default as EmptyState } from './EmptyState';
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import i18n from '@/i18n'
|
||||||
import type { PageData } from '@/types'
|
import type { PageData } from '@/types'
|
||||||
import type { ListParams } from '@/api'
|
import type { ListParams } from '@/api'
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ export function useListQuery<T>(
|
|||||||
setTotal(res.total ?? 0)
|
setTotal(res.total ?? 0)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (currentVersion !== versionRef.current) return
|
if (currentVersion !== versionRef.current) return
|
||||||
setError(err instanceof Error ? err.message : '加载失败')
|
setError(err instanceof Error ? err.message : i18n.t('error.title'))
|
||||||
} finally {
|
} finally {
|
||||||
if (currentVersion === versionRef.current) {
|
if (currentVersion === versionRef.current) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user