Merge branch 'release/v0.7.0' into main

This commit is contained in:
Svilen Markov 2024-12-05 13:32:16 +00:00 committed by GitHub
commit e5106c0704
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 5355 additions and 3711 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
/assets
/build
/playground
/.idea
glance*.yml

View file

@ -1,4 +1,4 @@
FROM golang:1.22.5-alpine3.20 AS builder
FROM golang:1.23.1-alpine3.20 AS builder
WORKDIR /app
COPY . /app
@ -13,4 +13,4 @@ HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
CMD wget --spider -q http://localhost:8080/api/healthz
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"]
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]

View file

@ -7,4 +7,4 @@ HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
CMD wget --spider -q http://localhost:8080/api/healthz
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"]
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]

View file

@ -20,6 +20,7 @@
* Twitch channels & top games
* GitHub releases
* Repository overview
* Docker containers
* Site monitor
* Search box
@ -51,6 +52,8 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a
```
#### Docker
<!-- TODO: update -->
> [!IMPORTANT]
>
> Make sure you have a valid `glance.yml` file in the same directory before running the container.

View file

@ -15,6 +15,8 @@
- [Reddit](#reddit)
- [Search](#search-widget)
- [Group](#group)
- [Split Column](#split-column)
- [Custom API](#custom-api)
- [Extension](#extension)
- [Weather](#weather)
- [Monitor](#monitor)
@ -30,8 +32,10 @@
- [Twitch Top Games](#twitch-top-games)
- [iframe](#iframe)
- [HTML](#html)
- [Docker](#docker)
## Intro
<!-- TODO: update -->
Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
## Preconfigured page
@ -111,6 +115,8 @@ This will give you a page that looks like the following:
Configure the widgets, add more of them, add extra pages, etc. Make it your own!
<!-- TODO: update - add information about top level document key -->
## Server
Server configuration is done through a top level `server` property. Example:
@ -313,6 +319,7 @@ pages:
| width | string | no | |
| center-vertically | boolean | no | false |
| hide-desktop-navigation | boolean | no | false |
| expand-mobile-page-navigation | boolean | no | false |
| show-mobile-header | boolean | no | false |
| columns | array | yes | |
@ -339,6 +346,9 @@ When set to `true`, vertically centers the content on the page. Has no effect if
#### `hide-desktop-navigation`
Whether to show the navigation links at the top of the page on desktop.
#### `expand-mobile-page-navigation`
Whether the mobile page navigation should be expanded by default.
#### `show-mobile-header`
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
@ -525,10 +535,22 @@ An array of RSS/atom feeds. The title can optionally be changed.
| hide-categories | boolean | no | false | Only applicable for `detailed-list` style |
| hide-description | boolean | no | false | Only applicable for `detailed-list` style |
| item-link-prefix | string | no | | |
| headers | key (string) & value (string) | no | | |
###### `item-link-prefix`
If an RSS feed isn't returning item links with a base domain and Glance has failed to automatically detect the correct domain you can manually add a prefix to each link with this property.
###### `headers`
Optionally specify the headers that will be sent with the request. Example:
```yaml
- type: rss
feeds:
- url: https://domain.com/rss
headers:
User-Agent: Custom User Agent
```
##### `limit`
The maximum number of articles to show.
@ -828,6 +850,7 @@ Preview:
| <kbd>Enter</kbd> | Perform search in the same tab | Search input is focused and not empty |
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty |
| <kbd>Escape</kbd> | Leave focus | Search input is focused |
| <kbd>Up</kbd> | Insert the last search query since the page was opened into the input field | Search input is focused |
> [!TIP]
>
@ -890,7 +913,7 @@ url: https://www.amazon.com/s?k={QUERY}
```
### Group
Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget.
Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget or a split column widget within a group widget.
Example:
@ -933,6 +956,67 @@ Example:
<<: *shared-properties
```
### Split Column
<!-- TODO: update -->
Splits a full sized column in half, allowing you to place widgets side by side. This is converted to a single column on mobile devices or if not enough width is available. Widgets are defined using a `widgets` property exactly as you would on a page column.
Example of a full page with an effective 4 column layout using two split column widgets inside of two full sized columns:
<details>
<summary>View config</summary>
```yaml
shared:
- &reddit-props
type: reddit
collapse-after: 4
show-thumbnails: true
pages:
- name: Split Column Demo
width: wide
columns:
- size: full
widgets:
- type: split-column
widgets:
- subreddit: gaming
<<: *reddit-props
- subreddit: worldnews
<<: *reddit-props
- subreddit: lifeprotips
<<: *reddit-props
show-thumbnails: false
- subreddit: askreddit
<<: *reddit-props
show-thumbnails: false
- size: full
widgets:
- type: split-column
widgets:
- subreddit: todayilearned
<<: *reddit-props
collapse-after: 2
- subreddit: aww
<<: *reddit-props
- subreddit: science
<<: *reddit-props
- subreddit: showerthoughts
<<: *reddit-props
show-thumbnails: false
```
</details>
<br>
Preview:
![](images/split-column-widget-preview.png)
### Custom API
<!-- TODO -->
### 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).
@ -948,12 +1032,16 @@ Display a widget provided by an external source (3rd party). If you want to lear
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| url | string | yes | |
| fallback-content-type | string | no | |
| allow-potentially-dangerous-html | boolean | no | false |
| parameters | key & value | no | |
##### `url`
The URL of the extension.
##### `fallback-content-type`
Optionally specify the fallback content type of the extension if the URL does not return a valid `Widget-Content-Type` header. Currently the only supported value for this property is `html`.
##### `allow-potentially-dangerous-html`
Whether to allow the extension to display HTML.
@ -1065,11 +1153,19 @@ You can hover over the "ERROR" text to view more information.
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| sites | array | yes | |
| style | string | no | |
| show-failing-only | boolean | no | false |
##### `show-failing-only`
Shows only a list of failing sites when set to `true`.
##### `style`
Used to change the appearance of the widget. Possible values are `compact`.
Preview of `compact`:
![](images/monitor-widget-compact-preview.png)
##### `sites`
Properties for each site:
@ -1082,6 +1178,7 @@ Properties for each site:
| icon | string | no | |
| allow-insecure | boolean | no | false |
| same-tab | boolean | no | false |
| alt-status-codes | array | no | |
`title`
@ -1097,7 +1194,7 @@ The URL which will be requested and its response will determine the status of th
`icon`
Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix:
Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
```yaml
icon: si:jellyfin
@ -1107,7 +1204,7 @@ icon: si:adguard
> [!WARNING]
>
> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
`allow-insecure`
@ -1117,6 +1214,15 @@ Whether to ignore invalid/self-signed certificates.
Whether to open the link in the same or a new tab.
`alt-status-codes`
Status codes other than 200 that you want to return "OK".
```yaml
alt-status-codes:
- 403
```
### Releases
Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
@ -1238,6 +1344,7 @@ Preview:
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| service | string | no | pihole |
| allow-insecure | bool | no | false |
| url | string | yes | |
| username | string | when service is `adguard` | |
| password | string | when service is `adguard` | |
@ -1247,6 +1354,9 @@ Preview:
##### `service`
Either `adguard` or `pihole`.
##### `allow-insecure`
Whether to allow invalid/self-signed certificates when making the request to the service.
##### `url`
The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
@ -1375,7 +1485,7 @@ An array of groups which can optionally have a title and a custom color.
`icon`
URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix:
URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
```yaml
icon: si:gmail
@ -1385,7 +1495,7 @@ icon: si:reddit
> [!WARNING]
>
> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
`same-tab`
@ -1494,15 +1604,25 @@ Example:
```yaml
- type: calendar
start-sunday: false
```
Preview:
![](images/calendar-widget-preview.png)
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| start-sunday | boolean | no | false |
##### `start-sunday`
Whether calendar weeks start on Sunday or Monday.
> [!NOTE]
>
> There is currently no customizability available for the calendar. Extra features will be added in the future.
> There is currently little customizability available for the calendar. Extra features will be added in the future.
### Markets
Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
@ -1539,7 +1659,7 @@ Preview:
An array of markets for which to display information about.
##### `sort-by`
By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `change` for descending order based on the stock's percentage change (e.g. 1% would be sorted higher than -1%) or `absolute-change` for descending order based on the stock's absolute price change (e.g. -1% would be sorted higher than +0.5%).
###### Properties for each stock
| Name | Type | Required |
@ -1674,3 +1794,75 @@ Example:
```
Note the use of `|` after `source:`, this allows you to insert a multi-line string.
### Docker Containers
<!-- TODO: update -->
The Docker widget allows you to monitor your Docker containers.
To enable this feature, ensure that your setup provides access to the **docker.sock** file (also you may use a TCP connection).
Add the following to your `docker-compose` or `docker run` command to enable the Docker widget:
**Docker Example:**
```bash
docker run -d -p 8080:8080 \
-v ./glance.yml:/app/glance.yml \
-v /etc/timezone:/etc/timezone:ro \
-v /etc/localtime:/etc/localtime:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
glanceapp/glance
```
**Docker Compose Example:**
```yaml
services:
glance:
image: glanceapp/glance
volumes:
- ./glance.yml:/app/glance.yml
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- 8080:8080
restart: unless-stopped
```
#### Configuration
To integrate the Docker widget into your dashboard, include the following snippet in your `glance.yml` file:
```yaml
- type: docker
host-url: tcp://localhost:2375
cache: 1m
```
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| host-url | string | no | `unix:///var/run/docker.sock` |
#### Leveraging Container Labels
You can use container labels to control visibility, URLs, icons, and titles within the Docker widget. Add the following labels to your container configuration for enhanced customization:
```yaml
labels:
- "glance.enable=true" # Enable or disable visibility of the container (default: true)
- "glance.title=Glance" # Optional friendly name (defaults to container name)
- "glance.url=https://app.example.com" # Optional URL associated with the container
- "glance.iconUrl=si:docker" # Optional URL to an image which will be used as the icon for the site
```
**Default Values:**
| Name | Default |
|----------------|------------|
| glance.enable | true |
| glance.title | Container name |
| glance.url | (none) |
| glance.iconUrl | si:docker |
Preview:
![](images/docker-widget-preview.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

13
go.mod
View file

@ -1,19 +1,24 @@
module github.com/glanceapp/glance
go 1.22.5
go 1.23.1
require (
github.com/fsnotify/fsnotify v1.8.0
github.com/mmcdole/gofeed v1.3.0
golang.org/x/text v0.16.0
github.com/tidwall/gjson v1.18.0
golang.org/x/text v0.20.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/PuerkitoBio/goquery v1.10.0 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/net v0.27.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/sys v0.27.0 // indirect
)

25
go.sum
View file

@ -1,10 +1,12 @@
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@ -23,6 +25,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@ -33,8 +42,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -45,6 +54,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -54,8 +65,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@ -64,4 +75,4 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,56 +0,0 @@
package assets
import (
"crypto/md5"
"embed"
"encoding/hex"
"io"
"io/fs"
"log/slog"
"strconv"
"time"
)
//go:embed static
var _publicFS embed.FS
//go:embed templates
var _templateFS embed.FS
var PublicFS, _ = fs.Sub(_publicFS, "static")
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
func getFSHash(files fs.FS) string {
hash := md5.New()
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err == nil {
return hex.EncodeToString(hash.Sum(nil))[:10]
}
slog.Warn("Could not compute assets cache", "err", err)
return strconv.FormatInt(time.Now().Unix(), 10)
}
var PublicFSHash = getFSHash(PublicFS)

View file

@ -1,109 +0,0 @@
package assets
import (
"fmt"
"html/template"
"math"
"strconv"
"time"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
var (
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
PageContentTemplate = compileTemplate("content.html")
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html")
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
SearchTemplate = compileTemplate("search.html", "widget-base.html")
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
GroupTemplate = compileTemplate("group.html", "widget-base.html")
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
)
var globalTemplateFunctions = template.FuncMap{
"relativeTime": relativeTimeSince,
"formatViewerCount": formatViewerCount,
"formatNumber": intl.Sprint,
"absInt": func(i int) int {
return int(math.Abs(float64(i)))
},
"formatPrice": func(price float64) string {
return intl.Sprintf("%.2f", price)
},
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
},
}
func compileTemplate(primary string, dependencies ...string) *template.Template {
t, err := template.New(primary).
Funcs(globalTemplateFunctions).
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
if err != nil {
panic(err)
}
return t
}
var intl = message.NewPrinter(language.English)
func formatViewerCount(count int) string {
if count < 1_000 {
return strconv.Itoa(count)
}
if count < 10_000 {
return fmt.Sprintf("%.1fk", float64(count)/1_000)
}
if count < 1_000_000 {
return fmt.Sprintf("%dk", count/1_000)
}
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
}
func relativeTimeSince(t time.Time) string {
delta := time.Since(t)
if delta < time.Minute {
return "1m"
}
if delta < time.Hour {
return fmt.Sprintf("%dm", delta/time.Minute)
}
if delta < 24*time.Hour {
return fmt.Sprintf("%dh", delta/time.Hour)
}
if delta < 30*24*time.Hour {
return fmt.Sprintf("%dd", delta/(24*time.Hour))
}
if delta < 12*30*24*time.Hour {
return fmt.Sprintf("%dmo", delta/(30*24*time.Hour))
}
return fmt.Sprintf("%dy", delta/(365*24*time.Hour))
}

View file

@ -1,14 +0,0 @@
<style>
:root {
{{ if .App.Config.Theme.BackgroundColor }}
--bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
--bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
--bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
{{ end }}
{{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
{{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
{{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
{{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
}
</style>

View file

@ -1,120 +0,0 @@
package feed
import (
"net/http"
"strings"
)
type adguardStatsResponse struct {
TotalQueries int `json:"num_dns_queries"`
QueriesSeries []int `json:"dns_queries"`
BlockedQueries int `json:"num_blocked_filtering"`
BlockedSeries []int `json:"blocked_filtering"`
ResponseTime float64 `json:"avg_processing_time"`
TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
}
func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) {
requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
request.SetBasicAuth(username, password)
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request)
if err != nil {
return nil, err
}
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
stats := &DNSStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
ResponseTime: int(responseJson.ResponseTime * 1000),
TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount),
}
if stats.TotalQueries <= 0 {
return stats, nil
}
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
for i := 0; i < topBlockedDomainsCount; i++ {
domain := responseJson.TopBlockedDomains[i]
var firstDomain string
for k := range domain {
firstDomain = k
break
}
if firstDomain == "" {
continue
}
stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{
Domain: firstDomain,
})
if stats.BlockedQueries > 0 {
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
}
}
queriesSeries := responseJson.QueriesSeries
blockedSeries := responseJson.BlockedSeries
const bars = 8
const hoursSpan = 24
const hoursPerBar int = hoursSpan / bars
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 {
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
} else if len(blockedSeries) < hoursSpan {
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
}
maxQueriesInSeries := 0
for i := 0; i < bars; i++ {
queries := 0
blocked := 0
for j := 0; j < hoursPerBar; j++ {
queries += queriesSeries[i*hoursPerBar+j]
blocked += blockedSeries[i*hoursPerBar+j]
}
stats.Series[i] = DNSStatsSeries{
Queries: queries,
Blocked: blocked,
}
if queries > 0 {
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
}
for i := 0; i < bars; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}

View file

@ -1,53 +0,0 @@
package feed
import "time"
// TODO: very inflexible, refactor to allow more customizability
// TODO: allow changing first day of week
// TODO: allow changing between showing the previous and next week and the entire month
func NewCalendar(now time.Time) *Calendar {
year, week := now.ISOWeek()
weekday := now.Weekday()
if weekday == 0 {
weekday = 7
}
currentMonthDays := daysInMonth(now.Month(), year)
var previousMonthDays int
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
previousMonthDays = daysInMonth(12, year-1)
} else {
previousMonthDays = daysInMonth(previousMonthNumber, year)
}
startDaysFrom := now.Day() - int(weekday+6)
days := make([]int, 21)
for i := 0; i < 21; i++ {
day := startDaysFrom + i
if day < 1 {
day = previousMonthDays + day
} else if day > currentMonthDays {
day = day - currentMonthDays
}
days[i] = day
}
return &Calendar{
CurrentDay: now.Day(),
CurrentWeekNumber: week,
CurrentMonthName: now.Month().String(),
CurrentYear: year,
Days: days,
}
}
func daysInMonth(m time.Month, year int) int {
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
}

View file

@ -1,39 +0,0 @@
package feed
import (
"fmt"
"net/http"
)
type codebergReleaseResponseJson struct {
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"`
}
func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf(
"https://codeberg.org/api/v1/repos/%s/releases/latest",
request.Repository,
),
nil,
)
if err != nil {
return nil, err
}
response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest)
if err != nil {
return nil, err
}
return &AppRelease{
Source: ReleaseSourceCodeberg,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),
}, nil
}

