Add DNS Stats widget

This commit is contained in:
Svilen Markov 2024-08-22 23:11:45 +01:00
parent 822b72eee4
commit 1df080983a
10 changed files with 591 additions and 2 deletions

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@ -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; }

View File

@ -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{

View File

@ -0,0 +1,85 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="widget-small-content-bounds dns-stats">
<div class="flex text-center justify-between dns-stats-totals">
<div>
<div class="color-highlight size-h3">{{ .Stats.TotalQueries | formatNumber }}</div>
<div class="size-h6">QUERIES</div>
</div>
<div>
<div class="color-highlight size-h3">{{ .Stats.BlockedPercent }}%</div>
<div class="size-h6">BLOCKED</div>
</div>
{{ if gt .Stats.ResponseTime 0 }}
<div>
<div class="color-highlight size-h3">{{ .Stats.ResponseTime | formatNumber }}ms</div>
<div class="size-h6">LATENCY</div>
</div>
{{ else }}
<div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
<div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatViewerCount }}</div>
<div class="size-h6">DOMAINS</div>
</div>
{{ end }}
</div>
<div class="dns-stats-graph margin-top-15">
<div class="dns-stats-graph-gridlines-container">
<svg class="dns-stats-graph-gridlines" shape-rendering="crispEdges" viewBox="0 0 1 100" preserveAspectRatio="none">
<g stroke="var(--color-graph-gridlines)" stroke-width="1">
<line x1="0" y1="1" x2="1" y2="1" vector-effect="non-scaling-stroke" />
<line x1="0" y1="25" x2="1" y2="25" vector-effect="non-scaling-stroke" />
<line x1="0" y1="50" x2="1" y2="50" vector-effect="non-scaling-stroke" />
<line x1="0" y1="75" x2="1" y2="75" vector-effect="non-scaling-stroke" />
<line x1="0" y1="99" x2="1" y2="99" vector-effect="non-scaling-stroke" stroke="var(--color-progress-bar-border)"/>
</g>
</svg>
</div>
<div class="dns-stats-graph-columns">
{{ range $i, $column := .Stats.Series }}
<div class="dns-stats-graph-column" data-popover-type="html" data-popover-position="above" data-popover-show-delay="500">
<div data-popover-html>
<div class="flex text-center justify-between gap-25">
<div>
<div class="color-highlight size-h3">{{ $column.Queries | formatNumber }}</div>
<div class="size-h6">QUERIES</div>
</div>
<div>
<div class="color-highlight size-h3">{{ $column.PercentBlocked }}%</div>
<div class="size-h6">BLOCKED</div>
</div>
</div>
</div>
{{ if gt $column.PercentTotal 0}}
<div class="dns-stats-graph-bar" style="--bar-height: {{ $column.PercentTotal }}">
{{ if ne $column.Queries $column.Blocked }}
<div class="queries"></div>
{{ end }}
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
{{ end }}
</div>
{{ end }}
<div class="dns-stats-graph-time">{{ index $.TimeLabels $i }}</div>
</div>
{{ end }}
</div>
</div>
{{ if .Stats.TopBlockedDomains }}
<details class="details margin-top-40">
<summary class="summary">Top blocked domains</summary>
<ul class="list list-gap-4 list-with-transition size-h5">
{{ range .Stats.TopBlockedDomains }}
<li class="flex justify-between align-center">
<div class="text-truncate rtl">{{ .Domain }}</div>
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
</li>
{{ end }}
</ul>
</details>
{{ end }}
</div>
{{ end }}

99
internal/feed/adguard.go Normal file
View File

@ -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
}

109
internal/feed/pihole.go Normal file
View File

@ -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
}

View File

@ -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"`

View File

@ -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)
}

View File

@ -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)
}