diff --git a/.dockerignore b/.dockerignore index 860bcae..cb158d6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -45,6 +45,7 @@ secrets/ # Build artifacts data/ logs/ +lokistack.json opsxloki.json *.db *.db-journal diff --git a/.env.example b/.env.example index 21d7823..3951ef2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a30fb01..bf7646f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`。 diff --git a/CLAUDE.md b/CLAUDE.md index 208dc3b..5b564e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`。 diff --git a/Dockerfile b/Dockerfile index 27f59cc..ad1e4c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile index f5e174f..1d51b3e 100644 --- a/Makefile +++ b/Makefile @@ -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..." diff --git a/README.md b/README.md index cb3cea1..29e5b5c 100644 --- a/README.md +++ b/README.md @@ -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 :/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 文件 diff --git a/backend/config/config.go b/backend/config/config.go index 0e35d86..5bf9a18 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -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 } diff --git a/backend/controllers/alerts.go b/backend/controllers/alerts.go index 2eb60c5..dca4959 100644 --- a/backend/controllers/alerts.go +++ b/backend/controllers/alerts.go @@ -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 { diff --git a/backend/controllers/auth.go b/backend/controllers/auth.go index 2cfa3a5..3fa1b36 100644 --- a/backend/controllers/auth.go +++ b/backend/controllers/auth.go @@ -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" ) diff --git a/backend/controllers/cluster.go b/backend/controllers/cluster.go index 681b9c3..7291fbe 100644 --- a/backend/controllers/cluster.go +++ b/backend/controllers/cluster.go @@ -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" diff --git a/backend/controllers/collector.go b/backend/controllers/collector.go index 0cfd812..c4b6ae6 100644 --- a/backend/controllers/collector.go +++ b/backend/controllers/collector.go @@ -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" ) diff --git a/backend/controllers/deployment.go b/backend/controllers/deployment.go index be88544..cb441ec 100644 --- a/backend/controllers/deployment.go +++ b/backend/controllers/deployment.go @@ -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" diff --git a/backend/controllers/host.go b/backend/controllers/host.go index c6162e7..4bb614f 100644 --- a/backend/controllers/host.go +++ b/backend/controllers/host.go @@ -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}) -} diff --git a/backend/controllers/image_registry.go b/backend/controllers/image_registry.go index 35d9b66..edd1d64 100644 --- a/backend/controllers/image_registry.go +++ b/backend/controllers/image_registry.go @@ -1,9 +1,9 @@ package controllers import ( - "opsxloki/dto" - "opsxloki/services" - "opsxloki/utils" + "lokistack/dto" + "lokistack/services" + "lokistack/utils" "github.com/gin-gonic/gin" ) diff --git a/backend/controllers/ldap.go b/backend/controllers/ldap.go index a1802d8..b2afea9 100644 --- a/backend/controllers/ldap.go +++ b/backend/controllers/ldap.go @@ -1,9 +1,9 @@ package controllers import ( - "opsxloki/dto" - "opsxloki/services" - "opsxloki/utils" + "lokistack/dto" + "lokistack/services" + "lokistack/utils" "github.com/gin-gonic/gin" ) diff --git a/backend/controllers/log_policy.go b/backend/controllers/log_policy.go index a665f5b..0b5e3c6 100644 --- a/backend/controllers/log_policy.go +++ b/backend/controllers/log_policy.go @@ -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" ) diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index a012c0d..2496a2f 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -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" ) diff --git a/backend/controllers/storage.go b/backend/controllers/storage.go index 4e81213..8921026 100644 --- a/backend/controllers/storage.go +++ b/backend/controllers/storage.go @@ -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" ) diff --git a/backend/controllers/user.go b/backend/controllers/user.go index c791437..cc0f630 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -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) diff --git a/backend/dto/image_registry.go b/backend/dto/image_registry.go index 82116f7..6df61d7 100644 --- a/backend/dto/image_registry.go +++ b/backend/dto/image_registry.go @@ -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"` diff --git a/backend/dto/logs.go b/backend/dto/logs.go index 99bd3f0..d486335 100644 --- a/backend/dto/logs.go +++ b/backend/dto/logs.go @@ -1,6 +1,6 @@ package dto -import "opsxloki/models" +import "lokistack/models" // 日志查询域 type LogSourceRequest struct { diff --git a/backend/dto/user.go b/backend/dto/user.go index c8bfee7..d7adef6 100644 --- a/backend/dto/user.go +++ b/backend/dto/user.go @@ -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"` +} diff --git a/backend/go.mod b/backend/go.mod index d117a82..4defb87 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,4 +1,4 @@ -module opsxloki +module lokistack go 1.24.0 diff --git a/backend/main.go b/backend/main.go index 3a2d51a..c4b2d06 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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") } diff --git a/backend/middleware/jwt.go b/backend/middleware/jwt.go index 961fa48..acd7ad0 100644 --- a/backend/middleware/jwt.go +++ b/backend/middleware/jwt.go @@ -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" ) diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go index 23d652c..d79077b 100644 --- a/backend/middleware/ratelimit.go +++ b/backend/middleware/ratelimit.go @@ -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 diff --git a/backend/models/cluster.go b/backend/models/cluster.go index 537eafc..fbd7f53 100644 --- a/backend/models/cluster.go +++ b/backend/models/cluster.go @@ -3,7 +3,7 @@ package models import ( "time" - "opsxloki/pkg/crypto" + "lokistack/pkg/crypto" ) type Cluster struct { diff --git a/backend/models/deployment.go b/backend/models/deployment.go index b606ae6..42aa36c 100644 --- a/backend/models/deployment.go +++ b/backend/models/deployment.go @@ -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 故意省略 diff --git a/backend/models/host.go b/backend/models/host.go index d365e26..5fbb792 100644 --- a/backend/models/host.go +++ b/backend/models/host.go @@ -3,7 +3,7 @@ package models import ( "time" - "opsxloki/pkg/crypto" + "lokistack/pkg/crypto" ) // SSHAuthConfig SSH认证配置(定义在models包避免循环导入) diff --git a/backend/models/storage.go b/backend/models/storage.go index 0372063..9859bee 100644 --- a/backend/models/storage.go +++ b/backend/models/storage.go @@ -3,7 +3,7 @@ package models import ( "time" - "opsxloki/pkg/crypto" + "lokistack/pkg/crypto" ) type StorageConfig struct { diff --git a/backend/models/user.go b/backend/models/user.go index 04ed929..a2409f2 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -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"` diff --git a/backend/pkg/crypto/crypto.go b/backend/pkg/crypto/crypto.go index d272af7..cf4210f 100644 --- a/backend/pkg/crypto/crypto.go +++ b/backend/pkg/crypto/crypto.go @@ -14,7 +14,7 @@ import ( "io" "strings" - "opsxloki/config" + "lokistack/config" "golang.org/x/crypto/argon2" "golang.org/x/crypto/hkdf" diff --git a/backend/pkg/crypto/main_test.go b/backend/pkg/crypto/main_test.go index 563c77b..d601cc8 100644 --- a/backend/pkg/crypto/main_test.go +++ b/backend/pkg/crypto/main_test.go @@ -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()) } diff --git a/backend/pkg/jwt/jwt.go b/backend/pkg/jwt/jwt.go index 43cf5ec..37ce3b3 100644 --- a/backend/pkg/jwt/jwt.go +++ b/backend/pkg/jwt/jwt.go @@ -8,7 +8,7 @@ import ( "strconv" "time" - "opsxloki/config" + "lokistack/config" "github.com/gin-gonic/gin" jwtv5 "github.com/golang-jwt/jwt/v5" diff --git a/backend/pkg/jwt/main_test.go b/backend/pkg/jwt/main_test.go index be171d9..c3b99f0 100644 --- a/backend/pkg/jwt/main_test.go +++ b/backend/pkg/jwt/main_test.go @@ -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()) } diff --git a/backend/repository/db.go b/backend/repository/db.go index ca95cc0..644bf46 100644 --- a/backend/repository/db.go +++ b/backend/repository/db.go @@ -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 diff --git a/backend/repository/db_test.go b/backend/repository/db_test.go index c0143f9..e32a700 100644 --- a/backend/repository/db_test.go +++ b/backend/repository/db_test.go @@ -6,7 +6,7 @@ import ( "sync" "testing" - "opsxloki/models" + "lokistack/models" ) // TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试: diff --git a/backend/repository/log_policy_test.go b/backend/repository/log_policy_test.go index 9700f94..91ebe96 100644 --- a/backend/repository/log_policy_test.go +++ b/backend/repository/log_policy_test.go @@ -1,7 +1,7 @@ package repository import ( - "opsxloki/models" + "lokistack/models" "testing" ) diff --git a/backend/repository/main_test.go b/backend/repository/main_test.go index 72bb981..1307123 100644 --- a/backend/repository/main_test.go +++ b/backend/repository/main_test.go @@ -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) } diff --git a/backend/routers/router.go b/backend/routers/router.go index 497c771..3dc80e4 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -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) diff --git a/backend/seed/alert_rules.go b/backend/seed/alert_rules.go index 27b4404..26668c9 100644 --- a/backend/seed/alert_rules.go +++ b/backend/seed/alert_rules.go @@ -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}, diff --git a/backend/seed/seed.go b/backend/seed/seed.go index 7faf219..ea1ee2a 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -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 exec deploy/opsx-loki -- cat %s", path) + log.Printf(" kubectl -n exec deploy/lokistack -- cat %s", path) log.Printf(" ⚠️ 首次登录后请立即修改密码(改密后本文件自动删除)") log.Printf("==================================================") } else { diff --git a/backend/services/alert.go b/backend/services/alert.go index 2031843..26581b3 100644 --- a/backend/services/alert.go +++ b/backend/services/alert.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "opsxloki/models" - "opsxloki/repository" + "lokistack/models" + "lokistack/repository" ) const alertCheckInterval = time.Minute diff --git a/backend/services/auth.go b/backend/services/auth.go index 70c3253..66714f6 100644 --- a/backend/services/auth.go +++ b/backend/services/auth.go @@ -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() diff --git a/backend/services/cluster.go b/backend/services/cluster.go index 91e4a0c..555a67c 100644 --- a/backend/services/cluster.go +++ b/backend/services/cluster.go @@ -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{} diff --git a/backend/services/cluster_info.go b/backend/services/cluster_info.go index c0018a5..d9c87b7 100644 --- a/backend/services/cluster_info.go +++ b/backend/services/cluster_info.go @@ -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" diff --git a/backend/services/cluster_workloads.go b/backend/services/cluster_workloads.go index decdef0..d23de47 100644 --- a/backend/services/cluster_workloads.go +++ b/backend/services/cluster_workloads.go @@ -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 { diff --git a/backend/services/cluster_workloads_test.go b/backend/services/cluster_workloads_test.go index ba1744e..91e9371 100644 --- a/backend/services/cluster_workloads_test.go +++ b/backend/services/cluster_workloads_test.go @@ -5,7 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" - "opsxloki/models" + "lokistack/models" "testing" ) diff --git a/backend/services/collector.go b/backend/services/collector.go index 820a02c..0fcf200 100644 --- a/backend/services/collector.go +++ b/backend/services/collector.go @@ -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 名称") diff --git a/backend/services/collector_deployer.go b/backend/services/collector_deployer.go index e03669e..582d247 100644 --- a/backend/services/collector_deployer.go +++ b/backend/services/collector_deployer.go @@ -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"}, diff --git a/backend/services/collector_templates.go b/backend/services/collector_templates.go index e2a53c8..77ba40c 100644 --- a/backend/services/collector_templates.go +++ b/backend/services/collector_templates.go @@ -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: diff --git a/backend/services/deployment.go b/backend/services/deployment.go index 30e8cdf..06307b0 100644 --- a/backend/services/deployment.go +++ b/backend/services/deployment.go @@ -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 diff --git a/backend/services/deployment_config.go b/backend/services/deployment_config.go index 81985dc..0206c71 100644 --- a/backend/services/deployment_config.go +++ b/backend/services/deployment_config.go @@ -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: diff --git a/backend/services/deployment_config_test.go b/backend/services/deployment_config_test.go index 7fe4bc8..c8073e7 100644 --- a/backend/services/deployment_config_test.go +++ b/backend/services/deployment_config_test.go @@ -9,7 +9,7 @@ import ( "testing" "gopkg.in/yaml.v3" - "opsxloki/models" + "lokistack/models" ) func TestRedactLokiYAML(t *testing.T) { diff --git a/backend/services/deployment_retention_test.go b/backend/services/deployment_retention_test.go index 9cd8dc9..ff9bb53 100644 --- a/backend/services/deployment_retention_test.go +++ b/backend/services/deployment_retention_test.go @@ -3,7 +3,7 @@ package services import ( "testing" - "opsxloki/repository" + "lokistack/repository" ) func TestParseRetentionYAML(t *testing.T) { diff --git a/backend/services/host.go b/backend/services/host.go index 9e01b2f..f656db9 100644 --- a/backend/services/host.go +++ b/backend/services/host.go @@ -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) -} diff --git a/backend/services/image_registry.go b/backend/services/image_registry.go index 674e15f..85c9171 100644 --- a/backend/services/image_registry.go +++ b/backend/services/image_registry.go @@ -9,8 +9,8 @@ import ( "fmt" "strings" - "opsxloki/models" - "opsxloki/repository" + "lokistack/models" + "lokistack/repository" ) const ( diff --git a/backend/services/ingress_access.go b/backend/services/ingress_access.go index 03be3cf..a6abe6d 100644 --- a/backend/services/ingress_access.go +++ b/backend/services/ingress_access.go @@ -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" diff --git a/backend/services/k8s_client.go b/backend/services/k8s_client.go index 9d4df0d..15a17d9 100644 --- a/backend/services/k8s_client.go +++ b/backend/services/k8s_client.go @@ -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 } diff --git a/backend/services/k8s_resources.go b/backend/services/k8s_resources.go index 84b4ba6..908b3cf 100644 --- a/backend/services/k8s_resources.go +++ b/backend/services/k8s_resources.go @@ -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 { diff --git a/backend/services/ldap.go b/backend/services/ldap.go index 28fce2e..b9638b9 100644 --- a/backend/services/ldap.go +++ b/backend/services/ldap.go @@ -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" ) diff --git a/backend/services/ldap_import.go b/backend/services/ldap_import.go index 9a6c13c..12c193d 100644 --- a/backend/services/ldap_import.go +++ b/backend/services/ldap_import.go @@ -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"` diff --git a/backend/services/log_policy.go b/backend/services/log_policy.go index 68678ea..a2437a3 100644 --- a/backend/services/log_policy.go +++ b/backend/services/log_policy.go @@ -5,8 +5,8 @@ import ( "strings" "time" - "opsxloki/models" - "opsxloki/repository" + "lokistack/models" + "lokistack/repository" ) func ListLogPolicyGroups() []models.LogPolicyGroup { diff --git a/backend/services/log_query.go b/backend/services/log_query.go index 8a03ea9..735aef2 100644 --- a/backend/services/log_query.go +++ b/backend/services/log_query.go @@ -17,8 +17,8 @@ import ( "strings" "time" - "opsxloki/models" - "opsxloki/repository" + "lokistack/models" + "lokistack/repository" "k8s.io/client-go/rest" ) diff --git a/backend/services/log_query_test.go b/backend/services/log_query_test.go index 045576a..28ee5b5 100644 --- a/backend/services/log_query_test.go +++ b/backend/services/log_query_test.go @@ -1,7 +1,7 @@ package services import ( - "opsxloki/models" + "lokistack/models" "strings" "testing" ) diff --git a/backend/services/log_source.go b/backend/services/log_source.go index c21fd21..bdbc2e9 100644 --- a/backend/services/log_source.go +++ b/backend/services/log_source.go @@ -9,9 +9,9 @@ import ( "strings" "time" - "opsxloki/models" - "opsxloki/pkg/crypto" - "opsxloki/repository" + "lokistack/models" + "lokistack/pkg/crypto" + "lokistack/repository" ) // ==================== 数据源 CRUD ==================== diff --git a/backend/services/loki_config.go b/backend/services/loki_config.go index 0fae8d8..e73a059 100644 --- a/backend/services/loki_config.go +++ b/backend/services/loki_config.go @@ -6,7 +6,7 @@ import ( "fmt" "text/template" - "opsxloki/pkg/crypto" + "lokistack/pkg/crypto" ) const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }} diff --git a/backend/services/loki_deployer.go b/backend/services/loki_deployer.go index d0dc59e..ce7fdf9 100644 --- a/backend/services/loki_deployer.go +++ b/backend/services/loki_deployer.go @@ -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 diff --git a/backend/services/loki_docker.go b/backend/services/loki_docker.go index 7fb3aea..0124cab 100644 --- a/backend/services/loki_docker.go +++ b/backend/services/loki_docker.go @@ -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 diff --git a/backend/services/loki_docker_test.go b/backend/services/loki_docker_test.go index b2c7dea..00d79b1 100644 --- a/backend/services/loki_docker_test.go +++ b/backend/services/loki_docker_test.go @@ -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) } } diff --git a/backend/services/loki_manifest.go b/backend/services/loki_manifest.go index 083f910..e751737 100644 --- a/backend/services/loki_manifest.go +++ b/backend/services/loki_manifest.go @@ -6,7 +6,7 @@ import ( "encoding/base64" "fmt" - "opsxloki/pkg/crypto" + "lokistack/pkg/crypto" ) func (d *LokiDeployer) generateManifest(ns string) string { diff --git a/backend/services/loki_object_store_test.go b/backend/services/loki_object_store_test.go index e94cf61..25f3cbf 100644 --- a/backend/services/loki_object_store_test.go +++ b/backend/services/loki_object_store_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "opsxloki/models" + "lokistack/models" ) // TestObjectStoreConfigs 验证 OSS / COS / OBS 生成的 Loki 配置: diff --git a/backend/services/notifier.go b/backend/services/notifier.go index 38278d4..ed93e39 100644 --- a/backend/services/notifier.go +++ b/backend/services/notifier.go @@ -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 日志告警"}}}, }, }, } diff --git a/backend/services/notify_channel.go b/backend/services/notify_channel.go index 4a37afc..5f64962 100644 --- a/backend/services/notify_channel.go +++ b/backend/services/notify_channel.go @@ -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 日志告警"}}}, }, }, } diff --git a/backend/services/ssh.go b/backend/services/ssh.go index a2e75f7..f9b990e 100644 --- a/backend/services/ssh.go +++ b/backend/services/ssh.go @@ -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" ) diff --git a/backend/services/storage.go b/backend/services/storage.go index bcac2d7..002e7a9 100644 --- a/backend/services/storage.go +++ b/backend/services/storage.go @@ -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{} diff --git a/backend/services/storage_update_test.go b/backend/services/storage_update_test.go index 6ce8d44..f8fe068 100644 --- a/backend/services/storage_update_test.go +++ b/backend/services/storage_update_test.go @@ -3,7 +3,7 @@ package services import ( "testing" - "opsxloki/repository" + "lokistack/repository" ) func TestStorageUpdateReplacesBothCredentials(t *testing.T) { diff --git a/build.sh b/build.sh index 7491c75..ff9e935 100755 --- a/build.sh +++ b/build.sh @@ -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" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 895dd1b..5f8df2a 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -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}" diff --git a/docs/deployment/kubernetes.md b/docs/deployment/kubernetes.md index 7639ee8..18f91a3 100644 --- a/docs/deployment/kubernetes.md +++ b/docs/deployment/kubernetes.md @@ -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 diff --git a/frontend/index.html b/frontend/index.html index 00352bb..db0e59a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ - opsx-loki + LokiStack
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5b3e8ee..3516175 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 1dbb0a5..fef75ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bda78de..184a83d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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]) } /** diff --git a/frontend/src/api/host/index.ts b/frontend/src/api/host/index.ts index 011f523..6336e2c 100644 --- a/frontend/src/api/host/index.ts +++ b/frontend/src/api/host/index.ts @@ -34,7 +34,4 @@ export const hostApi = { validate: (id: number) => http.post<{ message: string }>(`/hosts/${id}/validate`), getInfo: (id: number) => http.get(`/hosts/${id}/info`), - - execute: (id: number, command: string) => - http.post<{ stdout: string; stderr: string }>(`/hosts/${id}/execute`, { command }), }; diff --git a/frontend/src/api/user/index.ts b/frontend/src/api/user/index.ts index f34c998..7174b9f 100644 --- a/frontend/src/api/user/index.ts +++ b/frontend/src/api/user/index.ts @@ -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 }), diff --git a/frontend/src/components/CardActionButton.tsx b/frontend/src/components/CardActionButton.tsx new file mode 100644 index 0000000..42e6058 --- /dev/null +++ b/frontend/src/components/CardActionButton.tsx @@ -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 ( + + ) +}) diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index e25cfe5..161a637 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -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 ( - {title} + {resolvedTitle} {desc} - 取消 + {t('dialog.cancel')} { @@ -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} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 5ab5c0b..7e99779 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 (
-

页面渲染出错

+

{i18n.t('boundary.title')}

- 页面遇到异常,可尝试刷新;若持续出现请把下方信息反馈给开发者。 + {i18n.t('boundary.hint')}

-              {this.state.message || '未知错误'}
+              {this.state.message || i18n.t('boundary.unknown')}
             
diff --git a/frontend/src/components/ErrorState.tsx b/frontend/src/components/ErrorState.tsx index f6fb2c3..8aa5851 100644 --- a/frontend/src/components/ErrorState.tsx +++ b/frontend/src/components/ErrorState.tsx @@ -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 ( -

{message}

-

请检查网络或后端服务状态后重试

+

{resolvedMessage}

+

{t('error.hint')}

{onRetry && (
)} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ce8eaf2..6664a5c 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 ( -
)} ) } -// 主题三态切换:浅色 → 深色 → 跟随系统(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 ( ) } // 页面级加载骨架(挂在 main 内,切页时布局不闪) function PageSkeleton() { + const { t } = useTranslation() return ( -
+
@@ -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 })
)} -

+

{pageTitle}

{user && ( -
+
{user.username} - {user.role === 'admin' ? '管理员' : user.role} + {user.role === 'admin' ? t('layout.admin') : user.role}
)} diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx index f572733..b6ac6ea 100644 --- a/frontend/src/components/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner.tsx @@ -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 (
- {text &&

{text}

} + {resolvedText &&

{resolvedText}

}
) }) @@ -37,7 +39,7 @@ export const LoadingSpinner = memo(function LoadingSpinner({ * 全屏居中加载状态 */ export const FullScreenLoader = memo(function FullScreenLoader({ - text = '加载中...', + text, }: Pick) { return (
diff --git a/frontend/src/components/PageContainer.tsx b/frontend/src/components/PageContainer.tsx index 707995c..8eb5ada 100644 --- a/frontend/src/components/PageContainer.tsx +++ b/frontend/src/components/PageContainer.tsx @@ -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)) diff --git a/frontend/src/components/PaginationBar.tsx b/frontend/src/components/PaginationBar.tsx index f18668c..c36a221 100644 --- a/frontend/src/components/PaginationBar.tsx +++ b/frontend/src/components/PaginationBar.tsx @@ -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 (
-

共 {total} 条

+

{t('pagination.total', { count: total })}

) } @@ -102,7 +104,7 @@ export const PaginationBar = memo(function PaginationBar({

- 共 {total} 条 · 第 {page}/{totalPages} 页 + {t('pagination.summary', { count: total, page, pages: totalPages })}

) diff --git a/frontend/src/components/PasswordInput.tsx b/frontend/src/components/PasswordInput.tsx index a14c994..ee5d218 100644 --- a/frontend/src/components/PasswordInput.tsx +++ b/frontend/src/components/PasswordInput.tsx @@ -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, 'type'> { @@ -24,7 +25,7 @@ export const PasswordInput = memo(function PasswordInput({ + ) + })} +
+
+ ) +}) + +export default LanguageSection diff --git a/frontend/src/i18n/domains.ts b/frontend/src/i18n/domains.ts new file mode 100644 index 0000000..650f265 --- /dev/null +++ b/frontend/src/i18n/domains.ts @@ -0,0 +1,28 @@ +// 页面域命名空间注册 — dashboard / users / alerts 三域词典在此挂载。 +// 约束:index.ts 只注册 common(由他人维护,不做改动);域词典通过本模块以 +// addResourceBundle 挂载,由各域页面入口副作用 import 引入(页面为懒加载 chunk, +// chunk 求值先于组件渲染,因此 useTranslation('') 时资源必然已就绪)。 +// 新增页面域时:在 locales// 下新增词典文件,并在下方 DOMAIN_BUNDLES 补一条。 +import i18n from './index' + +import zhDashboard from './locales/zh/dashboard' +import enDashboard from './locales/en/dashboard' +import zhUsers from './locales/zh/users' +import enUsers from './locales/en/users' +import zhAlerts from './locales/zh/alerts' +import enAlerts from './locales/en/alerts' + +const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [ + ['zh', 'dashboard', zhDashboard], + ['en', 'dashboard', enDashboard], + ['zh', 'users', zhUsers], + ['en', 'users', enUsers], + ['zh', 'alerts', zhAlerts], + ['en', 'alerts', enAlerts], +] + +for (const [lng, ns, resources] of DOMAIN_BUNDLES) { + if (!i18n.hasResourceBundle(lng, ns)) { + i18n.addResourceBundle(lng, ns, resources) + } +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..078bf3d --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,87 @@ +// i18n 入口 — 语言持久化(lokistack.i18n.lang)+ 命名空间注册。 +// 约定:全局框架用 common 命名空间;每个页面域一个命名空间(见 locales//.ts), +// 页面组件用 useTranslation('') 取文案,禁止散落硬编码。 +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +import zhCommon from './locales/zh/common' +import enCommon from './locales/en/common' +import zhClusters from './locales/zh/clusters' +import enClusters from './locales/en/clusters' +import zhHosts from './locales/zh/hosts' +import enHosts from './locales/en/hosts' +import zhStorage from './locales/zh/storage' +import enStorage from './locales/en/storage' +import zhDeployments from './locales/zh/deployments' +import enDeployments from './locales/en/deployments' +import zhCollectors from './locales/zh/collectors' +import enCollectors from './locales/en/collectors' +import zhWizard from './locales/zh/wizard' +import enWizard from './locales/en/wizard' +import zhLogs from './locales/zh/logs' +import enLogs from './locales/en/logs' +import zhDashboard from './locales/zh/dashboard' +import enDashboard from './locales/en/dashboard' +import zhUsers from './locales/zh/users' +import enUsers from './locales/en/users' +import zhAlerts from './locales/zh/alerts' +import enAlerts from './locales/en/alerts' +import zhSettings from './locales/zh/settings' +import enSettings from './locales/en/settings' + +const LANG_KEY = 'lokistack.i18n.lang' +export type AppLanguage = 'zh' | 'en' + +export function getStoredLanguage(): AppLanguage { + const stored = localStorage.getItem(LANG_KEY) + return stored === 'en' ? 'en' : 'zh' +} + +export function storeLanguage(lang: AppLanguage): void { + localStorage.setItem(LANG_KEY, lang) +} + +function detectLanguage(): AppLanguage { + const stored = localStorage.getItem(LANG_KEY) + if (stored === 'en' || stored === 'zh') return stored + return navigator.language?.toLowerCase().startsWith('zh') ? 'zh' : 'en' +} + +void i18n.use(initReactI18next).init({ + resources: { + zh: { + common: zhCommon, + clusters: zhClusters, + hosts: zhHosts, + storage: zhStorage, + deployments: zhDeployments, + collectors: zhCollectors, + wizard: zhWizard, + logs: zhLogs, + dashboard: zhDashboard, + users: zhUsers, + alerts: zhAlerts, + settings: zhSettings, + }, + en: { + common: enCommon, + clusters: enClusters, + hosts: enHosts, + storage: enStorage, + deployments: enDeployments, + collectors: enCollectors, + wizard: enWizard, + logs: enLogs, + dashboard: enDashboard, + users: enUsers, + alerts: enAlerts, + settings: enSettings, + }, + }, + lng: detectLanguage(), + fallbackLng: 'zh', + defaultNS: 'common', + interpolation: { escapeValue: false }, +}) + +export default i18n diff --git a/frontend/src/i18n/locales/en/alerts.ts b/frontend/src/i18n/locales/en/alerts.ts new file mode 100644 index 0000000..a086d8a --- /dev/null +++ b/frontend/src/i18n/locales/en/alerts.ts @@ -0,0 +1,82 @@ +// alerts namespace — Log alerts domain (header / rule form & templates / delivery history / Feishu (Lark) notify hints) +export default { + title: 'Alerts', + subtitle: + 'Monitor logs with LogQL count rules; push a Feishu (Lark) card when the threshold is exceeded (1-minute cycle)', + checkNow: 'Check Now', + checking: 'Checking...', + create: 'New Rule', + emptyTitle: 'No alert rules yet', + emptyDesc: + 'e.g. {namespace=~".+"} |= "FATAL" — any match within 15 minutes pushes a Feishu (Lark) card', + sourceFallback: 'Data source {{id}}', + noChannels: 'No channels linked', + channelSep: ', ', + ruleMeta: + 'Fires on more than {{threshold}} matches within {{window}} min · Cooldown {{cooldown}} min', + lastFired: 'Last fired {{time}}', + editAria: 'Edit {{name}}', + deleteAria: 'Delete {{name}}', + historyTitle: 'Delivery history (last 50)', + notifyHintPre: 'Notifications not configured? Go to', + notifyHintPost: 'to configure a Feishu (Lark) Webhook', + editTitle: 'Edit rule · {{name}}', + createTitle: 'New Alert Rule', + templates: 'Quick templates (click to prefill, then adjust as needed)', + saving: 'Saving...', + save: 'Save Changes', + submitCreate: 'Create Rule', + loadFailed: 'Failed to load alert data', + saveFailed: 'Failed to save', + deleteFailed: 'Failed to delete', + checkDone: 'Check completed. See delivery history for results.', + checkFailed: 'Check failed', + toggleFailed: 'Failed to toggle', + confirmDeleteTitle: 'Confirm alert rule deletion', + delivery: { + sent: 'Sent', + cooldown: 'Skipped (cooldown)', + failed: 'Failed', + hits_one: '{{count}} match', + hits_other: '{{count}} matches', + }, + tpl: { + nsError: { + label: 'Namespace errors', + desc: 'Error logs in a given namespace (default astralx)', + }, + allNsError: { + label: 'All-NS errors > 1000', + desc: 'Total error logs across all namespaces exceed 1000', + }, + ingress5xx: { + label: 'Ingress 5xx', + desc: 'ingress-nginx 5xx (requires JSON access logs; the platform can normalize them in one click)', + }, + ingress4xx: { + label: 'Ingress 4xx', + desc: 'ingress-nginx 4xx (requires JSON access logs)', + }, + fatal: { + label: 'FATAL', + desc: 'Alert on any FATAL log across all namespaces', + }, + }, + form: { + name: 'Rule name *', + namePlaceholder: 'App fatal errors', + source: 'Data source *', + sourcePlaceholder: 'Select a data source', + query: 'LogQL query *', + queryHint: + 'Common: {app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"', + channels: 'Channels (multi-select to fan out to multiple alert groups)', + noChannels: 'Create channels first under "Settings → Alert Notifications"', + channelDisabled: '(disabled)', + window: 'Window (min)', + threshold: 'Threshold (matches)', + thresholdHint: '0 = alert on any match', + cooldown: 'Cooldown (min)', + enabled: 'Enable rule', + }, +} diff --git a/frontend/src/i18n/locales/en/clusters.ts b/frontend/src/i18n/locales/en/clusters.ts new file mode 100644 index 0000000..3b73061 --- /dev/null +++ b/frontend/src/i18n/locales/en/clusters.ts @@ -0,0 +1,89 @@ +// clusters namespace — cluster management page (English) +// Rich-text values (/) are rendered via react-i18next ; keep the tags. +export default { + title: 'Clusters', + description: 'Manage Kubernetes clusters for Loki deployment', + actions: { + add: 'Add Cluster', + detail: 'Details', + editAria: 'Edit cluster {{name}}', + validateAria: 'Validate cluster {{name}}', + deleteAria: 'Delete cluster {{name}}', + }, + fields: { + accessType: 'Access Method', + apiServer: 'API Server', + version: 'Version', + nodes: 'Nodes', + unknown: 'Unknown', + }, + empty: { + title: 'No clusters yet', + description: 'Add your first Kubernetes cluster to get started', + }, + delete: { + warning: + 'Delete "{{name}}"? Warning: {{dep}} deployments and {{col}} collector policies still reference this cluster; they will stop working once it is deleted.', + }, + toast: { + deleteFailed: 'Failed to delete cluster', + validateFailed: 'Validation failed', + createFailed: 'Failed to create cluster', + }, + form: { + addTitle: 'Add Kubernetes Cluster', + editTitle: 'Edit Cluster', + editTitleWithName: 'Edit Cluster · {{name}}', + nameLabel: 'Cluster Name *', + descriptionLabel: 'Description', + descriptionPlaceholder: 'Production logging cluster', + accessTypeLabel: 'Access Method', + kubeconfigLabel: 'Kubeconfig *', + kubeconfigLabelEdit: 'Kubeconfig (leave blank to keep unchanged)', + kubeconfigPlaceholder: 'Paste kubeconfig YAML (API Server is detected automatically)...', + apiServerDetected: 'Detected API Server: {{server}}', + apiServerLabel: 'API Server *', + tokenLabel: 'Token *', + tokenLabelEdit: 'Token (leave blank to keep unchanged)', + tokenPlaceholder: 'Paste Service Account Token...', + tokenInsecureHint: + 'Uses insecure mode, for clusters on private networks or with self-signed certificates', + rbacSummary: 'Least-privilege recommendations (click to expand)', + rbacScope: + 'Grant permissions only in the log namespace so a leaked Token cannot affect other namespaces; do not use cluster-admin.', + rbacScriptIntro: + 'Creates the log namespace, ServiceAccount, namespace-scoped Role and a long-lived Token in one shot (run it once in the cluster, then paste the Token into the field below):', + rbacScriptComment1: '# 1) Create in one shot: namespace + SA + namespace-scoped Role + long-lived Token', + rbacScriptComment2: '# 2) Extract the long-lived Token (it does not expire) and paste it into the Token field', + rbacNamespaceNote: + 'The Namespace above is created by an administrator; the platform Token itself has no cluster-wide permissions. Only grant the namespaces: create/get ClusterRole if the platform really must create its own namespaces — note that namespace creation cannot be restricted by name, so pre-creating log is safer.', + rbacTokenNote: + 'The Token is long-lived but bound to a Role in the log namespace only (it can only touch Pods and workloads there), so a leak is limited to log. The server converts it to a kubeconfig and stores it encrypted with ENCRYPTION_KEY; the database keeps only ENC: ciphertext and the API never returns the plaintext. To rotate it, delete and recreate the Secret — the old Token stops working immediately.', + contextLabel: 'Context (optional)', + contextPlaceholder: 'Leave blank to use the default context', + submitAdd: 'Add Cluster', + submitSave: 'Save Changes', + submittingAdd: 'Adding...', + submittingSave: 'Saving...', + validation: { + nameRequired: 'Cluster name is required', + kubeconfigRequired: 'Kubeconfig content is required', + apiServerRequired: 'API Server address is required', + tokenRequired: 'Token is required', + }, + }, + detail: { + loadError: 'Failed to load cluster details', + version: 'Version', + unknown: 'Unknown', + currentContext: 'Current Context', + defaultContext: 'Default', + nodesCount: 'Nodes ({{count}})', + nodeReady: 'Ready', + nodeNotReady: 'Not Ready', + nodeIp: 'IP: {{value}}', + nodeOs: 'OS: {{os}}/{{arch}}', + nodeVersion: 'Version: {{version}}', + namespaces: 'Namespaces', + }, +} diff --git a/frontend/src/i18n/locales/en/collectors.ts b/frontend/src/i18n/locales/en/collectors.ts new file mode 100644 index 0000000..d69654a --- /dev/null +++ b/frontend/src/i18n/locales/en/collectors.ts @@ -0,0 +1,131 @@ +// collectors namespace — "Log Collectors" page domain, English copy. +// Registration: the i18n entry (src/i18n/index.ts) is framework-owned; each page-domain +// dictionary registers itself via addResourceBundle when side-effect imported by its +// pages (zh/en each register their own bundle), coexisting with the static common NS. + +const collectors = { + page: { + title: 'Log Collectors', + description: 'Configure log collection policies for hosts and clusters; logs are pushed to the linked Loki', + addAction: 'Add Collection Policy', + }, + empty: { + title: 'No collection policies yet', + description: 'Create a log collector and link it to a deployed Loki', + }, + typeLabel: { + hostPromtail: 'Host Promtail', + k8sDaemonset: 'Promtail DaemonSet', + k8sFluentbitSidecar: 'Fluent Bit Sidecar', + }, + field: { + loki: 'Linked Loki', + cluster: 'Cluster', + host: 'Host', + namespace: 'Namespace', + logPath: 'Log Path', + target: 'Target', + unknown: 'Unknown', + }, + action: { + install: 'Deploy', + uninstall: 'Uninstall', + editAria: 'Edit collection policy {{name}}', + deleteAria: 'Delete collector {{name}}', + }, + toast: { + loadOptionsFailed: 'Failed to load Loki deployments, clusters, or hosts', + installFailed: 'Failed to deploy collector', + uninstallFailed: 'Failed to uninstall collector', + deleteFailed: 'Failed to delete collector', + createFailed: 'Failed to create collector', + saveFailed: 'Failed to save collection policy', + }, + deleteConfirm: 'Delete "{{name}}"? The collector will be uninstalled automatically. This cannot be undone.', + form: { + title: 'Add Collection Policy', + name: 'Name', + type: 'Collector Type', + typeOption: { + hostPromtail: 'Host Promtail (SSH)', + k8sDaemonset: 'Kubernetes Promtail DaemonSet', + k8sFluentbitSidecar: 'Kubernetes Fluent Bit Sidecar', + }, + lokiDeployment: 'Linked Loki Deployment', + lokiPlaceholder: 'Select a deployed Loki (push target)', + statusParen: ' ({{status}})', + hostParen: ' ({{ip}})', + host: 'Target Host', + hostPlaceholder: 'Select a host', + cluster: 'Target Cluster', + clusterPlaceholder: 'Select a cluster', + namespace: 'Namespace', + nsPickCluster: 'Select a cluster first', + nsLoading: 'Loading namespaces…', + nsPick: 'Select a namespace', + nsEmpty: 'No readable namespaces', + nsLoadFailed: 'Failed to read namespaces', + pickFromCluster: 'Pick from cluster', + manualInput: 'Enter manually', + targetDeployment: 'Target Deployment', + wlPickNamespace: 'Select a namespace first', + wlLoading: 'Loading Deployments…', + wlPick: 'Select a Deployment', + wlEmpty: 'No Deployments in this namespace', + wlLoadFailed: 'Failed to read Deployments', + wlReady: ' ({{ready}}/{{total}} ready)', + logPath: 'Log Path', + logPathHostHint: 'Supports glob wildcards (e.g. /var/log/*.log /var/log/syslog); separate multiple paths with commas', + logPathSidecarHint: + 'Enter the file path your app writes to, e.g. /var/log/app/*.log. Existing log volumes are shared automatically; without one, a shared volume is created only in the dedicated logs directory. Regular paths such as /tmp/test.log are not mounted automatically.', + labels: 'Custom Labels (JSON, optional)', + labelsHint: 'When valid JSON, attached as labels to every log line for filtering in Loki/Grafana; leave empty to skip', + submit: 'Create & Deploy', + submitting: 'Creating...', + validation: { + nameRequired: 'Enter a collector name', + lokiRequired: 'Select the Loki deployment to link', + hostRequired: 'Select the target host', + clusterRequired: 'Select the target cluster', + namespaceRequired: 'Select a namespace', + targetDeploymentRequired: 'Enter the target Deployment name', + }, + }, + edit: { + title: 'Edit Collection Policy', + namespaces: 'Namespaces to Collect', + allNamespaces: 'All namespaces cluster-wide', + selectedCount: '{{count}} selected', + allCheckbox: 'All namespaces (collect cluster-wide)', + nsSearch: 'Search namespaces…', + loading: 'Loading…', + nsListEmpty: 'Namespace list unavailable', + nsNoMatch: 'No matches', + nsLoadFailed: 'Failed to load namespaces', + nsHint: + 'Check "All namespaces" to collect cluster-wide; otherwise only the selected namespaces are collected. Generates the kubernetes_sd_configs.namespaces allowlist for Promtail.', + appLabelSource: 'app label source', + appSourcePlaceholder: 'Empty = default (controller-name fallback chain)', + appSourceHint: + 'By default falls back through app.kubernetes.io/name → app → controller name → pod name. If your cluster has its own convention label (e.g. k8s.kuboard.cn/name), enter it here and it takes the highest priority; the rest still serve as fallback.', + appSourcePreset: { + default: 'Default: app.kubernetes.io/name → app → controller name → pod name', + controllerName: 'controller_name (ReplicaSet name of the Pod, hash stripped = Deployment name)', + k8sAppName: 'app.kubernetes.io/name (Kubernetes standard label)', + app: 'app (common shorthand)', + kuboard: 'k8s.kuboard.cn/name (Kuboard injected)', + }, + appInclude: 'Only collect these apps (optional)', + appIncludePlaceholder: 'Empty = no limit; comma-separated, e.g. cex-spot-api, cex-asset-api', + appIncludeHint: + 'When set, Promtail keeps only logs whose app label matches these values (action: keep) — the most direct way to control log volume. Values must match the app resolved by the "app label source" above.', + logPathHint: 'The Sidecar requires the app container\'s shared log volume or the dedicated logs directory. Click "Deploy" after saving to apply the new configuration.', + save: 'Save', + saving: 'Saving…', + validation: { + nameRequired: 'Enter a policy name', + }, + }, +} + +export default collectors diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts new file mode 100644 index 0000000..435d69d --- /dev/null +++ b/frontend/src/i18n/locales/en/common.ts @@ -0,0 +1,99 @@ +// common namespace — app shell (menu / top bar / login / shared components / status) +export default { + appName: 'LokiStack', + menu: { + dashboard: 'Dashboard', + wizard: 'Deploy Wizard', + clusters: 'Clusters', + hosts: 'Hosts', + storage: 'Storage', + deployments: 'Deploy Loki', + collectors: 'Log Collectors', + logs: 'Log Query', + alerts: 'Alerts', + settings: 'Settings', + users: 'Users', + login: 'Sign In', + notFound: 'Page Not Found', + }, + layout: { + nav: 'Main navigation', + sheetTitle: 'LokiStack Navigation', + collapse: 'Collapse menu', + expand: 'Expand menu', + openNav: 'Open navigation menu', + logout: 'Sign out', + logoutShort: 'Sign out', + toLight: 'Switch to light theme', + toDark: 'Switch to dark theme', + admin: 'Admin', + loading: 'Loading page', + }, + login: { + subtitle: 'Loki Log Management Platform', + heading: 'Sign In', + username: 'Username', + usernamePlaceholder: 'Enter username', + password: 'Password', + passwordPlaceholder: 'Enter password', + remember: 'Remember username', + submit: 'Sign In', + submitting: 'Signing in...', + errCredential: 'Invalid username or password', + errConn: 'Cannot reach the backend. Make sure it is running on port 8080.', + errFallback: 'Sign-in failed', + backendDown: 'Backend is not running', + backendDownHint: 'Cannot reach the API service. Start the backend first:', + }, + dialog: { + confirmTitle: 'Confirm Deletion', + confirmText: 'Delete', + cancel: 'Cancel', + processing: 'Processing...', + confirmIrreversible: 'Are you sure? This cannot be undone.', + confirmDefault: 'Delete "{{name}}"? This cannot be undone.', + }, + error: { + title: 'Failed to load', + hint: 'Check the network or backend service, then retry', + retry: 'Retry', + }, + pagination: { + total: '{{count}} items', + summary: '{{count}} items · Page {{page}} of {{pages}}', + }, + status: { + active: 'Running', + running: 'Running', + inactive: 'Stopped', + stopped: 'Stopped', + pending: 'Pending', + deploying: 'Deploying', + failed: 'Failed', + error: 'Error', + }, + notFound: { + heading: 'Page Not Found', + hint: 'The page does not exist or has been removed', + backHome: 'Back to Home', + }, + lang: { + section: 'Language', + zh: '中文', + en: 'English', + }, + boundary: { + title: 'Rendering Error', + hint: 'Something went wrong. Try refreshing; if it persists, report the details below.', + unknown: 'Unknown error', + reload: 'Reload Page', + reset: 'Reset Component', + }, + spinner: { + default: 'Loading...', + }, + password: { + hide: 'Hide password', + show: 'Show password', + }, +} diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts new file mode 100644 index 0000000..7eb3a71 --- /dev/null +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -0,0 +1,28 @@ +// dashboard namespace — Dashboard domain (stat cards / quick start / recent deployments / resource lists) +export default { + stat: { + totalDeployments: 'Deployments', + runningCount: '{{count}} running', + clusters: 'Clusters', + connected: 'Connected', + hosts: 'Hosts', + sshReady: 'SSH ready', + status: 'Status', + }, + quickStart: { + title: 'Start collecting logs in three steps', + subtitle: 'Target & environment → Storage → Deploy Loki and Grafana, then add a collection strategy', + cta: 'Open Deploy Wizard', + }, + recent: { + title: 'Recent Deployments', + viewAll: 'View all', + emptyTitle: 'No deployments yet', + emptyDesc: 'Create your first Loki deployment', + }, + list: { + manage: 'Manage', + empty: 'No {{title}} yet', + }, + loadFailed: 'Failed to load dashboard data', +} diff --git a/frontend/src/i18n/locales/en/deployments.ts b/frontend/src/i18n/locales/en/deployments.ts new file mode 100644 index 0000000..d46cffc --- /dev/null +++ b/frontend/src/i18n/locales/en/deployments.ts @@ -0,0 +1,124 @@ +// deployments namespace — Loki deployment page copy (page / card / form / retention / logs dialogs) +export default { + page: { + title: 'Deploy Loki', + description: 'Deploy and manage Grafana Loki log aggregation services', + create: 'New Deployment', + emptyTitle: 'No deployments', + emptyDescription: 'Create your first Loki deployment', + // delete confirmation: host-docker removes the container too; other modes only remove the record + deleteDockerDesc: + 'Delete "{{name}}"? The Loki container on the host will be removed as well. This cannot be undone.', + deleteRecordDesc: + 'Delete "{{name}}"? Deleting the record does not uninstall the Loki resources already deployed on the cluster/host. This cannot be undone.', + errLoadRefs: 'Failed to load cluster/host/storage data', + errDeploy: 'Deployment failed', + errStop: 'Failed to stop', + errRestart: 'Failed to restart', + errDelete: 'Failed to delete the deployment', + }, + card: { + mode: 'Mode', + modeK8s: 'Kubernetes', + modeDocker: 'Host Docker', + version: 'Version', + target: 'Target', + targetCluster: 'Cluster: {{name}}', + targetHost: 'Host: {{name}}', + targetUnknown: 'Unknown', + namespace: 'Namespace', + replicas: 'Replicas', + replicasValue: '{{replicas}} · Retain {{days}}d', + retention: 'Retention', + retentionValue: '{{days}}d', + storage: 'Storage', + localStorage: 'Local', + ready: 'Loki ready', + notReady: 'Loki not ready', + runningHint: + 'Tip: link Loki under "Log Query → Data Sources" to start querying; for cross-cluster/public access, configure an Ingress domain for Loki first.', + configure: 'Configure', + deploy: 'Deploy', + deploying: 'Deploying...', + stop: 'Stop', + restart: 'Restart', + logs: 'Logs', + deleteAria: 'Delete deployment {{name}}', + }, + form: { + title: 'Create Loki Deployment', + name: 'Deployment Name *', + description: 'Description', + descriptionPlaceholder: 'Loki log service for production', + version: 'Version', + mode: 'Deploy Mode', + modeK8s: 'Kubernetes', + modeDocker: 'Host Docker', + cluster: 'Target Cluster *', + clusterPlaceholder: 'Select a cluster', + namespace: 'Namespace *', + host: 'Target Host *', + hostPlaceholder: 'Select a host', + listenIP: 'Listen IP', + listenIPPlaceholder: 'Defaults to the host IP once a host is selected', + listenPort: 'Access Port', + dockerHint: 'Starts a Docker container on the host via SSH; config and data stay on the host.', + storage: 'Storage Config', + storagePlaceholder: 'Select storage (optional)', + localStorage: 'Local storage (default)', + replicas: 'Replicas', + retentionDays: 'Retention (days)', + storageSize: 'Storage Size', + memory: 'Memory', + authEnabled: 'Enable multi-tenant authentication', + create: 'Create Deployment', + creating: 'Creating...', + errCreate: 'Failed to create the deployment', + // zod validation messages (t injected by the schema factory) + nameRequired: 'Enter a deployment name', + versionRequired: 'Select a Loki version', + retentionInvalid: 'Retention days must be an integer no less than 1', + replicasInvalid: 'Replicas must be an integer between 1 and 10', + cpuRequired: 'Enter the CPU quota (e.g. 500m)', + memRequired: 'Enter the memory quota (e.g. 512Mi)', + storageSizeRequired: 'Enter the storage size (e.g. 20Gi)', + clusterRequired: 'Select a target cluster', + hostRequired: 'Select a target host', + portInvalid: 'Port must be between 1 and 65535', + namespaceRequired: 'Enter the namespace', + storageSizeMin: 'Alibaba Cloud ESSD requires at least 20Gi — enter 20Gi or more', + }, + retention: { + title: 'Edit Loki Config · {{name}}', + locationLabel: 'Config location: ', + locationHostBinary: '/etc/loki/loki.yaml → limits_config.retention_period', + locationHostDocker: 'Host /etc/lokistack/loki-{{id}}/loki.yaml → limits_config.retention_period', + locationK8s: 'ConfigMap {{namespace}}/loki-config → loki.yaml', + compactorNote: 'Retention is enforced by the Loki compactor; MinIO only stores the log objects.', + liveTitle: 'Full live loki.yaml', + liveHint: + 'Read from the deployment target with storage credentials masked; retention days below are edited separately.', + view: 'View Full Config', + refresh: 'Refresh', + loading: 'Loading…', + templateNote: 'Generated by the deployment template', + yamlLabel: 'Retention Config YAML', + yamlHint: 'Edit limits_config.retention_period, e.g. 90d or 2160h. Only this setting is accepted here.', + applyHintRunning: + 'Save only updates the deployment record; Save & Apply redeploys Loki and queries may be briefly interrupted.', + applyHintIdle: 'Takes effect on the next deployment.', + cancel: 'Cancel', + saveOnly: 'Save Only', + saveApply: 'Save & Apply', + processing: 'Processing…', + savedApplied: 'Saved {{days}} days — Loki is being redeployed', + yamlRequired: 'Enter the YAML config', + errLoad: 'Failed to read the live config', + errSave: 'Failed to save the Loki config', + }, + logs: { + title: 'Deployment Logs - {{name}}', + empty: 'No logs yet...', + errLoad: 'Failed to load deployment logs', + }, +} diff --git a/frontend/src/i18n/locales/en/hosts.ts b/frontend/src/i18n/locales/en/hosts.ts new file mode 100644 index 0000000..41d231c --- /dev/null +++ b/frontend/src/i18n/locales/en/hosts.ts @@ -0,0 +1,81 @@ +// hosts namespace — host management page (English) +export default { + title: 'Hosts', + description: 'Manage SSH hosts for binary-based Loki deployment', + actions: { + add: 'Add Host', + detail: 'Details', + editAria: 'Edit host {{name}}', + validateAria: 'Validate host {{name}}', + deleteAria: 'Delete host {{name}}', + }, + fields: { + address: 'Address', + user: 'User', + auth: 'Auth', + authPassword: 'Password', + authKey: 'SSH Key', + os: 'OS', + unknown: 'Unknown', + }, + empty: { + title: 'No hosts yet', + description: 'Add your first SSH host to get started', + }, + delete: { + warning: + 'Delete "{{name}}"? Warning: {{dep}} deployments and {{col}} collector policies still reference this host; they will stop working once it is deleted.', + }, + toast: { + deleteFailed: 'Failed to delete host', + validateFailed: 'Validation failed', + createFailed: 'Failed to add host', + }, + form: { + addTitle: 'Add SSH Host', + editTitle: 'Edit Host', + editTitleWithName: 'Edit Host · {{name}}', + nameLabel: 'Name *', + hostIpLabel: 'Host IP *', + portLabel: 'Port', + userLabel: 'Username *', + authTypeLabel: 'Authentication', + passwordLabel: 'Password *', + passwordLabelEdit: 'Password (leave blank to keep unchanged)', + passwordPlaceholder: 'SSH login password', + privateKeyLabel: 'Private Key *', + privateKeyLabelEdit: 'Private Key (leave blank to keep unchanged)', + keyPhraseLabel: 'Passphrase (optional)', + keyPhrasePlaceholder: 'Leave blank if no passphrase', + labelsLabel: 'Labels (JSON)', + submitAdd: 'Add Host', + submitSave: 'Save Changes', + submittingAdd: 'Adding...', + submittingSave: 'Saving...', + validation: { + nameRequired: 'Host name is required', + hostIpRequired: 'Host IP is required', + portRequired: 'Port is required', + portNumeric: 'Port must be a number', + portRange: 'Port must be between 1 and 65535', + userRequired: 'Username is required', + passwordRequired: 'SSH password is required', + privateKeyRequired: 'Paste the SSH private key content', + }, + }, + detail: { + loadError: 'Failed to load host details', + usage: 'Resource Usage', + memory: 'Memory', + disk: 'Disk', + usageDetail: '({{detail}})', + basicInfo: 'Basic Info', + hostname: 'Hostname', + os: 'OS', + arch: 'Architecture', + kernel: 'Kernel', + cpuCores: 'CPU Cores', + uptime: 'Uptime', + unknown: 'Unknown', + }, +} diff --git a/frontend/src/i18n/locales/en/logs.ts b/frontend/src/i18n/locales/en/logs.ts new file mode 100644 index 0000000..76dd98f --- /dev/null +++ b/frontend/src/i18n/locales/en/logs.ts @@ -0,0 +1,325 @@ +// logs namespace — Log Query page (/logs) English copy +// Terms left untranslated: LogQL, Live Tail (Tail), Loki, label, stream, Ingress, Pod, Namespace. +export default { + nav: { + aria: 'Log center navigation', + }, + tabs: { + query: 'Loki Query', + access: 'Query Access', + sources: 'Data Sources', + descQuery: 'Search by log stream and time range; drag the timeline to narrow the range, and click a log line to view its context.', + descAccess: 'Reuse log scopes with policy groups and attach multiple users at once.', + descSources: 'Configure Loki connections and authentication.', + }, + toolbar: { + run: 'Run', + running: 'Running…', + tail: 'Tail', + tailStop: 'Stop Tail', + sourceAria: 'Data source', + sourceEmptyOption: 'No data sources available', + limitLabel: 'Per page', + limitHint: 'Up to 1000 entries per page; paging keeps only the current page. For wide ranges, check the timeline first, then drag to select a peak window.', + rangeInvalid: 'Select a valid start and end time', + queryFailed: 'Query failed', + }, + source: { + loadFailed: 'Failed to load data sources', + missingTitle: 'No Loki data source available', + missingBody: + 'Link a Loki data source first under "Data Sources": for in-cluster Loki, pick "Cluster Service" (Namespace / Service / port, no Ingress needed); for cross-cluster or public queries, expose Loki via an Ingress domain first, then add it with "Direct URL". Queries are unavailable until a data source is linked.', + goConfigure: 'Go to data sources', + sectionAria: 'Data source management', + heading: 'Loki Data Sources', + hint: 'Tip: when Loki runs in the cluster, add a "Cluster Service" data source directly (no Ingress needed); for cross-cluster / public queries, expose Loki via an Ingress domain first, then link it with "Direct URL". Without a linked data source, the query page cannot be used.', + managedCluster: 'Cluster {{name}}', + managedBadge: 'Platform deployed · managed with the deployment', + add: '+ Add manually', + nameLabel: 'Name', + targetUrl: 'Direct URL', + targetClusterSvc: 'Cluster Service', + addressLabel: 'Loki URL', + clusterLabel: 'Cluster', + clusterPlaceholder: 'Select a cluster…', + namespaceLabel: 'Namespace', + serviceLabel: 'Service', + portLabel: 'Port', + authLabel: 'Authentication', + authNone: 'None', + authBasic: 'Basic Auth', + authBearer: 'Bearer Token', + authHeaders: 'Custom Headers', + usernameLabel: 'Username', + passwordLabel: 'Password', + tokenLabel: 'Token', + headersLabel: 'Custom Headers (JSON object)', + headersInvalid: 'Headers must be a JSON object with string values', + skipTLS: 'Skip TLS verification', + setDefault: 'Set as default', + test: 'Test connection', + save: 'Save data source', + saveFailed: 'Failed to save', + remove: 'Delete', + }, + range: { + lastMinutes: 'Last {{count}} minutes', + lastHours: 'Last {{count}} hours', + lastHours_one: 'Last {{count}} hour', + custom: 'Custom time', + title: 'Select time range', + start: 'Start time', + end: 'End time', + apply: 'Apply', + recent: 'Recently used', + }, + editor: { + aria: 'LogQL query', + valuesOf: 'Values of {{label}}', + quickTab: 'Quick LogQL', + historyTab: 'History', + historyTabCount: 'History ({{count}})', + escClose: 'Esc to close', + historyEmpty: 'Frequently used LogQL will appear here after you run a query', + }, + templates: { + aria: 'Quick query templates', + title: 'Quick LogQL builder', + hint: 'Pick a namespace, then an app, then a pod; app candidates come only from the selected namespace.', + namespace: 'Namespace', + app: 'App', + appWithKey: 'App ({{key}})', + pod: 'Pod', + selectCount: 'Select ({{count}} total)', + noApp: 'No app in this scope', + pickNsFirst: 'Pick a namespace first to narrow down', + noPod: 'No pods under this app', + optional: 'Optional', + scopeLogs: 'Query this scope', + errorLogs: 'Errors (error)', + warnLogs: 'Warnings (warn)', + podLogs: 'Specific pod logs', + ingressLogs: 'Ingress access logs', + scopeHint: 'Pick a namespace, then an app or pod — or choose values in the Label browser', + }, + labels: { + browser: 'Label Browser', + selectedCount: '{{count}} selected', + loadingLabels: 'Loading labels…', + noLabels: 'No labels available', + pickHint: 'Select a label on the left, then search its values', + valuesOf: 'Values of {{label}}', + searchAria: 'Search values of {{label}}', + searchPlaceholder: 'Search or type a label value', + scoped: 'Narrow candidates by current query', + loading: 'Loading…', + noValues: 'No matching values', + useAsValue: 'Use "{{value}}" as value', + hint: 'Select namespace first to query all replicas in that Namespace; then app / service_name to narrow to a service (workloads without an app label can rely on namespace alone). Candidates come from logs written to Loki within the current time range; the authorized scope is still enforced server-side.', + }, + volume: { + statsLoading: 'Counting logs across the whole time range; you can keep browsing the log page.', + statsError: 'Log volume statistics timed out or failed; the log page still works. Narrow the time range and query again.', + histogramLoading: 'Counting…', + histogramEmpty: 'No log volume data', + timelineAria: 'Log volume timeline', + timelineTitle: 'Log volume timeline', + timelineHint: 'Hover to see counts; press and drag at any height to select a time span; the query runs automatically after selection.', + timelineTotal: '{{count}} logs in range', + timelineBucket: '{{count}} logs', + timelineBucket_one: '{{count}} log', + }, + results: { + aria: 'Log results', + title: 'Log Results', + summary: 'Page {{page}} · {{count}} entries', + summary_one: 'Page {{page}} · {{count}} entry', + summaryMore: 'Page {{page}} · {{count}} entries · more available', + summaryMore_one: 'Page {{page}} · {{count}} entry · more available', + viewAria: 'Log display mode', + viewRaw: 'Raw', + viewTable: 'Table', + viewLine: 'Line', + viewStream: 'Stream', + viewPie: 'Pie', + loadingPage: 'Loading log page', + loadingQuery: 'Querying Loki', + loadingWait: 'Waiting for {{seconds}}s. Wide-range queries may take a while.', + pagingHint: 'Logs are paged newest to oldest; charts show the current page, and the top timeline covers the whole query range.', + domainFilter: 'Domain filter', + domainPlaceholder: 'Type or pick a domain', + domainQuery: 'Query in Loki', + domainHint: 'Matches only the request Host recorded in log lines. Showing {{shown}} / {{total}}.', + domainNoMatch: 'No logs match the current domain filter', + filterLabel: 'Filter by this label', + showContext: 'Show 5 lines before and after', + prevPage: 'Previous', + nextPage: 'Next', + pageOf: 'Page {{page}}', + noSourceTitle: 'No queryable data source', + noSourceHint: 'Ask an administrator to add or authorize a data source', + noMatchTitle: 'No matching log entries', + noMatchHint: 'No results under the current time range and filters', + idleTitle: 'Enter a LogQL query and run it', + idleHint: 'You can also build queries quickly in the Label browser', + retry: 'Query again', + }, + views: { + noDomain: 'No domain recorded', + otherDomain: 'Other domains', + unknown: 'Unknown', + untagged: 'Untagged', + other: 'Other', + tableLoaded: '{{count}} log entries loaded on this page.', + tableEmpty: 'No logs to display', + thTime: 'Time', + thNsPod: 'namespace / pod', + thLevel: 'Level', + thLog: 'Log', + lineByLoaded: 'Based on currently loaded logs; load more for additional results.', + lineByRange: 'Log volume aggregated over the query time range, from Loki.', + lineGroupAria: 'Line chart grouping', + lineByDomain: 'By domain', + lineByTotal: 'Total', + lineEmpty: 'No time-series data for this query', + lineSeriesTotal: 'Log count', + streamParsed: 'Showing {{count}} parsed entries', + streamByLevel: 'Showing {{count}} loaded entries by level', + streamSuffix: ', based on currently loaded results.', + streamGroupAria: 'Stream chart grouping', + streamByStatus: 'By HTTP status class', + streamEmpty: 'No log streams to display', + pieByAccess: 'Grouping {{count}} parsed entries by access fields', + pieByLabel: 'Grouping {{count}} loaded entries by label', + pieGroupAria: 'Pie chart grouping', + pieDomain: 'Domain (request Host)', + pieStatus: 'HTTP status', + pieMethod: 'Method', + piePath: 'Path', + pieUpstream: 'Upstream service', + pieLevel: 'Level', + pieEmpty: 'No label distribution to display', + }, + ingress: { + modesAria: 'Ingress statistics mode', + modeTraffic: 'Endpoints PV / UV', + modeErrors: 'Top 10 errors', + modeSlow: 'Top 10 slow requests', + modeDetail: 'Request details', + summary: + 'Based on the {{loaded}} entries loaded on this page, of which {{parsed}} are ingress-nginx access logs. Statistics are recomputed after paging; UV is deduplicated by non-empty client IP.', + statPv: 'PV', + statUv: 'UV (IP)', + statErrors: '4xx / 5xx', + statSlow: 'Slow ≥ {{threshold}}s', + errorClassAria: 'Error status range', + errorAll: 'All 4xx / 5xx', + error5xx: '5xx only', + error4xx: '4xx only', + slowThreshold: 'Slow request threshold', + seconds: '{{count}}s', + slowHint: 'Sorted by request_time from the access log; 408 / 499 / 504 are only timeout hints and do not by themselves prove a backend timeout.', + slowMissing: '{{count}} entries lack a valid duration.', + trafficHint: 'Success rate = 2xx / 3xx requests ÷ PV; query strings are stripped from paths.', + thHost: 'Request Host', + thPath: 'Path', + thSuccess: 'Success rate', + thErrorRate: 'Error rate', + thStatus: 'Status codes', + thSlow: 'Slow', + thMaxLatency: 'Max latency', + thAvgLatency: 'Avg latency', + thTimeout: '408 / 499 / 504', + thClientIp: 'Client IP', + thStatusShort: 'Status', + thLatency: 'Latency', + thTime: 'Time', + empty: 'No matching requests on this page', + noDomain: 'No Host recorded', + }, + context: { + title: 'Context of the same log stream', + sameFile: 'Same file', + sameStream: 'Same log stream', + meta: 'Up to 5 entries before and after · ±5 minutes', + file: 'File: {{name}}', + current: 'Current', + currentAria: 'Current log', + beforeAria: 'Logs before', + afterAria: 'Logs after', + beforeTitle: 'Before · {{count}} entries', + beforeTitle_one: 'Before · {{count}} entry', + afterTitle: 'After · {{count}} entries', + afterTitle_one: 'After · {{count}} entry', + beforeEmpty: 'No logs in the previous 5 minutes', + afterEmpty: 'No logs in the following 5 minutes', + loadFailed: 'Failed to load context', + }, + access: { + loadFailed: 'Failed to load query policies', + validate: 'Fill in the policy group name, choose a data source, and select "Anyone" or at least one user', + saveFailed: 'Failed to save policy group', + deleteFailed: 'Failed to delete policy group', + sourceRef: 'Data source #{{id}}', + listTitle: 'Query policy groups', + create: 'New', + listHint: 'One log scope can be shared by multiple users; later changes apply to all of them.', + loading: 'Loading…', + empty: 'No policy groups yet', + anyone: 'Anyone', + userCount: '{{count}} users', + userCount_one: '{{count}} user', + editTitle: 'Edit policy group', + createTitle: 'New policy group', + formHint: 'Choose a data source and the allowed log scope, then attach the users who reuse this policy.', + remove: 'Delete', + nameLabel: 'Policy group name', + namePlaceholder: 'e.g. Production agent logs', + sourceLabel: 'Data source', + sourceEmpty: 'Add a data source first', + scopeTitle: 'Allowed log scope', + ownerFallback: 'Policy group', + scopeHint: 'Restrict precisely by Namespace, App, or Pod. Unselected labels are not restricted.', + usersTitle: 'Users', + selectedAnyone: 'Selected: Anyone (anyone)', + selectedCount: 'Selected {{count}}', + anyoneToggle: 'Anyone (anyone)', + anyoneActive: '✓ Anyone (anyone)', + selectFiltered: 'Select filtered', + clear: 'Clear', + usersHint: 'A user can belong to only one policy group per data source; users already in another group are grayed out.', + searchAria: 'Search users', + searchPlaceholder: 'Search by username or nickname', + grouped: 'In a group', + noMatch: 'No matching users', + saving: 'Saving…', + save: 'Save policy group', + deleteTitle: 'Delete query policy group', + deleteDesc: 'After deleting "{{name}}", attached users will lose the data source access granted by this group.', + ruleAria: 'Query scope of {{name}}', + nsLabel: 'Namespace', + appLabel: 'App', + podLabel: 'Pod', + }, + select: { + placeholder: 'Search and select', + any: 'Any', + loading: 'Loading…', + noMatch: 'No matches', + }, + raw: { + searching: 'Querying…', + count: '{{count}} logs', + count_one: '{{count}} log', + dedupSuffix: ' (deduplicated from {{count}})', + dedup: 'Dedup', + empty: 'No matching logs', + original: 'Raw log', + labelsTitle: 'Labels (click to filter)', + filterValue: 'Filter this value', + excludeValue: 'Exclude this value', + fields: 'Extracted fields', + loadMore: 'Load more', + loadingMore: 'Loading…', + }, +} diff --git a/frontend/src/i18n/locales/en/settings.ts b/frontend/src/i18n/locales/en/settings.ts new file mode 100644 index 0000000..f389e2d --- /dev/null +++ b/frontend/src/i18n/locales/en/settings.ts @@ -0,0 +1,141 @@ +// settings namespace — Settings page (general/password/about, notifications, LDAP, image registry), English copy. +// Note: theme & language card titles and ThemeSection copy are owned elsewhere and are not in this dictionary. +export default { + title: 'Settings', + description: 'General settings, alert notifications, LDAP integration, and the default image registry', + tabs: { + general: 'General', + notify: 'Alert Notifications', + ldap: 'LDAP Integration', + registry: 'Default Image Registry', + }, + // Action labels shared across sections + actions: { + cancel: 'Cancel', + saving: 'Saving...', + testing: 'Testing...', + }, + password: { + title: 'Change Password', + oldLabel: 'Current password', + oldPlaceholder: 'Enter current password', + newLabel: 'New password', + newPlaceholder: 'Enter new password (at least 6 characters)', + confirmLabel: 'Confirm new password', + confirmPlaceholder: 'Re-enter new password', + submit: 'Change password', + submitting: 'Changing...', + errMismatch: 'Passwords do not match', + errTooShort: 'New password must be at least 6 characters', + errFailed: 'Failed to change password', + }, + about: { + title: 'About LokiStack', + versionLabel: 'Version', + backendLabel: 'Backend', + frontendLabel: 'Frontend', + k8sLabel: 'Kubernetes Access', + summaryLabel: 'Overview', + backendValue: 'Go + Gin + JSON file storage', + frontendValue: 'React 19 + TypeScript + Tailwind CSS', + k8sValue: 'client-go, direct API Server access', + summaryValue: 'Lightweight Loki log management platform', + }, + notify: { + cardTitle: 'Alert Channels — Feishu (Lark)', + create: 'New channel', + // Empty state is assembled as "prefix + link + suffix" (link points to the Alerts page) + emptyBefore: 'No channels yet — create one per alert group (e.g. "App alerts", "Nginx alerts"), then link rules to it on the ', + emptyLink: 'Alerts', + emptyAfter: ' page', + webhookConfigured: 'Webhook configured', + webhookMissing: 'Not configured', + secretConfigured: ' · Signing secret configured', + testAria: 'Test {{name}}', + editAria: 'Edit {{name}}', + deleteAria: 'Delete {{name}}', + editTitle: 'Edit Channel · {{name}}', + createTitle: 'New Notification Channel', + nameLabel: 'Channel name *', + namePlaceholder: 'e.g. App alerts / Nginx alerts', + webhookLabel: 'Webhook URL', + webhookKeepHint: ' (leave blank to keep current)', + webhookSavedPlaceholder: 'Saved — enter a new URL to replace it', + signingLabel: 'Signing secret (optional)', + signingSavedPlaceholder: 'Saved — leave blank to keep current', + signingPlaceholder: 'Feishu (Lark) bot signing secret', + enable: 'Enable channel', + saveChanges: 'Save changes', + createChannel: 'Create channel', + deleteTitle: 'Delete Notification Channel', + testSent: 'Test card sent to "{{name}}"', + errLoad: 'Failed to load notification channels', + errNameRequired: 'Channel name is required', + errWebhookRequired: 'A Webhook URL is required for a new channel', + errSave: 'Save failed', + errSend: 'Send failed', + errToggle: 'Failed to toggle', + errDelete: 'Delete failed', + }, + ldap: { + cardTitle: 'LDAP Integration', + headerHint: 'Enables LDAP sign-in and bulk user import', + errLoad: 'Failed to load LDAP configuration', + disabledHint: + 'LDAP is disabled — turn it on and fill in the directory details (server / Bind DN / Base DN). After saving, "Import from LDAP" appears on the Users page.', + hostLabel: 'Server address *', + portLabel: 'Port', + ldapsLabel: 'LDAPS', + bindDnLabel: 'Bind DN *', + bindPasswordLabel: 'Bind password *', + bindPasswordSavedPlaceholder: 'Saved — enter a new password to replace it', + bindPasswordPlaceholder: 'Administrator password', + baseDnLabel: 'Base DN *', + userFilterLabel: 'User filter', + usernameLabel: 'Username', + nameLabel: 'Full name', + emailLabel: 'Email', + noEmail: 'No email', + testButton: 'Test connection', + saveButton: 'Save configuration', + errMissingFields: 'Fill in the server address / Bind DN / Base DN first', + errTest: 'Test failed', + savedToast: 'LDAP configuration saved', + }, + registry: { + cardTitle: 'Image Registry', + modeOverseasLabel: 'Official (overseas)', + modeOverseasDesc: 'docker.io, for overseas networks', + modeCnLabel: 'China mirror', + modeCnDesc: 'docker.m.daocloud.io mirror, for networks in China', + modeCustomLabel: 'Private registry', + modeCustomDesc: 'Your own registry prefix (e.g. Huawei Cloud SWR)', + errLoad: 'Failed to load image registry configuration', + savedToast: 'Image registry saved. Future deployments and collectors will use the new image addresses.', + customPrefixLabel: 'Private registry prefix *', + customPrefixHint: 'The platform prepends this prefix to image names, e.g. {{example}}/grafana/loki:3.4.0', + imagesLabel: 'Per-service images (repository / tag can be overridden)', + tagFollowVersion: 'Follows deployment version', + repoAria: '{{label}} image repository', + tagAria: '{{label}} image tag', + // The images hint is assembled as "prefix + grafana/loki + suffix" + imagesHintBefore: 'Repository is the image path (e.g. ', + imagesHintAfter: '); when Tag is empty, Loki / Grafana follow the deployment version and Promtail / Fluent Bit use built-in defaults.', + previewTitle: 'Effective image preview', + saveButton: 'Save image registry', + }, + themeCard: { + title: 'Theme', + light: 'Light', + dark: 'Dark', + system: 'Follow System', + auto: 'Scheduled', + timezone: 'Timezone', + timezoneAria: 'Theme schedule timezone', + browser: 'Follow browser (local)', + autoHint: 'Light from {{start}}:00 to {{end}}:00 in the selected timezone, dark otherwise; the sun/moon toggle overrides anytime.', + }, + prefsCard: { + title: 'Appearance & Language', + }, +} diff --git a/frontend/src/i18n/locales/en/storage.ts b/frontend/src/i18n/locales/en/storage.ts new file mode 100644 index 0000000..b871b65 --- /dev/null +++ b/frontend/src/i18n/locales/en/storage.ts @@ -0,0 +1,55 @@ +// storage namespace — storage page copy (page shell / card / form dialog) +export default { + page: { + title: 'Storage', + description: 'Configure object storage backends for Loki', + add: 'Add Storage', + emptyTitle: 'No storage configs', + emptyDescription: 'Add your first Loki storage backend', + // delete confirmation shown while deployments still reference the config + deleteWarning_one: + 'Delete "{{name}}"? Warning: {{count}} deployment still uses this storage; its object storage config will stop working.', + deleteWarning_other: + 'Delete "{{name}}"? Warning: {{count}} deployments still use this storage; their object storage configs will stop working.', + errDelete: 'Failed to delete the storage config', + errSetDefault: 'Failed to set the default storage', + }, + card: { + default: 'Default', + endpoint: 'Endpoint', + pathPrefix: 'Path prefix', + none: 'None', + connection: 'Connection', + insecureHttp: 'HTTP (insecure)', + setDefault: 'Set as default', + editAria: 'Edit storage config {{name}}', + deleteAria: 'Delete storage config {{name}}', + }, + form: { + addTitle: 'Add Storage Config', + editTitle: 'Edit Storage Config', + name: 'Name *', + storageType: 'Storage Type', + endpoint: 'Endpoint', + bucket: 'Bucket *', + region: 'Region', + secretPlaceholder: 'Enter the Secret Key', + secretPlaceholderSaved: 'Saved · leave empty to keep', + insecure: 'Insecure (HTTP)', + setDefault: 'Set as default', + autoCreate: 'Auto-create the bucket during testing if missing', + test: 'Test Connection', + testing: 'Testing...', + add: 'Add Storage', + adding: 'Adding...', + save: 'Save Changes', + saving: 'Saving...', + testHintSaved: 'Fill in Bucket and Access Key first (Secret Key is saved — leave it empty)', + testHintMissing: 'Fill in Bucket and Access Key / Secret Key first', + errSubmit: 'Failed to create the storage config', + errTest: 'Connection test failed', + // zod validation messages (t injected by the schema factory) + nameRequired: 'Enter a storage config name', + bucketRequired: 'Enter the bucket name', + }, +} diff --git a/frontend/src/i18n/locales/en/users.ts b/frontend/src/i18n/locales/en/users.ts new file mode 100644 index 0000000..418308b --- /dev/null +++ b/frontend/src/i18n/locales/en/users.ts @@ -0,0 +1,87 @@ +// users namespace — User management domain (header / role & source badges / create & reset password / LDAP import) +export default { + title: 'User Management', + subtitle: 'Manage platform sign-in accounts (admin only)', + create: 'Create User', + ldapImport: 'Import from LDAP', + emptyTitle: 'No users yet', + emptyDesc: 'Create the first user account', + listTitle: 'Users ({{count}} total)', + currentUser: '(you)', + noNickname: 'No nickname set', + createdAt: 'Created {{date}}', + resetPassword: 'Reset Password', + resetDesc: + 'After resetting the password for "{{name}}", all of their sign-in sessions will be invalidated immediately.', + newPassword: 'New password *', + randomPassword: 'Generate random password', + resetting: 'Resetting...', + resetDone: 'Password for "{{name}}" has been reset; all of their sessions are now invalid', + resetFailed: 'Failed to reset password', + deleteAria: 'Delete user {{name}}', + deleteFailed: 'Failed to delete user', + cannotDeleteSelf: 'You cannot delete the account you are currently signed in with', + confirmDeleteTitle: 'Confirm user deletion', + createFailed: 'Failed to create user', + adminOnly: { + title: 'Admin access required', + desc: 'User management is available to admins only. Contact an administrator for account matters.', + }, + role: { + admin: 'Admin', + user: 'User', + }, + source: { + ldap: 'LDAP import', + local: 'Local account', + }, + form: { + username: 'Username *', + usernamePlaceholder: 'Sign-in username', + usernameMin: 'Username must be at least 2 characters', + usernameMax: 'Username must be at most 32 characters', + nickname: 'Nickname', + nicknamePlaceholder: 'Display nickname (optional)', + password: 'Initial password *', + passwordPlaceholder: 'At least 6 characters', + passwordMin: 'Password must be at least 6 characters', + role: 'Role *', + rolePlaceholder: 'Select a role', + submitting: 'Creating...', + }, + ldap: { + title: 'Import users from LDAP', + desc: 'Users from the LDAP directory. Imported users sign in with their LDAP password and are assigned the User role.', + added: '{{count}} added', + converted: '{{count}} converted to LDAP', + skipped: '{{count}} skipped', + failedCount: '{{count}} failed', + continue: 'Import more', + done: 'Done', + loading: 'Loading LDAP users...', + summary: '{{total}} users · {{selected}} selected', + errorHintPre: 'Go to', + errorHintPost: 'to check the LDAP configuration and status', + imported: 'Imported', + willConvert: 'Exists locally, will convert to LDAP', + noName: 'No name', + noEmail: 'No email', + empty: 'No users in this directory', + importing: 'Importing...', + importCount_one: 'Import {{count}} user', + importCount_other: 'Import {{count}} users', + importFailed: 'Import failed', + loadFailed: 'Failed to load LDAP users', + }, + selectAria: 'Select user {{name}}', + batch: { + selected: '{{count}} selected', + delete: 'Delete Selected', + clear: 'Clear selection', + selectAll: 'Select all', + confirmTitle: 'Confirm Bulk Deletion', + confirmDesc: 'Delete {{count}} selected users? Protected accounts (your own / the last admin) will be skipped. This cannot be undone.', + skippedWarn: '{{count}} skipped: {{reasons}}', + failed: 'Bulk delete failed', + }, +} diff --git a/frontend/src/i18n/locales/en/wizard.ts b/frontend/src/i18n/locales/en/wizard.ts new file mode 100644 index 0000000..8f41a2f --- /dev/null +++ b/frontend/src/i18n/locales/en/wizard.ts @@ -0,0 +1,69 @@ +// wizard namespace — "Deploy Wizard" page domain, English copy. +// Registration: same as the collectors namespace — each page-domain dictionary registers +// itself via addResourceBundle when side-effect imported (no changes to src/i18n/index.ts). + +const wizard = { + page: { + title: 'Deploy Wizard', + description: 'Set up the logging platform in four steps: environment → storage → deploy → collect (steps can be completed in any order)', + loadFailed: 'Failed to load wizard status', + }, + steps: { + flow: 'Deployment flow', + doneAria: '{{title}} completed', + todoAria: '{{title}} pending', + env: { title: 'Environment & Target', desc: 'Kubernetes cluster or bare-metal host' }, + storage: { title: 'Prepare Storage', desc: 'Object storage or local disk' }, + deploy: { title: 'Deploy Loki', desc: 'Built-in log query' }, + collect: { title: 'Configure Collection', desc: 'Host files / cluster DaemonSet' }, + }, + hint: { + env: 'Start by choosing an environment and connecting a cluster or host', + storage: 'Environment ready — object storage is recommended next', + deploy: 'Target and storage ready — create your Loki deployment', + collect: 'Loki is running — add a collection policy to start collecting logs', + all: 'All set: logs are being collected — head to Log Query to analyze them', + }, + env: { + k8sTitle: 'Kubernetes Cluster', + k8sDesc: 'Deploy Loki as a StatefulSet via the Kubernetes API; a DaemonSet collects container logs', + hostTitle: 'Bare-Metal Host', + hostDesc: 'Deploy Loki with host Docker and collect specified log files', + ready: 'Ready', + connected: 'Connected: {{names}}', + nameSep: ', ', + k8sHint: 'Connect a cluster via kubeconfig or ServiceAccount token; connectivity is verified automatically on creation', + addCluster: 'Add Cluster', + hostHint: 'Connect a host via SSH key or password (used for deployment and later collector installation)', + addHost: 'Add Host', + pickFirst: '↑ Pick an environment above to get the matching target onboarding form', + }, + storage: { + defaultLabel: 'Default storage: ', + none: 'No storage configured — use local MinIO / RustFS, or AWS / Alibaba Cloud object storage', + manage: 'Manage Storage', + add: 'Add Storage', + configure: 'Configure Storage', + hint: 'Object storage (MinIO / RustFS / S3 / OSS) is recommended for more reliable replication and retention. Use "Test Connection" when adding to verify credentials and auto-create missing buckets; skip to use local disk.', + }, + deploy: { + meta: '{{mode}} · Namespace {{namespace}} · {{endpoint}}', + endpointPending: 'endpoint available after deployment', + openGrafana: 'Open Grafana ({{url}} · admin/admin)', + openLoki: 'Open Loki', + details: 'Deployment Details', + none: 'No running Loki — deploy to Kubernetes or host Docker', + create: 'Create Deployment', + triggering: 'Triggering deployment…', + deployTriggered: 'Deployment of "{{name}}" triggered — check the deployments page for logs and progress', + }, + collect: { + title: 'Log Collection Policies', + activeCount: '{{count}} active', + desc: 'Hosts: specified log file paths · Clusters: all container logs via DaemonSet (deployed into the linked Loki\'s namespace)', + add: 'Add Collection Policy', + manage: 'Manage Collectors', + }, +} + +export default wizard diff --git a/frontend/src/i18n/locales/zh/alerts.ts b/frontend/src/i18n/locales/zh/alerts.ts new file mode 100644 index 0000000..c621ed5 --- /dev/null +++ b/frontend/src/i18n/locales/zh/alerts.ts @@ -0,0 +1,78 @@ +// alerts 命名空间 — 日志告警域中文文案(页头 / 规则表单与模板 / 发送历史 / 飞书通知提示) +export default { + title: '日志告警', + subtitle: '按 LogQL 计数规则监控日志,超阈值推送飞书卡片(周期 1 分钟)', + checkNow: '立即检查', + checking: '检查中...', + create: '新建规则', + emptyTitle: '暂无告警规则', + emptyDesc: '例如:{namespace=~".+"} |= "FATAL",15 分钟内超过 0 条即推送飞书', + sourceFallback: '数据源 {{id}}', + noChannels: '未关联通道', + channelSep: '、', + ruleMeta: '{{window}} 分钟超过 {{threshold}} 条触发 · 冷却 {{cooldown}} 分钟', + lastFired: '上次触发 {{time}}', + editAria: '编辑 {{name}}', + deleteAria: '删除 {{name}}', + historyTitle: '发送历史(最近 50 条)', + notifyHintPre: '通知未配置?去', + notifyHintPost: '配置飞书 Webhook', + editTitle: '编辑规则 · {{name}}', + createTitle: '新建告警规则', + templates: '快捷模板(点击预填,可再修改)', + saving: '保存中...', + save: '保存修改', + submitCreate: '创建规则', + loadFailed: '加载告警数据失败', + saveFailed: '保存失败', + deleteFailed: '删除失败', + checkDone: '已执行一次检查,结果见发送历史', + checkFailed: '检查失败', + toggleFailed: '切换失败', + confirmDeleteTitle: '确认删除告警规则', + delivery: { + sent: '已发送', + cooldown: '冷却跳过', + failed: '失败', + hits_other: '{{count}} 条命中', + }, + tpl: { + nsError: { + label: '业务 NS error', + desc: '指定命名空间(默认 astralx)内 error 日志', + }, + allNsError: { + label: '全 NS error > 1000', + desc: '所有命名空间 error 日志总量超过 1000 条', + }, + ingress5xx: { + label: 'Ingress 5xx', + desc: 'ingress-nginx 5xx(需 JSON access log,平台可一键标准化)', + }, + ingress4xx: { + label: 'Ingress 4xx', + desc: 'ingress-nginx 4xx(需 JSON access log)', + }, + fatal: { + label: 'FATAL', + desc: '所有命名空间出现 FATAL 即告警', + }, + }, + form: { + name: '规则名称 *', + namePlaceholder: '应用致命异常', + source: '数据源 *', + sourcePlaceholder: '选择数据源', + query: 'LogQL 查询 *', + queryHint: + '常用:{app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"', + channels: '通知通道(可多选,扇出到多个告警群)', + noChannels: '请先在「系统设置 → 告警通知」创建通道', + channelDisabled: '(禁用)', + window: '统计窗口(分)', + threshold: '触发阈值(条)', + thresholdHint: '0 = 出现即告警', + cooldown: '冷却(分)', + enabled: '启用规则', + }, +} diff --git a/frontend/src/i18n/locales/zh/clusters.ts b/frontend/src/i18n/locales/zh/clusters.ts new file mode 100644 index 0000000..cdd08d3 --- /dev/null +++ b/frontend/src/i18n/locales/zh/clusters.ts @@ -0,0 +1,88 @@ +// clusters 命名空间 — 集群管理页中文文案 +// 富文本(含 /)配合 react-i18next 渲染,勿删标签 +export default { + title: '集群管理', + description: '管理用于 Loki 部署的 Kubernetes 集群', + actions: { + add: '添加集群', + detail: '详情', + editAria: '编辑集群 {{name}}', + validateAria: '验证集群 {{name}}', + deleteAria: '删除集群 {{name}}', + }, + fields: { + accessType: '接入方式', + apiServer: 'API Server', + version: '版本', + nodes: '节点数', + unknown: '未知', + }, + empty: { + title: '暂无集群', + description: '添加第一个 Kubernetes 集群开始使用', + }, + delete: { + warning: + '确定要删除「{{name}}」吗?警告:仍有 {{dep}} 个部署、{{col}} 个采集策略引用该集群,删除后它们将无法工作。', + }, + toast: { + deleteFailed: '删除集群失败', + validateFailed: '验证失败', + createFailed: '创建集群失败', + }, + form: { + addTitle: '添加 Kubernetes 集群', + editTitle: '编辑集群', + editTitleWithName: '编辑集群 · {{name}}', + nameLabel: '集群名称 *', + descriptionLabel: '描述', + descriptionPlaceholder: '生产环境日志集群', + accessTypeLabel: '接入方式', + kubeconfigLabel: 'Kubeconfig 内容 *', + kubeconfigLabelEdit: 'Kubeconfig 内容(留空保持不变)', + kubeconfigPlaceholder: '粘贴 kubeconfig YAML(自动识别 API Server)...', + apiServerDetected: '已识别 API Server:{{server}}', + apiServerLabel: 'API Server *', + tokenLabel: 'Token *', + tokenLabelEdit: 'Token(留空保持不变)', + tokenPlaceholder: '粘贴 Service Account Token...', + tokenInsecureHint: '采用 insecure 模式,便于内网/自签证书集群接入', + rbacSummary: '最小授权建议(点击展开)', + rbacScope: + '只授予 log 命名空间权限,Token 泄露也不会影响其他命名空间;不要使用 cluster-admin。', + rbacScriptIntro: + '一次性创建 log 命名空间、ServiceAccount、命名空间级 Role 和长期 Token(在集群里执行一次,Token 粘贴到下方输入框):', + rbacScriptComment1: '# 1) 一次创建:命名空间 + SA + 命名空间级 Role + 长期 Token', + rbacScriptComment2: '# 2) 取出长期 Token(不自动过期),粘贴到 Token 输入框', + rbacNamespaceNote: + '上面的 Namespace 由管理员创建,平台 Token 本身没有任何集群级权限。只有确实需要平台自建命名空间时,才额外授予 namespaces: create/get 的 ClusterRole;注意 namespace 的 create 无法按名称限制,所以预建 log 更安全。', + rbacTokenNote: + 'Token 长期有效,但只绑定 log 命名空间的 Role(仅能操作该命名空间的 Pod / 工作负载),泄露影响面仅限于 log;服务端会转成 kubeconfig 并用 ENCRYPTION_KEY 加密存储,数据库里只有 ENC: 密文,接口不返回明文。轮换时删除该 Secret 重建即可,旧 Token 立即失效。', + contextLabel: '上下文(可选)', + contextPlaceholder: '留空则使用默认上下文', + submitAdd: '添加集群', + submitSave: '保存修改', + submittingAdd: '添加中...', + submittingSave: '保存中...', + validation: { + nameRequired: '请输入集群名称', + kubeconfigRequired: '请填写 kubeconfig 内容', + apiServerRequired: '请填写 API Server 地址', + tokenRequired: '请填写 Token', + }, + }, + detail: { + loadError: '加载集群详情失败', + version: '版本', + unknown: '未知', + currentContext: '当前上下文', + defaultContext: '默认', + nodesCount: '节点({{count}})', + nodeReady: '就绪', + nodeNotReady: '未就绪', + nodeIp: 'IP:{{value}}', + nodeOs: '系统:{{os}}/{{arch}}', + nodeVersion: '版本:{{version}}', + namespaces: '命名空间', + }, +} diff --git a/frontend/src/i18n/locales/zh/collectors.ts b/frontend/src/i18n/locales/zh/collectors.ts new file mode 100644 index 0000000..6717e77 --- /dev/null +++ b/frontend/src/i18n/locales/zh/collectors.ts @@ -0,0 +1,135 @@ +// collectors 命名空间 — 「日志采集」页面域中文文案。 +// 注册说明:i18n 入口(src/i18n/index.ts)由框架统一维护,页面域词典在被页面组件 +// 与入口处 resources 静态注册的 common 命名空间共存。 + +const collectors = { + page: { + title: '日志采集', + description: '为主机与集群配置日志采集策略,日志将推送到关联的 Loki', + addAction: '添加采集策略', + }, + empty: { + title: '暂无采集策略', + description: '新建一个日志采集器,关联已部署的 Loki', + }, + // 采集器类型徽章(卡片描述行) + typeLabel: { + hostPromtail: '主机 Promtail', + k8sDaemonset: 'Promtail DaemonSet', + k8sFluentbitSidecar: 'Fluent Bit Sidecar', + }, + // 卡片字段标签 + field: { + loki: '关联 Loki', + cluster: '集群', + host: '主机', + namespace: '命名空间', + logPath: '日志路径', + target: '目标', + unknown: '未知', + }, + action: { + install: '部署', + uninstall: '卸载', + editAria: '编辑采集策略 {{name}}', + deleteAria: '删除采集器 {{name}}', + }, + toast: { + loadOptionsFailed: '加载 Loki 部署/集群/主机数据失败', + installFailed: '部署采集器失败', + uninstallFailed: '卸载采集器失败', + deleteFailed: '删除采集器失败', + createFailed: '创建采集器失败', + saveFailed: '保存采集策略失败', + }, + deleteConfirm: '确定要删除「{{name}}」吗?删除时将自动卸载采集端,此操作不可恢复。', + form: { + title: '添加采集策略', + name: '名称', + type: '采集器类型', + // 新建表单下拉里的类型选项(比卡片徽章多部署通道说明) + typeOption: { + hostPromtail: '主机 Promtail(SSH)', + k8sDaemonset: 'Kubernetes Promtail DaemonSet', + k8sFluentbitSidecar: 'Kubernetes Fluent Bit Sidecar', + }, + lokiDeployment: '关联 Loki 部署', + lokiPlaceholder: '选择已部署的 Loki(推送目标)', + // 下拉项名称后的括号后缀(状态/IP),中英文括号形态不同 + statusParen: '({{status}})', + hostParen: '({{ip}})', + host: '目标主机', + hostPlaceholder: '选择主机', + cluster: '目标集群', + clusterPlaceholder: '选择集群', + namespace: '命名空间', + nsPickCluster: '先选择集群', + nsLoading: '加载命名空间…', + nsPick: '选择命名空间', + nsEmpty: '暂无可读取的命名空间', + nsLoadFailed: '读取命名空间失败', + pickFromCluster: '从集群选择', + manualInput: '手动填写', + targetDeployment: '目标 Deployment', + wlPickNamespace: '先选择命名空间', + wlLoading: '加载 Deployment…', + wlPick: '选择 Deployment', + wlEmpty: '此命名空间暂无 Deployment', + wlLoadFailed: '读取 Deployment 失败', + wlReady: '(就绪 {{ready}}/{{total}})', + logPath: '采集日志路径', + logPathHostHint: '支持 glob 通配(如 /var/log/*.log /var/log/syslog);多个路径用逗号分隔', + logPathSidecarHint: + '填写业务写入的文件路径,如 /var/log/app/*.log。已有日志卷会自动共享;无日志卷时仅在专用 logs 目录创建共享卷。/tmp/test.log 等普通目录不会自动挂载。', + labels: '自定义标签(JSON,可选)', + labelsHint: '合法 JSON 时将附加到每条日志的标签,便于在 Loki/Grafana 中筛选;留空则不加', + submit: '创建并部署', + submitting: '创建中...', + validation: { + nameRequired: '请输入采集器名称', + lokiRequired: '请选择关联的 Loki 部署', + hostRequired: '请选择目标主机', + clusterRequired: '请选择目标集群', + namespaceRequired: '请选择命名空间', + targetDeploymentRequired: '请填写目标 Deployment 名称', + }, + }, + edit: { + title: '编辑采集策略', + namespaces: '采集命名空间', + allNamespaces: '全集群所有命名空间', + selectedCount: '已选 {{count}} 个', + allCheckbox: '所有命名空间(全集群采集)', + nsSearch: '搜索命名空间…', + loading: '加载中…', + nsListEmpty: '未取到命名空间列表(可手动输入)', + nsNoMatch: '无匹配', + nsLoadFailed: '加载命名空间失败', + // nsHint/appSourceHint/appIncludeHint 含行内 ,渲染处用 Trans 组件 + nsHint: + '勾「所有命名空间」= 采集全集群;否则只采集勾选的命名空间。生成 Promtail 的 kubernetes_sd_configs.namespaces 白名单。', + appLabelSource: 'app 标签取值来源', + appSourcePlaceholder: '留空 = 默认(controller 名回退链)', + appSourceHint: + '默认按 app.kubernetes.io/name → app → controller 名 → pod 名 四级回退。若集群有自己的私有规范标签(如 k8s.kuboard.cn/name),填在这里会作为最高优先级,其余仍兜底。', + appSourcePreset: { + default: '默认:app.kubernetes.io/name → app → controller 名 → pod 名', + controllerName: 'controller_name(Pod 的 ReplicaSet 名,去 hash = Deployment 名)', + k8sAppName: 'app.kubernetes.io/name(Kubernetes 标准标签)', + app: 'app(常见简写)', + kuboard: 'k8s.kuboard.cn/name(Kuboard 注入)', + }, + appInclude: '只采集这些 app(可选)', + appIncludePlaceholder: '留空 = 不限;多个用逗号分隔,如 cex-spot-api, cex-asset-api', + appIncludeHint: + '填了之后 Promtail 只保留 app 命中这些值的日志(action: keep),是控制日志量最直接的手段。值需与上面「app 标签取值来源」解析出的 app 一致。', + logPathHint: 'Sidecar 需使用业务容器的共享日志卷,或专用 logs 目录。保存后点击「部署」应用新配置。', + save: '保存', + saving: '保存中…', + validation: { + nameRequired: '请输入采集策略名称', + }, + }, +} + +export default collectors diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts new file mode 100644 index 0000000..0180612 --- /dev/null +++ b/frontend/src/i18n/locales/zh/common.ts @@ -0,0 +1,99 @@ +// common 命名空间 — 全局框架(菜单/顶栏/登录/通用组件/状态)中文文案 +export default { + appName: 'LokiStack', + menu: { + dashboard: '仪表盘', + wizard: '部署向导', + clusters: '集群管理', + hosts: '主机管理', + storage: '存储配置', + deployments: '部署 Loki', + collectors: '日志采集', + logs: '日志查询', + alerts: '日志告警', + settings: '系统设置', + users: '用户管理', + login: '登录', + notFound: '页面不存在', + }, + layout: { + nav: '主导航', + sheetTitle: 'LokiStack 导航', + collapse: '收起菜单', + expand: '展开菜单', + openNav: '打开导航菜单', + logout: '退出登录', + logoutShort: '退出', + toLight: '切换为浅色', + toDark: '切换为深色', + admin: '管理员', + loading: '页面加载中', + }, + login: { + subtitle: 'Loki 日志管理平台', + heading: '登录', + username: '用户名', + usernamePlaceholder: '请输入用户名', + password: '密码', + passwordPlaceholder: '请输入密码', + remember: '记住用户名', + submit: '登录', + submitting: '登录中...', + errCredential: '用户名或密码错误', + errConn: '无法连接后端服务,请确认后端已启动(端口 8080)。', + errFallback: '登录失败', + backendDown: '后端服务未启动', + backendDownHint: '无法连接 API 服务,请先启动后端:', + }, + dialog: { + confirmTitle: '确认删除', + confirmText: '删除', + cancel: '取消', + processing: '处理中...', + confirmIrreversible: '确定要执行该操作吗?此操作不可恢复。', + confirmDefault: '确定要删除「{{name}}」吗?此操作不可恢复。', + }, + error: { + title: '加载失败', + hint: '请检查网络或后端服务状态后重试', + retry: '重试', + }, + pagination: { + total: '共 {{count}} 条', + summary: '共 {{count}} 条 · 第 {{page}}/{{pages}} 页', + }, + status: { + active: '运行中', + running: '运行中', + inactive: '已停止', + stopped: '已停止', + pending: '等待中', + deploying: '部署中', + failed: '失败', + error: '异常', + }, + notFound: { + heading: '页面不存在', + hint: '页面不存在或已被移除', + backHome: '返回首页', + }, + lang: { + section: '语言', + zh: '中文', + en: 'English', + }, + boundary: { + title: '页面渲染出错', + hint: '页面遇到异常,可尝试刷新;若持续出现请把下方信息反馈给开发者。', + unknown: '未知错误', + reload: '刷新页面', + reset: '重置组件', + }, + spinner: { + default: '加载中...', + }, + password: { + hide: '隐藏密码', + show: '显示密码', + }, +} diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts new file mode 100644 index 0000000..4afdcef --- /dev/null +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -0,0 +1,28 @@ +// dashboard 命名空间 — 仪表盘域中文文案(指标卡 / 快速开始引导 / 最近部署 / 资源快捷列表) +export default { + stat: { + totalDeployments: '部署总数', + runningCount: '{{count}} 个运行中', + clusters: '集群', + connected: '已接入', + hosts: '主机', + sshReady: 'SSH 就绪', + status: '状态', + }, + quickStart: { + title: '三步开始收集日志', + subtitle: '环境与目标 → 存储 → 部署 Loki 与 Grafana,最后添加采集策略', + cta: '进入部署向导', + }, + recent: { + title: '最近部署', + viewAll: '查看全部', + emptyTitle: '暂无部署', + emptyDesc: '创建你的第一个 Loki 部署', + }, + list: { + manage: '管理', + empty: '暂无{{title}}', + }, + loadFailed: '加载仪表盘数据失败', +} diff --git a/frontend/src/i18n/locales/zh/deployments.ts b/frontend/src/i18n/locales/zh/deployments.ts new file mode 100644 index 0000000..4b056c2 --- /dev/null +++ b/frontend/src/i18n/locales/zh/deployments.ts @@ -0,0 +1,121 @@ +// deployments 命名空间 — Loki 部署页中文文案(页面/卡片/表单/保留配置/日志弹窗) +export default { + page: { + title: '部署 Loki', + description: '部署与管理 Grafana Loki 日志聚合服务', + create: '新建部署', + emptyTitle: '暂无部署', + emptyDescription: '创建你的第一个 Loki 部署', + // 删除确认:host-docker 连容器一起删,其余模式仅删记录不卸载资源 + deleteDockerDesc: '确定要删除「{{name}}」吗?将同时移除主机上的 Loki 容器,此操作不可恢复。', + deleteRecordDesc: + '确定要删除「{{name}}」吗?删除记录不会自动卸载集群/主机上已部署的 Loki 资源,此操作不可恢复。', + errLoadRefs: '加载集群/主机/存储数据失败', + errDeploy: '部署失败', + errStop: '停止失败', + errRestart: '重启失败', + errDelete: '删除部署失败', + }, + card: { + mode: '方式', + modeK8s: 'Kubernetes', + modeDocker: '主机 Docker', + version: '版本', + target: '目标', + targetCluster: '集群:{{name}}', + targetHost: '主机:{{name}}', + targetUnknown: '未知', + namespace: '命名空间', + replicas: '副本', + replicasValue: '{{replicas}} · 保留 {{days}} 天', + retention: '保留', + retentionValue: '{{days}} 天', + storage: '存储', + localStorage: '本地', + ready: 'Loki 就绪', + notReady: 'Loki 未就绪', + runningHint: + '提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。', + configure: '配置', + deploy: '部署', + deploying: '部署中...', + stop: '停止', + restart: '重启', + logs: '日志', + deleteAria: '删除部署 {{name}}', + }, + form: { + title: '创建 Loki 部署', + name: '部署名称 *', + description: '描述', + descriptionPlaceholder: '生产环境 Loki 日志服务', + version: '版本', + mode: '部署方式', + modeK8s: 'Kubernetes', + modeDocker: '主机 Docker', + cluster: '目标集群 *', + clusterPlaceholder: '请选择集群', + namespace: '命名空间 *', + host: '目标主机 *', + hostPlaceholder: '请选择主机', + listenIP: '监听内网 IP', + listenIPPlaceholder: '选择主机后默认使用主机 IP', + listenPort: '访问端口', + dockerHint: '通过 SSH 在主机启动 Docker 容器,配置和数据保存在主机本地。', + storage: '存储配置', + storagePlaceholder: '请选择存储(可选)', + localStorage: '本地存储(默认)', + replicas: '副本数', + retentionDays: '保留天数', + storageSize: '存储容量', + memory: '内存', + authEnabled: '启用多租户鉴权', + create: '创建部署', + creating: '创建中...', + errCreate: '创建部署失败', + // zod 校验消息(schema 工厂注入 t) + nameRequired: '请输入部署名称', + versionRequired: '请选择 Loki 版本', + retentionInvalid: '保留天数需为不小于 1 的整数', + replicasInvalid: '副本数需为 1-10 之间的整数', + cpuRequired: '请填写 CPU 配额(如 500m)', + memRequired: '请填写内存配额(如 512Mi)', + storageSizeRequired: '请填写存储容量(如 20Gi)', + clusterRequired: '请选择目标集群', + hostRequired: '请选择目标主机', + portInvalid: '端口须在 1-65535 之间', + namespaceRequired: '请填写命名空间', + storageSizeMin: '阿里云 ESSD 最小 20Gi,请填 20Gi 或以上', + }, + retention: { + title: '编辑 Loki 配置 · {{name}}', + locationLabel: '配置位置:', + locationHostBinary: '/etc/loki/loki.yaml → limits_config.retention_period', + locationHostDocker: '主机 /etc/lokistack/loki-{{id}}/loki.yaml → limits_config.retention_period', + locationK8s: 'ConfigMap {{namespace}}/loki-config → loki.yaml', + compactorNote: '保留策略由 Loki compactor 执行;MinIO 只负责存储日志对象。', + liveTitle: '运行中的完整 loki.yaml', + liveHint: '从部署目标读取,存储凭证已遮盖;下方保留天数仍单独编辑。', + view: '查看完整配置', + refresh: '刷新配置', + loading: '读取中…', + templateNote: '由部署模板生成', + yamlLabel: '保留配置 YAML', + yamlHint: '编辑 limits_config.retention_period,例如 90d 或 2160h。此处只接受这一项配置。', + applyHintRunning: '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。', + applyHintIdle: '保存后在下次部署时生效。', + cancel: '取消', + saveOnly: '仅保存', + saveApply: '保存并应用', + processing: '处理中…', + savedApplied: '已保存 {{days}} 天,Loki 正在重新部署', + yamlRequired: '请输入 YAML 配置', + errLoad: '读取运行配置失败', + errSave: '保存 Loki 配置失败', + }, + logs: { + title: '部署日志 - {{name}}', + empty: '暂无日志...', + errLoad: '加载部署日志失败', + }, +} diff --git a/frontend/src/i18n/locales/zh/hosts.ts b/frontend/src/i18n/locales/zh/hosts.ts new file mode 100644 index 0000000..0b71ec1 --- /dev/null +++ b/frontend/src/i18n/locales/zh/hosts.ts @@ -0,0 +1,81 @@ +// hosts 命名空间 — 主机管理页中文文案 +export default { + title: '主机管理', + description: '管理用于二进制方式部署 Loki 的 SSH 主机', + actions: { + add: '添加主机', + detail: '详情', + editAria: '编辑主机 {{name}}', + validateAria: '验证主机 {{name}}', + deleteAria: '删除主机 {{name}}', + }, + fields: { + address: '地址', + user: '用户', + auth: '认证', + authPassword: '密码', + authKey: 'SSH 密钥', + os: '系统', + unknown: '未知', + }, + empty: { + title: '暂无主机', + description: '添加第一台 SSH 主机开始使用', + }, + delete: { + warning: + '确定要删除「{{name}}」吗?警告:仍有 {{dep}} 个部署、{{col}} 个采集策略引用该主机,删除后它们将无法工作。', + }, + toast: { + deleteFailed: '删除主机失败', + validateFailed: '验证失败', + createFailed: '添加主机失败', + }, + form: { + addTitle: '添加 SSH 主机', + editTitle: '编辑主机', + editTitleWithName: '编辑主机 · {{name}}', + nameLabel: '名称 *', + hostIpLabel: '主机 IP *', + portLabel: '端口', + userLabel: '用户名 *', + authTypeLabel: '认证方式', + passwordLabel: '密码 *', + passwordLabelEdit: '密码(留空保持不变)', + passwordPlaceholder: 'SSH 登录密码', + privateKeyLabel: '私钥 *', + privateKeyLabelEdit: '私钥(留空保持不变)', + keyPhraseLabel: '密钥口令(可选)', + keyPhrasePlaceholder: '无口令则留空', + labelsLabel: '标签(JSON)', + submitAdd: '添加主机', + submitSave: '保存修改', + submittingAdd: '添加中...', + submittingSave: '保存中...', + validation: { + nameRequired: '请输入主机名称', + hostIpRequired: '请输入主机 IP', + portRequired: '请输入端口', + portNumeric: '端口必须为数字', + portRange: '端口必须在 1-65535 之间', + userRequired: '请输入用户名', + passwordRequired: '请填写 SSH 登录密码', + privateKeyRequired: '请粘贴 SSH 私钥内容', + }, + }, + detail: { + loadError: '加载主机详情失败', + usage: '资源使用率', + memory: '内存', + disk: '磁盘', + usageDetail: '({{detail}})', + basicInfo: '基本信息', + hostname: '主机名', + os: '系统', + arch: '架构', + kernel: '内核', + cpuCores: 'CPU 核数', + uptime: '运行时长', + unknown: '未知', + }, +} diff --git a/frontend/src/i18n/locales/zh/logs.ts b/frontend/src/i18n/locales/zh/logs.ts new file mode 100644 index 0000000..0fbd7c1 --- /dev/null +++ b/frontend/src/i18n/locales/zh/logs.ts @@ -0,0 +1,316 @@ +// logs 命名空间 — 日志查询页(/logs)中文文案 +export default { + nav: { + aria: '日志中心导航', + }, + tabs: { + query: 'Loki 查询', + access: '查询授权', + sources: '数据源管理', + descQuery: '按日志流和时间范围检索;拖动时间线缩小范围,点日志查看前后文。', + descAccess: '用策略组复用日志范围,并一次关联多个用户。', + descSources: '配置 Loki 连接与认证方式。', + }, + toolbar: { + run: '查询', + running: '查询中…', + tail: 'Tail', + tailStop: '停止 Tail', + sourceAria: '数据源', + sourceEmptyOption: '暂无可用数据源', + limitLabel: '每页条数', + limitHint: '每页最多 1000 条;翻页只保留当前页。大范围先看时间线,再拖选高峰时段。', + rangeInvalid: '请选择有效的起止时间', + queryFailed: '查询失败', + }, + source: { + loadFailed: '加载数据源失败', + missingTitle: '还没有可用的 Loki 数据源', + missingBody: + '请先到「数据源管理」关联 Loki:集群内可直接选「集群服务」(命名空间 / Service / 端口,无需 Ingress);跨集群或公网查询,请先给 Loki 配置 Ingress 域名,再用「URL 直连」填写该域名。未关联数据源无法查询。', + goConfigure: '去配置数据源', + sectionAria: '数据源管理', + heading: 'Loki 数据源', + hint: '提示:Loki 部署在集群内时,可直接添加「集群服务」数据源(无需 Ingress);跨集群 / 公网查询需先为 Loki 配置 Ingress 域名,再用「URL 直连」关联。未关联数据源时,查询页无法使用。', + managedCluster: '集群 {{name}}', + managedBadge: '平台部署 · 随部署管理', + add: '+ 手动添加', + nameLabel: '名称', + targetUrl: 'URL 直连', + targetClusterSvc: '集群服务', + addressLabel: 'Loki 地址', + clusterLabel: '集群', + clusterPlaceholder: '选择集群…', + namespaceLabel: '命名空间', + serviceLabel: 'Service', + portLabel: '端口', + authLabel: '认证方式', + authNone: '无认证', + authBasic: 'Basic Auth', + authBearer: 'Bearer Token', + authHeaders: '自定义 Headers', + usernameLabel: '用户名', + passwordLabel: '密码', + tokenLabel: '令牌', + headersLabel: '自定义 Headers(JSON 对象)', + headersInvalid: 'Headers 必须是字符串键值 JSON 对象', + skipTLS: '跳过 TLS 校验', + setDefault: '设为默认', + test: '测试连接', + save: '保存数据源', + saveFailed: '保存失败', + remove: '删除', + }, + range: { + lastMinutes: '最近 {{count}} 分钟', + lastHours: '最近 {{count}} 小时', + custom: '自定义时间', + title: '选择时间范围', + start: '开始时间', + end: '结束时间', + apply: '应用时间', + recent: '最近使用', + }, + editor: { + aria: 'LogQL 查询语句', + valuesOf: '{{label}} 的值', + quickTab: '快捷 LogQL', + historyTab: '查询历史', + historyTabCount: '查询历史 ({{count}})', + escClose: 'Esc 关闭', + historyEmpty: '运行查询后,常用 LogQL 会出现在这里', + }, + templates: { + aria: '快捷查询模板', + title: '快捷生成 LogQL', + hint: '先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。', + namespace: '命名空间', + app: 'App', + appWithKey: 'App({{key}})', + pod: '副本 / Pod', + selectCount: '请选择(共 {{count}} 个)', + noApp: '该范围内无 app', + pickNsFirst: '先选命名空间可缩小范围', + noPod: '该 app 下无副本', + optional: '可选', + scopeLogs: '查询该范围日志', + errorLogs: '错误 error', + warnLogs: '告警 warn', + podLogs: '指定副本日志', + ingressLogs: 'Ingress 访问日志', + scopeHint: '先选 namespace,再选 app 或 pod,也可在 Label 浏览器选值', + }, + labels: { + browser: 'Label 浏览器', + selectedCount: '已选 {{count}}', + loadingLabels: '加载标签中…', + noLabels: '暂无可用标签', + pickHint: '选择左侧标签,再搜索对应的值', + valuesOf: '{{label}} 的值', + searchAria: '搜索 {{label}} 的值', + searchPlaceholder: '搜索或输入标签值', + scoped: '按当前查询条件缩小候选', + loading: '加载中…', + noValues: '没有匹配值', + useAsValue: '使用“{{value}}”作为值', + hint: '先选 namespace 可查该命名空间全部副本;再选 app / service_name 可缩小到某个服务(无 app 标签的工作负载用 namespace 即可)。候选来自当前时间范围内已写入 Loki 的日志;授权范围仍由服务端限制。', + }, + volume: { + statsLoading: '正在统计整个时间范围的日志量,日志页可继续浏览。', + statsError: '日志量统计超时或失败;日志页仍可使用。可缩小时间范围后重新查询。', + histogramLoading: '统计中…', + histogramEmpty: '暂无日志量数据', + timelineAria: '日志量时间线', + timelineTitle: '日志量时间线', + timelineHint: '悬停查看数量,按住任意高度拖选时间;选择后自动查询。', + timelineTotal: '时间桶合计 {{count}} 条', + timelineBucket: '{{count}} 条', + }, + results: { + aria: '日志结果', + title: '日志结果', + summary: '第 {{page}} 页 · 本页 {{count}} 条', + summaryMore: '第 {{page}} 页 · 本页 {{count}} 条 · 还有更多', + viewAria: '日志展示方式', + viewRaw: '原始日志', + viewTable: '表格', + viewLine: '折线图', + viewStream: '流图', + viewPie: '饼图', + loadingPage: '正在加载日志页', + loadingQuery: '正在查询 Loki', + loadingWait: '已等待 {{seconds}} 秒。大范围查询可能需要较长时间。', + pagingHint: '日志按时间从新到旧翻页;图表展示当前页,顶部时间线展示整个查询范围。', + domainFilter: '域名筛选', + domainPlaceholder: '输入或选择域名', + domainQuery: '在 Loki 中查询', + domainHint: '仅匹配日志记录的请求 Host。当前 {{shown}} / {{total}} 条。', + domainNoMatch: '当前域名筛选没有匹配的日志', + filterLabel: '筛选此标签', + showContext: '查看前后各 5 条', + prevPage: '上一页', + nextPage: '下一页', + pageOf: '第 {{page}} 页', + noSourceTitle: '暂无可查询的数据源', + noSourceHint: '请联系管理员添加或授权数据源', + noMatchTitle: '未找到匹配的日志记录', + noMatchHint: '当前时间范围和筛选条件下没有匹配结果', + idleTitle: '输入 LogQL 并运行查询', + idleHint: '也可以使用 Label 浏览器快速构建查询', + retry: '重新查询', + }, + views: { + noDomain: '未记录域名', + otherDomain: '其他域名', + unknown: '未知', + untagged: '未标记', + other: '其他', + tableLoaded: '当前页已加载 {{count}} 条日志。', + tableEmpty: '暂无可展示的日志', + thTime: '时间', + thNsPod: 'namespace / pod', + thLevel: '级别', + thLog: '日志', + lineByLoaded: '按当前已加载日志统计;更多结果请继续加载。', + lineByRange: '按查询时间范围统计的日志量,来自 Loki 时间聚合。', + lineGroupAria: '折线图分组字段', + lineByDomain: '按域名', + lineByTotal: '总量', + lineEmpty: '当前查询没有可用的时间统计', + lineSeriesTotal: '日志条数', + streamParsed: '展示 {{count}} 条已解析日志', + streamByLevel: '按级别展示当前已加载的 {{count}} 条日志', + streamSuffix: ',基于当前已加载结果。', + streamGroupAria: '流图分组字段', + streamByStatus: '按 HTTP 状态段', + streamEmpty: '暂无可展示的日志流', + pieByAccess: '按访问字段统计已解析的 {{count}} 条日志', + pieByLabel: '按标签统计当前已加载的 {{count}} 条日志', + pieGroupAria: '饼图分组字段', + pieDomain: '域名(请求 Host)', + pieStatus: 'HTTP 状态码', + pieMethod: '请求方法', + piePath: '请求路径', + pieUpstream: '上游服务', + pieLevel: '级别', + pieEmpty: '暂无可展示的标签分布', + }, + ingress: { + modesAria: 'Ingress 统计方式', + modeTraffic: '接口 PV / UV', + modeErrors: '异常 Top 10', + modeSlow: '慢请求 Top 10', + modeDetail: '请求明细', + summary: + '仅统计当前页已加载的 {{loaded}} 条日志,其中 {{parsed}} 条为 ingress-nginx 访问日志。翻页后重新统计;UV 按非空客户端 IP 去重。', + statPv: 'PV', + statUv: 'UV(IP)', + statErrors: '4xx / 5xx', + statSlow: '慢请求 ≥ {{threshold}}s', + errorClassAria: '异常状态码范围', + errorAll: '全部 4xx / 5xx', + error5xx: '只看 5xx', + error4xx: '只看 4xx', + slowThreshold: '慢请求阈值', + seconds: '{{count}} 秒', + slowHint: '依据 access log 的 request_time 排序;408 / 499 / 504 仅作超时线索,不能单凭状态码断定后端超时。', + slowMissing: '{{count}} 条缺少有效耗时。', + trafficHint: '访问成功率按 2xx / 3xx 请求数 ÷ PV 计算;路径已去掉查询参数。', + thHost: '请求 Host', + thPath: '接口路径', + thSuccess: '成功率', + thErrorRate: '异常率', + thStatus: '状态码', + thSlow: '慢请求', + thMaxLatency: '最长耗时', + thAvgLatency: '平均耗时', + thTimeout: '408 / 499 / 504', + thClientIp: '客户端 IP', + thStatusShort: '状态', + thLatency: '耗时', + thTime: '时间', + empty: '当前页没有符合条件的请求', + noDomain: '未记录 Host', + }, + context: { + title: '同一日志流的前后文', + sameFile: '同一文件', + sameStream: '同一日志流', + meta: '前后各最多 5 条 · 前后 5 分钟', + file: '文件: {{name}}', + current: '当前日志', + currentAria: '当前日志', + beforeAria: '之前的日志', + afterAria: '之后的日志', + beforeTitle: '之前 · {{count}} 条', + afterTitle: '之后 · {{count}} 条', + beforeEmpty: '此前 5 分钟内无日志', + afterEmpty: '此后 5 分钟内无日志', + loadFailed: '读取上下文失败', + }, + access: { + loadFailed: '加载查询策略失败', + validate: '请填写策略组名称,选择数据源,并选择「所有人」或至少一位用户', + saveFailed: '保存策略组失败', + deleteFailed: '删除策略组失败', + sourceRef: '数据源 #{{id}}', + listTitle: '查询策略组', + create: '新建', + listHint: '一套日志范围可关联多个用户,后续修改会同时生效。', + loading: '加载中…', + empty: '还没有策略组', + anyone: '所有人', + userCount: '{{count}} 位用户', + editTitle: '编辑策略组', + createTitle: '新建策略组', + formHint: '选择数据源及允许的日志范围,再关联需要复用此策略的用户。', + remove: '删除', + nameLabel: '策略组名称', + namePlaceholder: '例如:生产环境 Agent 日志', + sourceLabel: '数据源', + sourceEmpty: '请先添加数据源', + scopeTitle: '允许查询的日志范围', + ownerFallback: '策略组', + scopeHint: '可按命名空间、App、副本 / Pod 精确限制。未选择的标签不作限制。', + usersTitle: '关联用户', + selectedAnyone: '已选 所有人(anyone)', + selectedCount: '已选 {{count}} 位', + anyoneToggle: '所有人(anyone)', + anyoneActive: '✓ 所有人(anyone)', + selectFiltered: '选择当前筛选', + clear: '清空', + usersHint: '同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。', + searchAria: '搜索用户', + searchPlaceholder: '搜索用户名或昵称', + grouped: '已分组', + noMatch: '没有匹配用户', + saving: '保存中…', + save: '保存策略组', + deleteTitle: '删除查询策略组', + deleteDesc: '删除「{{name}}」后,关联用户将失去由该组授予的数据源访问权限。', + ruleAria: '{{name}} 查询范围', + nsLabel: '命名空间', + appLabel: 'App', + podLabel: '副本 / Pod', + }, + select: { + placeholder: '搜索并选择', + any: '不限', + loading: '加载中…', + noMatch: '无匹配值', + }, + raw: { + searching: '查询中…', + count: '{{count}} 条', + dedupSuffix: '(去重后,原 {{count}} 条)', + dedup: '去重', + empty: '没有匹配的日志', + original: '日志原文', + labelsTitle: '标签(点击过滤)', + filterValue: '过滤此值', + excludeValue: '排除此值', + fields: '提取字段', + loadMore: '加载更多', + loadingMore: '加载中…', + }, +} diff --git a/frontend/src/i18n/locales/zh/settings.ts b/frontend/src/i18n/locales/zh/settings.ts new file mode 100644 index 0000000..f571738 --- /dev/null +++ b/frontend/src/i18n/locales/zh/settings.ts @@ -0,0 +1,141 @@ +// settings 命名空间 — 系统设置页(通用配置/密码/关于、告警通知、LDAP 接入、镜像源)中文文案 +// 注意:主题卡片与语言卡片的标题、ThemeSection 内文案由其他模块负责,不在本词典内。 +export default { + title: '系统设置', + description: '通用配置、告警通知、LDAP 接入与默认镜像源', + tabs: { + general: '通用配置', + notify: '告警通知', + ldap: 'LDAP 接入', + registry: '默认镜像源', + }, + // 跨小节复用的操作文案 + actions: { + cancel: '取消', + saving: '保存中...', + testing: '测试中...', + }, + password: { + title: '修改密码', + oldLabel: '当前密码', + oldPlaceholder: '请输入当前密码', + newLabel: '新密码', + newPlaceholder: '请输入新密码(至少 6 位)', + confirmLabel: '确认新密码', + confirmPlaceholder: '请再次输入新密码', + submit: '修改密码', + submitting: '修改中...', + errMismatch: '两次输入的密码不一致', + errTooShort: '新密码至少需要 6 位', + errFailed: '密码修改失败', + }, + about: { + title: '关于 lokistack', + versionLabel: '版本', + backendLabel: '后端', + frontendLabel: '前端', + k8sLabel: 'Kubernetes 接入', + summaryLabel: '简介', + backendValue: 'Go + Gin + JSON 文件存储', + frontendValue: 'React 19 + TypeScript + Tailwind CSS', + k8sValue: 'client-go 直连 API Server', + summaryValue: '轻量级 Loki 日志管理平台', + }, + notify: { + cardTitle: '告警通知通道(飞书/Lark)', + create: '新建通道', + // 空态文案按「前段 + 链接 + 后段」拼接(链接指向日志告警页) + emptyBefore: '尚无通知通道 —— 每个告警群创建一个通道(如「应用告警群」「Nginx 告警群」),在', + emptyLink: '日志告警', + emptyAfter: '页把规则关联到对应通道', + webhookConfigured: 'Webhook 已配置', + webhookMissing: '未配置', + secretConfigured: ' · 签名已配置', + testAria: '测试 {{name}}', + editAria: '编辑 {{name}}', + deleteAria: '删除 {{name}}', + editTitle: '编辑通道 · {{name}}', + createTitle: '新建通知通道', + nameLabel: '通道名称 *', + namePlaceholder: '如:应用告警群 / Nginx 告警群', + webhookLabel: 'Webhook 地址', + webhookKeepHint: '(留空保持不变)', + webhookSavedPlaceholder: '已保存,输入新地址以修改', + signingLabel: '签名校验密钥(可选)', + signingSavedPlaceholder: '已保存,留空保持不变', + signingPlaceholder: '飞书机器人「签名校验」密钥', + enable: '启用通道', + saveChanges: '保存修改', + createChannel: '创建通道', + deleteTitle: '确认删除通知通道', + testSent: '测试卡片已发送到「{{name}}」', + errLoad: '加载通知通道失败', + errNameRequired: '请填写通道名称', + errWebhookRequired: '新建通道需要填写 Webhook 地址', + errSave: '保存失败', + errSend: '发送失败', + errToggle: '切换失败', + errDelete: '删除失败', + }, + ldap: { + cardTitle: 'LDAP 接入', + headerHint: '启用后支持 LDAP 登录与批量导入', + errLoad: '加载 LDAP 配置失败', + disabledHint: + 'LDAP 未启用 —— 打开开关填写目录服务信息(服务器 / Bind DN / Base DN),保存后用户管理页将出现「从 LDAP 导入」', + hostLabel: '服务器地址 *', + portLabel: '端口', + ldapsLabel: 'LDAPS', + bindDnLabel: 'Bind DN *', + bindPasswordLabel: 'Bind 密码 *', + bindPasswordSavedPlaceholder: '已保存,输入新密码以修改', + bindPasswordPlaceholder: '管理员密码', + baseDnLabel: 'Base DN *', + userFilterLabel: '用户过滤器', + usernameLabel: '用户名', + nameLabel: '姓名', + emailLabel: '邮箱', + noEmail: '无邮箱', + testButton: '测试连接', + saveButton: '保存配置', + errMissingFields: '请先填写服务器地址 / Bind DN / Base DN', + errTest: '测试失败', + savedToast: 'LDAP 配置已保存', + }, + registry: { + cardTitle: '镜像仓库源', + modeOverseasLabel: '海外官方', + modeOverseasDesc: 'docker.io,海外网络环境', + modeCnLabel: '国内加速', + modeCnDesc: 'docker.m.daocloud.io 镜像,国内环境防被墙', + modeCustomLabel: '私有仓库', + modeCustomDesc: '自有 registry 前缀(如华为云 SWR)', + errLoad: '加载镜像源配置失败', + savedToast: '镜像源已保存,后续部署/采集将使用新镜像地址', + customPrefixLabel: '私有仓库前缀 *', + customPrefixHint: '平台会在镜像名前拼接此前缀,如 {{example}}/grafana/loki:3.4.0', + imagesLabel: '各服务镜像(可单独覆盖仓库 / Tag)', + tagFollowVersion: '跟随部署版本', + repoAria: '{{label}} 镜像仓库', + tagAria: '{{label}} 镜像 Tag', + // 镜像说明按「前段 + grafana/loki + 后段」拼接 + imagesHintBefore: '仓库填镜像路径(如 ', + imagesHintAfter: ');Tag 留空时 Loki / Grafana 跟随部署版本,Promtail / Fluent Bit 用内置默认。', + previewTitle: '生效镜像预览', + saveButton: '保存镜像源', + }, + themeCard: { + title: '主题', + light: '浅色', + dark: '深色', + system: '跟随系统', + auto: '定时切换', + timezone: '判定时区', + timezoneAria: '主题定时切换时区', + browser: '跟随浏览器(本地)', + autoHint: '按所选时区,{{start}}:00–{{end}}:00 使用浅色,其余时段深色;顶栏太阳/月亮可随时手动覆盖。', + }, + prefsCard: { + title: '外观与语言', + }, +} diff --git a/frontend/src/i18n/locales/zh/storage.ts b/frontend/src/i18n/locales/zh/storage.ts new file mode 100644 index 0000000..4be44b8 --- /dev/null +++ b/frontend/src/i18n/locales/zh/storage.ts @@ -0,0 +1,53 @@ +// storage 命名空间 — 存储配置页中文文案(页面编排/卡片/表单弹窗) +export default { + page: { + title: '存储配置', + description: '为 Loki 配置对象存储后端', + add: '添加存储', + emptyTitle: '暂无存储配置', + emptyDescription: '添加第一个 Loki 存储后端', + // 删除确认文案(有部署引用时的警告;count 走中文无复数) + deleteWarning: + '确定要删除「{{name}}」吗?警告:仍有 {{count}} 个部署使用该存储,删除后这些部署的对象存储配置将失效。', + errDelete: '删除存储配置失败', + errSetDefault: '设置默认存储失败', + }, + card: { + default: '默认', + endpoint: '访问地址', + pathPrefix: '路径前缀', + none: '无', + connection: '连接', + insecureHttp: 'HTTP(不加密)', + setDefault: '设为默认', + editAria: '编辑存储配置 {{name}}', + deleteAria: '删除存储配置 {{name}}', + }, + form: { + addTitle: '添加存储配置', + editTitle: '编辑存储配置', + name: '名称 *', + storageType: '存储类型', + endpoint: '访问地址', + bucket: 'Bucket *', + region: '区域', + secretPlaceholder: '请输入 Secret Key', + secretPlaceholderSaved: '已保存 · 留空则不修改', + insecure: '不加密 (HTTP)', + setDefault: '设为默认', + autoCreate: '测试时桶不存在则自动创建', + test: '测试连接', + testing: '测试中...', + add: '添加存储', + adding: '添加中...', + save: '保存修改', + saving: '保存中...', + testHintSaved: '请先填写 Bucket 与 Access Key(Secret Key 已保存,留空即可)', + testHintMissing: '请先填写 Bucket 与 Access Key / Secret Key', + errSubmit: '创建存储配置失败', + errTest: '测试连接失败', + // zod 校验消息(schema 工厂注入 t) + nameRequired: '请输入存储配置名称', + bucketRequired: '请填写 Bucket 名称', + }, +} diff --git a/frontend/src/i18n/locales/zh/users.ts b/frontend/src/i18n/locales/zh/users.ts new file mode 100644 index 0000000..c3c69c2 --- /dev/null +++ b/frontend/src/i18n/locales/zh/users.ts @@ -0,0 +1,85 @@ +// users 命名空间 — 用户管理域中文文案(页头 / 角色与来源徽章 / 创建·重置密码 / LDAP 导入) +export default { + title: '用户管理', + subtitle: '管理平台登录账号(仅管理员)', + create: '创建用户', + ldapImport: '从 LDAP 导入', + emptyTitle: '暂无用户', + emptyDesc: '创建第一个用户账号', + listTitle: '用户列表(共 {{count}} 个)', + currentUser: '(当前登录)', + noNickname: '未设置昵称', + createdAt: '创建于 {{date}}', + resetPassword: '重置密码', + resetDesc: '重置「{{name}}」的密码后,该用户的所有登录会话将立即失效。', + newPassword: '新密码 *', + randomPassword: '随机生成密码', + resetting: '重置中...', + resetDone: '已重置「{{name}}」的密码,其所有会话已失效', + resetFailed: '重置密码失败', + deleteAria: '删除用户 {{name}}', + deleteFailed: '删除用户失败', + cannotDeleteSelf: '不能删除当前登录账号', + confirmDeleteTitle: '确认删除用户', + createFailed: '创建用户失败', + adminOnly: { + title: '需要管理员权限', + desc: '用户管理仅管理员可用,请联系管理员处理账号事宜', + }, + role: { + admin: '管理员', + user: '普通用户', + }, + source: { + ldap: 'LDAP 导入', + local: '本地账号', + }, + form: { + username: '用户名 *', + usernamePlaceholder: '登录用户名', + usernameMin: '用户名至少 2 个字符', + usernameMax: '用户名最多 32 个字符', + nickname: '昵称', + nicknamePlaceholder: '显示昵称(可选)', + password: '初始密码 *', + passwordPlaceholder: '至少 6 位', + passwordMin: '密码至少需要 6 位', + role: '角色 *', + rolePlaceholder: '选择角色', + submitting: '创建中...', + }, + ldap: { + title: '从 LDAP 导入用户', + desc: '来自 LDAP 目录的用户;导入后使用 LDAP 密码登录,角色为普通用户', + added: '新增 {{count}}', + converted: '转为 LDAP {{count}}', + skipped: '跳过 {{count}}', + failedCount: '失败 {{count}}', + continue: '继续导入', + done: '完成', + loading: '加载 LDAP 用户…', + summary: '共 {{total}} 个用户,已选 {{selected}} 个', + errorHintPre: '请到', + errorHintPost: '检查 LDAP 配置与启用状态', + imported: '已导入', + willConvert: '本地同名,将转为 LDAP', + noName: '无姓名', + noEmail: '无邮箱', + empty: '目录下没有用户', + importing: '导入中...', + importCount_other: '导入 {{count}} 个用户', + importFailed: '导入失败', + loadFailed: '加载 LDAP 用户失败', + }, + selectAria: '选择用户 {{name}}', + batch: { + selected: '已选 {{count}} 项', + delete: '批量删除', + clear: '清除选择', + selectAll: '全选', + confirmTitle: '确认批量删除', + confirmDesc: '确定要删除选中的 {{count}} 个用户吗?受保护的账号(自己/最后一个管理员)将被跳过,此操作不可恢复。', + skippedWarn: '{{count}} 项被跳过:{{reasons}}', + failed: '批量删除失败', + }, +} diff --git a/frontend/src/i18n/locales/zh/wizard.ts b/frontend/src/i18n/locales/zh/wizard.ts new file mode 100644 index 0000000..78cbcc2 --- /dev/null +++ b/frontend/src/i18n/locales/zh/wizard.ts @@ -0,0 +1,70 @@ +// wizard 命名空间 — 「部署向导」页面域中文文案。 +// 注册说明:与 collectors 命名空间一致,页面域词典在被页面组件副作用导入时 +// 通过 addResourceBundle 自行挂载(不动 src/i18n/index.ts)。 + +const wizard = { + page: { + title: '部署向导', + description: '四步完成日志平台:环境 → 存储 → 部署 → 采集(步骤可按任意顺序完成)', + loadFailed: '加载向导状态失败', + }, + steps: { + flow: '部署流程', + doneAria: '{{title}} 已完成', + todoAria: '{{title}} 待完成', + env: { title: '环境与目标', desc: 'Kubernetes 集群或裸金属主机' }, + storage: { title: '准备存储', desc: '对象存储或本地磁盘' }, + deploy: { title: '部署 Loki', desc: '日志查询平台内置' }, + collect: { title: '配置采集', desc: '主机文件 / 集群 DaemonSet' }, + }, + // 步骤地图下方的进行提示(按第一个未完成步骤推导) + hint: { + env: '先选择部署环境并接入集群或主机', + storage: '环境已就绪,建议先配置对象存储', + deploy: '目标与存储已就绪,创建 Loki 部署吧', + collect: 'Loki 已运行,添加采集策略开始收集日志', + all: '全部就绪:日志正在收集,可到日志查询页分析', + }, + env: { + k8sTitle: 'Kubernetes 集群', + k8sDesc: '通过 Kubernetes API 部署 Loki StatefulSet,DaemonSet 采集容器日志', + hostTitle: '裸金属主机', + hostDesc: '以主机 Docker 部署 Loki,采集指定日志文件', + ready: '已就绪', + connected: '已接入:{{names}}', + nameSep: '、', + k8sHint: '通过 kubeconfig 或 ServiceAccount Token 接入集群,创建后自动验证连通性', + addCluster: '添加集群', + hostHint: 'SSH 密钥或密码接入主机(用于部署与后续采集器安装)', + addHost: '添加主机', + pickFirst: '↑ 选择部署环境后,此处提供对应的目标接入表单', + }, + storage: { + defaultLabel: '默认存储:', + none: '尚未配置存储 —— 可选本地 MinIO / RustFS,或 AWS / 阿里云对象存储', + manage: '管理存储', + add: '添加存储', + configure: '配置存储', + hint: '推荐对象存储(MinIO / RustFS / S3 / OSS):多副本与数据保留更可靠。添加时可用「测试连接」验证凭证,并自动创建不存在的桶;跳过则使用本地磁盘。', + }, + deploy: { + meta: '{{mode}} · 命名空间 {{namespace}} · {{endpoint}}', + endpointPending: 'endpoint 待部署完成生成', + openGrafana: '打开 Grafana({{url}} · admin/admin)', + openLoki: '打开 Loki', + details: '部署详情', + none: '尚无运行中的 Loki —— 选择 Kubernetes 或主机 Docker 部署', + create: '创建部署', + triggering: '正在触发部署…', + deployTriggered: '已触发「{{name}}」部署,可在部署页查看日志与进度', + }, + collect: { + title: '日志采集策略', + activeCount: '{{count}} 个采集中', + desc: '主机:指定日志文件路径 · 集群:DaemonSet 全量容器日志(部署到关联 Loki 的命名空间)', + add: '添加采集策略', + manage: '管理采集', + }, +} + +export default wizard diff --git a/frontend/src/index.css b/frontend/src/index.css index 5c018ef..569b99c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -36,14 +36,14 @@ --input: 214 25% 84%; --ring: 161 94% 30%; --radius: 0.625rem; - --sidebar-background: 222 47% 11%; - --sidebar-foreground: 215 20% 65%; - --sidebar-primary: 213 94% 72%; - --sidebar-primary-foreground: 222 47% 11%; - --sidebar-accent: 217 33% 17%; - --sidebar-accent-foreground: 210 40% 98%; - --sidebar-border: 217 33% 20%; - --sidebar-ring: 213 94% 72%; + --sidebar-background: 0 0% 100%; + --sidebar-foreground: 222 25% 25%; + --sidebar-primary: 161 94% 30%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 210 40% 94%; + --sidebar-accent-foreground: 222 47% 11%; + --sidebar-border: 214 32% 91%; + --sidebar-ring: 161 94% 30%; } .dark { @@ -74,12 +74,12 @@ --ring: 160 84% 39%; --sidebar-background: 222 47% 8%; --sidebar-foreground: 215 20% 65%; - --sidebar-primary: 213 94% 72%; - --sidebar-primary-foreground: 222 47% 11%; + --sidebar-primary: 160 84% 39%; + --sidebar-primary-foreground: 0 0% 100%; --sidebar-accent: 217 33% 17%; --sidebar-accent-foreground: 210 40% 98%; --sidebar-border: 217 33% 20%; - --sidebar-ring: 213 94% 72%; + --sidebar-ring: 160 84% 39%; } } diff --git a/frontend/src/lib/auth-storage.ts b/frontend/src/lib/auth-storage.ts index 04e8966..649d552 100644 --- a/frontend/src/lib/auth-storage.ts +++ b/frontend/src/lib/auth-storage.ts @@ -1,11 +1,13 @@ // 凭证存储统一出口 — 全项目对 token / 用户信息的读写必须经过这里, // 禁止组件直接操作 localStorage(避免 key 散落与多处重复实现)。 -// key 采用命名空间 opsxloki.auth.*(双端一致性约定,见开发标准 08 章)。 +// key 采用命名空间 lokistack.auth.*(双端一致性约定,见开发标准 08 章)。 import type { User } from '@/types'; -const TOKEN_KEY = 'opsxloki.auth.token'; -const REFRESH_TOKEN_KEY = 'opsxloki.auth.refresh-token'; -const USER_KEY = 'opsxloki.auth.user'; +const TOKEN_KEY = 'lokistack.auth.token'; +const REFRESH_TOKEN_KEY = 'lokistack.auth.refresh-token'; +const USER_KEY = 'lokistack.auth.user'; +// 「记住用户名」不属于凭证,clearAuth() 登出时保留 +const REMEMBER_USERNAME_KEY = 'lokistack.auth.remember-username'; export interface TokenPair { accessToken: string; @@ -51,3 +53,15 @@ export function clearAuth(): void { export function isLoggedIn(): boolean { return getAccessToken() !== null; } + +export function getRememberedUsername(): string | null { + return localStorage.getItem(REMEMBER_USERNAME_KEY); +} + +export function saveRememberedUsername(username: string | null): void { + if (username) { + localStorage.setItem(REMEMBER_USERNAME_KEY, username); + } else { + localStorage.removeItem(REMEMBER_USERNAME_KEY); + } +} diff --git a/frontend/src/lib/theme-schedule.ts b/frontend/src/lib/theme-schedule.ts new file mode 100644 index 0000000..2e2a272 --- /dev/null +++ b/frontend/src/lib/theme-schedule.ts @@ -0,0 +1,79 @@ +// 主题定时切换 — 「定时」模式按所选时区的钟点决定浅/深色(白天浅色、夜间深色)。 +// 存储独立于 next-themes 的主题 key:自动模式下由调度器持续把解析结果写入主题偏好。 +const AUTO_KEY = 'lokistack.theme.auto' +const TZ_KEY = 'lokistack.theme.timezone' +/** 同窗口内通知调度器状态变化(storage 事件不回弹到本窗口) */ +const CHANGE_EVENT = 'lokistack:theme-schedule-change' + +/** 白天区间:[DAY_START_HOUR, DAY_END_HOUR) 为浅色,其余深色 */ +export const DAY_START_HOUR = 7 +export const DAY_END_HOUR = 19 + +export function isThemeAuto(): boolean { + return localStorage.getItem(AUTO_KEY) === '1' +} + +export function setThemeAuto(on: boolean): void { + if (on) localStorage.setItem(AUTO_KEY, '1') + else localStorage.removeItem(AUTO_KEY) + window.dispatchEvent(new Event(CHANGE_EVENT)) +} + +/** 时区偏好;空串 = 跟随浏览器本地时区 */ +export function getThemeTimezone(): string { + return localStorage.getItem(TZ_KEY) ?? '' +} + +export function setThemeTimezone(tz: string): void { + if (tz) localStorage.setItem(TZ_KEY, tz) + else localStorage.removeItem(TZ_KEY) + window.dispatchEvent(new Event(CHANGE_EVENT)) +} + +/** 订阅调度器偏好变化(同窗口;跨窗口由调用方另行监听 storage 事件) */ +export function onThemeScheduleChange(handler: () => void): () => void { + window.addEventListener(CHANGE_EVENT, handler) + return () => window.removeEventListener(CHANGE_EVENT, handler) +} + +/** 取某时区的当前钟点(0-23);en-US + hour12:false 在个别引擎会把午夜返回 "24",取模兜底 */ +export function hourInTimezone(tz: string, now = new Date()): number { + const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', hour12: false } + if (tz) opts.timeZone = tz + const hour = Number(now.toLocaleString('en-US', opts).match(/\d+/)?.[0] ?? now.getHours()) + return hour % 24 +} + +/** 按时区判定此刻应为浅色还是深色 */ +export function resolveScheduledTheme(tz: string, now = new Date()): 'light' | 'dark' { + const hour = hourInTimezone(tz, now) + return hour >= DAY_START_HOUR && hour < DAY_END_HOUR ? 'light' : 'dark' +} + +/** 浏览器支持的 IANA 时区列表;不支持时回退到常用时区 */ +export function listTimezones(): string[] { + try { + const supported = (Intl as unknown as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf + if (typeof supported === 'function') return supported('timeZone') + } catch { + // ignore + } + return [ + 'UTC', + 'Asia/Shanghai', + 'Asia/Hong_Kong', + 'Asia/Taipei', + 'Asia/Tokyo', + 'Asia/Singapore', + 'Asia/Kolkata', + 'Asia/Dubai', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Moscow', + 'America/New_York', + 'America/Chicago', + 'America/Los_Angeles', + 'Australia/Sydney', + ] +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5261a89..6127008 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,16 +3,25 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router' import { ThemeProvider } from 'next-themes' import './index.css' +import '@/i18n' -// 一次性迁移旧 LokiDeploy 时代的 localStorage key → opsxloki.*(保留登录态/主题/折叠/查询历史) +// 一次性迁移历史 localStorage key → lokistack.*(保留登录态/主题/折叠/查询历史)。 +// 「读旧写新」保留一个迁移版本(开发标准 08 章),之后可移除旧 key 条目。 function migrateStorage() { + // 新命名空间的来源按时代从新到旧排列:同时存在时,较近期的偏好优先占位 const pairs: Array<[string, string]> = [ - ['lokideploy.auth.token', 'opsxloki.auth.token'], - ['lokideploy.auth.refresh-token', 'opsxloki.auth.refresh-token'], - ['lokideploy.auth.user', 'opsxloki.auth.user'], - ['lokideploy.theme.mode', 'opsxloki.theme.mode'], - ['lokideploy.layout.collapsed', 'opsxloki.layout.collapsed'], - ['lokideploy_log_query_history', 'opsxloki_log_query_history'], + ['opsxloki.auth.token', 'lokistack.auth.token'], + ['opsxloki.auth.refresh-token', 'lokistack.auth.refresh-token'], + ['opsxloki.auth.user', 'lokistack.auth.user'], + ['opsxloki.auth.remember-username', 'lokistack.auth.remember-username'], + ['opsxloki.theme.mode', 'lokistack.theme.mode'], + ['opsxloki.layout.collapsed', 'lokistack.layout.collapsed'], + ['opsxloki.chunk-reloaded', 'lokistack.chunk-reloaded'], + ['lokideploy.auth.token', 'lokistack.auth.token'], + ['lokideploy.auth.refresh-token', 'lokistack.auth.refresh-token'], + ['lokideploy.auth.user', 'lokistack.auth.user'], + ['lokideploy.theme.mode', 'lokistack.theme.mode'], + ['lokideploy.layout.collapsed', 'lokistack.layout.collapsed'], ] for (const [oldKey, newKey] of pairs) { const v = localStorage.getItem(oldKey) @@ -21,6 +30,24 @@ function migrateStorage() { localStorage.removeItem(oldKey) } } + // 按用户 id 拼接的 key(查询历史/时间范围历史)按前缀整体迁移 + const LEGACY_LOGS_PREFIX = 'opsxloki.logs.' + for (let i = localStorage.length - 1; i >= 0; i--) { + const oldKey = localStorage.key(i) + if (!oldKey?.startsWith(LEGACY_LOGS_PREFIX)) continue + const newKey = `lokistack.logs.${oldKey.slice(LEGACY_LOGS_PREFIX.length)}` + if (localStorage.getItem(newKey) === null) { + localStorage.setItem(newKey, localStorage.getItem(oldKey) as string) + } + localStorage.removeItem(oldKey) + } + // 收尾清扫:迁移完成后,残留的旧命名空间 key(多时代并存时的失势重复项)已无人读取,统一移除 + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i) + if (key?.startsWith('opsxloki.') || key?.startsWith('lokideploy.')) { + localStorage.removeItem(key) + } + } } migrateStorage() import App from './App.tsx' @@ -33,8 +60,8 @@ createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/alerts/index.tsx b/frontend/src/pages/alerts/index.tsx index 6c10efa..be738ee 100644 --- a/frontend/src/pages/alerts/index.tsx +++ b/frontend/src/pages/alerts/index.tsx @@ -21,6 +21,7 @@ import { alertsApi, type AlertRule, type AlertDelivery, type NotifyChannel } fro import { logsApi, type LogSource } from '@/api/logs' import { toast } from 'sonner' import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' interface RuleForm { name: string @@ -39,43 +40,37 @@ const DEFAULTS: RuleForm = { } interface AlertTemplate { - label: string - desc: string + key: 'nsError' | 'allNsError' | 'ingress5xx' | 'ingress4xx' | 'fatal' query: string windowMin: number threshold: number cooldownMin: number } -/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口 */ +/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口(label/desc 走 i18n) */ const TEMPLATES: AlertTemplate[] = [ { - label: '业务 NS error', - desc: '指定命名空间(默认 astralx)内 error 日志', + key: 'nsError', query: '{namespace="astralx"} |~ "(?i)error"', windowMin: 5, threshold: 10, cooldownMin: 30, }, { - label: '全 NS error > 1000', - desc: '所有命名空间 error 日志总量超过 1000 条', + key: 'allNsError', query: '{namespace=~".+"} |~ "(?i)error"', windowMin: 15, threshold: 1000, cooldownMin: 60, }, { - label: 'Ingress 5xx', - desc: 'ingress-nginx 5xx(需 JSON access log,平台可一键标准化)', + key: 'ingress5xx', query: '{namespace="ingress-nginx"} | json | status >= 500', windowMin: 5, threshold: 5, cooldownMin: 15, }, { - label: 'Ingress 4xx', - desc: 'ingress-nginx 4xx(需 JSON access log)', + key: 'ingress4xx', query: '{namespace="ingress-nginx"} | json | status >= 400 | status < 500', windowMin: 5, threshold: 50, cooldownMin: 30, }, { - label: 'FATAL', - desc: '所有命名空间出现 FATAL 即告警', + key: 'fatal', query: '{namespace=~".+"} |= "FATAL"', windowMin: 5, threshold: 0, cooldownMin: 15, }, @@ -83,6 +78,7 @@ const TEMPLATES: AlertTemplate[] = [ /** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */ const AlertsPage = memo(function AlertsPage() { + const { t } = useTranslation(['alerts', 'common']) const [rules, setRules] = useState([]) const [sources, setSources] = useState([]) const [channels, setChannels] = useState([]) @@ -106,9 +102,9 @@ const AlertsPage = memo(function AlertsPage() { setDeliveries(Array.isArray(d) ? d : []) setChannels(Array.isArray(ch) ? ch : []) } catch (err) { - toast.error(err instanceof Error ? err.message : '加载告警数据失败') + toast.error(err instanceof Error ? err.message : t('loadFailed')) } - }, []) + }, [t]) useEffect(() => { void refresh() @@ -130,12 +126,12 @@ const AlertsPage = memo(function AlertsPage() { setOpen(true) } - const applyTemplate = (t: AlertTemplate) => { - form.setValue('name', t.label) - form.setValue('query', t.query) - form.setValue('windowMin', String(t.windowMin)) - form.setValue('threshold', String(t.threshold)) - form.setValue('cooldownMin', String(t.cooldownMin)) + const applyTemplate = (tpl: AlertTemplate) => { + form.setValue('name', t(`tpl.${tpl.key}.label`)) + form.setValue('query', tpl.query) + form.setValue('windowMin', String(tpl.windowMin)) + form.setValue('threshold', String(tpl.threshold)) + form.setValue('cooldownMin', String(tpl.cooldownMin)) } const onSubmit = form.handleSubmit(async (v) => { @@ -153,7 +149,7 @@ const AlertsPage = memo(function AlertsPage() { setOpen(false) void refresh() } catch (err) { - toast.error(err instanceof Error ? err.message : '保存失败') + toast.error(err instanceof Error ? err.message : t('saveFailed')) } finally { setSubmitting(false) } @@ -167,7 +163,7 @@ const AlertsPage = memo(function AlertsPage() { setDeleteTarget(null) void refresh() } catch (err) { - toast.error(err instanceof Error ? err.message : '删除失败') + toast.error(err instanceof Error ? err.message : t('deleteFailed')) } finally { setDeleting(false) } @@ -177,10 +173,10 @@ const AlertsPage = memo(function AlertsPage() { setChecking(true) try { await alertsApi.checkNow() - toast.info('已执行一次检查,结果见发送历史') + toast.info(t('checkDone')) void refresh() } catch (err) { - toast.error(err instanceof Error ? err.message : '检查失败') + toast.error(err instanceof Error ? err.message : t('checkFailed')) } finally { setChecking(false) } @@ -191,23 +187,23 @@ const AlertsPage = memo(function AlertsPage() { await alertsApi.updateRule(rule.id, { enabled }) void refresh() } catch (err) { - toast.error(err instanceof Error ? err.message : '切换失败') + toast.error(err instanceof Error ? err.message : t('toggleFailed')) } } return (
} @@ -215,8 +211,8 @@ const AlertsPage = memo(function AlertsPage() { {rules.length === 0 ? ( ) : (
@@ -230,16 +226,33 @@ const AlertsPage = memo(function AlertsPage() {

{rule.name}

{rule.query}

- {src?.name ?? `数据源 ${rule.sourceId}`} → {(rule.channelIds || []).map((id) => channels.find((c) => c.id === id)?.name || `#${id}`).join('、') || '未关联通道'} · {rule.windowMin} 分钟超过 {rule.threshold} 条触发 · 冷却 {rule.cooldownMin} 分钟 - {rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · 上次触发 ${new Date(rule.lastFiredAt).toLocaleString()}`} + {src?.name ?? t('sourceFallback', { id: rule.sourceId })} →{' '} + {(rule.channelIds || []) + .map((id) => channels.find((c) => c.id === id)?.name || `#${id}`) + .join(t('channelSep')) || t('noChannels')}{' '} + · {t('ruleMeta', { + window: rule.windowMin, + threshold: rule.threshold, + cooldown: rule.cooldownMin, + })} + {rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · ${t('lastFired', { + time: new Date(rule.lastFiredAt).toLocaleString(), + })}`}

- ))}
@@ -318,16 +339,16 @@ const AlertsPage = memo(function AlertsPage() {
( - 规则名称 * - + {t('form.name')} + )} /> ( - 数据源 * + {t('form.source')}

- {'常用:{app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"'} + {t('form.queryHint')}

)} /> - ( - - 通知通道(可多选,扇出到多个告警群) + ( + + {t('form.channels')}
- {channels.length === 0 &&

请先在「系统设置 → 告警通知」创建通道

} + {channels.length === 0 &&

{t('form.noChannels')}

} {channels.map((ch) => { const checked = field.value.includes(String(ch.id)) return ( @@ -369,7 +390,7 @@ const AlertsPage = memo(function AlertsPage() { className="accent-primary" /> {ch.name} - {!ch.enabled && (禁用)} + {!ch.enabled && {t('form.channelDisabled')}} ) })} @@ -380,22 +401,22 @@ const AlertsPage = memo(function AlertsPage() {
( - 统计窗口(分) + {t('form.window')} )} /> ( - 触发阈值(条) + {t('form.threshold')} -

0 = 出现即告警

+

{t('form.thresholdHint')}

)} /> ( - 冷却(分) + {t('form.cooldown')} @@ -404,13 +425,13 @@ const AlertsPage = memo(function AlertsPage() { ( - 启用规则 + {t('form.enabled')} )} />
- +
@@ -422,7 +443,7 @@ const AlertsPage = memo(function AlertsPage() { open={deleteTarget !== null} onOpenChange={(o) => { if (!o) setDeleteTarget(null) }} name={deleteTarget?.name} - title="确认删除告警规则" + title={t('confirmDeleteTitle')} loading={deleting} onConfirm={handleDelete} /> diff --git a/frontend/src/pages/clusters/components/ClusterCard.tsx b/frontend/src/pages/clusters/components/ClusterCard.tsx index e7ebc91..d3c0715 100644 --- a/frontend/src/pages/clusters/components/ClusterCard.tsx +++ b/frontend/src/pages/clusters/components/ClusterCard.tsx @@ -1,8 +1,9 @@ import { memo } from 'react' import { Server, RefreshCw, Trash2, Eye, Pencil } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { useTranslation } from 'react-i18next' import { StatusBadge } from '@/components/StatusBadge' import { ResourceCard } from '@/components/ResourceCard' +import { CardActionButton } from '@/components/CardActionButton' import type { Cluster } from '@/types' interface ClusterCardProps { @@ -18,6 +19,7 @@ interface ClusterCardProps { export const ClusterCard = memo(function ClusterCard({ cluster, validating, onViewInfo, onValidate, onDelete, onEdit, }: ClusterCardProps) { + const { t } = useTranslation('clusters') return ( } fields={[ - { label: '接入方式', value: cluster.configType === 'token' ? 'Token' : 'Kubeconfig' }, - ...(cluster.apiServer ? [{ label: 'API Server', value: cluster.apiServer, title: cluster.apiServer }] : []), - { label: '版本', value: cluster.version || '未知' }, - { label: '节点数', value: String(cluster.nodesCount || 0) }, + { label: t('fields.accessType'), value: cluster.configType === 'token' ? 'Token' : 'Kubeconfig' }, + ...(cluster.apiServer ? [{ label: t('fields.apiServer'), value: cluster.apiServer, title: cluster.apiServer }] : []), + { label: t('fields.version'), value: cluster.version || t('fields.unknown') }, + { label: t('fields.nodes'), value: String(cluster.nodesCount || 0) }, ]} actions={ <> - - - - + onEdit(cluster)} /> + onViewInfo(cluster)} /> + onValidate(cluster.id)} + /> + onDelete(cluster)} /> } /> diff --git a/frontend/src/pages/clusters/components/ClusterDetailDialog.tsx b/frontend/src/pages/clusters/components/ClusterDetailDialog.tsx index 312d80c..7756963 100644 --- a/frontend/src/pages/clusters/components/ClusterDetailDialog.tsx +++ b/frontend/src/pages/clusters/components/ClusterDetailDialog.tsx @@ -1,5 +1,6 @@ import { memo, useEffect, useState } from 'react' import { Server } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Dialog, DialogContent, @@ -21,6 +22,7 @@ export const ClusterDetailDialog = memo(function ClusterDetailDialog({ cluster, onClose, }: ClusterDetailDialogProps) { + const { t } = useTranslation('clusters') const [info, setInfo] = useState(null) const [error, setError] = useState(null) @@ -33,12 +35,12 @@ export const ClusterDetailDialog = memo(function ClusterDetailDialog({ if (!cancelled) setInfo(data) }) .catch((err) => { - if (!cancelled) setError(err instanceof Error ? err.message : '加载集群详情失败') + if (!cancelled) setError(err instanceof Error ? err.message : t('detail.loadError')) }) return () => { cancelled = true } - }, [cluster]) + }, [cluster, t]) return ( !open && onClose()}> @@ -64,21 +66,22 @@ export const ClusterDetailDialog = memo(function ClusterDetailDialog({ /** 集群详情内容:版本/上下文/节点/命名空间 */ const ClusterDetail = memo(function ClusterDetail({ info }: { info: ClusterInfo }) { + const { t } = useTranslation('clusters') return (
-

版本

-

{info.version || '未知'}

+

{t('detail.version')}

+

{info.version || t('detail.unknown')}

-

当前上下文

-

{info.currentContext || '默认'}

+

{t('detail.currentContext')}

+

{info.currentContext || t('detail.defaultContext')}

-

节点({info.nodes.length})

+

{t('detail.nodesCount', { count: info.nodes.length })}

{info.nodes.map((node) => (
- {node.status === 'Ready' ? '就绪' : '未就绪'} + {node.status === 'Ready' ? t('detail.nodeReady') : t('detail.nodeNotReady')}
- IP:{node.internalIP} - 系统:{node.os}/{node.arch} - 版本:{node.version} + {t('detail.nodeIp', { value: node.internalIP })} + {t('detail.nodeOs', { os: node.os, arch: node.arch })} + {t('detail.nodeVersion', { version: node.version })}
{node.roles.length > 0 && (
@@ -120,7 +123,7 @@ const ClusterDetail = memo(function ClusterDetail({ info }: { info: ClusterInfo
-

命名空间

+

{t('detail.namespaces')}

{info.namespaces.map((ns) => ( { return m ? m[1] : '' } -// 表单 schema:zod 为唯一校验源,消息中文且具体 -const clusterFormSchema = z - .object({ - name: z.string().min(1, '请输入集群名称'), - description: z.string().optional(), - configType: z.enum(['kubeconfig', 'token']), - kubeconfig: z.string().optional(), - apiServer: z.string().optional(), - token: z.string().optional(), - context: z.string().optional(), - }) - .superRefine((data, ctx) => { - if (EDITING_REF.current) return // 编辑模式:凭证留空=保留,不做必填校验 - if (data.configType === 'kubeconfig' && !data.kubeconfig?.trim()) { - ctx.addIssue({ code: 'custom', path: ['kubeconfig'], message: '请填写 kubeconfig 内容' }) - } - if (data.configType === 'token') { - if (!data.apiServer?.trim()) { - ctx.addIssue({ code: 'custom', path: ['apiServer'], message: '请填写 API Server 地址' }) - } - if (!data.token?.trim()) { - ctx.addIssue({ code: 'custom', path: ['token'], message: '请填写 Token' }) - } - } - }) +/** 添加集群弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */ +const EDITING_REF = { current: false as boolean | null } -type ClusterFormValues = z.infer +// 表单 schema:zod 为唯一校验源,消息随语言(由组件内以 t 构造,语言切换后新校验即时生效) +const makeClusterFormSchema = (t: TFunction<'clusters'>) => + z + .object({ + name: z.string().min(1, t('form.validation.nameRequired')), + description: z.string().optional(), + configType: z.enum(['kubeconfig', 'token']), + kubeconfig: z.string().optional(), + apiServer: z.string().optional(), + token: z.string().optional(), + context: z.string().optional(), + }) + .superRefine((data, ctx) => { + if (EDITING_REF.current) return // 编辑模式:凭证留空=保留,不做必填校验 + if (data.configType === 'kubeconfig' && !data.kubeconfig?.trim()) { + ctx.addIssue({ code: 'custom', path: ['kubeconfig'], message: t('form.validation.kubeconfigRequired') }) + } + if (data.configType === 'token') { + if (!data.apiServer?.trim()) { + ctx.addIssue({ code: 'custom', path: ['apiServer'], message: t('form.validation.apiServerRequired') }) + } + if (!data.token?.trim()) { + ctx.addIssue({ code: 'custom', path: ['token'], message: t('form.validation.tokenRequired') }) + } + } + }) + +type ClusterFormValues = z.infer> const DEFAULT_VALUES: ClusterFormValues = { name: '', @@ -77,19 +83,18 @@ interface ClusterFormDialogProps { editing?: import('@/types').Cluster | null } -/** 添加集群弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */ -const EDITING_REF = { current: false as boolean | null } - export const ClusterFormDialog = memo(function ClusterFormDialog({ open, onOpenChange, onCreated, editing, }: ClusterFormDialogProps) { + const { t } = useTranslation('clusters') const [submitting, setSubmitting] = useState(false) + const schema = useMemo(() => makeClusterFormSchema(t), [t]) const form = useForm({ - resolver: zodResolver(clusterFormSchema), + resolver: zodResolver(schema), defaultValues: DEFAULT_VALUES, }) const configType = form.watch('configType') @@ -131,7 +136,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({ onOpenChange(false) onCreated() } catch (err) { - toast.error(err instanceof Error ? err.message : '创建集群失败') + toast.error(err instanceof Error ? err.message : t('toast.createFailed')) } finally { setSubmitting(false) } @@ -141,7 +146,13 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({ - {editing ? `编辑集群${editing.name ? ` · ${editing.name}` : ''}` : '添加 Kubernetes 集群'} + + {editing + ? editing.name + ? t('form.editTitleWithName', { name: editing.name }) + : t('form.editTitle') + : t('form.addTitle')} +
@@ -150,7 +161,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({ name="name" render={({ field }) => ( - 集群名称 * + {t('form.nameLabel')} @@ -163,9 +174,9 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({ name="description" render={({ field }) => ( - 描述 + {t('form.descriptionLabel')} - + @@ -173,7 +184,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({ /> - 接入方式 + {t('form.accessTypeLabel')} ( - Kubeconfig 内容{editing ? '(留空保持不变)' : ' *'} + + {editing ? t('form.kubeconfigLabelEdit') : t('form.kubeconfigLabel')} +