mirror of
https://github.com/glanceapp/glance.git
synced 2025-06-21 10:27:45 +02:00
Merge branch 'main' into dev
This commit is contained in:
commit
2bde4656ed
@ -1827,14 +1827,14 @@ 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 `pihole-v6` | |
|
||||
| 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`, `pihole`, or `technitium`.
|
||||
Either `adguard`, `technitium`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above).
|
||||
|
||||
##### `allow-insecure`
|
||||
Whether to allow invalid/self-signed certificates when making the request to the service.
|
||||
@ -1846,10 +1846,14 @@ The base URL of the service.
|
||||
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.
|
||||
Required when using AdGuard Home, where the password is the one used to log into the admin dashboard.
|
||||
|
||||
Also required 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`.
|
||||
|
||||
##### `token`
|
||||
Only required when using Pi-hole or Technitium. For Pi-hole, the API token which can be found in `Settings -> API -> Show API token`; for Technitium, an API token can be generated at `Administration -> Sessions -> Create Token`.
|
||||
Required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`.
|
||||
|
||||
Also required when using Technitium, an API token can be generated at `Administration -> Sessions -> Create Token`.
|
||||
|
||||
##### `hide-graph`
|
||||
Whether to hide the graph showing the number of queries over time.
|
||||
|
@ -66,9 +66,6 @@ pages:
|
||||
# hide-location: true
|
||||
|
||||
- type: markets
|
||||
# The link to go to when clicking on the symbol in the UI,
|
||||
# {SYMBOL} will be substituded with the symbol for each market
|
||||
symbol-link-template: https://www.tradingview.com/symbols/{SYMBOL}/news
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
|
@ -552,6 +552,10 @@ kbd:active {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.details[open] .summary {
|
||||
margin-bottom: .8rem;
|
||||
}
|
||||
@ -1128,7 +1132,7 @@ details[open] .summary::after {
|
||||
|
||||
.calendar-date {
|
||||
padding: 0.4rem 0;
|
||||
color: var(--color-text-paragraph);
|
||||
color: var(--color-text-base);
|
||||
position: relative;
|
||||
border-radius: var(--border-radius);
|
||||
background: none;
|
||||
@ -1269,6 +1273,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 {
|
||||
|
@ -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 }}
|
||||
|
@ -1,10 +1,10 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{- define "widget-content" }}
|
||||
<div class="dynamic-columns list-gap-20 list-with-separator">
|
||||
<ul class="dynamic-columns list-gap-20 list-with-separator">
|
||||
{{- range .Containers }}
|
||||
<div class="docker-container flex items-center gap-15">
|
||||
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px">
|
||||
<li class="docker-container flex items-center gap-15">
|
||||
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px" aria-hidden="true">
|
||||
<img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
<div data-popover-html>
|
||||
<div class="color-highlight text-truncate block">{{ .Image }}</div>
|
||||
@ -33,31 +33,33 @@
|
||||
{{- end }}
|
||||
</div>
|
||||
|
||||
<div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}">
|
||||
<div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}" aria-label="{{ .State }}">
|
||||
{{ template "state-icon" .StateIcon }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visually-hidden" aria-label="{{ .StateText }}"></div>
|
||||
</li>
|
||||
{{- else }}
|
||||
<div class="text-center">No containers available to show.</div>
|
||||
{{- end }}
|
||||
</div>
|
||||
</ul>
|
||||
{{- end }}
|
||||
|
||||
{{- define "state-icon" }}
|
||||
{{- if eq . "ok" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{- else if eq . "warn" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{- else if eq . "paused" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{- else }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{- end }}
|
||||
|
@ -6,7 +6,7 @@
|
||||
<li class="flex thumbnail-parent gap-10 items-center">
|
||||
<img class="video-horizontal-list-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||
<div class="min-width-0">
|
||||
<a class="block text-truncate color-primary-if-not-visited" href="{{ .Url }}">{{ .Title }}</a>
|
||||
<a class="block text-truncate color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text flex-nowrap">
|
||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||
<li class="min-width-0">
|
||||
|
@ -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) {}
|
||||
|
@ -266,12 +266,12 @@ func (r *decoratedGJSONResult) String(key string) string {
|
||||
return r.Get(key).String()
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Int(key string) int64 {
|
||||
func (r *decoratedGJSONResult) Int(key string) int {
|
||||
if key == "" {
|
||||
return r.Result.Int()
|
||||
return int(r.Result.Int())
|
||||
}
|
||||
|
||||
return r.Get(key).Int()
|
||||
return int(r.Get(key).Int())
|
||||
}
|
||||
|
||||
func (r *decoratedGJSONResult) Float(key string) float64 {
|
||||
@ -292,11 +292,11 @@ func (r *decoratedGJSONResult) Bool(key string) bool {
|
||||
|
||||
var customAPITemplateFuncs = func() template.FuncMap {
|
||||
funcs := template.FuncMap{
|
||||
"toFloat": func(a int64) float64 {
|
||||
"toFloat": func(a int) float64 {
|
||||
return float64(a)
|
||||
},
|
||||
"toInt": func(a float64) int64 {
|
||||
return int64(a)
|
||||
"toInt": func(a float64) int {
|
||||
return int(a)
|
||||
},
|
||||
"add": func(a, b float64) float64 {
|
||||
return a + b
|
||||
|
@ -1,14 +1,18 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -23,8 +27,9 @@ const (
|
||||
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"`
|
||||
@ -37,11 +42,18 @@ type dnsStatsWidget struct {
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
const (
|
||||
dnsServiceAdguard = "adguard"
|
||||
dnsServicePihole = "pihole"
|
||||
dnsServiceTechnitium = "technitium"
|
||||
dnsServicePiholeV6 = "pihole-v6"
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@ -55,11 +67,12 @@ func (widget *dnsStatsWidget) initialize() error {
|
||||
withCacheDuration(10 * time.Minute)
|
||||
|
||||
switch widget.Service {
|
||||
case "adguard":
|
||||
case "pihole":
|
||||
case "technitium":
|
||||
case dnsServiceAdguard:
|
||||
case dnsServicePiholeV6:
|
||||
case dnsServicePihole:
|
||||
case dnsServiceTechnitium:
|
||||
default:
|
||||
return errors.New("service must be either 'adguard', 'pihole', or 'technitium'")
|
||||
return fmt.Errorf("service must be one of: %s, %s, %s, %s", dnsServiceAdguard, dnsServicePihole, dnsServicePiholeV6, dnsServiceTechnitium)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -70,12 +83,25 @@ func (widget *dnsStatsWidget) update(ctx context.Context) {
|
||||
var err error
|
||||
|
||||
switch widget.Service {
|
||||
case "adguard":
|
||||
case dnsServiceAdguard:
|
||||
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph)
|
||||
case "pihole":
|
||||
stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||
case "technitium":
|
||||
case dnsServicePihole:
|
||||
stats, err = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||
case dnsServiceTechnitium:
|
||||
stats, err = fetchTechnitiumStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||
case dnsServicePiholeV6:
|
||||
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) {
|
||||
@ -97,11 +123,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
|
||||
}
|
||||
|
||||
@ -136,13 +162,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
|
||||
@ -163,7 +183,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
|
||||
|
||||
@ -206,11 +226,11 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
||||
|
||||
maxQueriesInSeries := 0
|
||||
|
||||
for i := 0; i < dnsStatsBars; i++ {
|
||||
for i := range dnsStatsBars {
|
||||
queries := 0
|
||||
blocked := 0
|
||||
|
||||
for j := 0; j < dnsStatsHoursPerBar; j++ {
|
||||
for j := range dnsStatsHoursPerBar {
|
||||
queries += queriesSeries[i*dnsStatsHoursPerBar+j]
|
||||
blocked += blockedSeries[i*dnsStatsHoursPerBar+j]
|
||||
}
|
||||
@ -229,35 +249,36 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < dnsStatsBars; i++ {
|
||||
for i := range dnsStatsBars {
|
||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
type piholeStatsResponse 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"`
|
||||
// Legacy Pi-hole stats response (before v6)
|
||||
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
|
||||
}
|
||||
@ -267,16 +288,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
|
||||
}
|
||||
@ -284,7 +305,7 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
||||
func fetchPihole5Stats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
||||
if token == "" {
|
||||
return nil, errors.New("missing API token")
|
||||
}
|
||||
@ -297,14 +318,8 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var client requestDoer
|
||||
if !allowInsecure {
|
||||
client = defaultHTTPClient
|
||||
} else {
|
||||
client = defaultInsecureHTTPClient
|
||||
}
|
||||
|
||||
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request)
|
||||
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||
responseJson, err := decodeJsonFromRequest[pihole5StatsResponse](client, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -348,7 +363,6 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
||||
}
|
||||
|
||||
var lowestTimestamp int64 = 0
|
||||
|
||||
for timestamp := range responseJson.QueriesSeries {
|
||||
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
|
||||
lowestTimestamp = timestamp
|
||||
@ -357,11 +371,11 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
||||
|
||||
maxQueriesInSeries := 0
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
for i := range dnsStatsBars {
|
||||
queries := 0
|
||||
blocked := 0
|
||||
|
||||
for j := 0; j < 18; j++ {
|
||||
for j := range 18 {
|
||||
index := lowestTimestamp + int64(i*10800+j*600)
|
||||
|
||||
queries += responseJson.QueriesSeries[index]
|
||||
@ -382,13 +396,287 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
for i := range dnsStatsBars {
|
||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
fetchNewSessionID := func() error {
|
||||
newSessionID, err := fetchPiholeSessionID(instanceURL, client, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessionID = newSessionID
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Blocked int `json:"blocked"`
|
||||
} `json:"history"`
|
||||
}
|
||||
|
||||
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)
|
||||
}()
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
var topDomainsResponse topDomainsResponseJson
|
||||
var topDomainsErr error
|
||||
|
||||
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: statsResponse.Queries.Total,
|
||||
BlockedQueries: statsResponse.Queries.Blocked,
|
||||
BlockedPercent: int(statsResponse.Queries.PercentBlocked),
|
||||
DomainsBlocked: statsResponse.Gravity.DomainsBlocked,
|
||||
}
|
||||
|
||||
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(statsResponse.Queries.Blocked) * 100),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(domains, func(a, b int) bool {
|
||||
return domains[a].PercentBlocked > domains[b].PercentBlocked
|
||||
})
|
||||
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
|
||||
}
|
||||
|
||||
return stats, sessionID, ternary(partialContent, errPartialContent, nil)
|
||||
}
|
||||
|
||||
func fetchPiholeSessionID(instanceURL string, client *http.Client, password string) (string, error) {
|
||||
requestBody := []byte(`{"password":"` + password + `"}`)
|
||||
|
||||
request, err := http.NewRequest("POST", instanceURL+"/api/auth", bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating authentication request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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 "", fmt.Errorf(
|
||||
"authentication response returned empty session ID, status code %d, message '%s'",
|
||||
response.StatusCode, jsonResponse.Session.Message,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type technitiumStatsResponse struct {
|
||||
Response struct {
|
||||
Stats struct {
|
||||
|
@ -56,7 +56,7 @@ func (widget *videosWidget) initialize() error {
|
||||
widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...)
|
||||
|
||||
for i := range widget.Playlists {
|
||||
widget.Channels[initialLen+i] = "playlist:" + widget.Playlists[i]
|
||||
widget.Channels[initialLen+i] = videosWidgetPlaylistPrefix + widget.Playlists[i]
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user