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:
opsx committed 2026-10-27 17:11:38 +08:00
1 parent 02d1bd87a7
commit f3733f0bfc
201 files changed
+5438 -2388

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ secrets/
# Build artifacts
data/
logs/
lokistack.json
opsxloki.json
*.db
*.db-journal
+1 -1
View File
@@ -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
+10 -10
View File
@@ -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`。
+8 -8
View File
@@ -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
View File
@@ -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"]
+10 -10
View File
@@ -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..."
+107 -492
View File
@@ -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 文件
+8 -5
View File
@@ -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
}
+8 -8
View File
@@ -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 {
+6 -6
View File
@@ -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"
)
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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"
)
+4 -4
View File
@@ -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"
+4 -24
View File
@@ -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})
}
+3 -3
View File
@@ -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 -3
View File
@@ -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 -3
View File
@@ -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"
)
+4 -4
View File
@@ -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"
)
+5 -5
View File
@@ -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"
)
+20 -5
View File
@@ -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 -1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
package dto
import "opsxloki/models"
import "lokistack/models"
// 日志查询域
type LogSourceRequest struct {
+4
View File
@@ -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
View File
@@ -1,4 +1,4 @@
module opsxloki
module lokistack
go 1.24.0
+7 -7
View File
@@ -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")
}
+4 -4
View File
@@ -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"
)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -3,7 +3,7 @@ package models
import (
"time"
"opsxloki/pkg/crypto"
"lokistack/pkg/crypto"
)
type Cluster struct {
-2
View File
@@ -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 故意省略
+1 -1
View File
@@ -3,7 +3,7 @@ package models
import (
"time"
"opsxloki/pkg/crypto"
"lokistack/pkg/crypto"
)
// SSHAuthConfig SSH认证配置(定义在models包避免循环导入)
+1 -1
View File
@@ -3,7 +3,7 @@ package models
import (
"time"
"opsxloki/pkg/crypto"
"lokistack/pkg/crypto"
)
type StorageConfig struct {
+1 -1
View File
@@ -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"`
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"io"
"strings"
"opsxloki/config"
"lokistack/config"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/hkdf"
+1 -1
View File
@@ -10,6 +10,6 @@ import (
func TestMain(m *testing.M) {
os.Setenv("JWT_SECRET", "crypto-pkg-test-secret-32chars!!")
os.Setenv("ENCRYPTION_KEY", "crypto-pkg-test-encryption-32ch!!")
os.Setenv("DATA_DIR", os.TempDir()+"/opsxloki-crypto-pkg-test")
os.Setenv("DATA_DIR", os.TempDir()+"/lokistack-crypto-pkg-test")
os.Exit(m.Run())
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strconv"
"time"
"opsxloki/config"
"lokistack/config"
"github.com/gin-gonic/gin"
jwtv5 "github.com/golang-jwt/jwt/v5"
+1 -1
View File
@@ -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())
}
+3 -3
View File
@@ -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
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sync"
"testing"
"opsxloki/models"
"lokistack/models"
)
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
+1 -1
View File
@@ -1,7 +1,7 @@
package repository
import (
"opsxloki/models"
"lokistack/models"
"testing"
)
+2 -2
View File
@@ -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)
}
+5 -5
View File
@@ -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)
+3 -3
View File
@@ -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},
+4 -4
View File
@@ -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 {
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"strings"
"time"
"opsxloki/models"
"opsxloki/repository"
"lokistack/models"
"lokistack/repository"
)
const alertCheckInterval = time.Minute
+28 -5
View File
@@ -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()
+5 -5
View File
@@ -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{}
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"opsxloki/models"
"lokistack/models"
"testing"
)
+12 -5
View File
@@ -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 名称")
+3 -3
View File
@@ -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"},
+3 -114
View File
@@ -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 -14
View File
@@ -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
+5 -1
View File
@@ -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:
+1 -1
View File
@@ -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) {
+4 -20
View File
@@ -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)
}
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"fmt"
"strings"
"opsxloki/models"
"opsxloki/repository"
"lokistack/models"
"lokistack/repository"
)
const (
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+5 -5
View File
@@ -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"
)
+4 -4
View File
@@ -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"`
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"time"
"opsxloki/models"
"opsxloki/repository"
"lokistack/models"
"lokistack/repository"
)
func ListLogPolicyGroups() []models.LogPolicyGroup {
+2 -2
View File
@@ -17,8 +17,8 @@ import (
"strings"
"time"
"opsxloki/models"
"opsxloki/repository"
"lokistack/models"
"lokistack/repository"
"k8s.io/client-go/rest"
)
+1 -1
View File
@@ -1,7 +1,7 @@
package services
import (
"opsxloki/models"
"lokistack/models"
"strings"
"testing"
)
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"strings"
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
"lokistack/models"
"lokistack/pkg/crypto"
"lokistack/repository"
)
// ==================== 数据源 CRUD ====================
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"text/template"
"opsxloki/pkg/crypto"
"lokistack/pkg/crypto"
)
const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }}
+4 -4
View File
@@ -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
+28 -8
View File
@@ -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
+2 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"encoding/base64"
"fmt"
"opsxloki/pkg/crypto"
"lokistack/pkg/crypto"
)
func (d *LokiDeployer) generateManifest(ns string) string {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"opsxloki/models"
"lokistack/models"
)
// TestObjectStoreConfigs 验证 OSS / COS / OBS 生成的 Loki 配置:
+5 -5
View File
@@ -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 日志告警"}}},
},
},
}
+4 -4
View File
@@ -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 日志告警"}}},
},
},
}
+3 -3
View File
@@ -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 -4
View File
@@ -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{}
+1 -1
View File
@@ -3,7 +3,7 @@ package services
import (
"testing"
"opsxloki/repository"
"lokistack/repository"
)
func TestStorageUpdateReplacesBothCredentials(t *testing.T) {
+7 -7
View File
@@ -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"
+3 -3
View File
@@ -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}"
+1 -1
View File
@@ -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
View File
@@ -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>
+69 -3
View File
@@ -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",
+3 -1
View File
@@ -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",
+7 -4
View File
@@ -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])
}
/**
-3
View File
@@ -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 }),
};
+7
View File
@@ -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>
)
})
+11 -7
View File
@@ -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>
+7 -6
View File
@@ -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>
+7 -4
View File
@@ -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>
)}
+49 -36
View File
@@ -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>
)}
+4 -2
View File
@@ -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">
+3 -1
View File
@@ -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))
+4 -2
View File
@@ -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>
)
+2 -1
View File
@@ -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"
>
+13 -8
View File
@@ -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>
)
})
+1
View File
@@ -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';
+36
View File
@@ -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 }
+2 -1
View File
@@ -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