Files
LokiStack/backend/services/cluster.go
T
logan f9c6c1d826 feat: opsx-loki 全面升级 — 前端重构/用户与LDAP/部署向导/日志查询
基于 WEB-UI-Development-Guide 标准的整体升级,并更名 LokiDeploy → opsx-loki。

前端(Gin+React+shadcn/ui):
- 按 02/07 章重构:页面域化拆分(无超 400 行文件)、PageContainer/ListQuery、
  反馈分级(成功静默/失败 toast/危险确认)、401 单飞刷新+队列重放
- 暗色模式(next-themes)+ 语义 Token(--primary 品牌色/success/warning/info)
- 部署向导(状态推导四步:环境→存储→Loki→采集,内嵌表单闭环)
- 日志查询页:LogQL 标签构建器/拖拽直方图/日志上下文/Live Tail/查询历史
- 按用户+标签策略的查询授权(服务端强制合并策略)

后端(Go+Gin+JSON DB):
- 简单用户管理(仅 admin,无 RBAC)+ TokenVersion 会话吊销 + 登录 IP 限流
- LDAP 接入:配置(AES 加密存储/hasPassword 脱敏)/测试连接/批量导入/登录
- 存储配置测试连接(SigV4 零依赖)+ 桶不存在自动创建
- Loki 三模式部署修复:SSH 密钥认证、3.x 配置模板、ruler 对象存储嵌套块、
  凭证解密、孤儿 deploying 状态恢复、Grafana 部署(可选默认关)+面板导入
- DaemonSet 采集纯 YAML(去 helm 依赖)+ DNS 自愈回退 ClusterIP + 集群名标签
- 日志查询代理:cluster-svc 经 kube-apiserver proxy/url 直连 + cursor 翻页
- 统一 log 命名空间;部署就绪探活;Grafana 访问地址自动记录

修复:run.sh 增量编译检测、Sanitized 脱敏补全、LokiDeployment Update 白名单、
健康检查 CreatedAt 零值等 10+ 存量 bug。

数据兼容:DB 沿用旧 lokideploy.json(新名优先);localStorage 一次性迁移;
HKDF 加密前缀保留(已加密凭证不受影响)。
2026-10-24 13:20:12 +08:00

200 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"opsxloki/config"
"opsxloki/models"
"opsxloki/repository"
"opsxloki/utils"
)
type ClusterService struct{}
func NewClusterService() *ClusterService {
return &ClusterService{}
}
func SaveKubeConfig(name, content string) (string, error) {
dir := config.GetConfig().KubeDir
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
path := filepath.Join(dir, fmt.Sprintf("%s.yaml", name))
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return "", err
}
return path, nil
}
// Create 创建集群。token 接入时自动构造等价 kubeconfig,统一存储,使部署模块同样可用。
func (s *ClusterService) Create(name, description, configType, apiServer, kubeconfig, token, contextName string) (*models.Cluster, error) {
if configType == "" {
configType = "kubeconfig"
}
finalKubeconfig := kubeconfig
if configType == "token" {
// 安全默认:不嵌入 CA、不跳过 TLS 验证,依赖系统 CA 池。
// 自签证书的集群请改用 kubeconfig 模式(其内含 CA 证书)。
finalKubeconfig = BuildKubeconfigFromToken(apiServer, token, "", false)
}
// 展示用 APIServer:token 模式取入参,kubeconfig 模式从内容提取
if apiServer == "" {
apiServer = ExtractAPIServer(finalKubeconfig)
}
path, err := SaveKubeConfig(name, finalKubeconfig)
if err != nil {
return nil, fmt.Errorf("kubeconfig 保存失败: %w", err)
}
cluster := models.Cluster{
ID: repository.GetDB().NextID("clusters"),
Name: name,
Description: description,
ConfigType: configType,
APIServer: apiServer,
KubeConfig: finalKubeconfig,
KubeConfigPath: path,
Context: contextName,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
db := repository.GetDB()
db.Lock()
db.Clusters = append(db.Clusters, cluster)
db.Unlock()
db.RequestSave()
go s.Validate(cluster.ID)
return &cluster, nil
}
func (s *ClusterService) Get(id uint) (*models.Cluster, error) {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Clusters {
if db.Clusters[i].ID == id {
c := db.Clusters[i]
return &c, nil
}
}
return nil, utils.ErrNotFound
}
func (s *ClusterService) List(page, pageSize int) ([]models.Cluster, int64, error) {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
total := int64(len(db.Clusters))
start := (page - 1) * pageSize
if start < 0 || start >= len(db.Clusters) {
return []models.Cluster{}, total, nil
}
end := start + pageSize
if end > len(db.Clusters) {
end = len(db.Clusters)
}
result := make([]models.Cluster, end-start)
copy(result, db.Clusters[start:end])
return result, total, nil
}
func (s *ClusterService) Update(id uint, updates map[string]interface{}) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Clusters {
if db.Clusters[i].ID == id {
if v, ok := updates["name"].(string); ok && v != "" {
db.Clusters[i].Name = v
}
if v, ok := updates["description"].(string); ok {
db.Clusters[i].Description = v
}
if v, ok := updates["status"].(string); ok && v != "" {
db.Clusters[i].Status = v
}
if v, ok := updates["version"].(string); ok {
db.Clusters[i].Version = v
}
if v, ok := updates["nodesCount"].(int); ok {
db.Clusters[i].NodesCount = v
}
if v, ok := updates["context"].(string); ok {
db.Clusters[i].Context = v
}
if v, ok := updates["lastConnected"].(time.Time); ok {
db.Clusters[i].LastConnected = v
}
// 接入方式与展示字段
if v, ok := updates["configType"].(string); ok && v != "" {
db.Clusters[i].ConfigType = v
}
if v, ok := updates["apiServer"].(string); ok {
db.Clusters[i].APIServer = v
}
// 凭据字段:空值不覆盖,避免编辑其它项时清空
if v, ok := updates["kubeconfig"].(string); ok && v != "" {
db.Clusters[i].KubeConfig = v
if db.Clusters[i].KubeConfigPath != "" {
_ = os.WriteFile(db.Clusters[i].KubeConfigPath, []byte(v), 0600)
}
}
db.Clusters[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
}
}
return utils.ErrNotFound
}
func (s *ClusterService) Delete(id uint) error {
db := repository.GetDB()
db.Lock()
defer db.Unlock()
for i, c := range db.Clusters {
if c.ID == id {
if c.KubeConfigPath != "" {
_ = os.Remove(c.KubeConfigPath)
}
db.Clusters = append(db.Clusters[:i], db.Clusters[i+1:]...)
db.RequestSave()
return nil
}
}
return utils.ErrNotFound
}
func (s *ClusterService) Validate(id uint) {
cluster, err := s.Get(id)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
info, err := GetClusterInfo(ctx, cluster)
if err != nil {
s.Update(id, map[string]interface{}{"status": "error"})
return
}
s.Update(id, map[string]interface{}{
"status": "active",
"version": info.Version,
"nodesCount": len(info.Nodes),
"lastConnected": time.Now(),
"context": info.CurrentCtx,
})
}