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:
1 parent
f9c6c1d826
commit
2d73661dd1
127 files changed
+8117
-2876
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Logs
|
||||
logs
|
||||
/logs/
|
||||
*.log
|
||||
|
||||
# Backend build artifacts
|
||||
|
||||
@@ -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`。
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -21,4 +21,7 @@ func Seed() {
|
||||
Role: "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// 内置告警规则:有 managed 数据源(部署过 Loki)时补齐通用模板(默认禁用)
|
||||
SeedAlertRules()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) ====================
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
# Logs
|
||||
logs
|
||||
/logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
@@ -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`),
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -8,3 +8,5 @@ export * from './storage';
|
||||
export * from './deployment';
|
||||
export * from './collector';
|
||||
export * from './user';
|
||||
export * from './alerts';
|
||||
export * from './settings';
|
||||
@@ -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}`),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export interface StorageCreateInput {
|
||||
pathPrefix?: string;
|
||||
insecure?: boolean;
|
||||
isDefault?: boolean;
|
||||
hasSecretKey?: boolean;
|
||||
}
|
||||
|
||||
export type StorageUpdateInput = Partial<StorageCreateInput>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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';
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user