View file

@ -1,102 +0,0 @@
package feed
import (
"fmt"
"net/http"
"strings"
)
type dockerHubRepositoryTagsResponse struct {
Results []dockerHubRepositoryTagResponse `json:"results"`
}
type dockerHubRepositoryTagResponse struct {
Name string `json:"name"`
LastPushed string `json:"tag_last_pushed"`
}
const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s"
const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags"
const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) {
nameParts := strings.Split(request.Repository, "/")
if len(nameParts) > 2 {
return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
} else if len(nameParts) == 1 {
nameParts = []string{"library", nameParts[0]}
}
tagParts := strings.SplitN(nameParts[1], ":", 2)
var requestURL string
if len(tagParts) == 2 {
requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1])
} else {
requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1])
}
httpRequest, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
if request.Token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
}
var tag *dockerHubRepositoryTagResponse
if len(tagParts) == 1 {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest)
if err != nil {
return nil, err
}
if len(response.Results) == 0 {
return nil, fmt.Errorf("no tags found for repository: %s", request.Repository)
}
tag = &response.Results[0]
} else {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest)
if err != nil {
return nil, err
}
tag = &response
}
var repo string
var displayName string
var notesURL string
if len(tagParts) == 1 {
repo = nameParts[1]
} else {
repo = tagParts[0]
}
if nameParts[0] == "library" {
displayName = repo
notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name)
} else {
displayName = nameParts[0] + "/" + repo
notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name)
}
return &AppRelease{
Source: ReleaseSourceDockerHub,
NotesUrl: notesURL,
Name: displayName,
Version: tag.Name,
TimeReleased: parseRFC3339Time(tag.LastPushed),
}, nil
}

View file

@ -1,97 +0,0 @@
package feed
import (
"fmt"
"html"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
)
type ExtensionType int
const (
ExtensionContentHTML ExtensionType = iota
ExtensionContentUnknown = iota
)
var ExtensionStringToType = map[string]ExtensionType{
"html": ExtensionContentHTML,
}
const (
ExtensionHeaderTitle = "Widget-Title"
ExtensionHeaderContentType = "Widget-Content-Type"
)
type ExtensionRequestOptions struct {
URL string `yaml:"url"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
}
type Extension struct {
Title string
Content template.HTML
}
func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
switch contentType {
case ExtensionContentHTML:
if options.AllowHtml {
return template.HTML(content)
}
fallthrough
default:
return template.HTML(html.EscapeString(string(content)))
}
}
func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
request, _ := http.NewRequest("GET", options.URL, nil)
query := url.Values{}
for key, value := range options.Parameters {
query.Set(key, value)
}
request.URL.RawQuery = query.Encode()
response, err := http.DefaultClient.Do(request)
if err != nil {
slog.Error("failed fetching extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
}
extension := Extension{}
if response.Header.Get(ExtensionHeaderTitle) == "" {
extension.Title = "Extension"
} else {
extension.Title = response.Header.Get(ExtensionHeaderTitle)
}
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
if !ok {
contentType = ExtensionContentUnknown
}
extension.Content = convertExtensionContent(options, body, contentType)
return extension, nil
}

View file

@ -1,48 +0,0 @@
package feed
import (
"fmt"
"net/http"
"net/url"
)
type gitlabReleaseResponseJson struct {
TagName string `json:"tag_name"`
ReleasedAt string `json:"released_at"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
}
func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf(
"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
url.QueryEscape(request.Repository),
),
nil,
)
if err != nil {
return nil, err
}
if request.Token != nil {
httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token)
}
response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest)
if err != nil {
return nil, err
}
return &AppRelease{
Source: ReleaseSourceGitlab,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.Links.Self,
TimeReleased: parseRFC3339Time(response.ReleasedAt),
}, nil
}

View file

@ -1,98 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
type hackerNewsPostResponseJson struct {
Id int `json:"id"`
Score int `json:"score"`
Title string `json:"title"`
TargetUrl string `json:"url,omitempty"`
CommentCount int `json:"descendants"`
TimePosted int64 `json:"time"`
}
func getHackerNewsPostIds(sort string) ([]int, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
if err != nil {
return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent)
}
return response, nil
}
func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) {
requests := make([]*http.Request, len(postIds))
for i, id := range postIds {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
requests[i] = request
}
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient)
job := newJob(task, requests).withWorkers(30)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
posts := make(ForumPosts, 0, len(postIds))
for i := range results {
if errs[i] != nil {
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
continue
}
var commentsUrl string
if commentsUrlTemplate == "" {
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
} else {
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
}
posts = append(posts, ForumPost{
Title: results[i].Title,
DiscussionUrl: commentsUrl,
TargetUrl: results[i].TargetUrl,
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
CommentCount: results[i].CommentCount,
Score: results[i].Score,
TimePosted: time.Unix(results[i].TimePosted, 0),
})
}
if len(posts) == 0 {
return nil, ErrNoContent
}
if len(posts) != len(postIds) {
return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent)
}
return posts, nil
}
func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
postIds, err := getHackerNewsPostIds(sort)
if err != nil {
return nil, err
}
if len(postIds) > limit {
postIds = postIds[:limit]
}
return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
}

View file

@ -1,91 +0,0 @@
package feed
import (
"net/http"
"strings"
"time"
)
type lobstersPostResponseJson struct {
CreatedAt string `json:"created_at"`
Title string `json:"title"`
URL string `json:"url"`
Score int `json:"score"`
CommentCount int `json:"comment_count"`
CommentsURL string `json:"comments_url"`
Tags []string `json:"tags"`
}
type lobstersFeedResponseJson []lobstersPostResponseJson
func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
request, err := http.NewRequest("GET", feedUrl, nil)
if err != nil {
return nil, err
}
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
if err != nil {
return nil, err
}
posts := make(ForumPosts, 0, len(feed))
for i := range feed {
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
posts = append(posts, ForumPost{
Title: feed[i].Title,
DiscussionUrl: feed[i].CommentsURL,
TargetUrl: feed[i].URL,
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
CommentCount: feed[i].CommentCount,
Score: feed[i].Score,
TimePosted: createdAt,
Tags: feed[i].Tags,
})
}
if len(posts) == 0 {
return nil, ErrNoContent
}
return posts, nil
}
func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
var feedUrl string
if customURL != "" {
feedUrl = customURL
} else {
if instanceURL != "" {
instanceURL = strings.TrimRight(instanceURL, "/") + "/"
} else {
instanceURL = "https://lobste.rs/"
}
if sortBy == "hot" {
sortBy = "hottest"
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = instanceURL + sortBy + ".json"
} else {
tags := strings.Join(tags, ",")
feedUrl = instanceURL + "t/" + tags + ".json"
}
}
posts, err := getLobstersPostsFromFeed(feedUrl)
if err != nil {
return nil, err
}
return posts, nil
}

View file

@ -1,77 +0,0 @@
package feed
import (
"context"
"errors"
"net/http"
"time"
)
type SiteStatusRequest struct {
URL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"`
}
type SiteStatus struct {
Code int
TimedOut bool
ResponseTime time.Duration
Error error
}
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
var url string
if statusRequest.CheckURL != "" {
url = statusRequest.CheckURL
} else {
url = statusRequest.URL
}
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return SiteStatus{
Error: err,
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
request = request.WithContext(ctx)
requestSentAt := time.Now()
var response *http.Response
if !statusRequest.AllowInsecure {
response, err = defaultClient.Do(request)
} else {
response, err = defaultInsecureClient.Do(request)
}
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
status.TimedOut = true
}
status.Error = err
return status, nil
}
defer response.Body.Close()
status.Code = response.StatusCode
return status, nil
}
func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
job := newJob(getSiteStatusTask, requests).withWorkers(20)
results, _, err := workerPoolDo(job)
if err != nil {
return nil, err
}
return results, nil
}

View file

@ -1,136 +0,0 @@
package feed
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"sort"
"strings"
)
type piholeStatsResponse struct {
TotalQueries int `json:"dns_queries_today"`
QueriesSeries map[int64]int `json:"domains_over_time"`
BlockedQueries int `json:"ads_blocked_today"`
BlockedSeries map[int64]int `json:"ads_over_time"`
BlockedPercentage float64 `json:"ads_percentage_today"`
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
DomainsBlocked int `json:"domains_being_blocked"`
}
// 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
type piholeTopBlockedDomains map[string]int
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
// because of the UnmarshalJSON method getting called recursively
temp := make(map[string]int)
err := json.Unmarshal(data, &temp)
if err != nil {
*p = make(piholeTopBlockedDomains)
} else {
*p = temp
}
return nil
}
func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
if token == "" {
return nil, errors.New("missing API token")
}
requestURL := strings.TrimRight(instanceURL, "/") +
"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request)
if err != nil {
return nil, err
}
stats := &DNSStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
BlockedPercent: int(responseJson.BlockedPercentage),
DomainsBlocked: responseJson.DomainsBlocked,
}
if len(responseJson.TopBlockedDomains) > 0 {
domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
for domain, count := range responseJson.TopBlockedDomains {
domains = append(domains, DNSStatsBlockedDomain{
Domain: domain,
PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
})
}
sort.Slice(domains, func(a, b int) bool {
return domains[a].PercentBlocked > domains[b].PercentBlocked
})
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
}
// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
slog.Warn(
"DNS stats for pihole: did not get expected 144 data points",
"len(queries)", len(responseJson.QueriesSeries),
"len(blocked)", len(responseJson.BlockedSeries),
)
return stats, nil
}
var lowestTimestamp int64 = 0
for timestamp := range responseJson.QueriesSeries {
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
lowestTimestamp = timestamp
}
}
maxQueriesInSeries := 0
for i := 0; i < 8; i++ {
queries := 0
blocked := 0
for j := 0; j < 18; j++ {
index := lowestTimestamp + int64(i*10800+j*600)
queries += responseJson.QueriesSeries[index]
blocked += responseJson.BlockedSeries[index]
}
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 := 0; i < 8; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}

View file

@ -1,241 +0,0 @@
package feed
import (
"math"
"sort"
"time"
)
type ForumPost struct {
Title string
DiscussionUrl string
TargetUrl string
TargetUrlDomain string
ThumbnailUrl string
CommentCount int
Score int
Engagement float64
TimePosted time.Time
Tags []string
IsCrosspost bool
}
type ForumPosts []ForumPost
type Calendar struct {
CurrentDay int
CurrentWeekNumber int
CurrentMonthName string
CurrentYear int
Days []int
}
type Weather struct {
Temperature int
ApparentTemperature int
WeatherCode int
CurrentColumn int
SunriseColumn int
SunsetColumn int
Columns []weatherColumn
}
type AppRelease struct {
Source ReleaseSource
SourceIconURL string
Name string
Version string
NotesUrl string
TimeReleased time.Time
Downvotes int
}
type AppReleases []AppRelease
type Video struct {
ThumbnailUrl string
Title string
Url string
Author string
AuthorUrl string
TimePosted time.Time
}
type Videos []Video
var currencyToSymbol = map[string]string{
"USD": "$",
"EUR": "€",
"JPY": "¥",
"CAD": "C$",
"AUD": "A$",
"GBP": "£",
"CHF": "Fr",
"NZD": "N$",
"INR": "₹",
"BRL": "R$",
"RUB": "₽",
"TRY": "₺",
"ZAR": "R",
"CNY": "¥",
"KRW": "₩",
"HKD": "HK$",
"SGD": "S$",
"SEK": "kr",
"NOK": "kr",
"DKK": "kr",
"PLN": "zł",
"PHP": "₱",
}
type DNSStats struct {
TotalQueries int
BlockedQueries int
BlockedPercent int
ResponseTime int
DomainsBlocked int
Series [8]DNSStatsSeries
TopBlockedDomains []DNSStatsBlockedDomain
}
type DNSStatsSeries struct {
Queries int
Blocked int
PercentTotal int
PercentBlocked int
}
type DNSStatsBlockedDomain struct {
Domain string
PercentBlocked int
}
type MarketRequest struct {
Name string `yaml:"name"`
Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"`
}
type Market struct {
MarketRequest
Currency string `yaml:"-"`
Price float64 `yaml:"-"`
PercentChange float64 `yaml:"-"`
SvgChartPoints string `yaml:"-"`
}
type Markets []Market
func (t Markets) SortByAbsChange() {
sort.Slice(t, func(i, j int) bool {
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
})
}
var weatherCodeTable = map[int]string{
0: "Clear Sky",
1: "Mainly Clear",
2: "Partly Cloudy",
3: "Overcast",
45: "Fog",
48: "Rime Fog",
51: "Drizzle",
53: "Drizzle",
55: "Drizzle",
56: "Drizzle",
57: "Drizzle",
61: "Rain",
63: "Moderate Rain",
65: "Heavy Rain",
66: "Freezing Rain",
67: "Freezing Rain",
71: "Snow",
73: "Moderate Snow",
75: "Heavy Snow",
77: "Snow Grains",
80: "Rain",
81: "Moderate Rain",
82: "Heavy Rain",
85: "Snow",
86: "Snow",
95: "Thunderstorm",
96: "Thunderstorm",
99: "Thunderstorm",
}
func (w *Weather) WeatherCodeAsString() string {
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
return weatherCode
}
return ""
}
const depreciatePostsOlderThanHours = 7
const maxDepreciation = 0.9
const maxDepreciationAfterHours = 24
func (p ForumPosts) CalculateEngagement() {
var totalComments int
var totalScore int
for i := range p {
totalComments += p[i].CommentCount
totalScore += p[i].Score
}
numberOfPosts := float64(len(p))
averageComments := float64(totalComments) / numberOfPosts
averageScore := float64(totalScore) / numberOfPosts
for i := range p {
p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
elapsed := time.Since(p[i].TimePosted)
if elapsed < time.Hour*depreciatePostsOlderThanHours {
continue
}
p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
}
}
func (p ForumPosts) SortByEngagement() {
sort.Slice(p, func(i, j int) bool {
return p[i].Engagement > p[j].Engagement
})
}
func (s *ForumPost) HasTargetUrl() bool {
return s.TargetUrl != ""
}
func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost {
recent := make([]ForumPost, 0, len(p))
for i := range p {
if time.Since(p[i].TimePosted) < postedBefore {
recent = append(recent, p[i])
}
}
return recent
}
func (r AppReleases) SortByNewest() AppReleases {
sort.Slice(r, func(i, j int) bool {
return r[i].TimeReleased.After(r[j].TimeReleased)
})
return r
}
func (v Videos) SortByNewest() Videos {
sort.Slice(v, func(i, j int) bool {
return v[i].TimePosted.After(v[j].TimePosted)
})
return v
}

