Add download function, prepare for RivaTuner format

This commit is contained in:
Erikas 2024-07-12 10:45:48 +03:00
parent af26d221ff
commit fb2642aad2
5 changed files with 297 additions and 181 deletions

View File

@ -1,12 +1,13 @@
package flightlesssomething package flightlesssomething
import ( import (
"archive/zip"
"bufio" "bufio"
"bytes" "bytes"
"encoding/csv"
"encoding/gob" "encoding/gob"
"errors" "errors"
"fmt" "fmt"
"log"
"math/big" "math/big"
"mime/multipart" "mime/multipart"
"os" "os"
@ -46,165 +47,179 @@ type BenchmarkData struct {
// readBenchmarkFiles reads the uploaded benchmark files and returns a slice of BenchmarkData. // readBenchmarkFiles reads the uploaded benchmark files and returns a slice of BenchmarkData.
func readBenchmarkFiles(files []*multipart.FileHeader) ([]*BenchmarkData, error) { func readBenchmarkFiles(files []*multipart.FileHeader) ([]*BenchmarkData, error) {
csvFiles := make([]*BenchmarkData, 0) benchmarkDatas := make([]*BenchmarkData, 0)
linesCount := 0
for _, fileHeader := range files { for _, fileHeader := range files {
csvFile := BenchmarkData{}
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer file.Close()
defer file.Close()
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
// Label is filename without extension // FirstLine identifies file format
csvFile.Label = strings.TrimSuffix(fileHeader.Filename, ".csv")
csvFile.Label = strings.TrimSuffix(csvFile.Label, ".htm")
// First line should contain this: os,cpu,gpu,ram,kernel,driver,cpuscheduler
if !scanner.Scan() { if !scanner.Scan() {
return nil, errors.New("invalid CSV file (err 1)") return nil, errors.New("failed to read file (err 1)")
} }
record := strings.Split(strings.TrimRight(scanner.Text(), ","), ",") firstLine := scanner.Text()
if len(record) != 7 {
return nil, errors.New("invalid CSV file (err 2)") var benchmarkData *BenchmarkData
var suffix string
switch firstLine {
case "os,cpu,gpu,ram,kernel,driver,cpuscheduler": // MangoHud
benchmarkData, err = readMangoHudFile(scanner)
suffix = ".csv"
case "PLACEHOLDER": // RivaTuner
benchmarkData, err = readMangoHudFile(scanner)
suffix = ".htm"
default:
return nil, errors.New("unsupported file format")
} }
if err != nil {
return nil, err
}
benchmarkData.Label = strings.TrimSuffix(fileHeader.Filename, suffix)
benchmarkDatas = append(benchmarkDatas, benchmarkData)
}
return benchmarkDatas, nil
}
func readMangoHudFile(scanner *bufio.Scanner) (*BenchmarkData, error) {
benchmarkData := &BenchmarkData{}
// Second line should contain values // Second line should contain values
if !scanner.Scan() { if !scanner.Scan() {
return nil, errors.New("invalid CSV file (err 3)") return nil, errors.New("failed to read file (err mh1)")
} }
record = strings.Split(scanner.Text(), ",") record := strings.Split(scanner.Text(), ",")
for i, v := range record { for i, v := range record {
switch i { switch i {
case 0: case 0:
csvFile.SpecOS = truncateString(strings.TrimSpace(v)) benchmarkData.SpecOS = truncateString(strings.TrimSpace(v))
case 1: case 1:
csvFile.SpecCPU = truncateString(strings.TrimSpace(v)) benchmarkData.SpecCPU = truncateString(strings.TrimSpace(v))
case 2: case 2:
csvFile.SpecGPU = truncateString(strings.TrimSpace(v)) benchmarkData.SpecGPU = truncateString(strings.TrimSpace(v))
case 3: case 3:
kilobytes := new(big.Int) kilobytes := new(big.Int)
_, ok := kilobytes.SetString(strings.TrimSpace(v), 10) _, ok := kilobytes.SetString(strings.TrimSpace(v), 10)
if !ok { if !ok {
return nil, errors.New("failed to convert RAM to big.Int")
}
bytes := new(big.Int).Mul(kilobytes, big.NewInt(1024)) bytes := new(big.Int).Mul(kilobytes, big.NewInt(1024))
csvFile.SpecRAM = humanize.Bytes(bytes.Uint64()) benchmarkData.SpecRAM = humanize.Bytes(bytes.Uint64())
} else {
benchmarkData.SpecRAM = truncateString(strings.TrimSpace(v))
}
case 4: case 4:
csvFile.SpecLinuxKernel = truncateString(strings.TrimSpace(v)) benchmarkData.SpecLinuxKernel = truncateString(strings.TrimSpace(v))
case 6: case 6:
csvFile.SpecLinuxScheduler = truncateString(strings.TrimSpace(v)) benchmarkData.SpecLinuxScheduler = truncateString(strings.TrimSpace(v))
} }
} }
// 3rd line contain headers for benchmark data: fps,frametime,cpu_load,gpu_load,cpu_temp,gpu_temp,gpu_core_clock,gpu_mem_clock,gpu_vram_used,gpu_power,ram_used,swap_used,process_rss,elapsed // 3rd line contain headers for benchmark data
if !scanner.Scan() { if !scanner.Scan() {
return nil, errors.New("invalid CSV file (err 5)") return nil, errors.New("failed to read file (err mh2)")
} }
record = strings.Split(strings.TrimRight(scanner.Text(), ","), ",") record = strings.Split(strings.TrimRight(scanner.Text(), ","), ",")
if len(record) != 14 { if len(record) == 0 {
return nil, errors.New("invalid CSV file (err 6)") return nil, errors.New("failed to read file (err mh3)")
} }
// Preallocate slices. First file will be inefficient, but later files will contain benchmarkData.DataFPS = make([]float64, 0)
// value of linesCount that would help to optimize preallocation. benchmarkData.DataFrameTime = make([]float64, 0)
csvFile.DataFPS = make([]float64, 0, linesCount) benchmarkData.DataCPULoad = make([]float64, 0)
csvFile.DataFrameTime = make([]float64, 0, linesCount) benchmarkData.DataGPULoad = make([]float64, 0)
csvFile.DataCPULoad = make([]float64, 0, linesCount) benchmarkData.DataCPUTemp = make([]float64, 0)
csvFile.DataGPULoad = make([]float64, 0, linesCount) benchmarkData.DataGPUTemp = make([]float64, 0)
csvFile.DataCPUTemp = make([]float64, 0, linesCount) benchmarkData.DataGPUCoreClock = make([]float64, 0)
csvFile.DataGPUTemp = make([]float64, 0, linesCount) benchmarkData.DataGPUMemClock = make([]float64, 0)
csvFile.DataGPUCoreClock = make([]float64, 0, linesCount) benchmarkData.DataGPUVRAMUsed = make([]float64, 0)
csvFile.DataGPUMemClock = make([]float64, 0, linesCount) benchmarkData.DataGPUPower = make([]float64, 0)
csvFile.DataGPUVRAMUsed = make([]float64, 0, linesCount) benchmarkData.DataRAMUsed = make([]float64, 0)
csvFile.DataGPUPower = make([]float64, 0, linesCount) benchmarkData.DataSwapUsed = make([]float64, 0)
csvFile.DataRAMUsed = make([]float64, 0, linesCount)
csvFile.DataSwapUsed = make([]float64, 0, linesCount)
var counter uint var counter uint
for scanner.Scan() { for scanner.Scan() {
record = strings.Split(scanner.Text(), ",") record = strings.Split(scanner.Text(), ",")
if len(record) != 14 { if len(record) < 12 { // Ignore last 2 columns as they are not needed
return nil, errors.New("invalid CSV file (err 7)") return nil, errors.New("failed to read file (err mh4)")
} }
val, err := strconv.ParseFloat(record[0], 64) val, err := strconv.ParseFloat(record[0], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse FPS value '%s': %v", record[0], err) return nil, fmt.Errorf("failed to parse FPS value '%s': %v", record[0], err)
} }
csvFile.DataFPS = append(csvFile.DataFPS, val) benchmarkData.DataFPS = append(benchmarkData.DataFPS, val)
val, err = strconv.ParseFloat(record[1], 64) val, err = strconv.ParseFloat(record[1], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse frametime value '%s': %v", record[1], err) return nil, fmt.Errorf("failed to parse frametime value '%s': %v", record[1], err)
} }
csvFile.DataFrameTime = append(csvFile.DataFrameTime, val) benchmarkData.DataFrameTime = append(benchmarkData.DataFrameTime, val)
val, err = strconv.ParseFloat(record[2], 64) val, err = strconv.ParseFloat(record[2], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse CPU load value '%s': %v", record[2], err) return nil, fmt.Errorf("failed to parse CPU load value '%s': %v", record[2], err)
} }
csvFile.DataCPULoad = append(csvFile.DataCPULoad, val) benchmarkData.DataCPULoad = append(benchmarkData.DataCPULoad, val)
val, err = strconv.ParseFloat(record[3], 64) val, err = strconv.ParseFloat(record[3], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse GPU load value '%s': %v", record[3], err) return nil, fmt.Errorf("failed to parse GPU load value '%s': %v", record[3], err)
} }
csvFile.DataGPULoad = append(csvFile.DataGPULoad, val) benchmarkData.DataGPULoad = append(benchmarkData.DataGPULoad, val)
val, err = strconv.ParseFloat(record[4], 64) val, err = strconv.ParseFloat(record[4], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse CPU temp value '%s': %v", record[4], err) return nil, fmt.Errorf("failed to parse CPU temp value '%s': %v", record[4], err)
} }
csvFile.DataCPUTemp = append(csvFile.DataCPUTemp, val) benchmarkData.DataCPUTemp = append(benchmarkData.DataCPUTemp, val)
val, err = strconv.ParseFloat(record[5], 64) val, err = strconv.ParseFloat(record[5], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse GPU temp value '%s': %v", record[5], err) return nil, fmt.Errorf("failed to parse GPU temp value '%s': %v", record[5], err)
} }
csvFile.DataGPUTemp = append(csvFile.DataGPUTemp, val) benchmarkData.DataGPUTemp = append(benchmarkData.DataGPUTemp, val)
val, err = strconv.ParseFloat(record[6], 64) val, err = strconv.ParseFloat(record[6], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse GPU core clock value '%s': %v", record[6], err) return nil, fmt.Errorf("failed to parse GPU core clock value '%s': %v", record[6], err)
} }
csvFile.DataGPUCoreClock = append(csvFile.DataGPUCoreClock, val) benchmarkData.DataGPUCoreClock = append(benchmarkData.DataGPUCoreClock, val)
val, err = strconv.ParseFloat(record[7], 64) val, err = strconv.ParseFloat(record[7], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse GPU mem clock value '%s': %v", record[7], err) return nil, fmt.Errorf("failed to parse GPU mem clock value '%s': %v", record[7], err)
} }
csvFile.DataGPUMemClock = append(csvFile.DataGPUMemClock, val) benchmarkData.DataGPUMemClock = append(benchmarkData.DataGPUMemClock, val)
val, err = strconv.ParseFloat(record[8], 64) val, err = strconv.ParseFloat(record[8], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse GPU VRAM used value '%s': %v", record[8], err) return nil, fmt.Errorf("failed to parse GPU VRAM used value '%s': %v", record[8], err)
} }
csvFile.DataGPUVRAMUsed = append(csvFile.DataGPUVRAMUsed, val) benchmarkData.DataGPUVRAMUsed = append(benchmarkData.DataGPUVRAMUsed, val)
val, err = strconv.ParseFloat(record[9], 64) val, err = strconv.ParseFloat(record[9], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse GPU power value '%s': %v", record[9], err) return nil, fmt.Errorf("failed to parse GPU power value '%s': %v", record[9], err)
} }
csvFile.DataGPUPower = append(csvFile.DataGPUPower, val) benchmarkData.DataGPUPower = append(benchmarkData.DataGPUPower, val)
val, err = strconv.ParseFloat(record[10], 64) val, err = strconv.ParseFloat(record[10], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse RAM used value '%s': %v", record[10], err) return nil, fmt.Errorf("failed to parse RAM used value '%s': %v", record[10], err)
} }
csvFile.DataRAMUsed = append(csvFile.DataRAMUsed, val) benchmarkData.DataRAMUsed = append(benchmarkData.DataRAMUsed, val)
val, err = strconv.ParseFloat(record[11], 64) val, err = strconv.ParseFloat(record[11], 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse SWAP used value '%s': %v", record[11], err) return nil, fmt.Errorf("failed to parse SWAP used value '%s': %v", record[11], err)
} }
csvFile.DataSwapUsed = append(csvFile.DataSwapUsed, val) benchmarkData.DataSwapUsed = append(benchmarkData.DataSwapUsed, val)
counter++ counter++
if counter == 100000 { if counter == 100000 {
@ -212,35 +227,26 @@ func readBenchmarkFiles(files []*multipart.FileHeader) ([]*BenchmarkData, error)
} }
} }
// Next file would be more efficient to preallocate slices
if linesCount < len(csvFile.DataFPS) {
linesCount = len(csvFile.DataFPS)
}
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
log.Println("error (4) parsing CSV:", err)
return nil, err return nil, err
} }
if len(csvFile.DataFPS) == 0 && if len(benchmarkData.DataFPS) == 0 &&
len(csvFile.DataFrameTime) == 0 && len(benchmarkData.DataFrameTime) == 0 &&
len(csvFile.DataCPULoad) == 0 && len(benchmarkData.DataCPULoad) == 0 &&
len(csvFile.DataGPULoad) == 0 && len(benchmarkData.DataGPULoad) == 0 &&
len(csvFile.DataCPUTemp) == 0 && len(benchmarkData.DataCPUTemp) == 0 &&
len(csvFile.DataGPUTemp) == 0 && len(benchmarkData.DataGPUTemp) == 0 &&
len(csvFile.DataGPUCoreClock) == 0 && len(benchmarkData.DataGPUCoreClock) == 0 &&
len(csvFile.DataGPUMemClock) == 0 && len(benchmarkData.DataGPUMemClock) == 0 &&
len(csvFile.DataGPUVRAMUsed) == 0 && len(benchmarkData.DataGPUVRAMUsed) == 0 &&
len(csvFile.DataGPUPower) == 0 && len(benchmarkData.DataGPUPower) == 0 &&
len(csvFile.DataRAMUsed) == 0 && len(benchmarkData.DataRAMUsed) == 0 &&
len(csvFile.DataSwapUsed) == 0 { len(benchmarkData.DataSwapUsed) == 0 {
return nil, errors.New("empty CSV file (err 8)") return nil, errors.New("empty CSV file")
} }
csvFiles = append(csvFiles, &csvFile) return benchmarkData, nil
}
return csvFiles, nil
} }
// truncateString truncates the input string to a maximum of 100 characters and appends "..." if it exceeds that length. // truncateString truncates the input string to a maximum of 100 characters and appends "..." if it exceeds that length.
@ -310,3 +316,63 @@ func deleteBenchmarkData(benchmarkID uint) error {
filePath := filepath.Join(benchmarksDir, fmt.Sprintf("%d.bin", benchmarkID)) filePath := filepath.Join(benchmarksDir, fmt.Sprintf("%d.bin", benchmarkID))
return os.Remove(filePath) return os.Remove(filePath)
} }
func createZipFromBenchmarkData(benchmarkData []*BenchmarkData) (*bytes.Buffer, error) {
// Create a buffer to write our archive to.
buf := new(bytes.Buffer)
zipWriter := zip.NewWriter(buf)
for _, data := range benchmarkData {
// Create a new CSV file in the zip archive.
fileName := fmt.Sprintf("%s.csv", data.Label)
fileWriter, err := zipWriter.Create(fileName)
if err != nil {
return nil, fmt.Errorf("could not create file in zip: %v", err)
}
// Create a CSV writer.
csvWriter := csv.NewWriter(fileWriter)
// Write the header.
header := []string{"os", "cpu", "gpu", "ram", "kernel", "driver", "cpuscheduler"}
csvWriter.Write(header)
specs := []string{data.SpecOS, data.SpecCPU, data.SpecGPU, data.SpecRAM, data.SpecLinuxKernel, "", data.SpecLinuxScheduler}
csvWriter.Write(specs)
// Write the data header.
dataHeader := []string{"fps", "frametime", "cpu_load", "gpu_load", "cpu_temp", "gpu_temp", "gpu_core_clock", "gpu_mem_clock", "gpu_vram_used", "gpu_power", "ram_used", "swap_used"}
csvWriter.Write(dataHeader)
// Write the data rows.
for i := range data.DataFPS {
row := []string{
strconv.FormatFloat(data.DataFPS[i], 'f', 4, 64),
strconv.FormatFloat(data.DataFrameTime[i], 'f', 4, 64),
strconv.FormatFloat(data.DataCPULoad[i], 'f', 4, 64),
strconv.FormatFloat(data.DataGPULoad[i], 'f', 4, 64),
strconv.FormatFloat(data.DataCPUTemp[i], 'f', 4, 64),
strconv.FormatFloat(data.DataGPUTemp[i], 'f', 4, 64),
strconv.FormatFloat(data.DataGPUCoreClock[i], 'f', 4, 64),
strconv.FormatFloat(data.DataGPUMemClock[i], 'f', 4, 64),
strconv.FormatFloat(data.DataGPUVRAMUsed[i], 'f', 4, 64),
strconv.FormatFloat(data.DataGPUPower[i], 'f', 4, 64),
strconv.FormatFloat(data.DataRAMUsed[i], 'f', 4, 64),
strconv.FormatFloat(data.DataSwapUsed[i], 'f', 4, 64),
}
csvWriter.Write(row)
}
// Make sure to flush the writer.
csvWriter.Flush()
if err := csvWriter.Error(); err != nil {
return nil, fmt.Errorf("could not write CSV: %v", err)
}
}
// Close the zip writer to flush the buffer.
if err := zipWriter.Close(); err != nil {
return nil, fmt.Errorf("could not close zip writer: %v", err)
}
return buf, nil
}

