Compare commits

...

39 commits
v0.7.8 ... main

Author SHA1 Message Date
Svilen Markov
03b616622e Fix bug when making single request in job 2025-04-14 01:07:04 +01:00
Svilen Markov
50e1da01fe Add newline 2025-04-13 17:40:30 +01:00
Svilen Markov
f8f50b26d8 Add mountpoint:info CLI command 2025-04-13 17:09:51 +01:00
Svilen Markov
a3bc133bcb Maybe fix for missing mountpoint info 2025-04-13 16:52:17 +01:00
Svilen Markov
50dae22ff4 Add restart: unless-stopped 2025-04-13 16:15:59 +01:00
Svilen Markov
5a91154eab Document function 2025-04-13 16:08:04 +01:00
Ralph Ocdol
d5b89d512a added unique to filter arrays with unique items in custom-api 2025-04-13 16:07:54 +01:00
Svilen Markov
97f43f88eb Only set title if attribute isn't set 2025-04-11 23:14:06 +01:00
Svilen Markov
3177af9524 Add rounded class 2025-04-11 22:34:53 +01:00
Svilen Markov
f99d22738c Add replaceMatches 2025-04-11 20:37:45 +01:00
Ralph Ocdol
ea7124db52 feat: custom-api's replaceAll with regex support 2025-04-11 20:37:36 +01:00
Lucas L.
b1247e5de6 Add filter on already seen links for RSS feeds 2025-04-11 20:34:47 +01:00
Svilen Markov
5ab962634e Fix sorting bug in twitch channels widget 2025-04-11 20:34:03 +01:00
Svilen Markov
d99944dcff Avoid spinning up unnecessary goroutines for single data jobs 2025-04-11 20:33:24 +01:00
Svilen Markov
920bcb06dc Print warnings when failing to get sensors 2025-04-11 19:34:49 +01:00
Svilen Markov
3679cc21ab Increase scale of everything on mobile 2025-04-10 00:44:55 +01:00
Svilen Markov
07e5626469 Add version & sensors:print cli commands 2025-04-10 00:39:08 +01:00
Svilen Markov
6bb6f87800 Fix diagnose command 2025-04-10 00:35:27 +01:00
Svilen Markov
cb6b52ff4c Bump go version 2025-04-10 00:34:53 +01:00
Svilen Markov
03c377dff3 Update dependencies 2025-04-10 00:14:40 +01:00
Svilen Markov
0e3f172032
Merge pull request #553 from Panonim/patch-1
Added Community Widgets Link to Readme.
2025-04-09 16:40:36 +01:00
Svilen Markov
fd704419d9 Update readme links 2025-04-09 16:39:53 +01:00
Artur Flis
32ac72f592
Update README.md 2025-04-02 19:57:01 +02:00
Svilen Markov
9c77b87435
Merge pull request #486 from daot/main
Make clickable area for docker-containers widget items bigger
2025-03-30 14:57:16 +01:00
Svilen Markov
d4d9e94f44 Make link hitbox take up full width 2025-03-30 14:53:59 +01:00
Svilen Markov
47cef2abf0
Merge pull request #499 from FranklyFuzzy/main
Added Dracula theme code and screenshot
2025-03-30 14:40:15 +01:00
Svilen Markov
0a5cdf380f
Merge pull request #528 from diceroll123/patch-1
Fix typo
2025-03-30 14:39:39 +01:00
Svilen Markov
9da7967158 Update docs 2025-03-29 18:00:36 +00:00
Svilen Markov
964744a9ae Add offsetNow function 2025-03-29 17:57:43 +00:00
Svilen Markov
779304d035 Allow skipping JSON check and add JSONLines 2025-03-29 13:36:22 +00:00
Svilen Markov
f15d7445bd Add concat function 2025-03-29 13:20:53 +00:00
Svilen Markov
26d68ba3fc Allow extension widget to specify title-url 2025-03-29 10:56:11 +00:00
Svilen Markov
958805a1fd Increase z-index of mobile nav
This fixes the carousel gradient side
being above it since it also has z-index 10
2025-03-29 10:49:37 +00:00
Svilen Markov
dd74c173a5 More custom-api additions/tweaks
* Remove need to convert to int for math stuff
* Add `now` and `duration` functions
2025-03-28 23:34:49 +00:00
Svilen Markov
bd020c93f5 Fix markets price precision 2025-03-28 14:11:17 +00:00
Steve C
75555f0426
Fix typo 2025-03-27 23:45:27 -04:00
FranklyFuzzy
1483b355af
Update themes.md
Added Dracula theme with screenshot
2025-03-23 14:16:09 -04:00
FranklyFuzzy
6a5bb635bb
Add files via upload 2025-03-23 14:14:54 -04:00
‮daot
0e0aca3844 Update docker-containers.html 2025-03-21 18:08:15 -05:00
24 changed files with 398 additions and 85 deletions