View file

@ -1,72 +0,0 @@
package feed
import (
"errors"
"fmt"
"log/slog"
)
type ReleaseSource string
const (
ReleaseSourceCodeberg ReleaseSource = "codeberg"
ReleaseSourceGithub ReleaseSource = "github"
ReleaseSourceGitlab ReleaseSource = "gitlab"
ReleaseSourceDockerHub ReleaseSource = "dockerhub"
)
type ReleaseRequest struct {
Source ReleaseSource
Repository string
Token *string
}
func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
var failed int
releases := make(AppReleases, 0, len(requests))
for i := range results {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i])
continue
}
releases = append(releases, *results[i])
}
if failed == len(requests) {
return nil, ErrNoContent
}
releases.SortByNewest()
if failed > 0 {
return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
}
return releases, nil
}
func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) {
switch request.Source {
case ReleaseSourceCodeberg:
return fetchLatestCodebergRelease(request)
case ReleaseSourceGithub:
return fetchLatestGithubRelease(request)
case ReleaseSourceGitlab:
return fetchLatestGitLabRelease(request)
case ReleaseSourceDockerHub:
return fetchLatestDockerHubRelease(request)
}
return nil, errors.New("unsupported source")
}

View file

@ -1,104 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
)
type marketResponseJson struct {
Chart struct {
Result []struct {
Meta struct {
Currency string `json:"currency"`
Symbol string `json:"symbol"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
ChartPreviousClose float64 `json:"chartPreviousClose"`
} `json:"meta"`
Indicators struct {
Quote []struct {
Close []float64 `json:"close,omitempty"`
} `json:"quote"`
} `json:"indicators"`
} `json:"result"`
} `json:"chart"`
}
// TODO: allow changing chart time frame
const marketChartDays = 21
func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
requests := make([]*http.Request, 0, len(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)
requests = append(requests, request)
}
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
}
markets := make(Markets, 0, len(responses))
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
continue
}
response := responses[i]
if len(response.Chart.Result) == 0 {
failed++
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
continue
}
prices := response.Chart.Result[0].Indicators.Quote[0].Close
if len(prices) > marketChartDays {
prices = prices[len(prices)-marketChartDays:]
}
previous := response.Chart.Result[0].Meta.RegularMarketPrice
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
previous = prices[len(prices)-2]
}
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
if !exists {
currency = response.Chart.Result[0].Meta.Currency
}
markets = append(markets, Market{
MarketRequest: marketRequests[i],
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice,
previous,
),
SvgChartPoints: points,
})
}
if len(markets) == 0 {
return nil, ErrNoContent
}
if failed > 0 {
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
}
return markets, nil
}

View file

@ -1,115 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
type youtubeFeedResponseXml struct {
Channel string `xml:"author>name"`
ChannelLink string `xml:"author>uri"`
Videos []struct {
Title string `xml:"title"`
Published string `xml:"published"`
Link struct {
Href string `xml:"href,attr"`
} `xml:"link"`
Group struct {
Thumbnail struct {
Url string `xml:"url,attr"`
} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
} `xml:"http://search.yahoo.com/mrss/ group"`
} `xml:"entry"`
}
func parseYoutubeFeedTime(t string) time.Time {
parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
if err != nil {
return time.Now()
}
return parsedTime
}
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) {
requests := make([]*http.Request, 0, len(channelIds))
for i := range channelIds {
var feedUrl string
if !includeShorts && strings.HasPrefix(channelIds[i], "UC") {
playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1)
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
} else {
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i]
}
request, _ := http.NewRequest("GET", feedUrl, nil)
requests = append(requests, request)
}
job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
}
videos := make(Videos, 0, len(channelIds)*15)
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i])
continue
}
response := responses[i]
for j := range response.Videos {
video := &response.Videos[j]
var videoUrl string
if videoUrlTemplate == "" {
videoUrl = video.Link.Href
} else {
parsedUrl, err := url.Parse(video.Link.Href)
if err == nil {
videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
} else {
videoUrl = "#"
}
}
videos = append(videos, Video{
ThumbnailUrl: video.Group.Thumbnail.Url,
Title: video.Title,
Url: videoUrl,
Author: response.Channel,
AuthorUrl: response.ChannelLink + "/videos",
TimePosted: parseYoutubeFeedTime(video.Published),
})
}
}
if len(videos) == 0 {
return nil, ErrNoContent
}
videos.SortByNewest()
if failed > 0 {
return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed)
}
return videos, nil
}

View file

@ -2,41 +2,66 @@ package glance
import (
"flag"
"fmt"
"os"
"strings"
)
type CliIntent uint8
type cliIntent uint8
const (
CliIntentServe CliIntent = iota
CliIntentCheckConfig = iota
cliIntentServe cliIntent = iota
cliIntentConfigValidate = iota
cliIntentConfigPrint = iota
cliIntentDiagnose = iota
)
type CliOptions struct {
Intent CliIntent
ConfigPath string
type cliOptions struct {
intent cliIntent
configPath string
}
func ParseCliOptions() (*CliOptions, error) {
func parseCliOptions() (*cliOptions, error) {
flags := flag.NewFlagSet("", flag.ExitOnError)
flags.Usage = func() {
fmt.Println("Usage: glance [options] command")
checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
fmt.Println("\nOptions:")
flags.PrintDefaults()
fmt.Println("\nCommands:")
fmt.Println(" config:validate Validate the config file")
fmt.Println(" config:print Print the parsed config file with embedded includes")
fmt.Println(" diagnose Run diagnostic checks")
}
configPath := flags.String("config", "glance.yml", "Set config path")
err := flags.Parse(os.Args[1:])
if err != nil {
return nil, err
}
intent := CliIntentServe
var intent cliIntent
var args = flags.Args()
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
if *checkConfig {
intent = CliIntentCheckConfig
if len(args) == 0 {
intent = cliIntentServe
} else if len(args) == 1 {
if args[0] == "config:validate" {
intent = cliIntentConfigValidate
} else if args[0] == "config:print" {
intent = cliIntentConfigPrint
} else if args[0] == "diagnose" {
intent = cliIntentDiagnose
} else {
return nil, unknownCommandErr
}
} else {
return nil, unknownCommandErr
}
return &CliOptions{
Intent: intent,
ConfigPath: *configPath,
return &cliOptions{
intent: intent,
configPath: *configPath,
}, nil
}

View file

@ -0,0 +1,232 @@
package glance
import (
"fmt"
"html/template"
"os"
"regexp"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
const (
hslHueMax = 360
hslSaturationMax = 100
hslLightnessMax = 100
)
type hslColorField struct {
Hue uint16
Saturation uint8
Lightness uint8
}
func (c *hslColorField) String() string {
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
}
func (c *hslColorField) AsCSSValue() template.CSS {
return template.CSS(c.String())
}
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
matches := hslColorFieldPattern.FindStringSubmatch(value)
if len(matches) != 4 {
return fmt.Errorf("invalid HSL color format: %s", value)
}
hue, err := strconv.ParseUint(matches[1], 10, 16)
if err != nil {
return err
}
if hue > hslHueMax {
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
}
saturation, err := strconv.ParseUint(matches[2], 10, 8)
if err != nil {
return err
}
if saturation > hslSaturationMax {
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
}
lightness, err := strconv.ParseUint(matches[3], 10, 8)
if err != nil {
return err
}
if lightness > hslLightnessMax {
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
}
c.Hue = uint16(hue)
c.Saturation = uint8(saturation)
c.Lightness = uint8(lightness)
return nil
}
var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
type durationField time.Duration
func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
matches := durationFieldPattern.FindStringSubmatch(value)
if len(matches) != 3 {
return fmt.Errorf("invalid duration format: %s", value)
}
duration, err := strconv.Atoi(matches[1])
if err != nil {
return err
}
switch matches[2] {
case "s":
*d = durationField(time.Duration(duration) * time.Second)
case "m":
*d = durationField(time.Duration(duration) * time.Minute)
case "h":
*d = durationField(time.Duration(duration) * time.Hour)
case "d":
*d = durationField(time.Duration(duration) * 24 * time.Hour)
}
return nil
}
var optionalEnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
type optionalEnvField string
func (f *optionalEnvField) UnmarshalYAML(node *yaml.Node) error {
var value string
err := node.Decode(&value)
if err != nil {
return err
}
replaced := optionalEnvFieldPattern.ReplaceAllStringFunc(value, func(match string) string {
if err != nil {
return ""
}
groups := optionalEnvFieldPattern.FindStringSubmatch(match)
if len(groups) != 3 {
return match
}
prefix, key := groups[1], groups[2]
if prefix == `\` {
if len(match) >= 2 {
return match[1:]
} else {
return ""
}
}
value, found := os.LookupEnv(key)
if !found {
err = fmt.Errorf("environment variable %s not found", key)
return ""
}
return prefix + value
})
if err != nil {
return err
}
*f = optionalEnvField(replaced)
return nil
}
func (f *optionalEnvField) String() string {
return string(*f)
}
type customIconField struct {
URL string
IsFlatIcon bool
// TODO: along with whether the icon is flat, we also need to know
// whether the icon is black or white by default in order to properly
// invert the color based on the theme being light or dark
}
func newCustomIconField(value string) customIconField {
field := customIconField{}
prefix, icon, found := strings.Cut(value, ":")
if !found {
field.URL = value
return field
}
switch prefix {
case "si":
field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
field.IsFlatIcon = true
case "di", "sh":
// syntax: di:<icon_name>[.svg|.png]
// syntax: sh:<icon_name>[.svg|.png]
// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
// any other extension will be interpreted as .svg
basename, ext, found := strings.Cut(icon, ".")
if !found {
ext = "svg"
basename = icon
}
if ext != "svg" && ext != "png" {
ext = "svg"
}
if prefix == "di" {
field.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
} else {
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/" + ext + "/" + basename + "." + ext
}
default:
field.URL = value
}
return field
}
func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
*i = newCustomIconField(value)
return nil
}

View file

@ -1,43 +1,91 @@
package glance
import (
"bytes"
"fmt"
"io"
"html/template"
"log"
"maps"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v3"
)
type Config struct {
Server Server `yaml:"server"`
Theme Theme `yaml:"theme"`
Branding Branding `yaml:"branding"`
Pages []Page `yaml:"pages"`
type config struct {
Server struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"`
StartedAt time.Time `yaml:"-"` // used in custom css file
} `yaml:"server"`
Document struct {
Head template.HTML `yaml:"head"`
} `yaml:"document"`
Theme struct {
BackgroundColor *hslColorField `yaml:"background-color"`
PrimaryColor *hslColorField `yaml:"primary-color"`
PositiveColor *hslColorField `yaml:"positive-color"`
NegativeColor *hslColorField `yaml:"negative-color"`
Light bool `yaml:"light"`
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
CustomCSSFile string `yaml:"custom-css-file"`
} `yaml:"theme"`
Branding struct {
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
} `yaml:"branding"`
Pages []page `yaml:"pages"`
}
func NewConfigFromYml(contents io.Reader) (*Config, error) {
config := NewConfig()
type page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
Columns []struct {
Size string `yaml:"size"`
Widgets widgets `yaml:"widgets"`
} `yaml:"columns"`
PrimaryColumnIndex int8 `yaml:"-"`
mu sync.Mutex `yaml:"-"`
}
contentBytes, err := io.ReadAll(contents)
func newConfigFromYAML(contents []byte) (*config, error) {
config := &config{}
config.Server.Port = 8080
err := yaml.Unmarshal(contents, config)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(contentBytes, config)
if err != nil {
return nil, err
}
if err = configIsValid(config); err != nil {
if err = isConfigStateValid(config); err != nil {
return nil, err
}
for p := range config.Pages {
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil {
return nil, err
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w])
}
}
}
@ -46,36 +94,213 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
return config, nil
}
func NewConfig() *Config {
config := &Config{}
config.Server.Host = ""
config.Server.Port = 8080
return config
func formatWidgetInitError(err error, w widget) error {
return fmt.Errorf("%s widget: %v", w.GetType(), err)
}
func configIsValid(config *Config) error {
var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
mainFileContents, err := os.ReadFile(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
}
mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
}
mainFileDir := filepath.Dir(mainFileAbsPath)
includes := make(map[string]struct{})
var includesLastErr error
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
if includesLastErr != nil {
return nil
}
matches := includePattern.FindSubmatch(match)
if len(matches) != 3 {
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
return nil
}
indent := string(matches[1])
includeFilePath := strings.TrimSpace(string(matches[2]))
if !filepath.IsAbs(includeFilePath) {
includeFilePath = filepath.Join(mainFileDir, includeFilePath)
}
var fileContents []byte
var err error
fileContents, err = os.ReadFile(includeFilePath)
if err != nil {
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
return nil
}
includes[includeFilePath] = struct{}{}
return []byte(prefixStringLines(indent, string(fileContents)))
})
if includesLastErr != nil {
return nil, nil, includesLastErr
}
return mainFileContents, includes, nil
}
func configFilesWatcher(
mainFilePath string,
lastContents []byte,
lastIncludes map[string]struct{},
onChange func(newContents []byte),
onErr func(error),
) (func() error, error) {
mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil {
return nil, fmt.Errorf("getting absolute path of main file: %w", err)
}
// TODO: refactor, flaky
lastIncludes[mainFileAbsPath] = struct{}{}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("creating watcher: %w", err)
}
updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) {
for filePath := range previousWatched {
if _, ok := newWatched[filePath]; !ok {
watcher.Remove(filePath)
}
}
for filePath := range newWatched {
if _, ok := previousWatched[filePath]; !ok {
if err := watcher.Add(filePath); err != nil {
log.Printf(
"Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
filePath, err,
)
}
}
}
}
updateWatchedFiles(nil, lastIncludes)
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
mu := sync.Mutex{}
checkForContentChangesBeforeCallback := func() {
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
if err != nil {
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
return
}
// TODO: refactor, flaky
currentIncludes[mainFileAbsPath] = struct{}{}
mu.Lock()
defer mu.Unlock()
if !maps.Equal(currentIncludes, lastIncludes) {
updateWatchedFiles(lastIncludes, currentIncludes)
lastIncludes = currentIncludes
}
if !bytes.Equal(lastContents, currentContents) {
lastContents = currentContents
onChange(currentContents)
}
}
const debounceDuration = 500 * time.Millisecond
var debounceTimer *time.Timer
debouncedCallback := func() {
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer.Reset(debounceDuration)
} else {
debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback)
}
}
go func() {
for {
select {
case event, isOpen := <-watcher.Events:
if !isOpen {
return
}
if event.Has(fsnotify.Write) {
debouncedCallback()
} else if event.Has(fsnotify.Remove) {
func() {
mu.Lock()
defer mu.Unlock()
fileAbsPath, _ := filepath.Abs(event.Name)
delete(lastIncludes, fileAbsPath)
}()
debouncedCallback()
}
case err, isOpen := <-watcher.Errors:
if !isOpen {
return
}
onErr(fmt.Errorf("watcher error: %w", err))
}
}
}()
onChange(lastContents)
return func() error {
if debounceTimer != nil {
debounceTimer.Stop()
}
return watcher.Close()
}, nil
}
func isConfigStateValid(config *config) error {
if len(config.Pages) == 0 {
return fmt.Errorf("no pages configured")
}
if config.Server.AssetsPath != "" {
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
}
}
for i := range config.Pages {
if config.Pages[i].Title == "" {
return fmt.Errorf("Page %d has no title", i+1)
return fmt.Errorf("page %d has no name", i+1)
}
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
return fmt.Errorf("Page %d: width can only be either wide or slim", i+1)
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
}
if len(config.Pages[i].Columns) == 0 {
return fmt.Errorf("Page %d has no columns", i+1)
return fmt.Errorf("page %d has no columns", i+1)
}
if config.Pages[i].Width == "slim" {
if len(config.Pages[i].Columns) > 2 {
return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1)
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
}
} else {
if len(config.Pages[i].Columns) > 3 {
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
return fmt.Errorf("page %d has more than 3 columns", i+1)
}
}
@ -83,7 +308,7 @@ func configIsValid(config *Config) error {
for j := range config.Pages[i].Columns {
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1)
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
}
columnSizesCount[config.Pages[i].Columns[j].Size]++
@ -92,7 +317,7 @@ func configIsValid(config *Config) error {
full := columnSizesCount["full"]
if full > 2 || full == 0 {
return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1)
return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1)
}
}

