Files
LokiStack/backend/repository/db.go
T
opsx f3733f0bfc feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂
- i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏
- 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条
- 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器
- UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正
- 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简
- 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
2026-10-27 17:11:38 +08:00

381 lines
9.4 KiB
Go

package repository
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"lokistack/config"
"lokistack/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"`
AlertRules []models.AlertRule `json:"alertRules"`
AlertDeliveries []models.AlertDelivery `json:"alertDeliveries"`
NotifyChannels []models.NotifyChannel `json:"notifyChannels"`
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, 0700)
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{},
AlertRules: []models.AlertRule{},
AlertDeliveries: []models.AlertDelivery{},
NotifyChannels: []models.NotifyChannel{},
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(".lokistack-%d.tmp", time.Now().UnixNano()))
if err := os.WriteFile(tmpFile, data, 0600); 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()
}
}