View File

@ -375,3 +375,53 @@ func getBenchmark(c *gin.Context) {
"benchmarkData": benchmarkDatas, "benchmarkData": benchmarkDatas,
}) })
} }
func getBenchmarkDownload(c *gin.Context) {
session := sessions.Default(c)
// Get benchmark ID from the path
id := c.Param("id")
// Get benchmark details
intID, err := strconv.Atoi(id)
if err != nil {
c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"activePage": "error",
"username": session.Get("Username"),
"userID": session.Get("ID"),
"errorMessage": "Internal server error occurred: " + err.Error(),
})
return
}
var benchmark Benchmark
benchmark.ID = uint(intID)
benchmarkDatas, err := retrieveBenchmarkData(benchmark.ID)
if err != nil {
c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"activePage": "error",
"username": session.Get("Username"),
"userID": session.Get("ID"),
"errorMessage": "Error occurred: " + err.Error(),
})
return
}
content, err := createZipFromBenchmarkData(benchmarkDatas)
if err != nil {
c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"activePage": "error",
"username": session.Get("Username"),
"userID": session.Get("ID"),
"errorMessage": "Error occurred: " + err.Error(),
})
return
}
fileName := "benchmark_" + id + ".zip"
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename="+fileName)
c.Data(http.StatusOK, "application/zip", content.Bytes())
}

