Compare commits
33 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d22ac6a7a4 | ||
![]() |
2f50f5ef34 | ||
![]() |
14db59318c | ||
![]() |
f9b3deaff2 | ||
![]() |
c265e42220 | ||
![]() |
c0bdf1551d | ||
![]() |
e373eeeed3 | ||
![]() |
6c8859863a | ||
![]() |
31ecd91f7c | ||
![]() |
474255c985 | ||
![]() |
652f9ceb5c | ||
![]() |
49668d4ba9 | ||
![]() |
acddaf07db | ||
![]() |
8da26ab409 | ||
![]() |
948289a038 | ||
![]() |
ce293ed891 | ||
![]() |
2738613344 | ||
![]() |
5d12d934b8 | ||
![]() |
19a89645a1 | ||
![]() |
9df9673e84 | ||
![]() |
9c98c6d0c4 | ||
![]() |
4d6600b0a3 | ||
![]() |
dac0d15e78 | ||
![]() |
5b45751c67 | ||
![]() |
c00d937f4c | ||
![]() |
c33fe45d4c | ||
![]() |
e355c643f4 | ||
![]() |
fcccb7eb38 | ||
![]() |
f9209406fb | ||
![]() |
facbf6f529 | ||
![]() |
94806ed45d | ||
![]() |
baee94ed1d | ||
![]() |
0c8358beaa |
15 changed files with 608 additions and 123 deletions
|
@ -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 |
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) + `"`)
|
||||||
|
}
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Add table
Reference in a new issue