Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3f022b2d | ||
|
|
b6d93a7f09 | ||
|
|
4b2438c298 | ||
|
|
3b79c8e09f | ||
|
|
d7bbf2b8e2 | ||
|
|
b8df34309f | ||
|
|
278fa3c397 | ||
|
|
6f48ee98e5 | ||
|
|
774b0c104b | ||
|
|
fbd4d9a74e | ||
|
|
3f2fefe4f7 | ||
|
|
fb8513bc9f | ||
|
|
e4ec958edb | ||
|
|
78ad88a245 | ||
|
|
99660aeee8 | ||
|
|
65a412eb59 | ||
|
|
2002ed1c9c | ||
|
|
488a1f6070 | ||
|
|
1e12d937aa | ||
|
|
e643a44b59 | ||
|
|
7319870289 | ||
|
|
72c1ebf66d | ||
|
|
008c4a3ab7 | ||
|
|
38d3d11571 | ||
|
|
d4565acfe7 | ||
|
|
16129c53bd | ||
|
|
cbf1961510 | ||
|
|
c76a4d4be7 | ||
|
|
27af0400c0 | ||
|
|
76a80ff034 | ||
|
|
f7f333ad52 | ||
|
|
0ce45e32aa | ||
|
|
abeb11c8a6 | ||
|
|
e01af4adec | ||
|
|
3043a0bd15 | ||
|
|
232cab01f8 | ||
|
|
b301953249 | ||
|
|
bb9cb03c8a |
+36
-16
@@ -97,9 +97,9 @@ Including config files from within your main config file is supported. This is d
|
|||||||
|
|
||||||
```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
|
||||||
```
|
```
|
||||||
@@ -356,7 +356,7 @@ pages:
|
|||||||
### Properties
|
### Properties
|
||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| title | string | yes | |
|
| name | string | yes | |
|
||||||
| slug | string | no | |
|
| slug | string | no | |
|
||||||
| width | string | no | |
|
| width | string | no | |
|
||||||
| center-vertically | boolean | no | false |
|
| center-vertically | boolean | no | false |
|
||||||
@@ -374,7 +374,7 @@ The URL friendly version of the title which is used to access the page. For exam
|
|||||||
#### `width`
|
#### `width`
|
||||||
The maximum width of the page on desktop. Possible values are `slim` and `wide`.
|
The maximum width of the page on desktop. Possible values are `slim` and `wide`.
|
||||||
|
|
||||||
* default: `1600px`
|
* default: `1600px` (when no value is specified)
|
||||||
* slim: `1100px`
|
* slim: `1100px`
|
||||||
* wide: `1920px`
|
* wide: `1920px`
|
||||||
|
|
||||||
@@ -1311,7 +1311,7 @@ headers:
|
|||||||
When set to `true`, removes the border and padding around the widget.
|
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://pkg.go.dev/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).
|
||||||
|
|
||||||
### 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).
|
||||||
@@ -1608,7 +1608,7 @@ services:
|
|||||||
glance:
|
glance:
|
||||||
image: glanceapp/glance
|
image: glanceapp/glance
|
||||||
environment:
|
environment:
|
||||||
- GITHUB_TOKEN: <your token>
|
- GITHUB_TOKEN=<your token>
|
||||||
```
|
```
|
||||||
|
|
||||||
and then use it in your `glance.yml` like this:
|
and then use it in your `glance.yml` like this:
|
||||||
@@ -1672,7 +1672,7 @@ For services with multiple containers you can specify a `glance.id` on the "main
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
servies:
|
services:
|
||||||
immich-server:
|
immich-server:
|
||||||
image: ghcr.io/immich-app/immich-server
|
image: ghcr.io/immich-app/immich-server
|
||||||
labels:
|
labels:
|
||||||
@@ -1765,29 +1765,31 @@ Preview:
|
|||||||
| allow-insecure | bool | no | false |
|
| allow-insecure | bool | no | false |
|
||||||
| url | string | yes | |
|
| url | string | yes | |
|
||||||
| username | string | when service is `adguard` | |
|
| username | string | when service is `adguard` | |
|
||||||
| password | string | when service is `adguard` | |
|
| password | string | when service is `adguard` or `pihole-v6` | |
|
||||||
| token | string | when service is `pihole` | |
|
| token | string | when service is `pihole` | |
|
||||||
| hide-graph | bool | no | false |
|
| hide-graph | bool | no | false |
|
||||||
| hide-top-domains | bool | no | false |
|
| hide-top-domains | bool | no | false |
|
||||||
| hour-format | string | no | 12h |
|
| hour-format | string | no | 12h |
|
||||||
|
|
||||||
##### `service`
|
##### `service`
|
||||||
Either `adguard` or `pihole`.
|
Either `adguard`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above).
|
||||||
|
|
||||||
##### `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}`.
|
Required when using AdGuard Home, where the password is the one used to log into the admin dashboard.
|
||||||
|
|
||||||
|
Also requried when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`.
|
||||||
|
|
||||||
##### `token`
|
##### `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 major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API 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.
|
||||||
@@ -1852,11 +1854,29 @@ Whether to hide the swap usage.
|
|||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| cpu-temp-sensor | string | no | |
|
| cpu-temp-sensor | string | no | |
|
||||||
|
| hide-mointpoints-by-default | boolean | no | false |
|
||||||
| mountpoints | map\[string\]object | no | |
|
| mountpoints | map\[string\]object | no | |
|
||||||
|
|
||||||
###### `cpu-temp-sensor`
|
###### `cpu-temp-sensor`
|
||||||
The name of the sensor to use for the CPU temperature. When not provided the widget will attempt to find the correct one, if it fails to do so the temperature will not be displayed. To view the available sensors you can use `sensors` command.
|
The name of the sensor to use for the CPU temperature. When not provided the widget will attempt to find the correct one, if it fails to do so the temperature will not be displayed. To view the available sensors you can use `sensors` command.
|
||||||
|
|
||||||
|
###### `hide-mountpoints-by-default`
|
||||||
|
If set to `true` you'll have to manually make each mountpoint visible by adding a `hide: false` property to it like so:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: server-stats
|
||||||
|
servers:
|
||||||
|
- type: local
|
||||||
|
hide-mountpoints-by-default: true
|
||||||
|
mountpoints:
|
||||||
|
"/":
|
||||||
|
hide: false
|
||||||
|
"/mnt/data":
|
||||||
|
hide: false
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful if you're running Glance inside of a container which usually mounts a lot of irrelevant filesystems.
|
||||||
|
|
||||||
###### `mountpoints`
|
###### `mountpoints`
|
||||||
A map of mountpoints to display disk usage for. The key is the path to the mountpoint and the value is an object with optional properties. Example:
|
A map of mountpoints to display disk usage for. The key is the path to the mountpoint and the value is an object with optional properties. Example:
|
||||||
|
|
||||||
|
|||||||
@@ -66,9 +66,6 @@ pages:
|
|||||||
# hide-location: true
|
# hide-location: true
|
||||||
|
|
||||||
- type: markets
|
- type: markets
|
||||||
# The link to go to when clicking on the symbol in the UI,
|
|
||||||
# {SYMBOL} will be substituded with the symbol for each market
|
|
||||||
symbol-link-template: https://www.tradingview.com/symbols/{SYMBOL}/news
|
|
||||||
markets:
|
markets:
|
||||||
- symbol: SPY
|
- symbol: SPY
|
||||||
name: S&P 500
|
name: S&P 500
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -53,6 +53,16 @@ theme:
|
|||||||
primary-color: 97 13 80
|
primary-color: 97 13 80
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Gruvbox Dark
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 0 0 16
|
||||||
|
primary-color: 43 59 81
|
||||||
|
positive-color: 61 66 44
|
||||||
|
negative-color: 6 96 59
|
||||||
|
```
|
||||||
|
|
||||||
### Kanagawa Dark
|
### Kanagawa Dark
|
||||||

|

|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -152,9 +152,9 @@ func newCustomIconField(value string) customIconField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if prefix == "di" {
|
if prefix == "di" {
|
||||||
field.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
|
field.URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext
|
||||||
} else {
|
} else {
|
||||||
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/" + ext + "/" + basename + "." + ext
|
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
field.URL = value
|
field.URL = value
|
||||||
|
|||||||
+36
-12
@@ -242,7 +242,7 @@ func configFilesWatcher(
|
|||||||
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
|
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
|
||||||
mu := sync.Mutex{}
|
mu := sync.Mutex{}
|
||||||
|
|
||||||
checkForContentChangesBeforeCallback := func() {
|
parseAndCompareBeforeCallback := func() {
|
||||||
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
|
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
|
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
|
||||||
@@ -268,15 +268,22 @@ func configFilesWatcher(
|
|||||||
|
|
||||||
const debounceDuration = 500 * time.Millisecond
|
const debounceDuration = 500 * time.Millisecond
|
||||||
var debounceTimer *time.Timer
|
var debounceTimer *time.Timer
|
||||||
debouncedCallback := func() {
|
debouncedParseAndCompareBeforeCallback := func() {
|
||||||
if debounceTimer != nil {
|
if debounceTimer != nil {
|
||||||
debounceTimer.Stop()
|
debounceTimer.Stop()
|
||||||
debounceTimer.Reset(debounceDuration)
|
debounceTimer.Reset(debounceDuration)
|
||||||
} else {
|
} else {
|
||||||
debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback)
|
debounceTimer = time.AfterFunc(debounceDuration, parseAndCompareBeforeCallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteLastInclude := func(filePath string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
fileAbsPath, _ := filepath.Abs(filePath)
|
||||||
|
delete(lastIncludes, fileAbsPath)
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -285,16 +292,33 @@ func configFilesWatcher(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if event.Has(fsnotify.Write) {
|
if event.Has(fsnotify.Write) {
|
||||||
debouncedCallback()
|
debouncedParseAndCompareBeforeCallback()
|
||||||
} else if event.Has(fsnotify.Remove) {
|
} else if event.Has(fsnotify.Rename) {
|
||||||
func() {
|
// on linux the file will no longer be watched after a rename, on windows
|
||||||
mu.Lock()
|
// it will continue to be watched with the new name but we have no access to
|
||||||
defer mu.Unlock()
|
// the new name in this event in order to stop watching it manually and match the
|
||||||
fileAbsPath, _ := filepath.Abs(event.Name)
|
// behavior in linux, may lead to weird unintended behaviors on windows as we're
|
||||||
delete(lastIncludes, fileAbsPath)
|
// only handling renames from linux's perspective
|
||||||
}()
|
// see https://github.com/fsnotify/fsnotify/issues/255
|
||||||
|
|
||||||
debouncedCallback()
|
// remove the old file from our manually tracked includes, calling
|
||||||
|
// debouncedParseAndCompareBeforeCallback will re-add it if it's still
|
||||||
|
// required after it triggers
|
||||||
|
deleteLastInclude(event.Name)
|
||||||
|
|
||||||
|
// wait for file to maybe get created again
|
||||||
|
// see https://github.com/glanceapp/glance/pull/358
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if _, err := os.Stat(event.Name); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
debouncedParseAndCompareBeforeCallback()
|
||||||
|
} else if event.Has(fsnotify.Remove) {
|
||||||
|
deleteLastInclude(event.Name)
|
||||||
|
debouncedParseAndCompareBeforeCallback()
|
||||||
}
|
}
|
||||||
case err, isOpen := <-watcher.Errors:
|
case err, isOpen := <-watcher.Errors:
|
||||||
if !isOpen {
|
if !isOpen {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func newApplication(config *config) (*application, error) {
|
|||||||
|
|
||||||
for w := range column.Widgets {
|
for w := range column.Widgets {
|
||||||
widget := column.Widgets[w]
|
widget := column.Widgets[w]
|
||||||
app.widgetByID[widget.id()] = widget
|
app.widgetByID[widget.GetID()] = widget
|
||||||
|
|
||||||
widget.setProviders(providers)
|
widget.setProviders(providers)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,7 +284,9 @@ function setupGroups() {
|
|||||||
|
|
||||||
for (let i = 0; i < titles.length; i++) {
|
for (let i = 0; i < titles.length; i++) {
|
||||||
titles[i].classList.remove("widget-group-title-current");
|
titles[i].classList.remove("widget-group-title-current");
|
||||||
|
titles[i].setAttribute("aria-selected", "false");
|
||||||
tabs[i].classList.remove("widget-group-content-current");
|
tabs[i].classList.remove("widget-group-content-current");
|
||||||
|
tabs[i].setAttribute("aria-hidden", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current < t) {
|
if (current < t) {
|
||||||
@@ -296,7 +298,9 @@ function setupGroups() {
|
|||||||
current = t;
|
current = t;
|
||||||
|
|
||||||
title.classList.add("widget-group-title-current");
|
title.classList.add("widget-group-title-current");
|
||||||
|
title.setAttribute("aria-selected", "true");
|
||||||
tabs[t].classList.add("widget-group-content-current");
|
tabs[t].classList.add("widget-group-content-current");
|
||||||
|
tabs[t].setAttribute("aria-hidden", "false");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,6 +674,7 @@ async function setupPage() {
|
|||||||
setupLazyImages();
|
setupLazyImages();
|
||||||
} finally {
|
} finally {
|
||||||
pageElement.classList.add("content-ready");
|
pageElement.classList.add("content-ready");
|
||||||
|
pageElement.setAttribute("aria-busy", "false");
|
||||||
|
|
||||||
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
||||||
contentReadyCallbacks[i]();
|
contentReadyCallbacks[i]();
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ function showPopover() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
contentElement.style.maxWidth = contentMaxWidth;
|
contentElement.style.maxWidth = contentMaxWidth;
|
||||||
containerElement.style.display = "block";
|
|
||||||
activeTarget.classList.add("popover-active");
|
activeTarget.classList.add("popover-active");
|
||||||
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
||||||
window.addEventListener("resize", queueRepositionContainer);
|
window.addEventListener("resize", queueRepositionContainer);
|
||||||
@@ -106,6 +105,8 @@ function showPopover() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function repositionContainer() {
|
function repositionContainer() {
|
||||||
|
containerElement.style.display = "block";
|
||||||
|
|
||||||
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
||||||
? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
|
? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
|
||||||
: activeTarget.getBoundingClientRect();
|
: activeTarget.getBoundingClientRect();
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
.visited-indicator:not(.text-truncate)::after,
|
.visited-indicator:not(.text-truncate)::after,
|
||||||
.visited-indicator.text-truncate::before,
|
.visited-indicator.text-truncate::before,
|
||||||
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
||||||
content: '↗';
|
content: '↗' / "";
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.expand-toggle-button-icon::before {
|
.expand-toggle-button-icon::before {
|
||||||
content: '';
|
content: '' / "";
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -341,6 +341,19 @@ html, body, .body-content {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5 {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
clip-path: inset(50%);
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -563,7 +576,7 @@ kbd:active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary::after {
|
.summary::after {
|
||||||
content: "◀";
|
content: "◀" / "";
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -822,7 +835,7 @@ details[open] .summary::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-horizontal-text > *:not(:last-child)::after {
|
.list-horizontal-text > *:not(:last-child)::after {
|
||||||
content: '•';
|
content: '•' / "";
|
||||||
color: var(--color-text-subdue);
|
color: var(--color-text-subdue);
|
||||||
margin: 0 0.4rem;
|
margin: 0 0.4rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1256,6 +1269,7 @@ details[open] .summary::after {
|
|||||||
|
|
||||||
.dns-stats-graph-bar > .blocked {
|
.dns-stats-graph-bar > .blocked {
|
||||||
background-color: var(--color-negative);
|
background-color: var(--color-negative);
|
||||||
|
flex-basis: calc(var(--percent) - 1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
|
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
|
||||||
|
|||||||
@@ -59,8 +59,8 @@
|
|||||||
{{ if ne $column.Queries $column.Blocked }}
|
{{ if ne $column.Queries $column.Blocked }}
|
||||||
<div class="queries"></div>
|
<div class="queries"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
|
{{ if gt $column.PercentBlocked 0 }}
|
||||||
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
|
<div class="blocked" style="--percent: {{ $column.PercentBlocked }}%"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
<summary class="summary">Top blocked domains</summary>
|
<summary class="summary">Top blocked domains</summary>
|
||||||
<ul class="list list-gap-4 list-with-transition size-h5">
|
<ul class="list list-gap-4 list-with-transition size-h5">
|
||||||
{{ range .Stats.TopBlockedDomains }}
|
{{ range .Stats.TopBlockedDomains }}
|
||||||
<li class="flex justify-between">
|
<li class="flex justify-between gap-10">
|
||||||
<div class="text-truncate rtl">{{ .Domain }}</div>
|
<div class="text-truncate rtl">{{ .Domain }}</div>
|
||||||
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -4,17 +4,18 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="widget-group-header">
|
<div class="widget-group-header">
|
||||||
<div class="widget-header gap-20">
|
<div class="widget-header gap-20" role="tablist">
|
||||||
{{ range $i, $widget := .Widgets }}
|
{{- range $i, $widget := .Widgets }}
|
||||||
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }}>{{ $widget.Title }}</button>
|
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }} aria-selected="{{ if eq $i 0 }}true{{ else }}false{{ end }}" arial-level="2" role="tab" aria-controls="widget-{{ .GetID }}-tabpanel-{{ $i }}" id="widget-{{ .GetID }}-tab-{{ $i }}">{{ $widget.Title }}</button>
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="widget-group-contents">
|
<div class="widget-group-contents">
|
||||||
{{ range $i, $widget := .Widgets }}
|
{{- range $i, $widget := .Widgets }}
|
||||||
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
|
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}" id="widget-{{ .GetID }}-tabpanel-{{ $i }}" role="tabpanel" aria-labelledby="widget-{{ .GetID }}-tab-{{ $i }}" aria-hidden="{{ if eq $i 0 }}false{{ else }}true{{ end }}">
|
||||||
{{ end }}
|
{{- .Render -}}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
{{ define "navigation-links" }}
|
{{ define "navigation-links" }}
|
||||||
{{ range .App.Config.Pages }}
|
{{ range .App.Config.Pages }}
|
||||||
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}"{{ if eq .Slug $.Page.Slug }} aria-current="page"{{ end }}>{{ .Title }}</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
<div class="header-container content-bounds">
|
<div class="header-container content-bounds">
|
||||||
<div class="header flex padding-inline-widget widget-content-frame">
|
<div class="header flex padding-inline-widget widget-content-frame">
|
||||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
||||||
<div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
|
<div class="logo" aria-hidden="true">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
|
||||||
<div class="nav flex grow">
|
<nav class="nav flex grow">
|
||||||
{{ template "navigation-links" . }}
|
{{ template "navigation-links" . }}
|
||||||
</div>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -57,17 +57,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-bounds grow">
|
<div class="content-bounds grow">
|
||||||
<div class="page" id="page">
|
<main class="page" id="page" aria-live="polite" aria-busy="true">
|
||||||
|
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
|
||||||
<div class="page-content" id="page-content"></div>
|
<div class="page-content" id="page-content"></div>
|
||||||
<div class="page-loading-container">
|
<div class="page-loading-container">
|
||||||
<!-- TODO: add a bigger/better loading indicator -->
|
<!-- TODO: add a bigger/better loading indicator -->
|
||||||
<div class="loading-icon"></div>
|
<div class="visually-hidden">Loading</div>
|
||||||
|
<div class="loading-icon" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ if not .App.Config.Branding.HideFooter }}
|
{{ if not .App.Config.Branding.HideFooter }}
|
||||||
<div class="footer flex items-center flex-column">
|
<footer class="footer flex items-center flex-column">
|
||||||
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||||
<div>
|
<div>
|
||||||
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||||
@@ -75,7 +77,7 @@
|
|||||||
{{ else }}
|
{{ else }}
|
||||||
{{ .App.Config.Branding.CustomFooter }}
|
{{ .App.Config.Branding.CustomFooter }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</footer>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<div class="mobile-navigation-offset"></div>
|
<div class="mobile-navigation-offset"></div>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
{{- if not .HideHeader}}
|
{{- if not .HideHeader}}
|
||||||
<div class="widget-header">
|
<div class="widget-header">
|
||||||
{{- if ne "" .TitleURL }}
|
{{- if ne "" .TitleURL }}
|
||||||
<a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>
|
<h2><a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a></h2>
|
||||||
{{- else }}
|
{{- else }}
|
||||||
<div class="uppercase">{{ .Title }}</div>
|
<h2 class="uppercase">{{ .Title }}</h2>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .IsWIP }}
|
{{- if .IsWIP }}
|
||||||
<div data-popover-type="html" data-popover-position="above">
|
<div data-popover-type="html" data-popover-position="above">
|
||||||
|
|||||||
@@ -186,3 +186,7 @@ func ternary[T any](condition bool, a, b T) T {
|
|||||||
|
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Having compile time errors about unused variables is cool and all, but I don't want to
|
||||||
|
// have to constantly comment out my code while I'm working on it and testing things out
|
||||||
|
func ItsUsedTrustMeBro(...any) {}
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
package glance
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"`
|
||||||
|
|
||||||
TimeLabels [8]string `yaml:"-"`
|
TimeLabels [8]string `yaml:"-"`
|
||||||
Stats *dnsStats `yaml:"-"`
|
Stats *dnsStats `yaml:"-"`
|
||||||
|
piholeSessionID string `yaml:"-"`
|
||||||
|
|
||||||
HourFormat string `yaml:"hour-format"`
|
HourFormat string `yaml:"hour-format"`
|
||||||
HideGraph bool `yaml:"hide-graph"`
|
HideGraph bool `yaml:"hide-graph"`
|
||||||
@@ -33,9 +44,9 @@ type dnsStatsWidget struct {
|
|||||||
|
|
||||||
func makeDNSWidgetTimeLabels(format string) [8]string {
|
func makeDNSWidgetTimeLabels(format string) [8]string {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var labels [8]string
|
var labels [dnsStatsBars]string
|
||||||
|
|
||||||
for h := 24; h > 0; h -= 3 {
|
for h := dnsStatsHoursSpan; h > 0; h -= dnsStatsHoursPerBar {
|
||||||
labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
|
labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +59,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-v6":
|
||||||
|
case "pihole":
|
||||||
|
default:
|
||||||
|
return errors.New("service must be one of: adguard, pihole-v6, pihole")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -59,10 +74,24 @@ 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 = fetchPihole5Stats(widget.URL, widget.AllowInsecure, widget.Token, widget.HideGraph)
|
||||||
|
case "pihole6":
|
||||||
|
var newSessionID string
|
||||||
|
stats, newSessionID, err = fetchPiholeStats(
|
||||||
|
widget.URL,
|
||||||
|
widget.AllowInsecure,
|
||||||
|
widget.Password,
|
||||||
|
widget.piholeSessionID,
|
||||||
|
!widget.HideGraph,
|
||||||
|
!widget.HideTopDomains,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
widget.piholeSessionID = newSessionID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
@@ -84,11 +113,11 @@ func (widget *dnsStatsWidget) Render() template.HTML {
|
|||||||
|
|
||||||
type dnsStats struct {
|
type dnsStats struct {
|
||||||
TotalQueries int
|
TotalQueries int
|
||||||
BlockedQueries int
|
BlockedQueries int // we don't actually use this anywhere in templates, maybe remove it later?
|
||||||
BlockedPercent int
|
BlockedPercent int
|
||||||
ResponseTime int
|
ResponseTime int
|
||||||
DomainsBlocked int
|
DomainsBlocked int
|
||||||
Series [8]dnsStatsSeries
|
Series [dnsStatsBars]dnsStatsSeries
|
||||||
TopBlockedDomains []dnsStatsBlockedDomain
|
TopBlockedDomains []dnsStatsBlockedDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,13 +152,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
|||||||
|
|
||||||
request.SetBasicAuth(username, password)
|
request.SetBasicAuth(username, password)
|
||||||
|
|
||||||
var client requestDoer
|
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||||
if !allowInsecure {
|
|
||||||
client = defaultHTTPClient
|
|
||||||
} else {
|
|
||||||
client = defaultInsecureHTTPClient
|
|
||||||
}
|
|
||||||
|
|
||||||
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request)
|
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -150,7 +173,7 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
|||||||
|
|
||||||
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
|
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
|
||||||
|
|
||||||
for i := 0; i < topBlockedDomainsCount; i++ {
|
for i := range topBlockedDomainsCount {
|
||||||
domain := responseJson.TopBlockedDomains[i]
|
domain := responseJson.TopBlockedDomains[i]
|
||||||
var firstDomain string
|
var firstDomain string
|
||||||
|
|
||||||
@@ -179,31 +202,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 := range dnsStatsBars {
|
||||||
queries := 0
|
queries := 0
|
||||||
blocked := 0
|
blocked := 0
|
||||||
|
|
||||||
for j := 0; j < hoursPerBar; j++ {
|
for j := range dnsStatsHoursPerBar {
|
||||||
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,35 +239,36 @@ func fetchAdguardStats(instanceURL string, allowInsecure bool, username, passwor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < bars; i++ {
|
for i := range dnsStatsBars {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type piholeStatsResponse struct {
|
// Legacy Pi-hole stats response (before v6)
|
||||||
TotalQueries int `json:"dns_queries_today"`
|
type pihole5StatsResponse struct {
|
||||||
QueriesSeries piholeQueriesSeries `json:"domains_over_time"`
|
TotalQueries int `json:"dns_queries_today"`
|
||||||
BlockedQueries int `json:"ads_blocked_today"`
|
QueriesSeries pihole5QueriesSeries `json:"domains_over_time"`
|
||||||
BlockedSeries map[int64]int `json:"ads_over_time"`
|
BlockedQueries int `json:"ads_blocked_today"`
|
||||||
BlockedPercentage float64 `json:"ads_percentage_today"`
|
BlockedSeries map[int64]int `json:"ads_over_time"`
|
||||||
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
|
BlockedPercentage float64 `json:"ads_percentage_today"`
|
||||||
DomainsBlocked int `json:"domains_being_blocked"`
|
TopBlockedDomains pihole5TopBlockedDomains `json:"top_ads"`
|
||||||
|
DomainsBlocked int `json:"domains_being_blocked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user has query logging disabled it's possible for domains_over_time to be returned as an
|
// If the user has query logging disabled it's possible for domains_over_time to be returned as an
|
||||||
// empty array rather than a map which will prevent unmashalling the rest of the data so we use
|
// empty array rather than a map which will prevent unmashalling the rest of the data so we use
|
||||||
// custom unmarshal behavior to fallback to an empty map.
|
// custom unmarshal behavior to fallback to an empty map.
|
||||||
// See https://github.com/glanceapp/glance/issues/289
|
// See https://github.com/glanceapp/glance/issues/289
|
||||||
type piholeQueriesSeries map[int64]int
|
type pihole5QueriesSeries map[int64]int
|
||||||
|
|
||||||
func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error {
|
func (p *pihole5QueriesSeries) UnmarshalJSON(data []byte) error {
|
||||||
temp := make(map[int64]int)
|
temp := make(map[int64]int)
|
||||||
|
|
||||||
err := json.Unmarshal(data, &temp)
|
err := json.Unmarshal(data, &temp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
*p = make(piholeQueriesSeries)
|
*p = make(pihole5QueriesSeries)
|
||||||
} else {
|
} else {
|
||||||
*p = temp
|
*p = temp
|
||||||
}
|
}
|
||||||
@@ -258,16 +278,16 @@ func (p *piholeQueriesSeries) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
|
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
|
||||||
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
|
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
|
||||||
type piholeTopBlockedDomains map[string]int
|
type pihole5TopBlockedDomains map[string]int
|
||||||
|
|
||||||
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
|
func (p *pihole5TopBlockedDomains) UnmarshalJSON(data []byte) error {
|
||||||
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
|
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
|
||||||
// because of the UnmarshalJSON method getting called recursively
|
// because of the UnmarshalJSON method getting called recursively
|
||||||
temp := make(map[string]int)
|
temp := make(map[string]int)
|
||||||
|
|
||||||
err := json.Unmarshal(data, &temp)
|
err := json.Unmarshal(data, &temp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
*p = make(piholeTopBlockedDomains)
|
*p = make(pihole5TopBlockedDomains)
|
||||||
} else {
|
} else {
|
||||||
*p = temp
|
*p = temp
|
||||||
}
|
}
|
||||||
@@ -275,7 +295,7 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
func fetchPihole5Stats(instanceURL string, allowInsecure bool, token string, noGraph bool) (*dnsStats, error) {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, errors.New("missing API token")
|
return nil, errors.New("missing API token")
|
||||||
}
|
}
|
||||||
@@ -288,14 +308,8 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var client requestDoer
|
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||||
if !allowInsecure {
|
responseJson, err := decodeJsonFromRequest[pihole5StatsResponse](client, request)
|
||||||
client = defaultHTTPClient
|
|
||||||
} else {
|
|
||||||
client = defaultInsecureHTTPClient
|
|
||||||
}
|
|
||||||
|
|
||||||
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -339,7 +353,6 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lowestTimestamp int64 = 0
|
var lowestTimestamp int64 = 0
|
||||||
|
|
||||||
for timestamp := range responseJson.QueriesSeries {
|
for timestamp := range responseJson.QueriesSeries {
|
||||||
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
|
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
|
||||||
lowestTimestamp = timestamp
|
lowestTimestamp = timestamp
|
||||||
@@ -348,11 +361,11 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
|||||||
|
|
||||||
maxQueriesInSeries := 0
|
maxQueriesInSeries := 0
|
||||||
|
|
||||||
for i := 0; i < 8; i++ {
|
for i := range dnsStatsBars {
|
||||||
queries := 0
|
queries := 0
|
||||||
blocked := 0
|
blocked := 0
|
||||||
|
|
||||||
for j := 0; j < 18; j++ {
|
for j := range 18 {
|
||||||
index := lowestTimestamp + int64(i*10800+j*600)
|
index := lowestTimestamp + int64(i*10800+j*600)
|
||||||
|
|
||||||
queries += responseJson.QueriesSeries[index]
|
queries += responseJson.QueriesSeries[index]
|
||||||
@@ -373,9 +386,282 @@ func fetchPiholeStats(instanceURL string, allowInsecure bool, token string, noGr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 8; i++ {
|
for i := range dnsStatsBars {
|
||||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchPiholeStats(
|
||||||
|
instanceURL string,
|
||||||
|
allowInsecure bool,
|
||||||
|
password string,
|
||||||
|
sessionID string,
|
||||||
|
includeGraph bool,
|
||||||
|
includeTopDomains bool,
|
||||||
|
) (*dnsStats, string, error) {
|
||||||
|
instanceURL = strings.TrimRight(instanceURL, "/")
|
||||||
|
var client = ternary(allowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
|
||||||
|
|
||||||
|
fetchNewSessionID := func() error {
|
||||||
|
newSessionID, err := fetchPiholeSessionID(instanceURL, client, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sessionID = newSessionID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionID == "" {
|
||||||
|
if err := fetchNewSessionID(); err != nil {
|
||||||
|
slog.Error("Failed to fetch Pihole v6 session ID", "error", err)
|
||||||
|
return nil, "", fmt.Errorf("fetching session ID: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isValid, err := checkPiholeSessionIDIsValid(instanceURL, client, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to check Pihole v6 session ID validity", "error", err)
|
||||||
|
return nil, "", fmt.Errorf("checking session ID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValid {
|
||||||
|
if err := fetchNewSessionID(); err != nil {
|
||||||
|
slog.Error("Failed to renew Pihole v6 session ID", "error", err)
|
||||||
|
return nil, "", fmt.Errorf("renewing session ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
type statsResponseJson struct {
|
||||||
|
Queries struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Blocked int `json:"blocked"`
|
||||||
|
PercentBlocked float64 `json:"percent_blocked"`
|
||||||
|
} `json:"queries"`
|
||||||
|
Gravity struct {
|
||||||
|
DomainsBlocked int `json:"domains_being_blocked"`
|
||||||
|
} `json:"gravity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
statsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/summary", nil)
|
||||||
|
statsRequest.Header.Set("x-ftl-sid", sessionID)
|
||||||
|
|
||||||
|
var statsResponse statsResponseJson
|
||||||
|
var statsErr error
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
statsResponse, statsErr = decodeJsonFromRequest[statsResponseJson](client, statsRequest)
|
||||||
|
if statsErr != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
type seriesResponseJson struct {
|
||||||
|
History []struct {
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Blocked int `json:"blocked"`
|
||||||
|
} `json:"history"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var seriesResponse seriesResponseJson
|
||||||
|
var seriesErr error
|
||||||
|
|
||||||
|
if includeGraph {
|
||||||
|
seriesRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/history", nil)
|
||||||
|
seriesRequest.Header.Set("x-ftl-sid", sessionID)
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
seriesResponse, seriesErr = decodeJsonFromRequest[seriesResponseJson](client, seriesRequest)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
type topDomainsResponseJson struct {
|
||||||
|
Domains []struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
} `json:"domains"`
|
||||||
|
TotalQueries int `json:"total_queries"`
|
||||||
|
BlockedQueries int `json:"blocked_queries"`
|
||||||
|
Took float64 `json:"took"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var topDomainsResponse topDomainsResponseJson
|
||||||
|
var topDomainsErr error
|
||||||
|
|
||||||
|
if includeTopDomains {
|
||||||
|
topDomainsRequest, _ := http.NewRequestWithContext(ctx, "GET", instanceURL+"/api/stats/top_domains?blocked=true", nil)
|
||||||
|
topDomainsRequest.Header.Set("x-ftl-sid", sessionID)
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
topDomainsResponse, topDomainsErr = decodeJsonFromRequest[topDomainsResponseJson](client, topDomainsRequest)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
partialContent := false
|
||||||
|
|
||||||
|
if statsErr != nil {
|
||||||
|
return nil, "", fmt.Errorf("fetching stats: %v", statsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeGraph && seriesErr != nil {
|
||||||
|
slog.Error("Failed to fetch Pihole v6 graph data", "error", seriesErr)
|
||||||
|
partialContent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeTopDomains && topDomainsErr != nil {
|
||||||
|
slog.Error("Failed to fetch Pihole v6 top domains", "error", topDomainsErr)
|
||||||
|
partialContent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := &dnsStats{
|
||||||
|
TotalQueries: statsResponse.Queries.Total,
|
||||||
|
BlockedQueries: statsResponse.Queries.Blocked,
|
||||||
|
BlockedPercent: int(statsResponse.Queries.PercentBlocked),
|
||||||
|
DomainsBlocked: statsResponse.Gravity.DomainsBlocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
ItsUsedTrustMeBro(seriesResponse, topDomainsResponse)
|
||||||
|
|
||||||
|
if includeGraph && seriesErr == nil {
|
||||||
|
if len(seriesResponse.History) != 145 {
|
||||||
|
slog.Error(
|
||||||
|
"Pihole v6 graph data has unexpected length",
|
||||||
|
"length", len(seriesResponse.History),
|
||||||
|
"expected", 145,
|
||||||
|
)
|
||||||
|
partialContent = true
|
||||||
|
} else {
|
||||||
|
// The API from v5 used to return 144 data points, but v6 returns 145.
|
||||||
|
// We only show data from the last 24 hours hours, Pihole returns data
|
||||||
|
// points in a 10 minute interval, 24*(60/10) = 144. Why is there an extra
|
||||||
|
// data point? I don't know, but we'll just ignore the first one since it's
|
||||||
|
// the oldest data point.
|
||||||
|
history := seriesResponse.History[1:]
|
||||||
|
|
||||||
|
const interval = 10
|
||||||
|
const dataPointsPerBar = dnsStatsHoursPerBar * (60 / interval)
|
||||||
|
|
||||||
|
maxQueriesInSeries := 0
|
||||||
|
|
||||||
|
for i := range dnsStatsBars {
|
||||||
|
queries := 0
|
||||||
|
blocked := 0
|
||||||
|
for j := range dataPointsPerBar {
|
||||||
|
index := i*dataPointsPerBar + j
|
||||||
|
queries += history[index].Total
|
||||||
|
blocked += history[index].Blocked
|
||||||
|
}
|
||||||
|
if queries > maxQueriesInSeries {
|
||||||
|
maxQueriesInSeries = queries
|
||||||
|
}
|
||||||
|
stats.Series[i] = dnsStatsSeries{
|
||||||
|
Queries: queries,
|
||||||
|
Blocked: blocked,
|
||||||
|
}
|
||||||
|
if queries > 0 {
|
||||||
|
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range dnsStatsBars {
|
||||||
|
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeTopDomains && topDomainsErr == nil && len(topDomainsResponse.Domains) > 0 {
|
||||||
|
domains := make([]dnsStatsBlockedDomain, 0, len(topDomainsResponse.Domains))
|
||||||
|
for i := range topDomainsResponse.Domains {
|
||||||
|
d := &topDomainsResponse.Domains[i]
|
||||||
|
domains = append(domains, dnsStatsBlockedDomain{
|
||||||
|
Domain: d.Domain,
|
||||||
|
PercentBlocked: int(float64(d.Count) / float64(statsResponse.Queries.Blocked) * 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(domains, func(a, b int) bool {
|
||||||
|
return domains[a].PercentBlocked > domains[b].PercentBlocked
|
||||||
|
})
|
||||||
|
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, sessionID, ternary(partialContent, errPartialContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPiholeSessionID(instanceURL string, client *http.Client, password string) (string, error) {
|
||||||
|
requestBody := []byte(`{"password":"` + password + `"}`)
|
||||||
|
|
||||||
|
request, err := http.NewRequest("POST", instanceURL+"/api/auth", bytes.NewBuffer(requestBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating authentication request: %v", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("sending authentication request: %v", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading authentication response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonResponse struct {
|
||||||
|
Session struct {
|
||||||
|
SID string `json:"sid"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &jsonResponse); err != nil {
|
||||||
|
return "", fmt.Errorf("parsing authentication response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"authentication request returned status %s with message '%s'",
|
||||||
|
response.Status, jsonResponse.Session.Message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonResponse.Session.SID == "" {
|
||||||
|
return "", errors.New("authentication response returned empty session ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse.Session.SID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPiholeSessionIDIsValid(instanceURL string, client *http.Client, sessionID string) (bool, error) {
|
||||||
|
request, err := http.NewRequest("GET", instanceURL+"/api/auth", nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("creating session ID check request: %v", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("x-ftl-sid", sessionID)
|
||||||
|
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusUnauthorized {
|
||||||
|
return false, fmt.Errorf("session ID check request returned status %s", response.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.StatusCode == http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefau
|
|||||||
|
|
||||||
func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) {
|
func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 3 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||||
return net.Dial("unix", socketPath)
|
return net.Dial("unix", socketPath)
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
|
|||||||
|
|
||||||
for i := range marketRequests {
|
for i := range marketRequests {
|
||||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
|
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
|
||||||
|
setBrowserUserAgentHeader(request)
|
||||||
requests = append(requests, request)
|
requests = append(requests, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +166,7 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
|
|||||||
|
|
||||||
points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||||
|
|
||||||
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
|
currency, exists := currencyToSymbol[strings.ToUpper(response.Chart.Result[0].Meta.Currency)]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
currency = response.Chart.Result[0].Meta.Currency
|
currency = response.Chart.Result[0].Meta.Currency
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/rand/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,8 +38,15 @@ type requestDoer interface {
|
|||||||
Do(*http.Request) (*http.Response, error)
|
Do(*http.Request) (*http.Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var userAgentPersistentVersion atomic.Int32
|
||||||
|
|
||||||
func setBrowserUserAgentHeader(request *http.Request) {
|
func setBrowserUserAgentHeader(request *http.Request) {
|
||||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")
|
if rand.IntN(2000) == 0 {
|
||||||
|
userAgentPersistentVersion.Store(rand.Int32N(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
|
||||||
|
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:"+version+".0) Gecko/20100101 Firefox/"+version+".0")
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
|
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ func (widget *videosWidget) initialize() error {
|
|||||||
// playlists are separate things rather than specifying a list of channels and some of
|
// playlists are separate things rather than specifying a list of channels and some of
|
||||||
// them awkwardly have a "playlist:" prefix
|
// them awkwardly have a "playlist:" prefix
|
||||||
if len(widget.Playlists) > 0 {
|
if len(widget.Playlists) > 0 {
|
||||||
|
initialLen := len(widget.Channels)
|
||||||
widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...)
|
widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...)
|
||||||
|
|
||||||
for i := range widget.Playlists {
|
for i := range widget.Playlists {
|
||||||
widget.Channels[len(widget.Channels)-1+i] = "playlist:" + widget.Playlists[i]
|
widget.Channels[initialLen+i] = "playlist:" + widget.Playlists[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,13 +121,13 @@ type widget interface {
|
|||||||
// These need to be exported because they get called in templates
|
// These need to be exported because they get called in templates
|
||||||
Render() template.HTML
|
Render() template.HTML
|
||||||
GetType() string
|
GetType() string
|
||||||
|
GetID() uint64
|
||||||
|
|
||||||
initialize() error
|
initialize() error
|
||||||
requiresUpdate(*time.Time) bool
|
requiresUpdate(*time.Time) bool
|
||||||
setProviders(*widgetProviders)
|
setProviders(*widgetProviders)
|
||||||
update(context.Context)
|
update(context.Context)
|
||||||
setID(uint64)
|
setID(uint64)
|
||||||
id() uint64
|
|
||||||
handleRequest(w http.ResponseWriter, r *http.Request)
|
handleRequest(w http.ResponseWriter, r *http.Request)
|
||||||
setHideHeader(bool)
|
setHideHeader(bool)
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,7 @@ func (w *widgetBase) update(ctx context.Context) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *widgetBase) id() uint64 {
|
func (w *widgetBase) GetID() uint64 {
|
||||||
return w.ID
|
return w.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+43
-13
@@ -3,6 +3,7 @@ package sysinfo
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -73,24 +74,52 @@ type MountpointInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemInfoRequest struct {
|
type SystemInfoRequest struct {
|
||||||
CPUTempSensor string `yaml:"cpu-temp-sensor"`
|
CPUTempSensor string `yaml:"cpu-temp-sensor"`
|
||||||
Mountpoints map[string]MointpointRequest `yaml:"mountpoints"`
|
HideMountpointsByDefault bool `yaml:"hide-mountpoints-by-default"`
|
||||||
|
Mountpoints map[string]MointpointRequest `yaml:"mountpoints"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MointpointRequest struct {
|
type MointpointRequest struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Hide bool `yaml:"hide"`
|
Hide *bool `yaml:"hide"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Currently caches hostname indefinitely which isn't ideal
|
// Currently caches hostname indefinitely which isn't ideal
|
||||||
// Potential issue with caching boot time as it may not initially get reported correctly:
|
// Potential issue with caching boot time as it may not initially get reported correctly:
|
||||||
// https://github.com/shirou/gopsutil/issues/842#issuecomment-1908972344
|
// https://github.com/shirou/gopsutil/issues/842#issuecomment-1908972344
|
||||||
var cachedHostInfo = struct {
|
type cacheableHostInfo struct {
|
||||||
available bool
|
available bool
|
||||||
hostname string
|
hostname string
|
||||||
platform string
|
platform string
|
||||||
bootTime timestampJSON
|
bootTime timestampJSON
|
||||||
}{}
|
}
|
||||||
|
|
||||||
|
var cachedHostInfo cacheableHostInfo
|
||||||
|
|
||||||
|
func getHostInfo() (cacheableHostInfo, error) {
|
||||||
|
var err error
|
||||||
|
info := cacheableHostInfo{}
|
||||||
|
|
||||||
|
info.hostname, err = os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info.platform, _, _, err = host.PlatformInformation()
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bootTime, err := host.BootTime()
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info.bootTime = timestampJSON{time.Unix(int64(bootTime), 0)}
|
||||||
|
info.available = true
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
|
func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
@@ -117,13 +146,9 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
|
|||||||
if cachedHostInfo.available {
|
if cachedHostInfo.available {
|
||||||
applyCachedHostInfo()
|
applyCachedHostInfo()
|
||||||
} else {
|
} else {
|
||||||
hostInfo, err := host.Info()
|
hostInfo, err := getHostInfo()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
cachedHostInfo.available = true
|
cachedHostInfo = hostInfo
|
||||||
cachedHostInfo.bootTime = timestampJSON{time.Unix(int64(hostInfo.BootTime), 0)}
|
|
||||||
cachedHostInfo.hostname = hostInfo.Hostname
|
|
||||||
cachedHostInfo.platform = hostInfo.Platform
|
|
||||||
|
|
||||||
applyCachedHostInfo()
|
applyCachedHostInfo()
|
||||||
} else {
|
} else {
|
||||||
addErr(fmt.Errorf("getting host info: %v", err))
|
addErr(fmt.Errorf("getting host info: %v", err))
|
||||||
@@ -177,7 +202,8 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
|
|||||||
// keeps returning a single sensor with key "ACPI\\ThermalZone\\TZ00_0" which
|
// keeps returning a single sensor with key "ACPI\\ThermalZone\\TZ00_0" which
|
||||||
// doesn't seem to be the CPU sensor or correspond to anything useful when
|
// doesn't seem to be the CPU sensor or correspond to anything useful when
|
||||||
// compared against the temperatures Libre Hardware Monitor reports
|
// compared against the temperatures Libre Hardware Monitor reports
|
||||||
if runtime.GOOS != "windows" {
|
// also disabled on openbsd because it's not implemented by go-psutil
|
||||||
|
if runtime.GOOS != "windows" && runtime.GOOS != "openbsd" {
|
||||||
sensorReadings, err := sensors.SensorsTemperatures()
|
sensorReadings, err := sensors.SensorsTemperatures()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if req.CPUTempSensor != "" {
|
if req.CPUTempSensor != "" {
|
||||||
@@ -205,7 +231,11 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
for _, fs := range filesystems {
|
for _, fs := range filesystems {
|
||||||
mpReq, ok := req.Mountpoints[fs.Mountpoint]
|
mpReq, ok := req.Mountpoints[fs.Mountpoint]
|
||||||
if ok && mpReq.Hide {
|
isHidden := req.HideMountpointsByDefault
|
||||||
|
if ok && mpReq.Hide != nil {
|
||||||
|
isHidden = *mpReq.Hide
|
||||||
|
}
|
||||||
|
if isHidden {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user