View File

@ -94,6 +94,7 @@ func Start(c *Config) {
r.POST("/benchmark", postBenchmarkCreate) r.POST("/benchmark", postBenchmarkCreate)
r.GET("/benchmark/:id", getBenchmark) r.GET("/benchmark/:id", getBenchmark)
r.DELETE("/benchmark/:id", deleteBenchmark) r.DELETE("/benchmark/:id", deleteBenchmark)
r.GET("/benchmark/:id/download", getBenchmarkDownload)
r.GET("/user/:id", getUser) r.GET("/user/:id", getUser)

View File

@ -6,7 +6,7 @@
{{if eq .benchmark.UserID .userID }} {{if eq .benchmark.UserID .userID }}
<a class="btn btn-danger me-2" data-bs-toggle="modal" data-bs-target="#exampleModal"><i class="fa-solid fa-trash"></i> Delete</a> <a class="btn btn-danger me-2" data-bs-toggle="modal" data-bs-target="#exampleModal"><i class="fa-solid fa-trash"></i> Delete</a>
{{end}} {{end}}
<a class="btn btn-secondary"><i class="fa-solid fa-download"></i> Download</a> <a class="btn btn-secondary" href="/benchmark/{{ .benchmark.ID }}/download" target="_blank" title="Reconstruct MangoHud-like CSV files"><i class="fa-solid fa-download"></i> Download</a>
</div> </div>
</div> </div>

View File

@ -1,5 +1,4 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js" integrity="sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://unpkg.com/htmx.org@2.0.0"></script> <script src="https://unpkg.com/htmx.org@2.0.0"></script>