diff --git a/go.mod b/go.mod index c5e60b2..75fbea9 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.23.0 toolchain go1.23.10 require ( - git.nobla.cn/golang/aeus v0.0.10 - git.nobla.cn/golang/rest v0.1.3 + git.nobla.cn/golang/aeus v0.0.11 + git.nobla.cn/golang/rest v0.1.4 github.com/envoyproxy/protoc-gen-validate v1.2.1 golang.org/x/text v0.23.0 // indirect google.golang.org/protobuf v1.36.6 diff --git a/go.sum b/go.sum index 0f24cb9..01bda95 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -git.nobla.cn/golang/aeus v0.0.10 h1:MDov2GQgV4dxFcjhiJDMwWgfEX0ROwarH3/ETP/9/ik= -git.nobla.cn/golang/aeus v0.0.10/go.mod h1:oOEwqIp6AhKKqj6sLFO8x7IycOROYHCb/2/CjF4+9CU= +git.nobla.cn/golang/aeus v0.0.11 h1:gbXIOVOQRDTIQTjw9wPVfNC9nXBaTJCABeDYmrHW2Oc= +git.nobla.cn/golang/aeus v0.0.11/go.mod h1:oOEwqIp6AhKKqj6sLFO8x7IycOROYHCb/2/CjF4+9CU= git.nobla.cn/golang/kos v0.1.32 h1:sFVCA7vKc8dPUd0cxzwExOSPX2mmMh2IuwL6cYS1pBc= git.nobla.cn/golang/kos v0.1.32/go.mod h1:35Z070+5oB39WcVrh5DDlnVeftL/Ccmscw2MZFe9fUg= -git.nobla.cn/golang/rest v0.1.3 h1:B+MX1teFURVxol77Ho5+SxyF61VfvXbzsh8IIajoGG0= -git.nobla.cn/golang/rest v0.1.3/go.mod h1:4viDk7VujDokpUeHQGbnSp2bkkVZEoIkWQIs/l/TTPQ= +git.nobla.cn/golang/rest v0.1.4 h1:9/XscfNXI3aPESpy8CPtVl17VSMxU9BihhedeG+h8YY= +git.nobla.cn/golang/rest v0.1.4/go.mod h1:4viDk7VujDokpUeHQGbnSp2bkkVZEoIkWQIs/l/TTPQ= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= diff --git a/pkg/chartjs/counter.go b/pkg/chartjs/counter.go new file mode 100644 index 0000000..e6d9654 --- /dev/null +++ b/pkg/chartjs/counter.go @@ -0,0 +1,78 @@ +package chartjs + +import ( + "slices" + "sync" +) + +// DataCounter 计数统计, 实现类似Pie 和 Doughnut 之类的图形 +type DataCounter struct { + mutex sync.Mutex + legends map[string]*counterValue +} + +func (c *DataCounter) Inc(leg string, label string, value float64) { + c.mutex.Lock() + defer c.mutex.Unlock() + v, ok := c.legends[leg] + if !ok { + v = &counterValue{ + Label: leg, + Values: make(map[string]float64), + } + c.legends[leg] = v + } + v.Values[label] += value +} + +func (c *DataCounter) Dec(legend string, label string, value float64) { + c.mutex.Lock() + defer c.mutex.Unlock() + v, ok := c.legends[legend] + if !ok { + return + } + if _, ok := v.Values[label]; ok { + v.Values[label] -= value + } +} + +func (c *DataCounter) Data() *CounterData { + c.mutex.Lock() + defer c.mutex.Unlock() + data := &CounterData{ + Lables: make([]string, 0, 5), + Datasets: make([]*CounterValue, 0, len(c.legends)), + } + for _, row := range c.legends { + for k, _ := range row.Values { + if !slices.Contains(data.Lables, k) { + data.Lables = append(data.Lables, k) + } + } + } + for _, row := range c.legends { + set := &CounterValue{ + Label: row.Label, + Data: make([]float64, 0, len(data.Lables)), + } + for _, label := range data.Lables { + set.Data = append(set.Data, row.Values[label]) + } + data.Datasets = append(data.Datasets, set) + } + return data +} + +func NewDataCounter(legends ...string) *DataCounter { + c := &DataCounter{ + legends: make(map[string]*counterValue), + } + for _, legend := range legends { + c.legends[legend] = &counterValue{ + Label: legend, + Values: make(map[string]float64), + } + } + return c +} diff --git a/pkg/chartjs/group.go b/pkg/chartjs/group.go new file mode 100644 index 0000000..665aa0d --- /dev/null +++ b/pkg/chartjs/group.go @@ -0,0 +1,46 @@ +package chartjs + +import "time" + +// TimeSeriesGroup 时间计算数据, 实现类似以时间为维度的折线图 +type TimeSeriesGroup struct { + step string + values map[string]*TimeSeries +} + +func (g *TimeSeriesGroup) Inc(tm time.Time, key string, value float64) { + v, ok := g.values[key] + if !ok { + v = NewTimeSeries(g.step) + g.values[key] = v + } + v.Inc(tm, value) +} + +func (g *TimeSeriesGroup) Dec(tm time.Time, key string, value float64) { + v, ok := g.values[key] + if !ok { + v = NewTimeSeries(g.step) + g.values[key] = v + } + v.Dec(tm, value) +} + +// Data 生成chart.js 的图表dataset +func (g *TimeSeriesGroup) Data(sts, ets int64) (values []*TimeseriesData) { + for key, row := range g.values { + data := &TimeseriesData{ + Label: key, + Data: row.Values(sts, ets), + } + values = append(values, data) + } + return +} + +func NewTimeSeriesGroup(step string) *TimeSeriesGroup { + return &TimeSeriesGroup{ + step: step, + values: make(map[string]*TimeSeries), + } +} diff --git a/pkg/chartjs/series.go b/pkg/chartjs/series.go new file mode 100644 index 0000000..611f711 --- /dev/null +++ b/pkg/chartjs/series.go @@ -0,0 +1,126 @@ +package chartjs + +import ( + "sync" + "time" +) + +const ( + Minute = "minute" + Hour = "hour" + Day = "day" + Method = "method" +) + +// 时间序列值, 实现了按时间进行分组的值计算 +type TimeSeries struct { + mutex sync.Mutex + step string + values []*TimeseriesValue +} + +func (s *TimeSeries) cutTimeSeries(tm time.Time) int64 { + switch s.step { + case Method: + return time.Date(tm.Year(), tm.Month(), 0, 0, 0, 0, 0, time.Local).Unix() + case Day: + return time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.Local).Unix() + case Hour: + return time.Date(tm.Year(), tm.Month(), tm.Day(), tm.Hour(), 0, 0, 0, time.Local).Unix() + default: + return time.Date(tm.Year(), tm.Month(), tm.Day(), tm.Hour(), tm.Minute(), 0, 0, time.Local).Unix() + } +} + +func (s *TimeSeries) truncateTime(ts int64) int64 { + tm := time.Unix(ts, 0) + switch s.step { + case Method: + return time.Date(tm.Year(), tm.Month(), 0, 0, 0, 0, 0, time.Local).Unix() + case Day: + return time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.Local).Unix() + case Hour: + return time.Date(tm.Year(), tm.Month(), tm.Day(), tm.Hour(), 0, 0, 0, time.Local).Unix() + default: + return time.Date(tm.Year(), tm.Month(), tm.Day(), tm.Hour(), tm.Minute(), 0, 0, time.Local).Unix() + } + return tm.Unix() +} + +func (s *TimeSeries) nextTimestamp(ts int64) int64 { + tm := time.Unix(ts, 0) + switch s.step { + case Method: + return tm.AddDate(0, 1, 0).Unix() + case Day: + return tm.AddDate(0, 0, 1).Unix() + case Hour: + return ts + 3600 + default: + return ts + 60 + } +} + +func (s *TimeSeries) Inc(tm time.Time, value float64) { + s.mutex.Lock() + defer s.mutex.Unlock() + ts := s.cutTimeSeries(tm) + for _, v := range s.values { + if v.Timestamp == ts { + v.Value += value + return + } + } + s.values = append(s.values, &TimeseriesValue{ + Timestamp: ts, + Value: value, + }) +} + +func (s *TimeSeries) Dec(tm time.Time, value float64) { + s.mutex.Lock() + defer s.mutex.Unlock() + ts := s.cutTimeSeries(tm) + for _, v := range s.values { + if v.Timestamp == ts { + v.Value -= value + return + } + } +} + +func (s *TimeSeries) Values(sts, ets int64) []*TimeseriesValue { + var ( + nextStamp int64 + ) + sts = s.truncateTime(sts) + ets = s.truncateTime(ets) + series := make([]*TimeseriesValue, 0, len(s.values)) + nextStamp = sts + for _, row := range s.values { + for row.Timestamp > nextStamp { + series = append(series, &TimeseriesValue{ + Timestamp: nextStamp, + Value: 0, + }) + nextStamp = s.nextTimestamp(nextStamp) + } + series = append(series, row) + nextStamp = s.nextTimestamp(nextStamp) + } + for ets > nextStamp { + series = append(series, &TimeseriesValue{ + Timestamp: nextStamp, + Value: 0, + }) + nextStamp = s.nextTimestamp(nextStamp) + } + return series +} + +func NewTimeSeries(step string) *TimeSeries { + return &TimeSeries{ + step: step, + values: make([]*TimeseriesValue, 0, 64), + } +} diff --git a/pkg/chartjs/types.go b/pkg/chartjs/types.go new file mode 100644 index 0000000..2b05d10 --- /dev/null +++ b/pkg/chartjs/types.go @@ -0,0 +1,66 @@ +package chartjs + +/* + { + label: 'My Time Series Data', + data: [ + { x: '2025-01-01', y: 10 }, + { x: '2025-01-02', y: 15 }, + { x: '2025-01-03', y: 8 }, + { x: '2025-01-04', y: 20 }, + { x: '2025-01-05', y: 12 } + ], + borderColor: 'rgb(75, 192, 192)', + tension: 0.1 + } +*/ + +type TimeseriesValue struct { + Timestamp int64 `json:"x"` + Value float64 `json:"y"` +} + +// TimeseriesData 时间序列的数据 +type TimeseriesData struct { + Label string `json:"label"` + Data []*TimeseriesValue `json:"data"` + BorderColor string `json:"borderColor,omitempty"` + BackgroundColor string `json:"backgroundColor,omitempty"` + Tension float64 `json:"tension,omitempty"` + Fill bool `json:"fill,omitempty"` +} + +/** +{ + labels: labels, + datasets: [ + { + label: 'Dataset 1', + data: Utils.numbers(NUMBER_CFG), + backgroundColor: [ + Utils.transparentize(Utils.CHART_COLORS.red, 0.5), + Utils.transparentize(Utils.CHART_COLORS.orange, 0.5), + Utils.transparentize(Utils.CHART_COLORS.yellow, 0.5), + Utils.transparentize(Utils.CHART_COLORS.green, 0.5), + Utils.transparentize(Utils.CHART_COLORS.blue, 0.5), + ] + } + ] +} +*/ + +type counterValue struct { + Label string `json:"label"` + Values map[string]float64 `json:"data"` +} + +type CounterValue struct { + Label string `json:"label"` + Data []float64 `json:"data"` + BackgroundColor []string `json:"backgroundColor,omitempty"` +} + +type CounterData struct { + Lables []string `json:"labels"` + Datasets []*CounterValue `json:"datasets"` +} diff --git a/server.go b/server.go index 82a3141..180ac99 100644 --- a/server.go +++ b/server.go @@ -10,6 +10,7 @@ import ( "reflect" "strconv" "strings" + "time" "git.nobla.cn/golang/aeus-admin/migrate" "git.nobla.cn/golang/aeus-admin/models" @@ -334,10 +335,151 @@ func registerRESTRoute(domain string, db *gorm.DB, hs *http.Server) { } } + handleKeyValues := func(ctx *http.Context) (err error) { + var ( + dbDependency dbcache.CacheDependency + pairs []*restTypes.TypeValue[any] + modelValue any + ) + moduleName := ctx.Param("module") + tableName := ctx.Param("table") + entities := rest.GetModels() + for _, entry := range entities { + if entry.ModuleName() == moduleName && entry.TableName() == tableName { + modelValue = reflect.New(entry.Value().Type()).Interface() + for _, field := range entry.Fields() { + if field.AutoUpdateTime > 0 { + dbDependency = dbcache.NewSqlDependency(fmt.Sprintf("SELECT MAX(`%s`) FROM `%s`", field.DBName, entry.TableName())) + break + } + } + break + } + } + if modelValue == nil { + return ctx.Error(errors.NotFound, "model not found") + } + labelColumn := ctx.Param("label") + valueColumn := ctx.Param("value") + opts := make([]dbcache.CacheOption, 0, 4) + opts = append(opts, dbcache.WithDB(db)) + if dbDependency != nil { + opts = append(opts, dbcache.WithCacheDuration(time.Minute*30)) + opts = append(opts, dbcache.WithDependency(dbDependency)) + } else { + opts = append(opts, dbcache.WithCacheDuration(time.Minute)) + } + if pairs, err = dbcache.TryCache(ctx.Context(), fmt.Sprintf("rest:kvpairs:%s:%s:%s:%s", moduleName, tableName, labelColumn, valueColumn), func(tx *gorm.DB) ([]*restTypes.TypeValue[any], error) { + return rest.ModelTypes[any](ctx.Context(), db, modelValue, "", labelColumn, valueColumn) + }, opts...); err == nil { + return ctx.Success(pairs) + } else { + return ctx.Error(errors.Unavailable, err.Error()) + } + } + + handleTierPairs := func(ctx *http.Context) (err error) { + var ( + dbDependency dbcache.CacheDependency + pairs []*restTypes.TierValue[string] + modelValue any + ) + moduleName := ctx.Param("module") + tableName := ctx.Param("table") + entities := rest.GetModels() + for _, entry := range entities { + if entry.ModuleName() == moduleName && entry.TableName() == tableName { + // 权限控制 + if err = entry.HasPermission(ctx.Context(), entry.Permission(restTypes.ScenarioList)); err != nil { + return ctx.Error(errors.AccessDenied, err.Error()) + } + modelValue = reflect.New(entry.Value().Type()).Interface() + for _, field := range entry.Fields() { + if field.AutoUpdateTime > 0 { + dbDependency = dbcache.NewSqlDependency(fmt.Sprintf("SELECT MAX(`%s`) FROM `%s`", field.DBName, entry.TableName())) + break + } + } + break + } + } + if modelValue == nil { + return ctx.Error(errors.NotFound, "model not found") + } + parentColumn := ctx.Param("parent") + labelColumn := ctx.Param("label") + valueColumn := ctx.Param("value") + opts := make([]dbcache.CacheOption, 0, 4) + opts = append(opts, dbcache.WithDB(db)) + if dbDependency != nil { + opts = append(opts, dbcache.WithCacheDuration(time.Minute*30)) + opts = append(opts, dbcache.WithDependency(dbDependency)) + } else { + opts = append(opts, dbcache.WithCacheDuration(time.Minute)) + } + if pairs, err = dbcache.TryCache(ctx.Context(), fmt.Sprintf("rest:tierpairs:%s:%s:%s:%s", moduleName, tableName, labelColumn, valueColumn), func(tx *gorm.DB) ([]*restTypes.TierValue[string], error) { + return rest.ModelTiers[string](ctx.Context(), db, modelValue, "", parentColumn, labelColumn, valueColumn) + }, opts...); err == nil { + return ctx.Success(pairs) + } else { + return ctx.Error(errors.Unavailable, err.Error()) + } + } + + handleNumberTierPairs := func(ctx *http.Context) (err error) { + var ( + dbDependency dbcache.CacheDependency + pairs []*restTypes.TierValue[int64] + modelValue any + ) + moduleName := ctx.Param("module") + tableName := ctx.Param("table") + entities := rest.GetModels() + for _, entry := range entities { + if entry.ModuleName() == moduleName && entry.TableName() == tableName { + // 权限控制 + if err = entry.HasPermission(ctx.Context(), entry.Permission(restTypes.ScenarioList)); err != nil { + return ctx.Error(errors.AccessDenied, err.Error()) + } + modelValue = reflect.New(entry.Value().Type()).Interface() + for _, field := range entry.Fields() { + if field.AutoUpdateTime > 0 { + dbDependency = dbcache.NewSqlDependency(fmt.Sprintf("SELECT MAX(`%s`) FROM `%s`", field.DBName, entry.TableName())) + break + } + } + break + } + } + if modelValue == nil { + return ctx.Error(errors.NotFound, "model not found") + } + parentColumn := ctx.Param("parent") + labelColumn := ctx.Param("label") + valueColumn := ctx.Param("value") + opts := make([]dbcache.CacheOption, 0, 4) + opts = append(opts, dbcache.WithDB(db)) + if dbDependency != nil { + opts = append(opts, dbcache.WithCacheDuration(time.Minute*30)) + opts = append(opts, dbcache.WithDependency(dbDependency)) + } else { + opts = append(opts, dbcache.WithCacheDuration(time.Minute)) + } + if pairs, err = dbcache.TryCache(ctx.Context(), fmt.Sprintf("rest:tierpairs:%s:%s:%s:%s", moduleName, tableName, labelColumn, valueColumn), func(tx *gorm.DB) ([]*restTypes.TierValue[int64], error) { + return rest.ModelTiers[int64](ctx.Context(), db, modelValue, "", parentColumn, labelColumn, valueColumn) + }, opts...); err == nil { + return ctx.Success(pairs) + } else { + return ctx.Error(errors.Unavailable, err.Error()) + } + } + hs.GET("/rest/schema/:module/:table", handleListSchemas) hs.PUT("/rest/schema/:module/:table", handleUpdateSchemas) hs.DELETE("/rest/schema/:id", handleDeleteSchema) - + hs.GET("/rest/kvpairs/:module/:table/:value/:label", handleKeyValues) //处理键值对数据 + hs.GET("/rest/tierpairs/str/:module/:table/:parent/:value/:label", handleTierPairs) //处理字符串类型的层级数据 + hs.GET("/rest/tierpairs/num/:module/:table/:parent/:value/:label", handleNumberTierPairs) //处理数字类型的层级数据, 只支持int64 } // AutoMigrate 自动生成一个模型的schema和权限的定义 diff --git a/service/setting.go b/service/setting.go index 481234d..7f9bf63 100644 --- a/service/setting.go +++ b/service/setting.go @@ -17,6 +17,7 @@ type ( SettingOption func(o *settingOptions) ) + type SettingService struct { opts *settingOptions }