View file

@ -1,4 +1,4 @@
FROM golang:1.23.6-alpine3.21 AS builder FROM golang:1.24.2-alpine3.21 AS builder
WORKDIR /app WORKDIR /app
COPY . /app COPY . /app

View file

@ -1,6 +1,7 @@
<p align="center"><em>What if you could see everything at a...</em></p> <p align="center"><em>What if you could see everything at a...</em></p>
<h1 align="center">Glance</h1> <h1 align="center">Glance</h1>
<p align="center"><a href="#installation">Install</a><a href="docs/configuration.md">Configuration</a><a href="docs/preconfigured-pages.md">Preconfigured pages</a><a href="docs/themes.md">Themes</a><a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a></p> <p align="center"><a href="#installation">Install</a><a href="docs/configuration.md">Configuration</a><a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a><a href="https://github.com/sponsors/glanceapp">Sponsor</a></p>
<p align="center"><a href="https://github.com/glanceapp/community-widgets">Community widgets</a><a href="docs/preconfigured-pages.md">Preconfigured pages</a><a href="docs/themes.md">Themes</a></p>
![](docs/images/readme-main-image.png) ![](docs/images/readme-main-image.png)
@ -194,6 +195,7 @@ services:
glance: glance:
container_name: glance container_name: glance
image: glanceapp/glance image: glanceapp/glance
restart: unless-stopped
volumes: volumes:
- ./config:/app/config - ./config:/app/config
ports: ports:

View file

@ -365,7 +365,7 @@ pages:
| show-mobile-header | boolean | no | false | | show-mobile-header | boolean | no | false |
| columns | array | yes | | | columns | array | yes | |
#### `title` #### `name`
The name of the page which gets shown in the navigation bar. The name of the page which gets shown in the navigation bar.
#### `slug` #### `slug`
@ -1297,6 +1297,7 @@ Examples:
| body | any | no | | | body | any | no | |
| frameless | boolean | no | false | | frameless | boolean | no | false |
| allow-insecure | boolean | no | false | | allow-insecure | boolean | no | false |
| skip-json-validation | boolean | no | false |
| template | string | yes | | | template | string | yes | |
| parameters | key (string) & value (string|array) | no | | | parameters | key (string) & value (string|array) | no | |
| subrequests | map of requests | no | | | subrequests | map of requests | no | |
@ -1344,6 +1345,9 @@ When set to `true`, removes the border and padding around the widget.
##### `allow-insecure` ##### `allow-insecure`
Whether to ignore invalid/self-signed certificates. Whether to ignore invalid/self-signed certificates.
##### `skip-json-validation`
When set to `true`, skips the JSON validation step. This is useful when the API returns JSON Lines/newline-delimited JSON, which is a format that consists of several JSON objects separated by newlines.
##### `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).
@ -1946,7 +1950,7 @@ 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 | | hide-mountpoints-by-default | boolean | no | false |
| mountpoints | map\[string\]object | no | | | mountpoints | map\[string\]object | no | |
###### `cpu-temp-sensor` ###### `cpu-temp-sensor`

View file

