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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user