mirror of https://github.com/glanceapp/glance.git
Add DNS Stats widget
parent
822b72eee4
commit
1df080983a
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
@ -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 }}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue