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: + + + +> [!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 0000000..defd139 Binary files /dev/null and b/docs/images/dns-stats-widget-preview.png differ diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index 3fb9c6b..4f35bad 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -35,7 +35,11 @@ --color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%))); --color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%))); --color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%)); - --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 10%))); + --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%))); + + --color-progress-bar-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm)))); + --color-progress-bar-background: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 27% * var(--cm)))); + --color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm)))); --ths: var(--bgh), calc(var(--bgs) * var(--tsm)); --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%)); @@ -126,6 +130,15 @@ .list-gap-24 { --list-half-gap: 1.2rem; } .list-gap-34 { --list-half-gap: 1.7rem; } +.page-columns-transitioned .list-with-transition > * { 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" }} +
+{{ 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) }