基于 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 加密前缀保留(已加密凭证不受影响)。
375 lines
9.1 KiB
Go
375 lines
9.1 KiB
Go
package repository
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"opsxloki/config"
|
|
"opsxloki/models"
|
|
)
|
|
|
|
// saveRequest 表示一个保存请求,通过channel序列化
|
|
type saveRequest struct {
|
|
done chan error // 用于同步等待保存完成
|
|
}
|
|
|
|
// JsonDB 使用基于channel的序列化保存机制,防止并发数据丢失。
|
|
// 该包只负责持久化(落盘 + 并发安全),领域实体定义在 models 包。
|
|
type JsonDB struct {
|
|
mu sync.RWMutex
|
|
dataFile string
|
|
Users []models.User `json:"users"`
|
|
Clusters []models.Cluster `json:"clusters"`
|
|
Hosts []models.Host `json:"hosts"`
|
|
Storages []models.StorageConfig `json:"storages"`
|
|
Deployments []models.LokiDeployment `json:"deployments"`
|
|
Collectors []models.Collector `json:"collectors"`
|
|
Logs []models.DeploymentLog `json:"logs"`
|
|
Settings []models.SystemSetting `json:"settings"`
|
|
LogSources []models.LogSource `json:"logSources"`
|
|
LogPolicyGroups []models.LogPolicyGroup `json:"logPolicyGroups"`
|
|
sequences map[string]uint
|
|
|
|
// 防抖保存机制
|
|
saveTimer *time.Timer
|
|
saveMu sync.Mutex
|
|
saveChan chan saveRequest
|
|
stopChan chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
var db *JsonDB
|
|
|
|
func GetDB() *JsonDB {
|
|
return db
|
|
}
|
|
|
|
func InitDB() error {
|
|
cfg := config.GetConfig()
|
|
_ = os.MkdirAll(cfg.DataDir, 0755)
|
|
|
|
d := &JsonDB{
|
|
dataFile: cfg.DatabasePath,
|
|
Users: []models.User{},
|
|
Clusters: []models.Cluster{},
|
|
Hosts: []models.Host{},
|
|
Storages: []models.StorageConfig{},
|
|
Deployments: []models.LokiDeployment{},
|
|
Collectors: []models.Collector{},
|
|
Logs: []models.DeploymentLog{},
|
|
Settings: []models.SystemSetting{},
|
|
LogSources: []models.LogSource{},
|
|
LogPolicyGroups: []models.LogPolicyGroup{},
|
|
sequences: map[string]uint{
|
|
"users": 0,
|
|
"clusters": 0,
|
|
"hosts": 0,
|
|
"storages": 0,
|
|
"deployments": 0,
|
|
"collectors": 0,
|
|
"logs": 0,
|
|
"logPolicyGroups": 0,
|
|
},
|
|
saveChan: make(chan saveRequest, 10),
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
|
|
if data, err := os.ReadFile(d.dataFile); err == nil {
|
|
if err := json.Unmarshal(data, d); err == nil {
|
|
d.sequences = map[string]uint{}
|
|
for _, u := range d.Users {
|
|
if u.ID > d.sequences["users"] {
|
|
d.sequences["users"] = u.ID
|
|
}
|
|
}
|
|
for _, c := range d.Clusters {
|
|
if c.ID > d.sequences["clusters"] {
|
|
d.sequences["clusters"] = c.ID
|
|
}
|
|
}
|
|
for _, h := range d.Hosts {
|
|
if h.ID > d.sequences["hosts"] {
|
|
d.sequences["hosts"] = h.ID
|
|
}
|
|
}
|
|
for _, s := range d.Storages {
|
|
if s.ID > d.sequences["storages"] {
|
|
d.sequences["storages"] = s.ID
|
|
}
|
|
}
|
|
for _, de := range d.Deployments {
|
|
if de.ID > d.sequences["deployments"] {
|
|
d.sequences["deployments"] = de.ID
|
|
}
|
|
}
|
|
for _, col := range d.Collectors {
|
|
if col.ID > d.sequences["collectors"] {
|
|
d.sequences["collectors"] = col.ID
|
|
}
|
|
}
|
|
for _, l := range d.Logs {
|
|
if l.ID > d.sequences["logs"] {
|
|
d.sequences["logs"] = l.ID
|
|
}
|
|
}
|
|
for _, group := range d.LogPolicyGroups {
|
|
if group.ID > d.sequences["logPolicyGroups"] {
|
|
d.sequences["logPolicyGroups"] = group.ID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
migratedLogAccess := migrateLogAccessToGroups(d)
|
|
// 启动序列化保存goroutine
|
|
d.wg.Add(1)
|
|
go d.saveWorker()
|
|
|
|
db = d
|
|
if migratedLogAccess {
|
|
d.RequestSave()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Legacy per-user grants are grouped by identical source and label scope.
|
|
func migrateLogAccessToGroups(d *JsonDB) bool {
|
|
changed := false
|
|
for i := range d.LogSources {
|
|
source := &d.LogSources[i]
|
|
if len(source.Access) == 0 {
|
|
continue
|
|
}
|
|
byLabels := map[string]*models.LogPolicyGroup{}
|
|
for _, rule := range source.Access {
|
|
validUser := false
|
|
for _, user := range d.Users {
|
|
if user.ID == rule.UserID && user.Role != "admin" {
|
|
validUser = true
|
|
break
|
|
}
|
|
}
|
|
if !validUser {
|
|
continue
|
|
}
|
|
alreadyGrouped := false
|
|
for _, group := range d.LogPolicyGroups {
|
|
if group.SourceID != source.ID {
|
|
continue
|
|
}
|
|
for _, id := range group.UserIDs {
|
|
if id == rule.UserID {
|
|
alreadyGrouped = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if alreadyGrouped {
|
|
continue
|
|
}
|
|
keyBytes, _ := json.Marshal(rule.Labels)
|
|
key := string(keyBytes)
|
|
group := byLabels[key]
|
|
if group == nil {
|
|
d.sequences["logPolicyGroups"]++
|
|
id := d.sequences["logPolicyGroups"]
|
|
group = &models.LogPolicyGroup{ID: id, Name: fmt.Sprintf("旧授权 · %s · %d", source.Name, id), SourceID: source.ID, Labels: rule.Labels, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
|
byLabels[key] = group
|
|
}
|
|
group.UserIDs = append(group.UserIDs, rule.UserID)
|
|
}
|
|
for _, group := range byLabels {
|
|
d.LogPolicyGroups = append(d.LogPolicyGroups, *group)
|
|
}
|
|
source.Access = nil
|
|
changed = true
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// Shutdown 优雅关闭数据库,确保所有数据已落盘。
|
|
// 顺序很关键:先停防抖定时器(不再产生新请求)→ 关闭 worker 并等其退出
|
|
// (worker 可能正在处理已入队的请求)→ 此时已无并发,做最后一次 doSave 把
|
|
// 最新内存状态原子写入。注意绝不能在 worker 仍在运行时直接 Save(),否则
|
|
// 两个 doSave 并发改写同一文件会发生"后写覆盖先写"的丢更新。
|
|
func Shutdown() error {
|
|
if db == nil {
|
|
return nil
|
|
}
|
|
|
|
// 1. 取消防抖定时器,阻止新的保存请求进入 channel
|
|
db.saveMu.Lock()
|
|
if db.saveTimer != nil {
|
|
db.saveTimer.Stop()
|
|
db.saveTimer = nil
|
|
}
|
|
db.saveMu.Unlock()
|
|
|
|
// 2. 通知 worker 停止并等待其排空 saveChan、完成进行中的 doSave 后退出
|
|
close(db.stopChan)
|
|
db.wg.Wait()
|
|
|
|
// 3. worker 已退出,无并发,落盘最终状态
|
|
if err := db.doSave(); err != nil {
|
|
return fmt.Errorf("final save failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// saveWorker 通过channel序列化所有保存操作,防止并发写入冲突
|
|
func (d *JsonDB) saveWorker() {
|
|
defer d.wg.Done()
|
|
for {
|
|
select {
|
|
case req, ok := <-d.saveChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
err := d.doSave()
|
|
if req.done != nil {
|
|
req.done <- err
|
|
}
|
|
case <-d.stopChan:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// doSave 执行实际的原子文件写入
|
|
func (d *JsonDB) doSave() error {
|
|
d.mu.RLock()
|
|
data, err := json.MarshalIndent(d, "", " ")
|
|
d.mu.RUnlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 原子写入:先写入临时文件,再重命名
|
|
dir := filepath.Dir(d.dataFile)
|
|
tmpFile := filepath.Join(dir, fmt.Sprintf(".opsxloki-%d.tmp", time.Now().UnixNano()))
|
|
|
|
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 确保数据落盘
|
|
f, err := os.Open(tmpFile)
|
|
if err != nil {
|
|
os.Remove(tmpFile)
|
|
return err
|
|
}
|
|
_ = f.Sync()
|
|
f.Close()
|
|
|
|
if err := os.Rename(tmpFile, d.dataFile); err != nil {
|
|
os.Remove(tmpFile)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Save 同步保存数据库到文件(原子写入)
|
|
func (d *JsonDB) Save() error {
|
|
return d.doSave()
|
|
}
|
|
|
|
// RequestSave 请求异步保存(防抖机制+序列化channel)
|
|
// 多次快速调用会在500ms内合并为一次保存
|
|
func (d *JsonDB) RequestSave() {
|
|
d.saveMu.Lock()
|
|
defer d.saveMu.Unlock()
|
|
|
|
// 如果已有待触发的定时器,重置它(防抖)
|
|
if d.saveTimer != nil {
|
|
d.saveTimer.Stop()
|
|
}
|
|
|
|
d.saveTimer = time.AfterFunc(500*time.Millisecond, func() {
|
|
d.saveMu.Lock()
|
|
d.saveTimer = nil
|
|
d.saveMu.Unlock()
|
|
|
|
// 通过channel发送到保存worker,确保序列化
|
|
select {
|
|
case d.saveChan <- saveRequest{}:
|
|
case <-d.stopChan:
|
|
}
|
|
})
|
|
}
|
|
|
|
// RequestSaveSync 请求同步保存,等待保存完成(用于shutdown场景)
|
|
func (d *JsonDB) RequestSaveSync() error {
|
|
req := saveRequest{done: make(chan error, 1)}
|
|
select {
|
|
case d.saveChan <- req:
|
|
case <-d.stopChan:
|
|
return d.Save() // worker已停止,直接同步保存
|
|
}
|
|
return <-req.done
|
|
}
|
|
|
|
func (d *JsonDB) NextID(table string) uint {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.sequences[table]++
|
|
return d.sequences[table]
|
|
}
|
|
|
|
func (d *JsonDB) Lock() {
|
|
d.mu.Lock()
|
|
}
|
|
|
|
func (d *JsonDB) Unlock() {
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
func (d *JsonDB) RLock() {
|
|
d.mu.RLock()
|
|
}
|
|
|
|
func (d *JsonDB) RUnlock() {
|
|
d.mu.RUnlock()
|
|
}
|
|
|
|
// CreateUser 创建用户并触发防抖保存
|
|
func (d *JsonDB) CreateUser(user *models.User) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if user.ID == 0 {
|
|
user.ID = d.sequences["users"] + 1
|
|
d.sequences["users"] = user.ID
|
|
}
|
|
if user.CreatedAt.IsZero() {
|
|
now := time.Now()
|
|
user.CreatedAt = now
|
|
user.UpdatedAt = now
|
|
}
|
|
d.Users = append(d.Users, *user)
|
|
d.RequestSave()
|
|
return nil
|
|
}
|
|
|
|
// RecoverOrphanDeployments 启动时把残留的 deploying 状态部署标记为失败:
|
|
// 部署 goroutine 随进程消亡,不恢复会永久卡在「部署中」(UI 无停止入口)。
|
|
func (d *JsonDB) RecoverOrphanDeployments() {
|
|
d.Lock()
|
|
defer d.Unlock()
|
|
recovered := 0
|
|
for i := range d.Deployments {
|
|
if d.Deployments[i].Status == "deploying" {
|
|
d.Deployments[i].Status = "failed"
|
|
d.Deployments[i].StatusMessage = "服务重启导致部署中断,请重新部署"
|
|
d.Deployments[i].UpdatedAt = time.Now()
|
|
recovered++
|
|
}
|
|
}
|
|
if recovered > 0 {
|
|
d.RequestSave()
|
|
}
|
|
}
|