205
internal/glance/diagnose.go Normal file
View file

@ -0,0 +1,205 @@
package glance
import (
"context"
"fmt"
"io"
"net"
"net/http"
"runtime"
"strings"
"sync"
"time"
)
const httpTestRequestTimeout = 10 * time.Second
var diagnosticSteps = []diagnosticStep{
{
name: "resolve cloudflare.com through Cloudflare DoH",
fn: func() (string, error) {
return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{
"accept": "application/dns-json",
}, 200)
},
},
{
name: "resolve cloudflare.com through Google DoH",
fn: func() (string, error) {
return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200)
},
},
{
name: "resolve github.com",
fn: func() (string, error) {
return testDNSResolution("github.com")
},
},
{
name: "resolve reddit.com",
fn: func() (string, error) {
return testDNSResolution("reddit.com")
},
},
{
name: "resolve twitch.tv",
fn: func() (string, error) {
return testDNSResolution("twitch.tv")
},
},
{
name: "fetch data from YouTube RSS feed",
fn: func() (string, error) {
return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200)
},
},
{
name: "fetch data from Twitch.tv GQL",
fn: func() (string, error) {
// this should always return 0 bytes, we're mainly looking for a 200 status code
return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200)
},
},
{
name: "fetch data from GitHub API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://api.github.com", 200)
},
},
{
name: "fetch data from Open-Meteo API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200)
},
},
{
name: "fetch data from Reddit API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
},
},
{
name: "fetch data from Yahoo finance API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
},
},
{
name: "fetch data from Hacker News Firebase API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200)
},
},
{
name: "fetch data from Docker Hub API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200)
},
},
}
func runDiagnostic() {
fmt.Println("```")
fmt.Println("Glance version: " + buildVersion)
fmt.Println("Go version: " + runtime.Version())
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
var wg sync.WaitGroup
for i := range diagnosticSteps {
step := &diagnosticSteps[i]
wg.Add(1)
go func() {
defer wg.Done()
start := time.Now()
step.extraInfo, step.err = step.fn()
step.elapsed = time.Since(start)
}()
}
wg.Wait()
for _, step := range diagnosticSteps {
var extraInfo string
if step.extraInfo != "" {
extraInfo = "| " + step.extraInfo + " "
}
fmt.Printf(
"%s %s %s| %dms\n",
boolToString(step.err == nil, "✓ Can", "✗ Can't"),
step.name,
extraInfo,
step.elapsed.Milliseconds(),
)
if step.err != nil {
fmt.Printf("└╴ error: %v\n", step.err)
}
}
fmt.Println("```")
}
type diagnosticStep struct {
name string
fn func() (string, error)
extraInfo string
err error
elapsed time.Duration
}
func testHttpRequest(method, url string, expectedStatusCode int) (string, error) {
return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode)
}
func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, method, url, nil)
for key, value := range headers {
request.Header.Add(key, value)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
printableBody := strings.ReplaceAll(string(body), "\n", "")
if len(printableBody) > 50 {
printableBody = printableBody[:50] + "..."
}
if len(printableBody) > 0 {
printableBody = ", " + printableBody
}
extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody)
if response.StatusCode != expectedStatusCode {
return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode)
}
return extraInfo, nil
}
func testDNSResolution(domain string) (string, error) {
ips, err := net.LookupIP(domain)
var ipStrings []string
if err == nil {
for i := range ips {
ipStrings = append(ipStrings, ips[i].String())
}
}
return strings.Join(ipStrings, ", "), err
}

62
internal/glance/embed.go Normal file
View file

@ -0,0 +1,62 @@
package glance
import (
"crypto/md5"
"embed"
"encoding/hex"
"io"
"io/fs"
"log"
"strconv"
"time"
)
//go:embed static
var _staticFS embed.FS
//go:embed templates
var _templateFS embed.FS
var staticFS, _ = fs.Sub(_staticFS, "static")
var templateFS, _ = fs.Sub(_templateFS, "templates")
var staticFSHash = func() string {
hash, err := computeFSHash(staticFS)
if err != nil {
log.Printf("Could not compute static assets cache key: %v", err)
return strconv.FormatInt(time.Now().Unix(), 10)
}
return hash
}()
func computeFSHash(files fs.FS) (string, error) {
hash := md5.New()
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil))[:10], nil
}

View file

@ -5,80 +5,96 @@ import (
"context"
"fmt"
"html/template"
"log/slog"
"log"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/widget"
)
var buildVersion = "dev"
var (
pageTemplate = mustParseTemplate("page.html", "document.html")
pageContentTemplate = mustParseTemplate("page-content.html")
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
type application struct {
Version string
Config config
ParsedThemeStyle template.HTML
type Application struct {
Version string
Config Config
slugToPage map[string]*Page
widgetByID map[uint64]widget.Widget
slugToPage map[string]*page
widgetByID map[uint64]widget
}
type Theme struct {
BackgroundColor *widget.HSLColorField `yaml:"background-color"`
PrimaryColor *widget.HSLColorField `yaml:"primary-color"`
PositiveColor *widget.HSLColorField `yaml:"positive-color"`
NegativeColor *widget.HSLColorField `yaml:"negative-color"`
Light bool `yaml:"light"`
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
CustomCSSFile string `yaml:"custom-css-file"`
func newApplication(config *config) (*application, error) {
app := &application{
Version: buildVersion,
Config: *config,
slugToPage: make(map[string]*page),
widgetByID: make(map[uint64]widget),
}
app.slugToPage[""] = &config.Pages[0]
providers := &widgetProviders{
assetResolver: app.AssetPath,
}
var err error
app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
if err != nil {
return nil, fmt.Errorf("parsing theme style: %v", err)
}
for p := range config.Pages {
page := &config.Pages[p]
page.PrimaryColumnIndex = -1
if page.Slug == "" {
page.Slug = titleToSlug(page.Title)
}
app.slugToPage[page.Slug] = page
for c := range page.Columns {
column := &page.Columns[c]
if page.PrimaryColumnIndex == -1 && column.Size == "full" {
page.PrimaryColumnIndex = int8(c)
}
for w := range column.Widgets {
widget := column.Widgets[w]
app.widgetByID[widget.id()] = widget
widget.setProviders(providers)
}
}
}
config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png")
} else {
config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
}
config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
return app, nil
}
type Server struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"`
AssetsHash string `yaml:"-"`
StartedAt time.Time `yaml:"-"` // used in custom css file
}
func (p *page) updateOutdatedWidgets() {
p.mu.Lock()
defer p.mu.Unlock()
type Branding struct {
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
}
type Column struct {
Size string `yaml:"size"`
Widgets widget.Widgets `yaml:"widgets"`
}
type templateData struct {
App *Application
Page *Page
}
type Page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
Columns []Column `yaml:"columns"`
mu sync.Mutex
}
func (p *Page) UpdateOutdatedWidgets() {
now := time.Now()
var wg sync.WaitGroup
@ -88,14 +104,14 @@ func (p *Page) UpdateOutdatedWidgets() {
for w := range p.Columns[c].Widgets {
widget := p.Columns[c].Widgets[w]
if !widget.RequiresUpdate(&now) {
if !widget.requiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.Update(context)
widget.update(context)
}()
}
}
@ -103,16 +119,7 @@ func (p *Page) UpdateOutdatedWidgets() {
wg.Wait()
}
// TODO: fix, currently very simple, lots of uncovered edge cases
func titleToSlug(s string) string {
s = strings.ToLower(s)
s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
func (a *Application) TransformUserDefinedAssetPath(path string) string {
func (a *application) transformUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path
}
@ -120,74 +127,26 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string {
return path
}
func NewApplication(config *Config) (*Application, error) {
if len(config.Pages) == 0 {
return nil, fmt.Errorf("no pages configured")
}
app := &Application{
Version: buildVersion,
Config: *config,
slugToPage: make(map[string]*Page),
widgetByID: make(map[uint64]widget.Widget),
}
app.Config.Server.AssetsHash = assets.PublicFSHash
app.slugToPage[""] = &config.Pages[0]
providers := &widget.Providers{
AssetResolver: app.AssetPath,
}
for p := range config.Pages {
if config.Pages[p].Slug == "" {
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
}
app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
widget := config.Pages[p].Columns[c].Widgets[w]
app.widgetByID[widget.GetID()] = widget
widget.SetProviders(providers)
}
}
}
config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png")
} else {
config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
}
config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
return app, nil
type pageTemplateData struct {
App *application
Page *page
}
func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")]
if !exists {
a.HandleNotFound(w, r)
a.handleNotFound(w, r)
return
}
pageData := templateData{
pageData := pageTemplateData{
Page: page,
App: a,
}
var responseBytes bytes.Buffer
err := assets.PageTemplate.Execute(&responseBytes, pageData)
err := pageTemplate.Execute(&responseBytes, pageData)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
@ -197,25 +156,22 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request)
w.Write(responseBytes.Bytes())
}
func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) {
func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")]
if !exists {
a.HandleNotFound(w, r)
a.handleNotFound(w, r)
return
}
pageData := templateData{
pageData := pageTemplateData{
Page: page,
}
page.mu.Lock()
defer page.mu.Unlock()
page.UpdateOutdatedWidgets()
page.updateOutdatedWidgets()
var responseBytes bytes.Buffer
err := assets.PageContentTemplate.Execute(&responseBytes, pageData)
err := pageContentTemplate.Execute(&responseBytes, pageData)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
@ -225,74 +181,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re
w.Write(responseBytes.Bytes())
}
func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) {
func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
// TODO: add proper not found page
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Page not found"))
}
func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
server := http.FileServer(fs)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: fix always setting cache control even if the file doesn't exist
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds())))
server.ServeHTTP(w, r)
})
}
func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
widgetValue := r.PathValue("widget")
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil {
a.HandleNotFound(w, r)
a.handleNotFound(w, r)
return
}
widget, exists := a.widgetByID[widgetID]
if !exists {
a.HandleNotFound(w, r)
a.handleNotFound(w, r)
return
}
widget.HandleRequest(w, r)
widget.handleRequest(w, r)
}
func (a *Application) AssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
func (a *application) AssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
}
func (a *Application) Serve() error {
func (a *application) server() (func() error, func() error) {
// TODO: add gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
mux.HandleFunc("GET /{$}", a.handlePageRequest)
mux.HandleFunc("GET /{page}", a.handlePageRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
)
var absAssetsPath string
if a.Config.Server.AssetsPath != "" {
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
if err != nil {
return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
}
slog.Info("Serving assets", "path", absAssetsPath)
assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
}
@ -301,8 +241,25 @@ func (a *Application) Serve() error {
Handler: mux,
}
a.Config.Server.StartedAt = time.Now()
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
start := func() error {
a.Config.Server.StartedAt = time.Now()
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
a.Config.Server.Host,
a.Config.Server.Port,
a.Config.Server.BaseURL,
absAssetsPath,
)
return server.ListenAndServe()
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
stop := func() error {
return server.Close()
}
return start, stop
}

View file

@ -2,45 +2,174 @@ package glance
import (
"fmt"
"io"
"log"
"net/http"
"os"
)
func Main() int {
options, err := ParseCliOptions()
var buildVersion = "dev"
func Main() int {
options, err := parseCliOptions()
if err != nil {
fmt.Println(err)
return 1
}
configFile, err := os.Open(options.ConfigPath)
if err != nil {
fmt.Printf("failed opening config file: %v\n", err)
return 1
}
config, err := NewConfigFromYml(configFile)
configFile.Close()
if err != nil {
fmt.Printf("failed parsing config file: %v\n", err)
return 1
}
if options.Intent == CliIntentServe {
app, err := NewApplication(config)
switch options.intent {
case cliIntentServe:
// remove in v0.10.0
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
return 1
}
if err := serveApp(options.configPath); err != nil {
fmt.Println(err)
return 1
}
case cliIntentConfigValidate:
contents, _, err := parseYAMLIncludes(options.configPath)
if err != nil {
fmt.Printf("failed creating application: %v\n", err)
fmt.Printf("Could not parse config file: %v\n", err)
return 1
}
if err := app.Serve(); err != nil {
fmt.Printf("http server error: %v\n", err)
if _, err := newConfigFromYAML(contents); err != nil {
fmt.Printf("Config file is invalid: %v\n", err)
return 1
}
case cliIntentConfigPrint:
contents, _, err := parseYAMLIncludes(options.configPath)
if err != nil {
fmt.Printf("Could not parse config file: %v\n", err)
return 1
}
fmt.Println(string(contents))
case cliIntentDiagnose:
runDiagnostic()
}
return 0
}
func serveApp(configPath string) error {
exitChannel := make(chan struct{})
// the onChange method gets called at most once per 500ms due to debouncing so we shouldn't
// need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason
hadValidConfigOnStartup := false
var stopServer func() error
onChange := func(newContents []byte) {
if stopServer != nil {
log.Println("Config file changed, reloading...")
}
config, err := newConfigFromYAML(newContents)
if err != nil {
log.Printf("Config has errors: %v", err)
if !hadValidConfigOnStartup {
close(exitChannel)
}
return
} else if !hadValidConfigOnStartup {
hadValidConfigOnStartup = true
}
app, err := newApplication(config)
if err != nil {
log.Printf("Failed to create application: %v", err)
return
}
if stopServer != nil {
if err := stopServer(); err != nil {
log.Printf("Error while trying to stop server: %v", err)
}
}
go func() {
var startServer func() error
startServer, stopServer = app.server()
if err := startServer(); err != nil {
log.Printf("Failed to start server: %v", err)
}
}()
}
onErr := func(err error) {
log.Printf("Error watching config files: %v", err)
}
configContents, configIncludes, err := parseYAMLIncludes(configPath)
if err != nil {
return fmt.Errorf("parsing config: %w", err)
}
stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr)
if err == nil {
defer stopWatching()
} else {
log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err)
config, err := newConfigFromYAML(configContents)
if err != nil {
return fmt.Errorf("validating config file: %w", err)
}
app, err := newApplication(config)
if err != nil {
return fmt.Errorf("creating application: %w", err)
}
startServer, _ := app.server()
if err := startServer(); err != nil {
return fmt.Errorf("starting server: %w", err)
}
}
<-exitChannel
return nil
}
func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
if !isRunningInsideDockerContainer() {
return false
}
if _, err := os.Stat(configPath); err == nil {
return false
}
// glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory
if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() {
return false
}
templateFile, _ := templateFS.Open("v0.7-update-notice-page.html")
bodyContents, _ := io.ReadAll(templateFile)
// TODO: update - add link
fmt.Println("!!! WARNING !!!")
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see <link> for more information.")
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(bodyContents))
})
server := http.Server{
Addr: ":8080",
Handler: mux,
}
server.ListenAndServe()
return true
}

