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
|
||||
data/
|
||||
logs/
|
||||
lokistack.json
|
||||
opsxloki.json
|
||||
*.db
|
||||
*.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:
|
||||
# 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)。
|
||||
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
|
||||
|
||||
@@ -34,8 +34,8 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
### 构建单体二进制(前端 + 后端)
|
||||
```bash
|
||||
bash build.sh # 产物:backend/bin/lokideploy,并把 frontend/dist 复制到 backend/dist
|
||||
cd backend && go build -o bin/lokideploy . # 仅构建后端
|
||||
bash build.sh # 产物:backend/bin/lokistack,并把 frontend/dist 复制到 backend/dist
|
||||
cd backend && go build -o bin/lokistack . # 仅构建后端
|
||||
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)**。
|
||||
|
||||
- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。
|
||||
@@ -80,7 +80,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
||||
- `seed/` — 数据库为空时写入默认 admin 用户。
|
||||
|
||||
### 数据层 — JSON 文件数据库(唯一数据源)
|
||||
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/opsxloki.json(兼容加载旧 lokideploy.json)`(默认 `./data/`)。
|
||||
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokistack.json(自动兼容历史版本数据文件)`(默认 `./data/`)。
|
||||
**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构:
|
||||
|
||||
- **并发模型**(重要):写操作由 `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)、
|
||||
30s 超时、`silent` 请求选项、**401 单飞刷新 + 队列重放**(refreshToken 走裸 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`(防开放重定向)。
|
||||
- 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与
|
||||
`document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。
|
||||
- 部署向导(`/wizard`):状态推导式步骤地图(preflight 端点聚合各资源状态),四步:环境与
|
||||
目标 → 存储 → Loki→ 采集;k8s 资源统一默认 `log` 命名空间(可改)。
|
||||
目标 → 存储 → Loki→ 采集;Kubernetes 资源统一默认 `log` 命名空间(可改)。
|
||||
Grafana 为**可选**(默认关闭):Grafana 自动部署已移除,平台内置日志查询。
|
||||
- **LDAP 接入**(参照 opsx-web-lite):设置页配置(SystemSetting 表 JSON,Bind 密码 AES 加密、
|
||||
- **LDAP 接入**:设置页配置(SystemSetting 表 JSON,Bind 密码 AES 加密、
|
||||
对外只回 `hasPassword`);启用后 ①登录支持 LDAP 用户验证并自动建号(authSource=ldap)②用户页
|
||||
出现「从 LDAP 导入」(已导入置灰;本地同名转 LDAP);`/settings/ldap/{test,users}` 端点。
|
||||
- **日志查询**(`/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 + 重试(与空态严格区分)。
|
||||
- 主题:语义 Token 单源(`src/index.css`,品牌色 = `--primary`,状态色 =
|
||||
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
|
||||
二进制直接提供 `dist/` 静态服务。构建指纹(`__APP_VERSION__`/`__COMMIT_HASH__`)
|
||||
在 Settings「关于」展示。
|
||||
|
||||
## 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。
|
||||
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
|
||||
见 `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)。
|
||||
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
|
||||
|
||||
@@ -33,8 +33,8 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
### 构建单体二进制(前端 + 后端)
|
||||
```bash
|
||||
bash build.sh # 产物:backend/bin/lokideploy,并把 frontend/dist 复制到 backend/dist
|
||||
cd backend && go build -o bin/lokideploy . # 仅构建后端
|
||||
bash build.sh # 产物:backend/bin/lokistack,并把 frontend/dist 复制到 backend/dist
|
||||
cd backend && go build -o bin/lokistack . # 仅构建后端
|
||||
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)**。
|
||||
|
||||
- `config/` — `GetConfig()` 单例。读取环境变量;密钥缺失或长度不足时直接 `log.Fatal`。
|
||||
@@ -80,7 +80,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
||||
- `seed/` — 数据库为空时写入默认 admin 用户。
|
||||
|
||||
### 数据层 — JSON 文件数据库(唯一数据源)
|
||||
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/opsxloki.json(兼容加载旧 lokideploy.json)`(默认 `./data/`)。
|
||||
持久化是 **单个 JSON 文件**,位于 `$DATA_DIR/lokistack.json(自动兼容历史版本数据文件)`(默认 `./data/`)。
|
||||
**没有 SQL 数据库**。`repository.JsonDB` 是一个与文件对应的内存结构:
|
||||
|
||||
- **并发模型**(重要):写操作由 `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)、
|
||||
30s 超时、`silent` 请求选项、**401 单飞刷新 + 队列重放**(refreshToken 走裸 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`(防开放重定向)。
|
||||
- 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与
|
||||
`document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。
|
||||
@@ -141,13 +141,13 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
|
||||
ErrorState + 重试(与空态严格区分)。
|
||||
- 主题:语义 Token 单源(`src/index.css`,品牌色 = `--primary`,状态色 =
|
||||
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
|
||||
二进制直接提供 `dist/` 静态服务。构建指纹(`__APP_VERSION__`/`__COMMIT_HASH__`)
|
||||
在 Settings「关于」展示。
|
||||
|
||||
## 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。
|
||||
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
|
||||
见 `services/deployment.go` 中的 `ValidateLokiVersion`。
|
||||
|
||||
+17
-17
@@ -1,8 +1,8 @@
|
||||
# ============================================================
|
||||
# opsx-loki - Secure Multi-Stage Dockerfile
|
||||
# lokistack - Secure Multi-Stage Dockerfile
|
||||
# ============================================================
|
||||
# P0 Fixes:
|
||||
# - Non-root user (opsxloki:1000)
|
||||
# - Non-root user (lokistack:1000)
|
||||
# - No hardcoded JWT_SECRET (validated at runtime)
|
||||
# - HEALTHCHECK instruction added
|
||||
# - Binary installation verification
|
||||
@@ -47,7 +47,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-a -installsuffix cgo \
|
||||
-ldflags="-s -w -extldflags '-static'" \
|
||||
-o bin/opsxloki .
|
||||
-o bin/lokistack .
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Stage 3: Final Runtime Image
|
||||
@@ -68,26 +68,26 @@ RUN apk --no-cache add \
|
||||
# ---------------------------------------------------------------
|
||||
# P0: Create non-root user and group
|
||||
# ---------------------------------------------------------------
|
||||
RUN addgroup -g 1000 opsxloki && \
|
||||
adduser -u 1000 -G opsxloki -s /bin/sh -D opsxloki
|
||||
RUN addgroup -g 1000 lokistack && \
|
||||
adduser -u 1000 -G lokistack -s /bin/sh -D lokistack
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Working directory with proper ownership
|
||||
# ---------------------------------------------------------------
|
||||
WORKDIR /app
|
||||
RUN chown opsxloki:opsxloki /app
|
||||
RUN chown lokistack:lokistack /app
|
||||
|
||||
# ---- Copy application files with correct ownership (P1) ----
|
||||
COPY --from=frontend-builder --chown=opsxloki:opsxloki /app/frontend/dist ./dist
|
||||
COPY --from=backend-builder --chown=opsxloki:opsxloki /app/backend/bin/opsxloki ./opsxloki
|
||||
COPY --from=frontend-builder --chown=lokistack:lokistack /app/frontend/dist ./dist
|
||||
COPY --from=backend-builder --chown=lokistack:lokistack /app/backend/bin/lokistack ./lokistack
|
||||
|
||||
# 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)
|
||||
RUN mkdir -p /home/opsxloki/.kube && \
|
||||
chown -R opsxloki:opsxloki /home/opsxloki && \
|
||||
chmod 700 /home/opsxloki/.kube
|
||||
RUN mkdir -p /home/lokistack/.kube && \
|
||||
chown -R lokistack:lokistack /home/lokistack && \
|
||||
chmod 700 /home/lokistack/.kube
|
||||
|
||||
# ---- Security hardening: remove unnecessary tools ----
|
||||
RUN rm -rf /tmp/* /var/cache/apk/*
|
||||
@@ -97,15 +97,15 @@ RUN rm -rf /tmp/* /var/cache/apk/*
|
||||
# ---------------------------------------------------------------
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV SERVER_PORT=8080
|
||||
ENV KUBECONFIG=/home/opsxloki/.kube/config
|
||||
ENV HOME=/home/opsxloki
|
||||
ENV KUBECONFIG=/home/lokistack/.kube/config
|
||||
ENV HOME=/home/lokistack
|
||||
|
||||
# NOTE: JWT_SECRET and ENCRYPTION_KEY are NOT set here.
|
||||
# They MUST be provided at runtime via docker-compose env or secrets.
|
||||
# The entrypoint script will enforce this requirement.
|
||||
|
||||
# ---- Copy and configure entrypoint script ----
|
||||
COPY --chown=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
|
||||
|
||||
EXPOSE 8080
|
||||
@@ -120,7 +120,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
# ---------------------------------------------------------------
|
||||
# P0: Switch to non-root user
|
||||
# ---------------------------------------------------------------
|
||||
USER opsxloki
|
||||
USER lokistack
|
||||
|
||||
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
|
||||
# ============================================================
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "opsx-loki v2 - Available Commands:"
|
||||
@echo "lokistack - Available Commands:"
|
||||
@echo ""
|
||||
@echo " make build - Build Docker image"
|
||||
@echo " make start - Start with docker-compose (dev)"
|
||||
@@ -24,12 +24,12 @@ help:
|
||||
|
||||
# Build Docker image
|
||||
build:
|
||||
@echo "Building opsx-loki v2 image..."
|
||||
@echo "Building lokistack image..."
|
||||
@docker compose build --no-cache
|
||||
|
||||
# Development
|
||||
start:
|
||||
@echo "Starting opsx-loki (dev mode)..."
|
||||
@echo "Starting lokistack (dev mode)..."
|
||||
@echo ""
|
||||
@if [ -z "$${JWT_SECRET:-}" ]; then \
|
||||
echo "WARNING: JWT_SECRET not set!"; \
|
||||
@@ -41,12 +41,12 @@ start:
|
||||
fi
|
||||
@docker compose up -d
|
||||
@echo ""
|
||||
@echo "opsx-loki started!"
|
||||
@echo "lokistack started!"
|
||||
@echo " URL: http://localhost:$${SERVER_PORT:-8080}"
|
||||
@echo " Check status: make status"
|
||||
|
||||
stop:
|
||||
@echo "Stopping opsx-loki..."
|
||||
@echo "Stopping lokistack..."
|
||||
@docker compose down --timeout 15
|
||||
|
||||
status:
|
||||
@@ -54,10 +54,10 @@ status:
|
||||
@docker compose ps
|
||||
@echo ""
|
||||
@echo "=== Health Checks ==="
|
||||
@docker inspect --format='{{.Name}}: {{.State.Health.Status}}' 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 "=== 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:
|
||||
@docker compose logs -f --tail=50
|
||||
@@ -69,7 +69,7 @@ prod-setup: secrets
|
||||
@echo "Production secrets created in ./secrets/"
|
||||
|
||||
prod:
|
||||
@echo "Starting opsx-loki (production mode)..."
|
||||
@echo "Starting lokistack (production mode)..."
|
||||
@if [ ! -f secrets/jwt_secret.txt ] || [ ! -f secrets/encryption_key.txt ]; then \
|
||||
echo "ERROR: Secrets not found. Run 'make secrets' first."; \
|
||||
exit 1; \
|
||||
@@ -99,7 +99,7 @@ secrets:
|
||||
|
||||
# Maintenance
|
||||
shell:
|
||||
@docker compose exec opsxloki /bin/sh
|
||||
@docker compose exec lokistack /bin/sh
|
||||
|
||||
clean:
|
||||
@echo "Cleaning up..."
|
||||
|
||||
@@ -1,101 +1,21 @@
|
||||
# lokistack v2 — 安全的 K8s Loki 部署平台
|
||||
# LokiStack — Loki 日志管理平台
|
||||
|
||||
一个轻量级的 **Gin + React + JSON 文件存储** 平台,用于通过 SSH 或 kubeconfig 把 Grafana Loki
|
||||
日志聚合系统部署到 Kubernetes 集群或裸金属主机上。
|
||||
一个轻量级的 **Gin + React + JSON 文件存储** 单体平台:通过 kubeconfig 或 SSH 把 Grafana Loki
|
||||
部署到 Kubernetes 集群或裸金属主机,并内置日志采集管理、LogQL 查询与告警。单体二进制约 13MB,零外部依赖。
|
||||
|
||||
> **v2 安全增强**:非 root 容器、运行时强制校验密钥、健康检查、资源限制、日志轮转、优雅关停。
|
||||
>
|
||||
> **面向 AI 编码代理**:本仓库的 `CLAUDE.md` 与 `AGENTS.md` 记录了架构约定与常用命令,供 Claude Code、
|
||||
> Cursor、Aider 等工具参考。两份文件内容一致,修改其一请同步另一份。
|
||||
> 面向 AI 编码代理:`CLAUDE.md` / `AGENTS.md` 记录了架构约定与常用命令,两份内容一致,修改其一请同步另一份。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **K8s 集群管理** — 通过 kubeconfig 添加集群,自动校验连通性
|
||||
- **SSH 主机管理** — 支持密码或密钥认证
|
||||
- **存储配置** — 支持 S3、OSS、MinIO、RustFS 等后端
|
||||
- **一键部署 Loki** — Helm Chart / YAML 清单 / 二进制 三种模式
|
||||
- **部署日志** — 实时日志流
|
||||
- **Web 终端** — 在浏览器中执行远程命令
|
||||
- **单体二进制** — 约 13MB,零外部依赖
|
||||
- **安全** — Argon2 密码哈希、AES 凭证加密、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`(代码里硬编码,不改),只是目录/卷名变了。
|
||||
- **Kubernetes 集群管理** — kubeconfig/Token 接入,自动校验连通性,节点与工作负载概览
|
||||
- **SSH 主机管理** — 密码或密钥认证,主机信息探测
|
||||
- **存储配置** — S3、OSS、COS、OBS、MinIO、RustFS 等对象存储后端
|
||||
- **一键部署 Loki** — `k8s-yaml` / `host-binary` / `host-docker` 三种模式,部署日志实时查看
|
||||
- **日志采集器** — 主机 Promtail、Kubernetes DaemonSet、Fluent Bit Sidecar,采集命名空间与 app 标签可配
|
||||
- **内置日志查询** — LogQL 高亮补全、直方图、标签过滤、Live Tail,无需 Grafana
|
||||
- **日志告警** — 告警规则 + 飞书(Lark)卡片通知
|
||||
- **用户与 LDAP** — 两级用户角色(admin/普通),支持 LDAP 对接与导入
|
||||
- **安全** — Argon2 密码哈希、AES-GCM(HKDF 派生)凭证加密、JWT 鉴权、非 root 容器
|
||||
|
||||
---
|
||||
|
||||
@@ -104,200 +24,88 @@ docker compose exec lokistack cat /app/data/opsxloki.json | head -c 100
|
||||
### 前置条件
|
||||
|
||||
- Docker Engine 24.0+ 与 Docker Compose v2+
|
||||
- 宿主机上装有 `kubectl` 和 `helm`(用于向外部集群部署 K8s 资源)
|
||||
- `openssl`(用于生成密钥)
|
||||
- `openssl`(生成密钥)
|
||||
|
||||
### 第 1 步:生成密钥(必需)
|
||||
> 镜像内置全部依赖(Kubernetes 操作走 client-go 直连 API Server),宿主机**无需**安装 `kubectl` / `helm`。
|
||||
|
||||
启动前必须生成所需的安全密钥:
|
||||
### 1. 生成密钥并启动
|
||||
|
||||
```bash
|
||||
# 必需:JWT 签名密钥(建议 >= 32 字符)
|
||||
export JWT_SECRET=$(openssl rand -hex 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)
|
||||
|
||||
# 启动
|
||||
export JWT_SECRET=$(openssl rand -hex 32) # >= 32 字符
|
||||
export ENCRYPTION_KEY=$(openssl rand -hex 32) # >= 32 字节
|
||||
docker compose up -d
|
||||
|
||||
# 查看健康状态
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
|
||||
# 打开 http://localhost:8080
|
||||
# 管理员密码见「第 4 步」(首次启动随机生成,不再是固定 admin123)
|
||||
docker compose ps # 等待 healthy
|
||||
```
|
||||
|
||||
### 第 3 步:验证健康状态
|
||||
未设置这两个变量时容器会**拒绝启动**,避免不安全的默认部署。
|
||||
|
||||
打开 `http://localhost:8080`,管理员初始密码见下一步。
|
||||
|
||||
### 2. 获取管理员初始密码
|
||||
|
||||
首次启动生成**随机密码**(不再是固定 `admin123`),同时打印到启动日志并写入数据卷
|
||||
`/app/data/initial-admin-password.txt`(权限 `0600`)。**日志只打印一次,文件才是可靠来源**:
|
||||
|
||||
```bash
|
||||
# 容器级健康检查(内部)
|
||||
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
|
||||
docker compose exec lokistack cat /app/data/initial-admin-password.txt
|
||||
|
||||
# 方式 2:首次启动日志(只打印一次,重启后不会再有)
|
||||
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
|
||||
# 读文件(推荐)
|
||||
# Kubernetes
|
||||
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
|
||||
# Docker Compose:部署前设置环境变量
|
||||
export ADMIN_INIT_PASSWORD='your-strong-password'
|
||||
docker compose up -d
|
||||
|
||||
# Kubernetes:在 deployment 的 env 中加入 ADMIN_INIT_PASSWORD
|
||||
curl http://localhost:8080/api/v1/health # 应用级
|
||||
docker inspect --format='{{.State.Health.Status}}' lokistack # 容器级,期望 healthy
|
||||
```
|
||||
|
||||
**安全说明**:
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 密码文件权限 | `0600`,属主 `lokistack` |
|
||||
| 自动清理 | **admin 首次修改密码后,该文件自动删除**(见 `seed.CleanupInitPassword`) |
|
||||
| 日志内容 | 仅在首次启动打印,且容器日志由运行时轮转 |
|
||||
| 忘了密码 | 数据卷里文件仍在;若文件已删,用 `ADMIN_INIT_PASSWORD` 重新初始化(需清空数据卷) |
|
||||
| 环境变量优先 | 设置了 `ADMIN_INIT_PASSWORD` 时不生成随机密码、不写文件 |
|
||||
|
||||
---
|
||||
|
||||
## 生产部署
|
||||
|
||||
### 方式 A:带密钥文件的 Docker Compose(推荐)
|
||||
推荐使用带密钥文件与 nginx 负载均衡的 `docker-compose.prod.yml`:
|
||||
|
||||
```bash
|
||||
# 创建密钥文件
|
||||
mkdir -p secrets
|
||||
openssl rand -hex 32 > secrets/jwt_secret.txt
|
||||
openssl rand -hex 32 > secrets/encryption_key.txt
|
||||
chmod 600 secrets/*.txt
|
||||
|
||||
# 使用 docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 方式 B:Docker Swarm
|
||||
**上线前清单**:
|
||||
|
||||
```bash
|
||||
# 初始化 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 文件)
|
||||
- [ ] 两个密钥均为密码学随机、>= 32 字节,存于 Docker Secrets 或外部保险柜(而非 env 文件)
|
||||
- [ ] `.env` 与 `secrets/` 已加入 `.gitignore`
|
||||
- [ ] 启用了 `no-new-privileges:true`(docker-compose 默认启用)
|
||||
- [ ] kubeconfig 以只读方式挂载(`:ro`)
|
||||
- [ ] SSH 密钥以只读方式挂载(`:ro`)
|
||||
- [ ] 已合理配置资源限制
|
||||
- [ ] 健康检查通过
|
||||
- [ ] 已配置日志轮转
|
||||
- [ ] kubeconfig 与 SSH 密钥以只读方式挂载(`:ro`)
|
||||
- [ ] 健康检查通过、日志轮转与资源限制已配置(compose 默认已含)
|
||||
|
||||
---
|
||||
|
||||
## 从源码构建
|
||||
## 从源码构建与开发
|
||||
|
||||
```bash
|
||||
# 构建前端 + 后端
|
||||
bash build.sh
|
||||
|
||||
# 用本地脚本启动(开发)
|
||||
./run.sh
|
||||
bash build.sh # 构建单体二进制 frontend/dist + backend/bin/lokistack
|
||||
./run.sh # 本地一键起前后端(自动注入开发用密钥)
|
||||
```
|
||||
|
||||
### 开发模式(热重载)
|
||||
开发模式(热重载,两个终端):
|
||||
|
||||
```bash
|
||||
# 终端 1 — 后端
|
||||
cd backend
|
||||
go run . # 监听 :8080
|
||||
|
||||
# 终端 2 — 前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # 打开 http://localhost:3000,API 代理到 localhost:8080
|
||||
cd backend && go run . # :8080;直接 go run 需先 export 两个密钥,否则拒绝启动
|
||||
cd frontend && npm install && npm run dev # :3000,/api 代理到 :8080
|
||||
```
|
||||
|
||||
> 直接 `go run` 启动后端前,必须先 `export JWT_SECRET` 与 `ENCRYPTION_KEY`,否则
|
||||
> `backend/config/config.go` 会 `log.Fatal` 拒绝启动。`./run.sh` 会自动注入开发用默认值。
|
||||
|
||||
### 测试与 lint
|
||||
测试与 lint:
|
||||
|
||||
```bash
|
||||
cd backend && go test ./... # 所有 Go 测试(utils、repository)
|
||||
cd backend && go test ./utils/ -run TestName -v # 运行单个测试
|
||||
cd frontend && npm run lint # ESLint
|
||||
cd backend && go test ./...
|
||||
cd frontend && npm run lint
|
||||
```
|
||||
|
||||
---
|
||||
@@ -305,131 +113,32 @@ cd frontend && npm run lint # ESLint
|
||||
## 架构
|
||||
|
||||
```
|
||||
前端(React + TypeScript + Tailwind CSS) [开发 :3000 / 生产 :8080]
|
||||
|
|
||||
| HTTP/REST + JWT Bearer Token
|
||||
v
|
||||
后端(Go + Gin) [:8080]
|
||||
|
|
||||
+---> JSON 文件数据库(data/opsxloki.json(兼容加载旧 lokideploy.json))
|
||||
+---> K8s CLI(kubectl / helm)
|
||||
+---> SSH 客户端(golang.org/x/crypto/ssh)
|
||||
+---> Loki 部署器(Helm Chart / YAML / Binary)
|
||||
前端(React 19 + TypeScript + Tailwind) [开发 :3000 / 生产由 Go 直接服务 :8080]
|
||||
| HTTP/REST + JWT Bearer
|
||||
后端(Go + Gin)[:8080]
|
||||
+---> JSON 文件数据库($DATA_DIR/lokistack.json,自动兼容历史数据文件)
|
||||
+---> client-go 直连 Kubernetes API Server(kubeconfig 认证)
|
||||
+---> SSH(golang.org/x/crypto/ssh)
|
||||
+---> Loki 部署器(YAML 清单 / 二进制 + systemd / Docker)
|
||||
```
|
||||
|
||||
### 后端分层
|
||||
- 请求流转:**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` | K8s 集群 | 原生 Kubernetes 清单 |
|
||||
| `host-binary` | Linux 主机 | 通过 SSH 部署二进制 + systemd |
|
||||
| `k8s-yaml` | Kubernetes 集群 | 原生清单(StatefulSet/ConfigMap/Service,PVC 持久化) |
|
||||
| `host-binary` | Linux 主机 | SSH 部署二进制 + systemd |
|
||||
| `host-docker` | Linux 主机 | SSH 拉起 Loki 容器,配置与数据落在主机固定目录 |
|
||||
|
||||
## 存储后端
|
||||
### 存储后端
|
||||
|
||||
| 类型 | 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 字符) |
|
||||
| `ENCRYPTION_KEY` | `$(openssl rand -hex 32)` | AES 凭证加密密钥(**必需**,>= 32 字节) |
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `JWT_SECRET` | JWT 签名密钥,>= 32 字符 |
|
||||
| `ENCRYPTION_KEY` | AES 凭证加密密钥,>= 32 字节 |
|
||||
|
||||
### 可选
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `SERVER_PORT` | `8080` | HTTP 服务端口 |
|
||||
| `DATA_DIR` | `/app/data` | 数据存储目录 |
|
||||
| `KUBECONFIG` | `/home/lokideploy/.kube/config` | kubeconfig 文件路径 |
|
||||
| `KUBECONFIG_PATH` | `~/.kube` | 宿主机上 kubeconfig 的挂载源路径 |
|
||||
| `SSH_KEY_PATH` | `~/.ssh` | 宿主机上 SSH 密钥的挂载源路径 |
|
||||
| `SERVER_PORT` | `8080` | HTTP 端口 |
|
||||
| `DATA_DIR` | `/app/data` | 数据目录 |
|
||||
| `ADMIN_INIT_PASSWORD` | 随机生成 | 指定管理员初始密码 |
|
||||
| `KUBECONFIG` | `/home/lokistack/.kube/config` | 容器内 kubeconfig 路径 |
|
||||
|
||||
### Docker Secrets 支持
|
||||
|
||||
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
|
||||
```
|
||||
支持 Docker Secrets 文件注入:`JWT_SECRET_FILE` / `ENCRYPTION_KEY_FILE` 指向 secret 文件路径即可
|
||||
(用法见 `docker-compose.prod.yml` 与 `docker-entrypoint.sh`)。
|
||||
|
||||
---
|
||||
|
||||
## 安全
|
||||
|
||||
### 鉴权与授权
|
||||
|
||||
- **Argon2** 密码哈希
|
||||
- **AES-GCM** 凭证加密(密钥由 `ENCRYPTION_KEY` 派生)
|
||||
- **JWT** token 鉴权
|
||||
- **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
|
||||
```
|
||||
- **鉴权**:JWT(access 2h + refresh 7 天轮换);改密/重置密码递增 `TokenVersion`,旧 token 立即失效。
|
||||
- **用户体系**:两级角色(admin/普通),用户管理仅 admin;登录接口带 IP 限流。
|
||||
- **容器**:非 root 用户(`lokistack:1000`)、`no-new-privileges`、丢弃全部多余 capabilities、
|
||||
资源限制(CPU 2 核 / 内存 512MB / PIDs 100 上限)与日志轮转。
|
||||
- **密钥**:绝不提交版本控制;生产用 Docker Secrets 或密钥保险柜;轮换用 `openssl rand -hex 32`。
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 容器立即退出
|
||||
**容器立即退出** — 多为密钥缺失。`docker compose logs` 应有
|
||||
`Missing required security environment variables`;设置两个密钥后重启。
|
||||
|
||||
```bash
|
||||
# 检查密钥是否已设置
|
||||
docker compose logs
|
||||
# 期望错误信息:"Missing required security environment variables"
|
||||
**健康检查失败** — `docker inspect --format='{{.State.Health}}' lokistack` 查状态,
|
||||
`docker compose logs --tail=50` 查原因。
|
||||
|
||||
# 修复:
|
||||
export JWT_SECRET=$(openssl rand -hex 32)
|
||||
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/
|
||||
```
|
||||
**kubeconfig 不可访问** — 核对宿主侧权限(`chmod 600 ~/.kube/config`)与挂载路径:
|
||||
`docker exec lokistack ls -la /home/lokistack/.kube/`。
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// databasePath 新名优先;旧 lokideploy.json 存在且新文件不存在时沿用旧文件(数据平滑迁移)
|
||||
// databasePath 新名优先;历史命名的数据文件存在且新文件不存在时沿用旧文件
|
||||
// (数据平滑迁移,不搬移文件、零丢失风险)
|
||||
func databasePath(dataDir string) string {
|
||||
newPath := filepath.Join(dataDir, "opsxloki.json")
|
||||
newPath := filepath.Join(dataDir, "lokistack.json")
|
||||
if _, err := os.Stat(newPath); err == nil {
|
||||
return newPath
|
||||
}
|
||||
oldPath := filepath.Join(dataDir, "lokideploy.json")
|
||||
if _, err := os.Stat(oldPath); err == nil {
|
||||
return oldPath
|
||||
for _, legacy := range []string{"opsxloki.json", "lokideploy.json"} {
|
||||
legacyPath := filepath.Join(dataDir, legacy)
|
||||
if _, err := os.Stat(legacyPath); err == nil {
|
||||
return legacyPath
|
||||
}
|
||||
}
|
||||
return newPath
|
||||
}
|
||||
@@ -4,16 +4,16 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"opsxloki/dto"
|
||||
"opsxloki/models"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AlertsController 告警规则 + 飞书通知配置(参照 opsx-sec 通知 API 设计)
|
||||
// AlertsController 告警规则 + 飞书通知配置
|
||||
type AlertsController struct{}
|
||||
|
||||
func NewAlertsController() *AlertsController { return &AlertsController{} }
|
||||
@@ -48,7 +48,7 @@ func (ctrl *AlertsController) TestNotifyConfig(c *gin.Context) {
|
||||
utils.Error(c, 400, "请先保存并启用飞书通知")
|
||||
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"),
|
||||
"**说明**: 管理员在设置页手动触发的连通性测试。",
|
||||
}); err != nil {
|
||||
@@ -272,7 +272,7 @@ func (ctrl *AlertsController) TestChannel(c *gin.Context) {
|
||||
utils.BadRequest(c, "请提供通道 ID 或 webhook 地址")
|
||||
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"),
|
||||
"**说明**: 通道连通性测试。",
|
||||
}); err != nil {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"opsxloki/dto"
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/jwt"
|
||||
"opsxloki/seed"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/jwt"
|
||||
"lokistack/seed"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@ package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"opsxloki/dto"
|
||||
"opsxloki/models"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"opsxloki/dto"
|
||||
"opsxloki/models"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@ package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"opsxloki/dto"
|
||||
"opsxloki/models"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"opsxloki/dto"
|
||||
"opsxloki/models"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -124,23 +124,3 @@ func (ctrl *HostController) GetInfo(c *gin.Context) {
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"opsxloki/dto"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"opsxloki/dto"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -3,9 +3,9 @@ package controllers
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/models"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/dto"
|
||||
"opsxloki/models"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"opsxloki/dto"
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -3,11 +3,11 @@ package controllers
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"opsxloki/dto"
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/jwt"
|
||||
"opsxloki/services"
|
||||
"opsxloki/utils"
|
||||
"lokistack/dto"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/jwt"
|
||||
"lokistack/services"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -77,6 +77,21 @@ func (ctrl *UserController) Delete(c *gin.Context) {
|
||||
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,目标用户旧会话全部失效)
|
||||
func (ctrl *UserController) ResetPassword(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package dto
|
||||
|
||||
import "opsxloki/services"
|
||||
import "lokistack/services"
|
||||
|
||||
type ImageRegistryRequest struct {
|
||||
Mode string `json:"mode" binding:"required,oneof=overseas cn custom"`
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
package dto
|
||||
|
||||
import "opsxloki/models"
|
||||
import "lokistack/models"
|
||||
|
||||
// 日志查询域
|
||||
type LogSourceRequest struct {
|
||||
|
||||
@@ -11,3 +11,7 @@ type UserCreateRequest struct {
|
||||
type ResetPasswordRequest struct {
|
||||
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
|
||||
|
||||
|
||||
+7
-7
@@ -9,11 +9,11 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"opsxloki/config"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/routers"
|
||||
"opsxloki/seed"
|
||||
"opsxloki/services"
|
||||
"lokistack/config"
|
||||
"lokistack/repository"
|
||||
"lokistack/routers"
|
||||
"lokistack/seed"
|
||||
"lokistack/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -52,7 +52,7 @@ func main() {
|
||||
defer stop()
|
||||
|
||||
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)
|
||||
// 初始密码:首次启动日志中的「已生成管理员账号」,或数据卷 initial-admin-password.txt
|
||||
log.Printf("首次部署请查看启动日志或 %s/initial-admin-password.txt 获取 admin 初始密码", cfg.DataDir)
|
||||
@@ -74,5 +74,5 @@ func main() {
|
||||
if err := repository.Shutdown(); err != nil {
|
||||
log.Printf("DB shutdown error: %v", err)
|
||||
}
|
||||
log.Println("LokiDeploy stopped")
|
||||
log.Println("lokistack stopped")
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package middleware
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/jwt"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/utils"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/jwt"
|
||||
"lokistack/repository"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"opsxloki/utils"
|
||||
"lokistack/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ipLimiter 内存滑动窗口 IP 限流(借鉴 opsx-web-lite rate_limit.go 的简化版)。
|
||||
// ipLimiter 内存滑动窗口 IP 限流(简化实现)。
|
||||
// 用于登录防爆破:超限返回 429。单实例部署下内存实现即可,无需外部存储。
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -3,7 +3,7 @@ package models
|
||||
import (
|
||||
"time"
|
||||
|
||||
"opsxloki/pkg/crypto"
|
||||
"lokistack/pkg/crypto"
|
||||
)
|
||||
|
||||
type Cluster struct {
|
||||
|
||||
@@ -34,7 +34,6 @@ type LokiDeployment struct {
|
||||
GrafanaEndpoint string `json:"grafanaEndpoint"`
|
||||
Status string `json:"status"`
|
||||
StatusMessage string `json:"statusMessage"`
|
||||
HelmValues string `json:"helmValues"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
AdminUser string `json:"adminUser"`
|
||||
AdminPassword string `json:"adminPassword"`
|
||||
@@ -76,7 +75,6 @@ func (d *LokiDeployment) Sanitized() map[string]interface{} {
|
||||
"grafanaEndpoint": d.GrafanaEndpoint,
|
||||
"status": d.Status,
|
||||
"statusMessage": d.StatusMessage,
|
||||
"helmValues": d.HelmValues,
|
||||
"endpoint": d.Endpoint,
|
||||
"adminUser": d.AdminUser,
|
||||
// AdminPassword 故意省略
|
||||
|
||||
@@ -3,7 +3,7 @@ package models
|
||||
import (
|
||||
"time"
|
||||
|
||||
"opsxloki/pkg/crypto"
|
||||
"lokistack/pkg/crypto"
|
||||
)
|
||||
|
||||
// SSHAuthConfig SSH认证配置(定义在models包避免循环导入)
|
||||
|
||||
@@ -3,7 +3,7 @@ package models
|
||||
import (
|
||||
"time"
|
||||
|
||||
"opsxloki/pkg/crypto"
|
||||
"lokistack/pkg/crypto"
|
||||
)
|
||||
|
||||
type StorageConfig struct {
|
||||
|
||||
@@ -3,7 +3,7 @@ package models
|
||||
import "time"
|
||||
|
||||
// User TokenVersion 用于服务端会话吊销:改密/管理员重置密码时递增,
|
||||
// 旧 token(access 与 refresh)随版本不匹配立即失效(借鉴 opsx-web-lite)。
|
||||
// 旧 token(access 与 refresh)随版本不匹配立即失效。
|
||||
type User struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"opsxloki/config"
|
||||
"lokistack/config"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
|
||||
@@ -10,6 +10,6 @@ import (
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv("JWT_SECRET", "crypto-pkg-test-secret-32chars!!")
|
||||
os.Setenv("ENCRYPTION_KEY", "crypto-pkg-test-encryption-32ch!!")
|
||||
os.Setenv("DATA_DIR", os.TempDir()+"/opsxloki-crypto-pkg-test")
|
||||
os.Setenv("DATA_DIR", os.TempDir()+"/lokistack-crypto-pkg-test")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"opsxloki/config"
|
||||
"lokistack/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwtv5 "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
@@ -10,6 +10,6 @@ import (
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv("JWT_SECRET", "jwt-pkg-test-secret-32chars-min!!")
|
||||
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())
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"opsxloki/config"
|
||||
"opsxloki/models"
|
||||
"lokistack/config"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
// saveRequest 表示一个保存请求,通过channel序列化
|
||||
@@ -256,7 +256,7 @@ func (d *JsonDB) doSave() error {
|
||||
|
||||
// 原子写入:先写入临时文件,再重命名
|
||||
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 {
|
||||
return err
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"opsxloki/config"
|
||||
"lokistack/config"
|
||||
)
|
||||
|
||||
// TestMain 为 repository 包预置安全环境变量与临时 DATA_DIR,并管理 JsonDB 生命周期。
|
||||
// config.GetConfig() 在 JWT_SECRET/ENCRYPTION_KEY 缺失时会 log.Fatal,故须先设置。
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "opsxloki-repository-test")
|
||||
dir, err := os.MkdirTemp("", "lokistack-repository-test")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/controllers"
|
||||
"opsxloki/middleware"
|
||||
"lokistack/controllers"
|
||||
"lokistack/middleware"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -53,7 +53,7 @@ func SetupRouter() *gin.Engine {
|
||||
api.GET("/metrics", func(c *gin.Context) {
|
||||
// 基础metrics输出(符合Prometheus文本格式)
|
||||
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 次/分钟)
|
||||
@@ -93,7 +93,7 @@ func SetupRouter() *gin.Engine {
|
||||
authorized.GET("/settings/image-registry", registryCtrl.Get)
|
||||
authorized.PUT("/settings/image-registry", middleware.AdminAuth(), registryCtrl.Save)
|
||||
|
||||
// 告警:飞书通知配置 + 告警规则(参照 opsx-sec)
|
||||
// 告警:飞书通知配置 + 告警规则
|
||||
alertNotify := authorized.Group("/alerts")
|
||||
{
|
||||
alertNotify.GET("/notify-config", alertsCtrl.GetNotifyConfig)
|
||||
@@ -125,6 +125,7 @@ func SetupRouter() *gin.Engine {
|
||||
users.GET("", userCtrl.List)
|
||||
users.POST("", userCtrl.Create)
|
||||
users.DELETE("/:id", userCtrl.Delete)
|
||||
users.POST("/batch-delete", userCtrl.BatchDelete)
|
||||
users.POST("/:id/reset-password", userCtrl.ResetPassword)
|
||||
users.POST("/batch-import-ldap", userCtrl.BatchImportLDAP)
|
||||
}
|
||||
@@ -150,7 +151,6 @@ func SetupRouter() *gin.Engine {
|
||||
authorized.DELETE("/hosts/:id", hostCtrl.Delete)
|
||||
authorized.POST("/hosts/:id/validate", hostCtrl.Validate)
|
||||
authorized.GET("/hosts/:id/info", hostCtrl.GetInfo)
|
||||
authorized.POST("/hosts/:id/execute", hostCtrl.Execute)
|
||||
|
||||
// Storage
|
||||
authorized.GET("/storages", storageCtrl.List)
|
||||
|
||||
@@ -8,8 +8,8 @@ package seed
|
||||
import (
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
type builtinRule struct {
|
||||
@@ -26,7 +26,7 @@ var builtinRules = []builtinRule{
|
||||
{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: "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: "应用错误过多(error > 1000)", query: `{namespace=~".+"} |~ "(?i)\berror\b"`, windowMin: 15, threshold: 1000, cooldown: 60},
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
// InitPasswordFile 首次启动生成的随机管理员密码,存放于数据卷根目录。
|
||||
@@ -75,7 +75,7 @@ func Seed() {
|
||||
log.Printf(" 已保存 : %s", path)
|
||||
log.Printf(" 获取方式:")
|
||||
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("==================================================")
|
||||
} else {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
const alertCheckInterval = time.Minute
|
||||
|
||||
@@ -2,11 +2,11 @@ package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/pkg/jwt"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/utils"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/pkg/jwt"
|
||||
"lokistack/repository"
|
||||
"lokistack/utils"
|
||||
)
|
||||
|
||||
type AuthService struct{}
|
||||
@@ -182,6 +182,29 @@ func (s *AuthService) ListUsers(page, pageSize int) ([]models.User, int64) {
|
||||
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 删除用户;不能删除自己,不能删除最后一个管理员
|
||||
func (s *AuthService) DeleteUser(id, requesterID uint) error {
|
||||
db := repository.GetDB()
|
||||
|
||||
@@ -3,16 +3,16 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"opsxloki/pkg/crypto"
|
||||
"lokistack/pkg/crypto"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/config"
|
||||
"opsxloki/models"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/utils"
|
||||
"lokistack/config"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
"lokistack/utils"
|
||||
)
|
||||
|
||||
type ClusterService struct{}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
type ClusterDeploymentOption struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/utils"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
"lokistack/utils"
|
||||
)
|
||||
|
||||
type CollectorService struct{}
|
||||
@@ -75,7 +75,14 @@ func (s *CollectorService) generateConfigSnapshot(c *models.Collector) string {
|
||||
case "host-promtail":
|
||||
return PromtailConfig(endpoint, c.LogPath, "host", c.Labels)
|
||||
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":
|
||||
host, port := parseLokiURL(endpoint)
|
||||
app := c.TargetDeployment
|
||||
@@ -101,7 +108,7 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto
|
||||
return nil, fmt.Errorf("主机采集器需要选择目标主机")
|
||||
}
|
||||
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 == "" {
|
||||
return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称")
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
// CollectorDeployer 采集器部署器,按 type 分发到三种安装/卸载流程
|
||||
@@ -178,7 +178,7 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== k8s-daemonset:helm 安装 Promtail DaemonSet =====
|
||||
// ===== k8s-daemonset:以原生清单安装 Promtail DaemonSet =====
|
||||
|
||||
func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error {
|
||||
cluster := d.collector.Cluster
|
||||
@@ -254,7 +254,7 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
|
||||
ns = "log"
|
||||
}
|
||||
// clusterrole / clusterrolebinding 用 loki-promtail;
|
||||
// 旧版本曾用 opsxloki-promtail,这里一并清理以免升级后留下孤儿资源。
|
||||
// 数组末两项是历史遗留命名,一并清理避免升级后留下孤儿资源。
|
||||
for _, item := range [][2]string{
|
||||
{"daemonset", "promtail"}, {"configmap", "promtail"}, {"serviceaccount", "promtail"},
|
||||
{"clusterrole", "loki-promtail"}, {"clusterrolebinding", "loki-promtail"},
|
||||
|
||||
@@ -83,120 +83,9 @@ scrape_configs:
|
||||
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),
|
||||
// 取代 helm 安装 —— 后端无需安装 helm 二进制。
|
||||
// relabel 体系与主流 helm chart 对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。
|
||||
// 经 Kubernetes API 直接提交,后端无需任何额外二进制。
|
||||
// relabel 体系与主流 Promtail 部署惯例对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。
|
||||
// sanitizePromLabel 把 K8s 标签名转成 Prometheus/Promtail 的 meta label 片段。
|
||||
//
|
||||
// k8s.kuboard.cn/name → k8s_kuboard_cn_name
|
||||
@@ -365,7 +254,7 @@ data:
|
||||
# 4) pod name ← 最后兜底,保证 app 永不为空
|
||||
#
|
||||
# 不使用 k8s.kuboard.cn/name 之类的平台私有标签:那只是 Kuboard 管理的集群才有,
|
||||
# 换 Rancher/Lens/Helm/kubectl 部署的集群就不存在,会破坏通用性。
|
||||
# 换其他方式部署的集群就不存在,会破坏通用性。
|
||||
# 如集群确实有私有规范标签,可在「采集器 → 编辑」里配置 AppLabelSource,
|
||||
# 它会作为最高优先级插到下面列表最前(其余仍兜底)。
|
||||
- source_labels:
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/seed"
|
||||
"opsxloki/utils"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
"lokistack/seed"
|
||||
"lokistack/utils"
|
||||
)
|
||||
|
||||
// LokiVersionList 支持的 Loki 版本列表(按推荐程度排序)
|
||||
@@ -569,19 +569,14 @@ func (s *DeploymentService) loadRelations(d *models.LokiDeployment) {
|
||||
|
||||
func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
|
||||
switch d.Mode {
|
||||
case "k8s-helm", "k8s-yaml":
|
||||
case "k8s-yaml":
|
||||
ns := d.Namespace
|
||||
if ns == "" {
|
||||
ns = "loki"
|
||||
ns = "log"
|
||||
}
|
||||
// k8s-yaml manifest 固定创建名为 loki 的 Service;
|
||||
// k8s-helm (grafana/loki) 以 release 名(=部署名,默认 loki)暴露 Service。
|
||||
// k8s-yaml manifest 固定创建名为 loki 的 Service。
|
||||
// 注意:不使用 loki-gateway,gateway 仅在 Distributed+gateway 启用时存在。
|
||||
svc := "loki"
|
||||
if d.Mode == "k8s-helm" && d.Name != "" {
|
||||
svc = d.Name
|
||||
}
|
||||
return fmt.Sprintf("http://%s.%s.svc.cluster.local:3100", svc, ns)
|
||||
return fmt.Sprintf("http://loki.%s.svc.cluster.local:3100", ns)
|
||||
case "host-binary":
|
||||
if d.Host != nil {
|
||||
return fmt.Sprintf("http://%s:3100", d.Host.HostIP)
|
||||
@@ -720,7 +715,7 @@ func (s *DeploymentService) recordGrafanaEndpoint(d *models.LokiDeployment) {
|
||||
}
|
||||
var endpoint string
|
||||
switch d.Mode {
|
||||
case "k8s-helm", "k8s-yaml":
|
||||
case "k8s-yaml":
|
||||
if d.Cluster == nil {
|
||||
s.AddLog(d.ID, "warn", "Grafana 地址记录跳过:未关联集群", "")
|
||||
return
|
||||
|
||||
@@ -68,7 +68,11 @@ func (s *DeploymentService) GetLiveConfig(ctx context.Context, id uint) (*LiveLo
|
||||
location = dockerConfigPath(d.ID)
|
||||
output, _, err := client.Execute(ctx, "cat "+location)
|
||||
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
|
||||
default:
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
func TestRedactLokiYAML(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package services
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"opsxloki/repository"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
func TestParseRetentionYAML(t *testing.T) {
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/utils"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/repository"
|
||||
"lokistack/utils"
|
||||
)
|
||||
|
||||
type HostService struct{}
|
||||
@@ -190,19 +190,3 @@ func (s *HostService) Validate(id uint) {
|
||||
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"
|
||||
"strings"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
const ingressNamespace = "ingress-nginx"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"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)
|
||||
}
|
||||
result.Timeout = timeout
|
||||
result.UserAgent = "LokiDeploy"
|
||||
result.UserAgent = "lokistack"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
kcScheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/restmapper"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
func ensureNamespace(ctx context.Context, cluster *models.Cluster, namespace string) error {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package services — LDAP 接入:配置存储(SystemSetting 表 JSON)、连接测试、
|
||||
// 用户搜索预览、批量导入与登录验证共用同一套连接/搜索代码。
|
||||
// 设计参照 opsx-web-lite(has_password 脱敏/空密码保留旧值/批量导入冲突策略)。
|
||||
// 约定:has_password 脱敏、空密码保留旧值、批量导入冲突策略。
|
||||
package services
|
||||
|
||||
import (
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/utils"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/repository"
|
||||
"lokistack/utils"
|
||||
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
@@ -3,9 +3,9 @@ package services
|
||||
import (
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
// LDAPImportUser 批量导入请求中的用户条目(数据来自预览接口)
|
||||
@@ -16,7 +16,7 @@ type LDAPImportUser struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// LDAPImportResult 导入结果(对齐 opsx-web-lite 契约)
|
||||
// LDAPImportResult 导入结果(与前端展示契约对齐)
|
||||
type LDAPImportResult struct {
|
||||
SuccessCount int `json:"successCount"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
func ListLogPolicyGroups() []models.LogPolicyGroup {
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
// ==================== 数据源 CRUD ====================
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
"opsxloki/pkg/crypto"
|
||||
"lokistack/pkg/crypto"
|
||||
)
|
||||
|
||||
const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
)
|
||||
|
||||
// LokiDeployer 负责 Loki 的 Kubernetes、主机 Docker 及历史部署模式。
|
||||
@@ -169,7 +169,7 @@ WantedBy=multi-user.target
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== Helm Values 生成 ====================
|
||||
// ==================== 部署参数与版本校验 ====================
|
||||
|
||||
func (d *LokiDeployer) effectiveVersion() string {
|
||||
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])
|
||||
}
|
||||
}
|
||||
// 历史 Helm release 不由此版本自动删除,避免误删旧数据。
|
||||
// 仅清理本产品创建的资源;其他途径部署的遗留资源不自动删除,避免误删数据。
|
||||
|
||||
if deployment.Mode == "host-binary" && deployment.Host != nil {
|
||||
host := deployment.Host
|
||||
|
||||
@@ -7,13 +7,19 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
func dockerContainerName(id uint) string { return fmt.Sprintf("lokideploy-loki-%d", id) }
|
||||
func dockerConfigDir(id uint) string { return fmt.Sprintf("/etc/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/lokistack/loki-%d", id) }
|
||||
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) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -52,11 +60,13 @@ func deleteDockerLoki(ctx context.Context, deployment *models.LokiDeployment) er
|
||||
}
|
||||
defer client.Close()
|
||||
name := dockerContainerName(deployment.ID)
|
||||
legacyName := legacyDockerContainerName(deployment.ID)
|
||||
if _, _, err := client.Execute(ctx, "docker info >/dev/null 2>&1"); err != nil {
|
||||
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 {
|
||||
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)
|
||||
configDir := dockerConfigDir(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 {
|
||||
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 {
|
||||
return fmt.Errorf("拉取 Loki 镜像失败: %v: %s", err, strings.TrimSpace(stderr))
|
||||
}
|
||||
// 同一部署 ID 的重部署只替换容器,配置和日志数据目录保留。
|
||||
client.Execute(ctx, "docker rm -f "+name+" >/dev/null 2>&1 || true")
|
||||
// 同一部署 ID 的重部署只替换容器(含历史命名),配置和日志数据目录保留。
|
||||
client.Execute(ctx, "docker rm -f "+name+" "+legacyDockerContainerName(deployment.ID)+" >/dev/null 2>&1 || true")
|
||||
port := deployment.ListenPort
|
||||
if port == 0 {
|
||||
port = 3100
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
func TestDockerLokiConfigUsesEnvironmentForStorageCredentials(t *testing.T) {
|
||||
@@ -24,7 +24,7 @@ func TestDockerLokiConfigUsesEnvironmentForStorageCredentials(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"opsxloki/pkg/crypto"
|
||||
"lokistack/pkg/crypto"
|
||||
)
|
||||
|
||||
func (d *LokiDeployer) generateManifest(ns string) string {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"opsxloki/models"
|
||||
"lokistack/models"
|
||||
)
|
||||
|
||||
// TestObjectStoreConfigs 验证 OSS / COS / OBS 生成的 Loki 配置:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package services
|
||||
|
||||
// 飞书/Lark 群机器人通知 — 卡片消息(参照 opsx-sec notifier 与运维告警脚本的卡片结构),
|
||||
// 飞书/Lark 群机器人通知 — 卡片消息,
|
||||
// webhook 存 SystemSetting(AES 加密),对外只回 hasWebhook。
|
||||
import (
|
||||
"bytes"
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -116,7 +116,7 @@ func SendLarkCard(title, color string, lines []string) error {
|
||||
},
|
||||
"elements": []interface{}{
|
||||
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"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/repository"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
const channelHTTPTimeout = 10 * time.Second
|
||||
@@ -182,7 +182,7 @@ func SendLarkCardToChannel(channelID uint, title, color string, lines []string)
|
||||
},
|
||||
"elements": []interface{}{
|
||||
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"
|
||||
"time"
|
||||
|
||||
"opsxloki/config"
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"lokistack/config"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"opsxloki/models"
|
||||
"opsxloki/pkg/crypto"
|
||||
"opsxloki/repository"
|
||||
"opsxloki/utils"
|
||||
"lokistack/models"
|
||||
"lokistack/pkg/crypto"
|
||||
"lokistack/repository"
|
||||
"lokistack/utils"
|
||||
)
|
||||
|
||||
type StorageService struct{}
|
||||
|
||||
@@ -3,7 +3,7 @@ package services
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"opsxloki/repository"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
func TestStorageUpdateReplacesBothCredentials(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# opsx-loki Build Script v2
|
||||
# lokistack Build Script
|
||||
# ============================================================
|
||||
# Builds frontend and backend into a single deployable package
|
||||
# with security hardening and integrity checks.
|
||||
@@ -16,7 +16,7 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
echo "========================================"
|
||||
echo " opsx-loki Build Script v2"
|
||||
echo " lokistack Build Script"
|
||||
echo " Security-hardened build pipeline"
|
||||
echo "========================================"
|
||||
|
||||
@@ -104,10 +104,10 @@ GIT_COMMIT=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "un
|
||||
|
||||
$GO_CMD build -trimpath \
|
||||
-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 " Binary size: $(du -h bin/opsxloki | cut -f1)"
|
||||
echo " Backend built: backend/bin/lokistack"
|
||||
echo " Binary size: $(du -h bin/lokistack | cut -f1)"
|
||||
echo " Build time: $BUILD_TIME"
|
||||
echo " Git commit: $GIT_COMMIT"
|
||||
|
||||
@@ -124,8 +124,8 @@ echo "========================================"
|
||||
echo " Build Complete!"
|
||||
echo "========================================"
|
||||
echo " Frontend: backend/dist/"
|
||||
echo " Backend: backend/bin/opsxloki"
|
||||
echo " Size: $(du -h backend/bin/opsxloki | cut -f1)"
|
||||
echo " Backend: backend/bin/lokistack"
|
||||
echo " Size: $(du -h backend/bin/lokistack | cut -f1)"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " SECURITY REMINDERS"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# opsx-loki Secure Entrypoint
|
||||
# lokistack Secure Entrypoint
|
||||
# ============================================================
|
||||
# Enforces required secrets at container startup
|
||||
# Supports Docker Secrets file-based injection
|
||||
@@ -43,7 +43,7 @@ if [ ${#MISSING_VARS[@]} -ne 0 ]; then
|
||||
echo "" >&2
|
||||
echo "These MUST be provided at runtime. Examples:" >&2
|
||||
echo "" >&2
|
||||
echo ' docker run -e JWT_SECRET="$(openssl rand -hex 32)" -e ENCRYPTION_KEY="$(openssl rand -hex 32)" opsxloki' >&2
|
||||
echo ' docker run -e JWT_SECRET="$(openssl rand -hex 32)" -e ENCRYPTION_KEY="$(openssl rand -hex 32)" lokistack' >&2
|
||||
echo "" >&2
|
||||
echo "Or via docker-compose secrets (recommended for production):" >&2
|
||||
echo ' secrets:' >&2
|
||||
@@ -85,7 +85,7 @@ shutdown() {
|
||||
}
|
||||
trap shutdown SIGTERM SIGINT
|
||||
|
||||
echo "opsx-loki starting with security configuration OK"
|
||||
echo "lokistack starting with security configuration OK"
|
||||
echo "Data directory: ${DATA_DIR}"
|
||||
echo "Server port: ${SERVER_PORT}"
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ spec:
|
||||
persistentVolumeClaim: {claimName: lokistack-data}
|
||||
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 delete pod pvc-loader
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="alternate icon" href="/favicon.ico" />
|
||||
<title>opsx-loki</title>
|
||||
<title>LokiStack</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+69
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "lokideploy-frontend",
|
||||
"name": "lokistack-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lokideploy-frontend",
|
||||
"name": "lokistack-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -40,6 +40,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"i18next": "^26.5.11",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -47,6 +48,7 @@
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-i18next": "^17.1.9",
|
||||
"react-resizable-panels": "^4.2.2",
|
||||
"react-router": "^7.6.1",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -4372,6 +4374,43 @@
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"dev": true,
|
||||
@@ -5099,6 +5138,33 @@
|
||||
"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": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@@ -5650,7 +5716,7 @@
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "opsx-loki-frontend",
|
||||
"name": "lokistack-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
@@ -42,6 +42,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"i18next": "^26.5.11",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -49,6 +50,7 @@
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-i18next": "^17.1.9",
|
||||
"react-resizable-panels": "^4.2.2",
|
||||
"react-router": "^7.6.1",
|
||||
"recharts": "^2.15.4",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { isLoggedIn, getUser } from '@/lib/auth-storage'
|
||||
import { routes, findRouteMeta } from '@/router/routes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
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() {
|
||||
const location = useLocation()
|
||||
const { t, i18n } = useTranslation()
|
||||
useEffect(() => {
|
||||
const title = findRouteMeta(location.pathname)?.title
|
||||
document.title = title && location.pathname !== '/login' ? `${title} · lokistack` : 'lokistack'
|
||||
}, [location.pathname])
|
||||
const titleKey = findRouteMeta(location.pathname)?.title
|
||||
const title = titleKey ? t(titleKey) : ''
|
||||
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`),
|
||||
|
||||
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}`),
|
||||
|
||||
// 批量删除:逐个应用守卫(不能删自己/最后一个 admin),失败项单独返回
|
||||
batchRemove: (ids: number[]) =>
|
||||
http.post<{ deleted: number; failures: Array<{ id: number; reason: string }> }>(
|
||||
'/users/batch-delete',
|
||||
{ ids },
|
||||
),
|
||||
|
||||
// 管理员重置密码(后端会吊销目标用户全部旧会话)
|
||||
resetPassword: (id: number, newPassword: string) =>
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -31,26 +32,29 @@ export const ConfirmDialog = memo(function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
name,
|
||||
title = '确认删除',
|
||||
title,
|
||||
description,
|
||||
confirmText = '删除',
|
||||
confirmText,
|
||||
loading = false,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const desc =
|
||||
description ??
|
||||
(name
|
||||
? `确定要删除「${name}」吗?此操作不可恢复。`
|
||||
: '确定要执行该操作吗?此操作不可恢复。')
|
||||
? t('dialog.confirmDefault', { name })
|
||||
: t('dialog.confirmIrreversible'))
|
||||
const resolvedTitle = title ?? t('dialog.confirmTitle')
|
||||
const resolvedConfirmText = confirmText ?? t('dialog.confirmText')
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogTitle>{resolvedTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{desc}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={loading}>{t('dialog.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={loading}
|
||||
onClick={(e) => {
|
||||
@@ -60,7 +64,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{loading ? '处理中...' : confirmText}
|
||||
{loading ? t('dialog.processing') : resolvedConfirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import i18n from '@/i18n'
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted dark:bg-slate-950 p-4">
|
||||
<div className="max-w-lg w-full bg-card rounded-2xl shadow-lg border border-destructive/30 p-6">
|
||||
<h2 className="text-lg font-semibold text-destructive mb-2">页面渲染出错</h2>
|
||||
<h2 className="text-lg font-semibold text-destructive mb-2">{i18n.t('boundary.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
页面遇到异常,可尝试刷新;若持续出现请把下方信息反馈给开发者。
|
||||
{i18n.t('boundary.hint')}
|
||||
</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">
|
||||
{this.state.message || '未知错误'}
|
||||
{this.state.message || i18n.t('boundary.unknown')}
|
||||
</pre>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90"
|
||||
>
|
||||
刷新页面
|
||||
{i18n.t('boundary.reload')}
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 bg-muted text-muted-foreground rounded-lg text-sm hover:bg-muted"
|
||||
>
|
||||
重置组件
|
||||
{i18n.t('boundary.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertCircle, RotateCw } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -15,21 +16,23 @@ interface ErrorStateProps {
|
||||
* 禁止把失败吞掉后渲染成「暂无数据」(数据组件三态之一,见开发标准 07 章)。
|
||||
*/
|
||||
export const ErrorState = memo(function ErrorState({
|
||||
message = '加载失败',
|
||||
message,
|
||||
onRetry,
|
||||
className,
|
||||
}: ErrorStateProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedMessage = message ?? t('error.title')
|
||||
return (
|
||||
<Card className="border-destructive/30 border-dashed">
|
||||
<CardContent className={className ?? 'p-12 text-center'}>
|
||||
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
|
||||
<p className="text-muted-foreground font-medium">{message}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">请检查网络或后端服务状态后重试</p>
|
||||
<p className="text-muted-foreground font-medium">{resolvedMessage}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('error.hint')}</p>
|
||||
{onRetry && (
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RotateCw className="w-3.5 h-3.5 mr-1" />
|
||||
重试
|
||||
{t('error.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,27 +16,30 @@ import {
|
||||
Menu,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { menuItems, findRouteMeta } from '@/router/routes'
|
||||
import { getUser, clearAuth } from '@/lib/auth-storage'
|
||||
import { isThemeAuto, setThemeAuto } from '@/lib/theme-schedule'
|
||||
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// 侧栏折叠状态持久化 key(命名空间见开发标准 08 章)
|
||||
const LAYOUT_COLLAPSED_KEY = 'opsxloki.layout.collapsed'
|
||||
const LAYOUT_COLLAPSED_KEY = 'lokistack.layout.collapsed'
|
||||
|
||||
// 侧栏导航 — 桌面 aside 与移动端抽屉共用;菜单数据来自路由配置单源派生
|
||||
function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
|
||||
const location = useLocation()
|
||||
const { t } = useTranslation()
|
||||
const isAdmin = getUser()?.role === 'admin'
|
||||
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
|
||||
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
|
||||
return (
|
||||
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto" aria-label="主导航">
|
||||
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto" aria-label={t('layout.nav')}>
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
@@ -44,17 +47,17 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
aria-label={item.label}
|
||||
aria-label={t(item.label)}
|
||||
className={cn(
|
||||
'relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
||||
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'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
|
||||
? '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/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" />}
|
||||
{!collapsed && <span className="text-sm font-medium">{item.label}</span>}
|
||||
{!collapsed && <span className="text-sm font-medium">{t(item.label)}</span>}
|
||||
{!collapsed && isActive && (
|
||||
<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">
|
||||
<Activity className="w-5 h-5 text-sidebar-primary" />
|
||||
</div>
|
||||
<span className="font-bold text-lg tracking-tight">lokistack</span>
|
||||
<span className="font-bold text-xl tracking-tight">LokiStack</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 主题三态切换:浅色 → 深色 → 跟随系统(key: opsxloki.theme.mode)
|
||||
// 主题二态切换:浅色 ↔ 深色(key: lokistack.theme.mode;resolvedTheme 兼容"跟随系统/定时"偏好)
|
||||
// 定时模式开启时点击 = 关闭定时并切到相反主题(用户手动覆盖调度)。
|
||||
function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'
|
||||
const label = next === 'light' ? '切换为浅色' : next === 'dark' ? '切换为深色' : '切换为跟随系统'
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const label = isDark ? t('layout.toLight') : t('layout.toDark')
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(next)}
|
||||
onClick={() => {
|
||||
if (isThemeAuto()) {
|
||||
setThemeAuto(false)
|
||||
setTheme(isDark ? 'light' : 'dark')
|
||||
return
|
||||
}
|
||||
setTheme(isDark ? 'light' : 'dark')
|
||||
}}
|
||||
aria-label={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' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : theme === 'dark' ? (
|
||||
<Moon className="w-4 h-4" />
|
||||
) : (
|
||||
<Monitor className="w-4 h-4" />
|
||||
)}
|
||||
{isDark ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// 页面级加载骨架(挂在 main 内,切页时布局不闪)
|
||||
function PageSkeleton() {
|
||||
const { t } = useTranslation()
|
||||
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">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<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 location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const user = getUser()
|
||||
|
||||
// 主题定时调度(「定时」模式开启时每分钟按时区解析浅/深色)
|
||||
useScheduledTheme()
|
||||
|
||||
// 顶栏标题来自路由 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(() => {
|
||||
@@ -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">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
aria-label={collapsed ? '展开菜单' : '收起菜单'}
|
||||
title={collapsed ? '展开菜单' : '收起菜单'}
|
||||
aria-label={collapsed ? t('layout.expand') : t('layout.collapse')}
|
||||
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"
|
||||
>
|
||||
{collapsed ? (
|
||||
@@ -190,7 +203,7 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="bg-sidebar text-sidebar-accent-foreground border-none p-0 w-72">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle className="text-white">lokistack 导航</SheetTitle>
|
||||
<SheetTitle className="text-sidebar-accent-foreground">{t('layout.sheetTitle')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col h-full">
|
||||
<SidebarLogo collapsed={false} />
|
||||
@@ -203,40 +216,40 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
{/* Top Bar */}
|
||||
<header className="h-16 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-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">
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="打开导航菜单"
|
||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
aria-label={t('layout.openNav')}
|
||||
className="p-2 rounded-lg text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</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}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<ThemeToggle />
|
||||
{user && (
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>{user.username}</span>
|
||||
<span className="px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium">
|
||||
{user.role === 'admin' ? '管理员' : user.role}
|
||||
{user.role === 'admin' ? t('layout.admin') : user.role}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="退出登录"
|
||||
aria-label="退出登录"
|
||||
title={t('layout.logout')}
|
||||
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"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span className="hidden md:inline">退出</span>
|
||||
<span className="hidden md:inline">{t('layout.logoutShort')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import i18n from '@/i18n'
|
||||
import { memo } from 'react'
|
||||
import { Activity } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -23,12 +24,13 @@ export const LoadingSpinner = memo(function LoadingSpinner({
|
||||
className,
|
||||
text,
|
||||
}: LoadingSpinnerProps) {
|
||||
const resolvedText = text ?? i18n.t('spinner.default')
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-3', className)}>
|
||||
<Activity
|
||||
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>
|
||||
)
|
||||
})
|
||||
@@ -37,7 +39,7 @@ export const LoadingSpinner = memo(function LoadingSpinner({
|
||||
* 全屏居中加载状态
|
||||
*/
|
||||
export const FullScreenLoader = memo(function FullScreenLoader({
|
||||
text = '加载中...',
|
||||
text,
|
||||
}: Pick<LoadingSpinnerProps, 'text'>) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation } from 'react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
@@ -26,7 +27,8 @@ export const PageContainer = memo(function PageContainer({
|
||||
children,
|
||||
}: PageContainerProps) {
|
||||
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 showHeader = Boolean(title && (!repeatedTitle || description || action))
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { memo, useMemo } from 'react'
|
||||
import {
|
||||
Pagination,
|
||||
@@ -42,13 +43,14 @@ export const PaginationBar = memo(function PaginationBar({
|
||||
onPageChange,
|
||||
className,
|
||||
}: PaginationBarProps) {
|
||||
const { t } = useTranslation()
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const pages = useMemo(() => pageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
if (total <= pageSize) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -102,7 +104,7 @@ export const PaginationBar = memo(function PaginationBar({
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
<p className="text-xs text-muted-foreground text-center mt-1">
|
||||
共 {total} 条 · 第 {page}/{totalPages} 页
|
||||
{t('pagination.summary', { count: total, page, pages: totalPages })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import i18n from '@/i18n'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface PasswordInputProps extends Omit<React.ComponentProps<typeof Input>, 'type'> {
|
||||
@@ -24,7 +25,7 @@ export const PasswordInput = memo(function PasswordInput({
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={visible ? '隐藏密码' : '显示密码'}
|
||||
aria-label={visible ? i18n.t('password.hide') : i18n.t('password.show')}
|
||||
onClick={() => setVisible((prev) => !prev)}
|
||||
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 { Clock } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { statusLabel } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { STATUS_CONFIG } from './status-config'
|
||||
|
||||
export type StatusSize = 'sm' | 'md'
|
||||
@@ -13,16 +13,19 @@ interface StatusBadgeProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function getStatusConfig(status: string): { icon: typeof Clock; color: string; bg: string; label: string } {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
function getStatusConfig(status: string): { icon: typeof Clock; color: string; bg: string } {
|
||||
return STATUS_CONFIG[status] || {
|
||||
icon: Clock,
|
||||
color: '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 }> = {
|
||||
@@ -41,7 +44,9 @@ export const StatusBadge = memo(function StatusBadge({
|
||||
size = 'sm',
|
||||
className,
|
||||
}: StatusBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
const config = useMemo(() => getStatusConfig(status), [status])
|
||||
const label = statusLabelOf(t, status)
|
||||
const sizeClasses = SIZE_MAP[size]
|
||||
const Icon = config.icon
|
||||
|
||||
@@ -61,7 +66,7 @@ export const StatusBadge = memo(function StatusBadge({
|
||||
status === 'deploying' && 'animate-pulse'
|
||||
)}
|
||||
/>
|
||||
{showLabel && <span>{config.label}</span>}
|
||||
{showLabel && <span>{label}</span>}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ export { default as PageContainer } from './PageContainer';
|
||||
export { default as PageHeader } from './PageHeader';
|
||||
export { default as InfoRow } from './InfoRow';
|
||||
export { ResourceCard } from './ResourceCard';
|
||||
export { CardActionButton } from './CardActionButton';
|
||||
export { default as ConfirmDialog } from './ConfirmDialog';
|
||||
export { default as ErrorState } from './ErrorState';
|
||||
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 i18n from '@/i18n'
|
||||
import type { PageData } from '@/types'
|
||||
import type { ListParams } from '@/api'
|
||||
|
||||
@@ -71,7 +72,7 @@ export function useListQuery<T>(
|
||||
setTotal(res.total ?? 0)
|
||||
} catch (err) {
|
||||
if (currentVersion !== versionRef.current) return
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
setError(err instanceof Error ? err.message : i18n.t('error.title'))
|
||||
} finally {
|
||||
if (currentVersion === versionRef.current) {
|
||||
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