Compare commits

..

1 commit
dev ... main

Author SHA1 Message Date
Svilen Markov
3b79c8e09f Remove symbol-link-template 2025-03-15 10:27:39 +00:00
15 changed files with 123 additions and 608 deletions

View file

@ -93,13 +93,13 @@ something: \${NOT_AN_ENV_VAR}
``` ```
### Including other config files ### Including other config files
Including config files from within your main config file is supported. This is done via the `$include` directive along with a relative or absolute path to the file you want to include. If the path is relative, it will be relative to the main config file. Additionally, environment variables can be used within included files, and changes to the included files will trigger an automatic reload. Example: Including config files from within your main config file is supported. This is done via the `!include` directive along with a relative or absolute path to the file you want to include. If the path is relative, it will be relative to the main config file. Additionally, environment variables can be used within included files, and changes to the included files will trigger an automatic reload. Example:
```yaml ```yaml
pages: pages:
- $include: home.yml !include: home.yml
- $include: videos.yml !include: videos.yml
- $include: homelab.yml !include: homelab.yml
``` ```
The file you are including should not have any additional indentation, its values should be at the top level and the appropriate amount of indentation will be added automatically depending on where the file is included. Example: The file you are including should not have any additional indentation, its values should be at the top level and the appropriate amount of indentation will be added automatically depending on where the file is included. Example:
@ -112,14 +112,14 @@ pages:
columns: columns:
- size: full - size: full
widgets: widgets:
$include: rss.yml !include: rss.yml
- name: News - name: News
columns: columns:
- size: full - size: full
widgets: widgets:
- type: group - type: group
widgets: widgets:
$include: rss.yml !include: rss.yml
- type: reddit - type: reddit
subreddit: news subreddit: news
``` ```
@ -133,9 +133,9 @@ pages:
- url: ${RSS_URL} - url: ${RSS_URL}
``` ```
The `$include` directive can be used anywhere in the config file, not just in the `pages` property, however it must be on its own line and have the appropriate indentation. The `!include` directive can be used anywhere in the config file, not just in the `pages` property, however it must be on its own line and have the appropriate indentation.
If you encounter YAML parsing errors when using the `$include` directive, the reported line numbers will likely be incorrect. This is because the inclusion of files is done before the YAML is parsed, as YAML itself does not support file inclusion. To help with debugging in cases like this, you can use the `config:print` command and pipe it into `less -N` to see the full config file with includes resolved and line numbers added: If you encounter YAML parsing errors when using the `!include` directive, the reported line numbers will likely be incorrect. This is because the inclusion of files is done before the YAML is parsed, as YAML itself does not support file inclusion. To help with debugging in cases like this, you can use the `config:print` command and pipe it into `less -N` to see the full config file with includes resolved and line numbers added:
```sh ```sh
glance --config /path/to/glance.yml config:print | less -N glance --config /path/to/glance.yml config:print | less -N
@ -1294,8 +1294,6 @@ Examples:
| headers | key (string) & value (string) | no | | | headers | key (string) & value (string) | no | |
| frameless | boolean | no | false | | frameless | boolean | no | false |
| template | string | yes | | | template | string | yes | |
| parameters | key (string) & value (string|array) | no | |
| subrequests | map of requests | no | |
##### `url` ##### `url`
The URL to fetch the data from. It must be accessible from the server that Glance is running on. The URL to fetch the data from. It must be accessible from the server that Glance is running on.
@ -1315,55 +1313,6 @@ When set to `true`, removes the border and padding around the widget.
##### `template` ##### `template`
The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md). The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md).
##### `parameters`
A list of keys and values that will be sent to the custom-api as query paramters.
##### `subrequests`
A map of additional requests that will be executed concurrently and then made available in the template via the `.Subrequest` property. Example:
```yaml
- type: custom-api
cache: 2h
subrequests:
another-one:
url: https://uselessfacts.jsph.pl/api/v2/facts/random
title: Random Fact
url: https://uselessfacts.jsph.pl/api/v2/facts/random
template: |
<p class="size-h4 color-paragraph">{{ .JSON.String "text" }}</p>
<p class="size-h4 color-paragraph margin-top-15">{{ (.Subrequest "another-one").JSON.String "text" }}</p>
```
The subrequests support all the same properties as the main request, except for `subrequests` itself, so you can use `headers`, `parameters`, etc.
`(.Subrequest "key")` can be a little cumbersome to write, so you can define a variable to make it easier:
```yaml
template: |
{{ $anotherOne := .Subrequest "another-one" }}
<p>{{ $anotherOne.JSON.String "text" }}</p>
```
You can also access the `.Response` property of a subrequest as you would with the main request:
```yaml
template: |
{{ $anotherOne := .Subrequest "another-one" }}
<p>{{ $anotherOne.Response.StatusCode }}</p>
```
> [!NOTE]
>
> Setting this property will override any query parameters that are already in the URL.
```yaml
parameters:
param1: value1
param2:
- item1
- item2
```
### Extension ### Extension
Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP). Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
@ -1527,7 +1476,6 @@ Properties for each site:
| allow-insecure | boolean | no | false | | allow-insecure | boolean | no | false |
| same-tab | boolean | no | false | | same-tab | boolean | no | false |
| alt-status-codes | array | no | | | alt-status-codes | array | no | |
| basic-auth | object | no | |
`title` `title`
@ -1576,16 +1524,6 @@ alt-status-codes:
- 403 - 403
``` ```
`basic-auth`
HTTP Basic Authentication credentials for protected sites.
```yaml
basic-auth:
usename: your-username
password: your-password
```
### Releases ### Releases
Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub. Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
@ -1799,7 +1737,7 @@ The path to the Docker socket.
| glance.parent | The ID of the parent container. Used to group containers under a single parent. | | glance.parent | The ID of the parent container. Used to group containers under a single parent. |
### DNS Stats ### DNS Stats
Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, or Technitium. Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home or Pi-hole.
Example: Example:
@ -1817,7 +1755,7 @@ Preview:
> [!NOTE] > [!NOTE]
> >
> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole or Technitium it will be the total number of blocked domains from all adlists. > 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 #### Properties
@ -1834,22 +1772,22 @@ Preview:
| hour-format | string | no | 12h | | hour-format | string | no | 12h |
##### `service` ##### `service`
Either `adguard`, `pihole`, or `technitium`. Either `adguard` or `pihole`.
##### `allow-insecure` ##### `allow-insecure`
Whether to allow invalid/self-signed certificates when making the request to the service. Whether to allow invalid/self-signed certificates when making the request to the service.
##### `url` ##### `url`
The base URL of the service. The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
##### `username` ##### `username`
Only required when using AdGuard Home. The username used to log into the admin dashboard. 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` ##### `password`
Only required when using AdGuard Home. The password used to log into the admin dashboard. 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` ##### `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`. 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}`.
##### `hide-graph` ##### `hide-graph`
Whether to hide the graph showing the number of queries over time. Whether to hide the graph showing the number of queries over time.
@ -2092,7 +2030,6 @@ An array of groups which can optionally have a title and a custom color.
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| title | string | yes | | | title | string | yes | |
| url | string | yes | | | url | string | yes | |
| description | string | no | |
| icon | string | no | | | icon | string | no | |
| same-tab | boolean | no | false | | same-tab | boolean | no | false |
| hide-arrow | boolean | no | false | | hide-arrow | boolean | no | false |