View file

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 300 B

After

Width:  |  Height:  |  Size: 300 B

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

Before

Width:  |  Height:  |  Size: 802 B

After

Width:  |  Height:  |  Size: 802 B

View file

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 553 B

View file

@ -1,5 +1,6 @@
import { setupPopovers } from './popover.js';
import { throttledDebounce, isElementVisible } from './utils.js';
import { setupMasonries } from './masonry.js';
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
async function fetchPageContent(pageData) {
// TODO: handle non 200 status codes/time outs
@ -104,6 +105,7 @@ function setupSearchBoxes() {
const bangsMap = {};
const kbdElement = widget.getElementsByTagName("kbd")[0];
let currentBang = null;
let lastQuery = "";
for (let j = 0; j < bangs.length; j++) {
const bang = bangs[j];
@ -140,6 +142,14 @@ function setupSearchBoxes() {
window.location.href = url;
}
lastQuery = query;
inputElement.value = "";
return;
}
if (event.key == "ArrowUp" && lastQuery.length > 0) {
inputElement.value = lastQuery;
return;
}
};
@ -245,8 +255,24 @@ function setupGroups() {
for (let t = 0; t < titles.length; t++) {
const title = titles[t];
if (title.dataset.titleUrl !== undefined) {
title.addEventListener("mousedown", (event) => {
if (event.button != 1) {
return;
}
openURLInNewTab(title.dataset.titleUrl, false);
event.preventDefault();
});
}
title.addEventListener("click", () => {
if (t == current) {
if (title.dataset.titleUrl !== undefined) {
openURLInNewTab(title.dataset.titleUrl);
}
return;
}
@ -502,9 +528,34 @@ function timeInZone(now, zone) {
timeInZone = now
}
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
return { time: timeInZone, diffInHours: diffInHours };
return { time: timeInZone, diffInMinutes: diffInMinutes };
}
function zoneDiffText(diffInMinutes) {
if (diffInMinutes == 0) {
return "";
}
const sign = diffInMinutes < 0 ? "-" : "+";
const signText = diffInMinutes < 0 ? "behind" : "ahead";
diffInMinutes = Math.abs(diffInMinutes);
const hours = Math.floor(diffInMinutes / 60);
const minutes = diffInMinutes % 60;
const hourSuffix = hours == 1 ? "" : "s";
if (minutes == 0) {
return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
}
if (hours == 0) {
return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
}
return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
}
function setupClocks() {
@ -547,9 +598,11 @@ function setupClocks() {
);
updateCallbacks.push((now) => {
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
setZoneTime(time);
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
const { text, title } = zoneDiffText(diffInMinutes);
diffElement.textContent = text;
diffElement.title = title;
});
}
}
@ -581,6 +634,7 @@ async function setupPage() {
setupCollapsibleLists();
setupCollapsibleGrids();
setupGroups();
setupMasonries();
setupDynamicRelativeTime();
setupLazyImages();
} finally {

View file

@ -0,0 +1,53 @@
import { clamp } from "./utils.js";
export function setupMasonries() {
const masonryContainers = document.getElementsByClassName("masonry");
for (let i = 0; i < masonryContainers.length; i++) {
const container = masonryContainers[i];
const options = {
minColumnWidth: container.dataset.minColumnWidth || 330,
maxColumns: container.dataset.maxColumns || 6,
};
const items = Array.from(container.children);
let previousColumnsCount = 0;
const render = function() {
const columnsCount = clamp(
Math.floor(container.offsetWidth / options.minColumnWidth),
1,
Math.min(options.maxColumns, items.length)
);
if (columnsCount === previousColumnsCount) {
return;
} else {
container.textContent = "";
previousColumnsCount = columnsCount;
}
const columnsFragment = document.createDocumentFragment();
for (let i = 0; i < columnsCount; i++) {
const column = document.createElement("div");
column.className = "masonry-column";
columnsFragment.append(column);
}
// poor man's masonry
// TODO: add an option that allows placing items in the
// shortest column instead of iterating the columns in order
for (let i = 0; i < items.length; i++) {
columnsFragment.children[i % columnsCount].appendChild(items[i]);
}
container.append(columnsFragment);
};
const observer = new ResizeObserver(() => requestAnimationFrame(render));
observer.observe(container);
}
}

View file

@ -56,6 +56,8 @@ function clearTogglePopoverTimeout() {
}
function showPopover() {
if (pendingTarget === null) return;
activeTarget = pendingTarget;
pendingTarget = null;
@ -109,9 +111,10 @@ function repositionContainer() {
const containerBounds = containerElement.getBoundingClientRect();
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5);
const position = activeTarget.dataset.popoverPosition || "below";
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
if (left < 0) {
containerElement.style.left = 0;
@ -120,11 +123,11 @@ function repositionContainer() {
} else if (left + containerBounds.width > window.innerWidth) {
containerElement.style.removeProperty("left");
containerElement.style.right = 0;
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + "px");
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
} else {
containerElement.style.removeProperty("right");
containerElement.style.left = left + "px";
containerElement.style.removeProperty("--triangle-offset");
containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + -1 + "px");
}
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;

View file

@ -23,3 +23,16 @@ export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
export function isElementVisible(element) {
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// NOTE: inconsistent behavior between browsers when it comes to
// whether the newly opened tab gets focused or not, potentially
// depending on the event that this function is called from
export function openURLInNewTab(url, focus = true) {
const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
if (focus && newWindow != null) newWindow.focus();
}

View file

@ -440,6 +440,17 @@ kbd:active {
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
}
.masonry {
display: flex;
gap: var(--widget-gap);
}
.masonry-column {
flex: 1;
display: flex;
flex-direction: column;
}
.popover-container, [data-popover-html] {
display: none;
}
@ -514,6 +525,7 @@ kbd:active {
list-style: none;
position: relative;
display: flex;
z-index: 1;
}
.details[open] .summary {
@ -535,6 +547,10 @@ kbd:active {
opacity: 1;
}
.details:not([open]) .list-with-transition {
display: none;
}
.summary::after {
content: "◀";
font-size: 1.2em;
@ -707,6 +723,7 @@ details[open] .summary::after {
justify-content: space-between;
position: relative;
margin-bottom: 1.8rem;
z-index: 1;
}
.widget-error-header::before {
@ -722,19 +739,11 @@ details[open] .summary::after {
.widget-error-icon {
width: 2.4rem;
height: 2.4rem;
border: 0.2rem solid var(--color-negative);
border-radius: 50%;
text-align: center;
line-height: 2rem;
flex-shrink: 0;
stroke: var(--color-negative);
opacity: 0.6;
}
.widget-error-icon::before {
content: '!';
color: var(--color-text-highlight);
}
.widget-content {
container-type: inline-size;
container-name: widget;
@ -1059,7 +1068,7 @@ details[open] .summary::after {
opacity: 0.8;
}
:root:not(.light-scheme) .simple-icon {
:root:not(.light-scheme) .flat-icon {
filter: invert(1);
}
@ -1095,7 +1104,6 @@ details[open] .summary::after {
.dns-stats-graph-gridlines-container {
position: absolute;
z-index: -1;
inset: 0;
}
@ -1122,7 +1130,6 @@ details[open] .summary::after {
content: '';
position: absolute;
inset: 1px 0;
z-index: -1;
opacity: 0;
background: var(--color-text-base);
transition: opacity .2s;
@ -1264,7 +1271,6 @@ details[open] .summary::after {
overflow: hidden;
mask-image: linear-gradient(0deg, transparent 40%, #000);
-webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
z-index: -1;
}
.weather-column-rain::before {
@ -1339,6 +1345,10 @@ details[open] .summary::after {
transform: translate(-50%, -50%);
}
.clock-time {
min-width: 8ch;
}
.clock-time span {
color: var(--color-text-highlight);
}
@ -1355,7 +1365,7 @@ details[open] .summary::after {
transition: filter 0.3s, opacity 0.3s;
}
.monitor-site-icon.simple-icon {
.monitor-site-icon.flat-icon {
opacity: 0.7;
}
@ -1363,7 +1373,7 @@ details[open] .summary::after {
opacity: 1;
}
.monitor-site:hover .monitor-site-icon:not(.simple-icon) {
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
filter: grayscale(0);
}
@ -1374,6 +1384,39 @@ details[open] .summary::after {
height: 2rem;
}
.monitor-site-status-icon-compact {
width: 1.8rem;
height: 1.8rem;
flex-shrink: 0;
}
.docker-container-icon {
display: block;
filter: grayscale(0.4);
object-fit: contain;
aspect-ratio: 1 / 1;
width: 2.7rem;
opacity: 0.8;
transition: filter 0.3s, opacity 0.3s;
}
.docker-container-icon.flat-icon {
opacity: 0.7;
}
.docker-container:hover .docker-container-icon {
opacity: 1;
}
.docker-container:hover .docker-container-icon:not(.flat-icon) {
filter: grayscale(0);
}
.docker-container-status-icon {
width: 2rem;
height: 2rem;
}
.thumbnail {
filter: grayscale(0.2) contrast(0.9);
opacity: 0.8;
@ -1493,6 +1536,14 @@ details[open] .summary::after {
border: 2px solid var(--color-widget-background);
}
.twitch-stream-preview {
max-width: 100%;
width: 400px;
aspect-ratio: 16 / 9;
border-radius: var(--border-radius);
object-fit: cover;
}
.reddit-card-thumbnail {
width: 100%;
height: 100%;
@ -1775,12 +1826,14 @@ details[open] .summary::after {
.gap-5 { gap: 0.5rem; }
.gap-7 { gap: 0.7rem; }
.gap-10 { gap: 1rem; }
.gap-12 { gap: 1.2rem; }
.gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; }
.gap-35 { gap: 3.5rem; }
.gap-45 { gap: 4.5rem; }
.gap-55 { gap: 5.5rem; }
.margin-left-auto { margin-left: auto; }
.margin-top-3 { margin-top: 0.3rem; }
.margin-top-5 { margin-top: 0.5rem; }
.margin-top-7 { margin-top: 0.7rem; }
@ -1794,6 +1847,7 @@ details[open] .summary::after {
.margin-block-3 { margin-block: 0.3rem; }
.margin-block-5 { margin-block: 0.5rem; }
.margin-block-7 { margin-block: 0.7rem; }
.margin-block-8 { margin-block: 0.8rem; }
.margin-block-10 { margin-block: 1rem; }
.margin-block-15 { margin-block: 1.5rem; }
.margin-bottom-3 { margin-bottom: 0.3rem; }
@ -1807,6 +1861,7 @@ details[open] .summary::after {
.list { --list-half-gap: 0rem; }
.list-gap-2 { --list-half-gap: 0.1rem; }
.list-gap-4 { --list-half-gap: 0.2rem; }
.list-gap-8 { --list-half-gap: 0.4rem; }
.list-gap-10 { --list-half-gap: 0.5rem; }
.list-gap-14 { --list-half-gap: 0.7rem; }
.list-gap-20 { --list-half-gap: 1rem; }

View file

@ -0,0 +1,56 @@
package glance
import (
"fmt"
"html/template"
"math"
"strconv"
"time"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
var intl = message.NewPrinter(language.English)
var globalTemplateFunctions = template.FuncMap{
"formatViewerCount": formatViewerCount,
"formatNumber": intl.Sprint,
"absInt": func(i int) int {
return int(math.Abs(float64(i)))
},
"formatPrice": func(price float64) string {
return intl.Sprintf("%.2f", price)
},
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
},
}
func mustParseTemplate(primary string, dependencies ...string) *template.Template {
t, err := template.New(primary).
Funcs(globalTemplateFunctions).
ParseFS(templateFS, append([]string{primary}, dependencies...)...)
if err != nil {
panic(err)
}
return t
}
func formatViewerCount(count int) string {
if count < 1_000 {
return strconv.Itoa(count)
}
if count < 10_000 {
return fmt.Sprintf("%.1fk", float64(count)/1_000)
}
if count < 1_000_000 {
return fmt.Sprintf("%dk", count/1_000)
}
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
}

View file

@ -8,9 +8,9 @@
<ul class="list list-gap-2">
{{ range .Links }}
<li class="flex items-center gap-10">
{{ if ne "" .Icon }}
{{ if ne "" .Icon.URL }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
</div>
{{ end }}
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>

View file

@ -11,13 +11,18 @@
</div>
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
{{ if .StartSunday }}
<div class="calendar-day">Su</div>
{{ end }}
<div class="calendar-day">Mo</div>
<div class="calendar-day">Tu</div>
<div class="calendar-day">We</div>
<div class="calendar-day">Th</div>
<div class="calendar-day">Fr</div>
<div class="calendar-day">Sa</div>
<div class="calendar-day">Su</div>
{{ if not .StartSunday }}
<div class="calendar-day">Su</div>
{{ end }}
</div>
<div class="flex flex-wrap">

View file

@ -0,0 +1,7 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
{{ define "widget-content" }}
{{ .CompiledHTML }}
{{ end }}

View file

@ -73,7 +73,7 @@
<summary class="summary">Top blocked domains</summary>
<ul class="list list-gap-4 list-with-transition size-h5">
{{ range .Stats.TopBlockedDomains }}
<li class="flex justify-between align-center">
<li class="flex justify-between">
<div class="text-truncate rtl">{{ .Domain }}</div>
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
</li>

View file

@ -0,0 +1,64 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="dynamic-columns list-gap-20 list-with-separator">
{{ range .Containers }}
<div class="docker-container flex items-center gap-15">
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem">
<img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
<div data-popover-html>
<div class="color-highlight text-truncate block">{{ .Image }}</div>
<div>{{ .StateText }}</div>
{{ if .Children }}
<ul class="list list-gap-4 margin-top-10">
{{ range .Children }}
<li class="flex gap-7 items-center">
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
<div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
</li>
{{ end }}
</ul>
{{ end }}
</div>
</div>
<div class="min-width-0">
{{ if .URL }}
<a href="{{ .URL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
{{ else }}
<div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
{{ end }}
{{ if .Description }}
<div class="text-truncate">{{ .Description }}</div>
{{ end }}
</div>
<div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}">
{{ template "state-icon" .StateIcon }}
</div>
</div>
{{ else }}
<div class="text-center">No containers available to show.</div>
{{ end }}
</div>
{{ end }}
{{ define "state-icon" }}
{{ if eq . "ok" }}
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
{{ else if eq . "warn" }}
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
{{ else if eq . "paused" }}
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
</svg>
{{ else }}
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
{{ end }}
{{ end }}

View file

@ -12,7 +12,7 @@
</svg>
{{ else if ne .ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
{{ else if .HasTargetUrl }}
{{ else if ne "" .TargetUrl }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
@ -37,7 +37,7 @@
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li>
<li>{{ .CommentCount | formatNumber }} comments</li>
{{ if .HasTargetUrl }}
{{ if ne "" .TargetUrl }}
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }}
</ul>

View file

@ -6,7 +6,7 @@
<div class="widget-group-header">
<div class="widget-header gap-20">
{{ range $i, $widget := .Widgets }}
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ 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 }}>{{ $widget.Title }}</button>
{{ end }}
</div>
</div>

View file

@ -6,7 +6,7 @@
<div class="flex items-center gap-15">
<div class="min-width-0">
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
<div class="text-truncate">{{ .Name }}</div>
<div title="{{ .Name }}" class="text-truncate">{{ .Name }}</div>
</div>
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>

View file

@ -0,0 +1,39 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ if not (and .ShowFailingOnly (not .HasFailing)) }}
<ul class="dynamic-columns list-gap-8">
{{ range .Sites }}
{{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }}{{ continue }}{{ end }}
<div class="flex items-center gap-12">
{{ template "site" . }}
</div>
{{ end }}
</ul>
{{ else }}
<div class="flex items-center justify-center gap-10 padding-block-5">
<p>All sites are online</p>
<svg class="shrink-0" style="width: 1.7rem;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}
{{ end }}
{{ define "site" }}
<a class="size-title-dynamic color-highlight text-truncate block grow" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
{{ if not .Status.TimedOut }}<div>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</div>{{ end }}
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon-compact" title="{{ .Status.Code }}">
<svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
</div>
{{ else }}
<div class="monitor-site-status-icon-compact" title="{{ if .Status.Error }}{{ .Status.Error }}{{ else }}{{ .Status.Code }}{{ end }}">
<svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}
{{ end }}

View file

@ -21,8 +21,8 @@
{{ end }}
{{ define "site" }}
{{ if .IconUrl }}
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
{{ if .Icon.URL }}
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ end }}
<div class="min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
@ -39,14 +39,14 @@
</div>
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
<svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
</div>
{{ else }}
<div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
<svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}

View file

@ -14,10 +14,13 @@
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
{{ define "document-head-after" }}
{{ template "page-style-overrides.gotmpl" . }}
{{ .App.ParsedThemeStyle }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
{{ end }}
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
{{ end }}
{{ define "navigation-links" }}
@ -44,9 +47,9 @@
<div class="mobile-navigation-icons">
<a class="mobile-navigation-label" href="#top"></a>
{{ range $i, $column := .Page.Columns }}
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
{{ end }}
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }}

View file

@ -1,13 +1,13 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Releases }}
<li>
<div class="flex items-center gap-10">
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if $.ShowSourceIcon }}
<img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
<img class="flat-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
{{ end }}
</div>
<ul class="list-horizontal-text">

View file

@ -1,58 +1,58 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
<a class="size-h4 color-highlight" href="https://github.com/{{ $.Repository.Name }}" target="_blank" rel="noreferrer">{{ .Repository.Name }}</a>
<ul class="list-horizontal-text">
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
<li>{{ .Repository.Stars | formatNumber }} stars</li>
<li>{{ .Repository.Forks | formatNumber }} forks</li>
</ul>
{{ if gt (len .RepositoryDetails.Commits) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
{{ if gt (len .Repository.Commits) 0 }}
<hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Commits }}
{{ range .Repository.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.Commits }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
{{ range .Repository.Commits }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
{{ if gt (len .Repository.PullRequests) 0 }}
<hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.PullRequests }}
{{ range .Repository.PullRequests }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.PullRequests }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
{{ range .Repository.PullRequests }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .RepositoryDetails.Issues) 0 }}
{{ if gt (len .Repository.Issues) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Issues }}
{{ range .Repository.Issues }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.Issues }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
{{ range .Repository.Issues }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>

View file

@ -0,0 +1,11 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="masonry" data-max-columns="{{ .MaxColumns }}">
{{ range .Widgets }}
{{ .Render }}
{{ end }}
</div>
{{ end }}

View file

@ -0,0 +1,14 @@
<style>
:root {
{{ if .BackgroundColor }}
--bgh: {{ .BackgroundColor.Hue }};
--bgs: {{ .BackgroundColor.Saturation }}%;
--bgl: {{ .BackgroundColor.Lightness }}%;
{{ end }}
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
{{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.AsCSSValue }};{{ end }}
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.AsCSSValue }};{{ end }}
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.AsCSSValue }};{{ end }}
}
</style>

View file

@ -5,9 +5,15 @@
{{ range .Channels }}
<li>
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
<div class="twitch-channel-avatar-container">
<div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem" data-popover-offset="0.2"{{ end }}>
{{ if .IsLive }}
<div data-popover-html>
<img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
<p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
</div>
{{ end }}
{{ if .Exists }}
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
<a href="https://twitch.tv/{{ .Login }}" target="_blank" rel="noreferrer">
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
</a>
{{ else }}

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="static/main.css">
<title>Update notice</title>
<style>
body {
display: flex;
align-items: center;
justify-content: center;
}
.content-bounds {
max-width: 700px;
margin-top: -10rem;
}
.comfy-line-height {
line-height: 1.9;
}
</style>
</head>
<body>
<!-- TODO: update - add links -->
<div class="content-bounds color-highlight">
<p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
<div class="widget-content-frame margin-top-10 padding-widget">
<p class="comfy-line-height">
The default location of glance.yml in the Docker image has
changed since v0.7.0, please see the <a class="color-primary" href="#">migration guide</a>
for instructions or visit the <a class="color-primary" href="#">release notes</a>
to find out more about why this change was necessary. Sorry for the inconvenience.
</p>
<p class="margin-top-15 color-base">Migration should take around 5 minutes.</p>
</div>
</div>
</body>
</html>

View file

@ -15,7 +15,9 @@
{{ else }}
<div class="widget-error-header">
<div class="color-negative size-h3">ERROR</div>
<div class="widget-error-icon"></div>
<svg class="widget-error-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
{{ end}}

View file

@ -1,19 +1,19 @@
package feed
package glance
import (
"errors"
"bytes"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"regexp"
"slices"
"strings"
"time"
)
var (
ErrNoContent = errors.New("failed to retrieve any content")
ErrPartialContent = errors.New("failed to retrieve some of the content")
)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
func percentChange(current, previous float64) float64 {
return (current/previous - 1) * 100
@ -25,7 +25,6 @@ func extractDomainFromUrl(u string) string {
}
parsed, err := url.Parse(u)
if err != nil {
return ""
}
@ -33,7 +32,7 @@ func extractDomainFromUrl(u string) string {
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
}
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
if len(values) < 2 {
return ""
}
@ -86,6 +85,21 @@ func stripURLScheme(url string) string {
return urlSchemePattern.ReplaceAllString(url, "")
}
func isRunningInsideDockerContainer() bool {
_, err := os.Stat("/.dockerenv")
return err == nil
}
func prefixStringLines(prefix string, s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = prefix + line
}
return strings.Join(lines, "\n")
}
func limitStringLength(s string, max int) (string, bool) {
asRunes := []rune(s)
@ -98,7 +112,6 @@ func limitStringLength(s string, max int) (string, bool) {
func parseRFC3339Time(t string) time.Time {
parsed, err := time.Parse(time.RFC3339, t)
if err != nil {
return time.Now()
}
@ -106,6 +119,14 @@ func parseRFC3339Time(t string) time.Time {
return parsed
}
func boolToString(b bool, trueValue, falseValue string) string {
if b {
return trueValue
}
return falseValue
}
func normalizeVersionFormat(version string) string {
version = strings.ToLower(strings.TrimSpace(version))
@ -115,3 +136,45 @@ func normalizeVersionFormat(version string) string {
return version
}
func titleToSlug(s string) string {
s = strings.ToLower(s)
s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
server := http.FileServer(fs)
cacheControlValue := fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: fix always setting cache control even if the file doesn't exist
w.Header().Set("Cache-Control", cacheControlValue)
server.ServeHTTP(w, r)
})
}
func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) {
var b bytes.Buffer
err := t.Execute(&b, data)
if err != nil {
return "", fmt.Errorf("executing template: %w", err)
}
return template.HTML(b.String()), nil
}
func stringToBool(s string) bool {
return s == "true" || s == "yes"
}
func itemAtIndexOrDefault[T any](items []T, index int, def T) T {
if index >= len(items) {
return def
}
return items[index]
}

View file

@ -0,0 +1,34 @@
package glance
import (
"html/template"
)
var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html")
type bookmarksWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
Groups []struct {
Title string `yaml:"title"`
Color *hslColorField `yaml:"color"`
Links []struct {
Title string `yaml:"title"`
URL string `yaml:"url"`
Icon customIconField `yaml:"icon"`
SameTab bool `yaml:"same-tab"`
HideArrow bool `yaml:"hide-arrow"`
} `yaml:"links"`
} `yaml:"groups"`
}
func (widget *bookmarksWidget) initialize() error {
widget.withTitle("Bookmarks").withError(nil)
widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate)
return nil
}
func (widget *bookmarksWidget) Render() template.HTML {
return widget.cachedHTML
}

View file

@ -0,0 +1,86 @@
package glance
import (
"context"
"html/template"
"time"
)
var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html")
type calendarWidget struct {
widgetBase `yaml:",inline"`
Calendar *calendar
StartSunday bool `yaml:"start-sunday"`
}
func (widget *calendarWidget) initialize() error {
widget.withTitle("Calendar").withCacheOnTheHour()
return nil
}
func (widget *calendarWidget) update(ctx context.Context) {
widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
widget.withError(nil).scheduleNextUpdate()
}
func (widget *calendarWidget) Render() template.HTML {
return widget.renderTemplate(widget, calendarWidgetTemplate)
}
type calendar struct {
CurrentDay int
CurrentWeekNumber int
CurrentMonthName string
CurrentYear int
Days []int
}
// TODO: very inflexible, refactor to allow more customizability
// TODO: allow changing between showing the previous and next week and the entire month
func newCalendar(now time.Time, startSunday bool) *calendar {
year, week := now.ISOWeek()
weekday := now.Weekday()
if !startSunday {
weekday = (weekday + 6) % 7 // Shift Monday to 0
}
currentMonthDays := daysInMonth(now.Month(), year)
var previousMonthDays int
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
previousMonthDays = daysInMonth(12, year-1)
} else {
previousMonthDays = daysInMonth(previousMonthNumber, year)
}
startDaysFrom := now.Day() - int(weekday) - 7
days := make([]int, 21)
for i := 0; i < 21; i++ {
day := startDaysFrom + i
if day < 1 {
day = previousMonthDays + day
} else if day > currentMonthDays {
day = day - currentMonthDays
}
days[i] = day
}
return &calendar{
CurrentDay: now.Day(),
CurrentWeekNumber: week,
CurrentMonthName: now.Month().String(),
CurrentYear: year,
Days: days,
}
}
func daysInMonth(m time.Month, year int) int {
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
}

View file

@ -1,7 +1,9 @@
package feed
package glance
import (
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"sort"
@ -9,7 +11,65 @@ import (
"time"
)
type ChangeDetectionWatch struct {
var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html")
type changeDetectionWidget struct {
widgetBase `yaml:",inline"`
ChangeDetections changeDetectionWatchList `yaml:"-"`
WatchUUIDs []string `yaml:"watches"`
InstanceURL string `yaml:"instance-url"`
Token optionalEnvField `yaml:"token"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
}
func (widget *changeDetectionWidget) initialize() error {
widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
if widget.Limit <= 0 {
widget.Limit = 10
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
if widget.InstanceURL == "" {
widget.InstanceURL = "https://www.changedetection.io"
}
return nil
}
func (widget *changeDetectionWidget) update(ctx context.Context) {
if len(widget.WatchUUIDs) == 0 {
uuids, err := fetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.WatchUUIDs = uuids
}
watches, err := fetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if len(watches) > widget.Limit {
watches = watches[:widget.Limit]
}
widget.ChangeDetections = watches
}
func (widget *changeDetectionWidget) Render() template.HTML {
return widget.renderTemplate(widget, changeDetectionWidgetTemplate)
}
type changeDetectionWatch struct {
Title string
URL string
LastChanged time.Time
@ -17,9 +77,9 @@ type ChangeDetectionWatch struct {
PreviousHash string
}
type ChangeDetectionWatches []ChangeDetectionWatch
type changeDetectionWatchList []changeDetectionWatch
func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList {
sort.Slice(r, func(i, j int) bool {
return r[i].LastChanged.After(r[j].LastChanged)
})
@ -35,15 +95,14 @@ type changeDetectionResponseJson struct {
PreviousHash string `json:"previous_md5"`
}
func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
if token != "" {
request.Header.Add("x-api-key", token)
}
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request)
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
}
@ -57,8 +116,8 @@ func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str
return uuids, nil
}
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) {
watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs))
if len(requestedWatchIDs) == 0 {
return watches, nil
@ -76,10 +135,9 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
requests[i] = request
}
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient)
job := newJob(task, requests).withWorkers(15)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
@ -89,13 +147,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
slog.Error("Failed to fetch or parse change detection watch", "url", requests[i].URL, "error", errs[i])
continue
}
watchJson := responses[i]
watch := ChangeDetectionWatch{
watch := changeDetectionWatch{
URL: watchJson.URL,
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
}
@ -126,13 +184,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
}
if len(watches) == 0 {
return nil, ErrNoContent
return nil, errNoContent
}
watches.SortByNewest()
watches.sortByNewest()
if failed > 0 {
return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
return watches, fmt.Errorf("%w: could not get %d watches", errPartialContent, failed)
}
return watches, nil

View file

@ -1,15 +1,15 @@
package widget
package glance
import (
"errors"
"fmt"
"html/template"
"time"
"github.com/glanceapp/glance/internal/assets"
)
type Clock struct {
var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html")
type clockWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
HourFormat string `yaml:"hour-format"`
@ -19,32 +19,30 @@ type Clock struct {
} `yaml:"timezones"`
}
func (widget *Clock) Initialize() error {
func (widget *clockWidget) initialize() error {
widget.withTitle("Clock").withError(nil)
if widget.HourFormat == "" {
widget.HourFormat = "24h"
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
return errors.New("invalid hour format for clock widget, must be either 12h or 24h")
return errors.New("hour-format must be either 12h or 24h")
}
for t := range widget.Timezones {
if widget.Timezones[t].Timezone == "" {
return errors.New("missing timezone value for clock widget")
return errors.New("missing timezone value")
}
_, err := time.LoadLocation(widget.Timezones[t].Timezone)
if err != nil {
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil {
return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err)
}
}
widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate)
return nil
}
func (widget *Clock) Render() template.HTML {
func (widget *clockWidget) Render() template.HTML {
return widget.cachedHTML
}

View file

@ -0,0 +1,58 @@
package glance
import (
"context"
"sync"
"time"
)
type containerWidgetBase struct {
Widgets widgets `yaml:"widgets"`
}
func (widget *containerWidgetBase) _initializeWidgets() error {
for i := range widget.Widgets {
if err := widget.Widgets[i].initialize(); err != nil {
return formatWidgetInitError(err, widget.Widgets[i])
}
}
return nil
}
func (widget *containerWidgetBase) _update(ctx context.Context) {
var wg sync.WaitGroup
now := time.Now()
for w := range widget.Widgets {
widget := widget.Widgets[w]
if !widget.requiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.update(ctx)
}()
}
wg.Wait()
}
func (widget *containerWidgetBase) _setProviders(providers *widgetProviders) {
for i := range widget.Widgets {
widget.Widgets[i].setProviders(providers)
}
}
func (widget *containerWidgetBase) _requiresUpdate(now *time.Time) bool {
for i := range widget.Widgets {
if widget.Widgets[i].requiresUpdate(now) {
return true
}
}
return false
}

View file

@ -0,0 +1,208 @@
package glance
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"time"
"github.com/tidwall/gjson"
)
var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
type customAPIWidget struct {
widgetBase `yaml:",inline"`
URL optionalEnvField `yaml:"url"`
Template string `yaml:"template"`
Frameless bool `yaml:"frameless"`
Headers map[string]optionalEnvField `yaml:"headers"`
APIRequest *http.Request `yaml:"-"`
compiledTemplate *template.Template `yaml:"-"`
CompiledHTML template.HTML `yaml:"-"`
}
func (widget *customAPIWidget) initialize() error {
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
if widget.URL == "" {
return errors.New("URL is required")
}
if widget.Template == "" {
return errors.New("template is required")
}
compiledTemplate, err := template.New("").Funcs(customAPITemplateFuncs).Parse(widget.Template)
if err != nil {
return fmt.Errorf("parsing template: %w", err)
}
widget.compiledTemplate = compiledTemplate
req, err := http.NewRequest(http.MethodGet, widget.URL.String(), nil)
if err != nil {
return err
}
for key, value := range widget.Headers {
req.Header.Add(key, value.String())
}
widget.APIRequest = req
return nil
}
func (widget *customAPIWidget) update(ctx context.Context) {
compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.CompiledHTML = compiledHTML
}
func (widget *customAPIWidget) Render() template.HTML {
return widget.renderTemplate(widget, customAPIWidgetTemplate)
}
func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
emptyBody := template.HTML("")
resp, err := defaultHTTPClient.Do(req)
if err != nil {
return emptyBody, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return emptyBody, err
}
body := string(bodyBytes)
if !gjson.Valid(body) {
truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated {
truncatedBody += "... <truncated>"
}
slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody)
return emptyBody, errors.New("invalid response JSON")
}
var templateBuffer bytes.Buffer
data := CustomAPITemplateData{
JSON: decoratedGJSONResult{gjson.Parse(body)},
Response: resp,
}
err = tmpl.Execute(&templateBuffer, &data)
if err != nil {
return emptyBody, err
}
return template.HTML(templateBuffer.String()), nil
}
type decoratedGJSONResult struct {
gjson.Result
}
type CustomAPITemplateData struct {
JSON decoratedGJSONResult
Response *http.Response
}
func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
decoratedResults := make([]decoratedGJSONResult, len(results))
for i, result := range results {
decoratedResults[i] = decoratedGJSONResult{result}
}
return decoratedResults
}
func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
if key == "" {
return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
}
return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
}
func (r *decoratedGJSONResult) String(key string) string {
if key == "" {
return r.Result.String()
}
return r.Get(key).String()
}
func (r *decoratedGJSONResult) Int(key string) int64 {
if key == "" {
return r.Result.Int()
}
return r.Get(key).Int()
}
func (r *decoratedGJSONResult) Float(key string) float64 {
if key == "" {
return r.Result.Float()
}
return r.Get(key).Float()
}
func (r *decoratedGJSONResult) Bool(key string) bool {
if key == "" {
return r.Result.Bool()
}
return r.Get(key).Bool()
}
var customAPITemplateFuncs = func() template.FuncMap {
funcs := template.FuncMap{
"toFloat": func(a int64) float64 {
return float64(a)
},
"toInt": func(a float64) int64 {
return int64(a)
},
"mathexpr": func(left float64, op string, right float64) float64 {
if right == 0 {
return 0
}
switch op {
case "+":
return left + right
case "-":
return left - right
case "*":
return left * right
case "/":
return left / right
default:
return 0
}
},
}
for key, value := range globalTemplateFunctions {
funcs[key] = value
}
return funcs
}()

View file

@ -0,0 +1,352 @@
package glance
import (
"context"
"encoding/json"
"errors"
"html/template"
"log/slog"
"net/http"
"sort"
"strings"
"time"
)
var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
type dnsStatsWidget struct {
widgetBase `yaml:",inline"`
TimeLabels [8]string `yaml:"-"`
Stats *dnsStats `yaml:"-"`
HourFormat string `yaml:"hour-format"`
Service string `yaml:"service"`
AllowInsecure bool `yaml:"allow-insecure"`
URL optionalEnvField `yaml:"url"`
Token optionalEnvField `yaml:"token"`
Username optionalEnvField `yaml:"username"`
Password optionalEnvField `yaml:"password"`
}
func makeDNSWidgetTimeLabels(format string) [8]string {
now := time.Now()
var labels [8]string
for h := 24; h > 0; h -= 3 {
labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
}
return labels
}
func (widget *dnsStatsWidget) initialize() error {
widget.
withTitle("DNS Stats").
withTitleURL(string(widget.URL)).
withCacheDuration(10 * time.Minute)
if widget.Service != "adguard" && widget.Service != "pihole" {
return errors.New("service must be either 'adguard' or 'pihole'")
}
return nil
}
func (widget *dnsStatsWidget) update(ctx context.Context) {
var stats *dnsStats
var err error
if widget.Service == "adguard" {
stats, err = fetchAdguardStats(string(widget.URL), widget.AllowInsecure, string(widget.Username), string(widget.Password))
} else {
stats, err = fetchPiholeStats(string(widget.URL), widget.AllowInsecure, string(widget.Token))
}
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.HourFormat == "24h" {
widget.TimeLabels = makeDNSWidgetTimeLabels("15:00")
} else {
widget.TimeLabels = makeDNSWidgetTimeLabels("3PM")
}
widget.Stats = stats
}
func (widget *dnsStatsWidget) Render() template.HTML {
return widget.renderTemplate(widget, dnsStatsWidgetTemplate)
}
type dnsStats struct {
TotalQueries int
BlockedQueries int
BlockedPercent int
ResponseTime int
DomainsBlocked int
Series [8]dnsStatsSeries
TopBlockedDomains []dnsStatsBlockedDomain
}
type dnsStatsSeries struct {
Queries int
Blocked int
PercentTotal int
PercentBlocked int
}
type dnsStatsBlockedDomain struct {
Domain string
PercentBlocked int
}
type adguardStatsResponse struct {
TotalQueries int `json:"num_dns_queries"`
QueriesSeries []int `json:"dns_queries"`
BlockedQueries int `json:"num_blocked_filtering"`
BlockedSeries []int `json:"blocked_filtering"`
ResponseTime float64 `json:"avg_processing_time"`
TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
}
func fetchAdguardStats(instanceURL string, allowInsecure bool, username, password string) (*dnsStats, error) {
requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
request.SetBasicAuth(username, password)
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
}
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request)
if err != nil {
return nil, err
}
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
stats := &dnsStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
ResponseTime: int(responseJson.ResponseTime * 1000),
TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount),
}
if stats.TotalQueries <= 0 {
return stats, nil
}
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
for i := 0; i < topBlockedDomainsCount; i++ {
domain := responseJson.TopBlockedDomains[i]
var firstDomain string
for k := range domain {
firstDomain = k
break
}
if firstDomain == "" {
continue
}
stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{
Domain: firstDomain,
})
if stats.BlockedQueries > 0 {
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
}
}
queriesSeries := responseJson.QueriesSeries
blockedSeries := responseJson.BlockedSeries
const bars = 8
const hoursSpan = 24
const hoursPerBar int = hoursSpan / bars
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 {
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
} else if len(blockedSeries) < hoursSpan {
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
}
maxQueriesInSeries := 0
for i := 0; i < bars; i++ {
queries := 0
blocked := 0
for j := 0; j < hoursPerBar; j++ {
queries += queriesSeries[i*hoursPerBar+j]
blocked += blockedSeries[i*hoursPerBar+j]
}
stats.Series[i] = dnsStatsSeries{
Queries: queries,
Blocked: blocked,
}
if queries > 0 {
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
}
for i := 0; i < bars; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}
type piholeStatsResponse struct {
TotalQueries int `json:"dns_queries_today"`
QueriesSeries map[int64]int `json:"domains_over_time"`
BlockedQueries int `json:"ads_blocked_today"`
BlockedSeries map[int64]int `json:"ads_over_time"`
BlockedPercentage float64 `json:"ads_percentage_today"`
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
DomainsBlocked int `json:"domains_being_blocked"`
}
// 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
type piholeTopBlockedDomains map[string]int
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
// because of the UnmarshalJSON method getting called recursively
temp := make(map[string]int)
err := json.Unmarshal(data, &temp)
if err != nil {
*p = make(piholeTopBlockedDomains)
} else {
*p = temp
}
return nil
}
func fetchPiholeStats(instanceURL string, allowInsecure bool, token string) (*dnsStats, error) {
if token == "" {
return nil, errors.New("missing API token")
}
requestURL := strings.TrimRight(instanceURL, "/") +
"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
}
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request)
if err != nil {
return nil, err
}
stats := &dnsStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
BlockedPercent: int(responseJson.BlockedPercentage),
DomainsBlocked: responseJson.DomainsBlocked,
}
if len(responseJson.TopBlockedDomains) > 0 {
domains := make([]dnsStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
for domain, count := range responseJson.TopBlockedDomains {
domains = append(domains, dnsStatsBlockedDomain{
Domain: domain,
PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
})
}
sort.Slice(domains, func(a, b int) bool {
return domains[a].PercentBlocked > domains[b].PercentBlocked
})
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
}
// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
slog.Warn(
"DNS stats for pihole: did not get expected 144 data points",
"len(queries)", len(responseJson.QueriesSeries),
"len(blocked)", len(responseJson.BlockedSeries),
)
return stats, nil
}
var lowestTimestamp int64 = 0
for timestamp := range responseJson.QueriesSeries {
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
lowestTimestamp = timestamp
}
}
maxQueriesInSeries := 0
for i := 0; i < 8; i++ {
queries := 0
blocked := 0
for j := 0; j < 18; j++ {
index := lowestTimestamp + int64(i*10800+j*600)
queries += responseJson.QueriesSeries[index]
blocked += responseJson.BlockedSeries[index]
}
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 := 0; i < 8; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}

View file

@ -0,0 +1,272 @@
package glance
import (
"context"
"encoding/json"
"fmt"
"html/template"
"net"
"net/http"
"sort"
"strings"
"time"
)
var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", "widget-base.html")
type dockerContainersWidget struct {
widgetBase `yaml:",inline"`
HideByDefault bool `yaml:"hide-by-default"`
SockPath string `yaml:"sock-path"`
Containers dockerContainerList `yaml:"-"`
}
func (widget *dockerContainersWidget) initialize() error {
widget.withTitle("Docker Containers").withCacheDuration(1 * time.Minute)
if widget.SockPath == "" {
widget.SockPath = "/var/run/docker.sock"
}
return nil
}
func (widget *dockerContainersWidget) update(ctx context.Context) {
containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
containers.sortByStateIconThenTitle()
widget.Containers = containers
}
func (widget *dockerContainersWidget) Render() template.HTML {
return widget.renderTemplate(widget, dockerContainersWidgetTemplate)
}
const (
dockerContainerLabelHide = "glance.hide"
dockerContainerLabelTitle = "glance.title"
dockerContainerLabelURL = "glance.url"
dockerContainerLabelDescription = "glance.description"
dockerContainerLabelSameTab = "glance.same-tab"
dockerContainerLabelIcon = "glance.icon"
dockerContainerLabelID = "glance.id"
dockerContainerLabelParent = "glance.parent"
)
const (
dockerContainerStateIconOK = "ok"
dockerContainerStateIconPaused = "paused"
dockerContainerStateIconWarn = "warn"
dockerContainerStateIconOther = "other"
)
var dockerContainerStateIconPriorities = map[string]int{
dockerContainerStateIconWarn: 0,
dockerContainerStateIconOther: 1,
dockerContainerStateIconPaused: 2,
dockerContainerStateIconOK: 3,
}
type dockerContainerJsonResponse struct {
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Status string `json:"Status"`
Labels dockerContainerLabels `json:"Labels"`
}
type dockerContainerLabels map[string]string
func (l *dockerContainerLabels) getOrDefault(label, def string) string {
if l == nil {
return def
}
v, ok := (*l)[label]
if !ok {
return def
}
if v == "" {
return def
}
return v
}
type dockerContainer struct {
Title string
URL string
SameTab bool
Image string
State string
StateText string
StateIcon string
Description string
Icon customIconField
Children dockerContainerList
}
type dockerContainerList []dockerContainer
func (containers dockerContainerList) sortByStateIconThenTitle() {
sort.SliceStable(containers, func(a, b int) bool {
p := &dockerContainerStateIconPriorities
if containers[a].StateIcon != containers[b].StateIcon {
return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon]
}
return strings.ToLower(containers[a].Title) < strings.ToLower(containers[b].Title)
})
}
func dockerContainerStateToStateIcon(state string) string {
switch state {
case "running":
return dockerContainerStateIconOK
case "paused":
return dockerContainerStateIconPaused
case "exited", "unhealthy", "dead":
return dockerContainerStateIconWarn
default:
return dockerContainerStateIconOther
}
}
func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) {
containers, err := fetchAllDockerContainersFromSock(socketPath)
if err != nil {
return nil, fmt.Errorf("fetching containers: %w", err)
}
containers, children := groupDockerContainerChildren(containers, hideByDefault)
dockerContainers := make(dockerContainerList, 0, len(containers))
for i := range containers {
container := &containers[i]
dc := dockerContainer{
Title: deriveDockerContainerTitle(container),
URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""),
Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""),
SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")),
Image: container.Image,
State: strings.ToLower(container.State),
StateText: strings.ToLower(container.Status),
Icon: newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")),
}
if idValue := container.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" {
if children, ok := children[idValue]; ok {
for i := range children {
child := &children[i]
dc.Children = append(dc.Children, dockerContainer{
Title: deriveDockerContainerTitle(child),
StateText: child.Status,
StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)),
})
}
}
}
dc.Children.sortByStateIconThenTitle()
stateIconSupersededByChild := false
for i := range dc.Children {
if dc.Children[i].StateIcon == dockerContainerStateIconWarn {
dc.StateIcon = dockerContainerStateIconWarn
stateIconSupersededByChild = true
break
}
}
if !stateIconSupersededByChild {
dc.StateIcon = dockerContainerStateToStateIcon(dc.State)
}
dockerContainers = append(dockerContainers, dc)
}
return dockerContainers, nil
}
func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string {
if v := container.Labels.getOrDefault(dockerContainerLabelTitle, ""); v != "" {
return v
}
return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/")
}
func groupDockerContainerChildren(
containers []dockerContainerJsonResponse,
hideByDefault bool,
) (
[]dockerContainerJsonResponse,
map[string][]dockerContainerJsonResponse,
) {
parents := make([]dockerContainerJsonResponse, 0, len(containers))
children := make(map[string][]dockerContainerJsonResponse)
for i := range containers {
container := &containers[i]
if isDockerContainerHidden(container, hideByDefault) {
continue
}
isParent := container.Labels.getOrDefault(dockerContainerLabelID, "") != ""
parent := container.Labels.getOrDefault(dockerContainerLabelParent, "")
if !isParent && parent != "" {
children[parent] = append(children[parent], *container)
} else {
parents = append(parents, *container)
}
}
return parents, children
}
func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefault bool) bool {
if v := container.Labels.getOrDefault(dockerContainerLabelHide, ""); v != "" {
return stringToBool(v)
}
return hideByDefault
}
func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) {
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
},
}
request, err := http.NewRequest("GET", "http://docker/containers/json?all=true", nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("sending request to socket: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 response status: %s", response.Status)
}
var containers []dockerContainerJsonResponse
if err := json.NewDecoder(response.Body).Decode(&containers); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return containers, nil
}

View file

@ -0,0 +1,152 @@
package glance
import (
"context"
"errors"
"fmt"
"html"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"time"
)
var extensionWidgetTemplate = mustParseTemplate("extension.html", "widget-base.html")
type extensionWidget struct {
widgetBase `yaml:",inline"`
URL string `yaml:"url"`
FallbackContentType string `yaml:"fallback-content-type"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
Extension extension `yaml:"-"`
cachedHTML template.HTML `yaml:"-"`
}
func (widget *extensionWidget) initialize() error {
widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
if widget.URL == "" {
return errors.New("URL is required")
}
if _, err := url.Parse(widget.URL); err != nil {
return fmt.Errorf("parsing URL: %v", err)
}
return nil
}
func (widget *extensionWidget) update(ctx context.Context) {
extension, err := fetchExtension(extensionRequestOptions{
URL: widget.URL,
FallbackContentType: widget.FallbackContentType,
Parameters: widget.Parameters,
AllowHtml: widget.AllowHtml,
})
widget.canContinueUpdateAfterHandlingErr(err)
widget.Extension = extension
if extension.Title != "" {
widget.Title = extension.Title
}
widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate)
}
func (widget *extensionWidget) Render() template.HTML {
return widget.cachedHTML
}
type extensionType int
const (
extensionContentHTML extensionType = iota
extensionContentUnknown = iota
)
var extensionStringToType = map[string]extensionType{
"html": extensionContentHTML,
}
const (
extensionHeaderTitle = "Widget-Title"
extensionHeaderContentType = "Widget-Content-Type"
)
type extensionRequestOptions struct {
URL string `yaml:"url"`
FallbackContentType string `yaml:"fallback-content-type"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
}
type extension struct {
Title string
Content template.HTML
}
func convertExtensionContent(options extensionRequestOptions, content []byte, contentType extensionType) template.HTML {
switch contentType {
case extensionContentHTML:
if options.AllowHtml {
return template.HTML(content)
}
fallthrough
default:
return template.HTML(html.EscapeString(string(content)))
}
}
func fetchExtension(options extensionRequestOptions) (extension, error) {
request, _ := http.NewRequest("GET", options.URL, nil)
query := url.Values{}
for key, value := range options.Parameters {
query.Set(key, value)
}
request.URL.RawQuery = query.Encode()
response, err := http.DefaultClient.Do(request)
if err != nil {
slog.Error("Failed fetching extension", "url", options.URL, "error", err)
return extension{}, fmt.Errorf("%w: request failed: %w", errNoContent, err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
slog.Error("Failed reading response body of extension", "url", options.URL, "error", err)
return extension{}, fmt.Errorf("%w: could not read body: %w", errNoContent, err)
}
extension := extension{}
if response.Header.Get(extensionHeaderTitle) == "" {
extension.Title = "Extension"
} else {
extension.Title = response.Header.Get(extensionHeaderTitle)
}
contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)]
if !ok {
contentType, ok = extensionStringToType[options.FallbackContentType]
if !ok {
contentType = extensionContentUnknown
}
}
extension.Content = convertExtensionContent(options, body, contentType)
return extension, nil
}

View file

@ -0,0 +1,52 @@
package glance
import (
"context"
"errors"
"html/template"
"time"
)
var groupWidgetTemplate = mustParseTemplate("group.html", "widget-base.html")
type groupWidget struct {
widgetBase `yaml:",inline"`
containerWidgetBase `yaml:",inline"`
}
func (widget *groupWidget) initialize() error {
widget.withError(nil)
widget.HideHeader = true
for i := range widget.Widgets {
widget.Widgets[i].setHideHeader(true)
if widget.Widgets[i].GetType() == "group" {
return errors.New("nested groups are not supported")
} else if widget.Widgets[i].GetType() == "split-column" {
return errors.New("split columns inside of groups are not supported")
}
}
if err := widget.containerWidgetBase._initializeWidgets(); err != nil {
return err
}
return nil
}
func (widget *groupWidget) update(ctx context.Context) {
widget.containerWidgetBase._update(ctx)
}
func (widget *groupWidget) setProviders(providers *widgetProviders) {
widget.containerWidgetBase._setProviders(providers)
}
func (widget *groupWidget) requiresUpdate(now *time.Time) bool {
return widget.containerWidgetBase._requiresUpdate(now)
}
func (widget *groupWidget) Render() template.HTML {
return widget.renderTemplate(widget, groupWidgetTemplate)
}

View file

@ -0,0 +1,152 @@
package glance
import (
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
type hackerNewsWidget struct {
widgetBase `yaml:",inline"`
Posts forumPostList `yaml:"-"`
Limit int `yaml:"limit"`
SortBy string `yaml:"sort-by"`
ExtraSortBy string `yaml:"extra-sort-by"`
CollapseAfter int `yaml:"collapse-after"`
CommentsUrlTemplate string `yaml:"comments-url-template"`
ShowThumbnails bool `yaml:"-"`
}
func (widget *hackerNewsWidget) initialize() error {
widget.
withTitle("Hacker News").
withTitleURL("https://news.ycombinator.com/").
withCacheDuration(30 * time.Minute)
if widget.Limit <= 0 {
widget.Limit = 15
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
widget.SortBy = "top"
}
return nil
}
func (widget *hackerNewsWidget) update(ctx context.Context) {
posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.ExtraSortBy == "engagement" {
posts.calculateEngagement()
posts.sortByEngagement()
}
if widget.Limit < len(posts) {
posts = posts[:widget.Limit]
}
widget.Posts = posts
}
func (widget *hackerNewsWidget) Render() template.HTML {
return widget.renderTemplate(widget, forumPostsTemplate)
}
type hackerNewsPostResponseJson struct {
Id int `json:"id"`
Score int `json:"score"`
Title string `json:"title"`
TargetUrl string `json:"url,omitempty"`
CommentCount int `json:"descendants"`
TimePosted int64 `json:"time"`
}
func fetchHackerNewsPostIds(sort string) ([]int, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
response, err := decodeJsonFromRequest[[]int](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent)
}
return response, nil
}
func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) {
requests := make([]*http.Request, len(postIds))
for i, id := range postIds {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
requests[i] = request
}
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient)
job := newJob(task, requests).withWorkers(30)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
posts := make(forumPostList, 0, len(postIds))
for i := range results {
if errs[i] != nil {
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
continue
}
var commentsUrl string
if commentsUrlTemplate == "" {
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
} else {
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
}
posts = append(posts, forumPost{
Title: results[i].Title,
DiscussionUrl: commentsUrl,
TargetUrl: results[i].TargetUrl,
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
CommentCount: results[i].CommentCount,
Score: results[i].Score,
TimePosted: time.Unix(results[i].TimePosted, 0),
})
}
if len(posts) == 0 {
return nil, errNoContent
}
if len(posts) != len(postIds) {
return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent)
}
return posts, nil
}
func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) {
postIds, err := fetchHackerNewsPostIds(sort)
if err != nil {
return nil, err
}
if len(postIds) > limit {
postIds = postIds[:limit]
}
return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
}

View file

@ -1,20 +1,20 @@
package widget
package glance
import (
"html/template"
)
type HTML struct {
type htmlWidget struct {
widgetBase `yaml:",inline"`
Source template.HTML `yaml:"source"`
}
func (widget *HTML) Initialize() error {
func (widget *htmlWidget) initialize() error {
widget.withTitle("").withError(nil)
return nil
}
func (widget *HTML) Render() template.HTML {
func (widget *htmlWidget) Render() template.HTML {
return widget.Source
}

View file

@ -1,32 +1,30 @@
package widget
package glance
import (
"errors"
"fmt"
"html/template"
"net/url"
"github.com/glanceapp/glance/internal/assets"
)
type IFrame struct {
var iframeWidgetTemplate = mustParseTemplate("iframe.html", "widget-base.html")
type iframeWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
Source string `yaml:"source"`
Height int `yaml:"height"`
}
func (widget *IFrame) Initialize() error {
func (widget *iframeWidget) initialize() error {
widget.withTitle("IFrame").withError(nil)
if widget.Source == "" {
return errors.New("missing source for iframe")
return errors.New("source is required")
}
_, err := url.Parse(widget.Source)
if err != nil {
return fmt.Errorf("invalid source for iframe: %v", err)
if _, err := url.Parse(widget.Source); err != nil {
return fmt.Errorf("parsing URL: %v", err)
}
if widget.Height == 50 {
@ -35,11 +33,11 @@ func (widget *IFrame) Initialize() error {
widget.Height = 50
}
widget.cachedHTML = widget.render(widget, assets.IFrameTemplate)
widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate)
return nil
}
func (widget *IFrame) Render() template.HTML {
func (widget *iframeWidget) Render() template.HTML {
return widget.cachedHTML
}

View file

@ -0,0 +1,144 @@
package glance
import (
"context"
"html/template"
"net/http"
"strings"
"time"
)
type lobstersWidget struct {
widgetBase `yaml:",inline"`
Posts forumPostList `yaml:"-"`
InstanceURL string `yaml:"instance-url"`
CustomURL string `yaml:"custom-url"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
SortBy string `yaml:"sort-by"`
Tags []string `yaml:"tags"`
ShowThumbnails bool `yaml:"-"`
}
func (widget *lobstersWidget) initialize() error {
widget.withTitle("Lobsters").withCacheDuration(time.Hour)
if widget.InstanceURL == "" {
widget.withTitleURL("https://lobste.rs")
} else {
widget.withTitleURL(widget.InstanceURL)
}
if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
widget.SortBy = "hot"
}
if widget.Limit <= 0 {
widget.Limit = 15
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
return nil
}
func (widget *lobstersWidget) update(ctx context.Context) {
posts, err := fetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.Limit < len(posts) {
posts = posts[:widget.Limit]
}
widget.Posts = posts
}
func (widget *lobstersWidget) Render() template.HTML {
return widget.renderTemplate(widget, forumPostsTemplate)
}
type lobstersPostResponseJson struct {
CreatedAt string `json:"created_at"`
Title string `json:"title"`
URL string `json:"url"`
Score int `json:"score"`
CommentCount int `json:"comment_count"`
CommentsURL string `json:"comments_url"`
Tags []string `json:"tags"`
}
type lobstersFeedResponseJson []lobstersPostResponseJson
func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) {
request, err := http.NewRequest("GET", feedUrl, nil)
if err != nil {
return nil, err
}
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultHTTPClient, request)
if err != nil {
return nil, err
}
posts := make(forumPostList, 0, len(feed))
for i := range feed {
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
posts = append(posts, forumPost{
Title: feed[i].Title,
DiscussionUrl: feed[i].CommentsURL,
TargetUrl: feed[i].URL,
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
CommentCount: feed[i].CommentCount,
Score: feed[i].Score,
TimePosted: createdAt,
Tags: feed[i].Tags,
})
}
if len(posts) == 0 {
return nil, errNoContent
}
return posts, nil
}
func fetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (forumPostList, error) {
var feedUrl string
if customURL != "" {
feedUrl = customURL
} else {
if instanceURL != "" {
instanceURL = strings.TrimRight(instanceURL, "/") + "/"
} else {
instanceURL = "https://lobste.rs/"
}
if sortBy == "hot" {
sortBy = "hottest"
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = instanceURL + sortBy + ".json"
} else {
tags := strings.Join(tags, ",")
feedUrl = instanceURL + "t/" + tags + ".json"
}
}
posts, err := fetchLobstersPostsFromFeed(feedUrl)
if err != nil {
return nil, err
}
return posts, nil
}

