feat: complete Loki query, insights, settings and deployment workflows

Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application.

Validated with go test ./..., npm run build, and npm run lint.
This commit is contained in:
logan committed 2026-10-25 21:28:32 +08:00
1 parent f9c6c1d826
commit 2d73661dd1
127 files changed
+8117 -2876

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
# Logs
logs
/logs/
*.log
# Backend build artifacts
+7 -7
View File
@@ -6,7 +6,7 @@
## 项目概览
opsx-loki 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana
Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。
Loki 日志聚合系统部署到 Kubernetes 集群(通过 client-go 直连 Kubernetes API Server)或裸金属 Linux 主机(通过 SSH)。
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
默认登录账号: `admin` / `admin123`(首次启动时由 `backend/seed/seed.go` 写入数据库)。
@@ -68,7 +68,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
用 `models.SanitizedJSON(...)` 包装返回结果。
- `services/` — 业务逻辑层。部署流水线就在这里:
`DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按部署模式
(`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。
(`k8s-yaml` / `host-binary` / `host-docker`)分发,Kubernetes 操作由 client-go 完成,主机操作通过 SSH。
其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`,
以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。
- `middleware/jwt.go` — `JWTAuth()`(解析后对照 JsonDB 校验 `TokenVersion`,并以数据库最新
@@ -130,12 +130,12 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
- 路由单源:`src/router/routes.tsx`(meta: title/icon/order)驱动菜单、顶栏标题与
`document.title`;守卫 `RequireAuth` 带来源路径回跳;404 走 `pages/not-found`。
- 部署向导(`/wizard`):状态推导式步骤地图(preflight 端点聚合各资源状态),四步:环境与
目标 → 存储 → Loki(+Grafana)→ 采集;k8s 资源统一默认 `log` 命名空间(可改)。
Grafana 为**可选**(默认关闭):k8s 用 NodePort(自动分配)+数据源 ConfigMap,主机用 tarball+systemd;平台已内置日志查询,通常无需 Grafana。
目标 → 存储 → Loki→ 采集;k8s 资源统一默认 `log` 命名空间(可改)。
Grafana 为**可选**(默认关闭):Grafana 自动部署已移除,平台内置日志查询。
- **LDAP 接入**(参照 opsx-web-lite):设置页配置(SystemSetting 表 JSON,Bind 密码 AES 加密、
对外只回 `hasPassword`);启用后 ①登录支持 LDAP 用户验证并自动建号(authSource=ldap)②用户页
出现「从 LDAP 导入」(已导入置灰;本地同名转 LDAP);`/settings/ldap/{test,users}` 端点。
- **日志查询**(`/logs`):数据源 `LogSources`(cluster-svc=经 kubectl --raw service proxy 查集群内
- **日志查询**(`/logs`):数据源 `LogSources`(cluster-svc=经 client-go 认证连接 API Server Service Proxy 查集群内
Loki,不依赖 ClusterIP/DNS;url=直连;部署成功自动注册);`/logs/{query,labels,label/*/values,histogram}`
代理端点(cursor 翻页);前端 LogQL 高亮补全 + 直方图 + 标签点击过滤 + Live Tail(3s 轮询)。
- 用户管理页(`/users`,meta `adminOnly: true` — 菜单按角色过滤,后端 AdminAuth 兜底);
@@ -151,8 +151,8 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
在 Settings「关于」展示。
## Loki 部署相关细节
- 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig);
- 合法的部署模式:`k8s-yaml`、`host-binary`、`host-docker`。K8s 模式需要一个 Cluster(kubeconfig);
`host-binary` 需要一个 SSH Host,会安装 Loki + systemd。
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
见 `services/deployment.go` 中的 `ValidateLokiVersion`。
- `kubectl`/`helm` 在 Docker 镜像中安装时做了 SHA256 校验(见 `Dockerfile`)。
- Docker 镜像无需安装 `kubectl` 或 `helm`。
+4 -4
View File
@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概览
opsx-loki 是一个基于 **Gin (Go) + React/TypeScript + JSON 文件存储** 的平台,用于把 Grafana
Loki 日志聚合系统部署到 Kubernetes 集群(通过 `kubectl`/`helm`)或裸金属 Linux 主机(通过 SSH)。
Loki 日志聚合系统部署到 Kubernetes 集群(通过 client-go 直连 Kubernetes API Server)或裸金属 Linux 主机(通过 SSH)。
Go 二进制同时提供 REST API 服务和已构建的前端(SPA)静态资源。
默认登录账号: `admin` / `admin123`(首次启动时由 `backend/seed/seed.go` 写入数据库)。
@@ -67,7 +67,7 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
用 `models.SanitizedJSON(...)` 包装返回结果。
- `services/` — 业务逻辑层。部署流水线就在这里:
`DeploymentService.Deploy()` → `LokiDeployer.Deploy()`,按部署模式
(`k8s-helm` / `k8s-yaml` / `host-binary`)分发,实际通过 shell 调用 `kubectl`/`helm` 或 SSH。
(`k8s-yaml` / `host-binary` / `host-docker`)分发,Kubernetes 操作由 client-go 完成,主机操作通过 SSH。
其它 service:`K8sClient`、`ssh`(+ `ssh_errors`)、`storage`、`cluster_info`,
以及较新的 **collector(日志采集器)** 模块(`collector*.go` — 部署客户端日志采集器)。
- `middleware/jwt.go` — `JWTAuth()`(解析后对照 JsonDB 校验 `TokenVersion`,并以数据库最新
@@ -147,8 +147,8 @@ docker compose -f docker-compose.prod.yml up -d # 生产环境(读取 ./secret
在 Settings「关于」展示。
## Loki 部署相关细节
- 合法的部署模式:`k8s-helm`、`k8s-yaml`、`host-binary`。K8s 模式需要一个 Cluster(kubeconfig);
- 合法的部署模式:`k8s-yaml`、`host-binary`、`host-docker`。K8s 模式需要一个 Cluster(kubeconfig);
`host-binary` 需要一个 SSH Host,会安装 Loki + systemd。
- Loki 版本会对照 `services.LokiVersionList` 校验(最低支持主版本:2) —
见 `services/deployment.go` 中的 `ValidateLokiVersion`。
- `kubectl`/`helm` 在 Docker 镜像中安装时做了 SHA256 校验(见 `Dockerfile`)。
- Docker 镜像无需安装 `kubectl` 或 `helm`。
-54
View File
@@ -55,67 +55,14 @@ FROM alpine:3.20
# Install runtime dependencies:
# ca-certificates: HTTPS TLS verification
# openssh-client: SSH key auth for host deployment
# curl: kubectl/helm download + runtime checks
# bash: Entrypoint script support
# wget: HEALTHCHECK (lighter than curl)
RUN apk --no-cache add \
ca-certificates \
openssh-client \
curl \
bash \
wget
ARG TARGETARCH=amd64
# ---- Install kubectl with checksum verification ----
# P1: curl downloads must have integrity verification
ENV KUBECTL_VERSION=v1.31.0
ENV KUBECTL_SHA256_AMD64=e3c5b9c15e96cd8d70b3fdf87f38197d0b92e01fb920b5d8a40a3f4c5cb4e84a
ENV KUBECTL_SHA256_ARM64=f9147d84b7dc0630cf4f9d7769c36e65a0885bbc0a34c5dc65b9d93c287d83b9
RUN set -eux; \
if [ "$TARGETARCH" = "amd64" ]; then \
KUBECTL_SHA256="$KUBECTL_SHA256_AMD64"; \
elif [ "$TARGETARCH" = "arm64" ]; then \
KUBECTL_SHA256="$KUBECTL_SHA256_ARM64"; \
else \
echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1; \
fi; \
curl -fsSL -o /tmp/kubectl \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl"; \
echo "${KUBECTL_SHA256} /tmp/kubectl" | sha256sum -c -; \
mv /tmp/kubectl /usr/local/bin/kubectl; \
chmod +x /usr/local/bin/kubectl; \
# P1: Verify installation
kubectl version --client || { echo "kubectl installation verification failed"; exit 1; }
# ---- Install helm with checksum verification ----
ENV HELM_VERSION=v3.16.3
# Helm checksums from official releases (https://github.com/helm/helm/releases)
# helm-v3.16.3-linux-amd64.tar.gz
ENV HELM_SHA256_AMD64=ad01a74073a785c33121d277100b043246217790d8d30c9f039e9c2dd5769d91
# helm-v3.16.3-linux-arm64.tar.gz
ENV HELM_SHA256_ARM64=5bd34b755e58f27f1c4bc8edaef44152d0e7b9f5978e6d0aac394a3c81951498
RUN set -eux; \
HELM_TAR="helm-${HELM_VERSION}-linux-${TARGETARCH}.tar.gz"; \
if [ "$TARGETARCH" = "amd64" ]; then \
HELM_SHA256="$HELM_SHA256_AMD64"; \
elif [ "$TARGETARCH" = "arm64" ]; then \
HELM_SHA256="$HELM_SHA256_ARM64"; \
else \
echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1; \
fi; \
curl -fsSL -o "/tmp/${HELM_TAR}" \
"https://get.helm.sh/${HELM_TAR}"; \
# P1: SHA256 integrity verification
echo "${HELM_SHA256} /tmp/${HELM_TAR}" | sha256sum -c -; \
mkdir -p /tmp/helm; \
tar -xzf "/tmp/${HELM_TAR}" -C /tmp/helm; \
mv "/tmp/helm/linux-${TARGETARCH}/helm" /usr/local/bin/helm; \
chmod +x /usr/local/bin/helm; \
rm -rf /tmp/helm*; \
# P1: Verify installation
helm version --client || { echo "helm installation verification failed"; exit 1; }
# ---------------------------------------------------------------
# P0: Create non-root user and group
# ---------------------------------------------------------------
@@ -149,7 +96,6 @@ RUN rm -rf /tmp/* /var/cache/apk/*
ENV DATA_DIR=/app/data
ENV SERVER_PORT=8080
ENV KUBECONFIG=/home/opsxloki/.kube/config
# HOME must be set for kubectl/helm to find config
ENV HOME=/home/opsxloki
# NOTE: JWT_SECRET and ENCRYPTION_KEY are NOT set here.
+283
View File
@@ -0,0 +1,283 @@
package controllers
import (
"strconv"
"time"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/repository"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
// AlertsController 告警规则 + 飞书通知配置(参照 opsx-sec 通知 API 设计)
type AlertsController struct{}
func NewAlertsController() *AlertsController { return &AlertsController{} }
// ==================== 通知配置 ====================
// GetNotifyConfig 通知配置(只回 enabled/hasWebhook/hasSecret)
func (ctrl *AlertsController) GetNotifyConfig(c *gin.Context) {
cfg := services.GetNotifyConfig()
utils.Success(c, services.NotifyPublicConfig{
Enabled: cfg.Enabled, HasWebhook: cfg.Webhook != "", HasSecret: cfg.SigningSecret != "",
})
}
// UpdateNotifyConfig 保存(webhook/签名留空=保留旧值)
func (ctrl *AlertsController) UpdateNotifyConfig(c *gin.Context) {
var req dto.NotifyConfigRequest
if !utils.BindAndValidate(c, &req) {
return
}
if err := services.SaveNotifyConfig(req.Enabled, req.Webhook, req.SigningSecret); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "saved"})
}
// TestNotifyConfig 发送测试卡片(验证 webhook 连通)
func (ctrl *AlertsController) TestNotifyConfig(c *gin.Context) {
cfg := services.GetNotifyConfig()
if !cfg.Enabled || cfg.Webhook == "" {
utils.Error(c, 400, "请先保存并启用飞书通知")
return
}
if err := services.SendLarkCard("✅ opsx-loki 通知测试", "green", []string{
"**时间**: " + time.Now().Format("2006-01-02 15:04:05"),
"**说明**: 管理员在设置页手动触发的连通性测试。",
}); err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, gin.H{"status": "sent"})
}
// ==================== 告警规则 CRUD ====================
func (ctrl *AlertsController) ListRules(c *gin.Context) {
db := repository.GetDB()
db.RLock()
out := make([]map[string]interface{}, 0, len(db.AlertRules))
for i := range db.AlertRules {
out = append(out, db.AlertRules[i].Sanitized())
}
db.RUnlock()
utils.Success(c, out)
}
func (ctrl *AlertsController) CreateRule(c *gin.Context) {
var req dto.AlertRuleRequest
if !utils.BindAndValidate(c, &req) {
return
}
if req.WindowMin <= 0 {
req.WindowMin = 15
}
if req.CooldownMin <= 0 {
req.CooldownMin = 60
}
if req.Threshold < 0 {
req.Threshold = 0
}
db := repository.GetDB()
rule := &models.AlertRule{
ID: db.NextID("alertRules"), Name: req.Name, SourceID: req.SourceID,
ChannelIDs: req.ChannelIDs,
Query: req.Query, WindowMin: req.WindowMin, Threshold: req.Threshold,
Enabled: req.Enabled, CooldownMin: req.CooldownMin,
}
db.Lock()
db.AlertRules = append(db.AlertRules, *rule)
db.RequestSave()
db.Unlock()
utils.Success(c, rule.Sanitized())
}
func (ctrl *AlertsController) UpdateRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var req map[string]interface{}
if !utils.BindAndValidate(c, &req) {
return
}
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.AlertRules {
if db.AlertRules[i].ID != uint(id) {
continue
}
if v, ok := req["name"].(string); ok && v != "" {
db.AlertRules[i].Name = v
}
if v, ok := req["query"].(string); ok && v != "" {
db.AlertRules[i].Query = v
}
if v, ok := req["sourceId"].(float64); ok && v > 0 {
db.AlertRules[i].SourceID = uint(v)
}
if arr, ok := req["channelIds"].([]interface{}); ok {
ids := make([]uint, 0, len(arr))
for _, item := range arr {
if v, ok := item.(float64); ok && v > 0 {
ids = append(ids, uint(v))
}
}
db.AlertRules[i].ChannelIDs = ids
}
if v, ok := req["windowMin"].(float64); ok && v > 0 {
db.AlertRules[i].WindowMin = int(v)
}
if v, ok := req["threshold"].(float64); ok && v >= 0 {
db.AlertRules[i].Threshold = int(v)
}
if v, ok := req["cooldownMin"].(float64); ok && v > 0 {
db.AlertRules[i].CooldownMin = int(v)
}
if v, ok := req["enabled"].(bool); ok {
db.AlertRules[i].Enabled = v
}
db.AlertRules[i].UpdatedAt = time.Now()
db.RequestSave()
utils.Success(c, db.AlertRules[i].Sanitized())
return
}
utils.NotFound(c, "规则不存在")
}
func (ctrl *AlertsController) DeleteRule(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.AlertRules {
if db.AlertRules[i].ID == uint(id) {
db.AlertRules = append(db.AlertRules[:i], db.AlertRules[i+1:]...)
db.RequestSave()
utils.Success(c, gin.H{"message": "deleted"})
return
}
}
utils.NotFound(c, "规则不存在")
}
// CheckNow 手动触发一次全量检查(不等定时器)
func (ctrl *AlertsController) CheckNow(c *gin.Context) {
services.RunAlertCheck(c.Request.Context())
utils.Success(c, gin.H{"message": "checked"})
}
// Deliveries 发送历史(最近 50)
func (ctrl *AlertsController) Deliveries(c *gin.Context) {
db := repository.GetDB()
db.RLock()
out := make([]map[string]interface{}, 0, len(db.AlertDeliveries))
for i := len(db.AlertDeliveries) - 1; i >= 0 && len(out) < 50; i-- {
d := db.AlertDeliveries[i]
out = append(out, map[string]interface{}{
"id": d.ID, "ruleId": d.RuleID, "ruleName": d.RuleName,
"count": d.Count, "status": d.Status, "reason": d.Reason, "createdAt": d.CreatedAt,
})
}
db.RUnlock()
utils.Success(c, out)
}
// ==================== 通知通道(多 webhook) ====================
func (ctrl *AlertsController) ListChannels(c *gin.Context) {
utils.Success(c, services.ListNotifyChannels())
}
func (ctrl *AlertsController) SaveChannel(c *gin.Context) {
var req dto.NotifyChannelRequest
if !utils.BindAndValidate(c, &req) {
return
}
// PUT /channels/:id 时路径参数优先
if pathID := c.Param("id"); pathID != "" {
if v, err := strconv.ParseUint(pathID, 10, 64); err == nil {
req.ID = uint(v)
}
}
ch, err := services.SaveNotifyChannel(req.ID, req.Name, req.Webhook, req.SigningSecret, req.Enabled)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, ch.Sanitized())
}
func (ctrl *AlertsController) DeleteChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := services.DeleteNotifyChannel(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deleted"})
}
// TestChannel 向指定通道发送测试卡片(用表单参数或已存通道)
func (ctrl *AlertsController) TestChannel(c *gin.Context) {
var req dto.NotifyChannelRequest
if !utils.BindAndValidate(c, &req) {
return
}
// 测试已存通道时 name 可不传(沿用原名称);直测新 webhook 时缺省名
name := req.Name
if req.ID > 0 {
if existing := services.GetNotifyChannel(req.ID); existing != nil && name == "" {
name = existing.Name
}
} else if name == "" {
name = "临时测试通道"
}
var channelID uint
if req.ID > 0 {
channelID = req.ID
// 表单 webhook 非空时先保存再测(未保存也可测新地址)
if req.Webhook != "" {
if _, err := services.SaveNotifyChannel(req.ID, name, req.Webhook, req.SigningSecret, true); err != nil {
utils.Error(c, 400, err.Error())
return
}
}
} else if req.Webhook != "" {
// 未保存直测:保存后可删
ch, err := services.SaveNotifyChannel(0, name, req.Webhook, req.SigningSecret, true)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
channelID = ch.ID
}
if channelID == 0 {
utils.BadRequest(c, "请提供通道 ID 或 webhook 地址")
return
}
if err := services.SendLarkCardToChannel(channelID, "✅ opsx-loki 通道测试", "green", []string{
"**时间**: " + time.Now().Format("2006-01-02 15:04:05"),
"**说明**: 通道连通性测试。",
}); err != nil {
utils.Error(c, 502, err.Error())
return
}
utils.Success(c, gin.H{"status": "sent"})
}
+44
View File
@@ -1,14 +1,58 @@
package controllers
import (
"context"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/services"
"opsxloki/utils"
"time"
"github.com/gin-gonic/gin"
)
func (ctrl *ClusterController) Namespaces(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
cluster, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "集群不存在")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 12*time.Second)
defer cancel()
items, err := services.ListClusterNamespaces(ctx, cluster)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"namespaces": items})
}
func (ctrl *ClusterController) Deployments(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
cluster, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "集群不存在")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 12*time.Second)
defer cancel()
items, err := services.ListClusterDeployments(ctx, cluster, c.Param("namespace"))
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"deployments": items})
}
type ClusterController struct {
service *services.ClusterService
}
+39 -1
View File
@@ -2,11 +2,11 @@ package controllers
import (
"context"
"time"
"opsxloki/dto"
"opsxloki/models"
"opsxloki/services"
"opsxloki/utils"
"time"
"github.com/gin-gonic/gin"
)
@@ -31,6 +31,8 @@ func (ctrl *DeploymentController) Create(c *gin.Context) {
Mode: req.Mode,
ClusterID: req.ClusterID,
HostID: req.HostID,
ListenIP: req.ListenIP,
ListenPort: req.ListenPort,
Namespace: req.Namespace,
StorageConfigID: req.StorageConfigID,
AuthEnabled: req.AuthEnabled,
@@ -120,6 +122,42 @@ func (ctrl *DeploymentController) Update(c *gin.Context) {
utils.Success(c, gin.H{"message": "updated"})
}
// UpdateRetention 校验 YAML 片段,保存部署配置,并可立即重部署。
func (ctrl *DeploymentController) UpdateRetention(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var req dto.UpdateRetentionRequest
if !utils.BindAndValidate(c, &req) {
return
}
days, err := ctrl.service.UpdateRetention(uint(id), req.YAML, req.Apply)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"retentionDays": days, "applying": req.Apply})
}
// GetLiveConfig 返回运行中的完整 Loki YAML,服务层会先遮盖凭证。
func (ctrl *DeploymentController) GetLiveConfig(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
result, err := ctrl.service.GetLiveConfig(ctx, uint(id))
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, result)
}
func (ctrl *DeploymentController) Delete(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
+30
View File
@@ -0,0 +1,30 @@
package controllers
import (
"opsxloki/dto"
"opsxloki/services"
"opsxloki/utils"
"github.com/gin-gonic/gin"
)
// ImageRegistryController 镜像源设置(国内/海外/私有仓库)
type ImageRegistryController struct{}
func NewImageRegistryController() *ImageRegistryController { return &ImageRegistryController{} }
func (ctrl *ImageRegistryController) Get(c *gin.Context) {
utils.Success(c, services.ImageRegistryPublicConfig())
}
func (ctrl *ImageRegistryController) Save(c *gin.Context) {
var req dto.ImageRegistryRequest
if !utils.BindAndValidate(c, &req) {
return
}
if err := services.SaveImageRegistry(req.Mode, req.CustomHost); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, services.ImageRegistryPublicConfig())
}
+15 -12
View File
@@ -2,7 +2,6 @@ package controllers
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
@@ -30,27 +29,23 @@ func (ctrl *LogsController) Context(c *gin.Context) {
utils.BadRequest(c, "时间戳无效")
return
}
parts := make([]string, 0, len(req.Labels))
for key, value := range req.Labels {
if !services.ValidLogLabelName(key) {
utils.BadRequest(c, "标签名无效")
return
}
encoded, _ := json.Marshal(value)
parts = append(parts, key+"="+string(encoded))
selector, err := services.ContextLogSelector(req.Labels)
if err != nil {
utils.BadRequest(c, err.Error())
return
}
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", "{"+strings.Join(parts, ",")+"}")
query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", selector)
if err != nil {
utils.Forbidden(c, err.Error())
return
}
span := int64(5 * time.Minute)
before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, 1, "backward", "")
before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, 5, "backward", "")
if err != nil {
utils.Error(c, 502, err.Error())
return
}
after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, 1, "forward", "")
after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, 5, "forward", "")
if err != nil {
utils.Error(c, 502, err.Error())
return
@@ -118,6 +113,10 @@ func (ctrl *LogsController) UpdateSource(c *gin.Context) {
if !utils.BindAndValidate(c, &updates) {
return
}
if src := services.GetLogSource(uint(id)); src != nil && src.Managed && updates["isDefault"] == nil {
utils.BadRequest(c, "平台部署的数据源由系统管理,不可编辑")
return
}
if err := services.UpdateLogSource(uint(id), updates); err != nil {
utils.Error(c, 400, err.Error())
return
@@ -131,6 +130,10 @@ func (ctrl *LogsController) DeleteSource(c *gin.Context) {
utils.BadRequest(c, "ID 无效")
return
}
if src := services.GetLogSource(uint(id)); src != nil && src.Managed {
utils.BadRequest(c, "平台部署的数据源随部署管理,请先删除对应部署")
return
}
if err := services.DeleteLogSource(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
+7 -7
View File
@@ -104,13 +104,13 @@ func (ctrl *StorageController) TestConnection(c *gin.Context) {
return
}
result := services.TestStorageConnection(services.StorageTestParams{
StorageType: req.StorageType,
Endpoint: req.Endpoint,
Bucket: req.Bucket,
Region: req.Region,
AccessKey: req.AccessKey,
SecretKey: req.SecretKey,
Insecure: req.Insecure,
StorageType: req.StorageType,
Endpoint: req.Endpoint,
Bucket: req.Bucket,
Region: req.Region,
AccessKey: req.AccessKey,
SecretKey: req.SecretKey,
Insecure: req.Insecure,
CreateIfMissing: req.CreateIfMissing,
})
utils.Success(c, result)
+27
View File
@@ -0,0 +1,27 @@
package dto
// 通知与告警域
type NotifyConfigRequest struct {
Enabled bool `json:"enabled"`
Webhook string `json:"webhook" binding:"omitempty,url"`
SigningSecret string `json:"signingSecret"`
}
type AlertRuleRequest struct {
Name string `json:"name" binding:"required"`
SourceID uint `json:"sourceId" binding:"required"`
ChannelIDs []uint `json:"channelIds"`
Query string `json:"query" binding:"required"`
WindowMin int `json:"windowMin"`
Threshold int `json:"threshold"`
Enabled bool `json:"enabled"`
CooldownMin int `json:"cooldownMin"`
}
type NotifyChannelRequest struct {
ID uint `json:"id"`
Name string `json:"name"`
Webhook string `json:"webhook" binding:"omitempty,url"`
SigningSecret string `json:"signingSecret"`
Enabled bool `json:"enabled"`
}
+9 -1
View File
@@ -5,9 +5,11 @@ type CreateDeploymentRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Version string `json:"version"`
Mode string `json:"mode" binding:"required,oneof=k8s-helm k8s-yaml host-binary"`
Mode string `json:"mode" binding:"required,oneof=k8s-yaml host-binary host-docker"`
ClusterID *uint `json:"clusterID"`
HostID *uint `json:"hostID"`
ListenIP string `json:"listenIP"`
ListenPort int `json:"listenPort"`
Namespace string `json:"namespace"`
StorageConfigID uint `json:"storageConfigID"`
AuthEnabled bool `json:"authEnabled"`
@@ -19,3 +21,9 @@ type CreateDeploymentRequest struct {
GrafanaEnabled bool `json:"grafanaEnabled"`
GrafanaVersion string `json:"grafanaVersion"`
}
// UpdateRetentionRequest 编辑 Loki limits_config 中的保留策略片段。
type UpdateRetentionRequest struct {
YAML string `json:"yaml" binding:"required"`
Apply bool `json:"apply"`
}
+6
View File
@@ -0,0 +1,6 @@
package dto
type ImageRegistryRequest struct {
Mode string `json:"mode" binding:"required,oneof=overseas cn custom"`
CustomHost string `json:"customHost"`
}
+36 -3
View File
@@ -11,6 +11,10 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.47.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.33.4
k8s.io/apimachinery v0.33.4
k8s.io/client-go v0.33.4
)
require (
@@ -20,28 +24,57 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
sigs.k8s.io/yaml v1.5.0 // indirect
)
+123 -7
View File
@@ -2,6 +2,8 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -16,6 +18,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
@@ -28,6 +34,16 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -36,15 +52,26 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -59,67 +86,156 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.33.4 h1:oTzrFVNPXBjMu0IlpA2eDDIU49jsuEorGHB4cvKupkk=
k8s.io/api v0.33.4/go.mod h1:VHQZ4cuxQ9sCUMESJV5+Fe8bGnqAARZ08tSTdHWfeAc=
k8s.io/apimachinery v0.33.4 h1:SOf/JW33TP0eppJMkIgQ+L6atlDiP/090oaX0y9pd9s=
k8s.io/apimachinery v0.33.4/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/client-go v0.33.4 h1:TNH+CSu8EmXfitntjUPwaKVPN0AYMbc9F1bBS8/ABpw=
k8s.io/client-go v0.33.4/go.mod h1:LsA0+hBG2DPwovjd931L/AoaezMPX9CmBgyVyBZmbCY=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ=
sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=
+5 -1
View File
@@ -13,6 +13,7 @@ import (
"opsxloki/repository"
"opsxloki/routers"
"opsxloki/seed"
"opsxloki/services"
)
func main() {
@@ -30,12 +31,15 @@ func main() {
// 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。
repository.GetDB().RecoverOrphanDeployments()
services.StartAlertEngine()
defer services.StopAlertEngine()
r := routers.SetupRouter()
srv := &http.Server{
Addr: ":" + cfg.ServerPort,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
WriteTimeout: 130 * time.Second,
IdleTimeout: 120 * time.Second,
}
+42
View File
@@ -0,0 +1,42 @@
package models
import "time"
// AlertRule 日志告警规则:对选定数据源周期执行 LogQL 计数,超过阈值即通知。
// 参照运维侧 app-alert.py 的两类模式:
//
// critical(出现即报,threshold=1)/ surge(突增才报,自定义阈值),统一收敛为「N 条 / 窗口 > 阈值」。
type AlertRule struct {
ID uint `json:"id"`
Name string `json:"name"`
SourceID uint `json:"sourceId"` // LogSource
ChannelIDs []uint `json:"channelIds"` // NotifyChannel 列表(告警扇出到多个群)
Query string `json:"query"` // 完整 LogQL({app="x"} |= "FATAL")
WindowMin int `json:"windowMin"` // 统计窗口(分钟)
Threshold int `json:"threshold"` // 窗口内条数 > 阈值触发
Enabled bool `json:"enabled"`
CooldownMin int `json:"cooldownMin"` // 同规则告警冷却(分钟,默认 60)
LastFiredAt time.Time `json:"lastFiredAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (r *AlertRule) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": r.ID, "name": r.Name, "sourceId": r.SourceID, "channelIds": r.ChannelIDs,
"query": r.Query, "windowMin": r.WindowMin, "threshold": r.Threshold,
"enabled": r.Enabled, "cooldownMin": r.CooldownMin,
"lastFiredAt": r.LastFiredAt, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
}
}
// AlertDelivery 告警发送历史(飞书 webhook 投递记录)
type AlertDelivery struct {
ID uint `json:"id"`
RuleID uint `json:"ruleId"`
RuleName string `json:"ruleName"`
Count int `json:"count"` // 窗口内命中条数
Status string `json:"status"` // sent | failed | cooldown-skipped
Reason string `json:"reason"`
CreatedAt time.Time `json:"createdAt"`
}
+4
View File
@@ -17,6 +17,8 @@ type LokiDeployment struct {
Cluster *Cluster `json:"cluster,omitempty"`
HostID *uint `json:"hostID,omitempty"`
Host *Host `json:"host,omitempty"`
ListenIP string `json:"listenIP,omitempty"`
ListenPort int `json:"listenPort,omitempty"`
Namespace string `json:"namespace"`
StorageConfigID uint `json:"storageConfigID"`
StorageConfig *StorageConfig `json:"storageConfig,omitempty"`
@@ -58,6 +60,8 @@ func (d *LokiDeployment) Sanitized() map[string]interface{} {
"mode": d.Mode,
"clusterID": d.ClusterID,
"hostID": d.HostID,
"listenIP": d.ListenIP,
"listenPort": d.ListenPort,
"namespace": d.Namespace,
"storageConfigID": d.StorageConfigID,
"authEnabled": d.AuthEnabled,
+2 -1
View File
@@ -24,6 +24,7 @@ type LogSource struct {
Headers map[string]string `json:"headers,omitempty"`
SkipTLSVerify bool `json:"skipTLSVerify"`
IsDefault bool `json:"isDefault"`
Managed bool `json:"managed"` // 平台部署自动注册(固定不可编辑)
Access []LogSourceAccess `json:"access,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -34,7 +35,7 @@ func (s *LogSource) Sanitized() map[string]interface{} {
"id": s.ID, "name": s.Name, "targetType": s.TargetType,
"clusterID": s.ClusterID, "namespace": s.Namespace, "service": s.Service, "port": s.Port,
"url": s.URL, "authType": s.AuthType, "basicUser": s.BasicUser,
"hasBasicPass": s.BasicPass != "", "isDefault": s.IsDefault,
"hasBasicPass": s.BasicPass != "", "isDefault": s.IsDefault, "managed": s.Managed,
"hasBearerToken": s.BearerToken != "", "headerNames": headerNames(s.Headers), "skipTLSVerify": s.SkipTLSVerify,
"access": s.Access,
"createdAt": s.CreatedAt, "updatedAt": s.UpdatedAt,
+23
View File
@@ -0,0 +1,23 @@
package models
import "time"
// NotifyChannel 通知通道:一个飞书/Lark 群机器人 webhook。
// 支持多通道对应不同告警群(如「应用告警群」「Nginx 告警群」),告警规则各自关联。
type NotifyChannel struct {
ID uint `json:"id"`
Name string `json:"name"`
Webhook string `json:"webhook"` // ENC: 加密存储,不外发
SigningSecret string `json:"signingSecret"` // ENC: 可选签名校验
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (c *NotifyChannel) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": c.ID, "name": c.Name,
"hasWebhook": c.Webhook != "", "hasSecret": c.SigningSecret != "",
"enabled": c.Enabled, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
}
}
+12 -11
View File
@@ -20,16 +20,17 @@ type StorageConfig struct {
func (s *StorageConfig) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": s.ID,
"name": s.Name,
"storageType": s.StorageType,
"endpoint": s.Endpoint,
"bucket": s.Bucket,
"region": s.Region,
"pathPrefix": s.PathPrefix,
"insecure": s.Insecure,
"isDefault": s.IsDefault,
"createdAt": s.CreatedAt,
"updatedAt": s.UpdatedAt,
"id": s.ID,
"name": s.Name,
"storageType": s.StorageType,
"endpoint": s.Endpoint,
"bucket": s.Bucket,
"region": s.Region,
"pathPrefix": s.PathPrefix,
"insecure": s.Insecure,
"isDefault": s.IsDefault,
"hasSecretKey": s.SecretKey != "",
"createdAt": s.CreatedAt,
"updatedAt": s.UpdatedAt,
}
}
+6
View File
@@ -31,6 +31,9 @@ type JsonDB struct {
Logs []models.DeploymentLog `json:"logs"`
Settings []models.SystemSetting `json:"settings"`
LogSources []models.LogSource `json:"logSources"`
AlertRules []models.AlertRule `json:"alertRules"`
AlertDeliveries []models.AlertDelivery `json:"alertDeliveries"`
NotifyChannels []models.NotifyChannel `json:"notifyChannels"`
LogPolicyGroups []models.LogPolicyGroup `json:"logPolicyGroups"`
sequences map[string]uint
@@ -63,6 +66,9 @@ func InitDB() error {
Logs: []models.DeploymentLog{},
Settings: []models.SystemSetting{},
LogSources: []models.LogSource{},
AlertRules: []models.AlertRule{},
AlertDeliveries: []models.AlertDelivery{},
NotifyChannels: []models.NotifyChannel{},
LogPolicyGroups: []models.LogPolicyGroup{},
sequences: map[string]uint{
"users": 0,
+29
View File
@@ -34,6 +34,8 @@ func SetupRouter() *gin.Engine {
userCtrl := controllers.NewUserController()
ldapCtrl := controllers.NewLdapController()
logsCtrl := controllers.NewLogsController()
alertsCtrl := controllers.NewAlertsController()
registryCtrl := controllers.NewImageRegistryController()
// API v1
api := r.Group("/api/v1")
@@ -86,6 +88,29 @@ func SetupRouter() *gin.Engine {
logs.GET("/health", logsCtrl.Health)
}
// 镜像源(国内加速/海外/私有仓库)
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)
alertNotify.PUT("/notify-config", middleware.AdminAuth(), alertsCtrl.UpdateNotifyConfig)
alertNotify.POST("/notify-config/test", middleware.AdminAuth(), alertsCtrl.TestNotifyConfig)
alertNotify.GET("/rules", alertsCtrl.ListRules)
alertNotify.POST("/rules", middleware.AdminAuth(), alertsCtrl.CreateRule)
alertNotify.PUT("/rules/:id", middleware.AdminAuth(), alertsCtrl.UpdateRule)
alertNotify.DELETE("/rules/:id", middleware.AdminAuth(), alertsCtrl.DeleteRule)
alertNotify.POST("/check", middleware.AdminAuth(), alertsCtrl.CheckNow)
alertNotify.GET("/deliveries", alertsCtrl.Deliveries)
alertNotify.GET("/channels", alertsCtrl.ListChannels)
alertNotify.POST("/channels", middleware.AdminAuth(), alertsCtrl.SaveChannel)
alertNotify.PUT("/channels/:id", middleware.AdminAuth(), alertsCtrl.SaveChannel)
alertNotify.DELETE("/channels/:id", middleware.AdminAuth(), alertsCtrl.DeleteChannel)
alertNotify.POST("/channels/test", middleware.AdminAuth(), alertsCtrl.TestChannel)
}
// LDAP 配置(admin 读写;查询供用户页判断导入按钮显隐,登录用户即可读)
authorized.GET("/settings/ldap", middleware.AdminAuth(), ldapCtrl.GetConfig)
authorized.PUT("/settings/ldap", middleware.AdminAuth(), ldapCtrl.UpdateConfig)
@@ -113,6 +138,8 @@ func SetupRouter() *gin.Engine {
authorized.DELETE("/clusters/:id", clusterCtrl.Delete)
authorized.POST("/clusters/:id/validate", clusterCtrl.Validate)
authorized.GET("/clusters/:id/info", clusterCtrl.GetInfo)
authorized.GET("/clusters/:id/namespaces", clusterCtrl.Namespaces)
authorized.GET("/clusters/:id/namespaces/:namespace/deployments", clusterCtrl.Deployments)
// Hosts
authorized.GET("/hosts", hostCtrl.List)
@@ -138,6 +165,8 @@ func SetupRouter() *gin.Engine {
authorized.GET("/deployments/:id", deploymentCtrl.Get)
authorized.POST("/deployments", deploymentCtrl.Create)
authorized.PUT("/deployments/:id", deploymentCtrl.Update)
authorized.POST("/deployments/:id/retention", deploymentCtrl.UpdateRetention)
authorized.GET("/deployments/:id/config", middleware.AdminAuth(), deploymentCtrl.GetLiveConfig)
authorized.DELETE("/deployments/:id", deploymentCtrl.Delete)
authorized.POST("/deployments/:id/deploy", deploymentCtrl.Deploy)
authorized.POST("/deployments/:id/stop", deploymentCtrl.Stop)
+83
View File
@@ -0,0 +1,83 @@
package seed
// 内置告警规则 — 新环境开箱即用的通用日志告警(参照运维告警脚本的两类模式):
// 致命类(出现即报):FATAL / panic / OutOfMemoryError / StackOverflowError
// 突增类(超阈值才报):应用 error 过多 / ingress-nginx 4xx / 5xx
// 绑定数据源:部署成功自动注册的 LogSource(managed)。
// 规则默认不启用(通知通道由管理员后期在设置页配置后再开启),ChannelIDs 留空。
import (
"time"
"opsxloki/models"
"opsxloki/repository"
)
type builtinRule struct {
name string
query string
windowMin int
threshold int
cooldown int
}
var builtinRules = []builtinRule{
// —— 致命类:15 分钟内出现即报 ——
{name: "致命日志 FATAL", query: `{namespace=~".+"} |= "FATAL"`, windowMin: 15, threshold: 0, cooldown: 60},
{name: "Go panic", query: `{namespace=~".+"} |= "panic:"`, windowMin: 15, threshold: 0, cooldown: 60},
{name: "JVM OutOfMemoryError", query: `{namespace=~".+"} |~ "(^|[^a-zA-Z])OutOfMemoryError"`, windowMin: 15, threshold: 0, cooldown: 60},
{name: "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: "容器 OOMKilled", query: `{app=~"kube-state-metrics|event-exporter"} |= "OOMKilled"`, windowMin: 15, threshold: 0, cooldown: 120},
// —— 突增类:超过基线阈值才报 ——
{name: "应用错误过多(error > 1000)", query: `{namespace=~".+"} |~ "(?i)\berror\b"`, windowMin: 15, threshold: 1000, cooldown: 60},
{name: "ingress-nginx 5xx 错误", query: `{app=~"ingress-nginx|nginx-ingress-controller"} |~ " (50[0-4]|5[2-9][0-9]) `, windowMin: 15, threshold: 100, cooldown: 60},
{name: "ingress-nginx 4xx 激增", query: `{app=~"ingress-nginx|nginx-ingress-controller"} |~ " 4[0-9][0-9] "`, windowMin: 15, threshold: 1000, cooldown: 60},
{name: "数据库连接失败", query: `{namespace=~".+"} |~ "(?i)(connection refused|connection reset by peer|too many connections)"`, windowMin: 15, threshold: 50, cooldown: 60},
}
// seedBuiltinAlertRules 为每个平台管理的 LogSource(managed=true)补齐内置规则:
// 首次部署 Loki 后即有一套可直接启用的告警模板;通知通道留空,管理员配置后启用。
// SeedAlertRules 补齐内置告警规则(幂等;公开给启动与部署成功两处调用)
func SeedAlertRules() {
db := repository.GetDB()
db.RLock()
type srcRef struct {
id uint
name string
}
var sources []srcRef
for i := range db.LogSources {
if db.LogSources[i].Managed {
sources = append(sources, srcRef{id: db.LogSources[i].ID, name: db.LogSources[i].Name})
}
}
// 已有规则(按 name)跳过
existing := map[string]bool{}
for i := range db.AlertRules {
existing[db.AlertRules[i].Name] = true
}
db.RUnlock()
if len(sources) == 0 {
return
}
// 绑定第一个 managed 数据源(通常是唯一部署)
target := sources[0]
newID := db.NextID("alertRules")
db.Lock()
defer db.Unlock()
for _, r := range builtinRules {
if existing[r.name] {
continue
}
newID++
db.AlertRules = append(db.AlertRules, models.AlertRule{
ID: newID, Name: r.name, SourceID: target.id,
Query: r.query, WindowMin: r.windowMin, Threshold: r.threshold,
Enabled: false, CooldownMin: r.cooldown,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
})
}
db.RequestSave()
}
+3
View File
@@ -21,4 +21,7 @@ func Seed() {
Role: "admin",
})
}
// 内置告警规则:有 managed 数据源(部署过 Loki)时补齐通用模板(默认禁用)
SeedAlertRules()
}
+265
View File
@@ -0,0 +1,265 @@
package services
// 日志告警引擎:周期执行 AlertRule 的 LogQL 计数查询,超阈值发送飞书卡片。
// 设计参照运维 app-alert.py:即时查询 sum(count_over_time(q[窗口])) 精确计数 +
// 按 app/pod 分组定位 + 日志样例 + 告警冷却(同规则冷却期内不重复发)。
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"opsxloki/models"
"opsxloki/repository"
)
const alertCheckInterval = time.Minute
var alertTicker *time.Ticker
// StartAlertEngine 启动周期检查(main 调用)
func StartAlertEngine() {
if alertTicker != nil {
return
}
alertTicker = time.NewTicker(alertCheckInterval)
go func() {
for range alertTicker.C {
RunAlertCheck(context.Background())
}
}()
}
// StopAlertEngine 优雅关停
func StopAlertEngine() {
if alertTicker != nil {
alertTicker.Stop()
alertTicker = nil
}
}
// lokiInstantCount 即时计数:sum(count_over_time(q[window]))
func lokiInstantCount(ctx context.Context, src *models.LogSource, query string, windowMin int) (int, error) {
wrapped := fmt.Sprintf("sum(count_over_time(%s[%dm]))", query, windowMin)
q := url.Values{}
q.Set("query", wrapped)
q.Set("time", fmt.Sprintf("%d", time.Now().UnixNano()))
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query", q)
if err != nil {
return 0, err
}
var resp struct {
Data struct {
Result []struct {
Value [2]interface{} `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return 0, err
}
if len(resp.Data.Result) == 0 {
return 0, nil
}
count := 0
switch v := resp.Data.Result[0].Value[1].(type) {
case string:
fmt.Sscanf(v, "%d", &count)
case float64:
count = int(v)
}
return count, nil
}
// lokiGroupTop 按 app/pod 分组定位(取前 10)
func lokiGroupTop(ctx context.Context, src *models.LogSource, query string, windowMin int) []string {
wrapped := fmt.Sprintf("sum by (app, pod) (count_over_time(%s[%dm]))", query, windowMin)
q := url.Values{}
q.Set("query", wrapped)
q.Set("time", fmt.Sprintf("%d", time.Now().UnixNano()))
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query", q)
if err != nil {
return nil
}
var resp struct {
Data struct {
Result []struct {
Metric map[string]string `json:"metric"`
Value [2]interface{} `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil
}
lines := []string{}
for _, r := range resp.Data.Result {
count := 0
if v, ok := r.Value[1].(string); ok {
fmt.Sscanf(v, "%d", &count)
} else if v, ok := r.Value[1].(float64); ok {
count = int(v)
}
app := r.Metric["app"]
if app == "" {
app = "?"
}
pod := r.Metric["pod"]
if pod == "" {
pod = "?"
}
lines = append(lines, fmt.Sprintf("- %s / %s(%d 条)", app, pod, count))
if len(lines) >= 10 {
break
}
}
return lines
}
// lokiSampleLines 日志样例(最多 3 条,每条截 220)
func lokiSampleLines(ctx context.Context, src *models.LogSource, query string, windowMin int) []string {
res, err := QueryLokiLogs(ctx, src, query, 0, 0, 3, "backward", "")
if err != nil {
return nil
}
out := []string{}
for _, e := range res.Entries {
line := e.Message
if len(line) > 220 {
line = line[:220]
}
out = append(out, "> "+line)
if len(out) >= 3 {
break
}
}
return out
}
func recordDelivery(ruleID uint, ruleName string, count int, status, reason string) {
db := repository.GetDB()
newID := db.NextID("alertDeliveries")
db.Lock()
db.AlertDeliveries = append(db.AlertDeliveries, models.AlertDelivery{
ID: newID, RuleID: ruleID, RuleName: ruleName,
Count: count, Status: status, Reason: reason,
})
// 只保留最近 50 条
if len(db.AlertDeliveries) > 50 {
db.AlertDeliveries = db.AlertDeliveries[len(db.AlertDeliveries)-50:]
}
db.RequestSave()
db.Unlock()
}
// RunAlertCheck 执行一次全部启用规则的检查(定时器与手动触发共用)
func RunAlertCheck(ctx context.Context) {
db := repository.GetDB()
db.RLock()
rules := make([]models.AlertRule, 0, len(db.AlertRules))
for i := range db.AlertRules {
if db.AlertRules[i].Enabled {
rules = append(rules, db.AlertRules[i])
}
}
db.RUnlock()
for i := range rules {
checkRule(ctx, &rules[i])
}
}
func checkRule(ctx context.Context, rule *models.AlertRule) {
src := GetLogSource(rule.SourceID)
if src == nil {
return
}
count, err := lokiInstantCount(ctx, src, rule.Query, rule.WindowMin)
if err != nil {
recordDelivery(rule.ID, rule.Name, 0, "failed", err.Error())
return
}
if count <= rule.Threshold {
return
}
// 冷却检查(同规则)
db := repository.GetDB()
db.RLock()
lastFired := rule.LastFiredAt
cooldown := rule.CooldownMin
if cooldown <= 0 {
cooldown = 60
}
db.RUnlock()
if !lastFired.IsZero() && time.Since(lastFired) < time.Duration(cooldown)*time.Minute {
recordDelivery(rule.ID, rule.Name, count, "cooldown", "冷却中,未重复发送")
return
}
// 组装卡片
groups := lokiGroupTop(ctx, src, rule.Query, rule.WindowMin)
samples := lokiSampleLines(ctx, src, rule.Query, rule.WindowMin)
ts := time.Now().Format("2006-01-02 15:04:05")
lines := []string{
fmt.Sprintf("**告警时间**: %s", ts),
fmt.Sprintf("**规则**: %s", rule.Name),
fmt.Sprintf("**命中数量**: %d 条(%d 分钟内)", count, rule.WindowMin),
fmt.Sprintf("**触发阈值**: > %d 条", rule.Threshold),
fmt.Sprintf("**查询**: `%s`", rule.Query),
}
if len(groups) > 0 {
lines = append(lines, "**来源 app/pod**:\n"+strings.Join(groups, "\n"))
}
if len(samples) > 0 {
lines = append(lines, "**日志样例**:\n"+strings.Join(samples, "\n"))
}
// 扇出到全部关联通道;部分失败记录失败原因(成功任一即更新冷却)
title := "🔴 日志告警 " + rule.Name
var failMsgs []string
if len(rule.ChannelIDs) > 0 {
for _, chID := range rule.ChannelIDs {
if err := SendLarkCardToChannel(chID, title, "red", lines); err != nil {
chName := fmt.Sprintf("通道%d", chID)
if ch := GetNotifyChannel(chID); ch != nil {
chName = ch.Name
}
failMsgs = append(failMsgs, fmt.Sprintf("%s: %v", chName, err))
}
}
} else if cfg := GetNotifyConfig(); cfg.Enabled && cfg.Webhook != "" {
// 兼容:未关联通道回退旧全局配置
if err := SendLarkCard(title, "red", lines); err != nil {
failMsgs = append(failMsgs, err.Error())
}
} else {
failMsgs = append(failMsgs, "规则未关联通知通道")
}
if len(failMsgs) > 0 {
recordDelivery(rule.ID, rule.Name, count, "failed", strings.Join(failMsgs, "; "))
return
}
recordDelivery(rule.ID, rule.Name, count, "sent", fmt.Sprintf("已扇出到 %d 个通道", maxInt(len(rule.ChannelIDs), 1)))
// 更新 LastFiredAt
db.Lock()
for i := range db.AlertRules {
if db.AlertRules[i].ID == rule.ID {
db.AlertRules[i].LastFiredAt = time.Now()
break
}
}
db.RequestSave()
db.Unlock()
recordDelivery(rule.ID, rule.Name, count, "sent", "")
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+8
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"fmt"
"opsxloki/pkg/crypto"
"os"
"path/filepath"
"time"
@@ -143,6 +144,13 @@ func (s *ClusterService) Update(id uint, updates map[string]interface{}) error {
if v, ok := updates["apiServer"].(string); ok {
db.Clusters[i].APIServer = v
}
// kubeconfig 编辑约定:非空才更新(留空=保留旧密文)
if v, ok := updates["kubeconfig"].(string); ok && v != "" {
if enc, err := crypto.EncryptField(v); err == nil {
db.Clusters[i].KubeConfig = enc
db.Clusters[i].KubeConfigPath = ""
}
}
// 凭据字段:空值不覆盖,避免编辑其它项时清空
if v, ok := updates["kubeconfig"].(string); ok && v != "" {
db.Clusters[i].KubeConfig = v
+38 -104
View File
@@ -2,13 +2,13 @@ package services
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/clientcmd"
"opsxloki/models"
"sort"
"strings"
"time"
)
type ClusterInfo struct {
@@ -30,72 +30,21 @@ type NodeInfo struct {
Labels map[string]string `json:"labels"`
}
// GetClusterInfo 通过 kubectl 查询集群信息。
// Token 接入的集群在创建时已构造等价 kubeconfig,因此 kubectl 同样适用。
// GetClusterInfo 从 API Server 读取集群信息,context 列表由 kubeconfig 本地解析。
func GetClusterInfo(ctx context.Context, cluster *models.Cluster) (*ClusterInfo, error) {
kubeconfig, err := resolveKubeconfig(cluster)
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return nil, err
}
kubeFile, err := os.CreateTemp("", "kube-*.yaml")
info := &ClusterInfo{Nodes: []NodeInfo{}, Namespaces: []string{}, Contexts: []string{}}
if version, err := client.Discovery().ServerVersion(); err == nil {
info.Version = version.GitVersion
}
nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
if err != nil {
return nil, err
return nil, fmt.Errorf("获取节点列表失败: %w", err)
}
defer os.Remove(kubeFile.Name())
kubeFile.WriteString(kubeconfig)
kubeFile.Close()
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
info := &ClusterInfo{
Nodes: []NodeInfo{},
Namespaces: []string{},
Contexts: []string{},
}
// Version(best-effort)
if versionOut, err := runCmd(ctx, env, "kubectl", "version", "-o", "json"); err == nil {
var versionData struct {
ServerVersion struct {
GitVersion string `json:"gitVersion"`
} `json:"serverVersion"`
}
json.Unmarshal(versionOut, &versionData)
info.Version = versionData.ServerVersion.GitVersion
}
// Nodes
nodesOut, err := runCmd(ctx, env, "kubectl", "get", "nodes", "-o", "json")
if err != nil {
return nil, fmt.Errorf("获取节点列表失败: %v", err)
}
var nodesData struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
Labels map[string]string `json:"labels"`
} `json:"metadata"`
Status struct {
Conditions []struct {
Type string `json:"type"`
Status string `json:"status"`
} `json:"conditions"`
NodeInfo struct {
KubeletVersion string `json:"kubeletVersion"`
OperatingSystem string `json:"operatingSystem"`
Architecture string `json:"architecture"`
} `json:"nodeInfo"`
Addresses []struct {
Type string `json:"type"`
Address string `json:"address"`
} `json:"addresses"`
} `json:"status"`
} `json:"items"`
}
json.Unmarshal(nodesOut, &nodesData)
for _, item := range nodesData.Items {
for _, item := range nodes.Items {
status := "Unknown"
for _, cond := range item.Status.Conditions {
if cond.Type == "Ready" {
@@ -107,58 +56,43 @@ func GetClusterInfo(ctx context.Context, cluster *models.Cluster) (*ClusterInfo,
break
}
}
var roles []string
for label := range item.Metadata.Labels {
roles := []string{}
for label := range item.Labels {
if strings.HasPrefix(label, "node-role.kubernetes.io/") {
roles = append(roles, label[len("node-role.kubernetes.io/"):])
roles = append(roles, strings.TrimPrefix(label, "node-role.kubernetes.io/"))
}
}
if len(roles) == 0 {
roles = []string{"worker"}
}
internalIP := ""
for _, addr := range item.Status.Addresses {
if addr.Type == "InternalIP" {
internalIP = addr.Address
sort.Strings(roles)
ip := ""
for _, address := range item.Status.Addresses {
if address.Type == "InternalIP" {
ip = address.Address
break
}
}
info.Nodes = append(info.Nodes, NodeInfo{
Name: item.Metadata.Name,
Status: status,
Roles: roles,
Version: item.Status.NodeInfo.KubeletVersion,
OS: item.Status.NodeInfo.OperatingSystem,
Arch: item.Status.NodeInfo.Architecture,
InternalIP: internalIP,
Labels: item.Metadata.Labels,
})
info.Nodes = append(info.Nodes, NodeInfo{Name: item.Name, Status: status, Roles: roles, Version: item.Status.NodeInfo.KubeletVersion, OS: item.Status.NodeInfo.OperatingSystem, Arch: item.Status.NodeInfo.Architecture, InternalIP: ip, Labels: item.Labels})
}
// Namespaces(best-effort)
if nsOut, err := runCmd(ctx, env, "kubectl", "get", "ns", "-o", "jsonpath={.items[*].metadata.name}"); err == nil {
info.Namespaces = strings.Fields(string(nsOut))
if namespaces, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}); err == nil {
for _, item := range namespaces.Items {
info.Namespaces = append(info.Namespaces, item.Name)
}
sort.Strings(info.Namespaces)
}
// Contexts(best-effort)
if ctxOut, err := runCmd(ctx, env, "kubectl", "config", "get-contexts", "-o", "name"); err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(ctxOut)), "\n") {
if line = strings.TrimSpace(line); line != "" {
info.Contexts = append(info.Contexts, line)
source, err := resolveKubeconfig(cluster)
if err == nil {
if raw, err := clientcmd.Load([]byte(source)); err == nil {
for name := range raw.Contexts {
info.Contexts = append(info.Contexts, name)
}
sort.Strings(info.Contexts)
info.CurrentCtx = raw.CurrentContext
if cluster.Context != "" {
info.CurrentCtx = cluster.Context
}
}
}
// Current context(best-effort)
if currentCtxOut, err := runCmd(ctx, env, "kubectl", "config", "current-context"); err == nil {
info.CurrentCtx = strings.TrimSpace(string(currentCtxOut))
}
return info, nil
}
func runCmd(ctx context.Context, env []string, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = env
return cmd.CombinedOutput()
}
+63
View File
@@ -0,0 +1,63 @@
package services
import (
"context"
"fmt"
"regexp"
"sort"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"opsxloki/models"
)
type ClusterDeploymentOption struct {
Name string `json:"name"`
Replicas int `json:"replicas"`
ReadyReplicas int `json:"readyReplicas"`
}
var kubernetesNamespacePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
func ListClusterNamespaces(ctx context.Context, cluster *models.Cluster) ([]string, error) {
client, err := clusterClient(cluster, 10*time.Second)
if err != nil {
return nil, err
}
items, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("读取集群命名空间失败: %w", err)
}
result := make([]string, 0, len(items.Items))
for _, item := range items.Items {
result = append(result, item.Name)
}
sort.Strings(result)
return result, nil
}
func ListClusterDeployments(ctx context.Context, cluster *models.Cluster, namespace string) ([]ClusterDeploymentOption, error) {
if len(namespace) > 63 || !kubernetesNamespacePattern.MatchString(namespace) {
return nil, fmt.Errorf("命名空间名称无效")
}
client, err := clusterClient(cluster, 10*time.Second)
if err != nil {
return nil, err
}
items, err := client.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("读取集群 Deployment 失败: %w", err)
}
result := make([]ClusterDeploymentOption, 0, len(items.Items))
for _, item := range items.Items {
replicas := int32(1)
if item.Spec.Replicas != nil {
replicas = *item.Spec.Replicas
}
result = append(result, ClusterDeploymentOption{
Name: item.Name, Replicas: int(replicas), ReadyReplicas: int(item.Status.ReadyReplicas),
})
}
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result, nil
}
@@ -0,0 +1,38 @@
package services
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"opsxloki/models"
"testing"
)
func TestClusterWorkloadSelectorsUseSelectedNamespace(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/namespaces":
fmt.Fprint(w, `{"apiVersion":"v1","kind":"NamespaceList","items":[{"metadata":{"name":"prod"}},{"metadata":{"name":"default"}}]}`)
case "/apis/apps/v1/namespaces/prod/deployments":
fmt.Fprint(w, `{"apiVersion":"apps/v1","kind":"DeploymentList","items":[{"metadata":{"name":"api"},"spec":{"replicas":3},"status":{"readyReplicas":2}}]}`)
default:
t.Errorf("unexpected Kubernetes API path %s", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
cluster := &models.Cluster{KubeConfig: fmt.Sprintf("apiVersion: v1\nkind: Config\nclusters:\n- name: test\n cluster:\n server: %s\ncontexts:\n- name: test\n context:\n cluster: test\n user: test\ncurrent-context: test\nusers:\n- name: test\n user: {}\n", server.URL)}
namespaces, err := ListClusterNamespaces(context.Background(), cluster)
if err != nil || len(namespaces) != 2 || namespaces[0] != "default" || namespaces[1] != "prod" {
t.Fatalf("unexpected namespaces: %v, %v", namespaces, err)
}
deployments, err := ListClusterDeployments(context.Background(), cluster, "prod")
if err != nil || len(deployments) != 1 || deployments[0].Name != "api" || deployments[0].Replicas != 3 || deployments[0].ReadyReplicas != 2 {
t.Fatalf("unexpected deployments: %v, %v", deployments, err)
}
if _, err := ListClusterDeployments(context.Background(), cluster, "-bad"); err == nil {
t.Fatal("expected invalid namespace to be rejected")
}
}
+3
View File
@@ -201,6 +201,9 @@ func (s *CollectorService) Update(id uint, updates map[string]interface{}) error
db.Collectors[i].StatusMessage = v
}
if v, ok := updates["logPath"].(string); ok {
if v != db.Collectors[i].LogPath {
db.Collectors[i].StatusMessage = "采集路径已保存,点击部署以应用新配置"
}
db.Collectors[i].LogPath = v
}
if v, ok := updates["labels"].(string); ok {
+109 -159
View File
@@ -2,10 +2,11 @@ package services
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"strings"
"time"
"opsxloki/models"
)
@@ -27,28 +28,6 @@ func (d *CollectorDeployer) log(level, message, detail string) {
}
}
// kubeconfigEnv 构造 kubectl/helm 所需的 KUBECONFIG 环境变量,返回 cleanup 删除临时文件
func kubeconfigEnv(cluster *models.Cluster) (env []string, cleanup func(), err error) {
kubeconfig, err := resolveKubeconfig(cluster)
if err != nil {
return nil, nil, err
}
kubeFile, err := os.CreateTemp("", "kube-*.yaml")
if err != nil {
return nil, nil, fmt.Errorf("create kubeconfig temp file failed: %w", err)
}
if _, err := kubeFile.WriteString(kubeconfig); err != nil {
kubeFile.Close()
os.Remove(kubeFile.Name())
return nil, nil, fmt.Errorf("write kubeconfig temp file failed: %w", err)
}
kubeFile.Close()
os.Chmod(kubeFile.Name(), 0600)
env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
cleanup = func() { os.Remove(kubeFile.Name()) }
return env, cleanup, nil
}
// Install 按采集器类型分发安装
func (d *CollectorDeployer) Install(ctx context.Context) error {
endpoint := d.loki.Endpoint
@@ -206,11 +185,6 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin
if cluster == nil {
return fmt.Errorf("集群采集器缺少目标集群")
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
// 默认部署到关联 Loki 的命名空间(统一 log 命名空间策略)
ns := d.collector.Namespace
@@ -226,102 +200,43 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin
clusterName = cluster.Name
}
// 幂等:先清掉同名残留(如历史失败安装留下的无注解资源)
del := exec.CommandContext(ctx, "kubectl", "delete",
"daemonset/promtail", "configmap/promtail", "serviceaccount/promtail",
"-n", ns, "--ignore-not-found=true", "--wait=false")
del.Env = env
del.CombinedOutput()
// 纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet),不依赖后端安装 helm
// 删除历史残留并通过 Kubernetes API 提交资源。
for _, kind := range []string{"daemonset", "configmap", "serviceaccount"} {
_ = deleteK8sResource(ctx, cluster, kind, ns, "promtail")
}
manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels)
if err := d.applyYAML(ctx, env, manifest, "promtail"); err != nil {
if err := d.applyYAML(ctx, nil, manifest, "promtail"); err != nil {
return err
}
d.log("info", fmt.Sprintf("等待 DaemonSet promtail 就绪(命名空间 %s)...", ns), "")
waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "daemonset/promtail",
"-n", ns, "--timeout=5m")
waitCmd.Env = env
waitOut, _ := waitCmd.CombinedOutput()
d.log("info", string(waitOut), "")
// DNS 自愈:CoreDNS 异常的集群上服务名不可解析,回退 Loki ClusterIP 重建配置
svcURL := endpoint
if strings.Contains(endpoint, ".svc") {
probe := exec.CommandContext(ctx, "kubectl", "exec", "-n", ns,
"daemonset/promtail", "--", "wget", "-q", "-O", "/dev/null", "-T", "5", endpoint+"/ready")
probe.Env = env
if pout, perr := probe.CombinedOutput(); perr != nil {
d.log("warn", fmt.Sprintf("promtail 无法解析 Loki 服务名(%s),回退 ClusterIP...", strings.TrimSpace(string(pout))), "")
ip := d.lokiClusterIP(ctx, env, endpoint)
if ip != "" {
// 只替换 host 部分,保留 scheme/port/path: http://loki.ns.svc:3100/... → http://IP:3100/...
afterScheme := strings.SplitN(endpoint, "//", 2)
if len(afterScheme) == 2 {
rest := afterScheme[1]
host := rest
if i := strings.IndexAny(rest, ":/"); i >= 0 {
host = rest[:i]
}
fallback := afterScheme[0] + "//" + strings.Replace(rest, host, ip, 1)
if err := d.applyYAML(ctx, env, PromtailDaemonsetYAML(ns, fallback, clusterName, d.collector.Labels), "promtail"); err == nil {
rs := exec.CommandContext(ctx, "kubectl", "rollout", "restart", "daemonset/promtail", "-n", ns)
rs.Env = env
rs.CombinedOutput()
d.log("info", fmt.Sprintf("promtail 推送地址已回退为 ClusterIP: %s", fallback), "")
svcURL = fallback
}
}
}
}
if err := waitDaemonSet(ctx, cluster, ns, "promtail"); err != nil {
return fmt.Errorf("Promtail DaemonSet 未就绪: %w", err)
}
svcURL := endpoint
d.collector.Config = svcURL
d.log("success", fmt.Sprintf("Promtail DaemonSet 已部署到 %s(推送至 %s)", ns, svcURL), "")
return nil
}
// applyYAML kubectl apply 一个 manifest(临时文件)
func (d *CollectorDeployer) applyYAML(ctx context.Context, env []string, manifest, label string) error {
f, cleanup, err := writeTempFile("collector-*.yaml", manifest, 0600)
if err != nil {
// applyYAML 通过 API Server 应用采集器清单。
func (d *CollectorDeployer) applyYAML(ctx context.Context, _ []string, manifest, label string) error {
if d.collector.Cluster == nil {
return fmt.Errorf("采集器缺少集群")
}
if err := applyManifest(ctx, d.collector.Cluster, manifest); err != nil {
d.log("error", fmt.Sprintf("应用 %s 失败: %v", label, err), "")
return err
}
defer cleanup()
cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", f)
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
d.log("error", fmt.Sprintf("kubectl apply %s failed: %v", label, err), string(out))
return fmt.Errorf("kubectl apply %s failed: %w\n%s", label, err, string(out))
}
return nil
}
// lokiClusterIP 从 endpoint 的命名空间取 loki Service 的 ClusterIP(不经 DNS)
func (d *CollectorDeployer) lokiClusterIP(ctx context.Context, env []string, endpoint string) string {
ns := "log"
if d.loki != nil && d.loki.Namespace != "" {
ns = d.loki.Namespace
}
cmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "loki", "-n", ns,
"-o", "jsonpath={.spec.clusterIP}")
cmd.Env = env
out, _ := cmd.CombinedOutput()
return strings.TrimSpace(string(out))
}
func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
cluster := d.collector.Cluster
if cluster == nil {
return nil
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" && d.loki != nil {
ns = d.loki.Namespace
@@ -329,12 +244,11 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
if ns == "" {
ns = "log"
}
cmd := exec.CommandContext(ctx, "kubectl", "delete",
"daemonset/promtail", "configmap/promtail", "serviceaccount/promtail",
"clusterrole/opsxloki-promtail", "clusterrolebinding/opsxloki-promtail",
"-n", ns, "--ignore-not-found=true")
cmd.Env = env
cmd.CombinedOutput()
for _, item := range [][2]string{{"daemonset", "promtail"}, {"configmap", "promtail"}, {"serviceaccount", "promtail"}, {"clusterrole", "opsxloki-promtail"}, {"clusterrolebinding", "opsxloki-promtail"}} {
if err := deleteK8sResource(ctx, cluster, item[0], ns, item[1]); err != nil {
return err
}
}
return nil
}
@@ -349,11 +263,10 @@ func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string)
if target == "" {
return fmt.Errorf("Sidecar 采集器缺少目标 Deployment")
}
env, cleanup, err := kubeconfigEnv(cluster)
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
@@ -367,57 +280,65 @@ func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string)
// 1. 检查目标 deployment 是否存在
d.log("info", fmt.Sprintf("检查目标 Deployment %s/%s", ns, target), "")
check := exec.CommandContext(ctx, "kubectl", "get", "deployment", target, "-n", ns)
check.Env = env
if out, err := check.CombinedOutput(); err != nil {
return fmt.Errorf("目标 Deployment 不存在: %s/%s\n%s", ns, target, string(out))
deployment, err := client.AppsV1().Deployments(ns).Get(ctx, target, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("读取目标 Deployment %s/%s: %w", ns, target, err)
}
// 2. 幂等检查:是否已有 fluent-bit 容器
getNames := exec.CommandContext(ctx, "kubectl", "get", "deployment", target, "-n", ns, "-o", "jsonpath={.spec.template.spec.containers[*].name}")
getNames.Env = env
namesOut, _ := getNames.CombinedOutput()
if strings.Contains(string(namesOut), "fluent-bit") {
d.log("warn", "目标 Deployment 已含 fluent-bit 容器,跳过注入", "")
return nil
deploymentJSON, err := json.Marshal(deployment)
if err != nil {
return err
}
mount, err := resolveSidecarLogMount(deploymentJSON, target, logPath, d.collector.ID)
if err != nil {
return err
}
hasSidecar := false
for _, container := range deployment.Spec.Template.Spec.Containers {
if container.Name == "fluent-bit" {
hasSidecar = true
break
}
}
// 3. 部署 ConfigMap
lokiHost, lokiPort := parseLokiURL(endpoint)
conf := FluentbitConfig(lokiHost, lokiPort, logPath, app, ns)
cm := FluentbitConfigMapYAML(conf, ns)
cmFile, cmCleanup, err := writeTempFile("fluent-bit-cm-*.yaml", cm, 0600)
if err != nil {
return err
}
defer cmCleanup()
configMapName := fmt.Sprintf("fluent-bit-config-%d", d.collector.ID)
cm := FluentbitConfigMapYAML(conf, ns, configMapName)
d.log("info", "部署 Fluent Bit ConfigMap", "")
applyCm := exec.CommandContext(ctx, "kubectl", "apply", "-f", cmFile)
applyCm.Env = env
if out, err := applyCm.CombinedOutput(); err != nil {
return fmt.Errorf("ConfigMap 部署失败: %w\n%s", err, string(out))
if err := applyManifest(ctx, cluster, cm); err != nil {
return fmt.Errorf("ConfigMap 部署失败: %w", err)
}
// 4. patch 注入 sidecar
d.log("info", fmt.Sprintf("为 %s 注入 Fluent Bit Sidecar", target), "")
patchFile, patchCleanup, err := writeTempFile("sidecar-patch-*.yaml", SidecarPatchYAML, 0600)
if err != nil {
return err
d.log("info", fmt.Sprintf("为 %s 配置 Fluent Bit Sidecar", target), "")
if err := patchDeployment(ctx, cluster, ns, target, SidecarPatchYAML(configMapName, mount)); err != nil {
return fmt.Errorf("Sidecar 注入失败: %w", err)
}
defer patchCleanup()
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile)
patch.Env = env
if out, err := patch.CombinedOutput(); err != nil {
return fmt.Errorf("Sidecar 注入失败: %w\n%s", err, string(out))
if hasSidecar {
if err := restartDeployment(ctx, cluster, ns, target); err != nil {
return fmt.Errorf("Sidecar 配置已更新,但重启 Deployment 失败: %w", err)
}
}
// 5. 等待 rollout
d.log("info", "等待 Pod 重建", "")
rollout := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/"+target, "-n", ns, "--timeout=5m")
rollout.Env = env
rollout.CombinedOutput()
if err := waitDeployment(ctx, cluster, ns, target); err != nil {
return fmt.Errorf("Sidecar Deployment 未就绪: %w", err)
}
// DNS 自愈:CoreDNS 异常集群上 svc 域名不可解析 → 用 Loki ClusterIP 重建 ConfigMap 并滚动重启
if strings.Contains(endpoint, ".svc") {
host := strings.Split(strings.Split(endpoint, "//")[1], ":")[0]
if podDNSBroken(ctx, cluster, ns, target, "fluent-bit", host) {
d.log("warn", "fluent-bit 无法解析 Loki 服务名,回退 ClusterIP…", "")
if ip, ierr := serviceClusterIP(ctx, cluster, lokiNamespaceOf(d.loki, endpoint), "loki"); ierr == nil && ip != "" {
conf2 := FluentbitConfig(ip, lokiPortOf(endpoint), logPath, target, ns)
cm2 := FluentbitConfigMapYAML(conf2, ns, configMapName)
if err := applyManifest(ctx, cluster, cm2); err == nil {
_ = restartDeployment(ctx, cluster, ns, target)
d.log("info", "fluent-bit 推送地址已回退为 ClusterIP: "+ip, "")
}
}
}
}
return nil
}
@@ -426,23 +347,52 @@ func (d *CollectorDeployer) uninstallSidecar(ctx context.Context) error {
if cluster == nil || d.collector.TargetDeployment == "" {
return nil
}
env, cleanup, err := kubeconfigEnv(cluster)
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "default"
}
patchFile, patchCleanup, err := writeTempFile("sidecar-rm-*.yaml", SidecarRemovePatchYAML, 0600)
deployment, err := client.AppsV1().Deployments(ns).Get(ctx, d.collector.TargetDeployment, metav1.GetOptions{})
if err != nil {
return err
}
defer patchCleanup()
deploymentJSON, err := json.Marshal(deployment)
if err != nil {
return err
}
removePatch, err := sidecarRemovePatchYAML(deploymentJSON, d.collector.ID)
if err != nil {
return err
}
if err := patchDeployment(ctx, cluster, ns, d.collector.TargetDeployment, removePatch); err != nil {
return fmt.Errorf("移除 Sidecar 失败: %w", err)
}
if err := deleteK8sResource(ctx, cluster, "configmap", ns, fmt.Sprintf("fluent-bit-config-%d", d.collector.ID)); err != nil {
return err
}
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile)
patch.Env = env
patch.CombinedOutput()
return nil
}
// lokiPortOf 从 endpoint 提取端口(默认 3100)
func lokiPortOf(endpoint string) string {
rest := strings.SplitN(endpoint, "//", 2)
if len(rest) != 2 {
return "3100"
}
hostPart := rest[1]
if i := strings.Index(hostPart, ":"); i >= 0 {
port := hostPart[i+1:]
if j := strings.IndexAny(port, "/"); j >= 0 {
port = port[:j]
}
if port != "" {
return port
}
}
return "3100"
}
+150
View File
@@ -0,0 +1,150 @@
package services
import (
"encoding/json"
"fmt"
"path"
"strings"
)
type sidecarLogMount struct {
containerName string
volumeName string
mountPath string
subPath string
createVolume bool
mountApplication bool
}
type sidecarDeployment struct {
Spec struct {
Template struct {
Spec struct {
Containers []struct {
Name string `json:"name"`
VolumeMounts []struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
SubPath string `json:"subPath"`
SubPathExpr string `json:"subPathExpr"`
ReadOnly bool `json:"readOnly"`
} `json:"volumeMounts"`
} `json:"containers"`
Volumes []struct {
Name string `json:"name"`
} `json:"volumes"`
} `json:"spec"`
} `json:"template"`
} `json:"spec"`
}
// resolveSidecarLogMount 优先复用业务容器已有的卷;否则仅在专用日志目录创建共享 emptyDir。
func resolveSidecarLogMount(deploymentJSON []byte, target, logPath string, collectorID uint) (sidecarLogMount, error) {
var deployment sidecarDeployment
if err := json.Unmarshal(deploymentJSON, &deployment); err != nil {
return sidecarLogMount{}, fmt.Errorf("读取目标 Deployment 失败: %w", err)
}
logDir := path.Dir(strings.TrimSpace(logPath))
if !path.IsAbs(logPath) || logDir == "/" || strings.ContainsAny(logDir, "*?[]\n\r") {
return sidecarLogMount{}, fmt.Errorf("Sidecar 采集路径需为专用目录下的绝对文件路径或 glob,例如 /var/log/app/*.log")
}
containers := deployment.Spec.Template.Spec.Containers
var appIndex = -1
var candidate = -1
for i, container := range containers {
if container.Name == target {
appIndex = i
break
}
if container.Name != "fluent-bit" {
if candidate >= 0 {
candidate = -2
} else if candidate == -1 {
candidate = i
}
}
}
if appIndex < 0 {
appIndex = candidate
}
if appIndex < 0 {
return sidecarLogMount{}, fmt.Errorf("目标 Deployment 中无法确定业务容器,请使容器名与 Deployment 名一致,或仅保留一个业务容器")
}
app := containers[appIndex]
mount := sidecarLogMount{containerName: app.Name}
longest := -1
for _, item := range app.VolumeMounts {
prefix := strings.TrimSuffix(item.MountPath, "/")
if (logDir == prefix || strings.HasPrefix(logDir, prefix+"/")) && len(prefix) > longest {
if item.ReadOnly || item.SubPathExpr != "" {
return sidecarLogMount{}, fmt.Errorf("业务容器的日志卷必须可写,且不能使用 subPathExpr")
}
mount.volumeName = item.Name
mount.mountPath = item.MountPath
mount.subPath = item.SubPath
longest = len(prefix)
}
}
if mount.volumeName != "" {
if mount.mountPath == "/tmp" {
return sidecarLogMount{}, fmt.Errorf("日志卷不能挂载在 /tmp:Fluent Bit 用此目录保存读取位置;请改用专用日志目录")
}
return mount, nil
}
// 自动挂载会遮蔽目录原有内容,仅对明确的专用日志目录执行。
if logDir != "/var/log/app" && path.Base(logDir) != "logs" && path.Base(logDir) != "log" {
return sidecarLogMount{}, fmt.Errorf("业务容器未共享 %s;请将采集路径改为 /var/log/app/*.log 或已有日志卷中的文件", logDir)
}
volumeName := fmt.Sprintf("loki-collector-logs-%d", collectorID)
for _, volume := range deployment.Spec.Template.Spec.Volumes {
if volume.Name == volumeName {
return sidecarLogMount{}, fmt.Errorf("卷 %s 已存在但业务容器未挂载,请先检查目标 Deployment", volumeName)
}
}
mount.volumeName = volumeName
mount.mountPath = logDir
mount.createVolume = true
mount.mountApplication = true
return mount, nil
}
// sidecarRemovePatchYAML 只清理该采集策略注入的卷,保留业务原有的日志卷。
func sidecarRemovePatchYAML(deploymentJSON []byte, collectorID uint) (string, error) {
var deployment sidecarDeployment
if err := json.Unmarshal(deploymentJSON, &deployment); err != nil {
return "", fmt.Errorf("读取目标 Deployment 失败: %w", err)
}
injectedName := fmt.Sprintf("loki-collector-logs-%d", collectorID)
appPatch := ""
for _, container := range deployment.Spec.Template.Spec.Containers {
if container.Name == "fluent-bit" {
continue
}
for _, mount := range container.VolumeMounts {
if mount.Name == injectedName {
appPatch += fmt.Sprintf(" - name: %s\n volumeMounts:\n - mountPath: %q\n $patch: delete\n", container.Name, mount.MountPath)
}
}
}
volumePatch := ""
for _, volume := range deployment.Spec.Template.Spec.Volumes {
if volume.Name == injectedName {
volumePatch = fmt.Sprintf(" - name: %s\n $patch: delete\n", injectedName)
break
}
}
return fmt.Sprintf(`spec:
template:
spec:
containers:
- name: fluent-bit
$patch: delete
%s volumes:
- name: fluent-bit-config
$patch: delete
- name: fluent-bit-state
$patch: delete
%s`, appPatch, volumePatch), nil
}
@@ -0,0 +1,64 @@
package services
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestSidecarReusesApplicationLogVolume(t *testing.T) {
deployment := []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"st-bos-forex-order","volumeMounts":[{"name":"app-logs","mountPath":"/bitcloud/app/st-bos-forex-order/logs"}]},{"name":"fluent-bit"}],"volumes":[{"name":"app-logs"}]}}}}`)
mount, err := resolveSidecarLogMount(deployment, "st-bos-forex-order", "/bitcloud/app/st-bos-forex-order/logs/*.log", 42)
if err != nil {
t.Fatal(err)
}
if mount.volumeName != "app-logs" || mount.mountPath != "/bitcloud/app/st-bos-forex-order/logs" || mount.createVolume || mount.mountApplication {
t.Fatalf("should reuse the existing application mount: %+v", mount)
}
patch := SidecarPatchYAML("fluent-bit-config-42", mount)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(patch), &parsed); err != nil {
t.Fatalf("invalid strategic merge patch: %v", err)
}
if !strings.Contains(patch, "name: fluent-bit-config-42") || !strings.Contains(patch, "name: app-logs") {
t.Fatalf("patch does not reference the existing log volume and dedicated config: %s", patch)
}
}
func TestSidecarCreatesSharedVolumeOnlyForDedicatedDirectory(t *testing.T) {
deployment := []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"jar-monitor"}]}}}}`)
mount, err := resolveSidecarLogMount(deployment, "jar-monitor", "/var/log/app/*.log", 7)
if err != nil {
t.Fatal(err)
}
if !mount.createVolume || !mount.mountApplication || mount.mountPath != "/var/log/app" || mount.volumeName != "loki-collector-logs-7" {
t.Fatalf("expected a shared volume for both containers: %+v", mount)
}
patch := SidecarPatchYAML("fluent-bit-config-7", mount)
var parsed map[string]any
if err := yaml.Unmarshal([]byte(patch), &parsed); err != nil {
t.Fatalf("invalid strategic merge patch: %v", err)
}
if !strings.Contains(patch, "name: jar-monitor\n volumeMounts:") {
t.Fatalf("application container is missing its log mount: %s", patch)
}
if _, err := resolveSidecarLogMount(deployment, "jar-monitor", "/tmp/test.log", 7); err == nil {
t.Fatal("/tmp must not be covered by a new emptyDir")
}
}
func TestSidecarRemovalKeepsApplicationOwnedVolume(t *testing.T) {
deployment := []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"app","volumeMounts":[{"name":"app-logs","mountPath":"/business/logs"},{"name":"loki-collector-logs-7","mountPath":"/var/log/app"}]},{"name":"fluent-bit"}],"volumes":[{"name":"app-logs"},{"name":"loki-collector-logs-7"},{"name":"fluent-bit-config"}]}}}}`)
patch, err := sidecarRemovePatchYAML(deployment, 7)
if err != nil {
t.Fatal(err)
}
var parsed map[string]any
if err := yaml.Unmarshal([]byte(patch), &parsed); err != nil {
t.Fatalf("invalid removal patch: %v", err)
}
if strings.Contains(patch, "name: app-logs") || !strings.Contains(patch, "name: loki-collector-logs-7") || !strings.Contains(patch, `mountPath: "/var/log/app"`) {
t.Fatalf("removal must only clean collector-owned mounts: %s", patch)
}
}
+39 -54
View File
@@ -194,6 +194,7 @@ extraVolumeMounts:
// 取代 helm 安装 —— 后端无需安装 helm 二进制。
// relabel 体系与主流 helm chart 对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。
func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string) string {
promtailImage := ResolveImage("docker.io/grafana/promtail:3.4.0")
extLabels := promtailExternalLabels(clusterName, labelsJSON)
return fmt.Sprintf(`apiVersion: v1
kind: ServiceAccount
@@ -292,7 +293,7 @@ spec:
operator: Exists
containers:
- name: promtail
image: grafana/promtail:3.4.0
image: %s
imagePullPolicy: IfNotPresent
args: ["-config.file=/etc/promtail/promtail.yaml"]
env:
@@ -340,11 +341,11 @@ spec:
- name: containers
hostPath:
path: /var/lib/docker/containers
`, ns, ns, ns, lokiURL, extLabels, ns)
`, ns, ns, ns, lokiURL, extLabels, ns, promtailImage)
}
// FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort)
// 生产级配置:kubernetes过滤器、内存缓冲、健康检查
// Sidecar 已明确知道应用与命名空间,无需 Kubernetes 元数据过滤器。
func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string {
return fmt.Sprintf(`[SERVICE]
Flush 1
@@ -352,11 +353,9 @@ func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port 2020
Parsers_File parsers.conf
storage.path /tmp/flb-storage/
storage.sync normal
storage.checksum off
storage.max_chunks_pause On
storage.backlog.mem_limit 5M
Health_Check On
HC_Errors_Count 5
@@ -383,70 +382,69 @@ func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string
Add app %s
Add source fluent-bit
[FILTER]
Name kubernetes
Match app.*
Kube_URL https://kubernetes.default.svc:443
Kube_CA_File /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
Kube_Token_File /var/run/secrets/kubernetes.io/serviceaccount/token
Merge_Log On
Keep_Log Off
K8S-Logging.Parser On
K8S-Logging.Exclude On
Labels On
Annotations Off
[OUTPUT]
Name loki
Match *
Host %s
Port %s
Labels job=fluent-bit, app=%s, namespace=%s
Drop_Records_Without_Labels On
Line_Format json
Auto_Kubernetes_Labels On
Retry_Limit 10
`, logPath, app, namespace, app, lokiHost, lokiPort, app, namespace)
}
// FluentbitConfigMapYAML 包装 fluent-bit.conf 为 ConfigMap YAML(部署到 namespace)
func FluentbitConfigMapYAML(conf, namespace string) string {
// FluentbitConfigMapYAML 为每个采集策略生成独立 ConfigMap,避免同命名空间的 Sidecar 相互覆盖。
func FluentbitConfigMapYAML(conf, namespace, name string) string {
return fmt.Sprintf(`apiVersion: v1
kind: ConfigMap
metadata:
name: fluent-bit-config
name: %s
namespace: %s
data:
fluent-bit.conf: |
%s
`, namespace, indentBlock(conf, " "))
`, name, namespace, indentBlock(conf, " "))
}
// SidecarPatchYAML strategic merge patch:为 Deployment 注入 Fluent Bit sidecar 容器
// 生产级配置:资源限制、健康检查、安全上下文
const SidecarPatchYAML = `spec:
// SidecarPatchYAML 将业务容器与 Fluent Bit 挂到同一日志卷和目录。
func SidecarPatchYAML(configMapName string, mount sidecarLogMount) string {
volume := ""
if mount.createVolume {
volume = fmt.Sprintf(" - name: %s\n emptyDir: {}\n", mount.volumeName)
}
appMount := ""
if mount.mountApplication {
appMount = fmt.Sprintf(" - name: %s\n volumeMounts:\n - name: %s\n mountPath: %q\n", mount.containerName, mount.volumeName, mount.mountPath)
}
subPath := ""
if mount.subPath != "" {
subPath = fmt.Sprintf(" subPath: %q\n", mount.subPath)
}
return fmt.Sprintf(`spec:
template:
spec:
volumes:
- name: app-logs
emptyDir: {}
- name: fluent-bit-config
%s - name: fluent-bit-config
configMap:
name: fluent-bit-config
name: %s
- name: fluent-bit-state
emptyDir: {}
containers:
- name: fluent-bit
image: cr.fluentbit.io/fluent/fluent-bit:3.1.1
%s - name: fluent-bit
image: %s
imagePullPolicy: IfNotPresent
ports:
- name: http
- name: fluent-bit-http
containerPort: 2020
protocol: TCP
volumeMounts:
- name: app-logs
mountPath: /var/log/app
readOnly: true
- name: %s
mountPath: %q
%s readOnly: true
- name: fluent-bit-config
mountPath: /fluent-bit/etc/
- name: fluent-bit-state
mountPath: /tmp
resources:
limits:
cpu: 200m
@@ -463,30 +461,17 @@ const SidecarPatchYAML = `spec:
livenessProbe:
httpGet:
path: /
port: http
port: fluent-bit-http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
port: fluent-bit-http
initialDelaySeconds: 5
periodSeconds: 5
`
// SidecarRemovePatchYAML strategic merge patch:从 Deployment 移除 Fluent Bit sidecar(卸载)
const SidecarRemovePatchYAML = `spec:
template:
spec:
containers:
- name: fluent-bit
$patch: delete
volumes:
- name: app-logs
$patch: delete
- name: fluent-bit-config
$patch: delete
`
`, volume, configMapName, appMount, ResolveImage("docker.io/fluent/fluent-bit:5.1.5"), mount.volumeName, mount.mountPath, subPath)
}
// indentBlock 给每行加前缀(ConfigMap 嵌入配置需缩进)
func indentBlock(s, pad string) string {
+98 -27
View File
@@ -1,9 +1,9 @@
package services
import (
"os/exec"
"context"
"fmt"
"net"
"strconv"
"strings"
"sync"
@@ -11,6 +11,7 @@ import (
"opsxloki/models"
"opsxloki/repository"
"opsxloki/seed"
"opsxloki/utils"
)
@@ -29,6 +30,8 @@ var LokiVersionList = []string{
// minSupportedMajorVersion 最低支持的主版本号
const minSupportedMajorVersion = 2
const DefaultRetentionDays = 30
// DeploymentService 部署业务逻辑层
type DeploymentService struct{}
@@ -43,6 +46,8 @@ type CreateDeploymentRequest struct {
Mode string `json:"mode" binding:"required"`
ClusterID *uint `json:"clusterID"`
HostID *uint `json:"hostID"`
ListenIP string `json:"listenIP"`
ListenPort int `json:"listenPort"`
Namespace string `json:"namespace"`
StorageConfigID uint `json:"storageConfigID"`
AuthEnabled bool `json:"authEnabled"`
@@ -56,6 +61,41 @@ type CreateDeploymentRequest struct {
}
func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDeployment, error) {
if req.Mode != "k8s-yaml" && req.Mode != "host-docker" && req.Mode != "host-binary" {
return nil, fmt.Errorf("不支持的部署方式: %s", req.Mode)
}
if req.GrafanaEnabled {
return nil, fmt.Errorf("Grafana 自动部署已移除,请使用独立 Grafana 服务")
}
if req.Mode == "host-docker" {
if _, err := dockerLokiImage(req.Version); err != nil {
return nil, err
}
if req.HostID == nil || *req.HostID == 0 {
return nil, fmt.Errorf("请选择目标主机")
}
if req.ListenPort == 0 {
req.ListenPort = 3100
}
if req.ListenPort < 1 || req.ListenPort > 65535 {
return nil, fmt.Errorf("监听端口须在 1 到 65535 之间")
}
if req.Replicas > 1 {
return nil, fmt.Errorf("主机 Docker 部署仅支持单副本")
}
if req.GrafanaEnabled {
return nil, fmt.Errorf("主机 Docker 模式暂不支持同时部署 Grafana")
}
if req.ListenIP != "" && net.ParseIP(req.ListenIP).To4() == nil {
return nil, fmt.Errorf("监听地址须为 IPv4 地址")
}
}
if req.RetentionDays < 0 {
return nil, fmt.Errorf("保留天数必须是正整数")
}
if req.RetentionDays == 0 {
req.RetentionDays = DefaultRetentionDays
}
// 版本兼容性预检查
if req.Version != "" {
if err := ValidateLokiVersion(req.Version); err != nil {
@@ -72,6 +112,8 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe
Mode: req.Mode,
ClusterID: req.ClusterID,
HostID: req.HostID,
ListenIP: req.ListenIP,
ListenPort: req.ListenPort,
Namespace: req.Namespace,
StorageConfigID: req.StorageConfigID,
AuthEnabled: req.AuthEnabled,
@@ -107,6 +149,17 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe
}
}
}
if d.Mode == "host-docker" {
if d.Host == nil {
return nil, fmt.Errorf("目标主机不存在")
}
if d.ListenIP == "" {
d.ListenIP = d.Host.HostIP
}
if net.ParseIP(d.ListenIP).To4() == nil {
return nil, fmt.Errorf("主机 IP 格式无效,请填写监听内网 IPv4 地址")
}
}
if d.StorageConfigID > 0 {
for i := range db.Storages {
if db.Storages[i].ID == d.StorageConfigID {
@@ -169,6 +222,12 @@ func (s *DeploymentService) List(page, pageSize int) ([]models.LokiDeployment, i
}
func (s *DeploymentService) Update(id uint, updates map[string]interface{}) error {
if value, exists := updates["retentionDays"]; exists {
days, ok := value.(float64)
if !ok || days < 1 || days != float64(int(days)) {
return fmt.Errorf("保留天数必须是正整数")
}
}
db := repository.GetDB()
db.Lock()
defer db.Unlock()
@@ -243,6 +302,22 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro
}
func (s *DeploymentService) Delete(id uint) error {
deployment, err := s.Get(id)
if err != nil {
return err
}
if deployment.Mode == "host-docker" {
if deployment.Status == "deploying" {
return fmt.Errorf("部署正在进行中,请等待完成后删除")
}
if deployment.Status != "pending" {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := deleteDockerLoki(ctx, deployment); err != nil {
return err
}
}
}
db := repository.GetDB()
db.Lock()
defer db.Unlock()
@@ -323,10 +398,11 @@ func (s *DeploymentService) StartDeploy(id uint) error {
"statusMessage": "Deployment completed successfully",
"endpoint": s.getEndpoint(deployment),
})
// 部署成功自动注册日志查询数据源 + 记录 Grafana 访问地址
// 部署成功自动注册日志查询数据源 + 记录 Grafana 访问地址 + 补内置告警模板
if fresh, gerr := s.Get(id); gerr == nil {
EnsureLogSourceForDeployment(fresh)
go s.recordGrafanaEndpoint(fresh)
go seed.SeedAlertRules()
}
s.AddLog(id, "success", "Deployment completed successfully", "")
}
@@ -353,7 +429,13 @@ func (s *DeploymentService) StopDeployment(id uint) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
CleanupLoki(ctx, deployment)
if deployment.Mode == "host-docker" {
if err := stopDockerLoki(ctx, deployment); err != nil {
return err
}
} else {
CleanupLoki(ctx, deployment)
}
return s.Update(id, map[string]interface{}{
"status": "stopped",
"statusMessage": "Deployment stopped by user",
@@ -499,6 +581,8 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
if d.Host != nil {
return fmt.Sprintf("http://%s:3100", d.Host.HostIP)
}
case "host-docker":
return fmt.Sprintf("http://%s:%d", d.ListenIP, d.ListenPort)
}
return ""
}
@@ -599,12 +683,12 @@ func (s *DeploymentService) Preflight() map[string]interface{} {
d := &db.Deployments[i]
if d.Status == "running" {
loki := map[string]interface{}{
"id": d.ID,
"name": d.Name,
"mode": d.Mode,
"endpoint": d.Endpoint,
"namespace": d.Namespace,
"grafana": d.GrafanaEnabled,
"id": d.ID,
"name": d.Name,
"mode": d.Mode,
"endpoint": d.Endpoint,
"namespace": d.Namespace,
"grafana": d.GrafanaEnabled,
"grafanaEndpoint": d.GrafanaEndpoint,
}
result["runningLoki"] = loki
@@ -638,29 +722,16 @@ func (s *DeploymentService) recordGrafanaEndpoint(d *models.LokiDeployment) {
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
env, cleanup, err := kubeconfigEnv(d.Cluster)
if err != nil {
return
}
defer cleanup()
ns := d.Namespace
if ns == "" {
ns = "log"
}
ipCmd := exec.CommandContext(ctx, "kubectl", "get", "nodes",
"-o", "jsonpath={.items[0].status.addresses[0].address}")
ipCmd.Env = env
ipOut, _ := ipCmd.CombinedOutput()
portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns,
"-o", "jsonpath={.spec.ports[0].nodePort}")
portCmd.Env = env
portOut, _ := portCmd.CombinedOutput()
ip := strings.TrimSpace(string(ipOut))
port := strings.TrimSpace(string(portOut))
s.AddLog(d.ID, "info", fmt.Sprintf("Grafana 地址探测: node=%s port=%s", ip, port), "")
if ip != "" && port != "" {
endpoint = fmt.Sprintf("http://%s:%s", ip, port)
ip, ipErr := firstNodeIP(ctx, d.Cluster)
port, portErr := serviceNodePort(ctx, d.Cluster, ns, "grafana")
if ipErr == nil && portErr == nil && port > 0 {
endpoint = fmt.Sprintf("http://%s:%d", ip, port)
}
case "host-binary":
if d.Host != nil {
endpoint = fmt.Sprintf("http://%s:3000", d.Host.HostIP)
+120
View File
@@ -0,0 +1,120 @@
package services
import (
"context"
"fmt"
"strings"
"gopkg.in/yaml.v3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"time"
)
type LiveLokiConfig struct {
Location string `json:"location"`
YAML string `json:"yaml"`
}
// GetLiveConfig 读取实际运行的 Loki 配置。仅返回 loki.yaml,不返回整个 ConfigMap 元数据。
func (s *DeploymentService) GetLiveConfig(ctx context.Context, id uint) (*LiveLokiConfig, error) {
d, err := s.Get(id)
if err != nil {
return nil, err
}
var raw, location string
switch d.Mode {
case "k8s-yaml":
if d.Cluster == nil {
return nil, fmt.Errorf("部署未关联 Kubernetes 集群")
}
client, err := clusterClient(d.Cluster, 30*time.Second)
if err != nil {
return nil, err
}
ns := d.Namespace
if ns == "" {
ns = "log"
}
configMap, err := client.CoreV1().ConfigMaps(ns).Get(ctx, "loki-config", metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("无法读取 %s/loki-config: %w", ns, err)
}
raw = configMap.Data["loki.yaml"]
location = fmt.Sprintf("ConfigMap %s/loki-config → loki.yaml", ns)
case "host-binary":
if d.Host == nil {
return nil, fmt.Errorf("部署未关联主机")
}
client, err := NewSSHClientWithAuth(HostSSHAuth(d.Host))
if err != nil {
return nil, fmt.Errorf("连接部署主机失败")
}
defer client.Close()
output, _, err := client.Execute(ctx, "cat /etc/loki/loki.yaml")
if err != nil {
return nil, fmt.Errorf("无法读取主机上的 /etc/loki/loki.yaml")
}
raw = output
location = "/etc/loki/loki.yaml"
case "host-docker":
if d.Host == nil {
return nil, fmt.Errorf("部署未关联主机")
}
client, err := NewSSHClientWithAuth(HostSSHAuth(d.Host))
if err != nil {
return nil, fmt.Errorf("连接部署主机失败")
}
defer client.Close()
location = dockerConfigPath(d.ID)
output, _, err := client.Execute(ctx, "cat "+location)
if err != nil {
return nil, fmt.Errorf("无法读取主机上的 Loki 配置")
}
raw = output
default:
return nil, fmt.Errorf("当前部署模式暂不支持查看运行配置")
}
if strings.TrimSpace(raw) == "" {
return nil, fmt.Errorf("运行配置为空或尚未部署")
}
redacted, err := redactLokiYAML(raw)
if err != nil {
return nil, fmt.Errorf("运行配置无法安全展示:YAML 格式无效")
}
return &LiveLokiConfig{Location: location, YAML: redacted}, nil
}
func redactLokiYAML(source string) (string, error) {
var document yaml.Node
if err := yaml.Unmarshal([]byte(source), &document); err != nil {
return "", err
}
redactYAMLNode(&document)
output, err := yaml.Marshal(&document)
return string(output), err
}
func redactYAMLNode(node *yaml.Node) {
if node.Kind == yaml.ScalarNode && strings.HasPrefix(node.Value, "ENC:") {
node.Value = "********"
return
}
if node.Kind == yaml.MappingNode {
for i := 0; i+1 < len(node.Content); i += 2 {
key, value := node.Content[i], node.Content[i+1]
switch strings.ToLower(key.Value) {
case "access_key_id", "secret_access_key", "account_key", "password", "token", "client_secret":
value.Kind = yaml.ScalarNode
value.Tag = "!!str"
value.Value = "********"
value.Content = nil
continue
}
redactYAMLNode(value)
}
return
}
for _, child := range node.Content {
redactYAMLNode(child)
}
}
+138
View File
@@ -0,0 +1,138 @@
package services
import (
"context"
"encoding/base64"
"io"
"regexp"
"strings"
"testing"
"gopkg.in/yaml.v3"
"opsxloki/models"
)
func TestRedactLokiYAML(t *testing.T) {
source := "storage_config:\n aws:\n bucketnames: loki\n access_key_id: ENC:private-access\n secret_access_key: private-secret\nlimits_config:\n retention_period: 720h\n"
got, err := redactLokiYAML(source)
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{"private-access", "private-secret", "ENC:"} {
if strings.Contains(got, secret) {
t.Fatalf("sensitive value remains in config: %s", secret)
}
}
for _, want := range []string{"bucketnames: loki", "retention_period: 720h", "access_key_id: '********'"} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in sanitized config: %s", want, got)
}
}
}
func TestMinIOConfigUsesBucketNameNotPathPrefix(t *testing.T) {
d := NewLokiDeployer(&models.LokiDeployment{StorageConfig: &models.StorageConfig{
StorageType: "minio", Bucket: "logs-bucket", PathPrefix: "wrong-prefix", Endpoint: "http://minio:9000",
AccessKey: "key", SecretKey: "secret", Insecure: true,
}}, nil)
config := d.generateLokiConfig()
if count := strings.Count(config, "bucketnames: logs-bucket"); count != 2 {
t.Fatalf("bucket name should appear in storage and ruler config; got %d", count)
}
if strings.Contains(config, "wrong-prefix") || strings.Contains(config, "s3://") {
t.Fatalf("invalid S3 URL or path prefix in generated config")
}
}
func TestDeployRejectsUndecryptedStorageCredential(t *testing.T) {
d := NewLokiDeployer(&models.LokiDeployment{StorageConfig: &models.StorageConfig{
StorageType: "minio", AccessKey: "ENC:unavailable", SecretKey: "secret",
}}, nil)
if err := d.Deploy(context.Background()); err == nil || !strings.Contains(err.Error(), "存储凭证解密失败") {
t.Fatalf("expected credential failure, got %v", err)
}
}
func TestGeneratedLokiConfigKeepsRequiredSettingsWithoutDefaults(t *testing.T) {
d := NewLokiDeployer(&models.LokiDeployment{RetentionDays: 90, Replicas: 1, StorageConfig: &models.StorageConfig{
StorageType: "minio", Bucket: "logs", Endpoint: "http://minio:9000",
AccessKey: "key", SecretKey: "secret", Insecure: true,
}}, nil)
config := d.generateLokiConfig()
var parsed map[string]interface{}
if err := yaml.Unmarshal([]byte(config), &parsed); err != nil {
t.Fatalf("invalid Loki YAML: %v", err)
}
for _, required := range []string{"retention_period: 2160h", "retention_enabled: true", "delete_request_store: s3", "bucketnames: logs", "store: tsdb", "schema: v13"} {
if !strings.Contains(config, required) {
t.Fatalf("required setting %q missing", required)
}
}
for _, defaultSetting := range []string{"http_listen_port:", "grpc_listen_port:", "chunk_idle_period:", "compaction_interval:", "retention_delete_delay:", "max_entries_limit_per_query:", "frontend_worker:", "chunk_store_config:", "autoforget_unhealthy:"} {
if strings.Contains(config, defaultSetting) {
t.Fatalf("unnecessary setting %q remained", defaultSetting)
}
}
d.deployment.Replicas = 2
if !strings.Contains(d.generateLokiConfig(), "autoforget_unhealthy: true") {
t.Fatal("multi-replica ring recovery setting missing")
}
}
func TestK8sManifestKeepsStorageCredentialsInSecret(t *testing.T) {
d := NewLokiDeployer(&models.LokiDeployment{Mode: "k8s-yaml", RetentionDays: 60, Replicas: 1, StorageConfig: &models.StorageConfig{
StorageType: "minio", Bucket: "loki", Endpoint: "http://minio:9000",
AccessKey: "test-access", SecretKey: "test-secret", Insecure: true,
}}, nil)
manifest := d.generateManifest("log")
decoder := yaml.NewDecoder(strings.NewReader(manifest))
var configYAML string
var secret map[string]interface{}
for {
var doc map[string]interface{}
if err := decoder.Decode(&doc); err != nil {
if err == io.EOF {
break
}
t.Fatalf("invalid manifest YAML: %v", err)
}
switch doc["kind"] {
case "ConfigMap":
configYAML = doc["data"].(map[string]interface{})["loki.yaml"].(string)
case "Secret":
secret = doc["data"].(map[string]interface{})
}
}
if configYAML == "" || secret == nil {
t.Fatal("ConfigMap or Secret missing")
}
if strings.Contains(configYAML, "test-access") || strings.Contains(configYAML, "test-secret") {
t.Fatal("storage credential leaked into ConfigMap")
}
for _, marker := range []string{"${LOKI_STORAGE_ACCESS_KEY}", "${LOKI_STORAGE_SECRET_KEY}", "retention_period: 1440h", "retention_enabled: true"} {
if !strings.Contains(configYAML, marker) {
t.Fatalf("required Loki setting %q missing", marker)
}
}
if !strings.Contains(manifest, "-config.expand-env=true") || !strings.Contains(manifest, "secretKeyRef:") {
t.Fatal("StatefulSet does not reference storage Secret")
}
for key, want := range map[string]string{"accessKey": "test-access", "secretKey": "test-secret"} {
decoded, err := base64.StdEncoding.DecodeString(secret[key].(string))
if err != nil || string(decoded) != want {
t.Fatalf("Secret %s data mismatch", key)
}
}
}
func TestK8sManifestChangesPodTemplateWhenRetentionChanges(t *testing.T) {
deployment := &models.LokiDeployment{RetentionDays: 30, Replicas: 1}
d := NewLokiDeployer(deployment, nil)
checksum := regexp.MustCompile(`checksum/loki-config: "[a-f0-9]{64}"`)
first := checksum.FindString(d.generateManifest("log"))
deployment.RetentionDays = 70
second := checksum.FindString(d.generateManifest("log"))
if first == "" || second == "" || first == second {
t.Fatalf("Pod config checksum must change with retention: %q -> %q", first, second)
}
}
+79
View File
@@ -0,0 +1,79 @@
package services
import (
"fmt"
"io"
"regexp"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
var retentionPeriodPattern = regexp.MustCompile(`^([1-9][0-9]*)(d|h)$`)
// parseRetentionYAML 仅接收 limits_config.retention_period,避免把任意配置或凭证写入部署。
func parseRetentionYAML(source string) (int, error) {
var fragment struct {
LimitsConfig *struct {
RetentionPeriod string `yaml:"retention_period"`
} `yaml:"limits_config"`
}
decoder := yaml.NewDecoder(strings.NewReader(source))
decoder.KnownFields(true)
if err := decoder.Decode(&fragment); err != nil {
return 0, fmt.Errorf("YAML 格式无效: %w", err)
}
var extra interface{}
if err := decoder.Decode(&extra); err != io.EOF {
return 0, fmt.Errorf("只允许一段 limits_config YAML")
}
if fragment.LimitsConfig == nil {
return 0, fmt.Errorf("缺少 limits_config.retention_period")
}
match := retentionPeriodPattern.FindStringSubmatch(fragment.LimitsConfig.RetentionPeriod)
if match == nil {
return 0, fmt.Errorf("retention_period 须使用正整数天数,如 90d;也支持 2160h")
}
value, err := strconv.Atoi(match[1])
if err != nil {
return 0, fmt.Errorf("保留时长超出范围")
}
if match[2] == "h" {
if value%24 != 0 {
return 0, fmt.Errorf("小时数必须是 24 的倍数")
}
value /= 24
}
if value < 1 || value > 36500 {
return 0, fmt.Errorf("保留天数须在 1 到 36500 之间")
}
return value, nil
}
// UpdateRetention 保存 Loki 的保留时长;apply 为 true 时触发当前部署重新生成并应用配置。
func (s *DeploymentService) UpdateRetention(id uint, source string, apply bool) (int, error) {
days, err := parseRetentionYAML(source)
if err != nil {
return 0, err
}
deployment, err := s.Get(id)
if err != nil {
return 0, err
}
if deployment.Status == "deploying" {
return 0, fmt.Errorf("部署正在进行中,请稍后编辑")
}
if apply && deployment.Status != "running" {
return 0, fmt.Errorf("当前部署未运行,请仅保存后再部署")
}
if err := s.Update(id, map[string]interface{}{"retentionDays": float64(days)}); err != nil {
return 0, err
}
if apply {
if err := s.StartDeploy(id); err != nil {
return days, fmt.Errorf("保留配置已保存,但重新部署失败: %w", err)
}
}
return days, nil
}
@@ -0,0 +1,68 @@
package services
import (
"testing"
"opsxloki/repository"
)
func TestParseRetentionYAML(t *testing.T) {
for _, tc := range []struct {
name string
source string
want int
}{
{"days", "limits_config:\n retention_period: 90d\n", 90},
{"hours", "limits_config:\n retention_period: 2160h\n", 90},
{"unknown field", "limits_config:\n retention_period: 90d\n ingestion_rate_mb: 16\n", 0},
{"fractional day", "limits_config:\n retention_period: 25h\n", 0},
{"extra document", "limits_config:\n retention_period: 90d\n---\nfoo: bar\n", 0},
{"missing field", "limits_config: {}\n", 0},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := parseRetentionYAML(tc.source)
if tc.want == 0 && err == nil {
t.Fatalf("accepted invalid YAML: %q", tc.source)
}
if tc.want != 0 && (err != nil || got != tc.want) {
t.Fatalf("got %d, err %v; want %d", got, err, tc.want)
}
})
}
}
func TestDeploymentRetentionPersistsAndRejectsInvalidValues(t *testing.T) {
t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("JWT_SECRET", "test-jwt-secret-with-at-least-32-characters")
t.Setenv("ENCRYPTION_KEY", "test-encryption-key-with-at-least-32-characters")
if err := repository.InitDB(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repository.Shutdown() })
service := NewDeploymentService()
deployment, err := service.Create(&CreateDeploymentRequest{
Name: "loki", Mode: "k8s-yaml", Version: "3.0.0",
})
if err != nil {
t.Fatal(err)
}
if deployment.RetentionDays != DefaultRetentionDays {
t.Fatalf("default retention = %d, want %d", deployment.RetentionDays, DefaultRetentionDays)
}
for _, invalid := range []float64{0, -1, 1.5} {
if err := service.Update(deployment.ID, map[string]interface{}{"retentionDays": invalid}); err == nil {
t.Fatalf("accepted invalid retention %v", invalid)
}
}
if err := service.Update(deployment.ID, map[string]interface{}{"retentionDays": float64(90)}); err != nil {
t.Fatal(err)
}
updated, err := service.Get(deployment.ID)
if err != nil || updated.RetentionDays != 90 {
t.Fatalf("updated retention = %v, err = %v", updated, err)
}
if err := repository.GetDB().RequestSaveSync(); err != nil {
t.Fatal(err)
}
}
-438
View File
@@ -1,438 +0,0 @@
package services
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"time"
"opsxloki/models"
)
// GrafanaDeployer 与 Loki 同环境部署 Grafana 并自动配置 Loki 数据源。
// k8s 模式:manifest(Deployment + NodePort Service + 数据源 ConfigMap),与 Loki 同 namespace;
// host-binary 模式:tarball + systemd,与 Loki 同机。
type GrafanaDeployer struct {
deployment *models.LokiDeployment
log func(level, message, detail string)
}
const defaultGrafanaVersion = "11.3.0"
func (d *GrafanaDeployer) version() string {
if d.deployment.GrafanaVersion != "" {
return d.deployment.GrafanaVersion
}
return defaultGrafanaVersion
}
// lokiPushURL 数据源指向的 Loki 地址(k8s 走集群内服务名,主机走本机)
func (d *GrafanaDeployer) lokiPushURL() string {
if d.deployment.Mode == "host-binary" {
return "http://127.0.0.1:3100"
}
ns := d.deployment.Namespace
if ns == "" {
ns = "log"
}
return fmt.Sprintf("http://loki.%s.svc.cluster.local:3100", ns)
}
// Deploy 部署入口:按模式分发
func (d *GrafanaDeployer) Deploy(ctx context.Context) error {
d.log("info", fmt.Sprintf("Deploying Grafana %s (mode: %s)...", d.version(), d.deployment.Mode), "")
switch d.deployment.Mode {
case "k8s-helm", "k8s-yaml":
return d.deployK8s(ctx)
case "host-binary":
return d.deployHost(ctx)
default:
return fmt.Errorf("unsupported mode for grafana: %s", d.deployment.Mode)
}
}
// ==================== K8s manifest 部署 ====================
// grafanaManifest 生成 Grafana 资源;lokiURL 显式传入(默认服务名,DNS 异常的集群可回退 ClusterIP)。
// 数据卷用 emptyDir:数据源/配置由 ConfigMap 供给,重启即恢复;避免无默认
// StorageClass 的集群上 PVC Pending 阻塞(UI 内自建的面板需重新导入,见已知限制)。
func (d *GrafanaDeployer) grafanaManifest(ns, lokiURL string) string {
version := d.version()
datasources := fmt.Sprintf(`apiVersion: 1
datasources:
- name: Loki
type: loki
uid: loki-ds
access: proxy
url: %s
isDefault: true
editable: false`, lokiURL)
return fmt.Sprintf(`apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-datasources
namespace: %s
data:
datasources.yaml: |
%s
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
namespace: %s
labels:
app: grafana
spec:
replicas: 1
selector:
matchLabels:
app: grafana
template:
metadata:
labels:
app: grafana
spec:
containers:
- name: grafana
image: grafana/grafana:%s
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
env:
- name: GF_SECURITY_ADMIN_USER
value: admin
- name: GF_SECURITY_ADMIN_PASSWORD
value: admin
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: datasources
mountPath: /etc/grafana/provisioning/datasources
volumes:
- name: datasources
configMap:
name: grafana-datasources
---
apiVersion: v1
kind: Service
metadata:
name: grafana
namespace: %s
labels:
app: grafana
spec:
# NodePort 便于集群外直接访问(端口由集群自动分配,实际端口见部署日志)
type: NodePort
ports:
- name: http
port: 3000
targetPort: 3000
selector:
app: grafana
`, ns, indentBlock(datasources, " "), ns, version, ns)
}
func (d *GrafanaDeployer) deployK8s(ctx context.Context) error {
if d.deployment.Cluster == nil {
return fmt.Errorf("please select target cluster")
}
env, cleanup, err := kubeconfigEnv(d.deployment.Cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.deployment.Namespace
if ns == "" {
ns = "log"
}
lokiURL := d.lokiPushURL()
manifestFile, manifestCleanup, err := writeTempFile("grafana-*.yaml", d.grafanaManifest(ns, lokiURL), 0600)
if err != nil {
return err
}
defer manifestCleanup()
d.log("info", "Applying Grafana manifest (Deployment + NodePort Service + datasource ConfigMap)...", "")
cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestFile)
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
d.log("error", fmt.Sprintf("kubectl apply grafana failed: %v", err), string(out))
return fmt.Errorf("kubectl apply grafana failed: %w\n%s", err, string(out))
}
waitCmd := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/grafana",
"-n", ns, "--timeout=5m")
waitCmd.Env = env
waitOut, _ := waitCmd.CombinedOutput()
d.log("info", string(waitOut), "")
// DNS 自愈:部分集群 CoreDNS 对 Service 记录异常(如 watch 失效),
// 经 grafana 容器探测 Loki 服务名,不通则回退 ClusterIP 并热更新数据源。
dnsProbe := exec.CommandContext(ctx, "kubectl", "exec", "-n", ns, "deploy/grafana", "--",
"wget", "-q", "-O", "/dev/null", "-T", "5", lokiURL+"/ready")
dnsProbe.Env = env
if probeOut, probeErr := dnsProbe.CombinedOutput(); probeErr != nil {
d.log("warn", fmt.Sprintf("Grafana 无法解析 Loki 服务名(%s),回退 ClusterIP…", strings.TrimSpace(string(probeOut))), "")
ipCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "loki", "-n", ns,
"-o", "jsonpath={.spec.clusterIP}")
ipCmd.Env = env
ipOut, ipErr := ipCmd.CombinedOutput()
clusterIP := strings.TrimSpace(string(ipOut))
if ipErr != nil || clusterIP == "" {
d.log("error", "获取 Loki ClusterIP 失败,数据源保持服务名形式", "")
} else {
fallbackURL := fmt.Sprintf("http://%s:3100", clusterIP)
fbFile, fbCleanup, ferr := writeTempFile("grafana-fb-*.yaml", d.grafanaManifest(ns, fallbackURL), 0600)
if ferr == nil {
fbApply := exec.CommandContext(ctx, "kubectl", "apply", "-f", fbFile)
fbApply.Env = env
fbApply.CombinedOutput()
fbCleanup()
restart := exec.CommandContext(ctx, "kubectl", "rollout", "restart", "deployment/grafana", "-n", ns)
restart.Env = env
restart.CombinedOutput()
wait2 := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/grafana",
"-n", ns, "--timeout=5m")
wait2.Env = env
w2, _ := wait2.CombinedOutput()
d.log("info", string(w2), "")
d.log("info", fmt.Sprintf("数据源已回退为 ClusterIP: %s", fallbackURL), "")
}
}
}
// 回读实际分配的 NodePort(固定端口易与集群已有服务冲突)
portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns,
"-o", "jsonpath={.spec.ports[0].nodePort}")
portCmd.Env = env
portOut, _ := portCmd.CombinedOutput()
nodePort := strings.TrimSpace(string(portOut))
access := fmt.Sprintf("http://<节点IP>:%s", nodePort)
if nodePort == "" {
access = "kubectl get svc grafana -n " + ns + " 查看访问端口"
}
d.log("success", fmt.Sprintf("Grafana deployed. 访问: %s (NodePort, 账号 admin/admin), 数据源已指向 %s", access, d.lokiPushURL()), "")
// 自动导入 Loki 日志面板(打开即可看图;密码被用户修改过则跳过并提示)
if err := d.importDashboard(ctx, env, ns); err != nil {
d.log("warn", fmt.Sprintf("面板自动导入跳过: %v", err), "")
} else {
d.log("info", "已导入「Loki 日志」面板(Explore 可用,默认账号 admin/admin)", "")
}
return nil
}
// importDashboard 通过 Grafana API 导入预置的 Loki 日志面板
func (d *GrafanaDeployer) importDashboard(ctx context.Context, env []string, ns string) error {
payload := grafanaDashboardPayload()
postDashboard := func(baseURL string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimSuffix(baseURL, "/")+"/api/dashboards/db", strings.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:admin")))
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
switch d.deployment.Mode {
case "k8s-helm", "k8s-yaml":
// 经 NodePort 直连(kube service proxy 的 POST 需额外 RBAC,直连最稳)
ipCmd := exec.CommandContext(ctx, "kubectl", "get", "nodes",
"-o", "jsonpath={.items[0].status.addresses[0].address}")
ipCmd.Env = env
ipOut, _ := ipCmd.CombinedOutput()
portCmd := exec.CommandContext(ctx, "kubectl", "get", "svc", "grafana", "-n", ns,
"-o", "jsonpath={.spec.ports[0].nodePort}")
portCmd.Env = env
portOut, _ := portCmd.CombinedOutput()
ip, port := strings.TrimSpace(string(ipOut)), strings.TrimSpace(string(portOut))
if ip == "" || port == "" {
return fmt.Errorf("获取 NodePort 失败")
}
return postDashboard(fmt.Sprintf("http://%s:%s", ip, port))
case "host-binary":
if d.deployment.Host == nil {
return fmt.Errorf("no host")
}
return postDashboard(fmt.Sprintf("http://%s:3000", d.deployment.Host.HostIP))
}
return fmt.Errorf("unsupported mode")
}
// ==================== 主机 tarball 部署 ====================
func (d *GrafanaDeployer) deployHost(ctx context.Context) error {
host := d.deployment.Host
if host == nil {
return fmt.Errorf("please select target host")
}
client, err := NewSSHClientWithAuth(HostSSHAuth(host))
if err != nil {
return err
}
defer client.Close()
arch, _, _ := client.Execute(ctx, "uname -m")
grafanaArch := "amd64"
if strings.Contains(arch, "aarch64") || strings.Contains(arch, "arm64") {
grafanaArch = "arm64"
}
version := d.version()
d.log("info", fmt.Sprintf("Downloading Grafana %s (%s)...", version, grafanaArch), "")
url := fmt.Sprintf("https://dl.grafana.com/oss/release/grafana-%s.linux-%s.tar.gz", version, grafanaArch)
dl := fmt.Sprintf("curl -fsSL -o /tmp/grafana.tar.gz %s && tar -xzf /tmp/grafana.tar.gz -C /opt/ && rm -rf /opt/grafana && ln -sfn /opt/grafana-v%s /opt/grafana && rm -f /tmp/grafana.tar.gz", url, version)
if out, _, err := client.Execute(ctx, dl); err != nil {
return fmt.Errorf("download grafana failed: %w\n%s", err, out)
}
// 数据源 provisioning(指向同机 Loki)
dsYAML := fmt.Sprintf(`apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: %s
isDefault: true
`, d.lokiPushURL())
client.Execute(ctx, "mkdir -p /etc/grafana/provisioning/datasources /var/lib/grafana /var/log/grafana")
writeCmd := fmt.Sprintf("cat > /etc/grafana/provisioning/datasources/loki.yaml <<'EOF'\n%s\nEOF", dsYAML)
if out, _, err := client.Execute(ctx, writeCmd); err != nil {
return fmt.Errorf("write datasource config failed: %w\n%s", err, out)
}
unit := `[Unit]
Description=Grafana
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/grafana
ExecStart=/opt/grafana/bin/grafana server --homepath /opt/grafana --config /opt/grafana/conf/defaults.ini
Restart=always
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target`
writeUnit := fmt.Sprintf("cat > /etc/systemd/system/grafana.service <<'EOF'\n%s\nEOF", unit)
if out, _, err := client.Execute(ctx, writeUnit); err != nil {
return fmt.Errorf("write systemd unit failed: %w\n%s", err, out)
}
if out, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable grafana && systemctl restart grafana"); err != nil {
return fmt.Errorf("start grafana failed: %w\n%s", err, out)
}
// 健康检查(最多 10 次)
for i := 0; i < 10; i++ {
out, _, _ := client.Execute(ctx, "curl -fsSL http://localhost:3000/api/health")
if strings.Contains(out, "\"version\"") {
d.log("success", fmt.Sprintf("Grafana running on %s:3000 (admin/admin), 数据源已指向本机 Loki", host.HostIP), "")
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
d.log("warn", "Grafana 服务已启动但健康检查未通过,请稍后确认 http://<主机IP>:3000", "")
return nil
}
// CleanupGrafana 卸载 Grafana(停止部署时同步调用)
func CleanupGrafana(ctx context.Context, deployment *models.LokiDeployment) {
if deployment == nil {
return
}
switch deployment.Mode {
case "k8s-helm", "k8s-yaml":
if deployment.Cluster == nil {
return
}
env, cleanup, err := kubeconfigEnv(deployment.Cluster)
if err != nil {
return
}
defer cleanup()
ns := deployment.Namespace
if ns == "" {
ns = "log"
}
cmd := exec.CommandContext(ctx, "kubectl", "delete",
"deployment/grafana", "service/grafana", "configmap/grafana-datasources",
"-n", ns, "--ignore-not-found=true")
cmd.Env = env
cmd.CombinedOutput()
case "host-binary":
if deployment.Host == nil {
return
}
client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host))
if err != nil {
return
}
defer client.Close()
client.Execute(ctx, "systemctl stop grafana 2>/dev/null; systemctl disable grafana 2>/dev/null; rm -f /etc/systemd/system/grafana.service; systemctl daemon-reload")
}
}
// grafanaDashboardPayload 预置「Loki 日志」面板:日志量(按命名空间)/错误日志/最近日志
func grafanaDashboardPayload() string {
return `{"dashboard":{
"uid":"loki-logs","title":"Loki 日志","tags":["lokideploy"],
"timezone":"browser","schemaVersion":39,"refresh":"30s","version":1,
"time":{"from":"now-1h","to":"now"},
"panels":[
{"id":1,"type":"timeseries","title":"日志量(按命名空间)",
"datasource":{"type":"loki","uid":"loki-ds"},
"gridPos":{"h":8,"w":12,"x":0,"y":0},
"fieldConfig":{"defaults":{"unit":"short"},"overrides":[]},
"targets":[{"refId":"A","expr":"sum by (namespace) (count_over_time({namespace=\".+\"}[5m]))","queryType":"range"}]},
{"id":2,"type":"timeseries","title":"错误日志",
"datasource":{"type":"loki","uid":"loki-ds"},
"gridPos":{"h":8,"w":12,"x":12,"y":0},
"fieldConfig":{"defaults":{"unit":"short","color":{"mode":"palette-classic"}},"overrides":[]},
"targets":[{"refId":"A","expr":"sum(count_over_time({namespace=\".+\"} |~ \"(?i)error\" [5m]))","queryType":"range"}]},
{"id":3,"type":"logs","title":"最近日志",
"datasource":{"type":"loki","uid":"loki-ds"},
"gridPos":{"h":10,"w":24,"x":0,"y":8},
"targets":[{"refId":"A","expr":"{namespace=\".+\"}","queryType":"range"}]}
]
},"overwrite":true}`
}
+27
View File
@@ -120,6 +120,33 @@ func (s *HostService) Update(id uint, updates map[string]interface{}) error {
if v, ok := updates["arch"].(string); ok {
db.Hosts[i].Arch = v
}
if v, ok := updates["hostIP"].(string); ok && v != "" {
db.Hosts[i].HostIP = v
}
if v, ok := updates["port"].(float64); ok && v > 0 {
db.Hosts[i].Port = int(v)
}
if v, ok := updates["user"].(string); ok && v != "" {
db.Hosts[i].User = v
}
if v, ok := updates["labels"].(string); ok {
db.Hosts[i].Labels = v
}
// 凭证编辑约定:非空才更新(留空=保留旧密文)
for _, field := range []string{"password", "privateKey", "keyPhrase"} {
if v, ok := updates[field].(string); ok && v != "" {
if enc, err := crypto.EncryptField(v); err == nil {
switch field {
case "password":
db.Hosts[i].Password = enc
case "privateKey":
db.Hosts[i].PrivateKey = enc
case "keyPhrase":
db.Hosts[i].KeyPhrase = enc
}
}
}
}
db.Hosts[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
+131
View File
@@ -0,0 +1,131 @@
package services
// 镜像仓库源 — 全局设置(参照 kuboard 的国内/海外镜像选择):
// overseas = docker.io 官方源;cn = daocloud 国内镜像;custom = 用户自填 registry 前缀。
// 影响平台拉取的全部镜像:Loki / Grafana / Promtail / Fluent Bit。
import (
"encoding/json"
"fmt"
"strings"
"opsxloki/models"
"opsxloki/repository"
)
const (
imageRegistrySettingKey = "image_registry"
RegistryOverseas = "overseas" // docker.io
RegistryCN = "cn" // docker.m.daocloud.io(国内加速)
RegistryCustom = "custom" // 用户私有仓库前缀
daocloudPrefix = "docker.m.daocloud.io"
)
// ImageRegistryConfig 镜像源配置
type ImageRegistryConfig struct {
Mode string `json:"mode"` // overseas | cn | custom
CustomHost string `json:"customHost"` // custom 模式下的仓库前缀,如 swr.cn-east-2.myhuaweicloud.com/xxx
}
type ImageRegistryPublic struct {
Mode string `json:"mode"`
CustomHost string `json:"customHost"`
// 示例预览(前端展示生效镜像)
SampleLoki string `json:"sampleLoki"`
SampleGrafana string `json:"sampleGrafana"`
SamplePromtail string `json:"samplePromtail"`
SampleFluentBit string `json:"sampleFluentBit"`
}
func GetImageRegistry() *ImageRegistryConfig {
db := repository.GetDB()
if db == nil {
return &ImageRegistryConfig{Mode: RegistryOverseas}
}
db.RLock()
var raw string
for i := range db.Settings {
if db.Settings[i].Key == imageRegistrySettingKey {
raw = db.Settings[i].Value
break
}
}
db.RUnlock()
cfg := &ImageRegistryConfig{Mode: RegistryOverseas}
if raw != "" {
_ = json.Unmarshal([]byte(raw), cfg)
}
if cfg.Mode == "" {
cfg.Mode = RegistryOverseas
}
return cfg
}
func SaveImageRegistry(mode, customHost string) error {
if mode != RegistryOverseas && mode != RegistryCN && mode != RegistryCustom {
return fmt.Errorf("无效的镜像源模式")
}
if mode == RegistryCustom {
customHost = strings.TrimSuffix(strings.TrimSpace(customHost), "/")
if customHost == "" || !strings.Contains(customHost, ".") {
return fmt.Errorf("自定义镜像仓库地址无效(需含域名,如 swr.cn-east-2.myhuaweicloud.com/your-ns)")
}
}
data, _ := json.Marshal(&ImageRegistryConfig{Mode: mode, CustomHost: customHost})
db := repository.GetDB()
newID := db.NextID("settings")
db.Lock()
defer db.Unlock()
for i := range db.Settings {
if db.Settings[i].Key == imageRegistrySettingKey {
db.Settings[i].Value = string(data)
db.RequestSave()
return nil
}
}
db.Settings = append(db.Settings, models.SystemSetting{ID: newID, Key: imageRegistrySettingKey, Value: string(data)})
db.RequestSave()
return nil
}
// ResolveImage 按当前镜像源解析镜像地址:docker.io/grafana/loki:3.4.0 形式的官方镜像
// 会按 mode 重写前缀;已带私有仓库前缀(含镜像源域名)的地址原样返回。
func ResolveImage(official string) string {
cfg := GetImageRegistry()
switch cfg.Mode {
case RegistryCN:
if strings.HasPrefix(official, "docker.io/") {
return daocloudPrefix + "/" + strings.TrimPrefix(official, "docker.io/")
}
if !strings.Contains(strings.SplitN(official, "/", 2)[0], ".") {
// 无仓库前缀的官方镜像(grafana/loki:tag)
return daocloudPrefix + "/" + official
}
return official
case RegistryCustom:
if strings.HasPrefix(official, "docker.io/") {
return cfg.CustomHost + "/" + strings.TrimPrefix(official, "docker.io/")
}
if !strings.Contains(strings.SplitN(official, "/", 2)[0], ".") {
return cfg.CustomHost + "/" + official
}
return official
default:
if strings.HasPrefix(official, "docker.io/") {
return official // 官方模式保留全限定名
}
return official
}
}
// ImageRegistryPublicConfig 对外输出(含示例预览)
func ImageRegistryPublicConfig() *ImageRegistryPublic {
cfg := GetImageRegistry()
return &ImageRegistryPublic{
Mode: cfg.Mode,
CustomHost: cfg.CustomHost,
SampleLoki: ResolveImage("docker.io/grafana/loki:3.4.0"),
SampleGrafana: ResolveImage("docker.io/grafana/grafana:11.3.0"),
SamplePromtail: ResolveImage("docker.io/grafana/promtail:3.4.0"),
SampleFluentBit: ResolveImage("docker.io/fluent/fluent-bit:5.1.5"),
}
}
+184
View File
@@ -0,0 +1,184 @@
package services
import (
"context"
"fmt"
"strings"
"time"
appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"opsxloki/models"
)
const ingressNamespace = "ingress-nginx"
const ingressControllerName = "ingress-nginx-controller"
// The field names are shared with the JSON parser on the Loki query page.
const ingressAccessFormat = `{"time":"$time_iso8601","remote_addr":"$remote_addr","host":"$host","http_host":"$http_host","request":"$request","method":"$request_method","uri":"$request_uri","status":$status,"body_bytes_sent":$body_bytes_sent,"http_referer":"$http_referer","http_user_agent":"$http_user_agent","request_length":$request_length,"request_time":$request_time,"proxy_upstream_name":"$proxy_upstream_name","upstream_addr":"$upstream_addr","upstream_status":"$upstream_status","upstream_response_time":"$upstream_response_time"}`
type IngressAccessStatus struct {
Namespace string `json:"namespace"`
Deployment string `json:"deployment"`
Image string `json:"image"`
ConfigMap string `json:"configMap"`
ConfigMapAttached bool `json:"configMapAttached"`
CurrentFormat string `json:"currentFormat"`
TargetFormat string `json:"targetFormat"`
Standardized bool `json:"standardized"`
ManagedBy string `json:"managedBy"`
}
func ingressConfigArg(args []string) string {
for i, arg := range args {
if strings.HasPrefix(arg, "--configmap=") {
return strings.TrimPrefix(arg, "--configmap=")
}
if arg == "--configmap" && i+1 < len(args) {
return args[i+1]
}
}
return ""
}
func ingressAccessState(ctx context.Context, cluster *models.Cluster) (IngressAccessStatus, error) {
client, err := clusterClient(cluster, 10*time.Second)
if err != nil {
return IngressAccessStatus{}, err
}
dep, err := client.AppsV1().Deployments(ingressNamespace).Get(ctx, ingressControllerName, metav1.GetOptions{})
if err != nil {
return IngressAccessStatus{}, fmt.Errorf("读取 ingress-nginx 控制器失败: %w", err)
}
index := -1
for i, container := range dep.Spec.Template.Spec.Containers {
if strings.Contains(container.Image, "ingress-nginx/controller:") {
index = i
break
}
}
if index < 0 {
return IngressAccessStatus{}, fmt.Errorf("未识别到社区 ingress-nginx controller 容器")
}
container := dep.Spec.Template.Spec.Containers[index]
configName := ingressConfigArg(container.Args)
if configName == "" {
configName = ingressNamespace + "/" + ingressControllerName
}
parts := strings.Split(configName, "/")
if len(parts) != 2 || parts[0] != ingressNamespace || parts[1] == "" {
return IngressAccessStatus{}, fmt.Errorf("控制器使用其他命名空间的 ConfigMap: %s", configName)
}
cm, err := client.CoreV1().ConfigMaps(parts[0]).Get(ctx, parts[1], metav1.GetOptions{})
if err != nil {
return IngressAccessStatus{}, fmt.Errorf("读取 ingress-nginx ConfigMap 失败: %w", err)
}
format := cm.Data["log-format-upstream"]
return IngressAccessStatus{
Namespace: ingressNamespace, Deployment: dep.Name, Image: container.Image,
ConfigMap: configName, ConfigMapAttached: ingressConfigArg(container.Args) != "",
CurrentFormat: format, TargetFormat: ingressAccessFormat,
Standardized: format == ingressAccessFormat && cm.Data["log-format-escape-json"] == "true" && ingressConfigArg(container.Args) != "",
ManagedBy: dep.Labels["app.kubernetes.io/managed-by"],
}, nil
}
func InspectIngressAccess(ctx context.Context, cluster *models.Cluster) (IngressAccessStatus, error) {
return ingressAccessState(ctx, cluster)
}
// ApplyIngressAccess changes only the two log keys, preserving unrelated ingress settings.
// Explicitly attaching the ConfigMap is necessary for controllers launched without --configmap.
func ApplyIngressAccess(ctx context.Context, cluster *models.Cluster) (IngressAccessStatus, error) {
state, err := ingressAccessState(ctx, cluster)
if err != nil {
return state, err
}
client, err := clusterClient(cluster, 10*time.Second)
if err != nil {
return state, err
}
parts := strings.Split(state.ConfigMap, "/")
cm, err := client.CoreV1().ConfigMaps(parts[0]).Get(ctx, parts[1], metav1.GetOptions{})
if err != nil {
return state, err
}
if !state.ConfigMapAttached {
for key := range cm.Data {
if key != "log-format-upstream" && key != "log-format-escape-json" {
return state, fmt.Errorf("控制器尚未关联 ConfigMap,且其中已有 %s 等设置;为避免意外启用原有配置,已跳过自动配置", key)
}
}
}
previousFormat := strings.Replace(ingressAccessFormat, `"request":"$request",`, "", 1)
if current := cm.Data["log-format-upstream"]; current != "" && current != ingressAccessFormat && current != previousFormat {
return state, fmt.Errorf("已有自定义 access log 格式;为避免覆盖,已跳过自动配置")
}
if state.Standardized {
if err := ensureIngressRolloutStrategy(ctx, cluster); err != nil {
return state, err
}
return state, nil
}
if cm.Data == nil {
cm.Data = make(map[string]string)
}
cm.Data["log-format-upstream"] = ingressAccessFormat
cm.Data["log-format-escape-json"] = "true"
if _, err = client.CoreV1().ConfigMaps(parts[0]).Update(ctx, cm, metav1.UpdateOptions{}); err != nil {
return state, fmt.Errorf("更新 ingress-nginx ConfigMap 失败: %w", err)
}
if !state.ConfigMapAttached {
dep, err := client.AppsV1().Deployments(ingressNamespace).Get(ctx, ingressControllerName, metav1.GetOptions{})
if err != nil {
return state, err
}
for i := range dep.Spec.Template.Spec.Containers {
container := &dep.Spec.Template.Spec.Containers[i]
if strings.Contains(container.Image, "ingress-nginx/controller:") {
container.Args = append(container.Args, "--configmap="+state.ConfigMap)
break
}
}
setIngressRolloutStrategy(dep)
if _, err = client.AppsV1().Deployments(ingressNamespace).Update(ctx, dep, metav1.UpdateOptions{}); err != nil {
return state, fmt.Errorf("ConfigMap 已更新,但控制器尚未关联配置;更新 Deployment 失败: %w", err)
}
}
return ingressAccessState(ctx, cluster)
}
// Host-network replicas occupying every ingress node cannot use a surge pod:
// the new pod would need the same 80/443 ports as an existing replica.
func setIngressRolloutStrategy(dep *appsv1.Deployment) bool {
if !dep.Spec.Template.Spec.HostNetwork || dep.Spec.Replicas == nil || *dep.Spec.Replicas < 2 {
return false
}
zero, one := intstr.FromInt32(0), intstr.FromInt32(1)
strategy := &dep.Spec.Strategy
if strategy.Type == appsv1.RecreateDeploymentStrategyType {
return false
}
if strategy.RollingUpdate != nil && strategy.RollingUpdate.MaxSurge != nil && *strategy.RollingUpdate.MaxSurge == zero && strategy.RollingUpdate.MaxUnavailable != nil && *strategy.RollingUpdate.MaxUnavailable == one {
return false
}
strategy.Type = appsv1.RollingUpdateDeploymentStrategyType
strategy.RollingUpdate = &appsv1.RollingUpdateDeployment{MaxSurge: &zero, MaxUnavailable: &one}
return true
}
func ensureIngressRolloutStrategy(ctx context.Context, cluster *models.Cluster) error {
client, err := clusterClient(cluster, 10*time.Second)
if err != nil {
return err
}
dep, err := client.AppsV1().Deployments(ingressNamespace).Get(ctx, ingressControllerName, metav1.GetOptions{})
if err != nil {
return err
}
if setIngressRolloutStrategy(dep) {
_, err = client.AppsV1().Deployments(ingressNamespace).Update(ctx, dep, metav1.UpdateOptions{})
}
return err
}
+37
View File
@@ -0,0 +1,37 @@
package services
import (
"strings"
"testing"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
)
func TestIngressAccessConfiguration(t *testing.T) {
if got := ingressConfigArg([]string{"/nginx-ingress-controller", "--configmap=ingress-nginx/ingress-nginx-controller"}); got != "ingress-nginx/ingress-nginx-controller" {
t.Fatalf("unexpected configmap: %q", got)
}
if got := ingressConfigArg([]string{"--configmap", "custom/controller"}); got != "custom/controller" {
t.Fatalf("unexpected separate configmap flag: %q", got)
}
for _, field := range []string{`"host":"$host"`, `"http_host":"$http_host"`, `"request":"$request"`, `"http_referer":"$http_referer"`, `"method":"$request_method"`, `"status":$status`} {
if !strings.Contains(ingressAccessFormat, field) {
t.Errorf("missing access log field %s", field)
}
}
}
func TestIngressHostNetworkRolloutStrategy(t *testing.T) {
replicas := int32(2)
dep := &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: &replicas, Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{HostNetwork: true}}}}
if !setIngressRolloutStrategy(dep) {
t.Fatal("expected host-network rollout adjustment")
}
if dep.Spec.Strategy.RollingUpdate.MaxSurge.IntVal != 0 || dep.Spec.Strategy.RollingUpdate.MaxUnavailable.IntVal != 1 {
t.Fatalf("unexpected rollout strategy: %+v", dep.Spec.Strategy.RollingUpdate)
}
if setIngressRolloutStrategy(dep) {
t.Fatal("strategy update must be idempotent")
}
}
+35
View File
@@ -5,8 +5,12 @@ import (
"fmt"
"os"
"regexp"
"time"
"opsxloki/models"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
// resolveKubeconfig 取集群最终的 kubeconfig 文本(优先内存字段,其次磁盘文件)
@@ -24,6 +28,37 @@ func resolveKubeconfig(cluster *models.Cluster) (string, error) {
return "", fmt.Errorf("集群未配置 kubeconfig")
}
// clusterRESTConfig 使用集群保存的 kubeconfig 构造原生 API Server 客户端配置。
func clusterRESTConfig(cluster *models.Cluster, timeout time.Duration) (*rest.Config, error) {
source, err := resolveKubeconfig(cluster)
if err != nil {
return nil, err
}
config, err := clientcmd.Load([]byte(source))
if err != nil {
return nil, fmt.Errorf("解析集群 kubeconfig 失败: %w", err)
}
overrides := &clientcmd.ConfigOverrides{}
if cluster.Context != "" {
overrides.CurrentContext = cluster.Context
}
result, err := clientcmd.NewNonInteractiveClientConfig(*config, config.CurrentContext, overrides, nil).ClientConfig()
if err != nil {
return nil, fmt.Errorf("构造 Kubernetes 客户端失败: %w", err)
}
result.Timeout = timeout
result.UserAgent = "LokiDeploy"
return result, nil
}
func clusterClient(cluster *models.Cluster, timeout time.Duration) (kubernetes.Interface, error) {
config, err := clusterRESTConfig(cluster, timeout)
if err != nil {
return nil, err
}
return kubernetes.NewForConfig(config)
}
// BuildKubeconfigFromToken 用 API Server + Token 构造最小 kubeconfig
// 优先使用 CA 证书验证 TLS,仅在用户显式要求时跳过证书验证
// Parameters:
+331
View File
@@ -0,0 +1,331 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/wait"
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/discovery"
"k8s.io/client-go/dynamic"
kcScheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/remotecommand"
"opsxloki/models"
)
func ensureNamespace(ctx context.Context, cluster *models.Cluster, namespace string) error {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return err
}
_, err = client.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
_, err = client.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, metav1.CreateOptions{})
}
return err
}
// applyManifest 使用 API Server 的 server-side apply,保留已有 YAML 模板与字段所有权。
func applyManifest(ctx context.Context, cluster *models.Cluster, manifest string) error {
cfg, err := clusterRESTConfig(cluster, 30*time.Second)
if err != nil {
return err
}
dc, err := discovery.NewDiscoveryClientForConfig(cfg)
if err != nil {
return err
}
groups, err := restmapper.GetAPIGroupResources(dc)
if err != nil {
return fmt.Errorf("读取 Kubernetes API 资源列表: %w", err)
}
mapper := restmapper.NewDiscoveryRESTMapper(groups)
client, err := dynamic.NewForConfig(cfg)
if err != nil {
return err
}
decoder := yamlutil.NewYAMLOrJSONDecoder(strings.NewReader(manifest), 4096)
for {
var raw map[string]interface{}
if err := decoder.Decode(&raw); err == io.EOF {
break
} else if err != nil {
return fmt.Errorf("解析 Kubernetes YAML: %w", err)
}
if len(raw) == 0 {
continue
}
obj := &unstructured.Unstructured{Object: raw}
mapping, err := mapper.RESTMapping(obj.GroupVersionKind().GroupKind(), obj.GroupVersionKind().Version)
if err != nil {
return err
}
name := obj.GetName()
if name == "" {
return fmt.Errorf("Kubernetes %s 缺少 metadata.name", obj.GetKind())
}
data, err := obj.MarshalJSON()
if err != nil {
return err
}
var resource dynamic.ResourceInterface = client.Resource(mapping.Resource)
if mapping.Scope.Name() == "namespace" {
resource = client.Resource(mapping.Resource).Namespace(obj.GetNamespace())
}
force := true
if _, err := resource.Patch(ctx, name, types.ApplyPatchType, data, metav1.PatchOptions{FieldManager: "lokideploy", Force: &force}); err != nil {
return fmt.Errorf("应用 %s %s/%s: %w", obj.GetKind(), obj.GetNamespace(), name, err)
}
}
return nil
}
func waitStatefulSet(ctx context.Context, cluster *models.Cluster, namespace, name string) error {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return err
}
limit, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
return wait.PollUntilContextCancel(limit, 3*time.Second, true, func(ctx context.Context) (bool, error) {
item, err := client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, err
}
desired := int32(1)
if item.Spec.Replicas != nil {
desired = *item.Spec.Replicas
}
return item.Status.ObservedGeneration >= item.Generation && item.Status.ReadyReplicas >= desired && item.Status.UpdatedReplicas >= desired, nil
})
}
func waitDeployment(ctx context.Context, cluster *models.Cluster, namespace, name string) error {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return err
}
limit, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
return wait.PollUntilContextCancel(limit, 3*time.Second, true, func(ctx context.Context) (bool, error) {
item, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, err
}
desired := int32(1)
if item.Spec.Replicas != nil {
desired = *item.Spec.Replicas
}
return item.Status.ObservedGeneration >= item.Generation && item.Status.ReadyReplicas >= desired && item.Status.UpdatedReplicas >= desired, nil
})
}
func waitDaemonSet(ctx context.Context, cluster *models.Cluster, namespace, name string) error {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return err
}
limit, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
return wait.PollUntilContextCancel(limit, 3*time.Second, true, func(ctx context.Context) (bool, error) {
item, err := client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, err
}
return item.Status.ObservedGeneration >= item.Generation && item.Status.NumberReady >= item.Status.DesiredNumberScheduled && item.Status.UpdatedNumberScheduled >= item.Status.DesiredNumberScheduled, nil
})
}
func restartDeployment(ctx context.Context, cluster *models.Cluster, namespace, name string) error {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return err
}
item, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return err
}
item.Spec.Template.Annotations = copyAnnotations(item.Spec.Template.Annotations)
item.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().UTC().Format(time.RFC3339)
_, err = client.AppsV1().Deployments(namespace).Update(ctx, item, metav1.UpdateOptions{})
return err
}
func copyAnnotations(source map[string]string) map[string]string {
result := map[string]string{}
for k, v := range source {
result[k] = v
}
return result
}
func deleteK8sResource(ctx context.Context, cluster *models.Cluster, kind, namespace, name string) error {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return err
}
var result error
switch kind {
case "statefulset":
result = client.AppsV1().StatefulSets(namespace).Delete(ctx, name, metav1.DeleteOptions{})
case "deployment":
result = client.AppsV1().Deployments(namespace).Delete(ctx, name, metav1.DeleteOptions{})
case "daemonset":
result = client.AppsV1().DaemonSets(namespace).Delete(ctx, name, metav1.DeleteOptions{})
case "service":
result = client.CoreV1().Services(namespace).Delete(ctx, name, metav1.DeleteOptions{})
case "configmap":
result = client.CoreV1().ConfigMaps(namespace).Delete(ctx, name, metav1.DeleteOptions{})
case "serviceaccount":
result = client.CoreV1().ServiceAccounts(namespace).Delete(ctx, name, metav1.DeleteOptions{})
case "persistentvolumeclaim":
result = client.CoreV1().PersistentVolumeClaims(namespace).Delete(ctx, name, metav1.DeleteOptions{})
case "clusterrole":
result = client.RbacV1().ClusterRoles().Delete(ctx, name, metav1.DeleteOptions{})
case "clusterrolebinding":
result = client.RbacV1().ClusterRoleBindings().Delete(ctx, name, metav1.DeleteOptions{})
default:
return fmt.Errorf("未知 Kubernetes 资源: %s", kind)
}
if apierrors.IsNotFound(result) {
return nil
}
return result
}
func serviceClusterIP(ctx context.Context, cluster *models.Cluster, namespace, name string) (string, error) {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return "", err
}
svc, err := client.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err
}
return svc.Spec.ClusterIP, nil
}
func serviceNodePort(ctx context.Context, cluster *models.Cluster, namespace, name string) (int32, error) {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return 0, err
}
svc, err := client.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return 0, err
}
if len(svc.Spec.Ports) == 0 {
return 0, fmt.Errorf("Service %s/%s 没有端口", namespace, name)
}
return svc.Spec.Ports[0].NodePort, nil
}
func firstNodeIP(ctx context.Context, cluster *models.Cluster) (string, error) {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return "", err
}
nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{Limit: 1})
if err != nil {
return "", err
}
for _, node := range nodes.Items {
for _, address := range node.Status.Addresses {
if address.Type == corev1.NodeInternalIP {
return address.Address, nil
}
}
}
return "", fmt.Errorf("集群无可用节点内网地址")
}
func patchDeployment(ctx context.Context, cluster *models.Cluster, namespace, name, yamlPatch string) error {
client, err := clusterClient(cluster, 30*time.Second)
if err != nil {
return err
}
patch, err := yamlutil.ToJSON([]byte(yamlPatch))
if err != nil {
return err
}
_, err = client.AppsV1().Deployments(namespace).Patch(ctx, name, types.StrategicMergePatchType, patch, metav1.PatchOptions{})
return err
}
// podDNSBroken 在指定 deployment 的容器里探测 hostname 是否可解析(wget /ready 连通性)。
func podDNSBroken(ctx context.Context, cluster *models.Cluster, namespace, deployment, container, host string) bool {
pods, err := listDeploymentPods(ctx, cluster, namespace, deployment)
if err != nil || len(pods) == 0 {
return false // 拿不到 pod 时不做自愈,交给后续日志排查
}
client, err := clusterClient(cluster, 15*time.Second)
if err != nil {
return false
}
restCfg, err := clusterRESTConfig(cluster, 15*time.Second)
if err != nil {
return false
}
execReq := client.CoreV1().RESTClient().Post().
Namespace(namespace).Resource("pods").Name(pods[0]).
SubResource("exec").
VersionedParams(&corev1.PodExecOptions{
Command: []string{"sh", "-c", "wget -q -O /dev/null -T 3 http://" + host + ":3100/ready"},
Container: container,
Stdout: true, Stderr: true,
}, kcScheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(restCfg, "POST", execReq.URL())
if err != nil {
return false
}
var out bytes.Buffer
err = exec.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: &out, Stderr: &out})
return err != nil // 执行失败(连接/解析不通)视为 DNS 故障
}
// listDeploymentPods 按 deployment selector 列出 pod 名。
func listDeploymentPods(ctx context.Context, cluster *models.Cluster, namespace, deployment string) ([]string, error) {
client, err := clusterClient(cluster, 15*time.Second)
if err != nil {
return nil, err
}
dep, err := client.AppsV1().Deployments(namespace).Get(ctx, deployment, metav1.GetOptions{})
if err != nil {
return nil, err
}
selector := metav1.FormatLabelSelector(dep.Spec.Selector)
podList, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
if err != nil {
return nil, err
}
names := make([]string, 0, len(podList.Items))
for i := range podList.Items {
names = append(names, podList.Items[i].Name)
}
return names, nil
}
// lokiNamespaceOf 从 endpoint 的 svc FQDN 提取 Loki 命名空间(log.ns.svc → ns)。
func lokiNamespaceOf(loki *models.LokiDeployment, endpoint string) string {
if loki != nil && loki.Namespace != "" {
return loki.Namespace
}
parts := strings.SplitN(endpoint, "//", 2)
if len(parts) == 2 {
host := strings.SplitN(parts[1], ".", 3)
if len(host) >= 2 {
return host[1]
}
}
return "log"
}
+11 -11
View File
@@ -27,17 +27,17 @@ func netDialer() *net.Dialer {
// LDAPConfig LDAP 配置(存储形态;BindPassword 以 ENC: 前缀 AES-GCM 加密落库)
type LDAPConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
UseSSL bool `json:"useSSL"`
BindDN string `json:"bindDN"`
BindPassword string `json:"bindPassword"` // 存储为 ENC:密文;对外输出时剔除
BaseDN string `json:"baseDN"`
UserFilter string `json:"userFilter"`
UsernameAttr string `json:"usernameAttr"`
NameAttr string `json:"nameAttr"`
EmailAttr string `json:"emailAttr"`
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
UseSSL bool `json:"useSSL"`
BindDN string `json:"bindDN"`
BindPassword string `json:"bindPassword"` // 存储为 ENC:密文;对外输出时剔除
BaseDN string `json:"baseDN"`
UserFilter string `json:"userFilter"`
UsernameAttr string `json:"usernameAttr"`
NameAttr string `json:"nameAttr"`
EmailAttr string `json:"emailAttr"`
}
// LDAPPublicConfig 对外输出形态(密码剔除,附 hasPassword)
+3 -1
View File
@@ -18,7 +18,9 @@ func ListLogPolicyGroups() []models.LogPolicyGroup {
groups[i] = group
groups[i].UserIDs = append([]uint(nil), group.UserIDs...)
groups[i].Labels = make(map[string]string, len(group.Labels))
for key, value := range group.Labels { groups[i].Labels[key] = value }
for key, value := range group.Labels {
groups[i].Labels[key] = value
}
}
return groups
}
+74 -349
View File
@@ -12,360 +12,34 @@ import (
"io"
"net/http"
"net/url"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
"k8s.io/client-go/rest"
)
const maxLogQueryLimit = 1000
const logQueryTimeout = 120 * time.Second
// ==================== 数据源 CRUD ====================
func GetLogSource(id uint) *models.LogSource {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.LogSources {
if db.LogSources[i].ID == id {
s := db.LogSources[i]
return &s
}
}
return nil
var lokiHTTPClients = [2]*http.Client{
{Timeout: logQueryTimeout, Transport: newLokiTransport(false)},
{Timeout: logQueryTimeout, Transport: newLokiTransport(true)},
}
func ListLogSources() []map[string]interface{} {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
out := make([]map[string]interface{}, 0, len(db.LogSources))
for i := range db.LogSources {
out = append(out, db.LogSources[i].Sanitized())
}
return out
}
func ListVisibleLogSources(userID uint, admin bool) []map[string]interface{} {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
out := make([]map[string]interface{}, 0)
for i := range db.LogSources {
s := &db.LogSources[i]
if admin || policyGroupAccess(db.LogPolicyGroups, s.ID, userID) != nil || sourceAccess(s, userID) != nil {
item := s.Sanitized()
if !admin {
item = map[string]interface{}{"id": s.ID, "name": s.Name, "isDefault": s.IsDefault}
}
out = append(out, item)
}
}
return out
}
func sourceAccess(src *models.LogSource, userID uint) *models.LogSourceAccess {
for i := range src.Access {
if src.Access[i].UserID == userID {
return &src.Access[i]
}
}
return nil
}
var labelNamePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
func ValidLogLabelName(name string) bool { return labelNamePattern.MatchString(name) }
// AuthorizedLogQuery constrains the Loki stream selector server-side. Non-admins must be explicitly granted access.
func AuthorizedLogQuery(src *models.LogSource, userID uint, admin bool, query string) (string, error) {
if admin {
return query, nil
}
db := repository.GetDB()
var access *models.LogSourceAccess
if db != nil {
db.RLock()
access = policyGroupAccess(db.LogPolicyGroups, src.ID, userID)
db.RUnlock()
}
if access == nil {
access = sourceAccess(src, userID)
}
if access == nil {
return "", fmt.Errorf("无权查询此数据源")
}
q := strings.TrimSpace(query)
if !strings.HasPrefix(q, "{") {
return "", fmt.Errorf("普通用户查询必须以日志流选择器开头")
}
close := strings.IndexByte(q, '}')
if close < 0 || strings.Contains(q[close+1:], "{") {
return "", fmt.Errorf("查询只能包含一个日志流选择器")
}
if len(access.Labels) == 0 {
return q, nil
}
keys := make([]string, 0, len(access.Labels))
for k := range access.Labels {
if !labelNamePattern.MatchString(k) {
return "", fmt.Errorf("访问策略包含无效标签名")
}
keys = append(keys, k)
}
sort.Strings(keys)
matchers := make([]string, 0, len(keys))
for _, k := range keys {
v, _ := json.Marshal(access.Labels[k])
matchers = append(matchers, k+"="+string(v))
}
inner := strings.TrimSpace(q[1:close])
if inner != "" {
inner += ","
}
return "{" + inner + strings.Join(matchers, ",") + "}" + q[close+1:], nil
}
func CreateLogSource(s *models.LogSource) (*models.LogSource, error) {
if err := encryptSourceCredentials(s); err != nil {
return nil, err
}
db := repository.GetDB()
newID := db.NextID("logSources")
db.Lock()
defer db.Unlock()
s.ID = newID
if s.IsDefault {
for i := range db.LogSources {
db.LogSources[i].IsDefault = false
}
}
// 首个数据源自动设默认
if len(db.LogSources) == 0 {
s.IsDefault = true
}
db.LogSources = append(db.LogSources, *s)
db.RequestSave()
return s, nil
}
func encryptSourceCredentials(s *models.LogSource) error {
if s.BasicPass != "" {
v, err := crypto.EncryptField(s.BasicPass)
if err != nil {
return err
}
s.BasicPass = v
}
if s.BearerToken != "" {
v, err := crypto.EncryptField(s.BearerToken)
if err != nil {
return err
}
s.BearerToken = v
}
for name, value := range s.Headers {
if !validSourceHeader(name) {
return fmt.Errorf("自定义 Header 名称无效: %s", name)
}
v, err := crypto.EncryptField(value)
if err != nil {
return err
}
s.Headers[name] = v
}
return nil
}
func validSourceHeader(name string) bool {
if name == "" || strings.EqualFold(name, "Host") || strings.EqualFold(name, "Authorization") {
return false
}
for _, c := range name {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
return true
}
func UpdateLogSource(id uint, patch map[string]interface{}) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.LogSources {
if db.LogSources[i].ID != id {
continue
}
if v, ok := patch["name"].(string); ok && v != "" {
db.LogSources[i].Name = v
}
if v, ok := patch["namespace"].(string); ok {
db.LogSources[i].Namespace = v
}
if v, ok := patch["service"].(string); ok {
db.LogSources[i].Service = v
}
if v, ok := patch["port"].(float64); ok {
db.LogSources[i].Port = int(v)
}
if v, ok := patch["url"].(string); ok {
db.LogSources[i].URL = v
}
if v, ok := patch["authType"].(string); ok {
db.LogSources[i].AuthType = v
}
if v, ok := patch["basicUser"].(string); ok {
db.LogSources[i].BasicUser = v
}
if v, ok := patch["bearerToken"].(string); ok {
if v != "" {
enc, err := crypto.EncryptField(v)
if err != nil {
return err
}
db.LogSources[i].BearerToken = enc
}
}
if v, ok := patch["skipTLSVerify"].(bool); ok {
db.LogSources[i].SkipTLSVerify = v
}
if raw, ok := patch["headers"]; ok {
data, err := json.Marshal(raw)
if err != nil {
return err
}
var headers map[string]string
if err := json.Unmarshal(data, &headers); err != nil {
return err
}
for name, value := range headers {
if !validSourceHeader(name) {
return fmt.Errorf("自定义 Header 名称无效: %s", name)
}
if value == "" {
if old, ok := db.LogSources[i].Headers[name]; ok {
headers[name] = old
}
continue
}
enc, err := crypto.EncryptField(value)
if err != nil {
return err
}
headers[name] = enc
}
db.LogSources[i].Headers = headers
}
if v, ok := patch["basicPass"].(string); ok && v != "" {
if enc, err := crypto.EncryptField(v); err == nil {
db.LogSources[i].BasicPass = enc
}
}
if v, ok := patch["isDefault"].(bool); ok && v {
for j := range db.LogSources {
db.LogSources[j].IsDefault = db.LogSources[j].ID == id
}
}
if raw, ok := patch["access"]; ok {
data, err := json.Marshal(raw)
if err != nil {
return err
}
var access []models.LogSourceAccess
if err := json.Unmarshal(data, &access); err != nil {
return err
}
if err := ValidateSourceAccess(access); err != nil {
return err
}
db.LogSources[i].Access = access
}
db.LogSources[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
}
return fmt.Errorf("数据源不存在")
}
func ValidateSourceAccess(access []models.LogSourceAccess) error {
seen := map[uint]bool{}
for _, rule := range access {
if rule.UserID == 0 || seen[rule.UserID] {
return fmt.Errorf("访问策略的用户 ID 无效或重复")
}
seen[rule.UserID] = true
for k := range rule.Labels {
if !labelNamePattern.MatchString(k) {
return fmt.Errorf("访问策略包含无效标签名: %s", k)
}
}
}
return nil
}
func DeleteLogSource(id uint) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.LogSources {
if db.LogSources[i].ID == id {
db.LogSources = append(db.LogSources[:i], db.LogSources[i+1:]...)
groups := db.LogPolicyGroups[:0]
for _, group := range db.LogPolicyGroups {
if group.SourceID != id {
groups = append(groups, group)
}
}
db.LogPolicyGroups = groups
db.RequestSave()
return nil
}
}
return fmt.Errorf("数据源不存在")
}
// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host-binary→url;k8s→cluster-svc)
func EnsureLogSourceForDeployment(d *models.LokiDeployment) {
if d == nil || d.Endpoint == "" {
return
}
db := repository.GetDB()
db.RLock()
for i := range db.LogSources {
if db.LogSources[i].Name == d.Name {
db.RUnlock()
return
}
}
db.RUnlock()
svc := "loki"
port := 3100
src := &models.LogSource{
Name: d.Name, TargetType: "cluster-svc",
ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port,
AuthType: "none",
}
if d.Mode == "host-binary" {
src.TargetType = "url"
src.URL = d.Endpoint
src.ClusterID = nil
}
_, _ = CreateLogSource(src)
func newLokiTransport(skipTLSVerify bool) *http.Transport {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: skipTLSVerify} //nolint:gosec // 数据源显式配置
return transport
}
// ==================== Loki HTTP 访问 ====================
// lokiProxyGET 对数据源执行 GET;cluster-svc 走 kubectl --raw service proxy。
// lokiProxyGET 对数据源执行 GET;cluster-svc 使用 client-go 的认证传输访问 API Server Service Proxy。
func lokiProxyGET(ctx context.Context, src *models.LogSource, path string, query url.Values) ([]byte, int, error) {
// cluster-svc:kubectl get --raw /api/v1/namespaces/{ns}/services/{svc}:{port}/proxy{path}?q
if src.TargetType == "cluster-svc" {
db := repository.GetDB()
db.RLock()
@@ -381,25 +55,39 @@ func lokiProxyGET(ctx context.Context, src *models.LogSource, path string, query
if cluster == nil {
return nil, 500, fmt.Errorf("数据源关联的集群不存在")
}
env, cleanup, err := kubeconfigEnv(cluster)
if !kubernetesNamespacePattern.MatchString(src.Namespace) || !kubernetesNamespacePattern.MatchString(src.Service) || src.Port < 1 || src.Port > 65535 {
return nil, 500, fmt.Errorf("Loki 数据源的命名空间、Service 或端口无效")
}
config, err := clusterRESTConfig(cluster, logQueryTimeout)
if err != nil {
return nil, 500, err
}
defer cleanup()
proxyPath := fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%d/proxy%s",
client, err := rest.HTTPClientFor(config)
if err != nil {
return nil, 500, fmt.Errorf("创建 Kubernetes API 客户端失败: %w", err)
}
full := strings.TrimRight(config.Host, "/") + fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%d/proxy%s",
src.Namespace, src.Service, src.Port, path)
full := proxyPath
if qs := query.Encode(); qs != "" {
full += "?" + qs
}
cmd := exec.CommandContext(ctx, "kubectl", "get", "--raw", full)
cmd.Env = env
out, err := cmd.CombinedOutput()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return nil, 502, fmt.Errorf("kubectl 代理查询失败: %v\n%s", err, truncateStr(string(out), 300))
return nil, 500, err
}
return out, 200, nil
resp, err := client.Do(req)
if err != nil {
return nil, 502, fmt.Errorf("Kubernetes API 代理查询失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, 502, err
}
if resp.StatusCode >= 400 {
return body, resp.StatusCode, fmt.Errorf("Kubernetes API 代理返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 300))
}
return body, resp.StatusCode, nil
}
// url 直连
@@ -415,9 +103,10 @@ func lokiProxyGET(ctx context.Context, src *models.LogSource, path string, query
return nil, 500, err
}
applyLokiAuth(req, src)
client := &http.Client{Timeout: 60 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: src.SkipTLSVerify},
}}
client := lokiHTTPClients[0]
if src.SkipTLSVerify {
client = lokiHTTPClients[1]
}
resp, err := client.Do(req)
if err != nil {
return nil, 502, err
@@ -477,6 +166,42 @@ type LogQueryResult struct {
NextCursor string `json:"nextCursor"`
}
// ContextLogSelector uses stable stream identity labels. Loki can add per-entry
// labels such as detected_level to query results; matching those would hide
// adjacent lines whose detected level differs from the selected line.
func ContextLogSelector(labels map[string]string) (string, error) {
for key := range labels {
if !ValidLogLabelName(key) {
return "", fmt.Errorf("标签名无效")
}
}
keys := []string{"cluster", "namespace", "app", "pod", "container", "filename"}
if labels["pod"] == "" {
keys = append(keys, "job", "instance", "host")
}
parts := make([]string, 0, len(keys))
for _, key := range keys {
if value := labels[key]; value != "" {
encoded, _ := json.Marshal(value)
parts = append(parts, key+"="+string(encoded))
}
}
if len(parts) == 0 {
for key, value := range labels {
if value == "" || key == "detected_level" || key == "level" || strings.HasPrefix(key, "__") {
continue
}
encoded, _ := json.Marshal(value)
parts = append(parts, key+"="+string(encoded))
}
sort.Strings(parts)
}
if len(parts) == 0 {
return "", fmt.Errorf("缺少可用于定位日志流的标签")
}
return "{" + strings.Join(parts, ",") + "}", nil
}
// QueryLokiLogs 日志查询:flatten streams → 统一行;cursor(最后一条纳秒时间戳)向后翻页
func QueryLokiLogs(ctx context.Context, src *models.LogSource, query string, start, end int64, limit int, direction, cursor string) (*LogQueryResult, error) {
if limit <= 0 {
+14
View File
@@ -40,3 +40,17 @@ func TestPolicyGroupAccess(t *testing.T) {
t.Fatal("nonmember gained access")
}
}
func TestContextLogSelectorIgnoresDetectedLevel(t *testing.T) {
selector, err := ContextLogSelector(map[string]string{
"cluster": "demo-prod", "namespace": "monitor", "app": "consul",
"pod": "consul-0", "container": "consul", "detected_level": "info",
"filename": "/var/log/pods/consul/1.log",
})
if err != nil {
t.Fatal(err)
}
if selector != `{cluster="demo-prod",namespace="monitor",app="consul",pod="consul-0",container="consul",filename="/var/log/pods/consul/1.log"}` {
t.Fatalf("unexpected context selector: %s", selector)
}
}
+350
View File
@@ -0,0 +1,350 @@
// 日志数据源管理 — CRUD、默认源、部署自动注册(Managed 标记)。
package services
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
)
// ==================== 数据源 CRUD ====================
func GetLogSource(id uint) *models.LogSource {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.LogSources {
if db.LogSources[i].ID == id {
s := db.LogSources[i]
return &s
}
}
return nil
}
func ListLogSources() []map[string]interface{} {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
out := make([]map[string]interface{}, 0, len(db.LogSources))
for i := range db.LogSources {
out = append(out, db.LogSources[i].Sanitized())
}
return out
}
func ListVisibleLogSources(userID uint, admin bool) []map[string]interface{} {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
out := make([]map[string]interface{}, 0)
for i := range db.LogSources {
s := &db.LogSources[i]
if admin || policyGroupAccess(db.LogPolicyGroups, s.ID, userID) != nil || sourceAccess(s, userID) != nil {
item := s.Sanitized()
if !admin {
item = map[string]interface{}{"id": s.ID, "name": s.Name, "isDefault": s.IsDefault}
}
out = append(out, item)
}
}
return out
}
func sourceAccess(src *models.LogSource, userID uint) *models.LogSourceAccess {
for i := range src.Access {
if src.Access[i].UserID == userID {
return &src.Access[i]
}
}
return nil
}
var labelNamePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
func ValidLogLabelName(name string) bool { return labelNamePattern.MatchString(name) }
// AuthorizedLogQuery constrains the Loki stream selector server-side. Non-admins must be explicitly granted access.
func AuthorizedLogQuery(src *models.LogSource, userID uint, admin bool, query string) (string, error) {
if admin {
return query, nil
}
db := repository.GetDB()
var access *models.LogSourceAccess
if db != nil {
db.RLock()
access = policyGroupAccess(db.LogPolicyGroups, src.ID, userID)
db.RUnlock()
}
if access == nil {
access = sourceAccess(src, userID)
}
if access == nil {
return "", fmt.Errorf("无权查询此数据源")
}
q := strings.TrimSpace(query)
if !strings.HasPrefix(q, "{") {
return "", fmt.Errorf("普通用户查询必须以日志流选择器开头")
}
close := strings.IndexByte(q, '}')
if close < 0 || strings.Contains(q[close+1:], "{") {
return "", fmt.Errorf("查询只能包含一个日志流选择器")
}
if len(access.Labels) == 0 {
return q, nil
}
keys := make([]string, 0, len(access.Labels))
for k := range access.Labels {
if !labelNamePattern.MatchString(k) {
return "", fmt.Errorf("访问策略包含无效标签名")
}
keys = append(keys, k)
}
sort.Strings(keys)
matchers := make([]string, 0, len(keys))
for _, k := range keys {
v, _ := json.Marshal(access.Labels[k])
matchers = append(matchers, k+"="+string(v))
}
inner := strings.TrimSpace(q[1:close])
if inner != "" {
inner += ","
}
return "{" + inner + strings.Join(matchers, ",") + "}" + q[close+1:], nil
}
func CreateLogSource(s *models.LogSource) (*models.LogSource, error) {
if err := encryptSourceCredentials(s); err != nil {
return nil, err
}
db := repository.GetDB()
newID := db.NextID("logSources")
db.Lock()
defer db.Unlock()
s.ID = newID
if s.IsDefault {
for i := range db.LogSources {
db.LogSources[i].IsDefault = false
}
}
// 首个数据源自动设默认
if len(db.LogSources) == 0 {
s.IsDefault = true
}
db.LogSources = append(db.LogSources, *s)
db.RequestSave()
return s, nil
}
func encryptSourceCredentials(s *models.LogSource) error {
if s.BasicPass != "" {
v, err := crypto.EncryptField(s.BasicPass)
if err != nil {
return err
}
s.BasicPass = v
}
if s.BearerToken != "" {
v, err := crypto.EncryptField(s.BearerToken)
if err != nil {
return err
}
s.BearerToken = v
}
for name, value := range s.Headers {
if !validSourceHeader(name) {
return fmt.Errorf("自定义 Header 名称无效: %s", name)
}
v, err := crypto.EncryptField(value)
if err != nil {
return err
}
s.Headers[name] = v
}
return nil
}
func validSourceHeader(name string) bool {
if name == "" || strings.EqualFold(name, "Host") || strings.EqualFold(name, "Authorization") {
return false
}
for _, c := range name {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
return true
}
func UpdateLogSource(id uint, patch map[string]interface{}) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.LogSources {
if db.LogSources[i].ID != id {
continue
}
if v, ok := patch["name"].(string); ok && v != "" {
db.LogSources[i].Name = v
}
if v, ok := patch["namespace"].(string); ok {
db.LogSources[i].Namespace = v
}
if v, ok := patch["service"].(string); ok {
db.LogSources[i].Service = v
}
if v, ok := patch["port"].(float64); ok {
db.LogSources[i].Port = int(v)
}
if v, ok := patch["url"].(string); ok {
db.LogSources[i].URL = v
}
if v, ok := patch["authType"].(string); ok {
db.LogSources[i].AuthType = v
}
if v, ok := patch["basicUser"].(string); ok {
db.LogSources[i].BasicUser = v
}
if v, ok := patch["bearerToken"].(string); ok {
if v != "" {
enc, err := crypto.EncryptField(v)
if err != nil {
return err
}
db.LogSources[i].BearerToken = enc
}
}
if v, ok := patch["skipTLSVerify"].(bool); ok {
db.LogSources[i].SkipTLSVerify = v
}
if raw, ok := patch["headers"]; ok {
data, err := json.Marshal(raw)
if err != nil {
return err
}
var headers map[string]string
if err := json.Unmarshal(data, &headers); err != nil {
return err
}
for name, value := range headers {
if !validSourceHeader(name) {
return fmt.Errorf("自定义 Header 名称无效: %s", name)
}
if value == "" {
if old, ok := db.LogSources[i].Headers[name]; ok {
headers[name] = old
}
continue
}
enc, err := crypto.EncryptField(value)
if err != nil {
return err
}
headers[name] = enc
}
db.LogSources[i].Headers = headers
}
if v, ok := patch["basicPass"].(string); ok && v != "" {
if enc, err := crypto.EncryptField(v); err == nil {
db.LogSources[i].BasicPass = enc
}
}
if v, ok := patch["isDefault"].(bool); ok && v {
for j := range db.LogSources {
db.LogSources[j].IsDefault = db.LogSources[j].ID == id
}
}
if raw, ok := patch["access"]; ok {
data, err := json.Marshal(raw)
if err != nil {
return err
}
var access []models.LogSourceAccess
if err := json.Unmarshal(data, &access); err != nil {
return err
}
if err := ValidateSourceAccess(access); err != nil {
return err
}
db.LogSources[i].Access = access
}
db.LogSources[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
}
return fmt.Errorf("数据源不存在")
}
func ValidateSourceAccess(access []models.LogSourceAccess) error {
seen := map[uint]bool{}
for _, rule := range access {
if rule.UserID == 0 || seen[rule.UserID] {
return fmt.Errorf("访问策略的用户 ID 无效或重复")
}
seen[rule.UserID] = true
for k := range rule.Labels {
if !labelNamePattern.MatchString(k) {
return fmt.Errorf("访问策略包含无效标签名: %s", k)
}
}
}
return nil
}
func DeleteLogSource(id uint) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.LogSources {
if db.LogSources[i].ID == id {
db.LogSources = append(db.LogSources[:i], db.LogSources[i+1:]...)
groups := db.LogPolicyGroups[:0]
for _, group := range db.LogPolicyGroups {
if group.SourceID != id {
groups = append(groups, group)
}
}
db.LogPolicyGroups = groups
db.RequestSave()
return nil
}
}
return fmt.Errorf("数据源不存在")
}
// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host-binary→url;k8s→cluster-svc)
func EnsureLogSourceForDeployment(d *models.LokiDeployment) {
if d == nil || d.Endpoint == "" {
return
}
db := repository.GetDB()
db.RLock()
for i := range db.LogSources {
if db.LogSources[i].Name == d.Name {
db.RUnlock()
return
}
}
db.RUnlock()
svc := "loki"
port := 3100
src := &models.LogSource{
Name: d.Name, TargetType: "cluster-svc",
ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port,
AuthType: "none",
}
if d.Mode == "host-binary" || d.Mode == "host-docker" {
src.TargetType = "url"
src.URL = d.Endpoint
src.ClusterID = nil
}
_, _ = CreateLogSource(src)
}
+370
View File
@@ -0,0 +1,370 @@
// LokiDeployer — loki.yaml 配置生成:模板 + 对象存储/规则存储/ring 拓扑。
package services
import (
"bytes"
"fmt"
"text/template"
)
const lokiConfigTemplate = `auth_enabled: {{ .AuthEnabled }}
common:
path_prefix: /var/lib/loki
{{- if .FilesystemStorage }}
storage:
filesystem:
chunks_directory: /var/lib/loki/chunks
rules_directory: /var/lib/loki/rules
{{- end }}
replication_factor: {{ .ReplicationFactor }}
ring:
instance_addr: {{ .RingInstanceAddr }}
kvstore:
store: {{ .RingKVStore }}
{{- if .StorageConfigBlock }}
storage_config:
{{ .StorageConfigBlock }}
{{- end }}
{{- if .MemberlistBlock }}
{{ .MemberlistBlock }}
{{- end }}
ingester:
wal:
dir: /var/lib/loki/wal
flush_on_shutdown: true
replay_memory_ceiling: 1GB
lifecycler:
ring:
kvstore:
store: {{ .RingKVStore }}
replication_factor: {{ .ReplicationFactor }}
{{- if .MultiReplica }}
autoforget_unhealthy: true
{{- end }}
compactor:
working_directory: /var/lib/loki/compactor
retention_enabled: true
delete_request_store: {{ .CompactorStoreType }}
limits_config:
retention_period: {{ .RetentionPeriod }}h
schema_config:
configs:
- from: "2024-01-01"
store: tsdb
object_store: {{ .ObjectStore }}
schema: v13
index:
prefix: tsdb_index_
period: 24h
ruler:
storage:
type: {{ .RulerStoreType }}
{{- if .RulerStorageBlock }}
{{ .RulerStorageBlock }}
{{- end }}
{{- if eq .RulerStoreType "local" }}
local:
directory: /var/lib/loki/rules
{{- end }}
rule_path: /var/lib/loki/rules-temp
ring:
kvstore:
store: {{ .RingKVStore }}
analytics:
reporting_enabled: false
`
type lokiConfigData struct {
AuthEnabled bool
ReplicationFactor int
RetentionPeriod int
StorageConfigBlock string
ObjectStore string
RulerStoreType string
CompactorStoreType string
RingInstanceAddr string // 单副本 127.0.0.1;多副本 0.0.0.0(须可被对端寻址)
RingKVStore string // 单副本 inmemory;多副本 memberlist
MemberlistBlock string // 多副本时非空,注入 memberlist 顶级配置
RulerStorageBlock string // 对象存储时非空,注入 ruler.storage.s3/gcs/azure 嵌套配置
MultiReplica bool
FilesystemStorage bool
}
func (d *LokiDeployer) generateLokiConfig() string {
retentionHours := d.deployment.RetentionDays * 24
if retentionHours <= 0 {
retentionHours = DefaultRetentionDays * 24
}
storageType := d.effectiveStorageType()
storageBlock := d.buildStorageConfigBlock()
objectStore := "filesystem"
rulerStore := "local"
compactorStore := "filesystem"
switch storageType {
case "s3", "oss", "minio":
objectStore = "s3"
rulerStore = "s3"
compactorStore = "s3"
case "gcs":
objectStore = "gcs"
rulerStore = "gcs"
compactorStore = "gcs"
case "azure":
objectStore = "azure"
rulerStore = "azure"
compactorStore = "azure"
}
// Ring 拓扑:单副本用 inmemory + 127.0.0.1(无需跨实例通信);
// 多副本必须切 memberlist + 0.0.0.0,否则 ring 无法收敛、写入失败。
ringAddr := "127.0.0.1"
ringStore := "inmemory"
memberlistBlock := ""
if d.deployment.Replicas >= 2 {
ringAddr = "0.0.0.0"
ringStore = "memberlist"
ns := d.deployment.Namespace
if ns == "" {
ns = "log"
}
memberlistBlock = fmt.Sprintf(`memberlist:
join_members:
- loki-headless.%s.svc.cluster.local
bind_port: 7946
dead_node_reclaim_time: 30s
gossip_to_dead_nodes_time: 2m
left_ingesters_timeout: 5m`, ns)
}
data := lokiConfigData{
AuthEnabled: d.deployment.AuthEnabled,
ReplicationFactor: d.replicationFactor(),
RetentionPeriod: retentionHours,
StorageConfigBlock: storageBlock,
ObjectStore: objectStore,
RulerStoreType: rulerStore,
CompactorStoreType: compactorStore,
RingInstanceAddr: ringAddr,
RingKVStore: ringStore,
MemberlistBlock: memberlistBlock,
RulerStorageBlock: d.buildRulerStorageBlock(),
MultiReplica: d.deployment.Replicas >= 2,
FilesystemStorage: objectStore == "filesystem",
}
tmpl, err := template.New("loki-config").Parse(lokiConfigTemplate)
if err != nil {
return d.generateLokiConfigFallback(retentionHours)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return d.generateLokiConfigFallback(retentionHours)
}
return buf.String()
}
func (d *LokiDeployer) buildStorageConfigBlock() string {
if d.deployment.StorageConfig == nil {
return ""
}
sc := d.deployment.StorageConfig
accessKey, secretKey := d.storageCredentialValues()
switch sc.StorageType {
case "s3":
endpoint := sc.Endpoint
if endpoint == "" {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region)
}
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` aws:
bucketnames: %s
endpoint: %s
region: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure)
case "oss":
return fmt.Sprintf(` alibabacloud:
bucket: %s
endpoint: %s
region: %s
access_key_id: %s
secret_access_key: %s`, sc.Bucket, sc.Endpoint, sc.Region, accessKey, secretKey)
case "minio":
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` aws:
bucketnames: %s
endpoint: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, sc.Endpoint, accessKey, secretKey, insecure)
case "gcs":
return fmt.Sprintf(` gcs:
bucket_name: %s`, sc.Bucket)
case "azure":
return fmt.Sprintf(` azure:
container_name: %s
account_name: %s
account_key: %s`, sc.Bucket, accessKey, secretKey)
default:
return ""
}
}
// buildRulerStorageBlock 生成 ruler.storage 下与存储类型同名的嵌套配置
// (Loki 3.x 的 ruler 存储客户端不回退读取 storage_config,缺少此块会以空 bucket 启动失败)
func (d *LokiDeployer) buildRulerStorageBlock() string {
if d.deployment.StorageConfig == nil {
return ""
}
sc := d.deployment.StorageConfig
accessKey, secretKey := d.storageCredentialValues()
switch sc.StorageType {
case "s3":
endpoint := sc.Endpoint
if endpoint == "" {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", sc.Region)
}
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` s3:
bucketnames: %s
endpoint: %s
region: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure)
case "oss":
return fmt.Sprintf(` s3:
bucketnames: %s
endpoint: %s
access_key_id: %s
secret_access_key: %s
s3forcepathstyle: true`, sc.Bucket, sc.Endpoint, accessKey, secretKey)
case "minio":
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` s3:
bucketnames: %s
endpoint: %s
access_key_id: %s
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, sc.Endpoint, accessKey, secretKey, insecure)
case "gcs":
return fmt.Sprintf(` gcs:
bucket_name: %s`, sc.Bucket)
case "azure":
return fmt.Sprintf(` azure:
container_name: %s
account_name: %s
account_key: %s`, sc.Bucket, accessKey, secretKey)
default:
return ""
}
}
func (d *LokiDeployer) storageCredentialValues() (string, string) {
if d.deployment.StorageConfig == nil {
return "", ""
}
if d.deployment.Mode == "k8s-yaml" || d.deployment.Mode == "host-docker" {
return "${LOKI_STORAGE_ACCESS_KEY}", "${LOKI_STORAGE_SECRET_KEY}"
}
return d.deployment.StorageConfig.AccessKey, d.deployment.StorageConfig.SecretKey
}
// generateLokiConfigFallback 当模板解析失败时的降级配置
func (d *LokiDeployer) generateLokiConfigFallback(retentionHours int) string {
return fmt.Sprintf(`auth_enabled: %v
server:
http_listen_port: 3100
grpc_listen_port: 9095
http_server_read_timeout: 3m
http_server_write_timeout: 3m
log_level: info
common:
path_prefix: /var/lib/loki
storage:
filesystem:
chunks_directory: /var/lib/loki/chunks
rules_directory: /var/lib/loki/rules
replication_factor: %d
ring:
instance_addr: 127.0.0.1
kvstore:
store: inmemory
ingester:
wal:
enabled: true
dir: /var/lib/loki/wal
lifecycler:
ring:
kvstore:
store: inmemory
replication_factor: %d
final_sleep: 0s
chunk_idle_period: 30m
max_chunk_age: 2h
querier:
max_concurrent: 10
compactor:
working_directory: /var/lib/loki/compactor
retention_enabled: true
retention_delete_delay: 2h
limits_config:
retention_period: %dh
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
schema_config:
configs:
- from: "2024-01-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: tsdb_index_
period: 24h
analytics:
reporting_enabled: false
`, d.deployment.AuthEnabled, d.replicationFactor(), d.replicationFactor(), retentionHours)
}
// ==================== 辅助函数 ====================
func (d *LokiDeployer) replicationFactor() int {
if d.deployment.Replicas >= 3 {
return 3
}
if d.deployment.Replicas >= 2 {
return 2
}
return 1
}
File diff suppressed because it is too large. Load diff
+149
View File
@@ -0,0 +1,149 @@
package services
import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"
"opsxloki/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 dockerConfigPath(id uint) string { return dockerConfigDir(id) + "/loki.yaml" }
func dockerDataDir(id uint) string { return fmt.Sprintf("/var/lib/lokideploy/loki-%d", id) }
func dockerLokiImage(version string) (string, error) {
if version == "" {
version = "3.0.0"
}
for _, supported := range LokiVersionList {
if supported == version && strings.HasPrefix(version, "3.") {
return "grafana/loki:" + version, nil
}
}
return "", fmt.Errorf("主机 Docker 部署请选择支持的 Loki 3.x 版本")
}
func stopDockerLoki(ctx context.Context, deployment *models.LokiDeployment) error {
if deployment.Host == nil {
return fmt.Errorf("部署未关联主机")
}
client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host))
if err != nil {
return fmt.Errorf("连接部署主机失败: %w", err)
}
defer client.Close()
if _, stderr, err := client.Execute(ctx, "docker stop "+dockerContainerName(deployment.ID)); err != nil {
return fmt.Errorf("停止 Loki 容器失败: %v: %s", err, strings.TrimSpace(stderr))
}
return nil
}
func deleteDockerLoki(ctx context.Context, deployment *models.LokiDeployment) error {
if deployment.Host == nil {
return fmt.Errorf("部署未关联主机")
}
client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host))
if err != nil {
return fmt.Errorf("连接部署主机失败: %w", err)
}
defer client.Close()
name := dockerContainerName(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)
if _, stderr, err := client.Execute(ctx, cmd); err != nil {
return fmt.Errorf("清理 Loki 容器与配置失败: %v: %s", err, strings.TrimSpace(stderr))
}
return nil
}
// uploadDockerFile 通过 SSH 写入固定路径。内容仅以 base64 传给远程 shell,避免 YAML 或凭证参与 shell 解析。
func uploadDockerFile(ctx context.Context, client *SSHClient, path, content, mode string) error {
encoded := base64.StdEncoding.EncodeToString([]byte(content))
cmd := fmt.Sprintf("umask 077 && printf '%%s' '%s' | base64 -d > %s && chmod %s %s", encoded, path, mode, path)
_, _, err := client.Execute(ctx, cmd)
if err != nil {
return fmt.Errorf("写入主机配置失败: %w", err)
}
return nil
}
func (d *LokiDeployer) deployDocker(ctx context.Context) error {
deployment := d.deployment
image, err := dockerLokiImage(deployment.Version)
if err != nil {
return err
}
if deployment.Host == nil {
return fmt.Errorf("请选择目标主机")
}
client, err := NewSSHClientWithAuth(HostSSHAuth(deployment.Host))
if err != nil {
return err
}
defer client.Close()
if _, _, err := client.Execute(ctx, "docker info >/dev/null 2>&1"); err != nil {
return fmt.Errorf("目标主机 Docker 不可用,请检查安装状态及 SSH 用户权限")
}
name := dockerContainerName(deployment.ID)
configDir := dockerConfigDir(deployment.ID)
dataDir := dockerDataDir(deployment.ID)
if _, _, err := client.Execute(ctx, "mkdir -p "+configDir+" "+dataDir+" && chmod 755 "+configDir+" && chown 10001:10001 "+dataDir); err != nil {
return fmt.Errorf("创建主机持久化目录失败: %w", err)
}
config := d.generateLokiConfig()
if err := uploadDockerFile(ctx, client, dockerConfigPath(deployment.ID), config, "644"); err != nil {
return err
}
// env 文件由 Docker daemon 读取;Loki 配置只保留变量引用。
env := ""
if sc := deployment.StorageConfig; sc != nil {
for _, value := range []string{sc.AccessKey, sc.SecretKey} {
if strings.ContainsAny(value, "\r\n") {
return fmt.Errorf("存储凭证包含不支持的换行字符")
}
}
env = "LOKI_STORAGE_ACCESS_KEY=" + sc.AccessKey + "\nLOKI_STORAGE_SECRET_KEY=" + sc.SecretKey + "\n"
}
if err := uploadDockerFile(ctx, client, configDir+"/storage.env", env, "600"); err != nil {
return err
}
d.log("info", "拉取 Docker 镜像 "+image, "")
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")
port := deployment.ListenPort
if port == 0 {
port = 3100
}
listenIP := deployment.ListenIP
if listenIP == "" {
listenIP = deployment.Host.HostIP
}
cmd := fmt.Sprintf("docker run -d --name %s --restart unless-stopped --env-file %s/storage.env -p %s:%d:3100 -v %s:/etc/loki:ro -v %s:/var/lib/loki %s -config.file=/etc/loki/loki.yaml -config.expand-env=true", name, configDir, listenIP, port, configDir, dataDir, image)
if _, stderr, err := client.Execute(ctx, cmd); err != nil {
return fmt.Errorf("启动 Loki 容器失败: %v: %s", err, strings.TrimSpace(stderr))
}
d.log("info", "等待 Loki 容器就绪", "")
readyCmd := fmt.Sprintf("curl -fsS --max-time 2 http://%s:%d/ready >/dev/null", listenIP, port)
for i := 0; i < 30; i++ {
if _, _, err := client.Execute(ctx, readyCmd); err == nil {
d.log("success", fmt.Sprintf("Loki 已启动:http://%s:%d", listenIP, port), "")
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
return fmt.Errorf("Loki 容器启动后未就绪,请在目标主机执行 docker logs %s", name)
}
+41
View File
@@ -0,0 +1,41 @@
package services
import (
"strings"
"testing"
"opsxloki/models"
)
func TestDockerLokiConfigUsesEnvironmentForStorageCredentials(t *testing.T) {
deployment := &models.LokiDeployment{
ID: 7, Mode: "host-docker", RetentionDays: 60, Replicas: 1,
StorageConfig: &models.StorageConfig{
StorageType: "minio", Endpoint: "http://10.10.10.88:9000", Bucket: "loki",
AccessKey: "test-access-secret", SecretKey: "test-secret-secret",
},
}
config := NewLokiDeployer(deployment, nil).generateLokiConfig()
if strings.Contains(config, "test-access-secret") || strings.Contains(config, "test-secret-secret") {
t.Fatal("Docker Loki config embedded storage credentials")
}
for _, expected := range []string{"${LOKI_STORAGE_ACCESS_KEY}", "${LOKI_STORAGE_SECRET_KEY}", "retention_period: 1440h"} {
if !strings.Contains(config, expected) {
t.Errorf("config missing %q", expected)
}
}
if got := dockerContainerName(deployment.ID); got != "lokideploy-loki-7" {
t.Fatalf("unexpected container name: %s", got)
}
}
func TestDockerLokiImageRejectsShellInputAndOldVersions(t *testing.T) {
for _, version := range []string{"2.9.8", "3.0.0;touch /tmp/example", "3.9.9"} {
if _, err := dockerLokiImage(version); err == nil {
t.Errorf("accepted unsupported version %q", version)
}
}
if image, err := dockerLokiImage("3.4.0"); err != nil || image != "grafana/loki:3.4.0" {
t.Fatalf("unexpected image %q, error %v", image, err)
}
}
+243
View File
@@ -0,0 +1,243 @@
// LokiDeployer — k8s-yaml 模式 manifest 生成(StatefulSet/PVC/ConfigMap/Service)。
package services
import (
"crypto/sha256"
"encoding/base64"
"fmt"
)
func (d *LokiDeployer) generateManifest(ns string) string {
version := d.effectiveVersion()
image := ResolveImage("docker.io/grafana/loki:" + version)
replicas := d.deployment.Replicas
if replicas < 1 {
replicas = 1
}
storageSize := d.deployment.StorageSize
if storageSize == "" {
storageSize = "10Gi"
}
cpu := d.deployment.ResourcesCPU
mem := d.deployment.ResourcesMem
if cpu == "" {
cpu = "1000m"
}
if mem == "" {
mem = "1Gi"
}
configData := d.generateLokiConfig()
secretManifest := ""
configExpandArg := ""
storageEnvBlock := ""
if sc := d.deployment.StorageConfig; sc != nil {
switch sc.StorageType {
case "s3", "oss", "minio", "azure":
secretManifest = fmt.Sprintf(`---
apiVersion: v1
kind: Secret
metadata:
name: loki-storage-credentials
namespace: %s
type: Opaque
data:
accessKey: %s
secretKey: %s`, ns,
base64.StdEncoding.EncodeToString([]byte(sc.AccessKey)),
base64.StdEncoding.EncodeToString([]byte(sc.SecretKey)))
configExpandArg = "\n - -config.expand-env=true"
storageEnvBlock = `
env:
- name: LOKI_STORAGE_ACCESS_KEY
valueFrom:
secretKeyRef:
name: loki-storage-credentials
key: accessKey
- name: LOKI_STORAGE_SECRET_KEY
valueFrom:
secretKeyRef:
name: loki-storage-credentials
key: secretKey`
}
}
// ConfigMap/Secret 更新本身不会重建 Pod。把内容摘要写入 Pod 模板以触发滚动更新。
configHash := sha256.Sum256([]byte(configData + "\n" + secretManifest))
manifest := fmt.Sprintf(`apiVersion: v1
kind: Namespace
metadata:
name: %s
---
apiVersion: v1
kind: ConfigMap
metadata:
name: loki-config
namespace: %s
data:
loki.yaml: |
%s
%s
---
apiVersion: v1
kind: Service
metadata:
name: loki
namespace: %s
labels:
app: loki
spec:
type: ClusterIP
ports:
- name: http
port: 3100
targetPort: 3100
protocol: TCP
- name: grpc
port: 9095
targetPort: 9095
protocol: TCP
selector:
app: loki
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: loki-data
namespace: %s
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: %s
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: loki
namespace: %s
labels:
app: loki
spec:
serviceName: loki
replicas: %d
selector:
matchLabels:
app: loki
template:
metadata:
labels:
app: loki
annotations:
checksum/loki-config: "%x"
spec:
securityContext:
runAsUser: 0
runAsGroup: 0
fsGroup: 0
containers:
- name: loki
image: grafana/loki:%s
imagePullPolicy: IfNotPresent
args:
- -config.file=/etc/loki/loki.yaml%s%s
ports:
- name: http
containerPort: 3100
protocol: TCP
- name: grpc
containerPort: 9095
protocol: TCP
resources:
limits:
cpu: "%s"
memory: "%s"
requests:
cpu: "200m"
memory: "256Mi"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
livenessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
volumeMounts:
- name: config
mountPath: /etc/loki
readOnly: true
- name: data
mountPath: /var/lib/loki
- name: wal
mountPath: /var/lib/loki/wal
volumes:
- name: config
configMap:
name: loki-config
- name: data
persistentVolumeClaim:
claimName: loki-data
- name: wal
emptyDir: {}
`, ns, ns, indentYAML(configData, 4), secretManifest, ns, ns, storageSize, ns, replicas, configHash, image, configExpandArg, storageEnvBlock, cpu, mem)
// 多副本:追加 Headless Service(clusterIP: None + selector app:loki),
// 使 memberlist 的 join_members (loki-headless.<ns>.svc.cluster.local) 能解析到所有 Pod IP。
if replicas >= 2 {
manifest += fmt.Sprintf(`
---
apiVersion: v1
kind: Service
metadata:
name: loki-headless
namespace: %s
labels:
app: loki
spec:
clusterIP: None
ports:
- name: http
port: 3100
targetPort: 3100
protocol: TCP
- name: grpc
port: 9095
targetPort: 9095
protocol: TCP
- name: memberlist
port: 7946
targetPort: 7946
protocol: TCP
selector:
app: loki
`, ns)
}
return manifest
}
// ==================== Loki 本地配置生成 (text/template) ====================
+146
View File
@@ -0,0 +1,146 @@
package services
// 飞书/Lark 群机器人通知 — 卡片消息(参照 opsx-sec notifier 与运维告警脚本的卡片结构),
// webhook 存 SystemSetting(AES 加密),对外只回 hasWebhook。
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
)
const (
notifySettingKey = "notify_settings"
notifyHTTPTimeout = 10 * time.Second
)
// NotifyConfig 通知配置(webhook/签名 加密存储)
type NotifyConfig struct {
Enabled bool `json:"enabled"`
Webhook string `json:"webhook"` // ENC: 密文存储
SigningSecret string `json:"signingSecret"` // 飞书签名校验(可选)
}
// NotifyPublicConfig 对外输出形态
type NotifyPublicConfig struct {
Enabled bool `json:"enabled"`
HasWebhook bool `json:"hasWebhook"`
HasSecret bool `json:"hasSecret"`
}
func GetNotifyConfig() *NotifyConfig {
db := repository.GetDB()
db.RLock()
var raw string
for i := range db.Settings {
if db.Settings[i].Key == notifySettingKey {
raw = db.Settings[i].Value
break
}
}
db.RUnlock()
cfg := &NotifyConfig{}
if raw != "" {
_ = json.Unmarshal([]byte(raw), cfg)
}
if c, err := DecryptCred(cfg.Webhook); err == nil {
cfg.Webhook = c
}
if c, err := DecryptCred(cfg.SigningSecret); err == nil {
cfg.SigningSecret = c
}
return cfg
}
func SaveNotifyConfig(enabled bool, webhook, secret string) error {
// 留空 = 保留旧值
old := GetNotifyConfig()
if webhook == "" {
webhook = old.Webhook
} else if enc, err := crypto.EncryptField(webhook); err == nil {
webhook = enc
}
if secret == "" {
secret = old.SigningSecret
} else if enc, err := crypto.EncryptField(secret); err == nil {
secret = enc
}
data, _ := json.Marshal(&NotifyConfig{Enabled: enabled, Webhook: webhook, SigningSecret: secret})
db := repository.GetDB()
newID := db.NextID("settings")
db.Lock()
defer db.Unlock()
for i := range db.Settings {
if db.Settings[i].Key == notifySettingKey {
db.Settings[i].Value = string(data)
db.RequestSave()
return nil
}
}
db.Settings = append(db.Settings, models.SystemSetting{ID: newID, Key: notifySettingKey, Value: string(data)})
db.RequestSave()
return nil
}
// larkSign 飞书签名:timestamp + "\n" + secret 的 HMAC-SHA256,base64
func larkSign(secret string, ts int64) string {
stringToSign := fmt.Sprintf("%d\n%s", ts, secret)
h := hmac.New(sha256.New, []byte(stringToSign))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
// SendLarkCard 发送飞书卡片;title 带颜色(red/orange),lines 为 markdown 内容行
func SendLarkCard(title, color string, lines []string) error {
cfg := GetNotifyConfig()
if !cfg.Enabled || cfg.Webhook == "" {
return fmt.Errorf("通知未启用或未配置 webhook")
}
content := strings.Join(lines, "\n")
payload := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"config": map[string]interface{}{"wide_screen_mode": true},
"header": map[string]interface{}{
"title": map[string]string{"tag": "plain_text", "content": title},
"template": color,
},
"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 日志告警"}}},
},
},
}
if cfg.SigningSecret != "" {
ts := time.Now().Unix()
payload["timestamp"] = fmt.Sprintf("%d", ts)
payload["sign"] = larkSign(cfg.SigningSecret, ts)
}
body, _ := json.Marshal(payload)
resp, err := (&http.Client{Timeout: notifyHTTPTimeout}).Post(cfg.Webhook, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("飞书返回 HTTP %d", resp.StatusCode)
}
var out struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
if out.Code != 0 {
return fmt.Errorf("飞书拒绝: %s", out.Msg)
}
return nil
}
+226
View File
@@ -0,0 +1,226 @@
package services
// 多通知通道(飞书/Lark 群机器人)CRUD 与按通道发送。
// 取代旧的单 webhook 全局配置:每个告警群一个通道,告警规则各自关联。
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
)
const channelHTTPTimeout = 10 * time.Second
func ListNotifyChannels() []map[string]interface{} {
db := repository.GetDB()
db.RLock()
out := make([]map[string]interface{}, 0, len(db.NotifyChannels))
for i := range db.NotifyChannels {
out = append(out, db.NotifyChannels[i].Sanitized())
}
db.RUnlock()
return out
}
func GetNotifyChannel(id uint) *models.NotifyChannel {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.NotifyChannels {
if db.NotifyChannels[i].ID == id {
c := db.NotifyChannels[i]
return &c
}
}
return nil
}
// GetNotifyChannelDecrypted 取通道并解密凭证(发送用)
func GetNotifyChannelDecrypted(id uint) *models.NotifyChannel {
c := GetNotifyChannel(id)
if c == nil {
return nil
}
if w, err := DecryptCred(c.Webhook); err == nil {
c.Webhook = w
}
if s, err := DecryptCred(c.SigningSecret); err == nil {
c.SigningSecret = s
}
return c
}
// SaveNotifyChannel 新建(id=0)或更新;webhook/secret 非空才更新(留空=保留旧值)
func SaveNotifyChannel(id uint, name, webhook, secret string, enabled bool) (*models.NotifyChannel, error) {
db := repository.GetDB()
// 更新:留空字段回填旧值(旧值已是密文)
if id > 0 {
db.RLock()
var old models.NotifyChannel
found := false
for i := range db.NotifyChannels {
if db.NotifyChannels[i].ID == id {
old = db.NotifyChannels[i]
found = true
break
}
}
db.RUnlock()
if !found {
return nil, fmt.Errorf("通道不存在")
}
if webhook == "" {
webhook = old.Webhook
} else if enc, err := crypto.EncryptField(webhook); err == nil {
webhook = enc
}
if secret == "" {
secret = old.SigningSecret
} else if enc, err := crypto.EncryptField(secret); err == nil {
secret = enc
}
db.Lock()
defer db.Unlock()
for i := range db.NotifyChannels {
if db.NotifyChannels[i].ID == id {
db.NotifyChannels[i].Name = name
db.NotifyChannels[i].Webhook = webhook
db.NotifyChannels[i].SigningSecret = secret
db.NotifyChannels[i].Enabled = enabled
db.NotifyChannels[i].UpdatedAt = time.Now()
db.RequestSave()
c := db.NotifyChannels[i]
return &c, nil
}
}
return nil, fmt.Errorf("通道不存在")
}
// 新建
if name == "" {
return nil, fmt.Errorf("通道名称不能为空")
}
if webhook == "" {
return nil, fmt.Errorf("webhook 不能为空")
}
if enc, err := crypto.EncryptField(webhook); err == nil {
webhook = enc
}
if secret != "" {
if enc, err := crypto.EncryptField(secret); err == nil {
secret = enc
}
}
newID := db.NextID("notifyChannels")
ch := &models.NotifyChannel{
ID: newID, Name: name, Webhook: webhook, SigningSecret: secret, Enabled: enabled,
}
db.Lock()
defer db.Unlock()
db.NotifyChannels = append(db.NotifyChannels, *ch)
db.RequestSave()
return ch, nil
}
func DeleteNotifyChannel(id uint) error {
// 有关联规则时拒绝删除
db := repository.GetDB()
db.RLock()
for i := range db.AlertRules {
for _, chID := range db.AlertRules[i].ChannelIDs {
if chID == id {
db.RUnlock()
return fmt.Errorf("仍有告警规则关联此通道,请先解除关联")
}
}
}
db.RUnlock()
db.Lock()
defer db.Unlock()
for i := range db.NotifyChannels {
if db.NotifyChannels[i].ID == id {
db.NotifyChannels = append(db.NotifyChannels[:i], db.NotifyChannels[i+1:]...)
db.RequestSave()
return nil
}
}
return fmt.Errorf("通道不存在")
}
// larkSignChannel 飞书签名
func larkSignChannel(secret string, ts int64) string {
stringToSign := fmt.Sprintf("%d\n%s", ts, secret)
h := hmac.New(sha256.New, []byte(stringToSign))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
// SendLarkCardToChannel 向指定通道发送卡片
func SendLarkCardToChannel(channelID uint, title, color string, lines []string) error {
ch := GetNotifyChannelDecrypted(channelID)
if ch == nil {
return fmt.Errorf("通知通道不存在")
}
if !ch.Enabled {
return fmt.Errorf("通道「%s」已禁用", ch.Name)
}
content := joinNonEmpty(lines, "\n")
payload := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"config": map[string]interface{}{"wide_screen_mode": true},
"header": map[string]interface{}{
"title": map[string]string{"tag": "plain_text", "content": title},
"template": color,
},
"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 日志告警"}}},
},
},
}
if ch.SigningSecret != "" {
ts := time.Now().Unix()
payload["timestamp"] = fmt.Sprintf("%d", ts)
payload["sign"] = larkSignChannel(ch.SigningSecret, ts)
}
body, _ := json.Marshal(payload)
resp, err := (&http.Client{Timeout: channelHTTPTimeout}).Post(ch.Webhook, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("飞书返回 HTTP %d", resp.StatusCode)
}
var out struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
if out.Code != 0 {
return fmt.Errorf("飞书拒绝: %s", out.Msg)
}
return nil
}
func joinNonEmpty(items []string, sep string) string {
result := ""
for _, item := range items {
if item == "" {
continue
}
if result != "" {
result += sep
}
result += item
}
return result
}
+21
View File
@@ -108,6 +108,27 @@ func (s *StorageService) Update(id uint, updates map[string]interface{}) error {
if v, ok := updates["region"].(string); ok {
db.Storages[i].Region = v
}
if v, ok := updates["pathPrefix"].(string); ok {
db.Storages[i].PathPrefix = v
}
if v, ok := updates["insecure"].(bool); ok {
db.Storages[i].Insecure = v
}
// 凭证编辑约定:传入非空才更新(留空=保留旧密文)
if v, ok := updates["accessKey"].(string); ok && v != "" {
enc, err := crypto.EncryptField(v)
if err != nil {
return fmt.Errorf("AccessKey 加密失败: %w", err)
}
db.Storages[i].AccessKey = enc
}
if v, ok := updates["secretKey"].(string); ok && v != "" {
enc, err := crypto.EncryptField(v)
if err != nil {
return fmt.Errorf("SecretKey 加密失败: %w", err)
}
db.Storages[i].SecretKey = enc
}
if v, ok := updates["isDefault"].(bool); ok {
if v {
for j := range db.Storages {
+7 -7
View File
@@ -24,13 +24,13 @@ type StorageTestResult struct {
// StorageTestParams 测试参数(与创建存储配置一致,额外支持自动建桶)
type StorageTestParams struct {
StorageType string
Endpoint string
Bucket string
Region string
AccessKey string
SecretKey string
Insecure bool
StorageType string
Endpoint string
Bucket string
Region string
AccessKey string
SecretKey string
Insecure bool
CreateIfMissing bool
}
+34
View File
@@ -0,0 +1,34 @@
package services
import (
"testing"
"opsxloki/repository"
)
func TestStorageUpdateReplacesBothCredentials(t *testing.T) {
t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("JWT_SECRET", "test-jwt-secret-with-at-least-32-characters")
t.Setenv("ENCRYPTION_KEY", "test-encryption-key-with-at-least-32-characters")
if err := repository.InitDB(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repository.Shutdown() })
svc := NewStorageService()
storage, err := svc.Create("test", "minio", "http://localhost:9000", "loki", "", "old-access", "old-secret", "", true, true)
if err != nil {
t.Fatal(err)
}
if err := svc.Update(storage.ID, map[string]interface{}{"accessKey": "new-access", "secretKey": "new-secret"}); err != nil {
t.Fatal(err)
}
updated, err := svc.Get(storage.ID)
if err != nil {
t.Fatal(err)
}
access, accessErr := DecryptCred(updated.AccessKey)
secret, secretErr := DecryptCred(updated.SecretKey)
if accessErr != nil || secretErr != nil || access != "new-access" || secret != "new-secret" {
t.Fatal("storage credentials were not both updated and decryptable")
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
# Logs
logs
/logs/
*.log
npm-debug.log*
yarn-debug.log*
+66
View File
@@ -0,0 +1,66 @@
// 告警域 API — 飞书通知配置 + 告警规则
import { http } from '../http'
export interface NotifyConfig {
enabled: boolean
hasWebhook: boolean
hasSecret: boolean
}
export interface AlertRule {
id: number
name: string
sourceId: number
channelIds: number[]
query: string
windowMin: number
threshold: number
enabled: boolean
cooldownMin: number
lastFiredAt?: string
createdAt?: string
}
export interface AlertDelivery {
id: number
ruleId: number
ruleName: string
count: number
status: 'sent' | 'failed' | 'cooldown'
reason: string
createdAt: string
}
export const alertsApi = {
getNotifyConfig: () => http.get<NotifyConfig>('/alerts/notify-config'),
saveNotifyConfig: (data: { enabled: boolean; webhook?: string; signingSecret?: string }) =>
http.put<{ message: string }>('/alerts/notify-config', data),
testNotifyConfig: () => http.post<{ status: string }>('/alerts/notify-config/test'),
listRules: () => http.get<AlertRule[]>('/alerts/rules'),
createRule: (data: Omit<AlertRule, 'id' | 'lastFiredAt' | 'createdAt'>) =>
http.post<AlertRule>('/alerts/rules', data),
updateRule: (id: number, data: Partial<AlertRule>) =>
http.put<AlertRule>(`/alerts/rules/${id}`, data),
deleteRule: (id: number) => http.delete<{ message: string }>(`/alerts/rules/${id}`),
checkNow: () => http.post<{ message: string }>('/alerts/check'),
deliveries: () => http.get<AlertDelivery[]>('/alerts/deliveries'),
// 通知通道(多 webhook 对应不同告警群)
listChannels: () => http.get<NotifyChannel[]>('/alerts/channels'),
saveChannel: (data: { id?: number; name: string; webhook?: string; signingSecret?: string; enabled: boolean }) =>
http.post<NotifyChannel>('/alerts/channels', data),
updateChannel: (id: number, data: { name: string; webhook?: string; signingSecret?: string; enabled: boolean }) =>
http.put<NotifyChannel>(`/alerts/channels/${id}`, data),
deleteChannel: (id: number) => http.delete<{ message: string }>(`/alerts/channels/${id}`),
testChannel: (data: { id?: number; name?: string; webhook?: string; signingSecret?: string }) =>
http.post<{ status: string }>('/alerts/channels/test', data),
}
export interface NotifyChannel {
id: number
name: string
hasWebhook: boolean
hasSecret: boolean
enabled: boolean
createdAt?: string
}
+10
View File
@@ -14,6 +14,7 @@ export interface ClusterCreateInput {
export type ClusterUpdateInput = Partial<ClusterCreateInput>;
export const clusterApi = {
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<Cluster>>(`/clusters${buildQuery(params)}`, options),
@@ -31,4 +32,13 @@ export const clusterApi = {
http.post<{ message: string }>(`/clusters/${id}/validate`),
getInfo: (id: number) => http.get<ClusterInfo>(`/clusters/${id}/info`),
namespaces: (id: number, options?: RequestOptions) =>
http.get<{ namespaces: string[] }>(`/clusters/${id}/namespaces`, options),
deployments: (id: number, namespace: string, options?: RequestOptions) =>
http.get<{ deployments: Array<{ name: string; replicas: number; readyReplicas: number }> }>(
`/clusters/${id}/namespaces/${encodeURIComponent(namespace)}/deployments`, options
),
};
+3
View File
@@ -10,6 +10,9 @@ export const collectorApi = {
create: (data: CreateCollectorRequest) => http.post<Collector>('/collectors', data),
update: (id: number, data: { name: string; logPath: string }) =>
http.put<{ message: string }>(`/collectors/${id}`, data),
remove: (id: number) => http.delete<{ message: string }>(`/collectors/${id}`),
install: (id: number) => http.post<{ message: string }>(`/collectors/${id}/install`),
+6
View File
@@ -21,6 +21,12 @@ export const deploymentApi = {
update: (id: number, data: DeploymentUpdateInput) =>
http.put<{ message: string }>(`/deployments/${id}`, data),
updateRetention: (id: number, yaml: string, apply: boolean) =>
http.post<{ retentionDays: number; applying: boolean }>(`/deployments/${id}/retention`, { yaml, apply }),
liveConfig: (id: number) =>
http.get<{ location: string; yaml: string }>(`/deployments/${id}/config`),
remove: (id: number) => http.delete<{ message: string }>(`/deployments/${id}`),
deploy: (id: number) => http.post<{ message: string }>(`/deployments/${id}/deploy`),
+2
View File
@@ -8,3 +8,5 @@ export * from './storage';
export * from './deployment';
export * from './collector';
export * from './user';
export * from './alerts';
export * from './settings';
+32
View File
@@ -0,0 +1,32 @@
import { http, buildQuery } from '../http'
import type { PageData } from '@/types'
export interface LogSourceAccess { userId: number; labels: Record<string, string> }
export interface LogPolicyGroup { id: number; name: string; sourceId: number; labels: Record<string, string>; userIds: number[]; createdAt?: string; updatedAt?: string }
export interface LogSource {
id: number; name: string; targetType: 'url' | 'cluster-svc'; clusterID?: number
namespace: string; service: string; port: number; url: string
authType: string; basicUser: string; hasBasicPass?: boolean
headers?: Record<string, string>; headerNames?: string[]; skipTLSVerify?: boolean; hasBearerToken?: boolean
isDefault: boolean; managed?: boolean; access?: LogSourceAccess[]
}
export interface LogEntry { timestamp: string; timestampNs: string; level: string; message: string; labels: Record<string, string> }
export interface HistogramBucket { timestamp: string; count: number }
export interface LogResult { entries: LogEntry[]; hasMore: boolean; nextCursor: string }
export interface LogQuery { sourceId: number; query: string; start: number; end: number; limit?: number; direction?: 'backward' | 'forward'; cursor?: string }
export const logsApi = {
sources: () => http.get<PageData<LogSource>>('/logs/sources'),
createSource: (source: Partial<LogSource> & { basicPass?: string; bearerToken?: string }) => http.post<LogSource>('/logs/sources', source),
updateSource: (id: number, data: Partial<LogSource>) => http.put(`/logs/sources/${id}`, data),
deleteSource: (id: number) => http.delete(`/logs/sources/${id}`),
testSource: (data: Partial<LogSource>) => http.post<{ success: boolean; message: string }>('/logs/sources/test', data),
query: (data: LogQuery) => http.post<LogResult>('/logs/query', data, { timeout: 125000 }),
context: (data: { sourceId: number; timestampNs: string; labels: Record<string, string> }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data),
histogram: (data: LogQuery) => http.post<Array<Record<string, unknown>>>('/logs/histogram', data, { timeout: 125000 }),
labels: (sourceId: number, start?: number, end?: number) => http.get<string[]>(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`),
labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get<string[]>(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`),
policyGroups: () => http.get<LogPolicyGroup[]>('/logs/policy-groups'),
createPolicyGroup: (data: Omit<LogPolicyGroup, 'id'>) => http.post<LogPolicyGroup>('/logs/policy-groups', data),
updatePolicyGroup: (id: number, data: Omit<LogPolicyGroup, 'id'>) => http.put<LogPolicyGroup>(`/logs/policy-groups/${id}`, data),
deletePolicyGroup: (id: number) => http.delete(`/logs/policy-groups/${id}`),
}
+17
View File
@@ -0,0 +1,17 @@
// 设置域 API — 镜像源等平台级配置
import { http } from '../http'
export interface ImageRegistryConfig {
mode: 'overseas' | 'cn' | 'custom'
customHost: string
sampleLoki: string
sampleGrafana: string
samplePromtail: string
sampleFluentBit: string
}
export const settingsApi = {
getImageRegistry: () => http.get<ImageRegistryConfig>('/settings/image-registry'),
saveImageRegistry: (data: { mode: string; customHost?: string }) =>
http.put<ImageRegistryConfig>('/settings/image-registry', data),
}
+1
View File
@@ -13,6 +13,7 @@ export interface StorageCreateInput {
pathPrefix?: string;
insecure?: boolean;
isDefault?: boolean;
hasSecretKey?: boolean;
}
export type StorageUpdateInput = Partial<StorageCreateInput>;
+12 -12
View File
@@ -46,10 +46,10 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
onClick={onNavigate}
aria-label={item.label}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all',
'relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
? 'bg-sidebar-primary/15 text-sidebar-primary font-semibold before:absolute before:left-0 before:h-5 before:w-0.5 before:rounded-full before:bg-sidebar-primary'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
)}
title={!collapsed ? item.label : undefined}
>
@@ -68,15 +68,15 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
// 侧栏 Logo 区
function SidebarLogo({ collapsed }: { collapsed: boolean }) {
return (
<div className="h-16 flex items-center px-4 border-b border-slate-700 flex-shrink-0">
<div className="h-16 flex items-center px-4 border-b border-sidebar-border flex-shrink-0">
{collapsed ? (
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center mx-auto">
<Activity className="w-5 h-5 text-white" />
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center mx-auto">
<Activity className="w-5 h-5 text-sidebar-primary" />
</div>
) : (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Activity className="w-5 h-5 text-white" />
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center">
<Activity className="w-5 h-5 text-sidebar-primary" />
</div>
<span className="font-bold text-lg tracking-tight">opsx-loki</span>
</div>
@@ -162,18 +162,18 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
{!isMobile && (
<aside
className={cn(
'bg-slate-900 text-white transition-all duration-300 flex flex-col flex-shrink-0',
'bg-sidebar text-sidebar-accent-foreground transition-all duration-300 flex flex-col flex-shrink-0',
collapsed ? 'w-16' : 'w-64'
)}
>
<SidebarLogo collapsed={collapsed} />
<SidebarNav collapsed={collapsed} />
<div className="p-2 border-t border-slate-700 flex-shrink-0">
<div className="p-2 border-t border-sidebar-border flex-shrink-0">
<button
onClick={toggleSidebar}
aria-label={collapsed ? '展开菜单' : '收起菜单'}
title={collapsed ? '展开菜单' : '收起菜单'}
className="flex items-center justify-center w-full py-2.5 rounded-lg text-slate-400 hover:bg-slate-800 hover:text-white transition-all"
className="flex items-center justify-center w-full py-2.5 rounded-lg text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
{collapsed ? (
<PanelLeftOpen className="w-5 h-5" />
@@ -188,7 +188,7 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
{/* 移动端抽屉侧栏 */}
{isMobile && (
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="bg-slate-900 text-white border-none p-0 w-72">
<SheetContent side="left" className="bg-sidebar text-sidebar-accent-foreground border-none p-0 w-72">
<SheetHeader className="sr-only">
<SheetTitle className="text-white">opsx-loki 导航</SheetTitle>
</SheetHeader>
+8 -1
View File
@@ -1,6 +1,8 @@
import { memo } from 'react'
import { useLocation } from 'react-router'
import { cn } from '@/lib/utils'
import { PageHeader } from '@/components/PageHeader'
import { findRouteMeta } from '@/router/routes'
interface PageContainerProps {
/** 页头标题;缺省则不渲染页头(如仪表盘总览页) */
@@ -23,9 +25,14 @@ export const PageContainer = memo(function PageContainer({
className,
children,
}: PageContainerProps) {
const { pathname } = useLocation()
const routeTitle = findRouteMeta(pathname)?.title
const repeatedTitle = Boolean(title && routeTitle && title.replace(/\s/g, '') === routeTitle.replace(/\s/g, ''))
const showHeader = Boolean(title && (!repeatedTitle || description || action))
return (
<div className={cn('space-y-6', className)}>
{title && <PageHeader title={title} description={description} action={action} />}
{showHeader && <PageHeader title={repeatedTitle ? undefined : title} description={description} action={action} />}
{children}
</div>
)
+5 -7
View File
@@ -2,7 +2,7 @@ import { memo } from 'react'
import { cn } from '@/lib/utils'
interface PageHeaderProps {
title: string
title?: string
description?: string
action?: React.ReactNode
className?: string
@@ -20,13 +20,11 @@ export const PageHeader = memo(function PageHeader({
className,
}: PageHeaderProps) {
return (
<div className={cn('flex items-center justify-between', className)}>
<div>
<h2 className="text-lg font-semibold text-foreground">
{title}
</h2>
<div className={cn('flex flex-wrap items-center justify-between gap-3', className)}>
<div className="min-w-0">
{title && <h2 className="text-lg font-semibold text-foreground">{title}</h2>}
{description && (
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
<p className={cn('text-sm text-muted-foreground', title && 'mt-0.5')}>{description}</p>
)}
</div>
{action && <div className="flex-shrink-0">{action}</div>}
+97
View File
@@ -0,0 +1,97 @@
import { memo } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
export interface ResourceField {
label: string
value: React.ReactNode
/** 值提示(截断时 title) */
title?: string
}
interface ResourceCardProps {
/** 头部图标 */
icon: LucideIcon
/** 图标配色(text-info/success/warning 等语义 token 类) */
iconClassName?: string
title: string
/** 标题旁附加(如 ready 状态点、uuid) */
titleExtra?: React.ReactNode
/** 右上角徽章(状态/保留天数等) */
badges?: React.ReactNode
/** 副标题/描述(单行截断) */
description?: string
/** 信息字段(两列网格;奇数个最后一项占整行) */
fields: ResourceField[]
/** 底部状态消息(如错误信息,浅底块) */
message?: string
/** 底部操作区(border-top 分隔) */
actions: React.ReactNode
}
/**
* 资源卡片 — 五大资源页(集群/主机/存储/部署/采集)统一样式:
* 图标+标题+徽章 → 描述 → 两列信息网格(label 上 value 下) → 分隔线操作区。
* 统一 p-4 内边距、hover 阴影、等高布局(网格内自动对齐)。
*/
export const ResourceCard = memo(function ResourceCard({
icon: Icon,
iconClassName = 'text-muted-foreground',
title,
titleExtra,
badges,
description,
fields,
message,
actions,
}: ResourceCardProps) {
return (
<Card className="border-border hover:shadow-md transition-shadow h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* 头部:图标 + 标题 + 徽章 */}
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-muted/70 flex items-center justify-center shrink-0">
<Icon className={cn('size-4', iconClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-medium text-sm text-foreground truncate" title={title}>
{title}
</h3>
{titleExtra}
{badges && <span className="ml-auto flex items-center gap-1.5 shrink-0">{badges}</span>}
</div>
{description && (
<p className="text-xs text-muted-foreground truncate mt-0.5" title={description}>
{description}
</p>
)}
</div>
</div>
{/* 信息:两列网格(label 灰小字在上,value 在下) */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
{fields.map((f) => (
<div key={f.label} className="min-w-0">
<p className="text-muted-foreground leading-4">{f.label}</p>
<p className="font-medium text-foreground truncate leading-5" title={f.title}>
{f.value}
</p>
</div>
))}
</div>
{/* 状态消息(错误/提示) */}
{message && (
<p className="rounded-md bg-muted/60 px-2.5 py-1.5 text-xs text-muted-foreground" title={message}>
{message}
</p>
)}
{/* 操作区:贴底 */}
<div className="flex gap-2 pt-3 border-t border-border mt-auto">{actions}</div>
</CardContent>
</Card>
)
})
+1
View File
@@ -3,6 +3,7 @@
export { default as PageContainer } from './PageContainer';
export { default as PageHeader } from './PageHeader';
export { default as InfoRow } from './InfoRow';
export { ResourceCard } from './ResourceCard';
export { default as ConfirmDialog } from './ConfirmDialog';
export { default as ErrorState } from './ErrorState';
export { default as EmptyState } from './EmptyState';
+6 -6
View File
@@ -38,12 +38,12 @@
--radius: 0.625rem;
--sidebar-background: 222 47% 11%;
--sidebar-foreground: 215 20% 65%;
--sidebar-primary: 161 94% 30%;
--sidebar-primary-foreground: 0 0% 100%;
--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: 161 94% 30%;
--sidebar-ring: 213 94% 72%;
}
.dark {
@@ -74,12 +74,12 @@
--ring: 160 84% 39%;
--sidebar-background: 222 47% 8%;
--sidebar-foreground: 215 20% 65%;
--sidebar-primary: 160 84% 39%;
--sidebar-primary-foreground: 0 0% 100%;
--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: 160 84% 39%;
--sidebar-ring: 213 94% 72%;
}
}
+362
View File
@@ -0,0 +1,362 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { BellRing, Plus, Play, Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { PageContainer } from '@/components/PageContainer'
import { EmptyState } from '@/components/EmptyState'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { alertsApi, type AlertRule, type AlertDelivery, type NotifyChannel } from '@/api/alerts'
import { logsApi, type LogSource } from '@/api/logs'
import { toast } from 'sonner'
import { useForm } from 'react-hook-form'
interface RuleForm {
name: string
sourceId: string
channelIds: string[]
query: string
windowMin: string
threshold: string
cooldownMin: string
enabled: boolean
}
const DEFAULTS: RuleForm = {
name: '', sourceId: '', channelIds: [], query: '{namespace=~".+"} |= "error"',
windowMin: '15', threshold: '0', cooldownMin: '60', enabled: true,
}
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
const AlertsPage = memo(function AlertsPage() {
const [rules, setRules] = useState<AlertRule[]>([])
const [sources, setSources] = useState<LogSource[]>([])
const [channels, setChannels] = useState<NotifyChannel[]>([])
const [deliveries, setDeliveries] = useState<AlertDelivery[]>([])
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<AlertRule | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AlertRule | null>(null)
const [deleting, setDeleting] = useState(false)
const [checking, setChecking] = useState(false)
const [submitting, setSubmitting] = useState(false)
const form = useForm<RuleForm>({ defaultValues: DEFAULTS })
const refresh = useCallback(async () => {
try {
const [r, s, d, ch] = await Promise.all([
alertsApi.listRules(), logsApi.sources(), alertsApi.deliveries(), alertsApi.listChannels(),
])
setRules(Array.isArray(r) ? r : (r as unknown as { list: AlertRule[] }).list ?? [])
setSources(s.list)
setDeliveries(Array.isArray(d) ? d : [])
setChannels(Array.isArray(ch) ? ch : [])
} catch (err) {
toast.error(err instanceof Error ? err.message : '加载告警数据失败')
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const openCreate = () => {
setEditing(null)
form.reset({ ...DEFAULTS, sourceId: sources[0] ? String(sources[0].id) : '', channelIds: channels.filter(c => c.enabled).slice(0, 1).map(c => String(c.id)) })
setOpen(true)
}
const openEdit = (rule: AlertRule) => {
setEditing(rule)
form.reset({
name: rule.name, sourceId: String(rule.sourceId), channelIds: (rule.channelIds || []).map(String), query: rule.query,
windowMin: String(rule.windowMin), threshold: String(rule.threshold),
cooldownMin: String(rule.cooldownMin), enabled: rule.enabled,
})
setOpen(true)
}
const onSubmit = form.handleSubmit(async (v) => {
setSubmitting(true)
try {
const payload = {
name: v.name, sourceId: Number(v.sourceId), channelIds: v.channelIds.map(Number).filter(Boolean), query: v.query,
windowMin: Number(v.windowMin) || 15,
threshold: Number(v.threshold) || 0,
cooldownMin: Number(v.cooldownMin) || 60,
enabled: v.enabled,
}
if (editing) await alertsApi.updateRule(editing.id, payload)
else await alertsApi.createRule(payload)
setOpen(false)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
} finally {
setSubmitting(false)
}
})
const handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await alertsApi.deleteRule(deleteTarget.id)
setDeleteTarget(null)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除失败')
} finally {
setDeleting(false)
}
}
const handleCheckNow = async () => {
setChecking(true)
try {
await alertsApi.checkNow()
toast.info('已执行一次检查,结果见发送历史')
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '检查失败')
} finally {
setChecking(false)
}
}
const toggleRule = async (rule: AlertRule, enabled: boolean) => {
try {
await alertsApi.updateRule(rule.id, { enabled })
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '切换失败')
}
}
return (
<PageContainer
title="日志告警"
description="按 LogQL 计数规则监控日志,超阈值推送飞书卡片(周期 1 分钟)"
action={
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCheckNow} disabled={checking}>
<Play className="w-4 h-4 mr-1" />
{checking ? '检查中...' : '立即检查'}
</Button>
<Button onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />
新建规则
</Button>
</div>
}
>
{rules.length === 0 ? (
<EmptyState
icon={BellRing}
title="暂无告警规则"
description={'例如:{namespace=~".+"} |= "FATAL",15 分钟内超过 0 条即推送飞书'}
/>
) : (
<div className="space-y-3">
{rules.map((rule) => {
const src = sources.find((s) => s.id === rule.sourceId)
return (
<Card key={rule.id} className="border-border">
<CardContent className="p-4 flex items-center gap-4 flex-wrap">
<Switch checked={rule.enabled} onCheckedChange={(v) => void toggleRule(rule, v)} />
<div className="min-w-0 flex-1">
<p className="font-medium">{rule.name}</p>
<p className="text-xs text-muted-foreground truncate font-mono mt-0.5">{rule.query}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{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()}`}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" aria-label={`编辑 ${rule.name}`} onClick={() => openEdit(rule)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline" size="sm" aria-label={`删除 ${rule.name}`}
className="text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget(rule)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* 发送历史 */}
{deliveries.length > 0 && (
<Card className="border-border">
<CardContent className="p-4">
<h2 className="text-sm font-semibold mb-3">发送历史(最近 50 条)</h2>
<div className="divide-y divide-border text-sm">
{deliveries.map((d) => (
<div key={d.id} className="py-2 flex items-center gap-3 flex-wrap">
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
d.status === 'sent' ? 'bg-success/10 text-success'
: d.status === 'cooldown' ? 'bg-muted text-muted-foreground'
: 'bg-destructive/10 text-destructive'
}`}
>
{d.status === 'sent' ? '已发送' : d.status === 'cooldown' ? '冷却跳过' : '失败'}
</span>
<span className="font-medium">{d.ruleName}</span>
<span className="text-muted-foreground text-xs">{d.count} 条命中</span>
{d.reason && <span className="text-destructive text-xs truncate flex-1">{d.reason}</span>}
<span className="text-muted-foreground text-xs ml-auto">{new Date(d.createdAt).toLocaleString()}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
<p className="text-xs text-muted-foreground">
通知未配置?去
<Link to="/settings" className="text-primary mx-1">系统设置</Link>
配置飞书 Webhook
</p>
{/* 规则弹窗 */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editing ? `编辑规则 · ${editing.name}` : '新建告警规则'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>规则名称 *</FormLabel>
<FormControl><Input placeholder="应用致命异常" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="sourceId" render={({ field }) => (
<FormItem>
<FormLabel>数据源 *</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl><SelectTrigger><SelectValue placeholder="选择数据源" /></SelectTrigger></FormControl>
<SelectContent>
{sources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
</div>
<FormField control={form.control} name="query" render={({ field }) => (
<FormItem>
<FormLabel>LogQL 查询 *</FormLabel>
<FormControl>
<Input className="font-mono" placeholder='{namespace="log"} |= "FATAL"' {...field} />
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
{'常用:{app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"'}
</p>
</FormItem>
)} />
<FormField control={form.control} name="channelIds" render={({ field }) => (
<FormItem>
<FormLabel>通知通道(可多选,扇出到多个告警群)</FormLabel>
<div className="grid grid-cols-2 gap-2 rounded-md border bg-background p-3 max-h-40 overflow-auto">
{channels.length === 0 && <p className="text-xs text-muted-foreground col-span-2">请先在「系统设置 → 告警通知」创建通道</p>}
{channels.map((ch) => {
const checked = field.value.includes(String(ch.id))
return (
<label key={ch.id} className={`flex items-center gap-2 text-sm cursor-pointer rounded-md border px-2.5 py-1.5 ${checked ? 'border-primary bg-primary/5 text-primary' : 'border-border'}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => {
if (e.target.checked) field.onChange([...field.value, String(ch.id)])
else field.onChange(field.value.filter((x) => x !== String(ch.id)))
}}
className="accent-primary"
/>
{ch.name}
{!ch.enabled && <span className="text-xs text-muted-foreground">(禁用)</span>}
</label>
)
})}
</div>
<FormMessage />
</FormItem>
)} />
<div className="grid grid-cols-3 gap-4">
<FormField control={form.control} name="windowMin" render={({ field }) => (
<FormItem>
<FormLabel>统计窗口(分)</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="threshold" render={({ field }) => (
<FormItem>
<FormLabel>触发阈值(条)</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">0 = 出现即告警</p>
</FormItem>
)} />
<FormField control={form.control} name="cooldownMin" render={({ field }) => (
<FormItem>
<FormLabel>冷却(分)</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<FormField control={form.control} name="enabled" render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
<FormLabel>启用规则</FormLabel>
</FormItem>
)} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : editing ? '保存修改' : '创建规则'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
name={deleteTarget?.name}
title="确认删除告警规则"
loading={deleting}
onConfirm={handleDelete}
/>
</PageContainer>
)
})
export default AlertsPage
@@ -1,9 +1,8 @@
import { memo } from 'react'
import { Server, RefreshCw, Trash2, Eye } from 'lucide-react'
import { Server, RefreshCw, Trash2, Eye, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { ResourceCard } from '@/components/ResourceCard'
import type { Cluster } from '@/types'
interface ClusterCardProps {
@@ -12,90 +11,42 @@ interface ClusterCardProps {
onViewInfo: (c: Cluster) => void
onValidate: (id: number) => void
onDelete: (c: Cluster) => void
onEdit: (c: Cluster) => void
}
/** 集群卡片 — 列表项展示与操作入口 */
/** 集群卡片 — 统一 ResourceCard 样式 */
export const ClusterCard = memo(function ClusterCard({
cluster,
validating,
onViewInfo,
onValidate,
onDelete,
cluster, validating, onViewInfo, onValidate, onDelete, onEdit,
}: ClusterCardProps) {
return (
<Card className="border-border hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Server className="w-5 h-5 text-info flex-shrink-0" />
<h3 className="font-semibold text-foreground truncate">
{cluster.name}
</h3>
</div>
<StatusBadge status={cluster.status} />
</div>
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{cluster.description || '无描述'}
</p>
<div className="space-y-1.5 text-xs text-muted-foreground mb-4">
<InfoRow
label="接入方式"
value={cluster.configType === 'token' ? 'Token' : 'Kubeconfig'}
/>
{cluster.apiServer && (
<InfoRow
label="API Server"
value={cluster.apiServer}
valueClass="truncate max-w-[150px]"
/>
)}
<InfoRow label="版本" value={cluster.version || '未知'} />
<InfoRow label="节点数" value={String(cluster.nodesCount || 0)} />
<InfoRow
label="最后连接"
value={
cluster.lastConnected
? new Date(cluster.lastConnected).toLocaleString()
: '从未'
}
/>
</div>
<div className="flex gap-2 pt-3 border-t border-border">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onViewInfo(cluster)}
>
<Eye className="w-3 h-3 mr-1" />
详情
<ResourceCard
icon={Server}
iconClassName="text-info"
title={cluster.name}
description={cluster.description || undefined}
badges={<StatusBadge status={cluster.status} />}
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) },
]}
actions={
<>
<Button variant="outline" size="sm" aria-label={`编辑集群 ${cluster.name}`} onClick={() => onEdit(cluster)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onValidate(cluster.id)}
disabled={validating === cluster.id}
>
<RefreshCw
className={`w-3 h-3 mr-1 ${validating === cluster.id ? 'animate-spin' : ''}`}
/>
验证
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onViewInfo(cluster)}>
<Eye className="size-3 mr-1" />详情
</Button>
<Button
variant="outline"
size="sm"
aria-label={`删除集群 ${cluster.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(cluster)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" aria-label={`验证集群 ${cluster.name}`} className="text-xs" disabled={validating === cluster.id} onClick={() => onValidate(cluster.id)}>
<RefreshCw className={`size-3 ${validating === cluster.id ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" aria-label={`删除集群 ${cluster.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(cluster)}>
<Trash2 className="size-3.5" />
</Button>
</>
}
/>
)
})
@@ -42,6 +42,7 @@ const clusterFormSchema = z
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 内容' })
}
@@ -72,13 +73,18 @@ interface ClusterFormDialogProps {
onOpenChange: (open: boolean) => void
/** 创建成功回调(父级刷新列表并关闭弹窗) */
onCreated: () => void
/** 编辑目标(编辑模式:kubeconfig 留空=保留) */
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 [submitting, setSubmitting] = useState(false)
@@ -90,24 +96,38 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
const kubeconfig = form.watch('kubeconfig')
// 打开时重置(编辑回填/关闭重置时机明确)
EDITING_REF.current = !!editing
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
form.reset(editing ? {
name: editing.name, description: editing.description || '',
configType: (editing.configType as 'kubeconfig' | 'token') || 'kubeconfig',
kubeconfig: '', apiServer: editing.apiServer || '', token: '', context: editing.context || '',
} as never : DEFAULT_VALUES)
}
}, [open, form])
}, [open, form, editing])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
await clusterApi.create({
name: values.name,
description: values.description,
configType: values.configType,
apiServer: values.configType === 'token' ? values.apiServer : undefined,
kubeconfig: values.configType === 'kubeconfig' ? values.kubeconfig : undefined,
token: values.configType === 'token' ? values.token : undefined,
context: values.context,
})
await (editing
? clusterApi.update(editing.id, {
name: values.name, description: values.description,
configType: values.configType,
apiServer: values.configType === 'token' ? values.apiServer : undefined,
kubeconfig: values.configType === 'kubeconfig' && values.kubeconfig ? values.kubeconfig : undefined,
token: values.configType === 'token' && values.token ? values.token : undefined,
context: values.context,
})
: clusterApi.create({
name: values.name,
description: values.description,
configType: values.configType,
apiServer: values.configType === 'token' ? values.apiServer : undefined,
kubeconfig: values.configType === 'kubeconfig' ? values.kubeconfig : undefined,
token: values.configType === 'token' ? values.token : undefined,
context: values.context,
}))
onOpenChange(false)
onCreated()
} catch (err) {
@@ -121,7 +141,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加 Kubernetes 集群</DialogTitle>
<DialogTitle>{editing ? `编辑集群${editing.name ? ` · ${editing.name}` : ''}` : '添加 Kubernetes 集群'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -175,7 +195,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="kubeconfig"
render={({ field }) => (
<FormItem>
<FormLabel>Kubeconfig 内容 *</FormLabel>
<FormLabel>Kubeconfig 内容{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<Textarea
placeholder="粘贴 kubeconfig YAML(自动识别 API Server)..."
@@ -212,7 +232,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>Token *</FormLabel>
<FormLabel>Token{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<Textarea
placeholder="粘贴 Service Account Token..."
@@ -253,7 +273,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? '添加中...' : '添加集群'}
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加集群'}
</Button>
</form>
</Form>
+13 -4
View File
@@ -27,6 +27,7 @@ const ClustersPage = memo(function ClustersPage() {
const [formOpen, setFormOpen] = useState(false)
const [detailCluster, setDetailCluster] = useState<Cluster | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Cluster | null>(null)
const [editingTarget, setEditingTarget] = useState<Cluster | null>(null)
const [deleting, setDeleting] = useState(false)
const [validating, setValidating] = useState<number | null>(null)
@@ -110,7 +111,7 @@ const ClustersPage = memo(function ClustersPage() {
/>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((cluster) => (
<ClusterCard
key={cluster.id}
@@ -119,6 +120,7 @@ const ClustersPage = memo(function ClustersPage() {
onViewInfo={setDetailCluster}
onValidate={handleValidate}
onDelete={setDeleteTarget}
onEdit={setEditingTarget}
/>
))}
</div>
@@ -132,9 +134,15 @@ const ClustersPage = memo(function ClustersPage() {
)}
<ClusterFormDialog
open={formOpen}
onOpenChange={setFormOpen}
onCreated={query.reload}
open={formOpen || editingTarget !== null}
onOpenChange={(o) => {
if (!o) {
setFormOpen(false)
setEditingTarget(null)
}
}}
onCreated={query.refresh}
editing={editingTarget}
/>
<ClusterDetailDialog
@@ -143,6 +151,7 @@ const ClustersPage = memo(function ClustersPage() {
onClose={() => setDetailCluster(null)}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -4,25 +4,25 @@ import {
Trash2,
Upload,
Download,
Pencil,
Server,
Monitor,
Cloud,
type LucideIcon,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { ResourceCard } from '@/components/ResourceCard'
import type { Collector } from '@/types'
// 采集器类型元数据(图标 + 类型徽章配色)
const COLLECTOR_TYPE_META: Record<string, { label: string; icon: LucideIcon; color: string }> = {
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'bg-purple-100 text-purple-700' },
'k8s-daemonset': { label: 'K8s DaemonSet', icon: Cloud, color: 'bg-info/10 text-info' },
'k8s-fluentbit-sidecar': { label: 'K8s Sidecar', icon: Server, color: 'bg-cyan-100 text-cyan-700' },
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'text-info' },
'k8s-daemonset': { label: 'Promtail DaemonSet', icon: Cloud, color: 'text-info' },
'k8s-fluentbit-sidecar': { label: 'Fluent Bit Sidecar', icon: Server, color: 'text-info' },
}
const FALLBACK_META = { label: '', icon: RadioTower, color: 'bg-muted text-slate-600' }
const FALLBACK_META = { label: '', icon: RadioTower, color: 'text-muted-foreground' }
interface CollectorCardProps {
collector: Collector
@@ -31,114 +31,48 @@ interface CollectorCardProps {
onInstall: (id: number) => void
onUninstall: (id: number) => void
onDelete: (c: Collector) => void
onEdit: (c: Collector) => void
}
/** 采集器卡片 — 列表项展示与操作入口(部署/卸载/删除) */
/** 采集策略卡片 — 统一 ResourceCard 样式 */
export const CollectorCard = memo(function CollectorCard({
collector,
operating,
onInstall,
onUninstall,
onDelete,
collector, operating, onInstall, onUninstall, onDelete, onEdit,
}: CollectorCardProps) {
const meta = COLLECTOR_TYPE_META[collector.type] ?? {
...FALLBACK_META,
label: collector.type,
}
const meta = COLLECTOR_TYPE_META[collector.type] ?? FALLBACK_META
const Icon = meta.icon
const isBusy = operating === collector.id
return (
<Card className="border-border hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Icon className="w-5 h-5 text-primary flex-shrink-0" />
<div className="min-w-0">
<h3 className="font-semibold text-foreground truncate">
{collector.name}
</h3>
<p className="text-xs text-muted-foreground">
关联 Loki:{collector.lokiDeployment?.name || '未知'}
</p>
</div>
</div>
<StatusBadge status={collector.status} />
</div>
<div className="space-y-1.5 text-xs text-muted-foreground mb-3">
<div className="flex justify-between">
<span>类型</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>
{meta.label}
</span>
</div>
{collector.host && (
<InfoRow
label="主机"
value={`${collector.host.name}(${collector.host.hostIP})`}
valueClass="truncate max-w-[200px]"
/>
)}
{collector.cluster && (
<InfoRow
label="集群"
value={collector.cluster.name}
valueClass="truncate max-w-[200px]"
/>
)}
{collector.targetDeployment && (
<InfoRow
label="目标 Deployment"
value={collector.targetDeployment}
valueClass="truncate max-w-[200px]"
/>
)}
{collector.namespace && (
<InfoRow label="命名空间" value={collector.namespace} valueClass="truncate max-w-[200px]" />
)}
{collector.logPath && (
<InfoRow label="日志路径" value={collector.logPath} valueClass="truncate max-w-[200px]" />
)}
{collector.statusMessage && (
<div className="p-2 bg-muted rounded text-xs mt-1">
{collector.statusMessage}
</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t border-border">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onInstall(collector.id)}
disabled={isBusy}
>
<Upload className="w-3 h-3 mr-1" />
{isBusy ? '处理中...' : '部署'}
<ResourceCard
icon={Icon}
iconClassName={meta.color}
title={collector.name}
description={meta.label}
badges={<StatusBadge status={collector.status} />}
fields={[
{ label: '关联 Loki', value: collector.lokiDeployment?.name || '未知' },
...(collector.cluster ? [{ label: '集群', value: collector.cluster.name }] : []),
...(collector.host ? [{ label: '主机', value: `${collector.host.name}(${collector.host.hostIP})` }] : []),
...(collector.namespace ? [{ label: '命名空间', value: collector.namespace }] : []),
...(collector.logPath ? [{ label: '日志路径', value: collector.logPath, title: collector.logPath }] : []),
...(collector.targetDeployment ? [{ label: '目标', value: collector.targetDeployment }] : []),
]}
message={collector.statusMessage || undefined}
actions={
<>
<Button variant="outline" size="sm" aria-label={`编辑采集策略 ${collector.name}`} title="编辑采集策略" onClick={() => onEdit(collector)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onUninstall(collector.id)}
disabled={isBusy}
>
<Download className="w-3 h-3 mr-1" />
卸载
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onInstall(collector.id)} disabled={isBusy}>
<Upload className="size-3 mr-1" />{isBusy ? '处理中...' : '部署'}
</Button>
<Button
variant="outline"
size="sm"
aria-label={`删除采集器 ${collector.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(collector)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onUninstall(collector.id)} disabled={isBusy}>
<Download className="size-3 mr-1" />卸载
</Button>
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" aria-label={`删除采集器 ${collector.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(collector)}>
<Trash2 className="size-3.5" />
</Button>
</>
}
/>
)
})
@@ -0,0 +1,74 @@
import { memo, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { collectorApi } from '@/api'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import type { Collector } from '@/types'
const schema = z.object({
name: z.string().trim().min(1, '请输入采集策略名称'),
logPath: z.string().trim(),
})
type Values = z.infer<typeof schema>
interface Props {
collector: Collector | null
onOpenChange: (open: boolean) => void
onSaved: () => void
}
export const CollectorEditDialog = memo(function CollectorEditDialog({ collector, onOpenChange, onSaved }: Props) {
const [saving, setSaving] = useState(false)
const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: { name: '', logPath: '' } })
useEffect(() => {
if (collector) form.reset({ name: collector.name, logPath: collector.logPath || '' })
}, [collector, form])
const submit = form.handleSubmit(async (values) => {
if (!collector) return
setSaving(true)
try {
await collectorApi.update(collector.id, values)
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存采集策略失败')
} finally {
setSaving(false)
}
})
const supportsPath = collector?.type !== 'k8s-daemonset'
return (
<Dialog open={collector !== null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>编辑采集策略</DialogTitle></DialogHeader>
<Form {...form}>
<form onSubmit={submit} className="space-y-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem><FormLabel>名称</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
{supportsPath && <FormField control={form.control} name="logPath" render={({ field }) => (
<FormItem>
<FormLabel>采集日志路径</FormLabel>
<FormControl><Input placeholder={collector?.type === 'host-promtail' ? '/var/log/*.log' : '/var/log/app/*.log'} {...field} /></FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">Sidecar 需使用业务容器的共享日志卷,或专用 logs 目录。保存后点击「部署」应用新配置。</p>
</FormItem>
)} />}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button type="submit" disabled={saving}>{saving ? '保存中…' : '保存'}</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
})
@@ -25,7 +25,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { collectorApi } from '@/api'
import { clusterApi, collectorApi } from '@/api'
import { statusLabel } from '@/lib/utils'
import { toast } from 'sonner'
import type { Cluster, Host, LokiDeployment } from '@/types'
@@ -34,8 +34,8 @@ type CollectorType = 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar'
const COLLECTOR_TYPES: Array<{ value: CollectorType; label: string }> = [
{ value: 'host-promtail', label: '主机 Promtail(SSH)' },
{ value: 'k8s-daemonset', label: 'K8s Promtail DaemonSet' },
{ value: 'k8s-fluentbit-sidecar', label: 'K8s Fluent Bit Sidecar' },
{ value: 'k8s-daemonset', label: 'Kubernetes Promtail DaemonSet' },
{ value: 'k8s-fluentbit-sidecar', label: 'Kubernetes Fluent Bit Sidecar' },
]
// 表单 schema:zod 为唯一校验源,消息中文且具体
@@ -69,6 +69,9 @@ const collectorFormSchema = z
message: '请填写目标 Deployment 名称',
})
}
if (data.type === 'k8s-fluentbit-sidecar' && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请选择命名空间' })
}
})
type CollectorFormValues = z.infer<typeof collectorFormSchema>
@@ -105,20 +108,70 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
hosts,
}: CollectorFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
const [namespaces, setNamespaces] = useState<string[]>([])
const [workloads, setWorkloads] = useState<Array<{ name: string; replicas: number; readyReplicas: number }>>([])
const [namespacesLoading, setNamespacesLoading] = useState(false)
const [workloadsLoading, setWorkloadsLoading] = useState(false)
const [namespacesError, setNamespacesError] = useState('')
const [workloadsError, setWorkloadsError] = useState('')
const [namespaceReload, setNamespaceReload] = useState(0)
const [workloadReload, setWorkloadReload] = useState(0)
const [manualNamespace, setManualNamespace] = useState(false)
const [manualDeployment, setManualDeployment] = useState(false)
const form = useForm<CollectorFormValues>({
resolver: zodResolver(collectorFormSchema),
defaultValues: DEFAULT_VALUES,
})
const collectorType = form.watch('type')
const clusterID = form.watch('clusterID')
const namespace = form.watch('namespace')
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
// 打开时重置(关闭重置时机明确)
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
setManualNamespace(false)
setManualDeployment(false)
}
}, [open, form])
useEffect(() => {
if (!open || !isSidecarType || !clusterID) return
const controller = new AbortController()
setNamespacesLoading(true)
setNamespacesError('')
setNamespaces([])
clusterApi.namespaces(Number(clusterID), { signal: controller.signal })
.then(({ namespaces: items }) => {
setNamespaces(items)
if (items.includes('default') && !form.getValues('namespace')) form.setValue('namespace', 'default')
})
.catch((err) => {
if (!controller.signal.aborted) setNamespacesError(err instanceof Error ? err.message : '读取命名空间失败')
})
.finally(() => { if (!controller.signal.aborted) setNamespacesLoading(false) })
return () => controller.abort()
}, [open, isSidecarType, clusterID, form, namespaceReload])
useEffect(() => {
if (!open || !isSidecarType || !clusterID || !namespace) return
const controller = new AbortController()
setWorkloadsLoading(true)
setWorkloadsError('')
setWorkloads([])
const timer = setTimeout(() => {
clusterApi.deployments(Number(clusterID), namespace, { signal: controller.signal })
.then(({ deployments: items }) => setWorkloads(items))
.catch((err) => {
if (!controller.signal.aborted) setWorkloadsError(err instanceof Error ? err.message : '读取 Deployment 失败')
})
.finally(() => { if (!controller.signal.aborted) setWorkloadsLoading(false) })
}, 250)
return () => { clearTimeout(timer); controller.abort() }
}, [open, isSidecarType, clusterID, namespace, workloadReload])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
@@ -151,7 +204,6 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
const isHostType = collectorType === 'host-promtail'
const isK8sType = collectorType !== 'host-promtail'
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -262,7 +314,14 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<FormItem>
<FormLabel>目标集群 *</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value} onValueChange={(value) => {
field.onChange(value)
form.setValue('namespace', '')
form.setValue('targetDeployment', '')
setManualNamespace(false)
setManualDeployment(false)
setWorkloads([])
}}>
<SelectTrigger>
<SelectValue placeholder="选择集群" />
</SelectTrigger>
@@ -285,14 +344,33 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="namespace"
render={({ field }) => (
<FormItem>
<FormLabel>命名空间</FormLabel>
<FormLabel>命名空间{isSidecarType ? ' *' : ''}</FormLabel>
<FormControl>
<Input
placeholder={collectorType === 'k8s-daemonset' ? 'logging' : 'default'}
{...field}
/>
{isSidecarType && !manualNamespace ? (
<Select value={field.value} onValueChange={(value) => {
field.onChange(value)
form.setValue('targetDeployment', '')
}} disabled={!clusterID || namespacesLoading}>
<SelectTrigger><SelectValue placeholder={!clusterID ? '先选择集群' : namespacesLoading ? '加载命名空间…' : '选择命名空间'} /></SelectTrigger>
<SelectContent>
{namespaces.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input placeholder={collectorType === 'k8s-daemonset' ? 'logging' : 'default'} {...field} onChange={(event) => {
field.onChange(event)
if (isSidecarType) form.setValue('targetDeployment', '')
}} />
)}
</FormControl>
<FormMessage />
{isSidecarType && <div className="flex items-center gap-2 text-xs">
<Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setManualNamespace((value) => !value)}>
{manualNamespace ? '从集群选择' : '手动填写'}
</Button>
{namespacesError && <><span className="text-destructive">{namespacesError}</span><Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setNamespaceReload((value) => value + 1)}>重试</Button></>}
{!namespacesLoading && !namespacesError && clusterID && namespaces.length === 0 && <span className="text-muted-foreground">暂无可读取的命名空间</span>}
</div>}
</FormItem>
)}
/>
@@ -304,9 +382,27 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<FormItem>
<FormLabel>目标 Deployment *</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
{manualDeployment ? (
<Input placeholder="my-app" {...field} />
) : (
<Select value={field.value} onValueChange={field.onChange} disabled={!namespace || workloadsLoading}>
<SelectTrigger><SelectValue placeholder={!namespace ? '先选择命名空间' : workloadsLoading ? '加载 Deployment…' : '选择 Deployment'} /></SelectTrigger>
<SelectContent>
{workloads.map((item) => <SelectItem key={item.name} value={item.name}>
{item.name}(就绪 {item.readyReplicas}/{item.replicas})
</SelectItem>)}
</SelectContent>
</Select>
)}
</FormControl>
<FormMessage />
<div className="flex items-center gap-2 text-xs">
<Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setManualDeployment((value) => !value)}>
{manualDeployment ? '从集群选择' : '手动填写'}
</Button>
{workloadsError && <><span className="text-destructive">{workloadsError}</span><Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setWorkloadReload((value) => value + 1)}>重试</Button></>}
{!workloadsLoading && !workloadsError && namespace && workloads.length === 0 && <span className="text-muted-foreground">此命名空间暂无 Deployment</span>}
</div>
</FormItem>
)}
/>
@@ -333,6 +429,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
支持 glob 通配(如 /var/log/*.log /var/log/syslog);多个路径用逗号分隔
</p>
)}
{isSidecarType && <p className="text-xs text-muted-foreground">填写业务写入的文件路径,如 /var/log/app/*.log。已有日志卷会自动共享;无日志卷时仅在专用 logs 目录创建共享卷。/tmp/test.log 等普通目录不会自动挂载。</p>}
</FormItem>
)}
/>
+6 -1
View File
@@ -13,6 +13,7 @@ import { useListQuery } from '@/hooks/useListQuery'
import type { Cluster, Collector, Host, LokiDeployment } from '@/types'
import { CollectorCard } from './components/CollectorCard'
import { CollectorFormDialog } from './components/CollectorFormDialog'
import { CollectorEditDialog } from './components/CollectorEditDialog'
/**
* 日志采集页 — 采集策略编排:列表走 useListQuery(取消+版本号),
@@ -30,6 +31,7 @@ const CollectorsPage = memo(function CollectorsPage() {
const [hosts, setHosts] = useState<Host[]>([])
const [formOpen, setFormOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Collector | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Collector | null>(null)
const [deleting, setDeleting] = useState(false)
/** 部署/卸载指令进行中的采集器 ID(按钮 loading + 防重复点击) */
@@ -128,7 +130,7 @@ const CollectorsPage = memo(function CollectorsPage() {
/>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((collector) => (
<CollectorCard
key={collector.id}
@@ -137,6 +139,7 @@ const CollectorsPage = memo(function CollectorsPage() {
onInstall={(id) => handleAction(id, 'install')}
onUninstall={(id) => handleAction(id, 'uninstall')}
onDelete={setDeleteTarget}
onEdit={setEditTarget}
/>
))}
</div>
@@ -158,6 +161,8 @@ const CollectorsPage = memo(function CollectorsPage() {
hosts={hosts}
/>
<CollectorEditDialog collector={editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null) }} onSaved={query.refresh} />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -1,10 +1,9 @@
import { memo, useEffect, useState } from 'react'
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink } from 'lucide-react'
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink, Pencil, Rocket } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge, StatusIcon } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { StatusBadge } from '@/components/StatusBadge'
import { deploymentApi } from '@/api'
import { ResourceCard } from '@/components/ResourceCard'
import type { LokiDeployment } from '@/types'
/** 部署目标展示:优先集群,其次主机 */
@@ -23,6 +22,7 @@ interface DeploymentCardProps {
onRestart: (id: number) => void
onDelete: (d: LokiDeployment) => void
onViewLogs: (d: LokiDeployment) => void
onEditRetention: (d: LokiDeployment) => void
}
/** 部署卡片 — 列表项展示与操作入口(部署/停止/重启/日志/访问/删除) */
@@ -34,6 +34,7 @@ export const DeploymentCard = memo(function DeploymentCard({
onRestart,
onDelete,
onViewLogs,
onEditRetention,
}: DeploymentCardProps) {
// Loki 就绪探测(运行中时经数据源代理 /ready)
const [ready, setReady] = useState<boolean | null>(null)
@@ -58,122 +59,75 @@ export const DeploymentCard = memo(function DeploymentCard({
const canDeploy = d.status === 'pending' || d.status === 'failed' || d.status === 'stopped'
return (
<Card className="border-border hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={d.status} size="md" />
<div className="min-w-0">
<h3 className="font-semibold text-foreground truncate">
{d.name}
</h3>
<p className="text-xs text-muted-foreground">{d.uuid}</p>
</div>
</div>
<StatusBadge status={d.status} />
{d.status === 'running' && ready !== null && (
<span
className={`w-2 h-2 rounded-full ${ready ? 'bg-success' : 'bg-destructive'}`}
title={ready ? 'Loki 就绪' : 'Loki 未就绪'}
/>
)}
</div>
<p className="text-sm text-muted-foreground mb-3">{d.description || '无描述'}</p>
<div className="space-y-1.5 text-xs text-muted-foreground mb-4">
<InfoRow label="方式" value={d.mode} />
<InfoRow label="版本" value={d.version} />
<InfoRow label="目标" value={getTargetLabel(d)} />
<InfoRow label="命名空间" value={d.namespace || 'loki'} />
<InfoRow label="副本数" value={String(d.replicas)} />
<InfoRow label="存储" value={d.storageConfig?.name || '本地'} />
{d.endpoint && (
<InfoRow label="访问地址" value={d.endpoint} valueClass="text-primary truncate max-w-[200px]" />
)}
{d.grafanaEnabled && d.grafanaEndpoint && (
<div className="flex justify-between items-center gap-2">
<span>Grafana</span>
<a
href={d.grafanaEndpoint}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline truncate max-w-[200px]"
title={`${d.grafanaEndpoint}(admin/admin)`}
>
{d.grafanaEndpoint.replace(/^https?:\/\//, '')}
</a>
</div>
)}
{d.statusMessage && (
<div className="p-2 bg-muted rounded text-xs">
{d.statusMessage}
</div>
)}
</div>
<div className="flex gap-1 pt-3 border-t border-border">
<ResourceCard
icon={Rocket}
iconClassName={d.status === 'running' ? 'text-success' : d.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}
title={d.name}
titleExtra={
d.status === 'running' && ready !== null ? (
<span
className={`w-2 h-2 rounded-full shrink-0 ${ready ? 'bg-success' : 'bg-destructive'}`}
title={ready ? 'Loki 就绪' : 'Loki 未就绪'}
/>
) : undefined
}
badges={<StatusBadge status={d.status} />}
description={d.description || d.uuid}
fields={[
{ label: '方式', value: d.mode === 'host-docker' ? '主机 Docker' : d.mode === 'k8s-yaml' ? 'Kubernetes' : d.mode },
{ label: '版本', value: d.version },
{ label: '目标', value: getTargetLabel(d) },
...(d.mode !== 'host-docker' ? [
{ label: '命名空间', value: d.namespace || 'loki' },
{ label: '副本', value: `${d.replicas} · 保留 ${d.retentionDays || 30} 天` },
] : [{ label: '保留', value: `${d.retentionDays || 30} 天` }]),
{ label: '存储', value: d.storageConfig?.name || '本地' },
...(d.endpoint ? [{ label: '访问', value: (
<span className="text-primary">{d.endpoint.replace(/^https?:\/\//, '')}</span>
), title: d.endpoint }] : []),
...(d.grafanaEnabled && d.grafanaEndpoint ? [{ label: 'Grafana', value: (
<a href={d.grafanaEndpoint} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" title={`${d.grafanaEndpoint}(admin/admin)`}>
{d.grafanaEndpoint.replace(/^https?:\/\//, '')}
</a>
) }] : []),
]}
message={d.statusMessage || undefined}
actions={
<>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onEditRetention(d)} disabled={isDeploying || isBusy}>
<Pencil className="size-3 mr-1" />配置
</Button>
{canDeploy ? (
<Button
variant="outline"
size="sm"
className="flex-1 text-xs bg-success/10 text-primary hover:bg-success/20 border-success/30"
onClick={() => onDeploy(d.id)}
disabled={isBusy}
>
<Play className="w-3 h-3 mr-1" />
{isBusy ? '部署中...' : '部署'}
<Button variant="outline" size="sm" className="flex-1 text-xs bg-success/10 text-primary hover:bg-success/20 border-success/30" onClick={() => onDeploy(d.id)} disabled={isBusy}>
<Play className="size-3 mr-1" />{isBusy ? '部署中...' : '部署'}
</Button>
) : isDeploying ? (
<Button variant="outline" size="sm" className="flex-1 text-xs" disabled>
<RotateCw className="w-3 h-3 mr-1 animate-spin" />
部署中...
<RotateCw className="size-3 mr-1 animate-spin" />部署中...
</Button>
) : (
<>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onStop(d.id)}>
<Square className="w-3 h-3 mr-1" />
停止
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onStop(d.id)}>
<Square className="size-3 mr-1" />停止
</Button>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => onRestart(d.id)}
disabled={isBusy}
>
<RotateCw className={`w-3 h-3 mr-1 ${isBusy ? 'animate-spin' : ''}`} />
重启
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onRestart(d.id)} disabled={isBusy}>
<RotateCw className={`size-3 mr-1 ${isBusy ? 'animate-spin' : ''}`} />重启
</Button>
</>
)}
<Button variant="outline" size="sm" className="text-xs" onClick={() => onViewLogs(d)}>
<Terminal className="w-3 h-3 mr-1" />
日志
<Terminal className="size-3 mr-1" />日志
</Button>
{d.endpoint && (
<Button
variant="outline"
size="sm"
className="text-xs text-primary hover:text-primary/80"
onClick={() => window.open(d.endpoint, '_blank', 'noopener,noreferrer')}
title="浏览器访问"
>
<ExternalLink className="w-3 h-3 mr-1" />
访问
<Button variant="outline" size="sm" className="text-xs text-primary hover:text-primary/80" onClick={() => window.open(d.endpoint, '_blank', 'noopener,noreferrer')} title="浏览器访问">
<ExternalLink className="size-3" />
</Button>
)}
<Button
variant="outline"
size="sm"
aria-label={`删除部署 ${d.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(d)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" aria-label={`删除部署 ${d.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(d)}>
<Trash2 className="size-3.5" />
</Button>
</div>
</CardContent>
</Card>
</>
}
/>
)
})
@@ -81,7 +81,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
defaultValues: DEFAULT_VALUES,
})
const mode = form.watch('mode')
const grafanaEnabled = form.watch('grafanaEnabled')
const hostID = form.watch('hostID')
// 打开时重置(关闭重置时机明确)
useEffect(() => {
@@ -100,6 +100,8 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
mode: values.mode,
clusterID: values.clusterID ? Number(values.clusterID) : undefined,
hostID: values.hostID ? Number(values.hostID) : undefined,
listenIP: values.mode === 'host-docker' ? (values.listenIP || hosts.find((h) => String(h.id) === values.hostID)?.hostIP) : undefined,
listenPort: values.mode === 'host-docker' ? Number(values.listenPort) : undefined,
namespace: values.namespace,
storageConfigID: values.storageConfigID ? Number(values.storageConfigID) : 0,
authEnabled: values.authEnabled,
@@ -108,8 +110,6 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
resourcesCPU: values.resourcesCPU,
resourcesMem: values.resourcesMem,
storageSize: values.storageSize,
grafanaEnabled: values.grafanaEnabled,
grafanaVersion: values.grafanaEnabled ? values.grafanaVersion || undefined : undefined,
})
onOpenChange(false)
onCreated()
@@ -121,7 +121,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
})
const showK8s = isK8sMode(mode)
const showHost = mode === 'host-binary'
const showHost = mode === 'host-docker'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -171,7 +171,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
{versions.map((v) => (
{versions.filter((v) => mode !== 'host-docker' || v.startsWith('3.')).map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
@@ -190,14 +190,16 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>部署方式</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value} onValueChange={(next) => {
field.onChange(next)
if (next === 'host-docker' && !form.getValues('version').startsWith('3.')) form.setValue('version', '3.0.0')
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="k8s-helm">K8s Helm 部署</SelectItem>
<SelectItem value="k8s-yaml">K8s YAML 部署</SelectItem>
<SelectItem value="host-binary">主机二进制部署</SelectItem>
<SelectItem value="k8s-yaml">Kubernetes</SelectItem>
<SelectItem value="host-docker">主机 Docker</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -249,7 +251,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
</>
)}
{showHost && (
{showHost && (<>
<FormField
control={form.control}
name="hostID"
@@ -274,7 +276,16 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
</FormItem>
)}
/>
)}
<div className="grid grid-cols-2 gap-4">
<FormField control={form.control} name="listenIP" render={({ field }) => (
<FormItem><FormLabel>监听内网 IP</FormLabel><FormControl><Input placeholder={hosts.find((h) => String(h.id) === hostID)?.hostIP || '选择主机后默认使用主机 IP'} {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="listenPort" render={({ field }) => (
<FormItem><FormLabel>访问端口</FormLabel><FormControl><Input type="number" min={1} max={65535} {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<p className="text-xs text-muted-foreground">通过 SSH 在主机启动 Docker 容器,配置和数据保存在主机本地。</p>
</>)}
<FormField
control={form.control}
@@ -303,7 +314,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
/>
<div className="grid grid-cols-3 gap-4">
<FormField
{showK8s && <FormField
control={form.control}
name="replicas"
render={({ field }) => (
@@ -315,7 +326,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormMessage />
</FormItem>
)}
/>
/>}
<FormField
control={form.control}
name="retentionDays"
@@ -329,7 +340,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
</FormItem>
)}
/>
<FormField
{showK8s && <FormField
control={form.control}
name="storageSize"
render={({ field }) => (
@@ -341,7 +352,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormMessage />
</FormItem>
)}
/>
/>}
</div>
<div className="grid grid-cols-2 gap-4">
@@ -392,41 +403,6 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
)}
/>
<FormField
control={form.control}
name="grafanaEnabled"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel>同时部署 Grafana(可选 — 平台已内置日志查询,通常无需 Grafana)</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{grafanaEnabled && (
<FormField
control={form.control}
name="grafanaVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Grafana 版本</FormLabel>
<FormControl>
<Input placeholder="11.3.0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90"
@@ -0,0 +1,146 @@
import { memo, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { deploymentApi } from '@/api'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Textarea } from '@/components/ui/textarea'
import type { LokiDeployment } from '@/types'
const schema = z.object({ yaml: z.string().trim().min(1, '请输入 YAML 配置') })
type Values = z.infer<typeof schema>
interface Props {
deployment: LokiDeployment | null
onClose: () => void
onSaved: () => void
}
function configLocation(deployment: LokiDeployment) {
if (deployment.mode === 'k8s-helm') return 'Helm values.yaml → loki.limits_config.retention_period'
if (deployment.mode === 'host-binary') return '/etc/loki/loki.yaml → limits_config.retention_period'
if (deployment.mode === 'host-docker') return `主机 /etc/lokideploy/loki-${deployment.id}/loki.yaml → limits_config.retention_period`
return `ConfigMap ${deployment.namespace || 'log'}/loki-config → loki.yaml`
}
export const RetentionDialog = memo(function RetentionDialog({ deployment, onClose, onSaved }: Props) {
const [saving, setSaving] = useState(false)
const [liveConfig, setLiveConfig] = useState<{ location: string; yaml: string } | null>(null)
const [configLoading, setConfigLoading] = useState(false)
const [configError, setConfigError] = useState('')
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { yaml: 'limits_config:\n retention_period: 30d\n' },
})
useEffect(() => {
if (deployment) {
form.reset({ yaml: `limits_config:\n retention_period: ${deployment.retentionDays || 30}d\n` })
}
setLiveConfig(null)
setConfigError('')
}, [deployment, form])
const loadConfig = async () => {
if (!deployment) return
setConfigLoading(true)
setConfigError('')
try {
setLiveConfig(await deploymentApi.liveConfig(deployment.id))
} catch (err) {
setConfigError(err instanceof Error ? err.message : '读取运行配置失败')
} finally {
setConfigLoading(false)
}
}
const save = (apply: boolean) => form.handleSubmit(async ({ yaml }) => {
if (!deployment) return
setSaving(true)
try {
const result = await deploymentApi.updateRetention(deployment.id, yaml, apply)
onSaved()
onClose()
if (apply) toast.info(`已保存 ${result.retentionDays} 天,Loki 正在重新部署`)
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存 Loki 配置失败')
onSaved()
} finally {
setSaving(false)
}
})
return (
<Dialog open={deployment !== null} onOpenChange={(open) => { if (!open && !saving) onClose() }}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>编辑 Loki 配置 · {deployment?.name}</DialogTitle>
</DialogHeader>
<div className="space-y-1 text-sm text-muted-foreground">
<p>配置位置:{deployment ? configLocation(deployment) : ''}</p>
<p>保留策略由 Loki compactor 执行;MinIO 只负责存储日志对象。</p>
</div>
<div className="space-y-2 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">运行中的完整 loki.yaml</p>
<p className="text-xs text-muted-foreground">从部署目标读取,存储凭证已遮盖;下方保留天数仍单独编辑。</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={loadConfig} disabled={configLoading || deployment?.mode === 'k8s-helm'}>
{configLoading ? '读取中…' : liveConfig ? '刷新配置' : '查看完整配置'}
</Button>
</div>
{deployment?.mode === 'k8s-helm' && <p className="text-xs text-muted-foreground">Helm 模式暂不支持读取运行配置。</p>}
{configError && <p className="text-sm text-destructive">{configError}</p>}
{liveConfig && (
<div>
<p className="mb-2 text-xs text-muted-foreground">{liveConfig.location}</p>
<pre className="max-h-[45vh] overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs whitespace-pre">{liveConfig.yaml}</pre>
</div>
)}
</div>
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
<p className="mb-1 font-medium text-foreground">由部署模板生成</p>
<pre className="text-muted-foreground">{'compactor:\n retention_enabled: true\n retention_delete_delay: 2h'}</pre>
</div>
<Form {...form}>
<form onSubmit={save(false)} className="space-y-4">
<FormField
control={form.control}
name="yaml"
render={({ field }) => (
<FormItem>
<FormLabel>保留配置 YAML</FormLabel>
<FormControl>
<Textarea rows={4} spellCheck={false} className="font-mono text-sm" {...field} />
</FormControl>
<p className="text-xs text-muted-foreground">
编辑 limits_config.retention_period,例如 90d 或 2160h。此处只接受这一项配置。
</p>
<FormMessage />
</FormItem>
)}
/>
<p className="text-sm text-muted-foreground">
{deployment?.status === 'running'
? '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。'
: '保存后在下次部署时生效。'}
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>取消</Button>
<Button type="submit" variant="outline" disabled={saving}>仅保存</Button>
{deployment?.status === 'running' && (
<Button type="button" onClick={save(true)} disabled={saving}>
{saving ? '处理中…' : '保存并应用'}
</Button>
)}
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
})
+12 -10
View File
@@ -5,10 +5,9 @@ import { z } from 'zod'
// 版本下拉兜底值;实际列表运行时从 GET /deployments/versions 拉取
export const FALLBACK_VERSIONS = ['3.0.0', '2.9.8', '2.9.7', '2.9.6']
export type DeploymentMode = 'k8s-helm' | 'k8s-yaml' | 'host-binary'
export type DeploymentMode = 'k8s-yaml' | 'host-docker'
export const isK8sMode = (mode: DeploymentMode) =>
mode === 'k8s-helm' || mode === 'k8s-yaml'
export const isK8sMode = (mode: DeploymentMode) => mode === 'k8s-yaml'
// 整数字段校验(输入框 type=number 产出字符串,提交前统一转换)
const isPositiveInt = (v: string, min: number, max?: number) => {
@@ -21,12 +20,12 @@ export const deploymentFormSchema = z
name: z.string().min(1, '请输入部署名称'),
description: z.string(),
version: z.string().min(1, '请选择 Loki 版本'),
mode: z.enum(['k8s-helm', 'k8s-yaml', 'host-binary']),
mode: z.enum(['k8s-yaml', 'host-docker']),
clusterID: z.string(),
hostID: z.string(),
listenIP: z.string(),
listenPort: z.string(),
namespace: z.string(),
grafanaEnabled: z.boolean(),
grafanaVersion: z.string().optional(),
storageConfigID: z.string(),
authEnabled: z.boolean(),
retentionDays: z
@@ -43,9 +42,12 @@ export const deploymentFormSchema = z
if (isK8sMode(data.mode) && !data.clusterID) {
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: '请选择目标集群' })
}
if (data.mode === 'host-binary' && !data.hostID) {
if (data.mode === 'host-docker' && !data.hostID) {
ctx.addIssue({ code: 'custom', path: ['hostID'], message: '请选择目标主机' })
}
if (data.mode === 'host-docker' && !isPositiveInt(data.listenPort, 1, 65535)) {
ctx.addIssue({ code: 'custom', path: ['listenPort'], message: '端口须在 1-65535 之间' })
}
if (isK8sMode(data.mode) && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请填写命名空间' })
}
@@ -57,12 +59,12 @@ export const DEFAULT_VALUES: DeploymentFormValues = {
name: '',
description: '',
version: '3.0.0',
mode: 'k8s-helm',
mode: 'k8s-yaml',
clusterID: '',
hostID: '',
listenIP: '',
listenPort: '3100',
namespace: 'log',
grafanaEnabled: false,
grafanaVersion: '11.3.0',
storageConfigID: '',
authEnabled: false,
retentionDays: '30',
+10 -1
View File
@@ -13,6 +13,7 @@ import { useListQuery } from '@/hooks/useListQuery'
import type { Cluster, Host, LokiDeployment, StorageConfig } from '@/types'
import { DeploymentCard } from './components/DeploymentCard'
import { DeploymentFormDialog } from './components/DeploymentFormDialog'
import { RetentionDialog } from './components/RetentionDialog'
import { LogViewerDialog } from './components/LogViewerDialog'
/**
@@ -32,6 +33,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
const [formOpen, setFormOpen] = useState(false)
const [logsDeployment, setLogsDeployment] = useState<LokiDeployment | null>(null)
const [retentionDeployment, setRetentionDeployment] = useState<LokiDeployment | null>(null)
const [deleteTarget, setDeleteTarget] = useState<LokiDeployment | null>(null)
const [deleting, setDeleting] = useState(false)
/** 部署/重启进行中的部署 ID(按钮 loading + 防重复点击) */
@@ -155,7 +157,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
/>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((deployment) => (
<DeploymentCard
key={deployment.id}
@@ -166,6 +168,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
onRestart={handleRestart}
onDelete={setDeleteTarget}
onViewLogs={setLogsDeployment}
onEditRetention={setRetentionDeployment}
/>
))}
</div>
@@ -192,6 +195,12 @@ const DeploymentsPage = memo(function DeploymentsPage() {
onClose={() => setLogsDeployment(null)}
/>
<RetentionDialog
deployment={retentionDeployment}
onClose={() => setRetentionDeployment(null)}
onSaved={query.refresh}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -1,9 +1,8 @@
import { memo } from 'react'
import { Monitor, RefreshCw, Trash2, Eye, Terminal } from 'lucide-react'
import { Monitor, RefreshCw, Trash2, Eye, Terminal, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { ResourceCard } from '@/components/ResourceCard'
import type { Host } from '@/types'
interface HostCardProps {
@@ -13,85 +12,44 @@ interface HostCardProps {
onOpenTerminal: (h: Host) => void
onValidate: (id: number) => void
onDelete: (h: Host) => void
onEdit: (h: Host) => void
}
/** 主机卡片 — 列表项展示与操作入口(删除传整个对象,供确认弹窗展示名称) */
/** 主机卡片 — 统一 ResourceCard 样式 */
export const HostCard = memo(function HostCard({
host,
validating,
onViewInfo,
onOpenTerminal,
onValidate,
onDelete,
host, validating, onViewInfo, onOpenTerminal, onValidate, onDelete, onEdit,
}: HostCardProps) {
return (
<Card className="border-border hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Monitor className="w-5 h-5 text-purple-500 flex-shrink-0" />
<h3 className="font-semibold text-foreground truncate">
{host.name}
</h3>
</div>
<StatusBadge status={host.status} />
</div>
<div className="space-y-1.5 text-xs text-muted-foreground mb-4">
<InfoRow label="IP 地址" value={`${host.hostIP}:${host.port}`} />
<InfoRow label="用户名" value={host.user} />
<InfoRow
label="认证方式"
value={host.authType === 'key' ? 'SSH 密钥' : '密码'}
/>
<InfoRow
label="系统"
value={`${host.os || '未知'} (${host.arch || '未知'})`}
/>
</div>
<div className="flex gap-2 pt-3 border-t border-border">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onViewInfo(host)}
>
<Eye className="w-3 h-3 mr-1" />
详情
<ResourceCard
icon={Monitor}
iconClassName="text-purple-500"
title={host.name}
badges={<StatusBadge status={host.status} />}
fields={[
{ label: '地址', value: `${host.hostIP}:${host.port}` },
{ label: '用户', value: host.user },
{ label: '认证', value: host.authType === 'key' ? 'SSH 密钥' : '密码' },
{ label: '系统', value: `${host.os || '未知'} / ${host.arch || '未知'}` },
]}
actions={
<>
<Button variant="outline" size="sm" aria-label={'编辑主机 ' + host.name} onClick={() => onEdit(host)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onOpenTerminal(host)}
>
<Terminal className="w-3 h-3 mr-1" />
终端
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onViewInfo(host)}>
<Eye className="size-3 mr-1" />详情
</Button>
<Button
variant="outline"
size="sm"
aria-label={`验证主机 ${host.name}`}
className="text-xs"
onClick={() => onValidate(host.id)}
disabled={validating === host.id}
>
<RefreshCw
className={`w-3 h-3 ${validating === host.id ? 'animate-spin' : ''}`}
/>
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onOpenTerminal(host)}>
<Terminal className="size-3 mr-1" />终端
</Button>
<Button
variant="outline"
size="sm"
aria-label={`删除主机 ${host.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(host)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" aria-label={`验证主机 ${host.name}`} disabled={validating === host.id} onClick={() => onValidate(host.id)}>
<RefreshCw className={`size-3 ${validating === host.id ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" aria-label={`删除主机 ${host.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(host)}>
<Trash2 className="size-3.5" />
</Button>
</>
}
/>
)
})
@@ -43,6 +43,7 @@ const hostFormSchema = z
labels: z.string().optional(),
})
.superRefine((data, ctx) => {
if (EDITING_REF.current) return
if (data.authType === 'password' && !data.password?.trim()) {
ctx.addIssue({
code: 'custom',
@@ -77,15 +78,19 @@ interface HostFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** 创建成功回调(父级刷新列表并关闭弹窗) */
onCreated: () => void
onCreated: () => void /** 编辑目标(编辑模式:凭证留空=保留) */
editing?: import('@/types').Host | null
}
/** 添加 SSH 主机弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
const EDITING_REF = { current: false as boolean | null }
export const HostFormDialog = memo(function HostFormDialog({
open,
onOpenChange,
onCreated,
}: HostFormDialogProps) {
editing,
}: HostFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
const form = useForm<HostFormValues>({
@@ -95,26 +100,35 @@ export const HostFormDialog = memo(function HostFormDialog({
const authType = form.watch('authType')
// 打开时重置(默认值),关闭/重开时机明确
EDITING_REF.current = !!editing
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
form.reset(editing ? {
name: editing.name, hostIP: editing.hostIP, port: String(editing.port),
user: editing.user, authType: editing.authType,
password: '', privateKey: '', keyPhrase: '', labels: editing.labels || '',
} as never : DEFAULT_VALUES)
}
}, [open, form])
}, [open, form, editing])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
await hostApi.create({
name: values.name,
hostIP: values.hostIP,
port: Number(values.port),
user: values.user,
authType: values.authType,
password: values.authType === 'password' ? values.password : undefined,
privateKey: values.authType === 'key' ? values.privateKey : undefined,
keyPhrase: values.authType === 'key' ? values.keyPhrase : undefined,
labels: values.labels,
})
await (editing
? hostApi.update(editing.id, {
name: values.name, hostIP: values.hostIP, port: Number(values.port),
user: values.user, authType: values.authType,
password: values.password || undefined,
privateKey: values.privateKey || undefined,
keyPhrase: values.keyPhrase || undefined,
labels: values.labels,
})
: hostApi.create({
name: values.name, hostIP: values.hostIP, port: Number(values.port),
user: values.user, authType: values.authType,
password: values.password, privateKey: values.privateKey,
keyPhrase: values.keyPhrase, labels: values.labels,
}))
onOpenChange(false)
onCreated()
} catch (err) {
@@ -128,7 +142,7 @@ export const HostFormDialog = memo(function HostFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加 SSH 主机</DialogTitle>
<DialogTitle>{editing ? `编辑主机${editing.name ? ` · ${editing.name}` : ''}` : '添加 SSH 主机'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -232,7 +246,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>密码 *</FormLabel>
<FormLabel>密码{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<PasswordInput placeholder="SSH 登录密码" {...field} />
</FormControl>
@@ -247,7 +261,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>私钥 *</FormLabel>
<FormLabel>私钥{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<Textarea
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
@@ -294,7 +308,7 @@ export const HostFormDialog = memo(function HostFormDialog({
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? '添加中...' : '添加主机'}
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加主机'}
</Button>
</form>
</Form>
+12 -4
View File
@@ -29,6 +29,7 @@ const HostsPage = memo(function HostsPage() {
const [detailHost, setDetailHost] = useState<Host | null>(null)
const [terminalHost, setTerminalHost] = useState<Host | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Host | null>(null)
const [editingTarget, setEditingTarget] = useState<Host | null>(null)
const [deleting, setDeleting] = useState(false)
const [validating, setValidating] = useState<number | null>(null)
@@ -111,7 +112,7 @@ const HostsPage = memo(function HostsPage() {
/>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((host) => (
<HostCard
key={host.id}
@@ -121,6 +122,7 @@ const HostsPage = memo(function HostsPage() {
onOpenTerminal={setTerminalHost}
onValidate={handleValidate}
onDelete={setDeleteTarget}
onEdit={setEditingTarget}
/>
))}
</div>
@@ -134,9 +136,15 @@ const HostsPage = memo(function HostsPage() {
)}
<HostFormDialog
open={formOpen}
onOpenChange={setFormOpen}
onCreated={query.reload}
open={formOpen || editingTarget !== null}
onOpenChange={(o) => {
if (!o) {
setFormOpen(false)
setEditingTarget(null)
}
}}
onCreated={query.refresh}
editing={editingTarget}
/>
<HostDetailDialog
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
import { logsApi } from '@/api/logs'
import { SearchableValueSelect } from './SearchableValueSelect'
const policyLabels = ['namespace', 'app', 'pod'] as const
type PolicyLabel = typeof policyLabels[number]
interface AccessRuleEditorProps {
sourceId: number
username: string
labels: Record<string, string>
onChange: (labels: Record<string, string>) => void
}
/** Exact stream-label restrictions for one user's access to a Loki source. */
export function AccessRuleEditor({ sourceId, username, labels, onChange }: AccessRuleEditorProps) {
const [options, setOptions] = useState<Record<PolicyLabel, string[]>>({ namespace: [], app: [], pod: [] })
const [loading, setLoading] = useState<Record<PolicyLabel, boolean>>({ namespace: false, app: false, pod: false })
const namespace = labels.namespace ?? ''
const app = labels.app ?? ''
useEffect(() => {
if (!sourceId) return
let active = true
const load = (name: PolicyLabel, scope: Record<string, string>) => {
setLoading(current => ({ ...current, [name]: true }))
logsApi.labelValues(sourceId, name, Object.keys(scope).length ? `{${Object.entries(scope).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}` : '')
.then(values => { if (active) setOptions(current => ({ ...current, [name]: values })) })
.catch(() => { if (active) setOptions(current => ({ ...current, [name]: [] })) })
.finally(() => { if (active) setLoading(current => ({ ...current, [name]: false })) })
}
load('namespace', {})
load('app', namespace ? { namespace } : {})
load('pod', { ...(namespace ? { namespace } : {}), ...(app ? { app } : {}) })
return () => { active = false }
}, [sourceId, namespace, app])
function setLabel(name: PolicyLabel, value: string) {
const next = { ...labels }
if (value) next[name] = value
else delete next[name]
if (name === 'namespace') { delete next.app; delete next.pod }
if (name === 'app') delete next.pod
onChange(next)
}
return <div className="grid gap-2 sm:grid-cols-3 flex-1" aria-label={`${username} 查询范围`}>
{policyLabels.map(name => <SearchableValueSelect key={name} label={name === 'namespace' ? '命名空间' : name === 'app' ? 'App' : '副本 / Pod'} values={options[name]} value={labels[name] ?? ''} onChange={value => setLabel(name, value)} allowEmpty loading={loading[name]} disabled={!sourceId} />)}
</div>
}
@@ -0,0 +1,11 @@
function escapeRegExp(text: string) { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
export function HighlightedLogLine({ message, terms, className = '' }: { message: string; terms: string[]; className?: string }) {
if (!terms.length) return <pre className={className}>{message}</pre>
const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'g')
const pieces = message.split(pattern)
const matches = new Set(terms)
return <pre className={className}>{pieces.map((piece, index) => matches.has(piece)
? <mark key={index} className="rounded-sm bg-warning/30 px-0.5 text-foreground">{piece}</mark>
: piece)}</pre>
}
@@ -0,0 +1,62 @@
import { useMemo, useState } from 'react'
import type { IngressSample, IngressRouteStats } from '../ingress-insights'
import { summarizeIngress } from '../ingress-insights'
type Mode = 'traffic' | 'errors' | 'slow' | 'detail'
type ErrorClass = 'all' | '5xx' | '4xx'
const modes: { id: Mode; label: string }[] = [
{ id: 'traffic', label: '接口 PV / UV' },
{ id: 'errors', label: '异常 Top 10' },
{ id: 'slow', label: '慢请求 Top 10' },
{ id: 'detail', label: '请求明细' },
]
const heading = 'px-3 py-2 text-left font-medium whitespace-nowrap'
const cell = 'px-3 py-2 tabular-nums'
const formatSeconds = (seconds: number | null) => seconds === null ? '—' : `${seconds.toFixed(3)} s`
const routeKey = (route: IngressRouteStats) => `${route.domain}\u0000${route.path}`
function Route({ route }: { route: IngressRouteStats }) {
return <><td className={`${cell} max-w-48 truncate`} title={route.domain}>{route.domain}</td>
<td className={`${cell} max-w-96 truncate font-mono`} title={route.path}>{route.path}</td></>
}
export function IngressInsightsTable({ samples, loadedCount }: { samples: IngressSample[]; loadedCount: number }) {
const [mode, setMode] = useState<Mode>('traffic')
const [errorClass, setErrorClass] = useState<ErrorClass>('all')
const [slowThreshold, setSlowThreshold] = useState(1)
const summary = useMemo(() => summarizeIngress(samples, slowThreshold), [samples, slowThreshold])
const traffic = useMemo(() => [...summary.routes].sort((a, b) => b.pv - a.pv || b.error5xx - a.error5xx).slice(0, 10), [summary.routes])
const errors = useMemo(() => [...summary.routes]
.filter(route => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx)
.sort((a, b) => {
const count = (route: IngressRouteStats) => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx
return count(b) - count(a) || b.error5xx - a.error5xx
}).slice(0, 10), [summary.routes, errorClass])
const slow = useMemo(() => [...summary.routes].filter(route => route.slow || route.timeoutSignals)
.sort((a, b) => b.slow - a.slow || b.timeoutSignals - a.timeoutSignals || (b.maxSeconds ?? 0) - (a.maxSeconds ?? 0)).slice(0, 10), [summary.routes])
return <div className="space-y-3">
<p className="text-xs text-muted-foreground">仅统计当前页已加载的 {loadedCount} 条日志,其中 {samples.length} 条为 ingress-nginx 访问日志。翻页后重新统计;UV 按非空客户端 IP 去重。</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['PV', summary.pv], ['UV(IP)', summary.uv], ['4xx / 5xx', summary.errors], [`慢请求 ≥ ${slowThreshold}s`, summary.slow],
].map(([label, value]) => <div key={label} className="rounded-lg border bg-muted/20 px-3 py-2"><div className="text-xs text-muted-foreground">{label}</div><div className="text-lg font-semibold tabular-nums">{value}</div></div>)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div role="tablist" aria-label="Ingress 统计方式" className="flex flex-wrap gap-1 rounded-md bg-muted/60 p-1">
{modes.map(item => <button key={item.id} type="button" role="tab" aria-selected={mode === item.id} onClick={() => setMode(item.id)} className={`rounded px-2.5 py-1.5 text-xs ${mode === item.id ? 'bg-card font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>{item.label}</button>)}
</div>
{mode === 'errors' && <select aria-label="异常状态码范围" value={errorClass} onChange={event => setErrorClass(event.target.value as ErrorClass)} className="h-8 rounded border bg-background px-2 text-xs"><option value="all">全部 4xx / 5xx</option><option value="5xx">只看 5xx</option><option value="4xx">只看 4xx</option></select>}
{mode === 'slow' && <label className="flex items-center gap-1 text-xs text-muted-foreground">慢请求阈值 <select aria-label="慢请求阈值" value={slowThreshold} onChange={event => setSlowThreshold(Number(event.target.value))} className="h-8 rounded border bg-background px-2 text-foreground"><option value={0.5}>0.5 秒</option><option value={1}>1 秒</option><option value={3}>3 秒</option><option value={5}>5 秒</option><option value={10}>10 秒</option></select></label>}
</div>
{mode === 'slow' && <p className="text-xs text-muted-foreground">依据 access log 的 request_time 排序;408 / 499 / 504 仅作超时线索,不能单凭状态码断定后端超时。{summary.timed < summary.pv && ` ${summary.pv - summary.timed} 条缺少有效耗时。`}</p>}
{mode === 'traffic' && <p className="text-xs text-muted-foreground">访问成功率按 2xx / 3xx 请求数 ÷ PV 计算;路径已去掉查询参数。</p>}
<div className="max-h-[60vh] overflow-auto rounded-lg border">
{mode === 'traffic' && <table className="w-full min-w-[680px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', 'PV', 'UV(IP)', '成功率', '4xx', '5xx'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{traffic.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.pv}</td><td className={cell}>{route.uv}</td><td className={cell}>{(route.success / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td></tr>)}</tbody></table>}
{mode === 'errors' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', '4xx', '5xx', '异常率', '状态码'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{errors.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td><td className={cell}>{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.statusCodes}</td></tr>)}</tbody></table>}
{mode === 'slow' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', '慢请求', '最长耗时', '平均耗时', '408 / 499 / 504'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{slow.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.slow}</td><td className={cell}>{formatSeconds(route.maxSeconds)}</td><td className={cell}>{formatSeconds(route.averageSeconds)}</td><td className={cell}>{route.timeoutSignals}</td></tr>)}</tbody></table>}
{mode === 'detail' && <table className="w-full min-w-[800px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['时间', '请求 Host', '接口路径', '客户端 IP', '状态', '耗时'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{samples.map(({ access, timestamp }, index) => <tr key={`${timestamp}-${index}`} className="hover:bg-muted/40"><td className={`${cell} whitespace-nowrap`}>{new Date(timestamp).toLocaleString()}</td><td className={`${cell} max-w-48 truncate`}>{access.domain || '—'}</td><td className={`${cell} max-w-96 truncate font-mono`} title={`${access.method} ${access.path}`}>{access.method} {access.path}</td><td className={cell}>{access.clientIp || '—'}</td><td className={cell}>{access.status}</td><td className={cell}>{access.requestTime ? `${access.requestTime} s` : '—'}</td></tr>)}</tbody></table>}
{((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) && <p className="px-3 py-10 text-center text-sm text-muted-foreground">当前页没有符合条件的请求</p>}
</div>
</div>
}
@@ -0,0 +1,45 @@
import { Crosshair } from 'lucide-react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { LogEntry } from '@/api/logs'
import { HighlightedLogLine } from './HighlightedLogLine'
export type LogContext = { row: LogEntry; before: LogEntry[]; after: LogEntry[] }
function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean; terms: string[] }) {
const match = row.message.match(/^\d{4}-\d\d-\d\dT\S+\s+(stdout|stderr)\s+[FP]\s+([\s\S]*)$/)
return <article className={`border-l-[3px] px-3 py-2 ${current ? 'border-l-warning bg-warning/15' : 'border-l-transparent bg-card'}`}>
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs">
{current && <span className="inline-flex items-center gap-1 rounded bg-warning px-2 py-0.5 font-semibold text-warning-foreground"><Crosshair className="size-3" />当前日志</span>}
<time className="font-mono tabular-nums text-muted-foreground">{new Date(row.timestamp).toLocaleString()}</time>
{match && <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground">{match[1]}</span>}
{row.level && <span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">{row.level}</span>}
</div>
<HighlightedLogLine message={match?.[2] ?? row.message} terms={terms} className="m-0 whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground" />
</article>
}
export function LogContextDialog({ context, terms, onClose }: { context: LogContext | null; terms: string[]; onClose: () => void }) {
return <Dialog open={context !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="border-b px-5 py-4 pr-12">
<DialogTitle>同一日志流的前后文</DialogTitle>
<DialogDescription>{context?.row.labels.filename ? '同一文件' : '同一日志流'} · 前后各最多 5 条 · 前后 5 分钟</DialogDescription>
{context && <div className="flex flex-wrap gap-1.5 pt-1 text-xs">
{['namespace', 'app', 'pod'].filter(key => context.row.labels[key]).map(key => <span key={key} className="rounded bg-muted px-2 py-1 font-mono text-foreground">{key}={context.row.labels[key]}</span>)}
{context.row.labels.filename && <span title={context.row.labels.filename} className="max-w-full truncate rounded bg-muted px-2 py-1 font-mono text-foreground">文件: {context.row.labels.filename.split('/').slice(-2).join('/')}</span>}
</div>}
</DialogHeader>
{context && <div className="space-y-3 overflow-y-auto bg-muted/20 px-5 py-4">
<section aria-label="之前的日志">
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之前 · {context.before.length} 条</h3>
{context.before.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.before.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此前 5 分钟内无日志</p>}
</section>
<section aria-label="当前日志" className="overflow-hidden rounded-lg border border-warning/70 shadow-sm"><ContextRow row={context.row} current terms={terms} /></section>
<section aria-label="之后的日志">
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之后 · {context.after.length} 条</h3>
{context.after.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.after.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此后 5 分钟内无日志</p>}
</section>
</div>}
</DialogContent>
</Dialog>
}
@@ -0,0 +1,68 @@
import { memo } from 'react'
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'
import type { HistogramBucket } from '@/api/logs'
interface LogHistogramProps {
buckets: HistogramBucket[]
loading: boolean
/** 点击柱体 → 缩放到相邻 bucket 时间范围 */
onZoomRange: (start: number, end: number) => void
}
/** 日志量直方图(服务端 count_over_time 聚合);点击柱体缩放时间范围 */
export const LogHistogram = memo(function LogHistogram({ buckets, loading, onZoomRange }: LogHistogramProps) {
const data = buckets.map((b) => ({
ts: b.timestamp.slice(11, 16),
count: b.count,
raw: new Date(b.timestamp).getTime(),
}))
if (loading && data.length === 0) {
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">统计中…</div>
}
if (data.length === 0) {
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">暂无日志量数据</div>
}
const handleClick = (idx: number) => {
const cur = data[idx]?.raw
if (!cur) return
const step = data.length > 1 ? Math.max(data[1].raw - data[0].raw, 60000) : 300000
onZoomRange(cur - step / 2, cur + step / 2)
}
return (
<div className="h-20">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 4, right: 8, bottom: 0, left: 8 }}
onClick={(state: { activeTooltipIndex?: number }) => {
if (state?.activeTooltipIndex !== undefined) handleClick(state.activeTooltipIndex)
}}
>
<defs>
<linearGradient id="logVol" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.5} />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="ts" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" width={28} allowDecimals={false} />
<Tooltip
contentStyle={{ fontSize: 11, borderRadius: 8, background: 'hsl(var(--popover))', border: '1px solid hsl(var(--border))' }}
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
/>
<Area
type="monotone"
dataKey="count"
stroke="hsl(var(--primary))"
fill="url(#logVol)"
strokeWidth={1.5}
cursor="pointer"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
})
@@ -0,0 +1,125 @@
import { useMemo, useState } from 'react'
import { Area, AreaChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import type { LogEntry } from '@/api/logs'
import { httpStatusClass, parseIngressAccessLog, pathWithoutQuery, stripLogEnvelope } from '../ingress-access-log'
import { IngressInsightsTable } from './IngressInsightsTable'
export type ResultView = 'raw' | 'table' | 'line' | 'stream' | 'pie'
type Bucket = { timestamp: string; count: number }
const colors = ['hsl(var(--info))', 'hsl(var(--success))', 'hsl(var(--warning))', 'hsl(var(--primary))', 'hsl(var(--destructive))', 'hsl(var(--muted-foreground))']
function EmptyChart({ message }: { message: string }) {
return <div className="flex h-64 items-center justify-center rounded-lg bg-muted/20 text-sm text-muted-foreground">{message}</div>
}
export function LogResultViews({ view, entries, buckets, start, end, domainFiltered = false }: {
view: Exclude<ResultView, 'raw'>
entries: LogEntry[]
buckets: Bucket[]
start: string
end: string
domainFiltered?: boolean
}) {
const [groupBy, setGroupBy] = useState<'domain' | 'status' | 'method' | 'path' | 'upstream' | 'app' | 'namespace' | 'pod' | 'level'>('domain')
const [lineBy, setLineBy] = useState<'domain' | 'total'>('domain')
const [streamBy, setStreamBy] = useState<'domain' | 'status'>('domain')
const parsedRows = useMemo(() => entries.map(entry => parseIngressAccessLog(entry.message)), [entries])
const ingressCount = useMemo(() => parsedRows.filter(Boolean).length, [parsedRows])
const ingressSamples = useMemo(() => entries.flatMap((entry, index) => {
const access = parsedRows[index]
return access ? [{ access, timestamp: entry.timestamp }] : []
}), [entries, parsedRows])
const streamDomains = useMemo(() => {
const counts = new Map<string, number>()
for (const row of parsedRows) if (row) {
const domain = row.domain || '未记录域名'
counts.set(domain, (counts.get(domain) ?? 0) + 1)
}
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([domain]) => domain)
}, [parsedRows])
const effectiveGroupBy = ingressCount ? groupBy : ['domain', 'status', 'method', 'path', 'upstream'].includes(groupBy) ? 'app' : groupBy
const lineData = useMemo(() => {
if (!domainFiltered && (lineBy === 'total' || !ingressCount)) return buckets.map(bucket => ({ time: new Date(bucket.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: bucket.count }))
const from = new Date(start).getTime(), to = new Date(end).getTime()
if (!(from < to)) return []
const steps = 16
const counts: Record<string, string | number>[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: 0 }))
for (const [rowIndex, entry] of entries.entries()) {
const index = Math.floor((new Date(entry.timestamp).getTime() - from) / (to - from) * steps)
if (index < 0 || index >= steps) continue
if (lineBy === 'domain' && ingressCount) {
const row = parsedRows[rowIndex]
if (!row) continue
const domain = row.domain || '未记录域名'
const key = streamDomains.includes(domain) ? domain : '其他域名'
counts[index][key] = Number(counts[index][key] ?? 0) + 1
} else counts[index].count = Number(counts[index].count) + 1
}
return counts
}, [buckets, domainFiltered, entries, ingressCount, lineBy, parsedRows, start, end, streamDomains])
const streamData = useMemo(() => {
const from = new Date(start).getTime(), to = new Date(end).getTime()
if (!(from < to)) return []
const steps = 16
const rows: Record<string, string | number>[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), error: 0, warn: 0, info: 0, other: 0, '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 }))
for (const [rowIndex, entry] of entries.entries()) {
const time = new Date(entry.timestamp).getTime()
const index = Math.floor((time - from) / (to - from) * steps)
if (index < 0 || index >= steps) continue
const ingress = parsedRows[rowIndex]
if (ingressCount) {
if (ingress) {
const key = streamBy === 'domain' ? (streamDomains.includes(ingress.domain || '未记录域名') ? ingress.domain || '未记录域名' : '其他域名') : httpStatusClass(ingress.status)
rows[index][key] = Number(rows[index][key] ?? 0) + 1
}
continue
}
const level = entry.level.toLowerCase()
const key = level === 'error' || level === 'warn' || level === 'info' ? level : 'other'
rows[index][key] = Number(rows[index][key] ?? 0) + 1
}
return rows
}, [entries, parsedRows, ingressCount, streamBy, streamDomains, start, end])
const pieData = useMemo(() => {
const counts = new Map<string, number>()
for (const [index, entry] of entries.entries()) {
const ingress = parsedRows[index]
if (['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) && !ingress) continue
const name = effectiveGroupBy === 'domain' ? ingress?.domain || '未记录域名'
: effectiveGroupBy === 'status' ? ingress?.status || '未知'
: effectiveGroupBy === 'method' ? ingress?.method || '未知'
: effectiveGroupBy === 'path' ? pathWithoutQuery(ingress?.path || '/')
: effectiveGroupBy === 'upstream' ? ingress?.upstreamName || '未标记'
: effectiveGroupBy === 'level' ? entry.level || 'unknown'
: entry.labels[effectiveGroupBy] || '未标记'
counts.set(name, (counts.get(name) ?? 0) + 1)
}
const sorted = [...counts].sort((a, b) => b[1] - a[1])
const top = sorted.slice(0, 7).map(([name, count]) => ({ name, count }))
const other = sorted.slice(7).reduce((sum, [, count]) => sum + count, 0)
if (other) top.push({ name: '其他', count: other })
return top
}, [entries, parsedRows, effectiveGroupBy])
if (view === 'table') return ingressCount > 0
? <IngressInsightsTable samples={ingressSamples} loadedCount={entries.length} />
: <div>
<p className="mb-2 text-xs text-muted-foreground">当前页已加载 {entries.length} 条日志。</p>
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志" /> : <div className="max-h-[60vh] overflow-auto rounded-lg border"><table className="w-full text-left text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['时间', 'namespace / pod', '级别', '日志'].map(title => <th key={title} className="border-b px-3 py-2 font-medium">{title}</th>)}</tr></thead><tbody className="divide-y">{entries.map((entry, index) => <tr key={`${entry.timestampNs}-${index}`} className="hover:bg-muted/40"><td className="whitespace-nowrap px-3 py-2 tabular-nums">{new Date(entry.timestamp).toLocaleString()}</td><td className="px-3 py-2">{entry.labels.namespace || '—'} / {entry.labels.pod || '—'}</td><td className="px-3 py-2">{entry.level}</td><td className="max-w-xl truncate px-3 py-2 font-mono" title={stripLogEnvelope(entry.message)}>{stripLogEnvelope(entry.message)}</td></tr>)}</tbody></table></div>}
</div>
if (view === 'line') return <div>
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{(lineBy === 'domain' && ingressCount) || domainFiltered ? '按当前已加载日志统计;更多结果请继续加载。' : '按查询时间范围统计的日志量,来自 Loki 时间聚合。'}</p>{ingressCount > 0 && <select aria-label="折线图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={lineBy} onChange={event => setLineBy(event.target.value as typeof lineBy)}><option value="domain">按域名</option><option value="total">总量</option></select>}</div>
{lineData.length === 0 ? <EmptyChart message="当前查询没有可用的时间统计" /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><LineChart data={lineData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{lineBy === 'domain' && ingressCount ? [...streamDomains, '其他域名'].map((domain, index) => <Line key={domain} type="monotone" dataKey={domain} name={domain} stroke={colors[index % colors.length]} strokeWidth={2} dot={false} />) : <Line type="monotone" dataKey="count" name="日志条数" stroke={colors[0]} strokeWidth={2} dot={false} />}</LineChart></ResponsiveContainer></div>}
</div>
if (view === 'stream') return <div>
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{ingressCount ? `展示 ${ingressCount} 条已解析日志` : `按级别展示当前已加载的 ${entries.length} 条日志`},基于当前已加载结果。</p>{ingressCount > 0 && <select aria-label="流图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={streamBy} onChange={event => setStreamBy(event.target.value as typeof streamBy)}><option value="domain">按域名</option><option value="status">按 HTTP 状态段</option></select>}</div>
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志流" /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><AreaChart data={streamData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{(ingressCount ? streamBy === 'domain' ? [...streamDomains, '其他域名'] : ['2xx', '3xx', '4xx', '5xx', 'other'] : ['error', 'warn', 'info', 'other']).map((level, index) => <Area key={level} type="monotone" dataKey={level} name={level} stackId="logs" stroke={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fill={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fillOpacity={0.65} />)}</AreaChart></ResponsiveContainer></div>}
</div>
return <div>
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) ? `按访问字段统计已解析的 ${ingressCount} 条日志` : `按标签统计当前已加载的 ${entries.length} 条日志`}</p><select aria-label="饼图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={effectiveGroupBy} onChange={event => setGroupBy(event.target.value as typeof groupBy)}>{ingressCount > 0 && <><option value="domain">域名(请求 Host)</option><option value="status">HTTP 状态码</option><option value="method">请求方法</option><option value="path">请求路径</option><option value="upstream">上游服务</option></>}<option value="app">app</option><option value="namespace">namespace</option><option value="pod">pod</option><option value="level">级别</option></select></div>
{pieData.length === 0 ? <EmptyChart message="暂无可展示的标签分布" /> : <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"><div className="h-72"><ResponsiveContainer width="100%" height="100%"><PieChart><Pie data={pieData} dataKey="count" nameKey="name" outerRadius="75%" innerRadius="42%">{pieData.map((item, index) => <Cell key={item.name} fill={colors[index % colors.length]} />)}</Pie><Tooltip /></PieChart></ResponsiveContainer></div><div className="flex flex-col justify-center gap-2 text-xs">{pieData.map((item, index) => <div key={item.name} className="flex items-center gap-2"><span className="size-2.5 shrink-0 rounded-sm" style={{ background: colors[index % colors.length] }} /><span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span><strong className="tabular-nums">{item.count}</strong></div>)}</div></div>}
</div>
}
@@ -0,0 +1,212 @@
import { memo, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { LogEntry } from '@/api/logs'
import { applyLabelFilter } from '../query-utils'
// 级别 → 颜色(语义 token)
const LEVEL_COLOR: Record<string, string> = {
error: 'text-destructive',
warn: 'text-warning',
info: 'text-success',
debug: 'text-muted-foreground',
}
/** 从日志行提取 k=v / JSON 字段(点击过滤用) */
function detectFields(message: string): Array<[string, string]> {
const out: Array<[string, string]> = []
const trimmed = message.trim()
if (trimmed.startsWith('{')) {
try {
const obj = JSON.parse(trimmed)
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
out.push([k, String(v)])
}
}
return out.slice(0, 12)
} catch {
/* 非 JSON 继续 */
}
}
const re = /([a-zA-Z_][\w.-]*)=("[^"]*"|\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(message)) !== null && out.length < 12) {
out.push([m[1], m[2].replace(/^"|"$/g, '')])
}
return out
}
interface LogResultsProps {
entries: LogEntry[]
loading: boolean
hasMore: boolean
onLoadMore: () => void
/** 点击标签过滤(改写查询) */
onFilter: (nextQuery: string) => void
query: string
}
/**
* 日志结果区(Grafana 风格裁剪版):级别着色、行展开(原文 + labels + 提取字段,
* 点击即过滤/排除)、去重开关
*/
export const LogResults = memo(function LogResults({
entries,
loading,
hasMore,
onLoadMore,
onFilter,
query,
}: LogResultsProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [dedup, setDedup] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
const display = useMemo(() => {
if (!dedup) return entries
const seen = new Set<string>()
return entries.filter((e) => {
const k = e.message.replace(/\d+/g, '#')
if (seen.has(k)) return false
seen.add(k)
return true
})
}, [entries, dedup])
const toggle = (key: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
// 公共标签(所有结果共有)
const commonLabels = useMemo(() => {
if (entries.length === 0) return [] as string[]
let keys = Object.keys(entries[0].labels)
for (const e of entries) {
keys = keys.filter((k) => e.labels[k] !== undefined && e.labels[k] === entries[0].labels[k])
}
return keys
}, [entries])
if (loading && entries.length === 0) {
return <div className="h-40 flex items-center justify-center text-sm text-muted-foreground">查询中…</div>
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{display.length} 条{dedup && `(去重后,原 ${entries.length} 条)`}
{commonLabels.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{commonLabels.slice(0, 6).map((k) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted">
{k}={entries[0].labels[k]}
</span>
))}
</span>
)}
</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={dedup} onChange={(e) => setDedup(e.target.checked)} className="accent-primary" />
去重
</label>
</div>
<div ref={listRef} className="rounded-lg border border-border divide-y divide-border max-h-[52vh] overflow-y-auto bg-card">
{display.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">没有匹配的日志</div>
) : (
display.map((e, idx) => {
const key = `${e.timestamp}-${idx}`
const open = expanded.has(key)
const fields = open ? detectFields(e.message) : []
return (
<div key={key} className="text-xs">
<button
type="button"
onClick={() => toggle(key)}
className="w-full text-left flex items-start gap-2 px-3 py-1.5 hover:bg-muted/50"
>
{open ? (
<ChevronDown className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
)}
<span className="text-muted-foreground flex-shrink-0 tabular-nums">{e.timestamp.slice(11, 23)}</span>
<span className={cn('font-semibold flex-shrink-0 uppercase w-10', LEVEL_COLOR[e.level] || 'text-muted-foreground')}>
{e.level}
</span>
<span className="font-mono break-all min-w-0">{e.message.slice(0, 500)}</span>
</button>
{open && (
<div className="px-3 pb-3 pl-10 space-y-2">
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">日志原文</p>
<pre className="p-2 rounded bg-muted/60 font-mono whitespace-pre-wrap break-all">{e.message}</pre>
</div>
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">标签(点击过滤)</p>
<div className="flex flex-wrap gap-1">
{Object.entries(e.labels).map(([k, v]) => (
<span key={k} className="group inline-flex items-center rounded bg-muted px-1.5 py-0.5">
<button
type="button"
className="hover:text-primary"
title="过滤此值"
onClick={() => onFilter(applyLabelFilter(query, k, v))}
>
{k}={v}
</button>
<button
type="button"
className="ml-1 opacity-0 group-hover:opacity-100 text-destructive"
title="排除此值"
onClick={() => onFilter(applyLabelFilter(query, k, v, true))}
>
×
</button>
</span>
))}
</div>
</div>
{fields.length > 0 && (
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">提取字段</p>
<div className="flex flex-wrap gap-1">
{fields.map(([k, v]) => (
<span key={k} className="inline-flex items-center rounded bg-info/10 text-info px-1.5 py-0.5">
<button type="button" className="hover:underline" onClick={() => onFilter(applyLabelFilter(query, k, v))}>
{k}={v}
</button>
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
)
})
)}
</div>
{hasMore && (
<button
type="button"
onClick={onLoadMore}
className="w-full py-2 text-xs text-primary hover:bg-primary/5 rounded-lg border border-border"
disabled={loading}
>
{loading ? '加载中…' : '加载更多'}
</button>
)}
</div>
)
})
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more