From 1df080983a8750e5f5bbb12e7dc322a28ed71824 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Thu, 22 Aug 2024 23:11:45 +0100 Subject: [PATCH] Add DNS Stats widget --- docs/configuration.md | 51 ++++++++ docs/images/dns-stats-widget-preview.png | Bin 0 -> 9956 bytes internal/assets/static/main.css | 147 ++++++++++++++++++++++- internal/assets/templates.go | 1 + internal/assets/templates/dns-stats.html | 85 +++++++++++++ internal/feed/adguard.go | 99 +++++++++++++++ internal/feed/pihole.go | 109 +++++++++++++++++ internal/feed/primitives.go | 22 ++++ internal/widget/dns-stats.go | 77 ++++++++++++ internal/widget/widget.go | 2 + 10 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 docs/images/dns-stats-widget-preview.png create mode 100644 internal/assets/templates/dns-stats.html create mode 100644 internal/feed/adguard.go create mode 100644 internal/feed/pihole.go create mode 100644 internal/widget/dns-stats.go diff --git a/docs/configuration.md b/docs/configuration.md index 0f076fd..618b1b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,6 +18,7 @@ - [Weather](#weather) - [Monitor](#monitor) - [Releases](#releases) + - [DNS Stats](#dns-stats) - [Repository](#repository) - [Bookmarks](#bookmarks) - [Calendar](#calendar) @@ -1120,6 +1121,56 @@ The maximum number of releases to show. #### `collapse-after` How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. +### DNS Stats +Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home or Pi-hole. + +Example: + +```yaml +- type: dns-stats + service: adguard + url: https://adguard.domain.com/ + username: admin + password: ${ADGUARD_PASSWORD} +``` + +Preview: + +![](images/dns-stats-widget-preview.png) + +> [!NOTE] +> +> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole it will be the total number of blocked domains from all adlists. + +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| service | string | no | pihole | +| url | string | yes | | +| username | string | when service is `adguard` | | +| password | string | when service is `adguard` | | +| token | string | when service is `pihole` | | +| hour-format | string | no | 12h | + +##### `service` +Either `adguard` or `pihole`. + +##### `url` +The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. + +##### `username` +Only required when using AdGuard Home. The username used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. + +##### `password` +Only required when using AdGuard Home. The password used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. + +##### `token` +Only required when using Pi-hole. The API token which can be found in `Settings -> API -> Show API token`. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. + +##### `hour-format` +Whether to display the relative time in the graph in `12h` or `24h` format. + ### Repository Display general information about a repository as well as a list of the latest open pull requests and issues. diff --git a/docs/images/dns-stats-widget-preview.png b/docs/images/dns-stats-widget-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..defd13992f92a1116fc73fdb34ac5ec957ce81a3 GIT binary patch literal 9956 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 * { animation: collapsibleItemReveal .25s backwards; } +.list-with-transition > *:nth-child(2) { animation-delay: 30ms; } +.list-with-transition > *:nth-child(3) { animation-delay: 60ms; } +.list-with-transition > *:nth-child(4) { animation-delay: 90ms; } +.list-with-transition > *:nth-child(5) { animation-delay: 120ms; } +.list-with-transition > *:nth-child(6) { animation-delay: 150ms; } +.list-with-transition > *:nth-child(7) { animation-delay: 180ms; } +.list-with-transition > *:nth-child(8) { animation-delay: 210ms; } + .list > *:not(:first-child) { margin-top: calc(var(--list-half-gap) * 2); } @@ -649,7 +662,10 @@ details[open] .summary::after { @container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } } @container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } } - +.widget-small-content-bounds { + max-width: 350px; + margin: 0 auto; +} .widget-error-header { display: flex; @@ -1003,12 +1019,136 @@ details[open] .summary::after { padding: 0.6rem 0; } + .calendar-day-today { border-radius: var(--border-radius); background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%))); color: var(--color-text-highlight); } +.dns-stats-totals { + transition: opacity .3s; + transition-delay: 50ms; +} + +.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals { + opacity: 0.1; + transition-delay: 0s; +} + +.dns-stats-graph { + --graph-height: 70px; + height: var(--graph-height); + position: relative; + margin-bottom: 2.5rem; +} + +.dns-stats-graph-gridlines-container { + position: absolute; + z-index: -1; + inset: 0; +} + +.dns-stats-graph-gridlines { + height: 100%; + width: 100%; +} + +.dns-stats-graph-columns { + display: flex; + height: 100%; +} + +.dns-stats-graph-column { + display: flex; + justify-content: flex-end; + align-items: center; + flex-direction: column; + width: calc(100% / 8); + position: relative; +} + +.dns-stats-graph-column::before { + content: ''; + position: absolute; + inset: 1px 0; + z-index: -1; + opacity: 0; + background: var(--color-text-base); + transition: opacity .2s; +} + +.dns-stats-graph-column:hover::before { + opacity: 0.05; +} + +.dns-stats-graph-bar { + width: 14px; + height: calc((var(--bar-height) / 100) * var(--graph-height)); + border: 1px solid var(--color-progress-bar-border); + border-radius: var(--border-radius) var(--border-radius) 0 0; + display: flex; + background: var(--color-widget-background); + padding: 2px 2px 0 2px; + display: flex; + flex-direction: column; + gap: 2px; + transition: border-color .2s; + min-height: 10px; +} + +.dns-stats-graph-column.popover-active .dns-stats-graph-bar { + border-color: var(--color-text-subdue); + border-bottom-color: var(--color-progress-bar-border); +} + +.dns-stats-graph-bar > * { + border-radius: 2px; + background: var(--color-progress-bar-background); + min-height: 1px; +} + +.dns-stats-graph-bar > .queries { + flex-grow: 1; +} + +.dns-stats-graph-bar > *:last-child { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.dns-stats-graph-bar > .blocked { + background-color: var(--color-negative); +} + +.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time { + opacity: 1; + transform: translateY(0); +} + +.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time { + position: absolute; + font-size: var(--font-size-h6); + inset-inline: 0; + text-align: center; + height: 2.5rem; + line-height: 2.5rem; + top: 100%; + user-select: none; + opacity: 0; + transform: translateY(-0.5rem); + transition: opacity .2s, transform .2s; +} + +.dns-stats-graph-column:hover .dns-stats-graph-time { + opacity: 1; + transform: translateY(0); +} + +.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time { + opacity: 0; +} + .weather-column { position: relative; display: flex; @@ -1547,6 +1687,7 @@ details[open] .summary::after { .color-positive { color: var(--color-positive); } .color-primary { color: var(--color-primary); } +.cursor-help { cursor: help; } .break-all { word-break: break-all; } .text-left { text-align: left; } .text-right { text-align: right; } @@ -1592,6 +1733,8 @@ details[open] .summary::after { .margin-top-15 { margin-top: 1.5rem; } .margin-top-20 { margin-top: 2rem; } .margin-top-25 { margin-top: 2.5rem; } +.margin-top-35 { margin-top: 3.5rem; } +.margin-top-40 { margin-top: 4rem; } .margin-top-auto { margin-top: auto; } .margin-block-3 { margin-block: 0.3rem; } .margin-block-5 { margin-block: 0.5rem; } diff --git a/internal/assets/templates.go b/internal/assets/templates.go index e29ed91..85abb69 100644 --- a/internal/assets/templates.go +++ b/internal/assets/templates.go @@ -38,6 +38,7 @@ var ( SearchTemplate = compileTemplate("search.html", "widget-base.html") ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") GroupTemplate = compileTemplate("group.html", "widget-base.html") + DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html") ) var globalTemplateFunctions = template.FuncMap{ diff --git a/internal/assets/templates/dns-stats.html b/internal/assets/templates/dns-stats.html new file mode 100644 index 0000000..5d83508 --- /dev/null +++ b/internal/assets/templates/dns-stats.html @@ -0,0 +1,85 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +
+
+
+
{{ .Stats.TotalQueries | formatNumber }}
+
QUERIES
+
+
+
{{ .Stats.BlockedPercent }}%
+
BLOCKED
+
+ {{ if gt .Stats.ResponseTime 0 }} +
+
{{ .Stats.ResponseTime | formatNumber }}ms
+
LATENCY
+
+ {{ else }} +
+
{{ .Stats.DomainsBlocked | formatViewerCount }}
+
DOMAINS
+
+ {{ end }} +
+ +
+
+ + + + + + + + + +
+ +
+ {{ range $i, $column := .Stats.Series }} +
+
+
+
+
{{ $column.Queries | formatNumber }}
+
QUERIES
+
+
+
{{ $column.PercentBlocked }}%
+
BLOCKED
+
+
+
+ {{ if gt $column.PercentTotal 0}} +
+ {{ if ne $column.Queries $column.Blocked }} +
+ {{ end }} + {{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }} +
+ {{ end }} +
+ {{ end }} +
{{ index $.TimeLabels $i }}
+
+ {{ end }} +
+
+ + {{ if .Stats.TopBlockedDomains }} +
+ Top blocked domains +
    + {{ range .Stats.TopBlockedDomains }} +
  • +
    {{ .Domain }}
    +
    {{ .PercentBlocked }}%
    +
  • + {{ end }} +
+
+ {{ end }} +
+{{ end }} diff --git a/internal/feed/adguard.go b/internal/feed/adguard.go new file mode 100644 index 0000000..440cb88 --- /dev/null +++ b/internal/feed/adguard.go @@ -0,0 +1,99 @@ +package feed + +import ( + "net/http" + "strings" +) + +type adguardStatsResponse struct { + TotalQueries int `json:"num_dns_queries"` + QueriesSeries []int `json:"dns_queries"` + BlockedQueries int `json:"num_blocked_filtering"` + BlockedSeries []int `json:"blocked_filtering"` + ResponseTime float64 `json:"avg_processing_time"` + TopBlockedDomains []map[string]int `json:"top_blocked_domains"` +} + +func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) { + requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" + + request, err := http.NewRequest("GET", requestURL, nil) + + if err != nil { + return nil, err + } + + request.SetBasicAuth(username, password) + + responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request) + + if err != nil { + return nil, err + } + + stats := &DNSStats{ + TotalQueries: responseJson.TotalQueries, + BlockedQueries: responseJson.BlockedQueries, + ResponseTime: int(responseJson.ResponseTime * 1000), + } + + if stats.TotalQueries <= 0 { + return stats, nil + } + + stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) + + var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5) + + for i := 0; i < topBlockedDomainsCount; i++ { + domain := responseJson.TopBlockedDomains[i] + var firstDomain string + + for k := range domain { + firstDomain = k + break + } + + if firstDomain == "" { + continue + } + + stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{ + Domain: firstDomain, + PercentBlocked: int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100), + }) + } + + // Adguard _should_ return data for the last 24 hours in a 1 hour interval + if len(responseJson.QueriesSeries) != 24 || len(responseJson.BlockedSeries) != 24 { + return stats, nil + } + + maxQueriesInSeries := 0 + + for i := 0; i < 8; i++ { + queries := 0 + blocked := 0 + + for j := 0; j < 3; j++ { + queries += responseJson.QueriesSeries[i*3+j] + blocked += responseJson.BlockedSeries[i*3+j] + } + + stats.Series[i] = DNSStatsSeries{ + Queries: queries, + Blocked: blocked, + PercentBlocked: int(float64(blocked) / float64(queries) * 100), + } + + if queries > maxQueriesInSeries { + maxQueriesInSeries = queries + } + } + + for i := 0; i < 8; i++ { + stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) + } + + return stats, nil +} diff --git a/internal/feed/pihole.go b/internal/feed/pihole.go new file mode 100644 index 0000000..badfb1a --- /dev/null +++ b/internal/feed/pihole.go @@ -0,0 +1,109 @@ +package feed + +import ( + "errors" + "net/http" + "sort" + "strings" +) + +type piholeStatsResponse struct { + TotalQueries int `json:"dns_queries_today"` + QueriesSeries map[int64]int `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 map[string]int `json:"top_ads"` + DomainsBlocked int `json:"domains_being_blocked"` +} + +func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) { + if token == "" { + return nil, errors.New("missing API token") + } + + requestURL := strings.TrimRight(instanceURL, "/") + + "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token + + request, err := http.NewRequest("GET", requestURL, nil) + + if err != nil { + return nil, err + } + + responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request) + + if err != nil { + return nil, err + } + + stats := &DNSStats{ + TotalQueries: responseJson.TotalQueries, + BlockedQueries: responseJson.BlockedQueries, + BlockedPercent: int(responseJson.BlockedPercentage), + DomainsBlocked: responseJson.DomainsBlocked, + } + + if len(responseJson.TopBlockedDomains) > 0 { + domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains)) + + for domain, count := range responseJson.TopBlockedDomains { + domains = append(domains, DNSStatsBlockedDomain{ + Domain: domain, + PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100), + }) + } + + sort.Slice(domains, func(a, b int) bool { + return domains[a].PercentBlocked > domains[b].PercentBlocked + }) + + stats.TopBlockedDomains = domains[:min(len(domains), 5)] + } + + // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 + if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 { + return stats, nil + } + + var lowestTimestamp int64 = 0 + + for timestamp := range responseJson.QueriesSeries { + if lowestTimestamp == 0 || timestamp < lowestTimestamp { + lowestTimestamp = timestamp + } + } + + maxQueriesInSeries := 0 + + for i := 0; i < 8; i++ { + queries := 0 + blocked := 0 + + for j := 0; j < 18; j++ { + index := lowestTimestamp + int64(i*10800+j*600) + + queries += responseJson.QueriesSeries[index] + blocked += responseJson.BlockedSeries[index] + } + + 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 := 0; i < 8; i++ { + stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) + } + + return stats, nil +} diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go index 7371983..916e69b 100644 --- a/internal/feed/primitives.go +++ b/internal/feed/primitives.go @@ -86,6 +86,28 @@ var currencyToSymbol = map[string]string{ "PHP": "₱", } +type DNSStats struct { + TotalQueries int + BlockedQueries int + BlockedPercent int + ResponseTime int + DomainsBlocked int + Series [8]DNSStatsSeries + TopBlockedDomains []DNSStatsBlockedDomain +} + +type DNSStatsSeries struct { + Queries int + Blocked int + PercentTotal int + PercentBlocked int +} + +type DNSStatsBlockedDomain struct { + Domain string + PercentBlocked int +} + type MarketRequest struct { Name string `yaml:"name"` Symbol string `yaml:"symbol"` diff --git a/internal/widget/dns-stats.go b/internal/widget/dns-stats.go new file mode 100644 index 0000000..91757b1 --- /dev/null +++ b/internal/widget/dns-stats.go @@ -0,0 +1,77 @@ +package widget + +import ( + "context" + "errors" + "html/template" + "strings" + "time" + + "github.com/glanceapp/glance/internal/assets" + "github.com/glanceapp/glance/internal/feed" +) + +type DNSStats struct { + widgetBase `yaml:",inline"` + + TimeLabels [8]string `yaml:"-"` + Stats *feed.DNSStats `yaml:"-"` + + HourFormat string `yaml:"hour-format"` + Service string `yaml:"service"` + URL OptionalEnvString `yaml:"url"` + Token OptionalEnvString `yaml:"token"` + Username OptionalEnvString `yaml:"username"` + Password OptionalEnvString `yaml:"password"` +} + +func makeDNSTimeLabels(format string) [8]string { + now := time.Now() + var labels [8]string + + for i := 24; i > 0; i -= 3 { + labels[7-(i/3-1)] = strings.ToLower(now.Add(-time.Duration(i) * time.Hour).Format(format)) + } + + return labels +} + +func (widget *DNSStats) Initialize() error { + widget. + withTitle("DNS Stats"). + withTitleURL(string(widget.URL)). + withCacheDuration(10 * time.Minute) + + if widget.Service != "adguard" && widget.Service != "pihole" { + return errors.New("DNS stats service must be either 'adguard' or 'pihole'") + } + + return nil +} + +func (widget *DNSStats) Update(ctx context.Context) { + var stats *feed.DNSStats + var err error + + if widget.Service == "adguard" { + stats, err = feed.FetchAdguardStats(string(widget.URL), string(widget.Username), string(widget.Password)) + } else { + stats, err = feed.FetchPiholeStats(string(widget.URL), string(widget.Token)) + } + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.HourFormat == "24h" { + widget.TimeLabels = makeDNSTimeLabels("15:00") + } else { + widget.TimeLabels = makeDNSTimeLabels("3PM") + } + + widget.Stats = stats +} + +func (widget *DNSStats) Render() template.HTML { + return widget.render(widget, assets.DNSStatsTemplate) +} diff --git a/internal/widget/widget.go b/internal/widget/widget.go index a08eeed..db37f5e 100644 --- a/internal/widget/widget.go +++ b/internal/widget/widget.go @@ -65,6 +65,8 @@ func New(widgetType string) (Widget, error) { widget = &Extension{} case "group": widget = &Group{} + case "dns-stats": + widget = &DNSStats{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) }