369 lines
10 KiB
Go
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 IS 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)
|
|
}
|
|
}
|