View file

@ -242,57 +242,6 @@ Other operations include `add`, `mul`, and `div`.
<hr> <hr>
JSON response:
```json
{
"posts": [
{
"title": "Exploring the Depths of Quantum Computing",
"date": "2023-10-27T10:00:00Z"
},
{
"title": "A Beginner's Guide to Sustainable Living",
"date": "2023-11-15T14:30:00+01:00"
},
{
"title": "The Art of Baking Sourdough Bread",
"date": "2023-12-03T08:45:22-08:00"
}
]
}
```
To parse the date and display the relative time (e.g. 2h, 1d, etc), you would use the following:
```
{{ range .JSON.Array "posts" }}
<div>{{ .String "title" }}</div>
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
{{ end }}
```
The `parseTime` function takes two arguments: the layout of the date string and the date string itself. The layout can be one of the following: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly" or a custom layout in Go's [date format](https://pkg.go.dev/time#pkg-constants).
Output:
```html
<div>Exploring the Depths of Quantum Computing</div>
<div data-dynamic-relative-time="1698400800"></div>
<div>A Beginner's Guide to Sustainable Living</div>
<div data-dynamic-relative-time="1700055000"></div>
<div>The Art of Baking Sourdough Bread</div>
<div data-dynamic-relative-time="1701621922"></div>
```
You don't have to worry about the internal implementation, this will then be dynamically populated by Glance on the client side to show the correct relative time.
The important thing to notice here is that the return value of `toRelativeTime` must be used as an attribute in an HTML tag, be it a `div`, `li`, `span`, etc.
<hr>
In some instances, you may want to know the status code of the response. This can be done using the following: In some instances, you may want to know the status code of the response. This can be done using the following:
```html ```html
@ -324,8 +273,6 @@ The following helper functions provided by Glance are available:
- `toFloat(i int) float`: Converts an integer to a float. - `toFloat(i int) float`: Converts an integer to a float.
- `toInt(f float) int`: Converts a float to an integer. - `toInt(f float) int`: Converts a float to an integer.
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. `<span {{ toRelativeTime .Time }}></span>`.
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly".
- `add(a, b float) float`: Adds two numbers. - `add(a, b float) float`: Adds two numbers.
- `sub(a, b float) float`: Subtracts two numbers. - `sub(a, b float) float`: Subtracts two numbers.
- `mul(a, b float) float`: Multiplies two numbers. - `mul(a, b float) float`: Multiplies two numbers.

