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 加密前缀保留(已加密凭证不受影响)。
This commit is contained in:
logan committed 2026-10-24 13:20:12 +08:00
1 parent 327e282356
commit f9c6c1d826
204 files changed
+14107 -9103

No files matched your search

+201 -3
View File
@@ -1,6 +1,7 @@
package services
import (
"os/exec"
"context"
"fmt"
"strconv"
@@ -8,9 +9,9 @@ import (
"sync"
"time"
"lokideploy/models"
"lokideploy/repository"
"lokideploy/utils"
"opsxloki/models"
"opsxloki/repository"
"opsxloki/utils"
)
// LokiVersionList 支持的 Loki 版本列表(按推荐程度排序)
@@ -50,6 +51,8 @@ type CreateDeploymentRequest struct {
ResourcesCPU string `json:"resourcesCPU"`
ResourcesMem string `json:"resourcesMem"`
StorageSize string `json:"storageSize"`
GrafanaEnabled bool `json:"grafanaEnabled"`
GrafanaVersion string `json:"grafanaVersion"`
}
func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDeployment, error) {
@@ -77,6 +80,8 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe
ResourcesCPU: req.ResourcesCPU,
ResourcesMem: req.ResourcesMem,
StorageSize: req.StorageSize,
GrafanaEnabled: req.GrafanaEnabled,
GrafanaVersion: req.GrafanaVersion,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -106,6 +111,13 @@ func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDe
for i := range db.Storages {
if db.Storages[i].ID == d.StorageConfigID {
st := db.Storages[i]
// 模型内为 ENC: 密文,生成 Loki 配置前解密(否则密文进 ConfigMap 且无法通过对象存储认证)
if ak, err := DecryptCred(st.AccessKey); err == nil && ak != "" {
st.AccessKey = ak
}
if sk, err := DecryptCred(st.SecretKey); err == nil && sk != "" {
st.SecretKey = sk
}
d.StorageConfig = &st
break
}
@@ -171,6 +183,57 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro
if v, ok := updates["endpoint"].(string); ok {
db.Deployments[i].Endpoint = v
}
// 业务字段(来自 PUT /deployments/:id;JSON 数字解码为 float64)
if v, ok := updates["name"].(string); ok && v != "" {
db.Deployments[i].Name = v
}
if v, ok := updates["description"].(string); ok {
db.Deployments[i].Description = v
}
if v, ok := updates["version"].(string); ok && v != "" {
db.Deployments[i].Version = v
}
if v, ok := updates["namespace"].(string); ok && v != "" {
db.Deployments[i].Namespace = v
}
if v, ok := updates["resourcesCPU"].(string); ok {
db.Deployments[i].ResourcesCPU = v
}
if v, ok := updates["resourcesMem"].(string); ok {
db.Deployments[i].ResourcesMem = v
}
if v, ok := updates["storageSize"].(string); ok && v != "" {
db.Deployments[i].StorageSize = v
}
if v, ok := updates["authEnabled"].(bool); ok {
db.Deployments[i].AuthEnabled = v
}
if v, ok := updates["grafanaEnabled"].(bool); ok {
db.Deployments[i].GrafanaEnabled = v
}
if v, ok := updates["grafanaVersion"].(string); ok && v != "" {
db.Deployments[i].GrafanaVersion = v
}
if v, ok := updates["grafanaEndpoint"].(string); ok {
db.Deployments[i].GrafanaEndpoint = v
}
if v, ok := updates["storageConfigID"].(float64); ok && v > 0 {
db.Deployments[i].StorageConfigID = uint(v)
}
if v, ok := updates["clusterID"].(float64); ok && v > 0 {
id := uint(v)
db.Deployments[i].ClusterID = &id
}
if v, ok := updates["hostID"].(float64); ok && v > 0 {
id := uint(v)
db.Deployments[i].HostID = &id
}
if v, ok := updates["retentionDays"].(float64); ok && v > 0 {
db.Deployments[i].RetentionDays = int(v)
}
if v, ok := updates["replicas"].(float64); ok && v > 0 {
db.Deployments[i].Replicas = int(v)
}
db.Deployments[i].UpdatedAt = time.Now()
db.RequestSave()
return nil
@@ -260,6 +323,11 @@ func (s *DeploymentService) StartDeploy(id uint) error {
"statusMessage": "Deployment completed successfully",
"endpoint": s.getEndpoint(deployment),
})
// 部署成功自动注册日志查询数据源 + 记录 Grafana 访问地址
if fresh, gerr := s.Get(id); gerr == nil {
EnsureLogSourceForDeployment(fresh)
go s.recordGrafanaEndpoint(fresh)
}
s.AddLog(id, "success", "Deployment completed successfully", "")
}
}()
@@ -398,6 +466,13 @@ func (s *DeploymentService) loadRelations(d *models.LokiDeployment) {
for i := range db.Storages {
if db.Storages[i].ID == d.StorageConfigID {
st := db.Storages[i]
// 模型内为 ENC: 密文,生成 Loki 配置前解密(否则密文进 ConfigMap 且无法通过对象存储认证)
if ak, err := DecryptCred(st.AccessKey); err == nil && ak != "" {
st.AccessKey = ak
}
if sk, err := DecryptCred(st.SecretKey); err == nil && sk != "" {
st.SecretKey = sk
}
d.StorageConfig = &st
break
}
@@ -472,3 +547,126 @@ func GetRecommendedVersions() []string {
copy(result, LokiVersionList)
return result
}
// Preflight 向导预检:聚合各资源真实状态,前端据此推导步骤完成态(状态推导式向导)
func (s *DeploymentService) Preflight() map[string]interface{} {
db := repository.GetDB()
db.RLock()
defer db.RUnlock()
result := map[string]interface{}{
"hasStorage": len(db.Storages) > 0,
"hasActiveCluster": false,
"clusterNames": []string{},
"hasActiveHost": false,
"hostNames": []string{},
"runningLoki": nil,
"activeCollectors": 0,
"grafanaReady": false,
}
var defaultStorage string
for i := range db.Storages {
if db.Storages[i].IsDefault {
defaultStorage = db.Storages[i].Name
break
}
}
if defaultStorage == "" && len(db.Storages) > 0 {
defaultStorage = db.Storages[0].Name
}
result["defaultStorage"] = defaultStorage
clusters := []string{}
for i := range db.Clusters {
clusters = append(clusters, db.Clusters[i].Name)
if db.Clusters[i].Status == "active" || db.Clusters[i].Status == "running" {
result["hasActiveCluster"] = true
}
}
result["clusterNames"] = clusters
hosts := []string{}
for i := range db.Hosts {
hosts = append(hosts, db.Hosts[i].Name)
if db.Hosts[i].Status == "active" {
result["hasActiveHost"] = true
}
}
result["hostNames"] = hosts
for i := range db.Deployments {
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,
"grafanaEndpoint": d.GrafanaEndpoint,
}
result["runningLoki"] = loki
if d.GrafanaEnabled {
result["grafanaReady"] = true
}
break
}
}
for i := range db.Collectors {
if db.Collectors[i].Status == "active" {
result["activeCollectors"] = result["activeCollectors"].(int) + 1
}
}
return result
}
// recordGrafanaEndpoint 记录 Grafana 可点击访问地址(k8s: 首节点 IP + NodePort;host: 主机 IP:3000),
// 供部署卡片/向导直接展示,用户不用翻日志找端口。
func (s *DeploymentService) recordGrafanaEndpoint(d *models.LokiDeployment) {
if d == nil || !d.GrafanaEnabled {
return
}
var endpoint string
switch d.Mode {
case "k8s-helm", "k8s-yaml":
if d.Cluster == nil {
s.AddLog(d.ID, "warn", "Grafana 地址记录跳过:未关联集群", "")
return
}
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)
}
case "host-binary":
if d.Host != nil {
endpoint = fmt.Sprintf("http://%s:3000", d.Host.HostIP)
}
}
if endpoint != "" {
_ = s.Update(d.ID, map[string]interface{}{"grafanaEndpoint": endpoint})
}
}