mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
943818f7d4
将 OpenFlare 与平台业务的数据访问从 model 与 apps 直连迁入 repository, model 仅保留实体与无 IO 规则;补充 code-check 架构守卫与开发规范。
212 lines
5.9 KiB
Go
212 lines
5.9 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package zone
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// StatsRange is a supported traffic window for Zone analytics.
|
|
type StatsRange string
|
|
|
|
// StatsRange constants representing supported analytics windows.
|
|
const (
|
|
// StatsRange24h represents a 24-hour time window.
|
|
StatsRange24h StatsRange = "24h"
|
|
// StatsRange7d represents a 7-day time window.
|
|
StatsRange7d StatsRange = "7d"
|
|
// StatsRange30d represents a 30-day time window.
|
|
StatsRange30d StatsRange = "30d"
|
|
)
|
|
|
|
const (
|
|
hoursPerDay = 24
|
|
daysPerWeek = 7
|
|
daysPerMonth = 30
|
|
minutesPerHour = 60
|
|
bucketMinutes24h = 60
|
|
bucketMinutes7d = 6 * minutesPerHour
|
|
bucketMinutes30d = 24 * minutesPerHour
|
|
)
|
|
|
|
// StatsPoint is one bucket on a Zone traffic chart.
|
|
type StatsPoint struct {
|
|
BucketStartedAt time.Time `json:"bucket_started_at"`
|
|
RequestCount int64 `json:"request_count"`
|
|
UniqueVisitors int64 `json:"unique_visitors"`
|
|
BytesSent int64 `json:"bytes_sent"`
|
|
}
|
|
|
|
// Stats summarizes edge traffic for all domains under a Zone.
|
|
type Stats struct {
|
|
Range StatsRange `json:"range"`
|
|
RangeHours int `json:"range_hours"`
|
|
WindowStartedAt time.Time `json:"window_started_at"`
|
|
WindowEndedAt time.Time `json:"window_ended_at"`
|
|
BucketMinutes int `json:"bucket_minutes"`
|
|
UniqueVisitors int64 `json:"unique_visitors"`
|
|
RequestCount int64 `json:"request_count"`
|
|
BytesSent int64 `json:"bytes_sent"`
|
|
DomainCount int `json:"domain_count"`
|
|
Available bool `json:"available"`
|
|
Series []StatsPoint `json:"series"`
|
|
}
|
|
|
|
func parseStatsRange(raw string) (StatsRange, time.Duration, int, error) {
|
|
switch StatsRange(strings.TrimSpace(raw)) {
|
|
case "", StatsRange24h:
|
|
return StatsRange24h, hoursPerDay * time.Hour, bucketMinutes24h, nil
|
|
case StatsRange7d:
|
|
return StatsRange7d, daysPerWeek * hoursPerDay * time.Hour, bucketMinutes7d, nil
|
|
case StatsRange30d:
|
|
return StatsRange30d, daysPerMonth * hoursPerDay * time.Hour, bucketMinutes30d, nil
|
|
default:
|
|
return "", 0, 0, errors.New(errStatsRangeInvalid)
|
|
}
|
|
}
|
|
|
|
// GetStats aggregates access-log traffic for a Zone over a time range.
|
|
func GetStats(ctx context.Context, id uint, rangeRaw string) (*Stats, error) {
|
|
statsRange, window, bucketMinutes, err := parseStatsRange(rangeRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := repository.GetZoneByID(ctx, id); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
domains, err := repository.ListZoneDomainsByZoneID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now().UTC().Truncate(time.Minute)
|
|
since := now.Add(-window)
|
|
// Align chart window start to bucket boundary for cleaner x-axis labels.
|
|
bucket := time.Duration(bucketMinutes) * time.Minute
|
|
since = since.Truncate(bucket)
|
|
|
|
result := &Stats{
|
|
Range: statsRange,
|
|
RangeHours: int(window / time.Hour),
|
|
WindowStartedAt: since,
|
|
WindowEndedAt: now,
|
|
BucketMinutes: bucketMinutes,
|
|
DomainCount: len(domains),
|
|
Available: true,
|
|
Series: emptyStatsSeries(since, now, bucketMinutes),
|
|
}
|
|
if len(domains) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
hosts := make([]string, 0, len(domains))
|
|
for _, domain := range domains {
|
|
if host := strings.TrimSpace(domain.Domain); host != "" {
|
|
hosts = append(hosts, host)
|
|
}
|
|
}
|
|
if len(hosts) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
requestCount, uniqueVisitors, totalBytesSent, err := repository.CountOpenFlareAccessLogs(ctx, model.OpenFlareAccessLogQuery{
|
|
Hosts: hosts,
|
|
Since: since,
|
|
Until: now,
|
|
})
|
|
if err != nil {
|
|
if isAnalyticsUnavailable(err) {
|
|
result.Available = false
|
|
return result, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
result.RequestCount = requestCount
|
|
result.UniqueVisitors = uniqueVisitors
|
|
result.BytesSent = totalBytesSent
|
|
|
|
buckets, err := repository.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
|
|
Hosts: hosts,
|
|
Since: since,
|
|
Until: now,
|
|
FoldMinutes: bucketMinutes,
|
|
SortBy: "logged_at",
|
|
SortOrder: "asc",
|
|
})
|
|
if err != nil {
|
|
if isAnalyticsUnavailable(err) {
|
|
result.Available = false
|
|
return result, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
byEpoch := make(map[int64]model.OpenFlareAccessLogBucketRow, len(buckets))
|
|
for _, bucketRow := range buckets {
|
|
if bucketRow == nil {
|
|
continue
|
|
}
|
|
byEpoch[bucketRow.BucketEpoch] = *bucketRow
|
|
}
|
|
series := emptyStatsSeries(since, now, bucketMinutes)
|
|
for index := range series {
|
|
epoch := series[index].BucketStartedAt.Unix()
|
|
if row, ok := byEpoch[epoch]; ok {
|
|
series[index].RequestCount = row.RequestCount
|
|
series[index].UniqueVisitors = row.UniqueIPCount
|
|
series[index].BytesSent = row.BytesSent
|
|
}
|
|
}
|
|
result.Series = series
|
|
return result, nil
|
|
}
|
|
|
|
func emptyStatsSeries(since, until time.Time, bucketMinutes int) []StatsPoint {
|
|
if bucketMinutes <= 0 {
|
|
bucketMinutes = 60
|
|
}
|
|
bucket := time.Duration(bucketMinutes) * time.Minute
|
|
start := since.UTC().Truncate(bucket)
|
|
end := until.UTC()
|
|
if !end.After(start) {
|
|
return []StatsPoint{{BucketStartedAt: start}}
|
|
}
|
|
// Cap points to keep chart readable.
|
|
maxPoints := 120
|
|
capacity := int(end.Sub(start)/bucket) + 1
|
|
if capacity > maxPoints {
|
|
capacity = maxPoints
|
|
}
|
|
points := make([]StatsPoint, 0, capacity)
|
|
for cursor := start; !cursor.After(end) && len(points) < maxPoints; cursor = cursor.Add(bucket) {
|
|
points = append(points, StatsPoint{BucketStartedAt: cursor})
|
|
}
|
|
if len(points) == 0 {
|
|
points = append(points, StatsPoint{BucketStartedAt: start})
|
|
}
|
|
return points
|
|
}
|
|
|
|
func isAnalyticsUnavailable(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, gorm.ErrInvalidDB) {
|
|
return true
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "clickhouse connection is not initialized") ||
|
|
strings.Contains(msg, "clickhouse is not") ||
|
|
strings.Contains(msg, "database is not initialized")
|
|
}
|