diff --git a/app/routes/account.go b/app/routes/account.go index 20f16c9..e7e7d7a 100644 --- a/app/routes/account.go +++ b/app/routes/account.go @@ -2,6 +2,7 @@ package routes import ( "fmt" + "math" "net/http" "proxmoxaas-dashboard/app/common" @@ -17,6 +18,68 @@ type Account struct { Min int Max int } + Resources map[string]any +} + +type Constraint struct { + Max int64 + Used int64 + Avail int64 +} + +type Match struct { + Name string + Match string + Max int64 + Used int64 + Avail int64 +} + +type NumericResource struct { + Type string + Name string + Multiplier int64 + Base int64 + Compact bool + Unit string + Display bool + Global Constraint + Nodes map[string]Constraint + Total Constraint +} + +type StorageResource struct { + Type string + Name string + Multiplier int64 + Base int64 + Compact bool + Unit string + Display bool + Disks []string + Global Constraint + Nodes map[string]Constraint + Total Constraint +} + +type ListResource struct { + Type string + Whitelist bool + Display bool + Global []Match + Nodes map[string][]Match + Total []Match +} + +type ResourceChart struct { + Type string + Display bool + Name string + Used int64 + Max int64 + Avail float64 + Prefix string + Unit string } func HandleGETAccount(c *gin.Context) { @@ -28,6 +91,58 @@ func HandleGETAccount(c *gin.Context) { return } + for k, v := range account.Resources { + switch t := v.(type) { + case NumericResource: + avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base) + account.Resources[k] = ResourceChart{ + Type: t.Type, + Display: t.Display, + Name: t.Name, + Used: t.Total.Used, + Max: t.Total.Max, + Avail: avail, + Prefix: prefix, + Unit: t.Unit, + } + case StorageResource: + avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base) + account.Resources[k] = ResourceChart{ + Type: t.Type, + Display: t.Display, + Name: t.Name, + Used: t.Total.Used, + Max: t.Total.Max, + Avail: avail, + Prefix: prefix, + Unit: t.Unit, + } + case ListResource: + l := struct { + Type string + Display bool + Resources []ResourceChart + }{ + Type: t.Type, + Display: t.Display, + Resources: []ResourceChart{}, + } + + for _, r := range t.Total { + l.Resources = append(l.Resources, ResourceChart{ + Type: t.Type, + Display: t.Display, + Name: r.Name, + Used: r.Used, + Max: r.Max, + Avail: float64(r.Avail), // usually an int + Unit: "", + }) + } + account.Resources[k] = l + } + } + c.HTML(http.StatusOK, "html/account.html", gin.H{ "global": common.Global, "page": "account", @@ -39,7 +154,9 @@ func HandleGETAccount(c *gin.Context) { } func GetUserAccount(username string, token string, csrf string) (Account, error) { - account := Account{} + account := Account{ + Resources: map[string]any{}, + } ctx := common.RequestContext{ Cookies: map[string]string{ @@ -49,6 +166,8 @@ func GetUserAccount(username string, token string, csrf string) (Account, error) }, Body: map[string]any{}, } + + // get user account basic data res, code, err := common.RequestGetAPI("/user/config/cluster", ctx) if err != nil { return account, err @@ -56,12 +175,89 @@ func GetUserAccount(username string, token string, csrf string) (Account, error) if code != 200 { return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res) } - err = mapstructure.Decode(ctx.Body, &account) if err != nil { return account, err } else { account.Username = username - return account, nil + } + + ctx.Body = map[string]any{} + // get user resources + res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx) + if err != nil { + return account, err + } + if code != 200 { + return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res) + } + resources := ctx.Body + + ctx.Body = map[string]any{} + // get resource meta data + res, code, err = common.RequestGetAPI("/global/config/resources", ctx) + if err != nil { + return account, err + } + if code != 200 { + return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res) + } + meta := ctx.Body["resources"].(map[string]any) + + // build each resource by its meta type + for k, v := range meta { + m := v.(map[string]any) + t := m["type"].(string) + r := resources[k].(map[string]any) + if t == "numeric" { + n := NumericResource{} + n.Type = t + err_m := mapstructure.Decode(m, &n) + err_r := mapstructure.Decode(r, &n) + if err_m != nil || err_r != nil { + return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error()) + } + account.Resources[k] = n + } else if t == "storage" { + n := StorageResource{} + n.Type = t + err_m := mapstructure.Decode(m, &n) + err_r := mapstructure.Decode(r, &n) + if err_m != nil || err_r != nil { + return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error()) + } + account.Resources[k] = n + } else if t == "list" { + n := ListResource{} + n.Type = t + err_m := mapstructure.Decode(m, &n) + err_r := mapstructure.Decode(r, &n) + if err_m != nil || err_r != nil { + return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error()) + } + account.Resources[k] = n + } + } + + return account, nil +} + +func FormatNumber(val int64, base int64) (float64, string) { + valf := float64(val) + basef := float64(base) + steps := 0 + for math.Abs(valf) > basef && steps < 4 { + valf /= basef + steps++ + } + + if base == 1000 { + prefixes := []string{"", "K", "M", "G", "T"} + return valf, prefixes[steps] + } else if base == 1024 { + prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"} + return valf, prefixes[steps] + } else { + return 0, "" } } diff --git a/web/html/account.html b/web/html/account.html index e91ece7..eeb74d4 100644 --- a/web/html/account.html +++ b/web/html/account.html @@ -3,8 +3,6 @@ {{template "head" .}} - - - -
-
- -
-
-
- `; - this.responsiveStyle = this.shadowRoot.querySelector("#responsive-style"); - this.canvas = this.shadowRoot.querySelector("canvas"); - this.caption = this.shadowRoot.querySelector("figcaption"); - } - - set data (data) { - for (const line of data.title) { - this.caption.innerHTML += `${line}`; - } - - this.canvas.role = "img"; - this.canvas.ariaLabel = data.ariaLabel; - - const chartData = { - type: "pie", - data: data.data, - options: { - plugins: { - title: { - display: false - }, - legend: { - display: false - }, - tooltip: { - enabled: true - } - }, - interaction: { - mode: "nearest" - }, - onHover: function (e, activeElements) { - if (window.innerWidth <= data.breakpoint) { - updateTooltipShow(e.chart, false); - } - else { - updateTooltipShow(e.chart, true); - } - } - } - }; - - this.chart = new window.Chart(this.canvas, chartData); - - if (data.breakpoint) { - this.responsiveStyle.media = `screen and (width <= ${data.breakpoint}px)`; - } - else { - this.responsiveStyle.media = "not all"; - } - } - - get data () { - return null; - } -} - -// this is a really bad way to do this, but chartjs api does not expose many ways to dynamically set hover and tooltip options -function updateTooltipShow (chart, enabled) { - chart.options.plugins.tooltip.enabled = enabled; - chart.options.interaction.mode = enabled ? "nearest" : null; - chart.update(); -} - -customElements.define("resource-chart", ResourceChart); - window.addEventListener("DOMContentLoaded", init); -const prefixes = { - 1024: [ - "", - "Ki", - "Mi", - "Gi", - "Ti" - ], - 1000: [ - "", - "K", - "M", - "G", - "T" - ] -}; - async function init () { setAppearance(); - let resources = requestAPI("/user/dynamic/resources"); - let meta = requestAPI("/global/config/resources"); - - resources = await resources; - meta = (await meta).resources; - - populateResources("#resource-container", meta, resources); - document.querySelector("#change-password").addEventListener("click", handlePasswordChangeForm); } -function populateResources (containerID, meta, resources) { - if (resources instanceof Object) { - const container = document.querySelector(containerID); - Object.keys(meta).forEach((resourceType) => { - if (meta[resourceType].display) { - if (meta[resourceType].type === "list") { - resources[resourceType].total.forEach((listResource) => { - createResourceUsageChart(container, listResource.name, listResource.avail, listResource.used, listResource.max, null); - }); - } - else { - createResourceUsageChart(container, meta[resourceType].name, resources[resourceType].total.avail, resources[resourceType].total.used, resources[resourceType].total.max, meta[resourceType]); - } - } - }); - } -} - -function createResourceUsageChart (container, resourceName, resourceAvail, resourceUsed, resourceMax, resourceUnitData) { - const chart = document.createElement("resource-chart"); - container.append(chart); - const maxStr = parseNumber(resourceMax, resourceUnitData); - const usedStr = parseNumber(resourceUsed, resourceUnitData); - const usedRatio = resourceUsed / resourceMax; - const R = Math.min(usedRatio * 510, 255); - const G = Math.min((1 - usedRatio) * 510, 255); - const usedColor = `rgb(${R}, ${G}, 0)`; - chart.data = { - title: [resourceName, `Used ${usedStr} of ${maxStr}`], - ariaLabel: `${resourceName} used ${usedStr} of ${maxStr}`, - data: { - labels: [ - "Used", - "Available" - ], - datasets: [{ - label: resourceName, - data: [resourceUsed, resourceAvail], - backgroundColor: [ - usedColor, - "rgb(140, 140, 140)" - ], - borderWidth: 0, - hoverOffset: 4 - }] - }, - breakpoint: 680 - }; - chart.style = "margin: 10px;"; -} - -function parseNumber (value, unitData) { - if (!unitData) { - return `${value}`; - } - const compact = unitData.compact; - const multiplier = unitData.multiplier; - const base = unitData.base; - const unit = unitData.unit; - value = multiplier * value; - if (value <= 0) { - return `0 ${unit}`; - } - else if (compact) { - const exponent = Math.floor(Math.log(value) / Math.log(base)); - value = value / base ** exponent; - const unitPrefix = prefixes[base][exponent]; - return `${value} ${unitPrefix}${unit}`; - } - else { - return `${value} ${unit}`; - } -} - function handlePasswordChangeForm () { const body = `
diff --git a/web/templates/resource-chart.tmpl b/web/templates/resource-chart.tmpl new file mode 100644 index 0000000..44e4fd1 --- /dev/null +++ b/web/templates/resource-chart.tmpl @@ -0,0 +1,47 @@ +{{define "resource-chart"}} + + + +{{end}}- \ No newline at end of file