feat: add dashboard metrics service
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
type DashboardMetrics struct {
|
||||
TodayProcessedImages int64 `json:"todayProcessedImages"`
|
||||
SuccessRate float64 `json:"successRate"`
|
||||
AverageDurationMs float64 `json:"averageDurationMs"`
|
||||
FailedTasks int64 `json:"failedTasks"`
|
||||
TodayAuditLogs int64 `json:"todayAuditLogs"`
|
||||
}
|
||||
|
||||
type DailyMetric struct {
|
||||
Date string `json:"date"`
|
||||
ProcessedImages int64 `json:"processedImages"`
|
||||
SucceededTasks int64 `json:"succeededTasks"`
|
||||
FailedTasks int64 `json:"failedTasks"`
|
||||
AverageDurationMs float64 `json:"averageDurationMs"`
|
||||
AuditLogs int64 `json:"auditLogs"`
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cmbone/internal/models"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTrendDays = 7
|
||||
maxTrendDays = 30
|
||||
imageTaskStatusFailed = "failed"
|
||||
)
|
||||
|
||||
type MetricsService struct {
|
||||
store *SuiStore
|
||||
}
|
||||
|
||||
func NewMetricsService(store *SuiStore) *MetricsService {
|
||||
return &MetricsService{store: store}
|
||||
}
|
||||
|
||||
func (s *MetricsService) GetDashboardMetrics() (models.DashboardMetrics, error) {
|
||||
start := localDayStart(time.Now())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
|
||||
taskMetric, err := s.queryTaskMetric(start, end)
|
||||
if err != nil {
|
||||
return models.DashboardMetrics{}, err
|
||||
}
|
||||
|
||||
auditLogs, err := s.countAuditLogs(start, end)
|
||||
if err != nil {
|
||||
return models.DashboardMetrics{}, err
|
||||
}
|
||||
|
||||
return models.DashboardMetrics{
|
||||
TodayProcessedImages: taskMetric.ProcessedImages,
|
||||
SuccessRate: successRate(taskMetric.SucceededTasks, taskMetric.ProcessedImages),
|
||||
AverageDurationMs: taskMetric.AverageDuration,
|
||||
FailedTasks: taskMetric.FailedTasks,
|
||||
TodayAuditLogs: auditLogs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MetricsService) GetProcessingTrend(days int) ([]models.DailyMetric, error) {
|
||||
days = normalizeTrendDays(days)
|
||||
today := localDayStart(time.Now())
|
||||
start := today.AddDate(0, 0, -days+1)
|
||||
|
||||
trend := make([]models.DailyMetric, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
dayStart := start.AddDate(0, 0, i)
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
taskMetric, err := s.queryTaskMetric(dayStart, dayEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auditLogs, err := s.countAuditLogs(dayStart, dayEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trend = append(trend, models.DailyMetric{
|
||||
Date: dayStart.Format("2006-01-02"),
|
||||
ProcessedImages: taskMetric.ProcessedImages,
|
||||
SucceededTasks: taskMetric.SucceededTasks,
|
||||
FailedTasks: taskMetric.FailedTasks,
|
||||
AverageDurationMs: taskMetric.AverageDuration,
|
||||
AuditLogs: auditLogs,
|
||||
})
|
||||
}
|
||||
return trend, nil
|
||||
}
|
||||
|
||||
type taskMetric struct {
|
||||
ProcessedImages int64
|
||||
SucceededTasks int64
|
||||
FailedTasks int64
|
||||
AverageDuration float64
|
||||
}
|
||||
|
||||
func (s *MetricsService) queryTaskMetric(start time.Time, end time.Time) (taskMetric, error) {
|
||||
var metric taskMetric
|
||||
var average sql.NullFloat64
|
||||
err := s.store.DB.QueryRow(`
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0),
|
||||
AVG(CASE WHEN duration_ms > 0 THEN duration_ms END)
|
||||
FROM image_tasks
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
`, imageTaskStatusSucceeded, imageTaskStatusFailed, formatMetricTime(start), formatMetricTime(end)).Scan(
|
||||
&metric.ProcessedImages,
|
||||
&metric.SucceededTasks,
|
||||
&metric.FailedTasks,
|
||||
&average,
|
||||
)
|
||||
if err != nil {
|
||||
return taskMetric{}, err
|
||||
}
|
||||
if average.Valid {
|
||||
metric.AverageDuration = average.Float64
|
||||
}
|
||||
return metric, nil
|
||||
}
|
||||
|
||||
func (s *MetricsService) countAuditLogs(start time.Time, end time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := s.store.DB.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM audit_logs
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
`, formatMetricTime(start), formatMetricTime(end)).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func normalizeTrendDays(days int) int {
|
||||
if days <= 0 {
|
||||
return defaultTrendDays
|
||||
}
|
||||
if days > maxTrendDays {
|
||||
return maxTrendDays
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func localDayStart(now time.Time) time.Time {
|
||||
local := now.In(time.Local)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())
|
||||
}
|
||||
|
||||
func formatMetricTime(value time.Time) string {
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func successRate(succeeded int64, total int64) float64 {
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(succeeded) * 100 / float64(total)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMetricsServiceGetDashboardMetrics(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
service := NewMetricsService(store)
|
||||
today := localDayStart(time.Now())
|
||||
yesterday := today.AddDate(0, 0, -1)
|
||||
|
||||
insertImageTaskForMetrics(t, store, imageTaskStatusSucceeded, 1000, today.Add(2*time.Hour))
|
||||
insertImageTaskForMetrics(t, store, imageTaskStatusSucceeded, 2000, today.Add(3*time.Hour))
|
||||
insertImageTaskForMetrics(t, store, imageTaskStatusFailed, 0, today.Add(4*time.Hour))
|
||||
insertImageTaskForMetrics(t, store, imageTaskStatusSucceeded, 900, yesterday.Add(2*time.Hour))
|
||||
insertAuditLogForMetrics(t, store, today.Add(5*time.Hour))
|
||||
insertAuditLogForMetrics(t, store, today.Add(6*time.Hour))
|
||||
insertAuditLogForMetrics(t, store, yesterday.Add(5*time.Hour))
|
||||
|
||||
metrics, err := service.GetDashboardMetrics()
|
||||
if err != nil {
|
||||
t.Fatalf("get dashboard metrics: %v", err)
|
||||
}
|
||||
|
||||
if metrics.TodayProcessedImages != 3 {
|
||||
t.Fatalf("today processed images = %d, want 3", metrics.TodayProcessedImages)
|
||||
}
|
||||
if metrics.FailedTasks != 1 {
|
||||
t.Fatalf("failed tasks = %d, want 1", metrics.FailedTasks)
|
||||
}
|
||||
if math.Abs(metrics.SuccessRate-66.6667) > 0.01 {
|
||||
t.Fatalf("success rate = %f, want about 66.67", metrics.SuccessRate)
|
||||
}
|
||||
if metrics.AverageDurationMs != 1500 {
|
||||
t.Fatalf("average duration = %f, want 1500", metrics.AverageDurationMs)
|
||||
}
|
||||
if metrics.TodayAuditLogs != 2 {
|
||||
t.Fatalf("today audit logs = %d, want 2", metrics.TodayAuditLogs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsServiceGetProcessingTrend(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
service := NewMetricsService(store)
|
||||
today := localDayStart(time.Now())
|
||||
twoDaysAgo := today.AddDate(0, 0, -2)
|
||||
|
||||
insertImageTaskForMetrics(t, store, imageTaskStatusSucceeded, 1200, twoDaysAgo.Add(2*time.Hour))
|
||||
insertImageTaskForMetrics(t, store, imageTaskStatusFailed, 0, today.Add(2*time.Hour))
|
||||
insertAuditLogForMetrics(t, store, today.Add(3*time.Hour))
|
||||
|
||||
trend, err := service.GetProcessingTrend(3)
|
||||
if err != nil {
|
||||
t.Fatalf("get processing trend: %v", err)
|
||||
}
|
||||
if len(trend) != 3 {
|
||||
t.Fatalf("trend len = %d, want 3", len(trend))
|
||||
}
|
||||
if trend[0].Date != twoDaysAgo.Format("2006-01-02") {
|
||||
t.Fatalf("first date = %q, want %q", trend[0].Date, twoDaysAgo.Format("2006-01-02"))
|
||||
}
|
||||
if trend[0].ProcessedImages != 1 || trend[0].SucceededTasks != 1 {
|
||||
t.Fatalf("first metric = %+v, want one succeeded task", trend[0])
|
||||
}
|
||||
if trend[2].ProcessedImages != 1 || trend[2].FailedTasks != 1 || trend[2].AuditLogs != 1 {
|
||||
t.Fatalf("today metric = %+v, want one failed task and one audit log", trend[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTrendDays(t *testing.T) {
|
||||
if got := normalizeTrendDays(0); got != defaultTrendDays {
|
||||
t.Fatalf("normalize 0 = %d, want %d", got, defaultTrendDays)
|
||||
}
|
||||
if got := normalizeTrendDays(maxTrendDays + 1); got != maxTrendDays {
|
||||
t.Fatalf("normalize max+1 = %d, want %d", got, maxTrendDays)
|
||||
}
|
||||
if got := normalizeTrendDays(3); got != 3 {
|
||||
t.Fatalf("normalize 3 = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func insertImageTaskForMetrics(t *testing.T, store *SuiStore, status string, durationMs int64, createdAt time.Time) {
|
||||
t.Helper()
|
||||
|
||||
_, err := store.DB.Exec(`
|
||||
INSERT INTO image_tasks (source_path, status, operation, duration_ms, error_message, created_at, updated_at)
|
||||
VALUES (?, ?, 'enhance', ?, '', ?, ?)
|
||||
`, "D:/images/sku.jpg", status, durationMs, formatMetricTime(createdAt), formatMetricTime(createdAt))
|
||||
if err != nil {
|
||||
t.Fatalf("insert image task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAuditLogForMetrics(t *testing.T, store *SuiStore, createdAt time.Time) {
|
||||
t.Helper()
|
||||
|
||||
_, err := store.DB.Exec(`
|
||||
INSERT INTO audit_logs (actor, action, target_type, target_id, detail, created_at)
|
||||
VALUES ('admin', 'image_task.create', 'image_task', '1', 'created', ?)
|
||||
`, formatMetricTime(createdAt))
|
||||
if err != nil {
|
||||
t.Fatalf("insert audit log: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@ func Migrate(db *sql.DB) error {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tasks_status_created_at ON image_tasks(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tasks_created_at ON image_tasks(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_task_results_task_id ON image_task_results(task_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_logs (
|
||||
@@ -138,6 +139,7 @@ func Migrate(db *sql.DB) error {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_logs_level_created_at ON app_logs(level, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_action_created_at ON audit_logs(action, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user