@ -226,10 +226,10 @@ JSON response:
} }
``` ```
Calculations can be performed, however all numbers must be converted to floats first if they are not already: Calculations can be performed on either ints or floats. If both numbers are ints, an int will be returned, otherwise a float will be returned. If you try to divide by zero, 0 will be returned. If you provide non-numeric values, `NaN` will be returned.
```html ```html
<div>{{ sub (.JSON.Int "price" | toFloat) (.JSON.Int "discount" | toFloat) }}</div> <div>{{ sub (.JSON.Int "price") (.JSON.Int "discount") }}</div>
``` ```
Output: Output:
@ -309,6 +309,55 @@ You can also access the response headers:
<div>{{ .Response.Header.Get "Content-Type" }}</div> <div>{{ .Response.Header.Get "Content-Type" }}</div>
``` ```
<hr>
JSON response:
```json
{"name": "Steve", "age": 30}
{"name": "Alex", "age": 25}
{"name": "John", "age": 35}
```
The above format is "[ndjson](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)" or "[JSON Lines](https://jsonlines.org/)", where each line is a separate JSON object. To parse this format, you must first disable the JSON validation check in your config, since by default the response is expected to be a single valid JSON object:
```yaml
- type: custom-api
skip-json-validation: true
```
Then, to iterate over each object you can use `.JSONLines`:
```html
{{ range .JSONLines }}
<p>{{ .String "name" }} is {{ .Int "age" }} years old</p>
{{ end }}
```
Output:
```html
<p>Steve is 30 years old</p>
<p>Alex is 25 years old</p>
<p>John is 35 years old</p>
```
For other ways of selecting data from a JSON Lines response, have a look at the docs for [tidwall/gjson](https://github.com/tidwall/gjson/tree/master?tab=readme-ov-file#json-lines). For example, to get an array of all names, you can use the following:
```html
{{ range .JSON.Array "..#.name" }}
<p>{{ .String "" }}</p>
{{ end }}
```
Output:
```html
<p>Steve</p>
<p>Alex</p>
<p>John</p>
```
## Functions ## Functions
The following functions are available on the `JSON` object: The following functions are available on the `JSON` object:
@ -325,6 +374,9 @@ 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>`. - `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>`.
- `now() time.Time`: Returns the current time.
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
- `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: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly". - `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: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`. - `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
- `add(a, b float) float`: Adds two numbers. - `add(a, b float) float`: Adds two numbers.
@ -337,12 +389,15 @@ The following helper functions provided by Glance are available:
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string. - `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
- `trimSpace(str string) string`: Trims whitespace from a string on both ends. - `trimSpace(str string) string`: Trims whitespace from a string on both ends.
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string. - `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string.
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string. - `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string. - `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order. - `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
- `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order. - `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order.
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order. - `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). - `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
- `concat(strings ...string) string`: Concatenates multiple strings together.
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
The following helper functions provided by Go's `text/template` are available: The following helper functions provided by Go's `text/template` are available:

View file

@ -26,6 +26,9 @@ If you know how to setup an HTTP server and a bit of HTML and CSS you're ready t
### `Widget-Title` ### `Widget-Title`
Used to specify the title of the widget. If not provided, the widget's title will be "Extension". Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
### `Widget-Title-URL`
Used to specify the URL that will be opened when the widget's title is clicked. If the user has specified a `title-url` in their config, it will take precedence over this header.
### `Widget-Content-Type` ### `Widget-Content-Type`
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text. Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -82,6 +82,17 @@ theme:
negative-color: 209 88 54 negative-color: 209 88 54
``` ```
### Dracula
![screenshot](images/themes/dracula.png)
```yaml
theme:
background-color: 231 15 21
primary-color: 265 89 79
contrast-multiplier: 1.2
positive-color: 135 94 66
negative-color: 0 100 67
```
## Light ## Light
### Catppuccin Latte ### Catppuccin Latte

20
go.mod
View file

@ -1,32 +1,32 @@
module github.com/glanceapp/glance module github.com/glanceapp/glance
go 1.23.6 go 1.24.2
require ( require (
github.com/fsnotify/fsnotify v1.8.0 github.com/fsnotify/fsnotify v1.9.0
github.com/mmcdole/gofeed v1.3.0 github.com/mmcdole/gofeed v1.3.0
github.com/shirou/gopsutil/v4 v4.25.1 github.com/shirou/gopsutil/v4 v4.25.3
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
golang.org/x/text v0.22.0 golang.org/x/text v0.24.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/PuerkitoBio/goquery v1.10.1 // indirect github.com/PuerkitoBio/goquery v1.10.2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/ebitengine/purego v0.8.2 // indirect github.com/ebitengine/purego v0.8.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect github.com/tklauser/numcpus v0.10.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.32.0 // indirect
) )

19
go.sum
View file

@ -1,5 +1,7 @@
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -9,16 +11,21 @@ github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
@ -34,6 +41,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@ -47,8 +56,12 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@ -74,6 +87,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -97,6 +112,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -117,6 +134,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View file

@ -5,23 +5,39 @@ import (
"fmt" "fmt"
"os" "os"
"strings" "strings"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/sensors"
) )
type cliIntent uint8 type cliIntent uint8
const ( const (
cliIntentServe cliIntent = iota cliIntentVersionPrint cliIntent = iota
cliIntentConfigValidate = iota cliIntentServe
cliIntentConfigPrint = iota cliIntentConfigValidate
cliIntentDiagnose = iota cliIntentConfigPrint
cliIntentDiagnose
cliIntentSensorsPrint
cliIntentMountpointInfo
) )
type cliOptions struct { type cliOptions struct {
intent cliIntent intent cliIntent
configPath string configPath string
args []string
} }
func parseCliOptions() (*cliOptions, error) { func parseCliOptions() (*cliOptions, error) {
var args []string
args = os.Args[1:]
if len(args) == 1 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") {
return &cliOptions{
intent: cliIntentVersionPrint,
}, nil
}
flags := flag.NewFlagSet("", flag.ExitOnError) flags := flag.NewFlagSet("", flag.ExitOnError)
flags.Usage = func() { flags.Usage = func() {
fmt.Println("Usage: glance [options] command") fmt.Println("Usage: glance [options] command")
@ -32,6 +48,8 @@ func parseCliOptions() (*cliOptions, error) {
fmt.Println("\nCommands:") fmt.Println("\nCommands:")
fmt.Println(" config:validate Validate the config file") fmt.Println(" config:validate Validate the config file")
fmt.Println(" config:print Print the parsed config file with embedded includes") fmt.Println(" config:print Print the parsed config file with embedded includes")
fmt.Println(" sensors:print List all sensors")
fmt.Println(" mountpoint:info Print information about a given mountpoint path")
fmt.Println(" diagnose Run diagnostic checks") fmt.Println(" diagnose Run diagnostic checks")
} }
configPath := flags.String("config", "glance.yml", "Set config path") configPath := flags.String("config", "glance.yml", "Set config path")
@ -41,7 +59,7 @@ func parseCliOptions() (*cliOptions, error) {
} }
var intent cliIntent var intent cliIntent
var args = flags.Args() args = flags.Args()
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " ")) unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
if len(args) == 0 { if len(args) == 0 {
@ -51,11 +69,19 @@ func parseCliOptions() (*cliOptions, error) {
intent = cliIntentConfigValidate intent = cliIntentConfigValidate
} else if args[0] == "config:print" { } else if args[0] == "config:print" {
intent = cliIntentConfigPrint intent = cliIntentConfigPrint
} else if args[0] == "sensors:print" {
intent = cliIntentSensorsPrint
} else if args[0] == "diagnose" { } else if args[0] == "diagnose" {
intent = cliIntentDiagnose intent = cliIntentDiagnose
} else { } else {
return nil, unknownCommandErr return nil, unknownCommandErr
} }
} else if len(args) == 2 {
if args[0] == "mountpoint:info" {
intent = cliIntentMountpointInfo
} else {
return nil, unknownCommandErr
}
} else { } else {
return nil, unknownCommandErr return nil, unknownCommandErr
} }
@ -63,5 +89,51 @@ func parseCliOptions() (*cliOptions, error) {
return &cliOptions{ return &cliOptions{
intent: intent, intent: intent,
configPath: *configPath, configPath: *configPath,
args: args,
}, nil }, nil
} }
func cliSensorsPrint() int {
tempSensors, err := sensors.SensorsTemperatures()
if err != nil {
fmt.Printf("Failed to retrieve list of sensors: %v\n", err)
if warns, ok := err.(*sensors.Warnings); ok {
for _, w := range warns.List {
fmt.Printf(" - %v\n", w)
}
}
return 1
}
if len(tempSensors) == 0 {
fmt.Println("No sensors found")
return 0
}
for _, sensor := range tempSensors {
fmt.Printf("%s: %.1f°C\n", sensor.SensorKey, sensor.Temperature)
}
return 0
}
func cliMountpointInfo(requestedPath string) int {
usage, err := disk.Usage(requestedPath)
if err != nil {
fmt.Printf("Failed to retrieve info for path %s: %v\n", requestedPath, err)
if warns, ok := err.(*disk.Warnings); ok {
for _, w := range warns.List {
fmt.Printf(" - %v\n", w)
}
}
return 1
}
fmt.Println("Path:", usage.Path)
fmt.Println("FS type:", ternary(usage.Fstype == "", "unknown", usage.Fstype))
fmt.Printf("Used percent: %.1f%%\n", usage.UsedPercent)
return 0
}

View file

@ -81,7 +81,9 @@ var diagnosticSteps = []diagnosticStep{
{ {
name: "fetch data from Yahoo finance API", name: "fetch data from Yahoo finance API",
fn: func() (string, error) { fn: func() (string, error) {
return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200) return testHttpRequestWithHeaders("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
}, 200)
}, },
}, },
{ {

View file

@ -18,6 +18,8 @@ func Main() int {
} }
switch options.intent { switch options.intent {
case cliIntentVersionPrint:
fmt.Println(buildVersion)
case cliIntentServe: case cliIntentServe:
// remove in v0.10.0 // remove in v0.10.0
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) { if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
@ -47,6 +49,10 @@ func Main() int {
} }
fmt.Println(string(contents)) fmt.Println(string(contents))
case cliIntentSensorsPrint:
return cliSensorsPrint()
case cliIntentMountpointInfo:
return cliMountpointInfo(options.args[1])
case cliIntentDiagnose: case cliIntentDiagnose:
runDiagnostic() runDiagnostic()
} }

View file

@ -649,7 +649,7 @@ function setupTruncatedElementTitles() {
for (let i = 0; i < elements.length; i++) { for (let i = 0; i < elements.length; i++) {
const element = elements[i]; const element = elements[i];
if (element.title === "") element.title = element.textContent; if (element.getAttribute("title") === null) element.title = element.textContent;
} }
} }

View file

@ -1839,7 +1839,7 @@ details[open] .summary::after {
transform: translateY(calc(100% - var(--mobile-navigation-height))); transform: translateY(calc(100% - var(--mobile-navigation-height)));
left: var(--content-bounds-padding); left: var(--content-bounds-padding);
right: var(--content-bounds-padding); right: var(--content-bounds-padding);
z-index: 10; z-index: 11;
background-color: var(--color-widget-background); background-color: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border); border: 1px solid var(--color-widget-content-border);
border-bottom: 0; border-bottom: 0;
@ -1985,7 +1985,7 @@ details[open] .summary::after {
@media (max-width: 550px) { @media (max-width: 550px) {
:root { :root {
font-size: 9px; font-size: 9.4px;
--widget-gap: 15px; --widget-gap: 15px;
--widget-content-vertical-padding: 10px; --widget-content-vertical-padding: 10px;
--widget-content-horizontal-padding: 10px; --widget-content-horizontal-padding: 10px;
@ -2038,6 +2038,7 @@ details[open] .summary::after {
.color-primary { color: var(--color-primary); } .color-primary { color: var(--color-primary); }
.cursor-help { cursor: help; } .cursor-help { cursor: help; }
.rounded { border-radius: var(--border-radius); }
.break-all { word-break: break-all; } .break-all { word-break: break-all; }
.text-left { text-align: left; } .text-left { text-align: left; }
.text-right { text-align: right; } .text-right { text-align: right; }

View file

@ -27,6 +27,9 @@ var globalTemplateFunctions = template.FuncMap{
"formatPrice": func(price float64) string { "formatPrice": func(price float64) string {
return intl.Sprintf("%.2f", price) return intl.Sprintf("%.2f", price)
}, },
"formatPriceWithPrecision": func(precision int, price float64) string {
return intl.Sprintf("%."+strconv.Itoa(precision)+"f", price)
},
"dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs, "dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs,
"formatServerMegabytes": func(mb uint64) template.HTML { "formatServerMegabytes": func(mb uint64) template.HTML {
var value string var value string

View file

@ -22,7 +22,7 @@
</div> </div>
</div> </div>
<div class="min-width-0"> <div class="min-width-0 grow">
{{- if .URL }} {{- if .URL }}
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a> <a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
{{- else }} {{- else }}

View file

@ -17,7 +17,7 @@
<div class="market-values shrink-0"> <div class="market-values shrink-0">
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div> <div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div> <div class="text-right">{{ .Currency }}{{ .Price | formatPriceWithPrecision .PriceHint }}</div>
</div> </div>
</div> </div>
{{ end }} {{ end }}

View file

@ -25,15 +25,16 @@ var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.
// Needs to be exported for the YAML unmarshaler to work // Needs to be exported for the YAML unmarshaler to work
type CustomAPIRequest struct { type CustomAPIRequest struct {
URL string `yaml:"url"` URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"` AllowInsecure bool `yaml:"allow-insecure"`
Headers map[string]string `yaml:"headers"` Headers map[string]string `yaml:"headers"`
Parameters queryParametersField `yaml:"parameters"` Parameters queryParametersField `yaml:"parameters"`
Method string `yaml:"method"` Method string `yaml:"method"`
BodyType string `yaml:"body-type"` BodyType string `yaml:"body-type"`
Body any `yaml:"body"` Body any `yaml:"body"`
bodyReader io.ReadSeeker `yaml:"-"` SkipJSONValidation bool `yaml:"skip-json-validation"`
httpRequest *http.Request `yaml:"-"` bodyReader io.ReadSeeker `yaml:"-"`
httpRequest *http.Request `yaml:"-"`
} }
type customAPIWidget struct { type customAPIWidget struct {
@ -157,6 +158,17 @@ type customAPITemplateData struct {
subrequests map[string]*customAPIResponseData subrequests map[string]*customAPIResponseData
} }
func (data *customAPITemplateData) JSONLines() []decoratedGJSONResult {
result := make([]decoratedGJSONResult, 0, 5)
gjson.ForEachLine(data.JSON.Raw, func(line gjson.Result) bool {
result = append(result, decoratedGJSONResult{line})
return true
})
return result
}
func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData { func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData {
req, exists := data.subrequests[key] req, exists := data.subrequests[key]
if !exists { if !exists {
@ -190,7 +202,7 @@ func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customA
body := strings.TrimSpace(string(bodyBytes)) body := strings.TrimSpace(string(bodyBytes))
if body != "" && !gjson.Valid(body) { if !req.SkipJSONValidation && body != "" && !gjson.Valid(body) {
truncatedBody, isTruncated := limitStringLength(body, 100) truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated { if isTruncated {
truncatedBody += "... <truncated>" truncatedBody += "... <truncated>"
@ -342,6 +354,23 @@ func (r *decoratedGJSONResult) Bool(key string) bool {
return r.Get(key).Bool() return r.Get(key).Bool()
} }
func customAPIDoMathOp[T int | float64](a, b T, op string) T {
switch op {
case "add":
return a + b
case "sub":
return a - b
case "mul":
return a * b
case "div":
if b == 0 {
return 0
}
return a / b
}
return 0
}
var customAPITemplateFuncs = func() template.FuncMap { var customAPITemplateFuncs = func() template.FuncMap {
var regexpCacheMu sync.Mutex var regexpCacheMu sync.Mutex
var regexpCache = make(map[string]*regexp.Regexp) var regexpCache = make(map[string]*regexp.Regexp)
@ -359,6 +388,31 @@ var customAPITemplateFuncs = func() template.FuncMap {
return regex return regex
} }
doMathOpWithAny := func(a, b any, op string) any {
switch at := a.(type) {
case int:
switch bt := b.(type) {
case int:
return customAPIDoMathOp(at, bt, op)
case float64:
return customAPIDoMathOp(float64(at), bt, op)
default:
return math.NaN()
}
case float64:
switch bt := b.(type) {
case int:
return customAPIDoMathOp(at, float64(bt), op)
case float64:
return customAPIDoMathOp(at, bt, op)
default:
return math.NaN()
}
default:
return math.NaN()
}
}
funcs := template.FuncMap{ funcs := template.FuncMap{
"toFloat": func(a int) float64 { "toFloat": func(a int) float64 {
return float64(a) return float64(a)
@ -366,21 +420,35 @@ var customAPITemplateFuncs = func() template.FuncMap {
"toInt": func(a float64) int { "toInt": func(a float64) int {
return int(a) return int(a)
}, },
"add": func(a, b float64) float64 { "add": func(a, b any) any {
return a + b return doMathOpWithAny(a, b, "add")
}, },
"sub": func(a, b float64) float64 { "sub": func(a, b any) any {
return a - b return doMathOpWithAny(a, b, "sub")
}, },
"mul": func(a, b float64) float64 { "mul": func(a, b any) any {
return a * b return doMathOpWithAny(a, b, "mul")
}, },
"div": func(a, b float64) float64 { "div": func(a, b any) any {
if b == 0 { return doMathOpWithAny(a, b, "div")
return math.NaN() },
"now": func() time.Time {
return time.Now()
},
"offsetNow": func(offset string) time.Time {
d, err := time.ParseDuration(offset)
if err != nil {
return time.Now()
}
return time.Now().Add(d)
},
"duration": func(str string) time.Duration {
d, err := time.ParseDuration(str)
if err != nil {
return 0
} }
return a / b return d
}, },
"parseTime": customAPIFuncParseTime, "parseTime": customAPIFuncParseTime,
"toRelativeTime": dynamicRelativeTimeAttrs, "toRelativeTime": dynamicRelativeTimeAttrs,
@ -403,6 +471,13 @@ var customAPITemplateFuncs = func() template.FuncMap {
"replaceAll": func(old, new, s string) string { "replaceAll": func(old, new, s string) string {
return strings.ReplaceAll(s, old, new) return strings.ReplaceAll(s, old, new)
}, },
"replaceMatches": func(pattern, replacement, s string) string {
if s == "" {
return ""
}
return getCachedRegexp(pattern).ReplaceAllString(s, replacement)
},
"findMatch": func(pattern, s string) string { "findMatch": func(pattern, s string) string {
if s == "" { if s == "" {
return "" return ""
@ -465,6 +540,21 @@ var customAPITemplateFuncs = func() template.FuncMap {
return results return results
}, },
"concat": func(items ...string) string {
return strings.Join(items, "")
},
"unique": func(key string, results []decoratedGJSONResult) []decoratedGJSONResult {
seen := make(map[string]struct{})
out := make([]decoratedGJSONResult, 0, len(results))
for _, result := range results {
val := result.String(key)
if _, ok := seen[val]; !ok {
seen[val] = struct{}{}
out = append(out, result)
}
}
return out
},
} }
for key, value := range globalTemplateFunctions { for key, value := range globalTemplateFunctions {

View file

@ -59,6 +59,10 @@ func (widget *extensionWidget) update(ctx context.Context) {
widget.Title = extension.Title widget.Title = extension.Title
} }
if widget.TitleURL == "" && extension.TitleURL != "" {
widget.TitleURL = extension.TitleURL
}
widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate) widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate)
} }
@ -69,8 +73,8 @@ func (widget *extensionWidget) Render() template.HTML {
type extensionType int type extensionType int
const ( const (
extensionContentHTML extensionType = iota extensionContentHTML extensionType = iota
extensionContentUnknown = iota extensionContentUnknown
) )
var extensionStringToType = map[string]extensionType{ var extensionStringToType = map[string]extensionType{
@ -79,6 +83,7 @@ var extensionStringToType = map[string]extensionType{
const ( const (
extensionHeaderTitle = "Widget-Title" extensionHeaderTitle = "Widget-Title"
extensionHeaderTitleURL = "Widget-Title-URL"
extensionHeaderContentType = "Widget-Content-Type" extensionHeaderContentType = "Widget-Content-Type"
extensionHeaderContentFrameless = "Widget-Content-Frameless" extensionHeaderContentFrameless = "Widget-Content-Frameless"
) )
@ -93,6 +98,7 @@ type extensionRequestOptions struct {
type extension struct { type extension struct {
Title string Title string
TitleURL string
Content template.HTML Content template.HTML
Frameless bool Frameless bool
} }
@ -142,6 +148,10 @@ func fetchExtension(options extensionRequestOptions) (extension, error) {
extension.Title = response.Header.Get(extensionHeaderTitle) extension.Title = response.Header.Get(extensionHeaderTitle)
} }
if response.Header.Get(extensionHeaderTitleURL) != "" {
extension.TitleURL = response.Header.Get(extensionHeaderTitleURL)
}
contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)] contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)]
if !ok { if !ok {

View file

@ -79,6 +79,7 @@ type market struct {
Name string Name string
Currency string Currency string
Price float64 Price float64
PriceHint int
PercentChange float64 PercentChange float64
SvgChartPoints string SvgChartPoints string
} }
@ -106,6 +107,7 @@ type marketResponseJson struct {
RegularMarketPrice float64 `json:"regularMarketPrice"` RegularMarketPrice float64 `json:"regularMarketPrice"`
ChartPreviousClose float64 `json:"chartPreviousClose"` ChartPreviousClose float64 `json:"chartPreviousClose"`
ShortName string `json:"shortName"` ShortName string `json:"shortName"`
PriceHint int `json:"priceHint"`
} `json:"meta"` } `json:"meta"`
Indicators struct { Indicators struct {
Quote []struct { Quote []struct {
@ -152,13 +154,14 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
continue continue
} }
prices := response.Chart.Result[0].Indicators.Quote[0].Close result := &response.Chart.Result[0]
prices := result.Indicators.Quote[0].Close
if len(prices) > marketChartDays { if len(prices) > marketChartDays {
prices = prices[len(prices)-marketChartDays:] prices = prices[len(prices)-marketChartDays:]
} }
previous := response.Chart.Result[0].Meta.RegularMarketPrice previous := result.Meta.RegularMarketPrice
if len(prices) >= 2 && prices[len(prices)-2] != 0 { if len(prices) >= 2 && prices[len(prices)-2] != 0 {
previous = prices[len(prices)-2] previous = prices[len(prices)-2]
@ -166,21 +169,22 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
currency, exists := currencyToSymbol[strings.ToUpper(response.Chart.Result[0].Meta.Currency)] currency, exists := currencyToSymbol[strings.ToUpper(result.Meta.Currency)]
if !exists { if !exists {
currency = response.Chart.Result[0].Meta.Currency currency = result.Meta.Currency
} }
markets = append(markets, market{ markets = append(markets, market{
marketRequest: marketRequests[i], marketRequest: marketRequests[i],
Price: response.Chart.Result[0].Meta.RegularMarketPrice, Price: result.Meta.RegularMarketPrice,
Currency: currency, Currency: currency,
PriceHint: result.Meta.PriceHint,
Name: ternary(marketRequests[i].CustomName == "", Name: ternary(marketRequests[i].CustomName == "",
response.Chart.Result[0].Meta.ShortName, result.Meta.ShortName,
marketRequests[i].CustomName, marketRequests[i].CustomName,
), ),
PercentChange: percentChange( PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice, result.Meta.RegularMarketPrice,
previous, previous,
), ),
SvgChartPoints: points, SvgChartPoints: points,

View file

@ -331,6 +331,7 @@ func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error)
failed := 0 failed := 0
entries := make(rssFeedItemList, 0, len(feeds)*10) entries := make(rssFeedItemList, 0, len(feeds)*10)
seen := make(map[string]struct{})
for i := range feeds { for i := range feeds {
if errs[i] != nil { if errs[i] != nil {
@ -339,7 +340,13 @@ func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error)
continue continue
} }
entries = append(entries, feeds[i]...) for _, item := range feeds[i] {
if _, exists := seen[item.Link]; exists {
continue
}
entries = append(entries, item)
seen[item.Link] = struct{}{}
}
} }
if failed == len(requests) { if failed == len(requests) {

View file

@ -196,6 +196,10 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) {
slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt) slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
} }
} }
} else {
// This prevents live channels with 0 viewers from being
// incorrectly sorted lower than offline channels
result.ViewersCount = -1
} }
return result, nil return result, nil

View file

@ -181,6 +181,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 {
results[0], errs[0] = job.task(job.data[0])
return results, errs, nil
}
tasksQueue := make(chan *workerPoolTask[I, O]) tasksQueue := make(chan *workerPoolTask[I, O])
resultsQueue := make(chan *workerPoolTask[I, O]) resultsQueue := make(chan *workerPoolTask[I, O])

View file

@ -227,35 +227,50 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
} }
} }
filesystems, err := disk.Partitions(false) addedMountpoints := map[string]struct{}{}
if err == nil { addMountpointInfo := func(requestedPath string, mpReq MointpointRequest) {
for _, fs := range filesystems { if _, exists := addedMountpoints[requestedPath]; exists {
mpReq, ok := req.Mountpoints[fs.Mountpoint] return
isHidden := req.HideMountpointsByDefault
if ok && mpReq.Hide != nil {
isHidden = *mpReq.Hide
}
if isHidden {
continue
}
usage, err := disk.Usage(fs.Mountpoint)
if err == nil {
mpInfo := MountpointInfo{
Path: fs.Mountpoint,
Name: mpReq.Name,
TotalMB: usage.Total / 1024 / 1024,
UsedMB: usage.Used / 1024 / 1024,
UsedPercent: uint8(math.Min(usage.UsedPercent, 100)),
}
info.Mountpoints = append(info.Mountpoints, mpInfo)
} else {
addErr(fmt.Errorf("getting filesystem usage for %s: %v", fs.Mountpoint, err))
}
} }
} else {
addErr(fmt.Errorf("getting filesystems: %v", err)) isHidden := req.HideMountpointsByDefault
if mpReq.Hide != nil {
isHidden = *mpReq.Hide
}
if isHidden {
return
}
usage, err := disk.Usage(requestedPath)
if err == nil {
mpInfo := MountpointInfo{
Path: requestedPath,
Name: mpReq.Name,
TotalMB: usage.Total / 1024 / 1024,
UsedMB: usage.Used / 1024 / 1024,
UsedPercent: uint8(math.Min(usage.UsedPercent, 100)),
}
info.Mountpoints = append(info.Mountpoints, mpInfo)
addedMountpoints[requestedPath] = struct{}{}
} else {
addErr(fmt.Errorf("getting filesystem usage for %s: %v", requestedPath, err))
}
}
if !req.HideMountpointsByDefault {
filesystems, err := disk.Partitions(false)
if err == nil {
for _, fs := range filesystems {
addMountpointInfo(fs.Mountpoint, req.Mountpoints[fs.Mountpoint])
}
} else {
addErr(fmt.Errorf("getting filesystems: %v", err))
}
}
for mountpoint, mpReq := range req.Mountpoints {
addMountpointInfo(mountpoint, mpReq)
} }
sort.Slice(info.Mountpoints, func(a, b int) bool { sort.Slice(info.Mountpoints, func(a, b int) bool {