View file

@ -66,9 +66,6 @@ pages:
# hide-location: true # hide-location: true
- type: markets - 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: markets:
- symbol: SPY - symbol: SPY
name: S&P 500 name: S&P 500

View file

@ -219,58 +219,3 @@ func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
return nil return nil
} }
type queryParametersField map[string][]string
func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
var decoded map[string]any
if err := node.Decode(&decoded); err != nil {
return err
}
*q = make(queryParametersField)
// TODO: refactor the duplication in the switch cases if any more types get added
for key, value := range decoded {
switch v := value.(type) {
case string:
(*q)[key] = []string{v}
case int, int8, int16, int32, int64, float32, float64:
(*q)[key] = []string{fmt.Sprintf("%v", v)}
case bool:
(*q)[key] = []string{fmt.Sprintf("%t", v)}
case []string:
(*q)[key] = append((*q)[key], v...)
case []any:
for _, item := range v {
switch item := item.(type) {
case string:
(*q)[key] = append((*q)[key], item)
case int, int8, int16, int32, int64, float32, float64:
(*q)[key] = append((*q)[key], fmt.Sprintf("%v", item))
case bool:
(*q)[key] = append((*q)[key], fmt.Sprintf("%t", item))
default:
return fmt.Errorf("invalid query parameter value type: %T", item)
}
}
default:
return fmt.Errorf("invalid query parameter value type: %T", value)
}
}
return nil
}
func (q *queryParametersField) toQueryString() string {
query := url.Values{}
for key, values := range *q {
for _, value := range values {
query.Add(key, value)
}
}
return query.Encode()
}

View file

