Refactor DNS stats widget

This commit is contained in:
Svilen Markov 2025-03-15 18:42:58 +00:00
parent 78ad88a245
commit 4b2438c298
6 changed files with 394 additions and 415 deletions

View File

@ -1765,35 +1765,31 @@ Preview:
| allow-insecure | bool | no | false |
| url | string | yes | |
| username | string | when service is `adguard` | |
| password | string | when service is `adguard` | |
| password | string | when service is `adguard` or `pihole6` | |
| token | string | when service is `pihole` | |
| hide-graph | bool | no | false |
| hide-top-domains | bool | no | false |
| hour-format | string | no | 12h |
##### `service`
Either `adguard` or `pihole`.
Either `adguard`, or `pihole` (major version 5 and below) or `pihole6` (major version 6 and above).
##### `allow-insecure`
Whether to allow invalid/self-signed certificates when making the request to the service.
##### `url`
The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
The base URL of the service.
##### `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}`.
Only required when using AdGuard Home. The username used to log into the admin dashboard.
##### `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}`.
Required when using AdGuard Home, where the password is the one used to log into the admin dashboard.
##### `token` (Deprecated)
Only required when using Pi-hole major version 5 or earlier. 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}`.
Also requried when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`.
##### `app-password`
Only required when using Pi-hole. The App Password can be found in `Settings -> Web Interface / API -> Configure app password`.
##### `pihole-version`
Only required if using an older version of Pi-hole (major version 5 or earlier).
##### `token`
Only required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`.
##### `hide-graph`
Whether to hide the graph showing the number of queries over time.

View File

@ -1256,6 +1256,7 @@ details[open] .summary::after {
.dns-stats-graph-bar > .blocked {
background-color: var(--color-negative);
flex-basis: calc(var(--percent) - 1px);
}
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {

View File

@ -59,8 +59,8 @@
{{ 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>
{{ if gt $column.PercentBlocked 0 }}
<div class="blocked" style="--percent: {{ $column.PercentBlocked }}%"></div>
{{ end }}
</div>
{{ end }}

View File

@ -186,3 +186,7 @@ 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) {}

View File

@ -8,20 +8,28 @@ import (
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
const (
dnsStatsBars = 8
dnsStatsHoursSpan = 24
dnsStatsHoursPerBar int = dnsStatsHoursSpan / dnsStatsBars
)
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"`
@ -30,17 +38,15 @@ type dnsStatsWidget struct {
AllowInsecure bool `yaml:"allow-insecure"`
URL string `yaml:"url"`
Token string `yaml:"token"`
AppPassword string `yaml:"app-password"`
PiHoleVersion string `yaml:"pihole-version"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
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))
}
@ -53,8 +59,12 @@ func (widget *dnsStatsWidget) initialize() error {
withTitleURL(string(widget.URL)).
withCacheDuration(10 * time.Minute)
if widget.Service != "adguard" && widget.Service != "pihole" {
return errors.New("service must be either 'adguard' or 'pihole'")
switch widget.Service {
case "adguard":
case "pihole6":
case "pihole":
default:
return errors.New("service must be one of: adguard, pihole6, pihole")
}
return nil
@ -64,10 +74,24 @@ func (widget *dnsStatsWidget) update(ctx context.Context) {
var stats *dnsStats
var err error
if widget.Service == "adguard" {
switch widget.Service {
case "adguard":
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph)
} else {
stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph, widget.PiHoleVersion, widget.AppPassword)
case "pihole":
stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
case "pihole6":
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) {
@ -89,11 +113,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
}
@ -128,13 +152,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
@ -155,7 +173,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
@ -184,31 +202,27 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
queriesSeries := responseJson.QueriesSeries
blockedSeries := responseJson.BlockedSeries
const bars = 8
const hoursSpan = 24
const hoursPerBar int = hoursSpan / bars
if len(queriesSeries) > hoursSpan {
queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
} else if len(queriesSeries) < hoursSpan {
queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
if len(queriesSeries) > dnsStatsHoursSpan {
queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:]
} else if len(queriesSeries) < dnsStatsHoursSpan {
queriesSeries = append(make([]int, dnsStatsHoursSpan-len(queriesSeries)), queriesSeries...)
}
if len(blockedSeries) > hoursSpan {
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
} else if len(blockedSeries) < hoursSpan {
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
if len(blockedSeries) > dnsStatsHoursSpan {
blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:]
} else if len(blockedSeries) < dnsStatsHoursSpan {
blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...)
}
maxQueriesInSeries := 0
for i := 0; i < bars; i++ {
for i := range dnsStatsBars {
queries := 0
blocked := 0
for j := 0; j < hoursPerBar; j++ {
queries += queriesSeries[i*hoursPerBar+j]
blocked += blockedSeries[i*hoursPerBar+j]
for j := range dnsStatsHoursPerBar {
queries += queriesSeries[i*dnsStatsHoursPerBar+j]
blocked += blockedSeries[i*dnsStatsHoursPerBar+j]
}
stats.Series[i] = dnsStatsSeries{
@ -225,7 +239,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
}
}
for i := 0; i < bars; i++ {
for i := range dnsStatsBars {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
@ -233,56 +247,28 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
}
// Legacy Pi-hole stats response (before v6)
type legacyPiholeStatsResponse 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"`
}
// Pi-hole v6+ response format
type piholeStatsResponse 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"`
//Note we do not need the full structure. We extract the values needed
//Adding dummy fields to allow easier json parsing.
QueriesSeries piholeQueriesSeries `json:"domains_over_time"` // Will always be empty
BlockedSeries map[int64]int `json:"ads_over_time"` // Will always be empty.
}
type piholeTopDomainsResponse struct {
Domains []Domains `json:"domains"`
TotalQueries int `json:"total_queries"`
BlockedQueries int `json:"blocked_queries"`
Took float64 `json:"took"`
}
type Domains struct {
Domain string `json:"domain"`
Count int `json:"count"`
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
}
@ -292,16 +278,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
}
@ -309,125 +295,175 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
return nil
}
// piholeGetSID retrieves a new SID from Pi-hole using the app password.
func piholeGetSID(instanceURL, appPassword string, allowInsecure bool) (string, error) {
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
func fetchPihole5Stats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
if token == "" {
return nil, errors.New("missing API token")
}
requestURL := strings.TrimRight(instanceURL, "/") + "/api/auth"
requestBody := []byte(`{"password":"` + appPassword + `"}`)
request, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(requestBody))
if err != nil {
return "", errors.New("failed to create authentication request: " + err.Error())
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return "", errors.New("failed to send authentication request: " + err.Error())
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", errors.New("authentication failed, received status: " + response.Status)
}
body, err := io.ReadAll(response.Body)
if err != nil {
return "", errors.New("failed to read authentication response: " + err.Error())
}
var jsonResponse struct {
Session struct {
SID string `json:"sid"`
} `json:"session"`
}
if err := json.Unmarshal(body, &jsonResponse); err != nil {
return "", errors.New("failed to parse authentication response: " + err.Error())
}
if jsonResponse.Session.SID == "" {
return "", errors.New("authentication response did not contain a valid SID")
}
return jsonResponse.Session.SID, nil
}
// checkPiholeSID checks if the SID is valid by checking HTTP response status code from /api/auth.
func checkPiholeSID(instanceURL string, sid string, allowInsecure bool) error {
requestURL := strings.TrimRight(instanceURL, "/") + "/api/auth"
requestURL := strings.TrimRight(instanceURL, "/") +
"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return err
}
request.Header.Set("x-ftl-sid", sid)
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
return nil, err
}
response, err := client.Do(request)
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
responseJson, err := decodeJsonFromRequest[pihole5StatsResponse](client, request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return errors.New("SID is invalid, received status: " + response.Status)
return nil, err
}
return nil
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)]
}
if noGraph {
return stats, nil
}
// 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 {
slog.Warn(
"DNS stats for pihole: did not get expected 144 data points",
"len(queries)", len(responseJson.QueriesSeries),
"len(blocked)", len(responseJson.BlockedSeries),
)
return stats, nil
}
var lowestTimestamp int64 = 0
for timestamp := range responseJson.QueriesSeries {
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
lowestTimestamp = timestamp
}
}
maxQueriesInSeries := 0
for i := range dnsStatsBars {
queries := 0
blocked := 0
for j := range 18 {
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 := range dnsStatsBars {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}
// fetchPiholeTopDomains fetches the top blocked domains for Pi-hole v6+.
func fetchPiholeTopDomains(instanceURL string, sid string, allowInsecure bool) (piholeTopDomainsResponse, error) {
requestURL := strings.TrimRight(instanceURL, "/") + "/api/stats/top_domains?blocked=true"
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)
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return piholeTopDomainsResponse{}, err
fetchNewSessionID := func() error {
newSessionID, err := fetchPiholeSessionID(instanceURL, client, password)
if err != nil {
return err
}
sessionID = newSessionID
return nil
}
request.Header.Set("x-ftl-sid", sid)
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
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 {
client = defaultInsecureHTTPClient
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)
}
}
}
return decodeJsonFromRequest[piholeTopDomainsResponse](client, request)
}
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// fetchPiholeSeries fetches the series data for Pi-hole v6+ (QueriesSeries and BlockedSeries).
func fetchPiholeSeries(instanceURL string, sid string, allowInsecure bool) (piholeQueriesSeries, map[int64]int, error) {
requestURL := strings.TrimRight(instanceURL, "/") + "/api/history"
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, nil, err
}
request.Header.Set("x-ftl-sid", sid)
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
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"`
}
// Define the correct struct to match the API response
var responseJson struct {
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"`
@ -435,39 +471,124 @@ func fetchPiholeSeries(instanceURL string, sid string, allowInsecure bool) (piho
} `json:"history"`
}
err = decodeJsonInto(client, request, &responseJson)
if err != nil {
return nil, nil, err
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)
}()
}
queriesSeries := make(piholeQueriesSeries)
blockedSeries := make(map[int64]int)
// Populate the series data from history array
for _, entry := range responseJson.History {
queriesSeries[entry.Timestamp] = entry.Total
blockedSeries[entry.Timestamp] = entry.Blocked
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"`
}
return queriesSeries, blockedSeries, nil
}
var topDomainsResponse topDomainsResponseJson
var topDomainsErr error
// Helper functions to process the responses
func parsePiholeStats(r piholeStatsResponse, topDomains piholeTopDomainsResponse, noGraph bool) *dnsStats {
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: r.Queries.Total,
BlockedQueries: r.Queries.Blocked,
BlockedPercent: int(r.Queries.PercentBlocked),
DomainsBlocked: r.Gravity.DomainsBlocked,
TotalQueries: statsResponse.Queries.Total,
BlockedQueries: statsResponse.Queries.Blocked,
BlockedPercent: int(statsResponse.Queries.PercentBlocked),
DomainsBlocked: statsResponse.Gravity.DomainsBlocked,
}
if len(topDomains.Domains) > 0 {
domains := make([]dnsStatsBlockedDomain, 0, len(topDomains.Domains))
for _, d := range topDomains.Domains {
ItsUsedTrustMeBro(seriesResponse, topDomainsResponse)
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(r.Queries.Blocked) * 100),
PercentBlocked: int(float64(d.Count) / float64(statsResponse.Queries.Blocked) * 100),
})
}
@ -476,204 +597,71 @@ func parsePiholeStats(r piholeStatsResponse, topDomains piholeTopDomainsResponse
})
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
}
if noGraph {
return stats
}
// Pihole _should_ return data for the last 24 hours
if len(r.QueriesSeries) != 145 || len(r.BlockedSeries) != 145 {
return stats
}
var lowestTimestamp int64 = 0
for timestamp := range r.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 += r.QueriesSeries[index]
blocked += r.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
}
func parsePiholeStatsLegacy(r legacyPiholeStatsResponse, noGraph bool) *dnsStats {
stats := &dnsStats{
TotalQueries: r.TotalQueries,
BlockedQueries: r.BlockedQueries,
BlockedPercent: int(r.BlockedPercentage),
DomainsBlocked: r.DomainsBlocked,
}
if len(r.TopBlockedDomains) > 0 {
domains := make([]dnsStatsBlockedDomain, 0, len(r.TopBlockedDomains))
for domain, count := range r.TopBlockedDomains {
domains = append(domains, dnsStatsBlockedDomain{
Domain: domain,
PercentBlocked: int(float64(count) / float64(r.BlockedQueries) * 100),
})
}
sort.Slice(domains, func(a, b int) bool {
return domains[a].PercentBlocked > domains[b].PercentBlocked
})
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
}
if noGraph {
return stats
}
// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
if len(r.QueriesSeries) != 144 || len(r.BlockedSeries) != 144 {
return stats
}
var lowestTimestamp int64 = 0
for timestamp := range r.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 += r.QueriesSeries[index]
blocked += r.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
return stats, sessionID, ternary(partialContent, errPartialContent, nil)
}
func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool, version, appPassword string) (*dnsStats, error) {
instanceURL = strings.TrimRight(instanceURL, "/")
var requestURL string
var sid string
isV6 := version == "" || version == "6"
func fetchPiholeSessionID(instanceURL string, client *http.Client, password string) (string, error) {
requestBody := []byte(`{"password":"` + password + `"}`)
if isV6 {
if appPassword == "" {
return nil, errors.New("missing app password")
}
sid = os.Getenv("SID")
if sid == "" {
newSid, err := piholeGetSID(instanceURL, appPassword, allowInsecure)
if err != nil {
return nil, fmt.Errorf("failed to get SID: %w", err)
}
sid = newSid
os.Setenv("SID", sid)
} else {
err := checkPiholeSID(instanceURL, sid, allowInsecure)
if err != nil {
newSid, err := piholeGetSID(instanceURL, appPassword, allowInsecure)
if err != nil {
return nil, fmt.Errorf("failed to get SID after invalid check: %w", err)
}
sid = newSid
os.Setenv("SID", sid)
}
}
requestURL = instanceURL + "/api/stats/summary"
} else {
if token == "" {
return nil, errors.New("missing API token")
}
requestURL = instanceURL + "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
}
request, err := http.NewRequest("GET", requestURL, nil)
request, err := http.NewRequest("POST", instanceURL+"/api/auth", bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
if isV6 {
request.Header.Set("x-ftl-sid", sid)
}
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
}
var responseJson interface{}
if isV6 {
responseJson, err = decodeJsonFromRequest[piholeStatsResponse](client, request)
} else {
responseJson, err = decodeJsonFromRequest[legacyPiholeStatsResponse](client, request)
return "", fmt.Errorf("creating authentication request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("failed to decode JSON response: %w", err)
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)
}
switch r := responseJson.(type) {
case piholeStatsResponse:
// Fetch top domains separately for v6+
topDomains, err := fetchPiholeTopDomains(instanceURL, sid, allowInsecure)
if err != nil {
return nil, fmt.Errorf("failed to fetch top domains: %w", err)
}
// Fetch series data separately for v6+
queriesSeries, blockedSeries, err := fetchPiholeSeries(instanceURL, sid, allowInsecure)
if err != nil {
return nil, fmt.Errorf("failed to fetch queries series: %w", err)
}
// Merge series data
r.QueriesSeries = queriesSeries
r.BlockedSeries = blockedSeries
return parsePiholeStats(r, topDomains, noGraph), nil
case legacyPiholeStatsResponse:
return parsePiholeStatsLegacy(r, noGraph), nil
default:
return nil, errors.New("unexpected response type")
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 "", errors.New("authentication response returned empty session ID")
}
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
}

View File

@ -82,16 +82,6 @@ func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T,
return result, nil
}
func decodeJsonInto[T any](client requestDoer, request *http.Request, out *T) error {
result, err := decodeJsonFromRequest[T](client, request)
if err != nil {
return err
}
*out = result
return nil
}
func decodeJsonFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) {
return func(request *http.Request) (T, error) {
return decodeJsonFromRequest[T](client, request)