|
package main |
|
|
|
import ( |
|
"encoding/json" |
|
"fmt" |
|
"log" |
|
"math" |
|
"os" |
|
"slices" |
|
"sort" |
|
"strconv" |
|
"time" |
|
|
|
"github.com/go-echarts/go-echarts/v2/charts" |
|
"github.com/go-echarts/go-echarts/v2/components" |
|
"github.com/go-echarts/go-echarts/v2/opts" |
|
"golang.org/x/exp/maps" |
|
) |
|
|
|
// Types to represent the return data of the Lagoon API. |
|
// |
|
// { |
|
// "data": { |
|
// "projectByName": { |
|
// "id": 1648, |
|
// "name": "example-com", |
|
// "environments": [ |
|
// { |
|
// "id": 231314, |
|
// "name": "dev", |
|
// "deleted": "0000-00-00 00:00:00", |
|
// "environmentType": "development", |
|
// "storages": [ |
|
// { |
|
// "id": 3913612, |
|
// "persistentStorageClaim": "mariadb", |
|
// "bytesUsed": 5325968, |
|
// "updated": "2019-12-13" |
|
// }, |
|
|
|
type Storage struct { |
|
Type string `json:"persistentStorageClaim"` |
|
KiloBytes int `json:"bytesUsed"` |
|
Updated string `json:"updated"` |
|
} |
|
|
|
type Environment struct { |
|
Name string `json:"name"` |
|
Deleted string `json:"deleted"` |
|
Storages []Storage `json:"storages"` |
|
} |
|
|
|
type Project struct { |
|
Name string `json:"name"` |
|
Environments []Environment `json:"environments"` |
|
} |
|
|
|
type RespData struct { |
|
ProjectByName Project `json:"projectByName"` |
|
} |
|
|
|
type RespErrors struct { |
|
} |
|
|
|
type ApiResponse struct { |
|
Data RespData `json:"data"` |
|
} |
|
|
|
// Type to represent the converted raw API data. |
|
type CalculatedData map[string]map[string]int |
|
|
|
func kbToGb(kb int) float64 { |
|
return math.Round(float64(kb)/10000) / 100 |
|
} |
|
|
|
func gbToKb(gb float64) int { |
|
return int(gb * 1000000) |
|
} |
|
|
|
// Chart a line for a given storage Type. |
|
func generateTypeLine(data CalculatedData, dates []string, Type string) []opts.LineData { |
|
items := make([]opts.LineData, 0) |
|
for _, date := range dates { |
|
kb, exists := data[date][Type] |
|
if exists { |
|
items = append(items, opts.LineData{Value: []interface{}{date, kbToGb(kb)}}) |
|
} else { |
|
items = append(items, opts.LineData{Value: []interface{}{date, 0}}) |
|
} |
|
} |
|
|
|
return items |
|
} |
|
|
|
// Chart a line that sums all storage Types. |
|
func generateTotalLine(data CalculatedData, dates []string) []opts.LineData { |
|
items := make([]opts.LineData, 0) |
|
for _, date := range dates { |
|
types := maps.Values(data[date]) |
|
typesTotal := 0 |
|
for _, kb := range types { |
|
typesTotal += kb |
|
} |
|
|
|
items = append(items, opts.LineData{Value: []interface{}{date, kbToGb(typesTotal)}}) |
|
} |
|
|
|
return items |
|
} |
|
|
|
// Chart a line with a rolling average of the total. |
|
func generateAverageLine(data CalculatedData, dates []string, days int) []opts.LineData { |
|
dayBuffer := make([]int, days) |
|
|
|
items := make([]opts.LineData, 0) |
|
for _, date := range dates { |
|
types := maps.Values(data[date]) |
|
typesTotal := 0 |
|
for _, kb := range types { |
|
typesTotal += kb |
|
} |
|
|
|
dayBuffer = append(dayBuffer[1:], typesTotal) |
|
|
|
daysTotal := 0 |
|
for _, kb := range dayBuffer { |
|
daysTotal += kb |
|
} |
|
|
|
items = append(items, opts.LineData{Value: []interface{}{date, kbToGb(daysTotal / days)}}) |
|
} |
|
|
|
return items |
|
} |
|
|
|
// Chart a line for a rolling sum of the total. |
|
// When passed `allotted`, subtract that amount to show the total "overage." |
|
func generateRollingTotalLine(data CalculatedData, dates []string, days int, allotted float64) []opts.LineData { |
|
dayBuffer := make([]int, days) |
|
|
|
items := make([]opts.LineData, 0) |
|
for _, date := range dates { |
|
types := maps.Values(data[date]) |
|
typesTotal := 0 |
|
for _, kb := range types { |
|
typesTotal += max(kb-gbToKb(allotted), 0) |
|
} |
|
|
|
dayBuffer = append(dayBuffer[1:], typesTotal) |
|
|
|
daysTotal := 0 |
|
for _, kb := range dayBuffer { |
|
daysTotal += kb |
|
} |
|
|
|
items = append(items, opts.LineData{Value: []interface{}{date, kbToGb(daysTotal)}}) |
|
} |
|
|
|
return items |
|
} |
|
|
|
// Chart a line with a billable month total. |
|
func generateMonthAverageLine(data CalculatedData, dates []string) []opts.LineData { |
|
monthTotal := 0 |
|
items := make([]opts.LineData, 0) |
|
for i, date := range dates { |
|
types := maps.Values(data[date]) |
|
typesTotal := 0 |
|
for _, kb := range types { |
|
typesTotal += kb |
|
} |
|
|
|
monthTotal += typesTotal |
|
|
|
today, _ := time.Parse(time.DateOnly, dates[i]) |
|
var tomorrow time.Time |
|
if len(dates) == i+1 { |
|
tomorrow = today.AddDate(0, 1, 0) |
|
} else { |
|
tomorrow, _ = time.Parse(time.DateOnly, dates[i+1]) |
|
} |
|
|
|
if today.Month() != tomorrow.Month() { |
|
t := time.Date(today.Year(), today.Month(), 32, 0, 0, 0, 0, time.UTC) |
|
daysInMonth := 32 - t.Day() |
|
// If it's the last day of the month (that we have data), add a datapoint |
|
items = append(items, opts.LineData{Value: []interface{}{date, kbToGb(monthTotal / daysInMonth)}}) |
|
// and reset the counter |
|
monthTotal = 0 |
|
} else { |
|
items = append(items, opts.LineData{Value: []interface{}{date, nil}}) |
|
} |
|
|
|
} |
|
|
|
return items |
|
} |
|
|
|
// Chart a line with a month excess over allotted. |
|
func generateMonthTotalLine(data CalculatedData, dates []string, allotted float64) []opts.LineData { |
|
monthTotal := 0 |
|
items := make([]opts.LineData, 0) |
|
for i, date := range dates { |
|
types := maps.Values(data[date]) |
|
typesTotal := 0 |
|
for _, kb := range types { |
|
typesTotal += max(kb-gbToKb(allotted), 0) |
|
} |
|
|
|
monthTotal += typesTotal |
|
|
|
today, _ := time.Parse(time.DateOnly, dates[i]) |
|
var tomorrow time.Time |
|
if len(dates) == i+1 { |
|
tomorrow = today.AddDate(0, 1, 0) |
|
} else { |
|
tomorrow, _ = time.Parse(time.DateOnly, dates[i+1]) |
|
} |
|
|
|
if today.Month() != tomorrow.Month() { |
|
// If it's the last day of the month (that we have data), add a datapoint |
|
items = append(items, opts.LineData{Value: []interface{}{date, kbToGb(monthTotal)}}) |
|
// and reset the counter |
|
monthTotal = 0 |
|
} else { |
|
items = append(items, opts.LineData{Value: []interface{}{date, nil}}) |
|
} |
|
|
|
} |
|
|
|
return items |
|
} |
|
|
|
// Common line chart base. |
|
func createLineChart(project string, title opts.Title) *charts.Line { |
|
line := charts.NewLine() |
|
|
|
line.SetGlobalOptions( |
|
charts.WithTitleOpts(title), |
|
charts.WithInitializationOpts(opts.Initialization{ |
|
PageTitle: fmt.Sprintf("%s | Lagoon Storage", project), |
|
Width: "2400px", |
|
Height: "500px", |
|
}), |
|
charts.WithYAxisOpts(opts.YAxis{ |
|
Name: "Use (GB)", |
|
NameLocation: "center", |
|
Type: "value", |
|
}), |
|
charts.WithXAxisOpts(opts.XAxis{ |
|
Type: "time", |
|
}), |
|
charts.WithDataZoomOpts(opts.DataZoom{ |
|
Type: "slider", |
|
}), |
|
charts.WithTooltipOpts(opts.Tooltip{Show: true, Trigger: "axis"}), |
|
) |
|
|
|
return line |
|
} |
|
|
|
// Generate a chart summing all environments. |
|
func allEnvsChart(resp ApiResponse, dates []string) *charts.Line { |
|
types := []string{} |
|
calcData := CalculatedData{} |
|
for _, env := range resp.Data.ProjectByName.Environments { |
|
for _, stor := range env.Storages { |
|
if !slices.Contains(types, stor.Type) { |
|
types = append(types, stor.Type) |
|
} |
|
|
|
_, dateExists := calcData[stor.Updated] |
|
if !dateExists { |
|
calcData[stor.Updated] = map[string]int{} |
|
} |
|
|
|
calcData[stor.Updated][stor.Type] += stor.KiloBytes |
|
} |
|
} |
|
|
|
line := createLineChart(resp.Data.ProjectByName.Name, opts.Title{ |
|
Title: fmt.Sprintf("%s\n%s — %s", resp.Data.ProjectByName.Name, dates[0], dates[len(dates)-1]), |
|
Subtitle: "All Environments", |
|
}) |
|
|
|
for _, Type := range types { |
|
line.AddSeries(Type, generateTypeLine(calcData, dates, Type), |
|
charts.WithLineStyleOpts(opts.LineStyle{ |
|
Type: "dashed", |
|
})) |
|
} |
|
|
|
line. |
|
AddSeries("Daily Total", generateTotalLine(calcData, dates)). |
|
AddSeries("30 Day Avg", generateAverageLine(calcData, dates, 30)). |
|
AddSeries("Month End Avg", generateMonthAverageLine(calcData, dates), |
|
charts.WithLineChartOpts(opts.LineChart{ |
|
ConnectNulls: true, |
|
ShowSymbol: true, |
|
}), |
|
charts.WithLineStyleOpts(opts.LineStyle{ |
|
Width: 5, |
|
})) |
|
|
|
if len(os.Args) > 2 { |
|
includedFreeStorage, _ := strconv.ParseFloat(os.Args[2], 64) |
|
line. |
|
AddSeries("Month End Excess", generateMonthTotalLine(calcData, dates, includedFreeStorage), |
|
charts.WithLineChartOpts(opts.LineChart{ |
|
ConnectNulls: true, |
|
ShowSymbol: true, |
|
})) |
|
|
|
} |
|
|
|
return line |
|
} |
|
|
|
// Generate a chart for one environment. |
|
func envChart(resp ApiResponse, dates []string, env Environment) *charts.Line { |
|
types := []string{} |
|
calcData := CalculatedData{} |
|
for _, stor := range env.Storages { |
|
if !slices.Contains(types, stor.Type) { |
|
types = append(types, stor.Type) |
|
} |
|
|
|
_, dateExists := calcData[stor.Updated] |
|
if !dateExists { |
|
calcData[stor.Updated] = map[string]int{} |
|
} |
|
|
|
calcData[stor.Updated][stor.Type] += stor.KiloBytes |
|
} |
|
|
|
line := createLineChart(resp.Data.ProjectByName.Name, opts.Title{ |
|
Title: env.Name, |
|
}) |
|
|
|
for _, Type := range types { |
|
line.AddSeries(Type, generateTypeLine(calcData, dates, Type), |
|
charts.WithLineStyleOpts(opts.LineStyle{ |
|
Type: "dashed", |
|
})) |
|
} |
|
|
|
line.AddSeries("Total", generateTotalLine(calcData, dates)) |
|
|
|
return line |
|
} |
|
|
|
func main() { |
|
|
|
if len(os.Args) < 2 { |
|
fmt.Println("Usage: go run . <filename> <included storage in GB>") |
|
return |
|
} |
|
|
|
loadFile := os.Args[1] |
|
fmt.Println("Generating charts from " + loadFile) |
|
|
|
content, err := os.ReadFile(loadFile) |
|
if err != nil { |
|
log.Fatal("Error when opening file: ", err) |
|
} |
|
|
|
var resp ApiResponse |
|
err = json.Unmarshal(content, &resp) |
|
if err != nil { |
|
fmt.Printf("Could not unmarshal json: %s\n", err) |
|
return |
|
} |
|
|
|
// Generate a list of all dates with recorded storage. |
|
datesMap := map[string]bool{} |
|
for _, env := range resp.Data.ProjectByName.Environments { |
|
for _, stor := range env.Storages { |
|
_, dateExists := datesMap[stor.Updated] |
|
if !dateExists { |
|
datesMap[stor.Updated] = true |
|
} |
|
} |
|
} |
|
|
|
dates := maps.Keys(datesMap) |
|
sort.Strings(dates) |
|
|
|
page := components.NewPage() |
|
page.SetLayout(components.Layout(components.PageCenterLayout)) |
|
|
|
page.AddCharts(allEnvsChart(resp, dates)) |
|
|
|
for _, env := range resp.Data.ProjectByName.Environments { |
|
page.AddCharts(envChart(resp, dates, env)) |
|
} |
|
|
|
saveFile := fmt.Sprintf("storage-%s.html", resp.Data.ProjectByName.Name) |
|
f, _ := os.Create(saveFile) |
|
_ = page.Render(f) |
|
|
|
fmt.Println("Charts saved as " + saveFile) |
|
} |