Svilen Markov пре 10 месеци
родитељ
комит
1df080983a

+ 51 - 0
docs/configuration.md

@@ -18,6 +18,7 @@
   - [Weather](#weather)
   - [Monitor](#monitor)
   - [Releases](#releases)
+  - [DNS Stats](#dns-stats)
   - [Repository](#repository)
   - [Bookmarks](#bookmarks)
   - [Calendar](#calendar)
@@ -1120,6 +1121,56 @@ The maximum number of releases to show.
 #### `collapse-after`
 How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
 
+### DNS Stats
+Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home or Pi-hole.
+
+Example:
+
+```yaml
+- type: dns-stats
+  service: adguard
+  url: https://adguard.domain.com/
+  username: admin
+  password: ${ADGUARD_PASSWORD}
+```
+
+Preview:
+
+![](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.
 

BIN
docs/images/dns-stats-widget-preview.png


+ 145 - 2
internal/assets/static/main.css

@@ -35,7 +35,11 @@
     --color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
     --color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
     --color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
-    --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 10%)));
+    --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
+
+    --color-progress-bar-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
+    --color-progress-bar-background: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 27% * var(--cm))));
+    --color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
 
     --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
     --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
@@ -126,6 +130,15 @@
 .list-gap-24 { --list-half-gap: 1.2rem; }
 .list-gap-34 { --list-half-gap: 1.7rem; }
 
+.page-columns-transitioned .list-with-transition > * { animation: collapsibleItemReveal .25s backwards; }
+.list-with-transition > *:nth-child(2) { animation-delay: 30ms; }
+.list-with-transition > *:nth-child(3) { animation-delay: 60ms; }
+.list-with-transition > *:nth-child(4) { animation-delay: 90ms; }
+.list-with-transition > *:nth-child(5) { animation-delay: 120ms; }
+.list-with-transition > *:nth-child(6) { animation-delay: 150ms; }
+.list-with-transition > *:nth-child(7) { animation-delay: 180ms; }
+.list-with-transition > *:nth-child(8) { animation-delay: 210ms; }
+
 .list > *:not(:first-child) {
     margin-top: calc(var(--list-half-gap) * 2);
 }
@@ -649,7 +662,10 @@ details[open] .summary::after {
 @container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
 @container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
 
-
+.widget-small-content-bounds {
+    max-width: 350px;
+    margin: 0 auto;
+}
 
 .widget-error-header {
     display: flex;
@@ -1003,12 +1019,136 @@ details[open] .summary::after {
     padding: 0.6rem 0;
 }
 
+
 .calendar-day-today {
     border-radius: var(--border-radius);
     background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
     color: var(--color-text-highlight);
 }
 
+.dns-stats-totals {
+    transition: opacity .3s;
+    transition-delay: 50ms;
+}
+
+.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals {
+    opacity: 0.1;
+    transition-delay: 0s;
+}
+
+.dns-stats-graph {
+    --graph-height: 70px;
+    height: var(--graph-height);
+    position: relative;
+    margin-bottom: 2.5rem;
+}
+
+.dns-stats-graph-gridlines-container {
+    position: absolute;
+    z-index: -1;
+    inset: 0;
+}
+
+.dns-stats-graph-gridlines {
+    height: 100%;
+    width: 100%;
+}
+
+.dns-stats-graph-columns {
+    display: flex;
+    height: 100%;
+}
+
+.dns-stats-graph-column {
+    display: flex;
+    justify-content: flex-end;
+    align-items: center;
+    flex-direction: column;
+    width: calc(100% / 8);
+    position: relative;
+}
+
+.dns-stats-graph-column::before {
+    content: '';
+    position: absolute;
+    inset: 1px 0;
+    z-index: -1;
+    opacity: 0;
+    background: var(--color-text-base);
+    transition: opacity .2s;
+}
+
+.dns-stats-graph-column:hover::before {
+    opacity: 0.05;
+}
+
+.dns-stats-graph-bar {
+    width: 14px;
+    height: calc((var(--bar-height) / 100) * var(--graph-height));
+    border: 1px solid var(--color-progress-bar-border);
+    border-radius: var(--border-radius) var(--border-radius) 0 0;
+    display: flex;
+    background: var(--color-widget-background);
+    padding: 2px 2px 0 2px;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    transition: border-color .2s;
+    min-height: 10px;
+}
+
+.dns-stats-graph-column.popover-active .dns-stats-graph-bar {
+    border-color: var(--color-text-subdue);
+    border-bottom-color: var(--color-progress-bar-border);
+}
+
+.dns-stats-graph-bar > * {
+    border-radius: 2px;
+    background: var(--color-progress-bar-background);
+    min-height: 1px;
+}
+
+.dns-stats-graph-bar > .queries {
+    flex-grow: 1;
+}
+
+.dns-stats-graph-bar > *:last-child {
+    border-bottom-right-radius: 0;
+    border-bottom-left-radius: 0;
+}
+
+.dns-stats-graph-bar > .blocked {
+    background-color: var(--color-negative);
+}
+
+.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
+    opacity: 1;
+    transform: translateY(0);
+}
+
+.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time {
+    position: absolute;
+    font-size: var(--font-size-h6);
+    inset-inline: 0;
+    text-align: center;
+    height: 2.5rem;
+    line-height: 2.5rem;
+    top: 100%;
+    user-select: none;
+    opacity: 0;
+    transform: translateY(-0.5rem);
+    transition: opacity .2s, transform .2s;
+}
+
+.dns-stats-graph-column:hover .dns-stats-graph-time {
+    opacity: 1;
+    transform: translateY(0);
+}
+
+.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time {
+    opacity: 0;
+}
+
 .weather-column {
     position: relative;
     display: flex;
@@ -1547,6 +1687,7 @@ details[open] .summary::after {
 .color-positive     { color: var(--color-positive); }
 .color-primary      { color: var(--color-primary); }
 
+.cursor-help        { cursor: help; }
 .break-all          { word-break: break-all; }
 .text-left          { text-align: left; }
 .text-right         { text-align: right; }
@@ -1592,6 +1733,8 @@ details[open] .summary::after {
 .margin-top-15      { margin-top: 1.5rem; }
 .margin-top-20      { margin-top: 2rem; }
 .margin-top-25      { margin-top: 2.5rem; }
+.margin-top-35      { margin-top: 3.5rem; }
+.margin-top-40      { margin-top: 4rem; }
 .margin-top-auto    { margin-top: auto; }
 .margin-block-3     { margin-block: 0.3rem; }
 .margin-block-5     { margin-block: 0.5rem; }

+ 1 - 0
internal/assets/templates.go

@@ -38,6 +38,7 @@ var (
 	SearchTemplate                = compileTemplate("search.html", "widget-base.html")
 	ExtensionTemplate             = compileTemplate("extension.html", "widget-base.html")
 	GroupTemplate                 = compileTemplate("group.html", "widget-base.html")
+	DNSStatsTemplate              = compileTemplate("dns-stats.html", "widget-base.html")
 )
 
 var globalTemplateFunctions = template.FuncMap{

+ 85 - 0
internal/assets/templates/dns-stats.html

@@ -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 - 0
internal/feed/adguard.go

@@ -0,0 +1,99 @@
+package feed
+
+import (
+	"net/http"
+	"strings"
+)
+
+type adguardStatsResponse struct {
+	TotalQueries      int              `json:"num_dns_queries"`
+	QueriesSeries     []int            `json:"dns_queries"`
+	BlockedQueries    int              `json:"num_blocked_filtering"`
+	BlockedSeries     []int            `json:"blocked_filtering"`
+	ResponseTime      float64          `json:"avg_processing_time"`
+	TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
+}
+
+func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) {
+	requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
+
+	request, err := http.NewRequest("GET", requestURL, nil)
+
+	if err != nil {
+		return nil, err
+	}
+
+	request.SetBasicAuth(username, password)
+
+	responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request)
+
+	if err != nil {
+		return nil, err
+	}
+
+	stats := &DNSStats{
+		TotalQueries:   responseJson.TotalQueries,
+		BlockedQueries: responseJson.BlockedQueries,
+		ResponseTime:   int(responseJson.ResponseTime * 1000),
+	}
+
+	if stats.TotalQueries <= 0 {
+		return stats, nil
+	}
+
+	stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
+
+	var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
+
+	for i := 0; i < topBlockedDomainsCount; i++ {
+		domain := responseJson.TopBlockedDomains[i]
+		var firstDomain string
+
+		for k := range domain {
+			firstDomain = k
+			break
+		}
+
+		if firstDomain == "" {
+			continue
+		}
+
+		stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{
+			Domain:         firstDomain,
+			PercentBlocked: int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100),
+		})
+	}
+
+	// Adguard _should_ return data for the last 24 hours in a 1 hour interval
+	if len(responseJson.QueriesSeries) != 24 || len(responseJson.BlockedSeries) != 24 {
+		return stats, nil
+	}
+
+	maxQueriesInSeries := 0
+
+	for i := 0; i < 8; i++ {
+		queries := 0
+		blocked := 0
+
+		for j := 0; j < 3; j++ {
+			queries += responseJson.QueriesSeries[i*3+j]
+			blocked += responseJson.BlockedSeries[i*3+j]
+		}
+
+		stats.Series[i] = DNSStatsSeries{
+			Queries:        queries,
+			Blocked:        blocked,
+			PercentBlocked: int(float64(blocked) / float64(queries) * 100),
+		}
+
+		if queries > maxQueriesInSeries {
+			maxQueriesInSeries = queries
+		}
+	}
+
+	for i := 0; i < 8; i++ {
+		stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
+	}
+
+	return stats, nil
+}

+ 109 - 0
internal/feed/pihole.go

@@ -0,0 +1,109 @@
+package feed
+
+import (
+	"errors"
+	"net/http"
+	"sort"
+	"strings"
+)
+
+type piholeStatsResponse struct {
+	TotalQueries      int            `json:"dns_queries_today"`
+	QueriesSeries     map[int64]int  `json:"domains_over_time"`
+	BlockedQueries    int            `json:"ads_blocked_today"`
+	BlockedSeries     map[int64]int  `json:"ads_over_time"`
+	BlockedPercentage float64        `json:"ads_percentage_today"`
+	TopBlockedDomains map[string]int `json:"top_ads"`
+	DomainsBlocked    int            `json:"domains_being_blocked"`
+}
+
+func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
+	if token == "" {
+		return nil, errors.New("missing API token")
+	}
+
+	requestURL := strings.TrimRight(instanceURL, "/") +
+		"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
+
+	request, err := http.NewRequest("GET", requestURL, nil)
+
+	if err != nil {
+		return nil, err
+	}
+
+	responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request)
+
+	if err != nil {
+		return nil, err
+	}
+
+	stats := &DNSStats{
+		TotalQueries:   responseJson.TotalQueries,
+		BlockedQueries: responseJson.BlockedQueries,
+		BlockedPercent: int(responseJson.BlockedPercentage),
+		DomainsBlocked: responseJson.DomainsBlocked,
+	}
+
+	if len(responseJson.TopBlockedDomains) > 0 {
+		domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
+
+		for domain, count := range responseJson.TopBlockedDomains {
+			domains = append(domains, DNSStatsBlockedDomain{
+				Domain:         domain,
+				PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
+			})
+		}
+
+		sort.Slice(domains, func(a, b int) bool {
+			return domains[a].PercentBlocked > domains[b].PercentBlocked
+		})
+
+		stats.TopBlockedDomains = domains[:min(len(domains), 5)]
+	}
+
+	// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
+	if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
+		return stats, nil
+	}
+
+	var lowestTimestamp int64 = 0
+
+	for timestamp := range responseJson.QueriesSeries {
+		if lowestTimestamp == 0 || timestamp < lowestTimestamp {
+			lowestTimestamp = timestamp
+		}
+	}
+
+	maxQueriesInSeries := 0
+
+	for i := 0; i < 8; i++ {
+		queries := 0
+		blocked := 0
+
+		for j := 0; j < 18; j++ {
+			index := lowestTimestamp + int64(i*10800+j*600)
+
+			queries += responseJson.QueriesSeries[index]
+			blocked += responseJson.BlockedSeries[index]
+		}
+
+		if queries > maxQueriesInSeries {
+			maxQueriesInSeries = queries
+		}
+
+		stats.Series[i] = DNSStatsSeries{
+			Queries: queries,
+			Blocked: blocked,
+		}
+
+		if queries > 0 {
+			stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
+		}
+	}
+
+	for i := 0; i < 8; i++ {
+		stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
+	}
+
+	return stats, nil
+}