@ -17,8 +17,6 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT = 20
type config struct { type config struct {
Server struct { Server struct {
Host string `yaml:"host"` Host string `yaml:"host"`
@ -146,31 +144,21 @@ func formatWidgetInitError(err error, w widget) error {
return fmt.Errorf("%s widget: %v", w.GetType(), err) return fmt.Errorf("%s widget: %v", w.GetType(), err)
} }
var includePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`) var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) { func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
return recursiveParseYAMLIncludes(mainFilePath, nil, 0)
}
func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{}, depth int) ([]byte, map[string]struct{}, error) {
if depth > CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT {
return nil, nil, fmt.Errorf("recursion depth limit of %d reached", CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT)
}
mainFileContents, err := os.ReadFile(mainFilePath) mainFileContents, err := os.ReadFile(mainFilePath)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, err) return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
} }
mainFileAbsPath, err := filepath.Abs(mainFilePath) mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err) return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
} }
mainFileDir := filepath.Dir(mainFileAbsPath) mainFileDir := filepath.Dir(mainFileAbsPath)
if includes == nil { includes := make(map[string]struct{})
includes = make(map[string]struct{})
}
var includesLastErr error var includesLastErr error
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte { mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
@ -193,14 +181,13 @@ func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{
var fileContents []byte var fileContents []byte
var err error var err error
includes[includeFilePath] = struct{}{} fileContents, err = os.ReadFile(includeFilePath)
fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1)
if err != nil { if err != nil {
includesLastErr = err includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
return nil return nil
} }
includes[includeFilePath] = struct{}{}
return []byte(prefixStringLines(indent, string(fileContents))) return []byte(prefixStringLines(indent, string(fileContents)))
}) })
@ -321,7 +308,7 @@ func configFilesWatcher(
// wait for file to maybe get created again // wait for file to maybe get created again
// see https://github.com/glanceapp/glance/pull/358 // see https://github.com/glanceapp/glance/pull/358
for range 10 { for i := 0; i < 10; i++ {
if _, err := os.Stat(event.Name); err == nil { if _, err := os.Stat(event.Name); err == nil {
break break
} }

View file

@ -27,7 +27,9 @@ var globalTemplateFunctions = template.FuncMap{
"formatPrice": func(price float64) string { "formatPrice": func(price float64) string {
return intl.Sprintf("%.2f", price) return intl.Sprintf("%.2f", price)
}, },
"dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs, "dynamicRelativeTimeAttrs": func(t interface{ Unix() int64 }) template.HTMLAttr {
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
},
"formatServerMegabytes": func(mb uint64) template.HTML { "formatServerMegabytes": func(mb uint64) template.HTML {
var value string var value string
var label string var label string
@ -79,7 +81,3 @@ func formatApproxNumber(count int) string {
return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m" return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m"
} }
func dynamicRelativeTimeAttrs(t interface{ Unix() int64 }) template.HTMLAttr {
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
}

View file

@ -2,29 +2,22 @@
{{ define "widget-content" }} {{ define "widget-content" }}
<div class="dynamic-columns list-gap-24 list-with-separator"> <div class="dynamic-columns list-gap-24 list-with-separator">
{{- range .Groups }} {{ range .Groups }}
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}> <div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
{{- if ne .Title "" }} {{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>
{{- end }}
<ul class="list list-gap-2"> <ul class="list list-gap-2">
{{- range .Links }} {{ range .Links }}
<li> <li class="flex items-center gap-10">
<div class="flex items-center gap-10"> {{ if ne "" .Icon.URL }}
{{- if ne "" .Icon.URL }}
<div class="bookmarks-icon-container"> <div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy"> <img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
</div> </div>
{{- end }} {{ end }}
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a> <a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
</div>
{{- if .Description }}
<div class="margin-bottom-5">{{ .Description }}</div>
{{- end }}
</li> </li>
{{- end }} {{ end }}
</ul> </ul>
</div> </div>
{{- end }} {{ end }}
</div> </div>
{{ end }} {{ end }}

View file

@ -24,7 +24,7 @@
{{ if .Icon.URL }} {{ if .Icon.URL }}
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy"> <img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ end }} {{ end }}
<div class="grow min-width-0"> <div class="min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a> <a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
{{ if not .Status.Error }} {{ if not .Status.Error }}

View file

@ -18,7 +18,6 @@ type bookmarksWidget struct {
Links []struct { Links []struct {
Title string `yaml:"title"` Title string `yaml:"title"`
URL string `yaml:"url"` URL string `yaml:"url"`
Description string `yaml:"description"`
Icon customIconField `yaml:"icon"` Icon customIconField `yaml:"icon"`
// we need a pointer to bool to know whether a value was provided, // we need a pointer to bool to know whether a value was provided,
// however there's no way to dereference a pointer in a template so // however there's no way to dereference a pointer in a template so

View file

@ -10,8 +10,6 @@ import (
"log/slog" "log/slog"
"math" "math"
"net/http" "net/http"
"strings"
"sync"
"time" "time"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
@ -19,20 +17,13 @@ import (
var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html") var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
// Needs to be exported for the YAML unmarshaler to work
type CustomAPIRequest struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Parameters queryParametersField `json:"parameters"`
httpRequest *http.Request `yaml:"-"`
}
type customAPIWidget struct { type customAPIWidget struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
*CustomAPIRequest `yaml:",inline"` // the primary request URL string `yaml:"url"`
Subrequests map[string]*CustomAPIRequest `yaml:"subrequests"`
Template string `yaml:"template"` Template string `yaml:"template"`
Frameless bool `yaml:"frameless"` Frameless bool `yaml:"frameless"`
Headers map[string]string `yaml:"headers"`
APIRequest *http.Request `yaml:"-"`
compiledTemplate *template.Template `yaml:"-"` compiledTemplate *template.Template `yaml:"-"`
CompiledHTML template.HTML `yaml:"-"` CompiledHTML template.HTML `yaml:"-"`
} }
@ -40,14 +31,8 @@ type customAPIWidget struct {
func (widget *customAPIWidget) initialize() error { func (widget *customAPIWidget) initialize() error {
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour) widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
if err := widget.CustomAPIRequest.initialize(); err != nil { if widget.URL == "" {
return fmt.Errorf("initializing primary request: %v", err) return errors.New("URL is required")
}
for key := range widget.Subrequests {
if err := widget.Subrequests[key].initialize(); err != nil {
return fmt.Errorf("initializing subrequest %q: %v", key, err)
}
} }
if widget.Template == "" { if widget.Template == "" {
@ -61,11 +46,22 @@ func (widget *customAPIWidget) initialize() error {
widget.compiledTemplate = compiledTemplate widget.compiledTemplate = compiledTemplate
req, err := http.NewRequest(http.MethodGet, widget.URL, nil)
if err != nil {
return err
}
for key, value := range widget.Headers {
req.Header.Add(key, value)
}
widget.APIRequest = req
return nil return nil
} }
func (widget *customAPIWidget) update(ctx context.Context) { func (widget *customAPIWidget) update(ctx context.Context) {
compiledHTML, err := fetchAndParseCustomAPI(widget.CustomAPIRequest, widget.Subrequests, widget.compiledTemplate) compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
} }
@ -77,153 +73,39 @@ func (widget *customAPIWidget) Render() template.HTML {
return widget.renderTemplate(widget, customAPIWidgetTemplate) return widget.renderTemplate(widget, customAPIWidgetTemplate)
} }
func (req *CustomAPIRequest) initialize() error { func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
if req.URL == "" { emptyBody := template.HTML("")
return errors.New("URL is required")
}
httpReq, err := http.NewRequest(http.MethodGet, req.URL, nil) resp, err := defaultHTTPClient.Do(req)
if err != nil { if err != nil {
return err return emptyBody, err
}
if len(req.Parameters) > 0 {
httpReq.URL.RawQuery = req.Parameters.toQueryString()
}
for key, value := range req.Headers {
httpReq.Header.Add(key, value)
}
req.httpRequest = httpReq
return nil
}
type customAPIResponseData struct {
JSON decoratedGJSONResult
Response *http.Response
}
type customAPITemplateData struct {
*customAPIResponseData
subrequests map[string]*customAPIResponseData
}
func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData {
req, exists := data.subrequests[key]
if !exists {
// We have to panic here since there's nothing sensible we can return and the
// lack of an error would cause requested data to return zero values which
// would be confusing from the user's perspective. Go's template module
// handles recovering from panics and will return the panic message as an
// error during template execution.
panic(fmt.Sprintf("subrequest with key %q has not been defined", key))
}
return req
}
func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
resp, err := defaultHTTPClient.Do(req.httpRequest.WithContext(ctx))
if err != nil {
return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body) bodyBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return emptyBody, err
} }
body := strings.TrimSpace(string(bodyBytes)) body := string(bodyBytes)
if body != "" && !gjson.Valid(body) { if !gjson.Valid(body) {
truncatedBody, isTruncated := limitStringLength(body, 100) truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated { if isTruncated {
truncatedBody += "... <truncated>" truncatedBody += "... <truncated>"
} }
slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody) slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody)
return nil, errors.New("invalid response JSON") return emptyBody, errors.New("invalid response JSON")
} }
data := &customAPIResponseData{ var templateBuffer bytes.Buffer
data := customAPITemplateData{
JSON: decoratedGJSONResult{gjson.Parse(body)}, JSON: decoratedGJSONResult{gjson.Parse(body)},
Response: resp, Response: resp,
} }
return data, nil
}
func fetchAndParseCustomAPI(
primaryReq *CustomAPIRequest,
subReqs map[string]*CustomAPIRequest,
tmpl *template.Template,
) (template.HTML, error) {
var primaryData *customAPIResponseData
subData := make(map[string]*customAPIResponseData, len(subReqs))
var err error
if len(subReqs) == 0 {
// If there are no subrequests, we can fetch the primary request in a much simpler way
primaryData, err = fetchCustomAPIRequest(context.Background(), primaryReq)
} else {
// If there are subrequests, we need to fetch them concurrently
// and cancel all requests if any of them fail. There's probably
// a more elegant way to do this, but this works for now.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
var mu sync.Mutex // protects subData and err
wg.Add(1)
go func() {
defer wg.Done()
var localErr error
primaryData, localErr = fetchCustomAPIRequest(ctx, primaryReq)
mu.Lock()
if localErr != nil && err == nil {
err = localErr
cancel()
}
mu.Unlock()
}()
for key, req := range subReqs {
wg.Add(1)
go func() {
defer wg.Done()
var localErr error
var data *customAPIResponseData
data, localErr = fetchCustomAPIRequest(ctx, req)
mu.Lock()
if localErr == nil {
subData[key] = data
} else if err == nil {
err = localErr
cancel()
}
mu.Unlock()
}()
}
wg.Wait()
}
emptyBody := template.HTML("")
if err != nil {
return emptyBody, err
}
data := customAPITemplateData{
customAPIResponseData: primaryData,
subrequests: subData,
}
var templateBuffer bytes.Buffer
err = tmpl.Execute(&templateBuffer, &data) err = tmpl.Execute(&templateBuffer, &data)
if err != nil { if err != nil {
return emptyBody, err return emptyBody, err
@ -236,6 +118,11 @@ type decoratedGJSONResult struct {
gjson.Result gjson.Result
} }
type customAPITemplateData struct {
JSON decoratedGJSONResult
Response *http.Response
}
func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult { func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
decoratedResults := make([]decoratedGJSONResult, len(results)) decoratedResults := make([]decoratedGJSONResult, len(results))
@ -314,28 +201,6 @@ var customAPITemplateFuncs = func() template.FuncMap {
return a / b return a / b
}, },
"parseTime": func(layout, value string) time.Time {
switch strings.ToLower(layout) {
case "rfc3339":
layout = time.RFC3339
case "rfc3339nano":
layout = time.RFC3339Nano
case "datetime":
layout = time.DateTime
case "dateonly":
layout = time.DateOnly
case "timeonly":
layout = time.TimeOnly
}
parsed, err := time.Parse(layout, value)
if err != nil {
return time.Unix(0, 0)
}
return parsed
},
"toRelativeTime": dynamicRelativeTimeAttrs,
} }
for key, value := range globalTemplateFunctions { for key, value := range globalTemplateFunctions {

View file

@ -14,12 +14,6 @@ import (
var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html") var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
const (
dnsStatsBars = 8
dnsStatsHoursSpan = 24
dnsStatsHoursPerBar int = dnsStatsHoursSpan / dnsStatsBars
)
type dnsStatsWidget struct { type dnsStatsWidget struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
@ -54,12 +48,8 @@ func (widget *dnsStatsWidget) initialize() error {
withTitleURL(string(widget.URL)). withTitleURL(string(widget.URL)).
withCacheDuration(10 * time.Minute) withCacheDuration(10 * time.Minute)
switch widget.Service { if widget.Service != "adguard" && widget.Service != "pihole" {
case "adguard": return errors.New("service must be either 'adguard' or 'pihole'")
case "pihole":
case "technitium":
default:
return errors.New("service must be either 'adguard', 'pihole', or 'technitium'")
} }
return nil return nil
@ -69,13 +59,10 @@ func (widget *dnsStatsWidget) update(ctx context.Context) {
var stats *dnsStats var stats *dnsStats
var err error var err error
switch widget.Service { if widget.Service == "adguard" {
case "adguard":
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph) stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph)
case "pihole": } else {
stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph) stats, err = fetchPiholeStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
case "technitium":
stats, err = fetchTechnitiumStats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
} }
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
@ -192,27 +179,31 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
queriesSeries := responseJson.QueriesSeries queriesSeries := responseJson.QueriesSeries
blockedSeries := responseJson.BlockedSeries blockedSeries := responseJson.BlockedSeries
if len(queriesSeries) > dnsStatsHoursSpan { const bars = 8
queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:] const hoursSpan = 24
} else if len(queriesSeries) < dnsStatsHoursSpan { const hoursPerBar int = hoursSpan / bars
queriesSeries = append(make([]int, dnsStatsHoursSpan-len(queriesSeries)), queriesSeries...)
if len(queriesSeries) > hoursSpan {
queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
} else if len(queriesSeries) < hoursSpan {
queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
} }
if len(blockedSeries) > dnsStatsHoursSpan { if len(blockedSeries) > hoursSpan {
blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:] blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
} else if len(blockedSeries) < dnsStatsHoursSpan { } else if len(blockedSeries) < hoursSpan {
blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...) blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
} }
maxQueriesInSeries := 0 maxQueriesInSeries := 0
for i := 0; i < dnsStatsBars; i++ { for i := 0; i < bars; i++ {
queries := 0 queries := 0
blocked := 0 blocked := 0
for j := 0; j < dnsStatsHoursPerBar; j++ { for j := 0; j < hoursPerBar; j++ {
queries += queriesSeries[i*dnsStatsHoursPerBar+j] queries += queriesSeries[i*hoursPerBar+j]
blocked += blockedSeries[i*dnsStatsHoursPerBar+j] blocked += blockedSeries[i*hoursPerBar+j]
} }
stats.Series[i] = dnsStatsSeries{ stats.Series[i] = dnsStatsSeries{
@ -229,7 +220,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
} }
} }
for i := 0; i < dnsStatsBars; i++ { for i := 0; i < bars; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
} }
@ -388,139 +379,3 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
return stats, nil return stats, nil
} }
type technitiumStatsResponse struct {
Response struct {
Stats struct {
TotalQueries int `json:"totalQueries"`
BlockedQueries int `json:"totalBlocked"`
BlockedZones int `json:"blockedZones"`
BlockListZones int `json:"blockListZones"`
} `json:"stats"`
MainChartData struct {
Datasets []struct {
Label string `json:"label"`
Data []int `json:"data"`
} `json:"datasets"`
} `json:"mainChartData"`
TopBlockedDomains []struct {
Domain string `json:"name"`
Count int `json:"hits"`
}
} `json:"response"`
}
func fetchTechnitiumStats(instanceUrl string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
if token == "" {
return nil, errors.New("missing API token")
}
requestURL := strings.TrimRight(instanceUrl, "/") + "/api/dashboard/stats/get?token=" + token + "&type=LastDay"
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
}
responseJson, err := decodeJsonFromRequest[technitiumStatsResponse](client, request)
if err != nil {
return nil, err
}
var topBlockedDomainsCount = min(len(responseJson.Response.TopBlockedDomains), 5)
stats := &dnsStats{
TotalQueries: responseJson.Response.Stats.TotalQueries,
BlockedQueries: responseJson.Response.Stats.BlockedQueries,
TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount),
DomainsBlocked: responseJson.Response.Stats.BlockedZones + responseJson.Response.Stats.BlockListZones,
}
if stats.TotalQueries <= 0 {
return stats, nil
}
stats.BlockedPercent = int(float64(responseJson.Response.Stats.BlockedQueries) / float64(responseJson.Response.Stats.TotalQueries) * 100)
for i := 0; i < topBlockedDomainsCount; i++ {
domain := responseJson.Response.TopBlockedDomains[i]
firstDomain := domain.Domain
if firstDomain == "" {
continue
}
stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{
Domain: firstDomain,
})
if stats.BlockedQueries > 0 {
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain.Count) / float64(responseJson.Response.Stats.BlockedQueries) * 100)
}
}
if noGraph {
return stats, nil
}
var queriesSeries, blockedSeries []int
for _, label := range responseJson.Response.MainChartData.Datasets {
switch label.Label {
case "Total":
queriesSeries = label.Data
case "Blocked":
blockedSeries = label.Data
}
}
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) > 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 < dnsStatsBars; i++ {
queries := 0
blocked := 0
for j := 0; j < dnsStatsHoursPerBar; j++ {
queries += queriesSeries[i*dnsStatsHoursPerBar+j]
blocked += blockedSeries[i*dnsStatsHoursPerBar+j]
}
stats.Series[i] = dnsStatsSeries{
Queries: queries,
Blocked: blocked,
}
if queries > 0 {
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
}
for i := 0; i < dnsStatsBars; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}

View file

@ -21,7 +21,7 @@ type extensionWidget struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
URL string `yaml:"url"` URL string `yaml:"url"`
FallbackContentType string `yaml:"fallback-content-type"` FallbackContentType string `yaml:"fallback-content-type"`
Parameters queryParametersField `yaml:"parameters"` Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"` AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
Extension extension `yaml:"-"` Extension extension `yaml:"-"`
cachedHTML template.HTML `yaml:"-"` cachedHTML template.HTML `yaml:"-"`
@ -84,7 +84,7 @@ const (
type extensionRequestOptions struct { type extensionRequestOptions struct {
URL string `yaml:"url"` URL string `yaml:"url"`
FallbackContentType string `yaml:"fallback-content-type"` FallbackContentType string `yaml:"fallback-content-type"`
Parameters queryParametersField `yaml:"parameters"` Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"` AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
} }
@ -109,7 +109,14 @@ func convertExtensionContent(options extensionRequestOptions, content []byte, co
func fetchExtension(options extensionRequestOptions) (extension, error) { func fetchExtension(options extensionRequestOptions) (extension, error) {
request, _ := http.NewRequest("GET", options.URL, nil) request, _ := http.NewRequest("GET", options.URL, nil)
request.URL.RawQuery = options.Parameters.toQueryString()
query := url.Values{}
for key, value := range options.Parameters {
query.Set(key, value)
}
request.URL.RawQuery = query.Encode()
response, err := http.DefaultClient.Do(request) response, err := http.DefaultClient.Do(request)
if err != nil { if err != nil {

View file

@ -118,10 +118,6 @@ type SiteStatusRequest struct {
DefaultURL string `yaml:"url"` DefaultURL string `yaml:"url"`
CheckURL string `yaml:"check-url"` CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"` AllowInsecure bool `yaml:"allow-insecure"`
BasicAuth struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
} `yaml:"basic-auth"`
} }
type siteStatus struct { type siteStatus struct {
@ -145,10 +141,6 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) {
}, nil }, nil
} }
if statusRequest.BasicAuth.Username != "" || statusRequest.BasicAuth.Password != "" {
request.SetBasicAuth(statusRequest.BasicAuth.Username, statusRequest.BasicAuth.Password)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel() defer cancel()
request = request.WithContext(ctx) request = request.WithContext(ctx)

View file

@ -147,8 +147,10 @@ const defaultNumWorkers = 10
func (job *workerPoolJob[I, O]) withWorkers(workers int) *workerPoolJob[I, O] { func (job *workerPoolJob[I, O]) withWorkers(workers int) *workerPoolJob[I, O] {
if workers == 0 { if workers == 0 {
job.workers = defaultNumWorkers job.workers = defaultNumWorkers
} else if workers > len(job.data) {
job.workers = len(job.data)
} else { } else {
job.workers = min(workers, len(job.data)) job.workers = workers
} }
return job return job
@ -179,11 +181,6 @@ func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error)
return results, errs, nil return results, errs, nil
} }
if len(job.data) == 1 {
output, err := job.task(job.data[0])
return append(results, output), append(errs, err), nil
}
tasksQueue := make(chan *workerPoolTask[I, O]) tasksQueue := make(chan *workerPoolTask[I, O])
resultsQueue := make(chan *workerPoolTask[I, O]) resultsQueue := make(chan *workerPoolTask[I, O])

View file

@ -18,10 +18,6 @@ import (
var widgetIDCounter atomic.Uint64 var widgetIDCounter atomic.Uint64
func newWidget(widgetType string) (widget, error) { func newWidget(widgetType string) (widget, error) {
if widgetType == "" {
return nil, errors.New("widget 'type' property is empty or not specified")
}
var w widget var w widget
switch widgetType { switch widgetType {
@ -108,7 +104,7 @@ func (w *widgets) UnmarshalYAML(node *yaml.Node) error {
widget, err := newWidget(meta.Type) widget, err := newWidget(meta.Type)
if err != nil { if err != nil {
return fmt.Errorf("line %d: %w", node.Line, err) return err
} }
if err = node.Decode(widget); err != nil { if err = node.Decode(widget); err != nil {