morffix/server/stats.go
Samuel Lorch a5fc856764
All checks were successful
/ release (push) Successful in 48s
Saved Stat
2025-03-19 23:07:11 +01:00

369 lines
10 KiB
Go

package server
import (
"bytes"
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"slices"
"strings"
"time"
"git.lastassault.de/speatzle/morffix/constants"
"github.com/go-echarts/go-echarts/v2/charts"
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/jackc/pgx/v5"
)
type StatsDisplay struct {
Charts []ChartData
Libraries []Library
SelectedLibrary string
Size []int
Duration []int
Count []int
Saved []int
}
type ChartData struct {
Element template.HTML
Script template.HTML
}
type PieValue struct {
Name string
Value int
}
type PieIntValue struct {
Id int
Value int
}
const CHART_COLOR_SUCCESS = "#7cffb2"
const CHART_COLOR_FAILED = "#ff6e76"
const CHART_COLOR_UNKNOWN = "#fddd60"
func generatePie(name string, data []opts.PieData) ChartData {
pie := charts.NewPie()
pie.SetGlobalOptions(
charts.WithInitializationOpts(opts.Initialization{
Theme: "dark",
BackgroundColor: "#111",
}),
charts.WithTitleOpts(opts.Title{
Title: name,
}))
for i := range data {
if data[i].Name == "Success" || data[i].Name == "Healthy" {
data[i].ItemStyle = &opts.ItemStyle{
Color: CHART_COLOR_SUCCESS,
}
} else if data[i].Name == "Failed" || data[i].Name == "Damaged" {
data[i].ItemStyle = &opts.ItemStyle{
Color: CHART_COLOR_FAILED,
}
} else if data[i].Name == "Unknown" || data[i].Name == "None" {
data[i].ItemStyle = &opts.ItemStyle{
Color: CHART_COLOR_UNKNOWN,
}
}
}
pie.AddSeries(name, data).SetSeriesOptions(
charts.WithLabelOpts(
opts.Label{
Show: opts.Bool(true),
Formatter: "{b}: {c}",
}),
)
snippet := pie.RenderSnippet()
return ChartData{
Element: template.HTML(snippet.Element),
Script: template.HTML(snippet.Script),
}
}
func generateStats(ctx context.Context, library_id string) ([]ChartData, error) {
data := []ChartData{}
rows, err := db.Query(ctx,
`SELECT COALESCE(jsonb_path_query_first(ffprobe_data, '$.streams[*] ? (@.codec_type == "video") ? (@.disposition.attached_pic == 0).codec_name')::text, 'Unknown') AS name, COUNT(*) AS value
FROM files
WHERE ffprobe_data IS NOT NULL AND ($1 = -1 OR library_id = $1)
GROUP BY 1;`, library_id)
if err != nil {
return nil, fmt.Errorf("Query Codecs: %w", err)
}
codecCounts, err := pgx.CollectRows(rows, pgx.RowToStructByName[PieValue])
if err != nil {
return nil, fmt.Errorf("Collect Codec Data: %w", err)
}
res := []opts.PieData{}
for _, v := range codecCounts {
res = append(res, opts.PieData{
Name: strings.ReplaceAll(v.Name, "\"", ""),
Value: v.Value,
})
}
data = append(data, generatePie("Codecs", res))
rows, err = db.Query(ctx,
`SELECT COALESCE(jsonb_path_query_first(ffprobe_data, '$.streams[*] ? (@.codec_type == "video") ? (@.disposition.attached_pic == 0).width')::text, 'Unknown') AS name, COUNT(*) AS value
FROM files
WHERE ffprobe_data IS NOT NULL AND ($1 = -1 OR library_id = $1)
GROUP BY 1;`, library_id)
if err != nil {
return nil, fmt.Errorf("Query Resolution: %w", err)
}
resolutionCounts, err := pgx.CollectRows(rows, pgx.RowToStructByName[PieValue])
if err != nil {
return nil, fmt.Errorf("Collect Codec Data: %w", err)
}
res = []opts.PieData{}
for _, v := range resolutionCounts {
res = append(res, opts.PieData{
Name: strings.ReplaceAll(v.Name, "\"", ""),
Value: v.Value,
})
}
data = append(data, generatePie("Resolution", res))
rows, err = db.Query(ctx,
`SELECT COALESCE(jsonb_path_query_first(ffprobe_data, '$.format.format_name')::text, 'Unknown') AS name, COUNT(*) AS value
FROM files
WHERE ffprobe_data IS NOT NULL AND ($1 = -1 OR library_id = $1)
GROUP BY 1;`, library_id)
if err != nil {
return nil, fmt.Errorf("Query Container: %w", err)
}
containerCounts, err := pgx.CollectRows(rows, pgx.RowToStructByName[PieValue])
if err != nil {
return nil, fmt.Errorf("Collect Container Data: %w", err)
}
res = []opts.PieData{}
for _, v := range containerCounts {
res = append(res, opts.PieData{
Name: strings.ReplaceAll(v.Name, "\"", ""),
Value: v.Value,
})
}
data = append(data, generatePie("Container", res))
rows, err = db.Query(ctx,
`SELECT health AS id, COUNT(*) AS value
FROM files WHERE ($1 = -1 OR library_id = $1)
GROUP BY 1;`, library_id)
if err != nil {
return nil, fmt.Errorf("Query Health: %w", err)
}
healthCounts, err := pgx.CollectRows(rows, pgx.RowToStructByName[PieIntValue])
if err != nil {
return nil, fmt.Errorf("Collect Health Data: %w", err)
}
res = []opts.PieData{}
for _, v := range healthCounts {
res = append(res, opts.PieData{
Name: constants.FileHealth(v.Id).String(),
Value: v.Value,
})
}
data = append(data, generatePie("Health", res))
rows, err = db.Query(ctx,
`SELECT transcode AS id, COUNT(*) AS value
FROM files WHERE ($1 = -1 OR library_id = $1)
GROUP BY 1;`, library_id)
if err != nil {
return nil, fmt.Errorf("Query Transcode: %w", err)
}
transcodeCounts, err := pgx.CollectRows(rows, pgx.RowToStructByName[PieIntValue])
if err != nil {
return nil, fmt.Errorf("Collect Transcode Data: %w", err)
}
res = []opts.PieData{}
for _, v := range transcodeCounts {
res = append(res, opts.PieData{
Name: constants.FileTranscode(v.Id).String(),
Value: v.Value,
})
}
data = append(data, generatePie("Transcode Status", res))
rows, err = db.Query(ctx,
`SELECT tasks.status AS id, COUNT(*) AS value
FROM tasks INNER JOIN files ON files.id = tasks.file_id
WHERE ($1 = -1 OR files.library_id = $1)
GROUP BY 1;`, library_id)
if err != nil {
return nil, fmt.Errorf("Query Task Status: %w", err)
}
taskStatusCounts, err := pgx.CollectRows(rows, pgx.RowToStructByName[PieIntValue])
if err != nil {
return nil, fmt.Errorf("Collect Task Status Data: %w", err)
}
res = []opts.PieData{}
for _, v := range taskStatusCounts {
res = append(res, opts.PieData{
Name: constants.TaskStatus(v.Id).String(),
Value: v.Value,
})
}
data = append(data, generatePie("Task Status", res))
type BarTaskRowValue struct {
Date time.Time
Status constants.TaskStatus
Count int
}
rows, err = db.Query(ctx,
`SELECT date_trunc('day', tasks.updated_at) date, tasks.status, COUNT(*) AS count
FROM tasks INNER JOIN files ON files.id = tasks.file_id
WHERE ($1 = -1 OR files.library_id = $1) AND tasks.updated_at > CURRENT_DATE - 7 AND (tasks.status = $2 OR tasks.status = $3)
GROUP BY 1,2
ORDER BY date;`, library_id, constants.TASK_STATUS_SUCCESS, constants.TASK_STATUS_FAILED)
if err != nil {
return nil, fmt.Errorf("Query Task Status Day: %w", err)
}
taskStatusDayCounts, err := pgx.CollectRows(rows, pgx.RowToStructByName[BarTaskRowValue])
if err != nil {
return nil, fmt.Errorf("Collect Task Status Day Data: %w", err)
}
days := []string{}
successBarData := []opts.BarData{}
failedBarData := []opts.BarData{}
for _, v := range taskStatusDayCounts {
if !slices.Contains(days, v.Date.Format(time.DateOnly)) {
days = append(days, v.Date.Format(time.DateOnly))
}
if v.Status == constants.TASK_STATUS_SUCCESS {
successBarData = append(successBarData, opts.BarData{
Value: v.Count,
ItemStyle: &opts.ItemStyle{
Color: CHART_COLOR_SUCCESS,
},
})
} else if v.Status == constants.TASK_STATUS_FAILED {
failedBarData = append(failedBarData, opts.BarData{
Value: v.Count,
ItemStyle: &opts.ItemStyle{
Color: CHART_COLOR_FAILED,
},
})
}
}
bar := charts.NewBar()
bar.SetGlobalOptions(
charts.WithInitializationOpts(opts.Initialization{
Theme: "dark",
BackgroundColor: "#111",
}),
charts.WithTitleOpts(opts.Title{
Title: "Task Success/Failed Last 7 Days",
}),
)
bar.SetXAxis(days).
AddSeries("Success", successBarData).
AddSeries("Failed", failedBarData).
SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{
Stack: "stackA",
}))
snippet := bar.RenderSnippet()
data = append(data, ChartData{
Element: template.HTML(snippet.Element),
Script: template.HTML(snippet.Script),
})
return data, nil
}
func handleStats(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
id = "-1"
}
data, err := generateStats(r.Context(), id)
if err != nil {
slog.ErrorContext(r.Context(), "Generate Stats:", "err", err)
http.Error(w, "Generate Stats: "+err.Error(), http.StatusInternalServerError)
return
}
rows, err := db.Query(r.Context(),
`SELECT *
FROM libraries;`)
if err != nil {
slog.ErrorContext(r.Context(), "Query Libraries", "err", err)
http.Error(w, "Query Libraries: "+err.Error(), http.StatusInternalServerError)
return
}
libraries, err := pgx.CollectRows(rows, pgx.RowToStructByName[Library])
if err != nil {
slog.ErrorContext(r.Context(), "Collect Libraries", "err", err)
http.Error(w, "Collect Libraries: "+err.Error(), http.StatusInternalServerError)
return
}
var size int
var count int
var duration int
err = db.QueryRow(r.Context(),
`SELECT SUM(size) AS size,
COUNT(id) as count,
0 as duration
FROM files WHERE ($1 = -1 OR files.library_id = $1) AND status = $2`, id, constants.FILE_STATUS_EXISTS).Scan(&size, &count, &duration)
if err != nil {
size = 0
count = 0
duration = 0
}
var saved int
err = db.QueryRow(r.Context(),
`SELECT (SUM(old_size) - COALESCE(SUM(size), 0)) AS saved
FROM files WHERE ($1 = -1 OR files.library_id = $1) AND status = $2 AND old_size NOT NULL`, id, constants.FILE_STATUS_EXISTS).Scan(&saved)
if err != nil {
saved = 0
}
buf := bytes.Buffer{}
err = templates.ExecuteTemplate(&buf, constants.STATS_TEMPLATE_NAME, StatsDisplay{
Libraries: libraries,
SelectedLibrary: id,
Size: splitInt(size),
Count: splitInt(count),
Duration: splitInt(duration),
Saved: splitInt(saved),
Charts: data,
})
if err != nil {
slog.ErrorContext(r.Context(), "Executing Stats Template", "err", err)
http.Error(w, "Error Executing Template: "+err.Error(), http.StatusInternalServerError)
return
}
_, err = w.Write(buf.Bytes())
if err != nil {
slog.ErrorContext(r.Context(), "Writing http Response", "err", err)
}
}