+ 22 - 0
internal/feed/primitives.go

@@ -86,6 +86,28 @@ var currencyToSymbol = map[string]string{
 	"PHP": "₱",
 }
 
+type DNSStats struct {
+	TotalQueries      int
+	BlockedQueries    int
+	BlockedPercent    int
+	ResponseTime      int
+	DomainsBlocked    int
+	Series            [8]DNSStatsSeries
+	TopBlockedDomains []DNSStatsBlockedDomain
+}
+
+type DNSStatsSeries struct {
+	Queries        int
+	Blocked        int
+	PercentTotal   int
+	PercentBlocked int
+}
+
+type DNSStatsBlockedDomain struct {
+	Domain         string
+	PercentBlocked int
+}
+
 type MarketRequest struct {
 	Name       string `yaml:"name"`
 	Symbol     string `yaml:"symbol"`

+ 77 - 0
internal/widget/dns-stats.go

@@ -0,0 +1,77 @@
+package widget
+
+import (
+	"context"
+	"errors"
+	"html/template"
+	"strings"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+	"github.com/glanceapp/glance/internal/feed"
+)
+
+type DNSStats struct {
+	widgetBase `yaml:",inline"`
+
+	TimeLabels [8]string      `yaml:"-"`
+	Stats      *feed.DNSStats `yaml:"-"`
+
+	HourFormat string            `yaml:"hour-format"`
+	Service    string            `yaml:"service"`
+	URL        OptionalEnvString `yaml:"url"`
+	Token      OptionalEnvString `yaml:"token"`
+	Username   OptionalEnvString `yaml:"username"`
+	Password   OptionalEnvString `yaml:"password"`
+}
+
+func makeDNSTimeLabels(format string) [8]string {
+	now := time.Now()
+	var labels [8]string
+
+	for i := 24; i > 0; i -= 3 {
+		labels[7-(i/3-1)] = strings.ToLower(now.Add(-time.Duration(i) * time.Hour).Format(format))
+	}
+
+	return labels
+}
+
+func (widget *DNSStats) Initialize() error {
+	widget.
+		withTitle("DNS Stats").
+		withTitleURL(string(widget.URL)).
+		withCacheDuration(10 * time.Minute)
+
+	if widget.Service != "adguard" && widget.Service != "pihole" {
+		return errors.New("DNS stats service must be either 'adguard' or 'pihole'")
+	}
+
+	return nil
+}
+
+func (widget *DNSStats) Update(ctx context.Context) {
+	var stats *feed.DNSStats
+	var err error
+
+	if widget.Service == "adguard" {
+		stats, err = feed.FetchAdguardStats(string(widget.URL), string(widget.Username), string(widget.Password))
+	} else {
+		stats, err = feed.FetchPiholeStats(string(widget.URL), string(widget.Token))
+	}
+
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	if widget.HourFormat == "24h" {
+		widget.TimeLabels = makeDNSTimeLabels("15:00")
+	} else {
+		widget.TimeLabels = makeDNSTimeLabels("3PM")
+	}
+
+	widget.Stats = stats
+}
+
+func (widget *DNSStats) Render() template.HTML {
+	return widget.render(widget, assets.DNSStatsTemplate)
+}

+ 2 - 0
internal/widget/widget.go

@@ -65,6 +65,8 @@ func New(widgetType string) (Widget, error) {
 		widget = &Extension{}
 	case "group":
 		widget = &Group{}
+	case "dns-stats":
+		widget = &DNSStats{}
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 	}