Compare commits

..

33 commits
main ... dev

Author SHA1 Message Date
Svilen Markov
d22ac6a7a4 Add parseTime to custom-api 2025-03-13 00:55:31 +00:00
Svilen Markov
2f50f5ef34 Merge branch 'main' into dev 2025-03-12 18:04:16 +00:00
Svilen Markov
14db59318c Avoid spinning up unnecessary goroutines for single data jobs 2025-03-12 10:35:54 +00:00
Svilen Markov
f9b3deaff2 Simplify worker num with min 2025-03-12 10:35:54 +00:00
Ralph Ocdol
c265e42220
Add subrequests to custom-api (#385)
* feat: custom-api multiple API queries

* fix template check

* refactor

* Update implementation & docs

* Swap statement

* Update docs

---------

Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
2025-03-12 10:30:29 +00:00
Svilen Markov
c0bdf1551d Add support for bool in query params fields 2025-03-10 09:56:47 +00:00
Ralph Ocdol
e373eeeed3
fix: full width clickable link for monitor-site (#405)
* feat: full width clickable link for monitor-site

* refactor

* Use grow instead of width-100

---------

Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
2025-03-10 09:49:38 +00:00
Svilen Markov
6c8859863a Add description property to bookmarks widget links 2025-03-02 00:09:28 +00:00
Svilen Markov
31ecd91f7c Fix failing to parse empty response body in custom api widget 2025-03-01 23:43:33 +00:00
Svilen Markov
474255c985 Tweak error message 2025-03-01 23:38:29 +00:00
Svilen Markov
652f9ceb5c
Merge pull request #378 from ralphocdol/custom-api-array-parameters
feat: add parameters and array parameters support
2025-03-01 23:36:29 +00:00
Svilen Markov
49668d4ba9 Also apply to extension widget 2025-03-01 23:30:20 +00:00
Svilen Markov
acddaf07db Add note to docs 2025-03-01 23:29:56 +00:00
Svilen Markov
8da26ab409 Make query parameters field reusable 2025-03-01 23:29:28 +00:00
Ralph Ocdol
948289a038 feat: add parameters and array parameters support 2025-02-28 08:48:07 +08:00
Svilen Markov
ce293ed891 Prevent infinite config include recursion 2025-02-27 07:22:18 +00:00
Svilen Markov
2738613344 Improve error message when widget type not specified 2025-02-27 07:12:07 +00:00
Svilen Markov
5d12d934b8 Use new range syntax 2025-02-27 07:11:44 +00:00
Svilen Markov
19a89645a1 Add support for nested includes 2025-02-27 07:11:03 +00:00
Svilen Markov
9df9673e84 Add alternative include syntax
Also make it the new recommended way for doing includes
2025-02-25 02:25:01 +00:00
Svilen Markov
9c98c6d0c4 Merge branch 'main' into dev 2025-02-22 13:33:17 +00:00
Svilen Markov
4d6600b0a3
Merge pull request #367 from ejsadiarin/dev
feat(monitor): add basic-auth feature for protected sites
2025-02-22 13:30:09 +00:00
Svilen Markov
dac0d15e78 Update implementation 2025-02-22 13:29:00 +00:00
ejsadiarin
5b45751c67 docs(monitor): add documentation for basic-auth feature 2025-02-19 17:40:56 +08:00
ejsadiarin
c00d937f4c feat(monitor): add basic-auth feature for protected sites
this closes [issue #316](https://github.com/glanceapp/glance/issues/316)

Furthermore, this could be expanded to also pass the configured basic
auth credentials to the request when the user clicks on the specific
monitor widget
2025-02-19 17:28:13 +08:00
Svilen Markov
c33fe45d4c Merge branch 'main' into dev 2025-02-17 23:27:42 +00:00
Svilen Markov
e355c643f4
Merge pull request #339 from KevinFumbles/dev
Added Technitium Service Option to DNS-Stats Widget
2025-02-17 23:20:54 +00:00
Svilen Markov
fcccb7eb38 Update error message 2025-02-17 23:20:38 +00:00
Svilen Markov
f9209406fb Reduce duplication of constants 2025-02-17 23:18:27 +00:00
Svilen Markov
facbf6f529 Remove mention of env variable syntax 2025-02-17 23:08:36 +00:00
Kevin
94806ed45d
Added blocked domains count for Technitium 2025-02-12 16:05:38 -05:00
Kevin
baee94ed1d
Added configuration documentation for Technitium dns-stats service 2025-02-12 15:58:21 -05:00
Kevin
0c8358beaa
Added Technitium as a valid service for dns-stats widget 2025-02-12 15:58:05 -05:00
15 changed files with 608 additions and 123 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,6 +1294,8 @@ 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.
@ -1313,6 +1315,55 @@ 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).
@ -1476,6 +1527,7 @@ 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`
@ -1524,6 +1576,16 @@ 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.
@ -1737,7 +1799,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 or Pi-hole. Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, or Technitium.
Example: Example:
@ -1755,7 +1817,7 @@ Preview:
> [!NOTE] > [!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. > 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.
#### Properties #### Properties
@ -1772,22 +1834,22 @@ Preview:
| hour-format | string | no | 12h | | hour-format | string | no | 12h |
##### `service` ##### `service`
Either `adguard` or `pihole`. Either `adguard`, `pihole`, or `technitium`.
##### `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. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. The base URL of the service.
##### `username` ##### `username`
Only required when using AdGuard Home. The username used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. Only required when using AdGuard Home. The username used to log into the admin dashboard.
##### `password` ##### `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}`. Only required when using AdGuard Home. The password used to log into the admin dashboard.
##### `token` ##### `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}`. 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`.
##### `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.
@ -2030,6 +2092,7 @@ 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,6 +242,57 @@ 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
@ -273,6 +324,8 @@ 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,6 +66,9 @@ 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,3 +219,58 @@ 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,6 +17,8 @@ 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"`
@ -144,21 +146,31 @@ 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)^(\s*)!include:\s*(.+)$`) var includePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`)
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 main YAML file: %w", err) return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, 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 main YAML file: %w", err) return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err)
} }
mainFileDir := filepath.Dir(mainFileAbsPath) mainFileDir := filepath.Dir(mainFileAbsPath)
includes := make(map[string]struct{}) if includes == nil {
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 {
@ -181,13 +193,14 @@ func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error)
var fileContents []byte var fileContents []byte
var err error var err error
fileContents, err = os.ReadFile(includeFilePath) includes[includeFilePath] = struct{}{}
fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1)
if err != nil { if err != nil {
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err) includesLastErr = err
return nil return nil
} }
includes[includeFilePath] = struct{}{}
return []byte(prefixStringLines(indent, string(fileContents))) return []byte(prefixStringLines(indent, string(fileContents)))
}) })
@ -308,7 +321,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 i := 0; i < 10; i++ { for range 10 {
if _, err := os.Stat(event.Name); err == nil { if _, err := os.Stat(event.Name); err == nil {
break break
} }

View file

@ -27,9 +27,7 @@ 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": func(t interface{ Unix() int64 }) template.HTMLAttr { "dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs,
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
@ -81,3 +79,7 @@ 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,22 +2,29 @@
{{ 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 "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }} {{- if ne .Title "" }}
<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 class="flex items-center gap-10"> <li>
{{ if ne "" .Icon.URL }} <div class="flex items-center gap-10">
<div class="bookmarks-icon-container"> {{- if ne "" .Icon.URL }}
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy"> <div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
</div>
{{- 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>
</div> </div>
{{ end }} {{- if .Description }}
<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 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="min-width-0"> <div class="grow 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

@ -16,9 +16,10 @@ type bookmarksWidget struct {
HideArrow bool `yaml:"hide-arrow"` HideArrow bool `yaml:"hide-arrow"`
Target string `yaml:"target"` Target string `yaml:"target"`
Links []struct { Links []struct {
Title string `yaml:"title"` Title string `yaml:"title"`
URL string `yaml:"url"` URL string `yaml:"url"`
Icon customIconField `yaml:"icon"` Description string `yaml:"description"`
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
// {{ if not .SameTab }} would return true for any non-nil pointer // {{ if not .SameTab }} would return true for any non-nil pointer

View file

@ -10,6 +10,8 @@ import (
"log/slog" "log/slog"
"math" "math"
"net/http" "net/http"
"strings"
"sync"
"time" "time"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
@ -17,22 +19,35 @@ 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"`
URL string `yaml:"url"` *CustomAPIRequest `yaml:",inline"` // the primary request
Template string `yaml:"template"` Subrequests map[string]*CustomAPIRequest `yaml:"subrequests"`
Frameless bool `yaml:"frameless"` Template string `yaml:"template"`
Headers map[string]string `yaml:"headers"` Frameless bool `yaml:"frameless"`
APIRequest *http.Request `yaml:"-"` compiledTemplate *template.Template `yaml:"-"`
compiledTemplate *template.Template `yaml:"-"` CompiledHTML template.HTML `yaml:"-"`
CompiledHTML template.HTML `yaml:"-"`
} }
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 widget.URL == "" { if err := widget.CustomAPIRequest.initialize(); err != nil {
return errors.New("URL is required") return fmt.Errorf("initializing primary request: %v", err)
}
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 == "" {
@ -46,22 +61,11 @@ 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.APIRequest, widget.compiledTemplate) compiledHTML, err := fetchAndParseCustomAPI(widget.CustomAPIRequest, widget.Subrequests, widget.compiledTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
} }
@ -73,39 +77,153 @@ func (widget *customAPIWidget) Render() template.HTML {
return widget.renderTemplate(widget, customAPIWidgetTemplate) return widget.renderTemplate(widget, customAPIWidgetTemplate)
} }
func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) { func (req *CustomAPIRequest) initialize() error {
emptyBody := template.HTML("") if req.URL == "" {
return errors.New("URL is required")
}
resp, err := defaultHTTPClient.Do(req) httpReq, err := http.NewRequest(http.MethodGet, req.URL, nil)
if err != nil { if err != nil {
return emptyBody, err return 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 emptyBody, err return nil, err
} }
body := string(bodyBytes) body := strings.TrimSpace(string(bodyBytes))
if !gjson.Valid(body) { if body != "" && !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.URL.String(), "body", truncatedBody) slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody)
return emptyBody, errors.New("invalid response JSON") return nil, errors.New("invalid response JSON")
} }
var templateBuffer bytes.Buffer data := &customAPIResponseData{
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
@ -118,11 +236,6 @@ 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))
@ -201,6 +314,28 @@ 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,6 +14,12 @@ 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"`
@ -48,8 +54,12 @@ func (widget *dnsStatsWidget) initialize() error {
withTitleURL(string(widget.URL)). withTitleURL(string(widget.URL)).
withCacheDuration(10 * time.Minute) withCacheDuration(10 * time.Minute)
if widget.Service != "adguard" && widget.Service != "pihole" { switch widget.Service {
return errors.New("service must be either 'adguard' or 'pihole'") case "adguard":
case "pihole":
case "technitium":
default:
return errors.New("service must be either 'adguard', 'pihole', or 'technitium'")
} }
return nil return nil
@ -59,10 +69,13 @@ func (widget *dnsStatsWidget) update(ctx context.Context) {
var stats *dnsStats var stats *dnsStats
var err error var err error
if widget.Service == "adguard" { switch widget.Service {
case "adguard":
stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph) stats, err = fetchAdguardStats(widget.URL, widget.AllowInsecure, widget.Username, widget.Password, widget.HideGraph)
} else { case "pihole":
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) {
@ -179,31 +192,27 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
queriesSeries := responseJson.QueriesSeries queriesSeries := responseJson.QueriesSeries
blockedSeries := responseJson.BlockedSeries blockedSeries := responseJson.BlockedSeries
const bars = 8 if len(queriesSeries) > dnsStatsHoursSpan {
const hoursSpan = 24 queriesSeries = queriesSeries[len(queriesSeries)-dnsStatsHoursSpan:]
const hoursPerBar int = hoursSpan / bars } else if len(queriesSeries) < dnsStatsHoursSpan {
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) > hoursSpan { if len(blockedSeries) > dnsStatsHoursSpan {
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:] blockedSeries = blockedSeries[len(blockedSeries)-dnsStatsHoursSpan:]
} else if len(blockedSeries) < hoursSpan { } else if len(blockedSeries) < dnsStatsHoursSpan {
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...) blockedSeries = append(make([]int, dnsStatsHoursSpan-len(blockedSeries)), blockedSeries...)
} }
maxQueriesInSeries := 0 maxQueriesInSeries := 0
for i := 0; i < bars; i++ { for i := 0; i < dnsStatsBars; i++ {
queries := 0 queries := 0
blocked := 0 blocked := 0
for j := 0; j < hoursPerBar; j++ { for j := 0; j < dnsStatsHoursPerBar; j++ {
queries += queriesSeries[i*hoursPerBar+j] queries += queriesSeries[i*dnsStatsHoursPerBar+j]
blocked += blockedSeries[i*hoursPerBar+j] blocked += blockedSeries[i*dnsStatsHoursPerBar+j]
} }
stats.Series[i] = dnsStatsSeries{ stats.Series[i] = dnsStatsSeries{
@ -220,7 +229,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
} }
} }
for i := 0; i < bars; i++ { for i := 0; i < dnsStatsBars; 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)
} }
@ -379,3 +388,139 @@ 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

@ -19,12 +19,12 @@ const extensionWidgetDefaultTitle = "Extension"
type extensionWidget struct { 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 map[string]string `yaml:"parameters"` Parameters queryParametersField `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:"-"`
} }
func (widget *extensionWidget) initialize() error { func (widget *extensionWidget) initialize() error {
@ -82,10 +82,10 @@ 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 map[string]string `yaml:"parameters"` Parameters queryParametersField `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"` AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
} }
type extension struct { type extension struct {
@ -109,14 +109,7 @@ 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,6 +118,10 @@ 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 {
@ -141,6 +145,10 @@ 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,10 +147,8 @@ 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 = workers job.workers = min(workers, len(job.data))
} }
return job return job
@ -181,6 +179,11 @@ 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,6 +18,10 @@ 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 {
@ -104,7 +108,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 err return fmt.Errorf("line %d: %w", node.Line, err)
} }
if err = node.Decode(widget); err != nil { if err = node.Decode(widget); err != nil {