{{ .Page.Title }}
diff --git a/internal/glance/templates/search.html b/internal/glance/templates/search.html index 6e8fc43..ae981c6 100644 --- a/internal/glance/templates/search.html +++ b/internal/glance/templates/search.html @@ -3,7 +3,7 @@ {{ define "widget-content-classes" }}widget-content-frameless{{ end }} {{ define "widget-content" }} -
+
{{ range .Bangs }}
diff --git a/internal/glance/templates/videos-vertical-list.html b/internal/glance/templates/videos-vertical-list.html
index b7ea6b2..a735a74 100644
--- a/internal/glance/templates/videos-vertical-list.html
+++ b/internal/glance/templates/videos-vertical-list.html
@@ -6,7 +6,7 @@
-
- {{ .Title }}
+ {{ .Title }}
-
diff --git a/internal/glance/utils.go b/internal/glance/utils.go
index 8455bfe..2f76965 100644
--- a/internal/glance/utils.go
+++ b/internal/glance/utils.go
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"html/template"
+ "math"
"net/http"
"net/url"
"os"
@@ -119,14 +120,6 @@ func parseRFC3339Time(t string) time.Time {
return parsed
}
-func boolToString(b bool, trueValue, falseValue string) string {
- if b {
- return trueValue
- }
-
- return falseValue
-}
-
func normalizeVersionFormat(version string) string {
version = strings.ToLower(strings.TrimSpace(version))
@@ -186,3 +179,62 @@ func ternary[T any](condition bool, a, b T) T {
return b
}
+
+// Having compile time errors about unused variables is cool and all, but I don't want to
+// have to constantly comment out my code while I'm working on it and testing things out
+func ItsUsedTrustMeBro(...any) {}
+
+func hslToHex(h, s, l float64) string {
+ s /= 100.0
+ l /= 100.0
+
+ var r, g, b float64
+
+ if s == 0 {
+ r, g, b = l, l, l
+ } else {
+ hueToRgb := func(p, q, t float64) float64 {
+ if t < 0 {
+ t += 1
+ }
+ if t > 1 {
+ t -= 1
+ }
+ if t < 1.0/6.0 {
+ return p + (q-p)*6.0*t
+ }
+ if t < 1.0/2.0 {
+ return q
+ }
+ if t < 2.0/3.0 {
+ return p + (q-p)*(2.0/3.0-t)*6.0
+ }
+ return p
+ }
+
+ q := 0.0
+ if l < 0.5 {
+ q = l * (1 + s)
+ } else {
+ q = l + s - l*s
+ }
+
+ p := 2*l - q
+
+ h /= 360.0
+
+ r = hueToRgb(p, q, h+1.0/3.0)
+ g = hueToRgb(p, q, h)
+ b = hueToRgb(p, q, h-1.0/3.0)
+ }
+
+ ir := int(math.Round(r * 255.0))
+ ig := int(math.Round(g * 255.0))
+ ib := int(math.Round(b * 255.0))
+
+ ir = int(math.Max(0, math.Min(255, float64(ir))))
+ ig = int(math.Max(0, math.Min(255, float64(ig))))
+ ib = int(math.Max(0, math.Min(255, float64(ib))))
+
+ return fmt.Sprintf("#%02x%02x%02x", ir, ig, ib)
+}
diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go
index 32f47b4..3c58b7e 100644
--- a/internal/glance/widget-custom-api.go
+++ b/internal/glance/widget-custom-api.go
@@ -3,6 +3,7 @@ package glance
import (
"bytes"
"context"
+ "encoding/json"
"errors"
"fmt"
"html/template"
@@ -10,6 +11,9 @@ import (
"log/slog"
"math"
"net/http"
+ "regexp"
+ "sort"
+ "strconv"
"strings"
"sync"
"time"
@@ -21,10 +25,16 @@ var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.
// Needs to be exported for the YAML unmarshaler to work
type CustomAPIRequest struct {
- URL string `json:"url"`
- Headers map[string]string `json:"headers"`
- Parameters queryParametersField `json:"parameters"`
- httpRequest *http.Request `yaml:"-"`
+ URL string `yaml:"url"`
+ AllowInsecure bool `yaml:"allow-insecure"`
+ Headers map[string]string `yaml:"headers"`
+ Parameters queryParametersField `yaml:"parameters"`
+ Method string `yaml:"method"`
+ BodyType string `yaml:"body-type"`
+ Body any `yaml:"body"`
+ SkipJSONValidation bool `yaml:"skip-json-validation"`
+ bodyReader io.ReadSeeker `yaml:"-"`
+ httpRequest *http.Request `yaml:"-"`
}
type customAPIWidget struct {
@@ -82,7 +92,41 @@ func (req *CustomAPIRequest) initialize() error {
return errors.New("URL is required")
}
- httpReq, err := http.NewRequest(http.MethodGet, req.URL, nil)
+ if req.Body != nil {
+ if req.Method == "" {
+ req.Method = http.MethodPost
+ }
+
+ if req.BodyType == "" {
+ req.BodyType = "json"
+ }
+
+ if req.BodyType != "json" && req.BodyType != "string" {
+ return errors.New("invalid body type, must be either 'json' or 'string'")
+ }
+
+ switch req.BodyType {
+ case "json":
+ encoded, err := json.Marshal(req.Body)
+ if err != nil {
+ return fmt.Errorf("marshaling body: %v", err)
+ }
+
+ req.bodyReader = bytes.NewReader(encoded)
+ case "string":
+ bodyAsString, ok := req.Body.(string)
+ if !ok {
+ return errors.New("body must be a string when body-type is 'string'")
+ }
+
+ req.bodyReader = strings.NewReader(bodyAsString)
+ }
+
+ } else if req.Method == "" {
+ req.Method = http.MethodGet
+ }
+
+ httpReq, err := http.NewRequest(strings.ToUpper(req.Method), req.URL, req.bodyReader)
if err != nil {
return err
}
@@ -91,6 +135,10 @@ func (req *CustomAPIRequest) initialize() error {
httpReq.URL.RawQuery = req.Parameters.toQueryString()
}
+ if req.BodyType == "json" {
+ httpReq.Header.Set("Content-Type", "application/json")
+ }
+
for key, value := range req.Headers {
httpReq.Header.Add(key, value)
}
@@ -110,6 +158,17 @@ type customAPITemplateData struct {
subrequests map[string]*customAPIResponseData
}
+func (data *customAPITemplateData) JSONLines() []decoratedGJSONResult {
+ result := make([]decoratedGJSONResult, 0, 5)
+
+ gjson.ForEachLine(data.JSON.Raw, func(line gjson.Result) bool {
+ result = append(result, decoratedGJSONResult{line})
+ return true
+ })
+
+ return result
+}
+
func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData {
req, exists := data.subrequests[key]
if !exists {
@@ -125,7 +184,12 @@ func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData
}
func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
- resp, err := defaultHTTPClient.Do(req.httpRequest.WithContext(ctx))
+ if req.bodyReader != nil {
+ req.bodyReader.Seek(0, io.SeekStart)
+ }
+
+ client := ternary(req.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
+ resp, err := client.Do(req.httpRequest.WithContext(ctx))
if err != nil {
return nil, err
}
@@ -138,14 +202,19 @@ func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customA
body := strings.TrimSpace(string(bodyBytes))
- if body != "" && !gjson.Valid(body) {
- truncatedBody, isTruncated := limitStringLength(body, 100)
- if isTruncated {
- truncatedBody += "...
" + if !req.SkipJSONValidation && body != "" && !gjson.Valid(body) { + if 200 <= resp.StatusCode && resp.StatusCode < 300 { + truncatedBody, isTruncated := limitStringLength(body, 100) + if isTruncated { + truncatedBody += "... " + } + + slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody) + return nil, errors.New("invalid response JSON") } - slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody) - return nil, errors.New("invalid response JSON") + return nil, errors.New(fmt.Sprintf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode))) + } data := &customAPIResponseData{ @@ -247,7 +316,7 @@ func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedG } func (r *decoratedGJSONResult) Exists(key string) bool { - return r.Get(key).Exists() + return r.Result.Get(key).Exists() } func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult { @@ -255,7 +324,7 @@ func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult { return gJsonResultArrayToDecoratedResultArray(r.Result.Array()) } - return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array()) + return gJsonResultArrayToDecoratedResultArray(r.Result.Get(key).Array()) } func (r *decoratedGJSONResult) String(key string) string { @@ -263,15 +332,15 @@ func (r *decoratedGJSONResult) String(key string) string { return r.Result.String() } - return r.Get(key).String() + return r.Result.Get(key).String() } -func (r *decoratedGJSONResult) Int(key string) int64 { +func (r *decoratedGJSONResult) Int(key string) int { if key == "" { - return r.Result.Int() + return int(r.Result.Int()) } - return r.Get(key).Int() + return int(r.Result.Get(key).Int()) } func (r *decoratedGJSONResult) Float(key string) float64 { @@ -279,7 +348,7 @@ func (r *decoratedGJSONResult) Float(key string) float64 { return r.Result.Float() } - return r.Get(key).Float() + return r.Result.Get(key).Float() } func (r *decoratedGJSONResult) Bool(key string) bool { @@ -287,55 +356,219 @@ func (r *decoratedGJSONResult) Bool(key string) bool { return r.Result.Bool() } - return r.Get(key).Bool() + return r.Result.Get(key).Bool() +} + +func (r *decoratedGJSONResult) Get(key string) *decoratedGJSONResult { + return &decoratedGJSONResult{r.Result.Get(key)} +} + +func customAPIDoMathOp[T int | float64](a, b T, op string) T { + switch op { + case "add": + return a + b + case "sub": + return a - b + case "mul": + return a * b + case "div": + if b == 0 { + return 0 + } + return a / b + } + return 0 } var customAPITemplateFuncs = func() template.FuncMap { - funcs := template.FuncMap{ - "toFloat": func(a int64) float64 { - return float64(a) - }, - "toInt": func(a float64) int64 { - return int64(a) - }, - "add": func(a, b float64) float64 { - return a + b - }, - "sub": func(a, b float64) float64 { - return a - b - }, - "mul": func(a, b float64) float64 { - return a * b - }, - "div": func(a, b float64) float64 { - if b == 0 { + var regexpCacheMu sync.Mutex + var regexpCache = make(map[string]*regexp.Regexp) + + getCachedRegexp := func(pattern string) *regexp.Regexp { + regexpCacheMu.Lock() + defer regexpCacheMu.Unlock() + + regex, exists := regexpCache[pattern] + if !exists { + regex = regexp.MustCompile(pattern) + regexpCache[pattern] = regex + } + + return regex + } + + doMathOpWithAny := func(a, b any, op string) any { + switch at := a.(type) { + case int: + switch bt := b.(type) { + case int: + return customAPIDoMathOp(at, bt, op) + case float64: + return customAPIDoMathOp(float64(at), bt, op) + default: return math.NaN() } + case float64: + switch bt := b.(type) { + case int: + return customAPIDoMathOp(at, float64(bt), op) + case float64: + return customAPIDoMathOp(at, bt, op) + default: + return math.NaN() + } + default: + return math.NaN() + } + } - return a / b + funcs := template.FuncMap{ + "toFloat": func(a int) float64 { + return float64(a) + }, + "toInt": func(a float64) int { + return int(a) + }, + "add": func(a, b any) any { + return doMathOpWithAny(a, b, "add") + }, + "sub": func(a, b any) any { + return doMathOpWithAny(a, b, "sub") + }, + "mul": func(a, b any) any { + return doMathOpWithAny(a, b, "mul") + }, + "div": func(a, b any) any { + return doMathOpWithAny(a, b, "div") + }, + "now": func() time.Time { + return time.Now() + }, + "offsetNow": func(offset string) time.Time { + d, err := time.ParseDuration(offset) + if err != nil { + return time.Now() + } + return time.Now().Add(d) + }, + "duration": func(str string) time.Duration { + d, err := time.ParseDuration(str) + if err != nil { + return 0 + } + + return d }, "parseTime": func(layout, value string) time.Time { - switch strings.ToLower(layout) { - case "rfc3339": - layout = time.RFC3339 - case "rfc3339nano": - layout = time.RFC3339Nano - case "datetime": - layout = time.DateTime - case "dateonly": - layout = time.DateOnly - case "timeonly": - layout = time.TimeOnly - } - - parsed, err := time.Parse(layout, value) - if err != nil { - return time.Unix(0, 0) - } - - return parsed + return customAPIFuncParseTimeInLocation(layout, value, time.UTC) + }, + "parseLocalTime": func(layout, value string) time.Time { + return customAPIFuncParseTimeInLocation(layout, value, time.Local) }, "toRelativeTime": dynamicRelativeTimeAttrs, + "parseRelativeTime": func(layout, value string) template.HTMLAttr { + // Shorthand to do both of the above with a single function call + return dynamicRelativeTimeAttrs(customAPIFuncParseTimeInLocation(layout, value, time.UTC)) + }, + // The reason we flip the parameter order is so that you can chain multiple calls together like this: + // {{ .JSON.String "foo" | trimPrefix "bar" | doSomethingElse }} + // instead of doing this: + // {{ trimPrefix (.JSON.String "foo") "bar" | doSomethingElse }} + // since the piped value gets passed as the last argument to the function. + "trimPrefix": func(prefix, s string) string { + return strings.TrimPrefix(s, prefix) + }, + "trimSuffix": func(suffix, s string) string { + return strings.TrimSuffix(s, suffix) + }, + "trimSpace": strings.TrimSpace, + "replaceAll": func(old, new, s string) string { + return strings.ReplaceAll(s, old, new) + }, + "replaceMatches": func(pattern, replacement, s string) string { + if s == "" { + return "" + } + + return getCachedRegexp(pattern).ReplaceAllString(s, replacement) + }, + "findMatch": func(pattern, s string) string { + if s == "" { + return "" + } + + return getCachedRegexp(pattern).FindString(s) + }, + "findSubmatch": func(pattern, s string) string { + if s == "" { + return "" + } + + regex := getCachedRegexp(pattern) + return itemAtIndexOrDefault(regex.FindStringSubmatch(s), 1, "") + }, + "sortByString": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult { + sort.Slice(results, func(a, b int) bool { + if order == "asc" { + return results[a].String(key) < results[b].String(key) + } + + return results[a].String(key) > results[b].String(key) + }) + + return results + }, + "sortByInt": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult { + sort.Slice(results, func(a, b int) bool { + if order == "asc" { + return results[a].Int(key) < results[b].Int(key) + } + + return results[a].Int(key) > results[b].Int(key) + }) + + return results + }, + "sortByFloat": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult { + sort.Slice(results, func(a, b int) bool { + if order == "asc" { + return results[a].Float(key) < results[b].Float(key) + } + + return results[a].Float(key) > results[b].Float(key) + }) + + return results + }, + "sortByTime": func(key, layout, order string, results []decoratedGJSONResult) []decoratedGJSONResult { + sort.Slice(results, func(a, b int) bool { + timeA := customAPIFuncParseTimeInLocation(layout, results[a].String(key), time.UTC) + timeB := customAPIFuncParseTimeInLocation(layout, results[b].String(key), time.UTC) + + if order == "asc" { + return timeA.Before(timeB) + } + + return timeA.After(timeB) + }) + + return results + }, + "concat": func(items ...string) string { + return strings.Join(items, "") + }, + "unique": func(key string, results []decoratedGJSONResult) []decoratedGJSONResult { + seen := make(map[string]struct{}) + out := make([]decoratedGJSONResult, 0, len(results)) + for _, result := range results { + val := result.String(key) + if _, ok := seen[val]; !ok { + seen[val] = struct{}{} + out = append(out, result) + } + } + return out + }, } for key, value := range globalTemplateFunctions { @@ -346,3 +579,30 @@ var customAPITemplateFuncs = func() template.FuncMap { return funcs }() + +func customAPIFuncParseTimeInLocation(layout, value string, loc *time.Location) time.Time { + switch strings.ToLower(layout) { + case "unix": + asInt, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return time.Unix(0, 0) + } + + return time.Unix(asInt, 0) + case "rfc3339": + layout = time.RFC3339 + case "rfc3339nano": + layout = time.RFC3339Nano + case "datetime": + layout = time.DateTime + case "dateonly": + layout = time.DateOnly + } + + parsed, err := time.ParseInLocation(layout, value, loc) + if err != nil { + return time.Unix(0, 0) + } + + return parsed +} diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go index 68b6ac2..7311b1b 100644 --- a/internal/glance/widget-dns-stats.go +++ b/internal/glance/widget-dns-stats.go @@ -1,14 +1,18 @@ package glance import ( + "bytes" "context" "encoding/json" "errors" + "fmt" "html/template" + "io" "log/slog" "net/http" "sort" "strings" + "sync" "time" ) @@ -23,8 +27,9 @@ const ( type dnsStatsWidget struct { widgetBase `yaml:",inline"` - TimeLabels [8]string `yaml:"-"` - Stats *dnsStats `yaml:"-"` + TimeLabels [8]string `yaml:"-"` + Stats *dnsStats `yaml:"-"` + piholeSessionID string `yaml:"-"` HourFormat string `yaml:"hour-format"` HideGraph bool `yaml:"hide-graph"` @@ -37,11 +42,18 @@ type dnsStatsWidget struct { Password string `yaml:"password"` } +const ( + dnsServiceAdguard = "adguard" + dnsServicePihole = "pihole" + dnsServiceTechnitium = "technitium" + dnsServicePiholeV6 = "pihole-v6" +) + func makeDNSWidgetTimeLabels(format string) [8]string { now := time.Now() - var labels [8]string + var labels [dnsStatsBars]string - for h := 24; h > 0; h -= 3 { + for h := dnsStatsHoursSpan; h > 0; h -= dnsStatsHoursPerBar { labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format)) } @@ -49,17 +61,24 @@ func makeDNSWidgetTimeLabels(format string) [8]string { } func (widget *dnsStatsWidget) initialize() error { + titleURL := strings.TrimRight(widget.URL, "/") + switch widget.Service { + case dnsServicePihole, dnsServicePiholeV6: + titleURL = titleURL + "/admin" + } + widget. withTitle("DNS Stats"). - withTitleURL(string(widget.URL)). + withTitleURL(titleURL). withCacheDuration(10 * time.Minute) switch widget.Service { - case "adguard": - case "pihole": - case "technitium": + case dnsServiceAdguard: + case dnsServicePiholeV6: + case dnsServicePihole: + case dnsServiceTechnitium: default: - return errors.New("service must be either 'adguard', 'pihole', or 'technitium'") + return fmt.Errorf("service must be one of: %s, %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6, dnsServiceTechnitium) } return nil @@ -70,12 +89,25 @@ func (widget *dnsStatsWidget) update(ctx context.Context) { var err error switch widget.Service { - case "adguard": + case dnsServiceAdguard: stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph) - case "pihole": - stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) - case "technitium": + case dnsServicePihole: + stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) + case dnsServiceTechnitium: stats, err = fetchTechnitiumStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) + case dnsServicePiholeV6: + var newSessionID string + stats, newSessionID, err = fetchPiholeStats( + widget.URL, + widget.AllowInsecure, + widget.Password, + widget.piholeSessionID, + !widget.HideGraph, + !widget.HideTopDomains, + ) + if err == nil { + widget.piholeSessionID = newSessionID + } } if !widget.canContinueUpdateAfterHandlingErr(err) { @@ -97,11 +129,11 @@ func (widget *dnsStatsWidget) Render() template.HTML { type dnsStats struct { TotalQueries int - BlockedQueries int + BlockedQueries int // we don't actually use this anywhere in templates, maybe remove it later? BlockedPercent int ResponseTime int DomainsBlocked int - Series [8]dnsStatsSeries + Series [dnsStatsBars]dnsStatsSeries TopBlockedDomains []dnsStatsBlockedDomain } @@ -136,13 +168,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor request.SetBasicAuth(username, password) - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient - } - + var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request) if err != nil { return nil, err @@ -163,7 +189,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) - for i := 0; i < topBlockedDomainsCount; i++ { + for i := range topBlockedDomainsCount { domain := responseJson.TopBlockedDomains[i] var firstDomain string @@ -206,11 +232,11 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor maxQueriesInSeries := 0 - for i := 0; i < dnsStatsBars; i++ { + for i := range dnsStatsBars { queries := 0 blocked := 0 - for j := 0; j < dnsStatsHoursPerBar; j++ { + for j := range dnsStatsHoursPerBar { queries += queriesSeries[i*dnsStatsHoursPerBar+j] blocked += blockedSeries[i*dnsStatsHoursPerBar+j] } @@ -229,35 +255,36 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor } } - for i := 0; i < dnsStatsBars; i++ { + for i := range dnsStatsBars { stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) } return stats, nil } -type piholeStatsResponse struct { - TotalQueries int `json:"dns_queries_today"` - QueriesSeries piholeQueriesSeries `json:"domains_over_time"` - BlockedQueries int `json:"ads_blocked_today"` - BlockedSeries map[int64]int `json:"ads_over_time"` - BlockedPercentage float64 `json:"ads_percentage_today"` - TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"` - DomainsBlocked int `json:"domains_being_blocked"` +// Legacy Pi-hole stats response (before v6) +type pihole5StatsResponse struct { + TotalQueries int `json:"dns_queries_today"` + QueriesSeries pihole5QueriesSeries `json:"domains_over_time"` + BlockedQueries int `json:"ads_blocked_today"` + BlockedSeries map[int64]int `json:"ads_over_time"` + BlockedPercentage float64 `json:"ads_percentage_today"` + TopBlockedDomains pihole5TopBlockedDomains `json:"top_ads"` + DomainsBlocked int `json:"domains_being_blocked"` } // If the user has query logging disabled it's possible for domains_over_time to be returned as an // empty array rather than a map which will prevent unmashalling the rest of the data so we use // custom unmarshal behavior to fallback to an empty map. // See https://github.com/glanceapp/glance/issues/289 -type piholeQueriesSeries map[int64]int +type pihole5QueriesSeries map[int64]int -func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error { +func (p *pihole5QueriesSeries) UnmarshalJSON(data []byte) error { temp := make(map[int64]int) err := json.Unmarshal(data, &temp) if err != nil { - *p = make(piholeQueriesSeries) + *p = make(pihole5QueriesSeries) } else { *p = temp } @@ -267,16 +294,16 @@ func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error { // If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array // Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling -type piholeTopBlockedDomains map[string]int +type pihole5TopBlockedDomains map[string]int -func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { +func (p *pihole5TopBlockedDomains) UnmarshalJSON(data []byte) error { // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow // because of the UnmarshalJSON method getting called recursively temp := make(map[string]int) err := json.Unmarshal(data, &temp) if err != nil { - *p = make(piholeTopBlockedDomains) + *p = make(pihole5TopBlockedDomains) } else { *p = temp } @@ -284,7 +311,7 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { return nil } -func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { +func fetchPihole5Stats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) { if token == "" { return nil, errors.New("missing API token") } @@ -297,14 +324,8 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr return nil, err } - var client requestDoer - if !allowInsecure { - client = defaultHTTPClient - } else { - client = defaultInsecureHTTPClient - } - - responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request) + var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) + responseJson, err := decodeJsonFromRequest[pihole5StatsResponse](client, request) if err != nil { return nil, err } @@ -348,7 +369,6 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr } var lowestTimestamp int64 = 0 - for timestamp := range responseJson.QueriesSeries { if lowestTimestamp == 0 || timestamp < lowestTimestamp { lowestTimestamp = timestamp @@ -357,11 +377,11 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr maxQueriesInSeries := 0 - for i := 0; i < 8; i++ { + for i := range dnsStatsBars { queries := 0 blocked := 0 - for j := 0; j < 18; j++ { + for j := range 18 { index := lowestTimestamp + int64(i*10800+j*600) queries += responseJson.QueriesSeries[index] @@ -382,13 +402,287 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr } } - for i := 0; i < 8; i++ { + for i := range dnsStatsBars { stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) } return stats, nil } +func fetchPiholeStats( + instanceURL string, + allowInsecure bool, + password string, + sessionID string, + includeGraph bool, + includeTopDomains bool, +) (*dnsStats, string, error) { + instanceURL = strings.TrimRight(instanceURL, "/") + var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) + + fetchNewSessionID := func() error { + newSessionID, err := fetchPiholeSessionID(instanceURL, client, password) + if err != nil { + return err + } + sessionID = newSessionID + return nil + } + + if sessionID == "" { + if err := fetchNewSessionID(); err != nil { + slog.Error("Failed to fetch Pihole v6 session ID", "error", err) + return nil, "", fmt.Errorf("fetching session ID: %v", err) + } + } else { + isValid, err := checkPiholeSessionIDIsValid(instanceURL, client, sessionID) + if err != nil { + slog.Error("Failed to check Pihole v6 session ID validity", "error", err) + return nil, "", fmt.Errorf("checking session ID: %v", err) + } + + if !isValid { + if err := fetchNewSessionID(); err != nil { + slog.Error("Failed to renew Pihole v6 session ID", "error", err) + return nil, "", fmt.Errorf("renewing session ID: %v", err) + } + } + } + + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + type statsResponseJson struct { + Queries struct { + Total int `json:"total"` + Blocked int `json:"blocked"` + PercentBlocked float64 `json:"percent_blocked"` + } `json:"queries"` + Gravity struct { + DomainsBlocked int `json:"domains_being_blocked"` + } `json:"gravity"` + } + + statsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/summary", nil) + statsRequest.Header.Set("x-ftl-sid", sessionID) + + var statsResponse statsResponseJson + var statsErr error + + wg.Add(1) + go func() { + defer wg.Done() + statsResponse, statsErr = decodeJsonFromRequest[statsResponseJson](client, statsRequest) + if statsErr != nil { + cancel() + } + }() + + type seriesResponseJson struct { + History []struct { + Timestamp int64 `json:"timestamp"` + Total int `json:"total"` + Blocked int `json:"blocked"` + } `json:"history"` + } + + var seriesResponse seriesResponseJson + var seriesErr error + + if includeGraph { + seriesRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/history", nil) + seriesRequest.Header.Set("x-ftl-sid", sessionID) + + wg.Add(1) + go func() { + defer wg.Done() + seriesResponse, seriesErr = decodeJsonFromRequest[seriesResponseJson](client, seriesRequest) + }() + } + + type topDomainsResponseJson struct { + Domains []struct { + Domain string `json:"domain"` + Count int `json:"count"` + } `json:"domains"` + TotalQueries int `json:"total_queries"` + BlockedQueries int `json:"blocked_queries"` + Took float64 `json:"took"` + } + + var topDomainsResponse topDomainsResponseJson + var topDomainsErr error + + if includeTopDomains { + topDomainsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/top_domains?blocked=true", nil) + topDomainsRequest.Header.Set("x-ftl-sid", sessionID) + + wg.Add(1) + go func() { + defer wg.Done() + topDomainsResponse, topDomainsErr = decodeJsonFromRequest[topDomainsResponseJson](client, topDomainsRequest) + }() + } + + wg.Wait() + partialContent := false + + if statsErr != nil { + return nil, "", fmt.Errorf("fetching stats: %v", statsErr) + } + + if includeGraph && seriesErr != nil { + slog.Error("Failed to fetch Pihole v6 graph data", "error", seriesErr) + partialContent = true + } + + if includeTopDomains && topDomainsErr != nil { + slog.Error("Failed to fetch Pihole v6 top domains", "error", topDomainsErr) + partialContent = true + } + + stats := &dnsStats{ + TotalQueries: statsResponse.Queries.Total, + BlockedQueries: statsResponse.Queries.Blocked, + BlockedPercent: int(statsResponse.Queries.PercentBlocked), + DomainsBlocked: statsResponse.Gravity.DomainsBlocked, + } + + if includeGraph && seriesErr == nil { + if len(seriesResponse.History) != 145 { + slog.Error( + "Pihole v6 graph data has unexpected length", + "length", len(seriesResponse.History), + "expected", 145, + ) + partialContent = true + } else { + // The API from v5 used to return 144 data points, but v6 returns 145. + // We only show data from the last 24 hours hours, Pihole returns data + // points in a 10 minute interval, 24*(60/10) = 144. Why is there an extra + // data point? I don't know, but we'll just ignore the first one since it's + // the oldest data point. + history := seriesResponse.History[1:] + + const interval = 10 + const dataPointsPerBar = dnsStatsHoursPerBar * (60 / interval) + + maxQueriesInSeries := 0 + + for i := range dnsStatsBars { + queries := 0 + blocked := 0 + for j := range dataPointsPerBar { + index := i*dataPointsPerBar + j + queries += history[index].Total + blocked += history[index].Blocked + } + if queries > maxQueriesInSeries { + maxQueriesInSeries = queries + } + stats.Series[i] = dnsStatsSeries{ + Queries: queries, + Blocked: blocked, + } + if queries > 0 { + stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) + } + } + + for i := range dnsStatsBars { + stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) + } + } + } + + if includeTopDomains && topDomainsErr == nil && len(topDomainsResponse.Domains) > 0 { + domains := make([]dnsStatsBlockedDomain, 0, len(topDomainsResponse.Domains)) + for i := range topDomainsResponse.Domains { + d := &topDomainsResponse.Domains[i] + domains = append(domains, dnsStatsBlockedDomain{ + Domain: d.Domain, + PercentBlocked: int(float64(d.Count) / float64(statsResponse.Queries.Blocked) * 100), + }) + } + + sort.Slice(domains, func(a, b int) bool { + return domains[a].PercentBlocked > domains[b].PercentBlocked + }) + stats.TopBlockedDomains = domains[:min(len(domains), 5)] + } + + return stats, sessionID, ternary(partialContent, errPartialContent, nil) +} + +func fetchPiholeSessionID(instanceURL string, client *http.Client, password string) (string, error) { + requestBody := []byte(`{"password":"` + password + `"}`) + + request, err := http.NewRequest("POST", instanceURL+"/api/auth", bytes.NewBuffer(requestBody)) + if err != nil { + return "", fmt.Errorf("creating authentication request: %v", err) + } + request.Header.Set("Content-Type", "application/json") + + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("sending authentication request: %v", err) + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("reading authentication response: %v", err) + } + + var jsonResponse struct { + Session struct { + SID string `json:"sid"` + Message string `json:"message"` + } `json:"session"` + } + + if err := json.Unmarshal(body, &jsonResponse); err != nil { + return "", fmt.Errorf("parsing authentication response: %v", err) + } + + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf( + "authentication request returned status %s with message '%s'", + response.Status, jsonResponse.Session.Message, + ) + } + + if jsonResponse.Session.SID == "" { + return "", fmt.Errorf( + "authentication response returned empty session ID, status code %d, message '%s'", + response.StatusCode, jsonResponse.Session.Message, + ) + } + + return jsonResponse.Session.SID, nil +} + +func checkPiholeSessionIDIsValid(instanceURL string, client *http.Client, sessionID string) (bool, error) { + request, err := http.NewRequest("GET", instanceURL+"/api/auth", nil) + if err != nil { + return false, fmt.Errorf("creating session ID check request: %v", err) + } + request.Header.Set("x-ftl-sid", sessionID) + + response, err := client.Do(request) + if err != nil { + return false, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusUnauthorized { + return false, fmt.Errorf("session ID check request returned status %s", response.Status) + } + + return response.StatusCode == http.StatusOK, nil +} + type technitiumStatsResponse struct { Response struct { Stats struct { diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index 61cb388..702a34b 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -278,6 +278,7 @@ func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefau return hideByDefault } + func fetchDockerContainersFromSource( source string, category string, @@ -311,6 +312,7 @@ func fetchDockerContainersFromSource( } } + fetchAll := ternary(runningOnly, "false", "true") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go index 47034f3..c2b11f5 100644 --- a/internal/glance/widget-extension.go +++ b/internal/glance/widget-extension.go @@ -22,6 +22,7 @@ type extensionWidget struct { URL string `yaml:"url"` FallbackContentType string `yaml:"fallback-content-type"` Parameters queryParametersField `yaml:"parameters"` + Headers map[string]string `yaml:"headers"` AllowHtml bool `yaml:"allow-potentially-dangerous-html"` Extension extension `yaml:"-"` cachedHTML template.HTML `yaml:"-"` @@ -46,6 +47,7 @@ func (widget *extensionWidget) update(ctx context.Context) { URL: widget.URL, FallbackContentType: widget.FallbackContentType, Parameters: widget.Parameters, + Headers: widget.Headers, AllowHtml: widget.AllowHtml, }) @@ -57,6 +59,10 @@ func (widget *extensionWidget) update(ctx context.Context) { widget.Title = extension.Title } + if widget.TitleURL == "" && extension.TitleURL != "" { + widget.TitleURL = extension.TitleURL + } + widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate) } @@ -67,8 +73,8 @@ func (widget *extensionWidget) Render() template.HTML { type extensionType int const ( - extensionContentHTML extensionType = iota - extensionContentUnknown = iota + extensionContentHTML extensionType = iota + extensionContentUnknown ) var extensionStringToType = map[string]extensionType{ @@ -77,6 +83,7 @@ var extensionStringToType = map[string]extensionType{ const ( extensionHeaderTitle = "Widget-Title" + extensionHeaderTitleURL = "Widget-Title-URL" extensionHeaderContentType = "Widget-Content-Type" extensionHeaderContentFrameless = "Widget-Content-Frameless" ) @@ -85,11 +92,13 @@ type extensionRequestOptions struct { URL string `yaml:"url"` FallbackContentType string `yaml:"fallback-content-type"` Parameters queryParametersField `yaml:"parameters"` + Headers map[string]string `yaml:"headers"` AllowHtml bool `yaml:"allow-potentially-dangerous-html"` } type extension struct { Title string + TitleURL string Content template.HTML Frameless bool } @@ -109,7 +118,13 @@ func convertExtensionContent(options extensionRequestOptions, content []byte, co func fetchExtension(options extensionRequestOptions) (extension, error) { request, _ := http.NewRequest("GET", options.URL, nil) - request.URL.RawQuery = options.Parameters.toQueryString() + if len(options.Parameters) > 0 { + request.URL.RawQuery = options.Parameters.toQueryString() + } + + for key, value := range options.Headers { + request.Header.Add(key, value) + } response, err := http.DefaultClient.Do(request) if err != nil { @@ -133,6 +148,10 @@ func fetchExtension(options extensionRequestOptions) (extension, error) { extension.Title = response.Header.Get(extensionHeaderTitle) } + if response.Header.Get(extensionHeaderTitleURL) != "" { + extension.TitleURL = response.Header.Get(extensionHeaderTitleURL) + } + contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)] if !ok { diff --git a/internal/glance/widget-markets.go b/internal/glance/widget-markets.go index a3bb325..b53b10a 100644 --- a/internal/glance/widget-markets.go +++ b/internal/glance/widget-markets.go @@ -79,6 +79,7 @@ type market struct { Name string Currency string Price float64 + PriceHint int PercentChange float64 SvgChartPoints string } @@ -106,6 +107,7 @@ type marketResponseJson struct { RegularMarketPrice float64 `json:"regularMarketPrice"` ChartPreviousClose float64 `json:"chartPreviousClose"` ShortName string `json:"shortName"` + PriceHint int `json:"priceHint"` } `json:"meta"` Indicators struct { Quote []struct { @@ -152,13 +154,14 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro continue } - prices := response.Chart.Result[0].Indicators.Quote[0].Close + result := &response.Chart.Result[0] + prices := result.Indicators.Quote[0].Close if len(prices) > marketChartDays { prices = prices[len(prices)-marketChartDays:] } - previous := response.Chart.Result[0].Meta.RegularMarketPrice + previous := result.Meta.RegularMarketPrice if len(prices) >= 2 && prices[len(prices)-2] != 0 { previous = prices[len(prices)-2] @@ -166,21 +169,22 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) - currency, exists := currencyToSymbol[strings.ToUpper(response.Chart.Result[0].Meta.Currency)] + currency, exists := currencyToSymbol[strings.ToUpper(result.Meta.Currency)] if !exists { - currency = response.Chart.Result[0].Meta.Currency + currency = result.Meta.Currency } markets = append(markets, market{ marketRequest: marketRequests[i], - Price: response.Chart.Result[0].Meta.RegularMarketPrice, + Price: result.Meta.RegularMarketPrice, Currency: currency, + PriceHint: result.Meta.PriceHint, Name: ternary(marketRequests[i].CustomName == "", - response.Chart.Result[0].Meta.ShortName, + result.Meta.ShortName, marketRequests[i].CustomName, ), PercentChange: percentChange( - response.Chart.Result[0].Meta.RegularMarketPrice, + result.Meta.RegularMarketPrice, previous, ), SvgChartPoints: points, diff --git a/internal/glance/widget-reddit.go b/internal/glance/widget-reddit.go index e7109fa..86832b5 100644 --- a/internal/glance/widget-reddit.go +++ b/internal/glance/widget-reddit.go @@ -194,7 +194,7 @@ func fetchSubredditPosts( var client requestDoer = defaultHTTPClient if requestUrlTemplate != "" { - requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl) + requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", url.QueryEscape(requestUrl)) } else if proxyClient != nil { client = proxyClient } diff --git a/internal/glance/widget-rss.go b/internal/glance/widget-rss.go index e7d2e8b..1598371 100644 --- a/internal/glance/widget-rss.go +++ b/internal/glance/widget-rss.go @@ -331,6 +331,7 @@ func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error) failed := 0 entries := make(rssFeedItemList, 0, len(feeds)*10) + seen := make(map[string]struct{}) for i := range feeds { if errs[i] != nil { @@ -339,7 +340,13 @@ func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error) continue } - entries = append(entries, feeds[i]...) + for _, item := range feeds[i] { + if _, exists := seen[item.Link]; exists { + continue + } + entries = append(entries, item) + seen[item.Link] = struct{}{} + } } if failed == len(requests) { diff --git a/internal/glance/widget-search.go b/internal/glance/widget-search.go index 9d2b600..300361d 100644 --- a/internal/glance/widget-search.go +++ b/internal/glance/widget-search.go @@ -20,6 +20,7 @@ type searchWidget struct { SearchEngine string `yaml:"search-engine"` Bangs []SearchBang `yaml:"bangs"` NewTab bool `yaml:"new-tab"` + Target string `yaml:"target"` Autofocus bool `yaml:"autofocus"` Placeholder string `yaml:"placeholder"` } @@ -33,6 +34,10 @@ func convertSearchUrl(url string) string { var searchEngines = map[string]string{ "duckduckgo": "https://duckduckgo.com/?q={QUERY}", "google": "https://www.google.com/search?q={QUERY}", + "bing": "https://www.bing.com/search?q={QUERY}", + "perplexity": "https://www.perplexity.ai/search?q={QUERY}", + "kagi": "https://kagi.com/search?q={QUERY}", + "startpage": "https://www.startpage.com/search?q={QUERY}", } func (widget *searchWidget) initialize() error { diff --git a/internal/glance/widget-twitch-channels.go b/internal/glance/widget-twitch-channels.go index f3ab206..1290a26 100644 --- a/internal/glance/widget-twitch-channels.go +++ b/internal/glance/widget-twitch-channels.go @@ -196,6 +196,10 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) { slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt) } } + } else { + // This prevents live channels with 0 viewers from being + // incorrectly sorted lower than offline channels + result.ViewersCount = -1 } return result, nil diff --git a/internal/glance/widget-utils.go b/internal/glance/widget-utils.go index 2688473..c6b7745 100644 --- a/internal/glance/widget-utils.go +++ b/internal/glance/widget-utils.go @@ -180,8 +180,8 @@ func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error) } if len(job.data) == 1 { - output, err := job.task(job.data[0]) - return append(results, output), append(errs, err), nil + results[0], errs[0] = job.task(job.data[0]) + return results, errs, nil } tasksQueue := make(chan *workerPoolTask[I, O]) diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go index fdc654c..ff79864 100644 --- a/internal/glance/widget-videos.go +++ b/internal/glance/widget-videos.go @@ -56,7 +56,7 @@ func (widget *videosWidget) initialize() error { widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...) for i := range widget.Playlists { - widget.Channels[initialLen+i] = "playlist:" + widget.Playlists[i] + widget.Channels[initialLen+i] = videosWidgetPlaylistPrefix + widget.Playlists[i] } } diff --git a/pkg/sysinfo/sysinfo.go b/pkg/sysinfo/sysinfo.go index 673b9d2..ed20318 100644 --- a/pkg/sysinfo/sysinfo.go +++ b/pkg/sysinfo/sysinfo.go @@ -201,11 +201,12 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) { // currently disabled on Windows because it requires elevated privilidges, otherwise // keeps returning a single sensor with key "ACPI\\ThermalZone\\TZ00_0" which // doesn't seem to be the CPU sensor or correspond to anything useful when - // compared against the temperatures Libre Hardware Monitor reports - // also disabled on openbsd because it's not implemented by go-psutil - if runtime.GOOS != "windows" && runtime.GOOS != "openbsd" { + // compared against the temperatures Libre Hardware Monitor reports. + // Also disabled on the bsd's because it's not implemented by go-psutil for them + if runtime.GOOS != "windows" && runtime.GOOS != "openbsd" && runtime.GOOS != "netbsd" && runtime.GOOS != "freebsd" { sensorReadings, err := sensors.SensorsTemperatures() - if err == nil { + _, errIsWarning := err.(*sensors.Warnings) + if err == nil || errIsWarning { if req.CPUTempSensor != "" { for i := range sensorReadings { if sensorReadings[i].SensorKey == req.CPUTempSensor { @@ -227,35 +228,50 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) { } } - filesystems, err := disk.Partitions(false) - if err == nil { - for _, fs := range filesystems { - mpReq, ok := req.Mountpoints[fs.Mountpoint] - isHidden := req.HideMountpointsByDefault - if ok && mpReq.Hide != nil { - isHidden = *mpReq.Hide - } - if isHidden { - continue - } - - usage, err := disk.Usage(fs.Mountpoint) - if err == nil { - mpInfo := MountpointInfo{ - Path: fs.Mountpoint, - Name: mpReq.Name, - TotalMB: usage.Total / 1024 / 1024, - UsedMB: usage.Used / 1024 / 1024, - UsedPercent: uint8(math.Min(usage.UsedPercent, 100)), - } - - info.Mountpoints = append(info.Mountpoints, mpInfo) - } else { - addErr(fmt.Errorf("getting filesystem usage for %s: %v", fs.Mountpoint, err)) - } + addedMountpoints := map[string]struct{}{} + addMountpointInfo := func(requestedPath string, mpReq MointpointRequest) { + if _, exists := addedMountpoints[requestedPath]; exists { + return } - } else { - addErr(fmt.Errorf("getting filesystems: %v", err)) + + isHidden := req.HideMountpointsByDefault + if mpReq.Hide != nil { + isHidden = *mpReq.Hide + } + if isHidden { + return + } + + usage, err := disk.Usage(requestedPath) + if err == nil { + mpInfo := MountpointInfo{ + Path: requestedPath, + Name: mpReq.Name, + TotalMB: usage.Total / 1024 / 1024, + UsedMB: usage.Used / 1024 / 1024, + UsedPercent: uint8(math.Min(usage.UsedPercent, 100)), + } + + info.Mountpoints = append(info.Mountpoints, mpInfo) + addedMountpoints[requestedPath] = struct{}{} + } else { + addErr(fmt.Errorf("getting filesystem usage for %s: %v", requestedPath, err)) + } + } + + if !req.HideMountpointsByDefault { + filesystems, err := disk.Partitions(false) + if err == nil { + for _, fs := range filesystems { + addMountpointInfo(fs.Mountpoint, req.Mountpoints[fs.Mountpoint]) + } + } else { + addErr(fmt.Errorf("getting filesystems: %v", err)) + } + } + + for mountpoint, mpReq := range req.Mountpoints { + addMountpointInfo(mountpoint, mpReq) } sort.Slice(info.Mountpoints, func(a, b int) bool {