View file

@ -0,0 +1,205 @@
package glance
import (
"context"
"fmt"
"html/template"
"log/slog"
"math"
"net/http"
"sort"
"time"
)
var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html")
type marketsWidget struct {
widgetBase `yaml:",inline"`
StocksRequests []marketRequest `yaml:"stocks"`
MarketRequests []marketRequest `yaml:"markets"`
Sort string `yaml:"sort-by"`
Markets marketList `yaml:"-"`
}
func (widget *marketsWidget) initialize() error {
widget.withTitle("Markets").withCacheDuration(time.Hour)
// legacy support, remove in v0.10.0
if len(widget.MarketRequests) == 0 {
widget.MarketRequests = widget.StocksRequests
}
return nil
}
func (widget *marketsWidget) update(ctx context.Context) {
markets, err := fetchMarketsDataFromYahoo(widget.MarketRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.Sort == "absolute-change" {
markets.sortByAbsChange()
}
if widget.Sort == "change" {
markets.sortByChange()
}
widget.Markets = markets
}
func (widget *marketsWidget) Render() template.HTML {
return widget.renderTemplate(widget, marketsWidgetTemplate)
}
type marketRequest struct {
Name string `yaml:"name"`
Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"`
}
type market struct {
marketRequest
Currency string
Price float64
PercentChange float64
SvgChartPoints string
}
type marketList []market
func (t marketList) sortByAbsChange() {
sort.Slice(t, func(i, j int) bool {
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
})
}
func (t marketList) sortByChange() {
sort.Slice(t, func(i, j int) bool {
return t[i].PercentChange > t[j].PercentChange
})
}
type marketResponseJson struct {
Chart struct {
Result []struct {
Meta struct {
Currency string `json:"currency"`
Symbol string `json:"symbol"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
ChartPreviousClose float64 `json:"chartPreviousClose"`
} `json:"meta"`
Indicators struct {
Quote []struct {
Close []float64 `json:"close,omitempty"`
} `json:"quote"`
} `json:"indicators"`
} `json:"result"`
} `json:"chart"`
}
// TODO: allow changing chart time frame
const marketChartDays = 21
func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, error) {
requests := make([]*http.Request, 0, len(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)
requests = append(requests, request)
}
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultHTTPClient), requests)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", errNoContent, err)
}
markets := make(marketList, 0, len(responses))
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
continue
}
response := responses[i]
if len(response.Chart.Result) == 0 {
failed++
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
continue
}
prices := response.Chart.Result[0].Indicators.Quote[0].Close
if len(prices) > marketChartDays {
prices = prices[len(prices)-marketChartDays:]
}
previous := response.Chart.Result[0].Meta.RegularMarketPrice
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
previous = prices[len(prices)-2]
}
points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
if !exists {
currency = response.Chart.Result[0].Meta.Currency
}
markets = append(markets, market{
marketRequest: marketRequests[i],
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice,
previous,
),
SvgChartPoints: points,
})
}
if len(markets) == 0 {
return nil, errNoContent
}
if failed > 0 {
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", errPartialContent, failed)
}
return markets, nil
}
var currencyToSymbol = map[string]string{
"USD": "$",
"EUR": "€",
"JPY": "¥",
"CAD": "C$",
"AUD": "A$",
"GBP": "£",
"CHF": "Fr",
"NZD": "N$",
"INR": "₹",
"BRL": "R$",
"RUB": "₽",
"TRY": "₺",
"ZAR": "R",
"CNY": "¥",
"KRW": "₩",
"HKD": "HK$",
"SGD": "S$",
"SEK": "kr",
"NOK": "kr",
"DKK": "kr",
"PLN": "zł",
"PHP": "₱",
}

Some files were not shown because too many files have changed in this diff Show more