Merge branch 'dev' into feature/qbittorrent-widget

pull/543/head
Svilen Markov 2025-08-26 02:29:20 +07:00 committed by GitHub
commit 76e1283d0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
87 changed files with 4408 additions and 703 deletions

@ -1 +1,2 @@
github: [glanceapp]
patreon: glanceapp

@ -1,4 +1,4 @@
FROM golang:1.23.6-alpine3.21 AS builder
FROM golang:1.24.4-alpine3.21 AS builder
WORKDIR /app
COPY . /app
@ -9,8 +9,5 @@ FROM alpine:3.21
WORKDIR /app
COPY --from=builder /app/glance .
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
CMD wget --spider -q http://localhost:8080/api/healthz
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]

@ -3,8 +3,5 @@ FROM alpine:3.21
WORKDIR /app
COPY glance .
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
CMD wget --spider -q http://localhost:8080/api/healthz
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]

@ -1,6 +1,7 @@
<p align="center"><em>What if you could see everything at a...</em></p>
<h1 align="center">Glance</h1>
<p align="center"><a href="#installation">Install</a><a href="docs/configuration.md">Configuration</a><a href="docs/preconfigured-pages.md">Preconfigured pages</a><a href="docs/themes.md">Themes</a><a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a></p>
<p align="center"><a href="#installation">Install</a><a href="docs/configuration.md#configuring-glance">Configuration</a><a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a><a href="https://github.com/sponsors/glanceapp">Sponsor</a></p>
<p align="center"><a href="https://github.com/glanceapp/community-widgets">Community widgets</a><a href="docs/preconfigured-pages.md">Preconfigured pages</a><a href="docs/themes.md">Themes</a></p>
![](docs/images/readme-main-image.png)
@ -16,7 +17,7 @@
* Docker containers status
* Server stats
* Custom widgets
* [and many more...](docs/configuration.md)
* [and many more...](docs/configuration.md#configuring-glance)
### Fast and lightweight
* Low memory usage
@ -45,8 +46,7 @@ Easily create your own theme by tweaking a few numbers or choose from one of the
<br>
## Configuration
Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](docs/configuration.md).
Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](docs/configuration.md#configuring-glance).
<details>
<summary><strong>Preview example configuration file</strong></summary>
<br>
@ -79,7 +79,7 @@ pages:
channels:
- theprimeagen
- j_blow
- piratesoftware
- giantwaffle
- cohhcarnage
- christitustech
- EJ_SA
@ -194,8 +194,10 @@ services:
glance:
container_name: glance
image: glanceapp/glance
restart: unless-stopped
volumes:
- ./config:/app/config
- /etc/localtime:/etc/localtime:ro
ports:
- 8080:8080
```

@ -6,6 +6,9 @@
- [Environment variables](#environment-variables)
- [Other ways of providing tokens/passwords/secrets](#other-ways-of-providing-tokenspasswordssecrets)
- [Including other config files](#including-other-config-files)
- [Icons](#icons)
- [Config schema](#config-schema)
- [Authentication](#authentication)
- [Server](#server)
- [Document](#document)
- [Branding](#branding)
@ -24,6 +27,7 @@
- [Custom API](#custom-api)
- [Extension](#extension)
- [Weather](#weather)
- [Todo](#todo)
- [Monitor](#monitor)
- [Releases](#releases)
- [Docker Containers](#docker-containers)
@ -182,6 +186,95 @@ docker run --rm -v ./glance.yml:/app/config/glance.yml glanceapp/glance config:p
This assumes that the config you want to print is in your current working directory and is named `glance.yml`.
## Icons
For widgets which provide you with the ability to specify icons such as the monitor, bookmarks, docker containers, etc, you can use the `icon` property to specify a URL to an image or use icon names from multiple libraries via prefixes:
```yml
icon: si:immich # si for Simple icons https://simpleicons.org/
icon: sh:immich # sh for selfh.st icons https://selfh.st/icons/
icon: di:immich # di for Dashboard icons https://github.com/homarr-labs/dashboard-icons
icon: mdi:camera # mdi for Material Design icons https://pictogrammers.com/library/mdi/
```
> [!NOTE]
>
> The 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.
Icons from the Simple icons library as well as Material Design icons will automatically invert their color to match your light or dark theme, however you may want to enable this manually for other icons. To do this, you can use the `auto-invert` prefix:
```yaml
icon: auto-invert https://example.com/path/to/icon.png # with a URL
icon: auto-invert sh:glance-dark # with a selfh.st icon
```
This expects the icon to be black and will automatically invert it to white when using a dark theme.
## Config schema
For property descriptions, validation and autocompletion of the config within your IDE, @not-first has kindly created a [schema](https://github.com/not-first/glance-schema). Massive thanks to them for this, go check it out and give them a star!
## Authentication
To make sure that only you and the people you want to share your dashboard with have access to it, you can set up authentication via username and password. This is done through a top level `auth` property. Example:
```yaml
auth:
secret-key: # this must be set to a random value generated using the secret:make CLI command
users:
admin:
password: 123456
svilen:
password: 123456
```
To generate a secret key, run the following command:
```sh
./glance secret:make
```
Or with Docker:
```sh
docker run --rm glanceapp/glance secret:make
```
### Using hashed passwords
If you do not want to store plain passwords in your config file or in environment variables, you can hash your password and provide its hash instead:
```sh
./glance password:hash mysecretpassword
```
Or with Docker:
```sh
docker run --rm glanceapp/glance password:hash mysecretpassword
```
Then, in your config file use the `password-hash` property instead of `password`:
```yaml
auth:
secret-key: # this must be set to a random value generated using the secret:make CLI command
users:
admin:
password-hash: $2a$10$o6SXqiccI3DDP2dN4ADumuOeIHET6Q4bUMYZD6rT2Aqt6XQ3DyO.6
```
### Preventing brute-force attacks
Glance will automatically block IP addresses of users who fail to authenticate 5 times in a row in the span of 5 minutes. In order for this feature to work correctly, Glance must know the real IP address of requests. If you're using a reverse proxy such as nginx, Traefik, NPM, etc, you must set the `proxied` property in the `server` configuration to `true`:
```yaml
server:
proxied: true
```
When set to `true`, Glance will use the `X-Forwarded-For` header to determine the original IP address of the request, so make sure that your reverse proxy is correctly configured to send that header.
## Server
Server configuration is done through a top level `server` property. Example:
@ -197,6 +290,7 @@ server:
| ---- | ---- | -------- | ------- |
| host | string | no | |
| port | number | no | 8080 |
| proxied | boolean | no | false |
| base-url | string | no | |
| assets-path | string | no | |
@ -206,6 +300,9 @@ The address which the server will listen on. Setting it to `localhost` means tha
#### `port`
A number between 1 and 65,535, so long as that port isn't already used by anything else.
#### `proxied`
Set to `true` if you're using a reverse proxy in front of Glance. This will make Glance use the `X-Forwarded-*` headers to determine the original request details.
#### `base-url`
The base URL that Glance is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Glance under a directory. If that's the case then you can set this value to `/glance` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path.
@ -268,6 +365,9 @@ branding:
<p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
logo-url: /assets/logo.png
favicon-url: /assets/logo.png
app-name: "My Dashboard"
app-icon-url: "/assets/app-icon.png"
app-background-color: "#151519"
```
### Properties
@ -279,6 +379,9 @@ branding:
| logo-text | string | no | G |
| logo-url | string | no | |
| favicon-url | string | no | |
| app-name | string | no | Glance |
| app-icon-url | string | no | Glance's default icon |
| app-background-color | string | no | Glance's default background color |
#### `hide-footer`
Hides the footer when set to `true`.
@ -295,6 +398,15 @@ Specify a URL to a custom image to use instead of the "G" found in the navigatio
#### `favicon-url`
Specify a URL to a custom image to use for the favicon.
#### `app-name`
Specify the name of the web app shown in browser tab and PWA.
#### `app-icon-url`
Specify URL for PWA and browser tab icon (512x512 PNG).
#### `app-background-color`
Specify background color for PWA. Must be a valid CSS color.
## Theme
Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.
@ -302,9 +414,24 @@ Example:
```yaml
theme:
# This will be the default theme
background-color: 100 20 10
primary-color: 40 90 40
contrast-multiplier: 1.1
disable-picker: false
presets:
gruvbox-dark:
background-color: 0 0 16
primary-color: 43 59 81
positive-color: 61 66 44
negative-color: 6 96 59
zebra:
light: true
background-color: 0 0 95
primary-color: 0 0 10
negative-color: 0 90 50
```
### Available themes
@ -321,6 +448,8 @@ If you don't want to spend time configuring your own theme, there are [several a
| contrast-multiplier | number | no | 1 |
| text-saturation-multiplier | number | no | 1 |
| custom-css-file | string | no | |
| disable-picker | bool | false | |
| presets | object | no | |
#### `light`
Whether the scheme is light or dark. This does not change the background color, it inverts the text colors so that they look appropriately on a light background.
@ -365,6 +494,33 @@ theme:
>
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
#### `disable-picker`
When set to `true` hides the theme picker and disables the abiltity to switch between themes. All users who previously picked a non-default theme will be switched over to the default theme.
#### `presets`
Define additional theme presets that can be selected from the theme picker on the page. For each preset, you can specify the same properties as for the default theme, such as `background-color`, `primary-color`, `positive-color`, `negative-color`, `contrast-multiplier`, etc., except for the `custom-css-file` property.
Example:
```yaml
theme:
presets:
my-custom-dark-theme:
background-color: 229 19 23
contrast-multiplier: 1.2
primary-color: 222 74 74
positive-color: 96 44 68
negative-color: 359 68 71
my-custom-light-theme:
light: true
background-color: 220 23 95
contrast-multiplier: 1.1
primary-color: 220 91 54
positive-color: 109 58 40
negative-color: 347 87 44
```
To override the default dark and light themes, use the key names `default-dark` and `default-light`.
## Pages & Columns
![illustration of pages and columns](images/pages-and-columns-illustration.png)
@ -395,8 +551,8 @@ pages:
| desktop-navigation-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 |
| head-widgets | array | no | |
| columns | array | yes | |
#### `name`
@ -427,9 +583,6 @@ 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.
@ -437,6 +590,43 @@ Preview:
![](images/mobile-header-preview.png)
#### `head-widgets`
Head widgets will be shown at the top of the page, above the columns, and take up the combined width of all columns. You can specify any widget, though some will look better than others, such as the markets, RSS feed with `horizontal-cards` style, and videos widgets. Example:
![](images/head-widgets-preview.png)
```yaml
pages:
- name: Home
head-widgets:
- type: markets
hide-header: true
markets:
- symbol: SPY
name: S&P 500
- symbol: BTC-USD
name: Bitcoin
- symbol: NVDA
name: NVIDIA
- symbol: AAPL
name: Apple
- symbol: MSFT
name: Microsoft
columns:
- size: small
widgets:
- type: calendar
- size: full
widgets:
- type: hacker-news
- size: small
widgets:
- type: weather
location: London, United Kingdom
```
### Columns
Columns are defined for each page using a `columns` property. There are two types of columns - `full` and `small`, which refers to their width. A small column takes up a fixed amount of width (300px) and a full column takes up the all of the remaining width. You can have up to 3 columns per page and you must have either 1 or 2 full columns. Example:
@ -515,6 +705,7 @@ pages:
| type | string | yes |
| title | string | no |
| title-url | string | no |
| hide-header | boolean | no | false |
| cache | string | no |
| css-class | string | no |
@ -527,6 +718,13 @@ The title of the widget. If left blank it will be defined by the widget.
#### `title-url`
The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available).
#### `hide-header`
When set to `true`, the header (title) of the widget will be hidden. You cannot hide the header of the group widget.
> [!NOTE]
>
> If a widget fails to update, a red dot or circle is shown next to the title of that widget indicating that the it is not working. You will not be able to see this if you hide the header.
#### `cache`
How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
@ -672,6 +870,7 @@ Preview:
| playlists | array | no | |
| limit | integer | no | 25 |
| style | string | no | horizontal-cards |
| sort-by | string | no | posted |
| collapse-after | integer | no | 7 |
| collapse-after-rows | integer | no | 4 |
| include-shorts | boolean | no | false |
@ -699,9 +898,18 @@ A list of playlist IDs:
- PL8mG-RkN2uTxTK4m_Vl2dYR9yE41kRdBg
```
The playlist ID can be found in its link which is in the form of
```
https://www.youtube.com...&list={ID}&...
```
##### `limit`
The maximum number of videos to show.
##### `sort-by`
Used to specify the order in which the videos should get returned. Possible values are `none`, `updated`, and `posted`.
Default value is `posted`.
##### `collapse-after`
Specify the number of videos to show when using the `vertical-list` style before the "SHOW MORE" button appears.
@ -828,7 +1036,10 @@ Display a list of posts from a specific subreddit.
> [!WARNING]
>
> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 response. As a workaround you can route the traffic from Glance through a VPN or your own HTTP proxy using the `request-url-template` property.
> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403
> response. As a workaround you can either [register an app on Reddit](https://ssl.reddit.com/prefs/apps/) and use the
> generated ID and secret in the widget configuration to authenticate your requests (see `app-auth` property), use a proxy
> (see `proxy` property) or route the traffic from Glance through a VPN.
Example:
@ -853,6 +1064,7 @@ Example:
| top-period | string | no | day |
| search | string | no | |
| extra-sort-by | string | no | |
| app-auth | object | no | |
##### `subreddit`
The subreddit for which to fetch the posts from.
@ -960,6 +1172,19 @@ Can be used to specify an additional sort which will be applied on top of the al
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
##### `app-auth`
```yaml
widgets:
- type: reddit
subreddit: technology
app-auth:
name: ${REDDIT_APP_NAME}
id: ${REDDIT_APP_CLIENT_ID}
secret: ${REDDIT_APP_SECRET}
```
To register an app on Reddit, go to [this page](https://ssl.reddit.com/prefs/apps/).
### Search Widget
Display a search bar that can be used to search for specific terms on various search engines.
@ -997,6 +1222,7 @@ Preview:
| search-engine | string | no | duckduckgo |
| new-tab | boolean | no | false |
| autofocus | boolean | no | false |
| target | string | no | _blank |
| placeholder | string | no | Type here to search… |
| bangs | array | no | |
@ -1018,6 +1244,9 @@ When set to `true`, swaps the shortcuts for showing results in the same or new t
##### `autofocus`
When set to `true`, automatically focuses the search input on page load.
##### `target`
The target to use when opening the search results in a new tab. Possible values are `_blank`, `_self`, `_parent` and `_top`.
##### `placeholder`
When set, modifies the text displayed in the input field before typing.
@ -1333,7 +1562,7 @@ Examples:
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| url | string | yes | |
| url | string | no | |
| headers | key (string) & value (string) | no | |
| method | string | no | GET |
| body-type | string | no | json |
@ -1342,6 +1571,7 @@ Examples:
| allow-insecure | boolean | no | false |
| skip-json-validation | boolean | no | false |
| template | string | yes | |
| options | map | no | |
| parameters | key (string) & value (string|array) | no | |
| subrequests | map of requests | no | |
@ -1394,6 +1624,95 @@ When set to `true`, skips the JSON validation step. This is useful when the API
##### `template`
The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md).
##### `options`
A map of options that will be passed to the template and can be used to modify the behavior of the widget.
<details>
<summary>View examples</summary>
<br>
Instead of defining options within the template and having to modify the template itself like such:
```yaml
- type: custom-api
template: |
{{ /* User configurable options */ }}
{{ $collapseAfter := 5 }}
{{ $showThumbnails := true }}
{{ $showFlairs := false }}
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ $collapseAfter }}">
{{ if $showThumbnails }}
<li>
<img src="{{ .JSON.String "thumbnail" }}" alt="thumbnail" />
</li>
{{ end }}
{{ if $showFlairs }}
<li>
<span class="flair">{{ .JSON.String "flair" }}</span>
</li>
{{ end }}
</ul>
```
You can use the `options` property to retrieve and define default values for these variables:
```yaml
- type: custom-api
template: |
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .Options.IntOr "collapse-after" 5 }}">
{{ if (.Options.BoolOr "show-thumbnails" true) }}
<li>
<img src="{{ .JSON.String "thumbnail" }}" alt="thumbnail" />
</li>
{{ end }}
{{ if (.Options.BoolOr "show-flairs" false) }}
<li>
<span class="flair">{{ .JSON.String "flair" }}</span>
</li>
{{ end }}
</ul>
```
This way, you can optionally specify the `collapse-after`, `show-thumbnails` and `show-flairs` properties in the widget configuration:
```yaml
- type: custom-api
options:
collapse-after: 5
show-thumbnails: true
show-flairs: false
```
Which means you can reuse the same template for multiple widgets with different options:
```yaml
# Note that `custom-widgets` isn't a special property, it's just used to define the reusable "anchor", see https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/
custom-widgets:
- &example-widget
type: custom-api
template: |
{{ .Options.StringOr "custom-option" "not defined" }}
pages:
- name: Home
columns:
- size: full
widgets:
- <<: *example-widget
options:
custom-option: "Value 1"
- <<: *example-widget
options:
custom-option: "Value 2"
```
Currently, the available methods on the `.Options` object are: `StringOr`, `IntOr`, `BoolOr` and `FloatOr`.
</details>
##### `parameters`
A list of keys and values that will be sent to the custom-api as query paramters.
@ -1549,6 +1868,44 @@ Otherwise, if set to `false` (which is the default) it'll be displayed as:
Greenville, United States
```
### Todo
A simple to-do list that allows you to add, edit and delete tasks. The tasks are stored in the browser's local storage.
Example:
```yaml
- type: to-do
```
Preview:
![](images/todo-widget-preview.png)
To reorder tasks, drag and drop them by grabbing the top side of the task:
![](images/reorder-todo-tasks-prevew.gif)
To delete a task, hover over it and click on the trash icon.
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| id | string | no | |
##### `id`
The ID of the todo list. If you want to have multiple todo lists, you must specify a different ID for each one. The ID is used to store the tasks in the browser's local storage. This means that if you have multiple todo lists with the same ID, they will share the same tasks.
#### Keyboard shortcuts
| Keys | Action | Condition |
| ---- | ------ | --------- |
| <kbd>Enter</kbd> | Add a task to the bottom of the list | When the "Add a task" field is focused |
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Add a task to the top of the list | When the "Add a task" field is focused |
| <kbd>Down Arrow</kbd> | Focus the last task that was added | When the "Add a task" field is focused |
| <kbd>Escape</kbd> | Focus the "Add a task" field | When a task is focused |
### Monitor
Display a list of sites and whether they are reachable (online) or not. This is determined by sending a GET request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
@ -1574,7 +1931,6 @@ Example:
- title: Vaultwarden
url: https://vault.yourdomain.com
icon: /assets/vaultwarden-logo.png
```
Preview:
@ -1612,6 +1968,7 @@ Properties for each site:
| check-url | string | no | |
| error-url | string | no | |
| icon | string | no | |
| timeout | string | no | 3s |
| allow-insecure | boolean | no | false |
| same-tab | boolean | no | false |
| alt-status-codes | array | no | |
@ -1623,7 +1980,7 @@ The title used to indicate the site.
`url`
The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check.
The URL of the monitored service, which must be reachable by Glance, and will be used as the link to go to when clicking on the title. If `check-url` is not specified, this is used as the status check.
`check-url`
@ -1635,17 +1992,11 @@ If the monitored service returns an error, the user will be redirected here. If
`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 or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
See [Icons](#icons) for more information on how to specify icons.
```yaml
icon: si:jellyfin
icon: si:gitea
icon: si:adguard
```
`timeout`
> [!WARNING]
>
> 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.
How long to wait for a response from the server before considering it unreachable. The value is a string and must be a number followed by one of s, m, h, d. Example: `5s` for 5 seconds, `1m` for 1 minute, etc.
`allow-insecure`
@ -1670,7 +2021,7 @@ HTTP Basic Authentication credentials for protected sites.
```yaml
basic-auth:
usename: your-username
username: your-username
password: your-password
```
@ -1815,6 +2166,19 @@ Configuration of the containers is done via labels applied to each container:
glance.description: Movies & shows
```
Alternatively, you can also define the values within your `glance.yml` via the `containers` property, where the key is the container name and each value is the same as the labels but without the "glance." prefix:
```yaml
- type: docker-containers
containers:
container_name_1:
name: Container Name
description: Description of the container
url: https://container.domain.com
icon: si:container-icon
hide: false
```
For services with multiple containers you can specify a `glance.id` on the "main" container and `glance.parent` on each "child" container:
<details>
@ -1866,25 +2230,84 @@ If any of the child containers are down, their status will propagate up to the p
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| hide-by-default | boolean | no | false |
| format-container-names | boolean | no | false |
| sock-path | string | no | /var/run/docker.sock |
| category | string | no | |
| running-only | boolean | no | false |
##### `hide-by-default`
Whether to hide the containers by default. If set to `true` you'll have to manually add a `glance.hide: false` label to each container you want to display. By default all containers will be shown and if you want to hide a specific container you can add a `glance.hide: true` label.
##### `format-container-names`
When set to `true`, automatically converts container names such as `container_name_1` into `Container Name 1`.
##### `sock-path`
The path to the Docker socket.
The path to the Docker socket. This can also be a [remote socket](https://docs.docker.com/engine/daemon/remote-access/) or proxied socket using something like [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy).
###### `category`
Filter to only the containers which have this category specified via the `glance.category` label. Useful if you want to have multiple containers widgets, each showing a different set of containers.
<details>
<summary>View example</summary>
<br>
```yaml
services:
jellyfin:
image: jellyfin/jellyfin:latest
labels:
glance.name: Jellyfin
glance.icon: si:jellyfin
glance.url: https://jellyfin.domain.com
glance.category: media
gitea:
image: gitea/gitea:latest
labels:
glance.name: Gitea
glance.icon: si:gitea
glance.url: https://gitea.domain.com
glance.category: dev-tools
vaultwarden:
image: vaultwarden/server:latest
labels:
glance.name: Vaultwarden
glance.icon: si:vaultwarden
glance.url: https://vaultwarden.domain.com
glance.category: dev-tools
```
Then you can use the `category` property to filter the containers:
```yaml
- type: docker-containers
title: Dev tool containers
category: dev-tools
- type: docker-containers
title: Media containers
category: media
```
</details>
##### `running-only`
Whether to only show running containers. If set to `true` only containers that are currently running will be displayed. If set to `false` all containers will be displayed regardless of their state.
#### Labels
| Name | Description |
| ---- | ----------- |
| glance.name | The name displayed in the UI. If not specified, the name of the container will be used. |
| glance.icon | The icon displayed in the UI. Can be an external URL or an icon prefixed with si:, sh: or di: like with the bookmarks and monitor widgets |
| glance.icon | See [Icons](#icons) for more information on how to specify icons |
| glance.url | The URL that the user will be redirected to when clicking on the container. |
| glance.same-tab | Whether to open the link in the same or a new tab. Default is `false`. |
| glance.description | A short description displayed in the UI. Default is empty. |
| glance.hide | Whether to hide the container. If set to `true` the container will not be displayed. Defaults to `false`. |
| glance.id | The custom ID of the container. Used to group containers under a single parent. |
| glance.parent | The ID of the parent container. Used to group containers under a single parent. |
| glance.category | The category of the container. Used to filter containers by category. |
### DNS Stats
Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, or Technitium.
@ -1936,7 +2359,7 @@ Only required when using AdGuard Home. The username used to log into the admin d
##### `password`
Required when using AdGuard Home, where the password is the one used to log into the admin dashboard.
Also required when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`.
For Pi-hole version 6+, this field is required if you have set a password to log into Pi-hole. You can either use the password you use to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`.
##### `token`
Required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`.
@ -2006,7 +2429,7 @@ Whether to hide the swap usage.
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| cpu-temp-sensor | string | no | |
| hide-mointpoints-by-default | boolean | no | false |
| hide-mountpoints-by-default | boolean | no | false |
| mountpoints | map\[string\]object | no | |
###### `cpu-temp-sensor`
@ -2081,6 +2504,7 @@ Example:
pull-requests-limit: 5
issues-limit: 3
commits-limit: 3
exclude-draft-prs: true
```
Preview:
@ -2096,6 +2520,7 @@ Preview:
| pull-requests-limit | integer | no | 3 |
| issues-limit | integer | no | 3 |
| commits-limit | integer | no | -1 |
| exclude-draft-prs | boolean | no | false |
##### `repository`
The owner and repository name that will have their information displayed.
@ -2112,6 +2537,9 @@ The maximum number of latest open issues to show. Set to `-1` to not show any.
##### `commits-limit`
The maximum number of lastest commits to show from the default branch. Set to `-1` to not show any.
##### `exclude-draft-prs`
Wheter to exclude draft pull requests from the list. Set to `false` by default to include them.
### Bookmarks
Display a list of links which can be grouped.
@ -2192,17 +2620,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 or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
```yaml
icon: si:gmail
icon: si:youtube
icon: si:reddit
```
> [!WARNING]
>
> 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.
See [Icons](#icons) for more information on how to specify icons.
`same-tab`

@ -219,6 +219,38 @@ Output:
JSON response:
```json
{
"user": {
"id": 42,
"name": "Alice",
"active": true
}
}
```
To loop through each property of the object, you would use the following:
```html
{{ range $key, $value := .JSON.Entries "user" }}
<div>{{ $key }}: {{ $value.String "" }}</div>
{{ end }}
```
Output:
```html
<div>id: 42</div>
<div>name: Alice</div>
<div>active: true</div>
```
Each property in the object is exposed as a pair, with `$key` being a string and `$value` providing access to the value using the usual JSON methods.
<hr>
JSON response:
```json
{
"price": 100,
@ -238,7 +270,7 @@ Output:
<div>90</div>
```
Other operations include `add`, `mul`, and `div`.
Other operations include `add`, `mul`, `div` and `mod`.
<hr>
@ -358,6 +390,52 @@ Output:
<p>John</p>
```
<hr>
In some instances, you may need to make two consecutive API calls, where you use the result of the first call in the second call. To achieve this, you can make additional HTTP requests from within the template itself using the following syntax:
```yaml
- type: custom-api
url: https://api.example.com/get-id-of-something
template: |
{{ $theID := .JSON.String "id" }}
{{
$something := newRequest (concat "https://api.example.com/something/" $theID)
| withParameter "key" "value"
| withHeader "Authorization" "Bearer token"
| getResponse
}}
{{ $something.JSON.String "title" }}
```
Here, `$theID` gets retrieved from the result of the first API call and used in the second API call. The `newRequest` function creates a new request, and the `getResponse` function executes it. You can also use `withParameter` and `withHeader` to optionally add parameters and headers to the request.
If you need to make a request to a URL that requires dynamic parameters, you can omit the `url` property in the YAML and run the request entirely from within the template itself:
```yaml
- type: custom-api
title: Events from the last 24h
template: |
{{
$events := newRequest "https://api.example.com/events"
| withParameter "after" (offsetNow "-24h" | formatTime "rfc3339")
| getResponse
}}
{{ if eq $events.Response.StatusCode 200 }}
{{ range $events.JSON.Array "events" }}
<div>{{ .String "title" }}</div>
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
{{ end }}
{{ else }}
<p>Failed to fetch data: {{ $events.Response.Status }}</p>
{{ end }}
```
*Note that you need to manually check for the correct status code.*
## Functions
The following functions are available on the `JSON` object:
@ -368,6 +446,15 @@ The following functions are available on the `JSON` object:
- `Bool(key string) bool`: Returns the value of the key as a boolean.
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
- `Entries(key string)`: Returns an iterator that allows you to loop through each property of the object. Example: `{{ range $key, $value := .JSON.Entries "user" }}`. This will yield pairs of key and value, where `$key` is a string and `$value` is a `JSON` object.
The following functions are available on the `Options` object:
- `StringOr(key string, default string) string`: Returns the value of the key as a string, or the default value if the key does not exist.
- `IntOr(key string, default int) int`: Returns the value of the key as an integer, or the default value if the key does not exist.
- `FloatOr(key string, default float) float`: Returns the value of the key as a float, or the default value if the key does not exist.
- `BoolOr(key string, default bool) bool`: Returns the value of the key as a boolean, or the default value if the key does not exist.
- `JSON(key string) JSON`: Returns the value of the key as a stringified `JSON` object, or throws an error if the key does not exist.
The following helper functions provided by Glance are available:
@ -378,17 +465,21 @@ The following helper functions provided by Glance are available:
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
- `formatTime(layout string, s string) time.Time`: Formats a `time.Time` into a string. The layout uses the same format as `parseTime`.
- `parseLocalTime(layout string, s string) time.Time`: Same as the above, except it will automatically convert the time to the server's timezone and in the absence of a timezone, it will use the local timezone instead of UTC.
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
- `add(a, b float) float`: Adds two numbers.
- `sub(a, b float) float`: Subtracts two numbers.
- `mul(a, b float) float`: Multiplies two numbers.
- `div(a, b float) float`: Divides two numbers.
- `mod(a, b int) int`: Remainder after dividing a by b (a % b).
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
- `trimSpace(str string) string`: Trims whitespace from a string on both ends.
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string.
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
@ -396,17 +487,21 @@ The following helper functions provided by Glance are available:
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
- `concat(strings ...string) string`: Concatenates multiple strings together.
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
- `percentChange(current float, previous float) float`: Calculates the percentage change between two numbers.
- `startOfDay(t time.Time) time.Time`: Returns the start of the day for a given time.
- `endOfDay(t time.Time) time.Time`: Returns the end of the day for a given time.
The following helper functions provided by Go's `text/template` are available:
- `eq(a, b any) bool`: Compares two values for equality.
- `ne(a, b any) bool`: Compares two values for inequality.
- `lt(a, b any) bool`: Compares two values for less than.
- `lte(a, b any) bool`: Compares two values for less than or equal to.
- `le(a, b any) bool`: Compares two values for less than or equal to.
- `gt(a, b any) bool`: Compares two values for greater than.
- `gte(a, b any) bool`: Compares two values for greater than or equal to.
- `and(a, b bool) bool`: Returns true if both values are true.
- `or(a, b bool) bool`: Returns true if either value is true.
- `ge(a, b any) bool`: Compares two values for greater than or equal to.
- `and(args ...bool) bool`: Returns true if **all** arguments are true; accepts two or more boolean values.
- `or(args ...bool) bool`: Returns true if **any** argument is true; accepts two or more boolean values.
- `not(a bool) bool`: Returns the opposite of the value.
- `index(a any, b int) any`: Returns the value at the specified index of an array.
- `len(a any) int`: Returns the length of an array.

@ -27,7 +27,7 @@ pages:
channels:
- theprimeagen
- j_blow
- piratesoftware
- giantwaffle
- cohhcarnage
- christitustech
- EJ_SA

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

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

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

@ -1,24 +1,25 @@
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/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/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
@ -32,8 +33,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@ -45,10 +48,10 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
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/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@ -58,6 +61,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -72,8 +79,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
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=
@ -95,8 +104,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -115,8 +124,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
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=

@ -0,0 +1,343 @@
package glance
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
mathrand "math/rand/v2"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const AUTH_SESSION_COOKIE_NAME = "session_token"
const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute
const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
const AUTH_TOKEN_SECRET_LENGTH = 32
const AUTH_USERNAME_HASH_LENGTH = 32
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
const AUTH_TIMESTAMP_LENGTH = 4 // uint32
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
// How long the token will be valid for
const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
// How long the token has left before it should be regenerated
const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
type doWhenUnauthorized int
const (
redirectToLogin doWhenUnauthorized = iota
showUnauthorizedJSON
)
type failedAuthAttempt struct {
attempts int
first time.Time
}
func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
if len(secret) != AUTH_SECRET_KEY_LENGTH {
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
usernameHash, err := computeUsernameHash(username, secret)
if err != nil {
return "", err
}
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
copy(data, usernameHash)
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
h.Write(data)
signature := h.Sum(nil)
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
// encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
return encodedToken, nil
}
func computeUsernameHash(username string, secret []byte) ([]byte, error) {
if len(secret) != AUTH_SECRET_KEY_LENGTH {
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
h.Write([]byte(username))
return h.Sum(nil), nil
}
func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) {
tokenBytes, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return nil, false, err
}
if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 {
return nil, false, fmt.Errorf("token length is invalid")
}
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH]
timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH]
providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:]
h := hmac.New(sha256.New, secretBytes[0:32])
h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH])
expectedSignatureBytes := h.Sum(nil)
if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) {
return nil, false, fmt.Errorf("signature does not match")
}
expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
if now.Unix() > expiresTimestamp {
return nil, false, fmt.Errorf("token has expired")
}
return usernameHashBytes,
// True if the token should be regenerated
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
nil
}
func makeAuthSecretKey(length int) (string, error) {
key := make([]byte, length)
_, err := rand.Read(key)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(key), nil
}
func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
return
}
waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond
ip := a.addressOfRequest(r)
a.authAttemptsMu.Lock()
exceededRateLimit, retryAfter := func() (bool, int) {
attempt, exists := a.failedAuthAttempts[ip]
if !exists {
a.failedAuthAttempts[ip] = &failedAuthAttempt{
attempts: 1,
first: time.Now(),
}
return false, 0
}
elapsed := time.Since(attempt.first)
if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
}
attempt.attempts++
return false, 0
}()
if exceededRateLimit {
a.authAttemptsMu.Unlock()
time.Sleep(waitOnFailure)
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.WriteHeader(http.StatusTooManyRequests)
return
} else {
// Clean up old failed attempts
for ipOfAttempt := range a.failedAuthAttempts {
if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
delete(a.failedAuthAttempts, ipOfAttempt)
}
}
a.authAttemptsMu.Unlock()
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var creds struct {
Username string `json:"username"`
Password string `json:"password"`
}
err = json.Unmarshal(body, &creds)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
logAuthFailure := func() {
log.Printf(
"Failed login attempt for user '%s' from %s",
creds.Username, ip,
)
}
if len(creds.Username) == 0 || len(creds.Password) == 0 {
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
if len(creds.Username) > 50 || len(creds.Password) > 100 {
logAuthFailure()
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
u, exists := a.Config.Auth.Users[creds.Username]
if !exists {
logAuthFailure()
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil {
logAuthFailure()
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
if err != nil {
log.Printf("Could not compute session token during login attempt: %v", err)
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
a.authAttemptsMu.Lock()
delete(a.failedAuthAttempts, ip)
a.authAttemptsMu.Unlock()
w.WriteHeader(http.StatusOK)
}
func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool {
if !a.RequiresAuth {
return true
}
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
if err != nil || token.Value == "" {
return false
}
usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now())
if err != nil {
return false
}
username, exists := a.usernameHashToUsername[string(usernameHash)]
if !exists {
return false
}
_, exists = a.Config.Auth.Users[username]
if !exists {
return false
}
if shouldRegenerate {
newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
if err != nil {
log.Printf("Could not compute session token during regeneration: %v", err)
return false
}
a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
}
return true
}
// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
if a.isAuthorized(w, r) {
return false
}
switch fallback {
case redirectToLogin:
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
case showUnauthorizedJSON:
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "Unauthorized"}`))
}
return true
}
// Maybe this should be a POST request instead?
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
}
func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: AUTH_SESSION_COOKIE_NAME,
Value: token,
Expires: expires,
Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https",
Path: a.Config.Server.BaseURL + "/",
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
})
}
func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) {
if a.isAuthorized(w, r) {
http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther)
return
}
data := &templateData{
App: a,
}
a.populateTemplateRequestData(&data.Request, r)
var responseBytes bytes.Buffer
err := loginPageTemplate.Execute(&responseBytes, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Write(responseBytes.Bytes())
}

@ -0,0 +1,85 @@
package glance
import (
"bytes"
"encoding/base64"
"testing"
"time"
)
func TestAuthTokenGenerationAndVerification(t *testing.T) {
secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
if err != nil {
t.Fatalf("Failed to generate secret key: %v", err)
}
secretBytes, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
t.Fatalf("Failed to decode secret key: %v", err)
}
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
now := time.Now()
username := "admin"
token, err := generateSessionToken(username, secretBytes, now)
if err != nil {
t.Fatalf("Failed to generate session token: %v", err)
}
usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now)
if err != nil {
t.Fatalf("Failed to verify session token: %v", err)
}
if shouldRegen {
t.Fatal("Token should not need to be regenerated immediately after generation")
}
computedUsernameHash, err := computeUsernameHash(username, secretBytes)
if err != nil {
t.Fatalf("Failed to compute username hash: %v", err)
}
if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
t.Fatal("Username hash does not match the expected value")
}
// Test token regeneration
timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second)
_, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod)
if err != nil {
t.Fatalf("Token verification should not fail during regeneration period, err: %v", err)
}
if !shouldRegen {
t.Fatal("Token should have been marked for regeneration")
}
// Test token expiration
_, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second))
if err == nil {
t.Fatal("Expected token verification to fail after token expiration")
}
// Test tampered token
decodedToken, err := base64.StdEncoding.DecodeString(token)
if err != nil {
t.Fatalf("Failed to decode token: %v", err)
}
// If any of the bytes are off by 1, the token should be considered invalid
for i := range len(decodedToken) {
tampered := make([]byte, len(decodedToken))
copy(tampered, decodedToken)
tampered[i] += 1
_, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now)
if err == nil {
t.Fatalf("Expected token verification to fail for tampered token at index %d", i)
}
}
}

@ -5,23 +5,41 @@ import (
"fmt"
"os"
"strings"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/sensors"
)
type cliIntent uint8
const (
cliIntentServe cliIntent = iota
cliIntentConfigValidate = iota
cliIntentConfigPrint = iota
cliIntentDiagnose = iota
cliIntentVersionPrint cliIntent = iota
cliIntentServe
cliIntentConfigValidate
cliIntentConfigPrint
cliIntentDiagnose
cliIntentSensorsPrint
cliIntentMountpointInfo
cliIntentSecretMake
cliIntentPasswordHash
)
type cliOptions struct {
intent cliIntent
configPath string
args []string
}
func parseCliOptions() (*cliOptions, error) {
var args []string
args = os.Args[1:]
if len(args) == 1 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") {
return &cliOptions{
intent: cliIntentVersionPrint,
}, nil
}
flags := flag.NewFlagSet("", flag.ExitOnError)
flags.Usage = func() {
fmt.Println("Usage: glance [options] command")
@ -30,10 +48,15 @@ func parseCliOptions() (*cliOptions, error) {
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")
fmt.Println(" config:validate Validate the config file")
fmt.Println(" config:print Print the parsed config file with embedded includes")
fmt.Println(" password:hash <pwd> Hash a password")
fmt.Println(" secret:make Generate a random secret key")
fmt.Println(" sensors:print List all sensors")
fmt.Println(" mountpoint:info Print information about a given mountpoint path")
fmt.Println(" diagnose Run diagnostic checks")
}
configPath := flags.String("config", "glance.yml", "Set config path")
err := flags.Parse(os.Args[1:])
if err != nil {
@ -41,7 +64,7 @@ func parseCliOptions() (*cliOptions, error) {
}
var intent cliIntent
var args = flags.Args()
args = flags.Args()
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
if len(args) == 0 {
@ -51,8 +74,24 @@ func parseCliOptions() (*cliOptions, error) {
intent = cliIntentConfigValidate
} else if args[0] == "config:print" {
intent = cliIntentConfigPrint
} else if args[0] == "sensors:print" {
intent = cliIntentSensorsPrint
} else if args[0] == "diagnose" {
intent = cliIntentDiagnose
} else if args[0] == "secret:make" {
intent = cliIntentSecretMake
} else {
return nil, unknownCommandErr
}
} else if len(args) == 2 {
if args[0] == "password:hash" {
intent = cliIntentPasswordHash
} else {
return nil, unknownCommandErr
}
} else if len(args) == 2 {
if args[0] == "mountpoint:info" {
intent = cliIntentMountpointInfo
} else {
return nil, unknownCommandErr
}
@ -63,5 +102,54 @@ func parseCliOptions() (*cliOptions, error) {
return &cliOptions{
intent: intent,
configPath: *configPath,
args: args,
}, nil
}
func cliSensorsPrint() int {
tempSensors, err := sensors.SensorsTemperatures()
if err != nil {
if warns, ok := err.(*sensors.Warnings); ok {
fmt.Printf("Could not retrieve information for some sensors (%v):\n", err)
for _, w := range warns.List {
fmt.Printf(" - %v\n", w)
}
fmt.Println()
} else {
fmt.Printf("Failed to retrieve sensor information: %v\n", err)
return 1
}
}
if len(tempSensors) == 0 {
fmt.Println("No sensors found")
return 0
}
fmt.Println("Sensors found:")
for _, sensor := range tempSensors {
fmt.Printf(" %s: %.1f°C\n", sensor.SensorKey, sensor.Temperature)
}
return 0
}
func cliMountpointInfo(requestedPath string) int {
usage, err := disk.Usage(requestedPath)
if err != nil {
fmt.Printf("Failed to retrieve info for path %s: %v\n", requestedPath, err)
if warns, ok := err.(*disk.Warnings); ok {
for _, w := range warns.List {
fmt.Printf(" - %v\n", w)
}
}
return 1
}
fmt.Println("Path:", usage.Path)
fmt.Println("FS type:", ternary(usage.Fstype == "", "unknown", usage.Fstype))
fmt.Printf("Used percent: %.1f%%\n", usage.UsedPercent)
return 0
}

@ -3,6 +3,7 @@ package glance
import (
"crypto/tls"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
@ -13,7 +14,8 @@ import (
"gopkg.in/yaml.v3"
)
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`)
var inStringPropertyPattern = regexp.MustCompile(`(?m)([a-zA-Z]+)\[(.*?)\]`)
const (
hslHueMax = 360
@ -22,17 +24,32 @@ const (
)
type hslColorField struct {
Hue uint16
Saturation uint8
Lightness uint8
H float64
S float64
L float64
}
func (c *hslColorField) String() string {
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.H, c.S, c.L)
}
func (c *hslColorField) ToHex() string {
return hslToHex(c.H, c.S, c.L)
}
func (c1 *hslColorField) SameAs(c2 *hslColorField) bool {
if c1 == nil && c2 == nil {
return true
}
if c1 == nil || c2 == nil {
return false
}
return c1.H == c2.H && c1.S == c2.S && c1.L == c2.L
}
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
var value string
errorLine := fmt.Sprintf("line %d:", node.Line)
if err := node.Decode(&value); err != nil {
return err
@ -41,39 +58,39 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
matches := hslColorFieldPattern.FindStringSubmatch(value)
if len(matches) != 4 {
return fmt.Errorf("invalid HSL color format: %s", value)
return fmt.Errorf("%s invalid HSL color format: %s", errorLine, value)
}
hue, err := strconv.ParseUint(matches[1], 10, 16)
hue, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return err
}
if hue > hslHueMax {
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
return fmt.Errorf("%s HSL hue must be between 0 and %d", errorLine, hslHueMax)
}
saturation, err := strconv.ParseUint(matches[2], 10, 8)
saturation, err := strconv.ParseFloat(matches[2], 64)
if err != nil {
return err
}
if saturation > hslSaturationMax {
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
return fmt.Errorf("%s HSL saturation must be between 0 and %d", errorLine, hslSaturationMax)
}
lightness, err := strconv.ParseUint(matches[3], 10, 8)
lightness, err := strconv.ParseFloat(matches[3], 64)
if err != nil {
return err
}
if lightness > hslLightnessMax {
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
return fmt.Errorf("%s HSL lightness must be between 0 and %d", errorLine, hslLightnessMax)
}
c.Hue = uint16(hue)
c.Saturation = uint8(saturation)
c.Lightness = uint8(lightness)
c.H = hue
c.S = saturation
c.L = lightness
return nil
}
@ -84,6 +101,7 @@ type durationField time.Duration
func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
var value string
errorLine := fmt.Sprintf("line %d:", node.Line)
if err := node.Decode(&value); err != nil {
return err
@ -92,12 +110,12 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
matches := durationFieldPattern.FindStringSubmatch(value)
if len(matches) != 3 {
return fmt.Errorf("invalid duration format: %s", value)
return fmt.Errorf("%s invalid duration format for value `%s`", errorLine, value)
}
duration, err := strconv.Atoi(matches[1])
if err != nil {
return err
return fmt.Errorf("%s invalid duration value: %s", errorLine, matches[1])
}
switch matches[2] {
@ -115,49 +133,91 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
}
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
URL template.URL
Color string
AutoInvert bool
}
func (h *customIconField) Elem() template.HTML {
return h.ElemWithClass("")
}
func (h *customIconField) ElemWithClass(class string) template.HTML {
if h.AutoInvert && h.Color == "" {
class = "flat-icon " + class
}
if h.Color != "" {
return template.HTML(
`<div class="icon colored-icon ` + class + `" style="--icon-color: ` + h.Color + `; --icon-url: url('` + string(h.URL) + `')"></div>`,
)
}
return template.HTML(
`<img class="icon ` + class + `" src="` + string(h.URL) + `" alt="" loading="lazy">`,
)
}
func newCustomIconField(value string) customIconField {
const autoInvertPrefix = "auto-invert "
field := customIconField{}
if strings.HasPrefix(value, autoInvertPrefix) {
field.AutoInvert = true
value = strings.TrimPrefix(value, autoInvertPrefix)
}
value, properties := parseInStringProperties(value)
if color, ok := properties["color"]; ok {
switch color {
case "primary":
color = "var(--color-primary)"
case "positive":
color = "var(--color-positive)"
case "negative":
color = "var(--color-negative)"
case "base":
color = "var(--color-text-base)"
case "subdue":
color = "var(--color-text-subdue)"
}
field.Color = color
}
prefix, icon, found := strings.Cut(value, ":")
if !found {
field.URL = value
field.URL = template.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
}
basename, ext, found := strings.Cut(icon, ".")
if !found {
ext = "svg"
basename = icon
}
if ext != "svg" && ext != "png" {
ext = "svg"
}
if ext != "svg" && ext != "png" {
ext = "svg"
}
if prefix == "di" {
field.URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext
} else {
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext
}
switch prefix {
case "si":
field.AutoInvert = true
field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + basename + ".svg")
case "di":
field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext)
case "mdi":
field.AutoInvert = true
field.URL = template.URL("https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/" + basename + ".svg")
case "sh":
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
case "hi":
field.AutoInvert = true
field.URL = template.URL("https://cdn.jsdelivr.net/npm/heroicons@latest/24/" + basename + ".svg")
default:
field.URL = value
field.URL = template.URL(value)
}
return field
@ -173,6 +233,23 @@ func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
return nil
}
func parseInStringProperties(value string) (string, map[string]string) {
properties := make(map[string]string)
value = inStringPropertyPattern.ReplaceAllStringFunc(value, func(match string) string {
matches := inStringPropertyPattern.FindStringSubmatch(match)
if len(matches) != 3 {
return ""
}
properties[matches[1]] = matches[2]
return ""
})
return strings.TrimSpace(value), properties
}
type proxyOptionsField struct {
URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"`
@ -231,6 +308,8 @@ func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
*q = make(queryParametersField)
errorLine := fmt.Sprintf("line %d:", node.Line)
// TODO: refactor the duplication in the switch cases if any more types get added
for key, value := range decoded {
switch v := value.(type) {
@ -252,11 +331,11 @@ func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
case bool:
(*q)[key] = append((*q)[key], fmt.Sprintf("%t", item))
default:
return fmt.Errorf("invalid query parameter value type: %T", item)
return fmt.Errorf("%s invalid query parameter value type: %T", errorLine, item)
}
}
default:
return fmt.Errorf("invalid query parameter value type: %T", value)
return fmt.Errorf("%s invalid query parameter value type: %T", errorLine, value)
}
}

@ -2,8 +2,10 @@ package glance
import (
"bytes"
"errors"
"fmt"
"html/template"
"iter"
"log"
"maps"
"os"
@ -27,49 +29,61 @@ const (
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
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
Proxied bool `yaml:"proxied"`
AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"`
} `yaml:"server"`
Auth struct {
SecretKey string `yaml:"secret-key"`
Users map[string]*user `yaml:"users"`
} `yaml:"auth"`
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"`
themeProperties `yaml:",inline"`
CustomCSSFile string `yaml:"custom-css-file"`
DisablePicker bool `yaml:"disable-picker"`
Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"`
} `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"`
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"`
FaviconType string `yaml:"-"`
AppName string `yaml:"app-name"`
AppIconURL string `yaml:"app-icon-url"`
AppBackgroundColor string `yaml:"app-background-color"`
} `yaml:"branding"`
Pages []page `yaml:"pages"`
}
type user struct {
Password string `yaml:"password"`
PasswordHashString string `yaml:"password-hash"`
PasswordHash []byte `yaml:"-"`
}
type page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
DesktopNavigationWidth string `yaml:"desktop-navigation-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 {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
HeadWidgets widgets `yaml:"head-widgets"`
Columns []struct {
Size string `yaml:"size"`
Widgets widgets `yaml:"widgets"`
} `yaml:"columns"`
@ -96,6 +110,12 @@ func newConfigFromYAML(contents []byte) (*config, error) {
}
for p := range config.Pages {
for w := range config.Pages[p].HeadWidgets {
if err := config.Pages[p].HeadWidgets[w].initialize(); err != nil {
return nil, formatWidgetInitError(err, config.Pages[p].HeadWidgets[w])
}
}
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 {
@ -424,11 +444,39 @@ func configFilesWatcher(
}, nil
}
// TODO: Refactor, we currently validate in two different places, this being
// one of them, which doesn't modify the data and only checks for logical errors
// and then again when creating the application which does modify the data and do
// further validation. Would be better if validation was done in a single place.
func isConfigStateValid(config *config) error {
if len(config.Pages) == 0 {
return fmt.Errorf("no pages configured")
}
if len(config.Auth.Users) > 0 && config.Auth.SecretKey == "" {
return fmt.Errorf("secret-key must be set when users are configured")
}
for username := range config.Auth.Users {
if username == "" {
return fmt.Errorf("user has no name")
}
if len(username) < 3 {
return errors.New("usernames must be at least 3 characters")
}
user := config.Auth.Users[username]
if user.Password == "" {
if user.PasswordHashString == "" {
return fmt.Errorf("user %s must have a password or a password-hash set", username)
}
} else if len(user.Password) < 6 {
return fmt.Errorf("the password for %s must be at least 6 characters", username)
}
}
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)
@ -487,3 +535,103 @@ func isConfigStateValid(config *config) error {
return nil
}
// Read-only way to store ordered maps from a YAML structure
type orderedYAMLMap[K comparable, V any] struct {
keys []K
data map[K]V
}
func newOrderedYAMLMap[K comparable, V any](keys []K, values []V) (*orderedYAMLMap[K, V], error) {
if len(keys) != len(values) {
return nil, fmt.Errorf("keys and values must have the same length")
}
om := &orderedYAMLMap[K, V]{
keys: make([]K, len(keys)),
data: make(map[K]V, len(keys)),
}
copy(om.keys, keys)
for i := range keys {
om.data[keys[i]] = values[i]
}
return om, nil
}
func (om *orderedYAMLMap[K, V]) Items() iter.Seq2[K, V] {
return func(yield func(K, V) bool) {
for _, key := range om.keys {
value, ok := om.data[key]
if !ok {
continue
}
if !yield(key, value) {
return
}
}
}
}
func (om *orderedYAMLMap[K, V]) Get(key K) (V, bool) {
value, ok := om.data[key]
return value, ok
}
func (self *orderedYAMLMap[K, V]) Merge(other *orderedYAMLMap[K, V]) *orderedYAMLMap[K, V] {
merged := &orderedYAMLMap[K, V]{
keys: make([]K, 0, len(self.keys)+len(other.keys)),
data: make(map[K]V, len(self.data)+len(other.data)),
}
merged.keys = append(merged.keys, self.keys...)
maps.Copy(merged.data, self.data)
for _, key := range other.keys {
if _, exists := self.data[key]; !exists {
merged.keys = append(merged.keys, key)
}
}
maps.Copy(merged.data, other.data)
return merged
}
func (om *orderedYAMLMap[K, V]) UnmarshalYAML(node *yaml.Node) error {
if node.Kind != yaml.MappingNode {
return fmt.Errorf("orderedMap: expected mapping node, got %d", node.Kind)
}
if len(node.Content)%2 != 0 {
return fmt.Errorf("orderedMap: expected even number of content items, got %d", len(node.Content))
}
om.keys = make([]K, len(node.Content)/2)
om.data = make(map[K]V, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
var key K
if err := keyNode.Decode(&key); err != nil {
return fmt.Errorf("orderedMap: decoding key: %v", err)
}
if _, ok := om.data[key]; ok {
return fmt.Errorf("orderedMap: duplicate key %v", key)
}
var value V
if err := valueNode.Decode(&value); err != nil {
return fmt.Errorf("orderedMap: decoding value: %v", err)
}
(*om).keys[i/2] = key
(*om).data[key] = value
}
return nil
}

@ -12,7 +12,7 @@ import (
"time"
)
const httpTestRequestTimeout = 10 * time.Second
const httpTestRequestTimeout = 15 * time.Second
var diagnosticSteps = []diagnosticStep{
{
@ -75,13 +75,17 @@ var diagnosticSteps = []diagnosticStep{
{
name: "fetch data from Reddit API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
return testHttpRequestWithHeaders("GET", "https://www.reddit.com/search.json", map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
}, 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)
return testHttpRequestWithHeaders("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
}, 200)
},
},
{
@ -163,7 +167,7 @@ func testHttpRequestWithHeaders(method, url string, headers map[string]string, e
request.Header.Add(key, value)
}
response, err := http.DefaultClient.Do(request)
response, err := defaultHTTPClient.Do(request)
if err != nil {
return "", err
}

@ -82,7 +82,6 @@ func computeFSHash(files fs.FS) (string, error) {
var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`)
var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`)
var whitespaceAtBeginningOfLinePattern = regexp.MustCompile(`(?m)^\s+`)
// Yes, we bundle at runtime, give comptime pls
var bundledCSSContents = func() []byte {

@ -3,52 +3,151 @@ package glance
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"html/template"
"log"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
pageTemplate = mustParseTemplate("page.html", "document.html")
pageContentTemplate = mustParseTemplate("page-content.html")
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
pageTemplate = mustParseTemplate("page.html", "document.html", "footer.html")
pageContentTemplate = mustParseTemplate("page-content.html")
manifestTemplate = mustParseTemplate("manifest.json")
)
const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
var reservedPageSlugs = []string{"login", "logout"}
type application struct {
Version string
Config config
ParsedThemeStyle template.HTML
Version string
CreatedAt time.Time
Config config
parsedManifest []byte
slugToPage map[string]*page
widgetByID map[uint64]widget
RequiresAuth bool
authSecretKey []byte
usernameHashToUsername map[string]string
authAttemptsMu sync.Mutex
failedAuthAttempts map[string]*failedAuthAttempt
}
func newApplication(config *config) (*application, error) {
func newApplication(c *config) (*application, error) {
app := &application{
Version: buildVersion,
Config: *config,
CreatedAt: time.Now(),
Config: *c,
slugToPage: make(map[string]*page),
widgetByID: make(map[uint64]widget),
}
config := &app.Config
app.slugToPage[""] = &config.Pages[0]
//
// Init auth
//
providers := &widgetProviders{
assetResolver: app.AssetPath,
if len(config.Auth.Users) > 0 {
secretBytes, err := base64.StdEncoding.DecodeString(config.Auth.SecretKey)
if err != nil {
return nil, fmt.Errorf("decoding secret-key: %v", err)
}
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
return nil, fmt.Errorf("secret-key must be exactly %d bytes", AUTH_SECRET_KEY_LENGTH)
}
app.usernameHashToUsername = make(map[string]string)
app.failedAuthAttempts = make(map[string]*failedAuthAttempt)
app.RequiresAuth = true
for username := range config.Auth.Users {
user := config.Auth.Users[username]
usernameHash, err := computeUsernameHash(username, secretBytes)
if err != nil {
return nil, fmt.Errorf("computing username hash for user %s: %v", username, err)
}
app.usernameHashToUsername[string(usernameHash)] = username
if user.PasswordHashString != "" {
user.PasswordHash = []byte(user.PasswordHashString)
user.PasswordHashString = ""
} else {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hashing password for user %s: %v", username, err)
}
user.Password = ""
user.PasswordHash = hashedPassword
}
}
app.authSecretKey = secretBytes
}
var err error
app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
if err != nil {
return nil, fmt.Errorf("parsing theme style: %v", err)
//
// Init themes
//
if !config.Theme.DisablePicker {
themeKeys := make([]string, 0, 2)
themeProps := make([]*themeProperties, 0, 2)
defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark")
if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) {
themeKeys = append(themeKeys, "default-dark")
themeProps = append(themeProps, &themeProperties{})
}
themeKeys = append(themeKeys, "default-light")
themeProps = append(themeProps, &themeProperties{
Light: true,
BackgroundColor: &hslColorField{240, 13, 95},
PrimaryColor: &hslColorField{230, 100, 30},
NegativeColor: &hslColorField{0, 70, 50},
ContrastMultiplier: 1.3,
TextSaturationMultiplier: 0.5,
})
themePresets, err := newOrderedYAMLMap(themeKeys, themeProps)
if err != nil {
return nil, fmt.Errorf("creating theme presets: %v", err)
}
config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets)
for key, properties := range config.Theme.Presets.Items() {
properties.Key = key
if err := properties.init(); err != nil {
return nil, fmt.Errorf("initializing preset theme %s: %v", key, err)
}
}
}
config.Theme.Key = "default"
if err := config.Theme.init(); err != nil {
return nil, fmt.Errorf("initializing default theme: %v", err)
}
//
// Init pages
//
app.slugToPage[""] = &config.Pages[0]
providers := &widgetProviders{
assetResolver: app.StaticAssetPath,
}
for p := range config.Pages {
@ -59,6 +158,10 @@ func newApplication(config *config) (*application, error) {
page.Slug = titleToSlug(page.Title)
}
if slices.Contains(reservedPageSlugs, page.Slug) {
return nil, fmt.Errorf("page slug \"%s\" is reserved", page.Slug)
}
app.slugToPage[page.Slug] = page
if page.Width == "default" {
@ -69,6 +172,12 @@ func newApplication(config *config) (*application, error) {
page.DesktopNavigationWidth = page.Width
}
for i := range page.HeadWidgets {
widget := page.HeadWidgets[i]
app.widgetByID[widget.GetID()] = widget
widget.setProviders(providers)
}
for c := range page.Columns {
column := &page.Columns[c]
@ -79,24 +188,44 @@ func newApplication(config *config) (*application, error) {
for w := range column.Widgets {
widget := column.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)
config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png")
} else {
config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
config.Branding.FaviconURL = ternary(
config.Branding.FaviconURL == "",
app.StaticAssetPath("favicon.svg"),
app.resolveUserDefinedAssetPath(config.Branding.FaviconURL),
)
config.Branding.FaviconType = ternary(
strings.HasSuffix(config.Branding.FaviconURL, ".svg"),
"image/svg+xml",
"image/png",
)
if config.Branding.AppName == "" {
config.Branding.AppName = "Glance"
}
if config.Branding.AppIconURL == "" {
config.Branding.AppIconURL = app.StaticAssetPath("app-icon.png")
}
config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
if config.Branding.AppBackgroundColor == "" {
config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex
}
manifest, err := executeTemplateToString(manifestTemplate, templateData{App: app})
if err != nil {
return nil, fmt.Errorf("parsing manifest.json: %v", err)
}
app.parsedManifest = []byte(manifest)
return app, nil
}
@ -107,6 +236,20 @@ func (p *page) updateOutdatedWidgets() {
var wg sync.WaitGroup
context := context.Background()
for w := range p.HeadWidgets {
widget := p.HeadWidgets[w]
if !widget.requiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.update(context)
}()
}
for c := range p.Columns {
for w := range p.Columns[c].Widgets {
widget := p.Columns[c].Widgets[w]
@ -126,7 +269,7 @@ func (p *page) updateOutdatedWidgets() {
wg.Wait()
}
func (a *application) transformUserDefinedAssetPath(path string) string {
func (a *application) resolveUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path
}
@ -134,26 +277,51 @@ func (a *application) transformUserDefinedAssetPath(path string) string {
return path
}
type pageTemplateData struct {
App *application
Page *page
type templateRequestData struct {
Theme *themeProperties
}
type templateData struct {
App *application
Page *page
Request templateRequestData
}
func (a *application) populateTemplateRequestData(data *templateRequestData, r *http.Request) {
theme := &a.Config.Theme.themeProperties
if !a.Config.Theme.DisablePicker {
selectedTheme, err := r.Cookie("theme")
if err == nil {
preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value)
if exists {
theme = preset
}
}
}
data.Theme = theme
}
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")]
if !exists {
a.handleNotFound(w, r)
return
}
pageData := pageTemplateData{
if a.handleUnauthorizedResponse(w, r, redirectToLogin) {
return
}
data := templateData{
Page: page,
App: a,
}
a.populateTemplateRequestData(&data.Request, r)
var responseBytes bytes.Buffer
err := pageTemplate.Execute(&responseBytes, pageData)
err := pageTemplate.Execute(&responseBytes, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
@ -165,13 +333,16 @@ func (a *application) handlePageRequest(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)
return
}
pageData := pageTemplateData{
if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
return
}
pageData := templateData{
Page: page,
}
@ -195,6 +366,35 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re
w.Write(responseBytes.Bytes())
}
func (a *application) addressOfRequest(r *http.Request) string {
remoteAddrWithoutPort := func() string {
for i := len(r.RemoteAddr) - 1; i >= 0; i-- {
if r.RemoteAddr[i] == ':' {
return r.RemoteAddr[:i]
}
}
return r.RemoteAddr
}
if !a.Config.Server.Proxied {
return remoteAddrWithoutPort()
}
// This should probably be configurable or look for multiple headers, not just this one
forwardedFor := r.Header.Get("X-Forwarded-For")
if forwardedFor == "" {
return remoteAddrWithoutPort()
}
ips := strings.Split(forwardedFor, ",")
if len(ips) == 0 || ips[0] == "" {
return remoteAddrWithoutPort()
}
return ips[0]
}
func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
// TODO: add proper not found page
w.WriteHeader(http.StatusNotFound)
@ -202,42 +402,60 @@ func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
}
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
widgetValue := r.PathValue("widget")
// TODO: this requires a rework of the widget update logic so that rather
// than locking the entire page we lock individual widgets
w.WriteHeader(http.StatusNotImplemented)
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil {
a.handleNotFound(w, r)
return
}
// widgetValue := r.PathValue("widget")
widget, exists := a.widgetByID[widgetID]
// widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
// if err != nil {
// a.handleNotFound(w, r)
// return
// }
if !exists {
a.handleNotFound(w, r)
return
}
// widget, exists := a.widgetByID[widgetID]
// if !exists {
// a.handleNotFound(w, r)
// return
// }
widget.handleRequest(w, r)
// widget.handleRequest(w, r)
}
func (a *application) AssetPath(asset string) string {
func (a *application) StaticAssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
}
func (a *application) VersionedAssetPath(asset string) string {
return a.Config.Server.BaseURL + asset +
"?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10)
}
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 /api/pages/{page}/content/{$}", a.handlePageContentRequest)
if !a.Config.Theme.DisablePicker {
mux.HandleFunc("POST /api/set-theme/{key}", a.handleThemeChangeRequest)
}
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
if a.RequiresAuth {
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
mux.HandleFunc("POST /api/authenticate", a.handleAuthenticationAttempt)
}
mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
http.StripPrefix(
@ -246,17 +464,23 @@ func (a *application) server() (func() error, func() error) {
),
)
cssBundleCacheControlValue := fmt.Sprintf(
assetCacheControlValue := fmt.Sprintf(
"public, max-age=%d",
int(STATIC_ASSETS_CACHE_DURATION.Seconds()),
)
mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", cssBundleCacheControlValue)
w.Header().Add("Cache-Control", assetCacheControlValue)
w.Header().Add("Content-Type", "text/css; charset=utf-8")
w.Write(bundledCSSContents)
})
mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", assetCacheControlValue)
w.Header().Add("Content-Type", "application/json")
w.Write(a.parsedManifest)
})
var absAssetsPath string
if a.Config.Server.AssetsPath != "" {
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
@ -270,7 +494,6 @@ func (a *application) server() (func() error, func() error) {
}
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,

@ -6,6 +6,11 @@ import (
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
)
var buildVersion = "dev"
@ -18,6 +23,8 @@ func Main() int {
}
switch options.intent {
case cliIntentVersionPrint:
fmt.Println(buildVersion)
case cliIntentServe:
// remove in v0.10.0
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
@ -47,14 +54,49 @@ func Main() int {
}
fmt.Println(string(contents))
case cliIntentSensorsPrint:
return cliSensorsPrint()
case cliIntentMountpointInfo:
return cliMountpointInfo(options.args[1])
case cliIntentDiagnose:
runDiagnostic()
case cliIntentSecretMake:
key, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
if err != nil {
fmt.Printf("Failed to make secret key: %v\n", err)
return 1
}
fmt.Println(key)
case cliIntentPasswordHash:
password := options.args[1]
if password == "" {
fmt.Println("Password cannot be empty")
return 1
}
if len(password) < 6 {
fmt.Println("Password must be at least 6 characters long")
return 1
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Printf("Failed to hash password: %v\n", err)
return 1
}
fmt.Println(string(hashedPassword))
}
return 0
}
func serveApp(configPath string) error {
// TODO: refactor if this gets any more complex, the current implementation is
// difficult to reason about due to all of the callbacks and simultaneous operations,
// use a single goroutine and a channel to initiate synchronous changes to the server
exitChannel := make(chan struct{})
hadValidConfigOnStartup := false
var stopServer func() error
@ -66,23 +108,35 @@ func serveApp(configPath string) error {
config, err := newConfigFromYAML(newContents)
if err != nil {
log.Printf("Config has errors: %v", err)
errStr := strings.ReplaceAll(err.Error(), "\n", "")
errStr = sequentialWhitespacePattern.ReplaceAllString(errStr, " ")
errStr = strings.ReplaceAll(errStr, "!!seq", "array")
log.Printf("Config has errors: %v", errStr)
printConfigLinesNearErrorIfAvailable(err, newContents)
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)
if !hadValidConfigOnStartup {
close(exitChannel)
}
return
}
if !hadValidConfigOnStartup {
hadValidConfigOnStartup = true
}
if stopServer != nil {
if err := stopServer(); err != nil {
log.Printf("Error while trying to stop server: %v", err)
@ -134,6 +188,41 @@ func serveApp(configPath string) error {
return nil
}
var errorLinePattern = regexp.MustCompile(`line (\d+)`)
func printConfigLinesNearErrorIfAvailable(err error, configContents []byte) {
if err == nil {
return
}
matches := errorLinePattern.FindStringSubmatch(err.Error())
if len(matches) < 2 {
return
}
errorAtLine, err := strconv.Atoi(matches[1])
if err != nil {
return
}
contents := strings.ReplaceAll(string(configContents), "\r\n", "\n")
lines := strings.Split(contents, "\n")
if errorAtLine < 1 || errorAtLine > len(lines) {
return
}
contextLength := 3
for i := max(0, errorAtLine-contextLength-1); i < min(len(lines), errorAtLine+contextLength); i++ {
if i == errorAtLine-1 {
fmt.Printf("-> %s\n", lines[i])
} else {
fmt.Printf("| %s\n", lines[i])
}
}
}
func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
if !isRunningInsideDockerContainer() {
return false

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

@ -0,0 +1,155 @@
.login-bounds {
max-width: 500px;
padding: 0 2rem;
}
.form-label {
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.form-input {
transition: border-color .2s;
}
.form-input input {
border: 0;
background: none;
width: 100%;
height: 5.2rem;
font: inherit;
outline: none;
color: var(--color-text-highlight);
}
.form-input-icon {
width: 2rem;
height: 2rem;
margin-top: -0.1rem;
opacity: 0.5;
}
.form-input input[type="password"] {
letter-spacing: 0.3rem;
font-size: 0.9em;
}
.form-input input[type="password"]::placeholder {
letter-spacing: 0;
font-size: var(--font-size-base);
}
.form-input:hover {
border-color: var(--color-progress-border);
}
.form-input:focus-within {
border-color: var(--color-primary);
transition-duration: .7s;
}
.login-button {
width: 100%;
display: block;
padding: 1rem;
background: none;
border: 1px solid var(--color-text-subdue);
border-radius: var(--border-radius);
color: var(--color-text-paragraph);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h4);
display: flex;
gap: .5rem;
align-items: center;
justify-content: center;
transition: all .3s, margin-top 0s;
margin-top: 3rem;
}
.login-button:not(:disabled) {
box-shadow: 0 0 10px 1px var(--color-separator);
}
.login-error-message:not(:empty) + .login-button {
margin-top: 2rem;
}
.login-button:focus, .login-button:hover {
outline: none;
border-color: var(--color-primary);
color: var(--color-primary);
}
.login-button:disabled {
border-color: var(--color-separator);
color: var(--color-text-subdue);
cursor: not-allowed;
}
.login-button svg {
width: 1.7rem;
height: 1.7rem;
transition: transform .2s;
}
.login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg {
transform: translateX(.5rem);
}
.animate-entrance {
animation: fieldReveal 0.7s backwards;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.animate-entrance:nth-child(1) { animation-delay: .1s; }
.animate-entrance:nth-child(2) { animation-delay: .2s; }
.animate-entrance:nth-child(4) { animation-delay: .3s; }
@keyframes fieldReveal {
from {
opacity: 0.0001;
transform: translateY(4rem);
}
}
.login-error-message {
color: var(--color-negative);
font-size: var(--font-size-base);
padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px);
position: relative;
margin-top: 2rem;
animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes errorMessageEntrance {
from {
opacity: 0;
transform: scale(1.1);
}
}
.login-error-message:empty {
display: none;
}
.login-error-message::before {
content: "";
position: absolute;
inset: 0;
border-radius: var(--border-radius);
background: var(--color-negative);
opacity: 0.05;
z-index: -1;
}
.footer {
animation-delay: .4s;
animation-duration: 1s;
}
.toggle-password-visibility {
background: none;
border: none;
cursor: pointer;
}

@ -48,6 +48,17 @@
transition: transform .3s;
}
.mobile-navigation-actions > * {
padding-block: 1.1rem;
padding-inline: var(--content-bounds-padding);
cursor: pointer;
transition: background-color 50ms;
}
.mobile-navigation-actions > *:active {
background-color: var(--color-widget-background-highlight);
}
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon {
--spacing: 7px;
color: var(--color-primary);
@ -60,7 +71,8 @@
.mobile-navigation-page-links {
border-top: 1px solid var(--color-widget-content-border);
padding: 15px var(--content-bounds-padding);
border-bottom: 1px solid var(--color-widget-content-border);
padding: 20px var(--content-bounds-padding);
display: flex;
align-items: center;
overflow-x: auto;

@ -1,4 +1,4 @@
.light-scheme {
:root[data-scheme=light] {
--scheme: 100% -;
}
@ -13,6 +13,7 @@
.page.content-ready > .page-content {
display: block;
animation: pageContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.page-column-small .size-title-dynamic {
@ -27,6 +28,23 @@ pre {
font: inherit;
}
input[type="text"] {
width: 100%;
border: 0;
background: none;
font: inherit;
color: inherit;
}
button {
font: inherit;
border: 0;
cursor: pointer;
background: none;
color: inherit;
}
::selection {
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
color: var(--color-text-highlight);
@ -43,6 +61,10 @@ pre {
width: 10px;
}
*:active {
-webkit-tap-highlight-color: transparent;
}
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 0.1rem;
@ -127,10 +149,9 @@ body {
.page-columns {
display: flex;
gap: var(--widget-gap);
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
@keyframes pageColumnsEntrance {
@keyframes pageContentEntrance {
from {
opacity: 0;
transform: translateY(10px);
@ -153,7 +174,9 @@ body {
@keyframes loadingContainerEntrance {
from {
opacity: 0;
/* Using 0.001 instead of 0 fixes a random 1s freeze on Chrome on page load when all */
/* elements have opacity 0 and are animated in. I don't want to be a web dev anymore. */
opacity: 0.001;
}
}
@ -219,7 +242,7 @@ kbd:active {
max-width: 1100px;
}
.page-center-vertically .page {
.page.center-vertically {
display: flex;
justify-content: center;
flex-direction: column;
@ -239,6 +262,7 @@ kbd:active {
.logo {
height: 100%;
flex-shrink: 0;
line-height: var(--header-height);
font-size: 2rem;
color: var(--color-text-highlight);
@ -246,7 +270,7 @@ kbd:active {
padding-right: var(--widget-content-horizontal-padding);
}
.logo:has(img) {
.logo:has(img, svg) {
display: flex;
align-items: center;
}
@ -255,13 +279,20 @@ kbd:active {
max-height: 2.7rem;
}
.nav {
.desktop-navigation {
overflow-x: auto;
min-width: 0;
height: 100%;
gap: var(--header-items-gap);
}
.nav .nav-item {
line-height: var(--header-height);
.desktop-navigation .nav-item {
display: flex;
align-items: center;
}
.desktop-navigation .nav-item-text {
margin-top: 3px;
}
.footer {
@ -293,3 +324,84 @@ kbd:active {
border-bottom-color: var(--color-primary);
color: var(--color-text-highlight);
}
.logout-button {
width: 2rem;
height: 2rem;
stroke: var(--color-text-subdue);
transition: stroke .2s;
}
.logout-button:hover, .logout-button:focus {
stroke: var(--color-text-highlight);
}
.theme-choices {
--presets-per-row: 2;
display: grid;
grid-template-columns: repeat(var(--presets-per-row), 1fr);
align-items: center;
gap: 1.35rem;
}
.theme-choices:has(> :nth-child(3)) {
--presets-per-row: 3;
}
.theme-preset {
background-color: var(--color);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 2rem;
padding-inline: 0.5rem;
border-radius: 0.3rem;
border: none;
cursor: pointer;
position: relative;
}
.theme-choices .theme-preset::before {
content: '';
position: absolute;
inset: -.4rem;
border-radius: .7rem;
border: 2px solid transparent;
transition: border-color .3s;
}
.theme-choices .theme-preset:hover::before {
border-color: var(--color-text-subdue);
}
.theme-choices .theme-preset.current::before {
border-color: var(--color-text-base);
}
.theme-preset-light {
gap: 0.3rem;
height: 1.8rem;
}
.theme-color {
background-color: var(--color);
width: 0.9rem;
height: 0.9rem;
border-radius: 0.2rem;
}
.theme-preset-light .theme-color {
width: 1rem;
height: 1rem;
border-radius: 0.3rem;
}
.current-theme-preview {
opacity: 0.4;
transition: opacity .3s;
}
.theme-picker.popover-active .current-theme-preview, .theme-picker:hover {
opacity: 1;
}

@ -380,7 +380,28 @@ details[open] .summary::after {
gap: 0.5rem;
}
:root:not(.light-scheme) .flat-icon {
.icon {
object-fit: contain;
user-select: none;
}
.icon:not(.colored-icon) {
opacity: 0.85;
filter: grayscale(0.2);
transition: opacity 0.2s, filter 0.2s;
}
.icon-parent:hover .icon {
opacity: 1;
filter: none;
}
.colored-icon {
mask: var(--icon-url) no-repeat center / contain;
background-color: var(--icon-color);
}
:root:not([data-scheme=light]) .flat-icon:not(.colored-icon) {
filter: invert(1);
}
@ -472,6 +493,22 @@ details[open] .summary::after {
filter: none;
}
.hide-scrollbars {
scrollbar-width: none;
}
/* Hide on Safari and Chrome */
.hide-scrollbars::-webkit-scrollbar {
display: none;
}
.ui-icon {
width: 2.3rem;
height: 2.3rem;
display: block;
flex-shrink: 0;
}
.size-h1 { font-size: var(--font-size-h1); }
.size-h2 { font-size: var(--font-size-h2); }
.size-h3 { font-size: var(--font-size-h3); }
@ -488,11 +525,72 @@ details[open] .summary::after {
.color-positive { color: var(--color-positive); }
.color-primary { color: var(--color-primary); }
.bg-highlight { background-color: var(--color-widget-background-highlight); }
.color-primary-if-not-visited:not(:visited) {
color: var(--color-primary);
}
.drag-and-drop-container {
position: relative;
}
.drag-and-drop-decoy {
outline: 1px dashed var(--color-primary);
opacity: 0.25;
border-radius: var(--border-radius);
}
.drag-and-drop-draggable {
position: absolute;
cursor: grabbing !important;
}
.drag-and-drop-draggable:empty {
display: none;
}
.drag-and-drop-draggable * {
cursor: grabbing !important;
}
.auto-scaling-textarea-container {
position: relative;
}
.auto-scaling-textarea {
position: absolute;
inset: 0;
background: none;
border: none;
font: inherit;
resize: none;
color: inherit;
overflow: hidden;
}
.auto-scaling-textarea:focus {
outline: none;
}
.auto-scaling-textarea-mimic {
white-space: pre-wrap;
min-height: 1lh;
user-select: none;
word-wrap: break-word;
font: inherit;
visibility: hidden;
}
.square-18 { width: 1.8rem; height: 1.8rem; }
.square-20 { width: 2rem; height: 2rem; }
.square-27 { width: 2.7rem; height: 2.7rem; }
.square-30 { width: 3rem; height: 3rem; }
.square-32 { width: 3.2rem; height: 3.2rem; }
.square-40 { width: 4rem; height: 4rem; }
.cursor-help { cursor: help; }
.rounded { border-radius: var(--border-radius); }
.break-all { word-break: break-all; }
.text-left { text-align: left; }
.text-right { text-align: right; }
@ -504,6 +602,7 @@ details[open] .summary::after {
.shrink { flex-shrink: 1; }
.shrink-0 { flex-shrink: 0; }
.min-width-0 { min-width: 0; }
.width-max-content { width: max-content; }
.max-width-100 { max-width: 100%; }
.block { display: block; }
.inline-block { display: inline-block; }
@ -522,6 +621,7 @@ details[open] .summary::after {
.grow { flex-grow: 1; }
.flex-column { flex-direction: column; }
.items-center { align-items: center; }
.self-center { align-self: center; }
.items-start { align-items: start; }
.items-end { align-items: end; }
.gap-5 { gap: 0.5rem; }
@ -531,10 +631,14 @@ details[open] .summary::after {
.gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; }
.gap-30 { gap: 3rem; }
.gap-35 { gap: 3.5rem; }
.gap-40 { gap: 4rem; }
.gap-45 { gap: 4.5rem; }
.gap-50 { gap: 5rem; }
.gap-55 { gap: 5.5rem; }
.margin-left-auto { margin-left: auto; }
.margin-inline-auto { margin-inline: auto; }
.margin-top-2 { margin-top: 0.2rem; }
.margin-top-3 { margin-top: 0.3rem; }
.margin-top-5 { margin-top: 0.5rem; }
@ -562,7 +666,11 @@ details[open] .summary::after {
.padding-widget { padding: var(--widget-content-padding); }
.padding-block-widget { padding-block: var(--widget-content-vertical-padding); }
.padding-inline-widget { padding-inline: var(--widget-content-horizontal-padding); }
.padding-inline-10 { padding-inline: 1rem; }
.pointer-events-none { pointer-events: none; }
.select-none { user-select: none; }
.padding-block-5 { padding-block: 0.5rem; }
.padding-10 { padding: 1rem; }
.scale-half { transform: scale(0.5); }
.list { --list-half-gap: 0rem; }
.list-gap-2 { --list-half-gap: 0.1rem; }
@ -573,3 +681,7 @@ details[open] .summary::after {
.list-gap-20 { --list-half-gap: 1rem; }
.list-gap-24 { --list-half-gap: 1.2rem; }
.list-gap-34 { --list-half-gap: 1.7rem; }
@media (max-width: 1190px) {
.size-base-on-mobile { font-size: var(--font-size-base); }
}

@ -20,12 +20,11 @@
background-color: var(--color-widget-background-highlight);
border-radius: var(--border-radius);
padding: 0.5rem;
opacity: 0.7;
flex-shrink: 0;
}
.bookmarks-icon {
width: 20px;
height: 20px;
opacity: 0.8;
.bookmarks-grid {
display: grid;
gap: 2.5rem 0;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
}

@ -1,26 +0,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;
}

@ -1,36 +0,0 @@
.monitor-site-icon {
display: block;
opacity: 0.8;
filter: grayscale(0.4);
object-fit: contain;
aspect-ratio: 1 / 1;
width: 3.2rem;
position: relative;
top: -0.1rem;
transition: filter 0.3s, opacity 0.3s;
}
.monitor-site-icon.flat-icon {
opacity: 0.7;
}
.monitor-site:hover .monitor-site-icon {
opacity: 1;
}
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
filter: grayscale(0);
}
.monitor-site-status-icon {
flex-shrink: 0;
margin-left: auto;
width: 2rem;
height: 2rem;
}
.monitor-site-status-icon-compact {
width: 1.8rem;
height: 1.8rem;
flex-shrink: 0;
}

@ -0,0 +1,129 @@
.todo-widget {
padding-top: 4rem;
}
.todo-plus-icon {
--icon-color: var(--color-text-subdue);
position: relative;
width: 1.4rem;
height: 1.4rem;
}
.todo-plus-icon::before, .todo-plus-icon::after {
content: "";
position: absolute;
background-color: var(--icon-color);
transition: background-color .2s;
}
.todo-plus-icon::before {
width: 2px;
inset-block: 0.2rem;
left: 50%;
transform: translateX(-50%);
}
.todo-plus-icon::after {
height: 2px;
inset-inline: 0.2rem;
top: 50%;
transform: translateY(-50%);
}
.todo-input textarea::placeholder {
color: var(--color-text-base-muted);
}
.todo-input {
position: relative;
color: var(--color-text-highlight);
}
.todo-input:focus-within .todo-plus-icon {
--icon-color: var(--color-text-base);
}
.todo-item {
transform-origin: center;
padding: 0.5rem 0;
}
.todo-item-checkbox {
-webkit-appearance: none;
appearance: none;
border: 2px solid var(--color-text-subdue);
width: 1.4rem;
height: 1.4rem;
position: relative;
cursor: pointer;
border-radius: 0.3rem;
transition: border-color .2s;
}
.todo-item-checkbox::before {
content: "";
inset: -1rem;
position: absolute;
}
.todo-item-checkbox::after {
content: '';
position: absolute;
inset: 0.3rem;
border-radius: 0.1rem;
opacity: 0;
transition: opacity .2s;
}
.todo-item-checkbox:checked::after {
background: var(--color-primary);
opacity: 1;
}
.todo-item-checkbox:focus-visible {
outline: none;
border-color: var(--color-primary);
}
.todo-item-text {
color: var(--color-text-base);
transition: color .35s;
}
.todo-item-text:focus {
color: var(--color-text-highlight);
}
.todo-item-drag-handle {
position: absolute;
top: -0.5rem;
inset-inline: 0;
height: 1rem;
cursor: grab;
}
.todo-item.is-being-dragged .todo-item-drag-handle {
height: 3rem;
top: -1.5rem;
}
.todo-item:has(.todo-item-checkbox:checked) .todo-item-text {
text-decoration: line-through;
color: var(--color-text-subdue);
}
.todo-item-delete {
width: 1.5rem;
height: 1.5rem;
opacity: 0;
transition: opacity .2s;
outline-offset: .5rem;
}
.todo-item:hover .todo-item-delete, .todo-item:focus-within .todo-item-delete {
opacity: 1;
}
.todo-item.is-being-dragged .todo-item-delete {
opacity: 0;
}

@ -2,10 +2,8 @@
@import "widget-calendar.css";
@import "widget-clock.css";
@import "widget-dns-stats.css";
@import "widget-docker-containers.css";
@import "widget-group.css";
@import "widget-markets.css";
@import "widget-monitor.css";
@import "widget-reddit.css";
@import "widget-releases.css";
@import "widget-rss.css";
@ -14,6 +12,7 @@
@import "widget-twitch.css";
@import "widget-videos.css";
@import "widget-weather.css";
@import "widget-todo.css";
@import "forum-posts.css";
.widget-error-header {
@ -43,6 +42,10 @@
opacity: 0.6;
}
.head-widgets {
margin-bottom: var(--widget-gap);
}
.widget-content {
container-type: inline-size;
container-name: widget;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 607 B

@ -0,0 +1,7 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="26" height="26" rx="3" fill="#151519"/>
<rect x="2" y="2" width="10" height="22" rx="2" fill="#ededed"/>
<rect x="14" y="2" width="10" height="10" rx="2" fill="#ededed"/>
<path d="M16.3018 5.04032L17.328 4H22V8.72984L20.9014 9.81855V6.49193C20.9014 6.35484 20.9095 6.21774 20.9256 6.08065C20.9497 5.93548 20.9859 5.81855 21.0342 5.72984L16.7847 10L16 9.2379L20.3099 4.93145C20.2294 4.97984 20.1167 5.0121 19.9718 5.02823C19.827 5.03629 19.674 5.04032 19.5131 5.04032H16.3018Z" fill="#151519"/>
<rect x="14" y="14" width="10" height="10" rx="2" fill="#ededed"/>
</svg>

After

Width:  |  Height:  |  Size: 677 B

@ -31,3 +31,28 @@ export function slideFade({
},
};
}
export function animateReposition(
element,
onAnimEnd,
animOptions = { duration: 400, easing: easeOutQuint }
) {
const rectBefore = element.getBoundingClientRect();
return () => {
const rectAfter = element.getBoundingClientRect();
const offsetY = rectBefore.y - rectAfter.y;
const offsetX = rectBefore.x - rectAfter.x;
element.animate({
keyframes: [
{ transform: `translate(${offsetX}px, ${offsetY}px)` },
{ transform: 'none' }
],
options: animOptions
}, onAnimEnd);
return rectAfter;
}
}

@ -30,7 +30,7 @@ const [datesEntranceLeft, datesEntranceRight] = directions(
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
export default function(element) {
element.swap(Calendar(
element.swapWith(Calendar(
Number(element.dataset.firstDayOfWeek ?? 1)
));
}

@ -0,0 +1,141 @@
import { find } from "./templating.js";
const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate";
const showPasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" 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="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>`;
const hidePasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" 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="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>`;
const container = find("#login-container");
const usernameInput = find("#username");
const passwordInput = find("#password");
const errorMessage = find("#error-message");
const loginButton = find("#login-button");
const toggleVisibilityButton = find("#toggle-password-visibility");
const state = {
lastUsername: "",
lastPassword: "",
isLoading: false,
isRateLimited: false
};
const lang = {
showPassword: "Show password",
hidePassword: "Hide password",
incorrectCredentials: "Incorrect username or password",
rateLimited: "Too many login attempts, try again in a few minutes",
unknownError: "An error occurred, please try again",
};
container.clearStyles("display");
setTimeout(() => usernameInput.focus(), 200);
toggleVisibilityButton
.html(showPasswordSVG)
.attr("title", lang.showPassword)
.on("click", function() {
if (passwordInput.type === "password") {
passwordInput.type = "text";
toggleVisibilityButton.html(hidePasswordSVG).attr("title", lang.hidePassword);
return;
}
passwordInput.type = "password";
toggleVisibilityButton.html(showPasswordSVG).attr("title", lang.showPassword);
});
function enableLoginButtonIfCriteriaMet() {
const usernameValue = usernameInput.value.trim();
const passwordValue = passwordInput.value.trim();
const usernameValid = usernameValue.length >= 3;
const passwordValid = passwordValue.length >= 6;
const isUsingLastCredentials =
usernameValue === state.lastUsername
&& passwordValue === state.lastPassword;
loginButton.disabled = !(
usernameValid
&& passwordValid
&& !isUsingLastCredentials
&& !state.isLoading
&& !state.isRateLimited
);
}
function handleLoginWithEnter(event) {
if (event.key !== "Enter") return;
if (loginButton.disabled) return;
document.activeElement.blur();
handleLoginAttempt();
}
usernameInput
.on("input", enableLoginButtonIfCriteriaMet)
.on("keydown", handleLoginWithEnter);
passwordInput
.on("input", enableLoginButtonIfCriteriaMet)
.on("keydown", handleLoginWithEnter);
async function handleLoginAttempt() {
state.lastUsername = usernameInput.value;
state.lastPassword = passwordInput.value;
errorMessage.text("");
loginButton.disable();
state.isLoading = true;
const response = await fetch(AUTH_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: usernameInput.value,
password: passwordInput.value
}),
});
state.isLoading = false;
if (response.status === 200) {
setTimeout(() => { window.location.href = pageData.baseURL + "/"; }, 300);
container.animate({
keyframes: [{ offset: 1, transform: "scale(0.95)", opacity: 0 }],
options: { duration: 300, easing: "ease", fill: "forwards" }}
);
find("footer")?.animate({
keyframes: [{ offset: 1, opacity: 0 }],
options: { duration: 300, easing: "ease", fill: "forwards", delay: 50 }
});
} else if (response.status === 401) {
errorMessage.text(lang.incorrectCredentials);
passwordInput.focus();
} else if (response.status === 429) {
errorMessage.text(lang.rateLimited);
state.isRateLimited = true;
const retryAfter = response.headers.get("Retry-After") || 30;
setTimeout(() => {
state.lastUsername = "";
state.lastPassword = "";
state.isRateLimited = false;
enableLoginButtonIfCriteriaMet();
}, retryAfter * 1000);
} else {
errorMessage.text(lang.unknownError);
passwordInput.focus();
}
}
loginButton.disable().on("click", handleLoginAttempt);

@ -37,9 +37,6 @@ export function setupMasonries() {
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]);
}

@ -1,6 +1,7 @@
import { setupPopovers } from './popover.js';
import { setupMasonries } from './masonry.js';
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
import { elem, find, findAll } from './templating.js';
async function fetchPageContent(pageData) {
// TODO: handle non 200 status codes/time outs
@ -104,6 +105,7 @@ function setupSearchBoxes() {
for (let i = 0; i < searchWidgets.length; i++) {
const widget = searchWidgets[i];
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
const target = widget.dataset.target || "_blank";
const newTab = widget.dataset.newTab === "true";
const inputElement = widget.getElementsByClassName("search-input")[0];
const bangElement = widget.getElementsByClassName("search-bang")[0];
@ -143,7 +145,7 @@ function setupSearchBoxes() {
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
window.open(url, '_blank').focus();
window.open(url, target).focus();
} else {
window.location.href = url;
}
@ -192,7 +194,7 @@ function setupSearchBoxes() {
document.addEventListener("keydown", (event) => {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
if (event.key != "s") return;
if (event.code != "KeyS") return;
inputElement.focus();
event.preventDefault();
@ -640,6 +642,17 @@ async function setupCalendars() {
calendar.default(elems[i]);
}
async function setupTodos() {
const elems = Array.from(document.getElementsByClassName("todo"));
if (elems.length == 0) return;
const todo = await import ('./todo.js');
for (let i = 0; i < elems.length; i++){
todo.default(elems[i]);
}
}
function setupTruncatedElementTitles() {
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
@ -649,11 +662,90 @@ function setupTruncatedElementTitles() {
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (element.title === "") element.title = element.textContent;
if (element.getAttribute("title") === null)
element.title = element.innerText.trim().replace(/\s+/g, " ");
}
}
async function changeTheme(key, onChanged) {
const themeStyleElem = find("#theme-style");
const response = await fetch(`${pageData.baseURL}/api/set-theme/${key}`, {
method: "POST",
});
if (response.status != 200) {
alert("Failed to set theme: " + response.statusText);
return;
}
const newThemeStyle = await response.text();
const tempStyle = elem("style")
.html("* { transition: none !important; }")
.appendTo(document.head);
themeStyleElem.html(newThemeStyle);
document.documentElement.setAttribute("data-theme", key);
document.documentElement.setAttribute("data-scheme", response.headers.get("X-Scheme"));
typeof onChanged == "function" && onChanged();
setTimeout(() => { tempStyle.remove(); }, 10);
}
function initThemePicker() {
const themeChoicesInMobileNav = find(".mobile-navigation .theme-choices");
if (!themeChoicesInMobileNav) return;
const themeChoicesInHeader = find(".header-container .theme-choices");
if (themeChoicesInHeader) {
themeChoicesInHeader.replaceWith(
themeChoicesInMobileNav.cloneNode(true)
);
}
const presetElems = findAll(".theme-choices .theme-preset");
let themePreviewElems = document.getElementsByClassName("current-theme-preview");
let isLoading = false;
presetElems.forEach((presetElement) => {
const themeKey = presetElement.dataset.key;
if (themeKey === undefined) {
return;
}
if (themeKey == pageData.theme) {
presetElement.classList.add("current");
}
presetElement.addEventListener("click", () => {
if (themeKey == pageData.theme) return;
if (isLoading) return;
isLoading = true;
changeTheme(themeKey, function() {
isLoading = false;
pageData.theme = themeKey;
presetElems.forEach((e) => { e.classList.remove("current"); });
Array.from(themePreviewElems).forEach((preview) => {
preview.querySelector(".theme-preset").replaceWith(
presetElement.cloneNode(true)
);
})
presetElems.forEach((e) => {
if (e.dataset.key != themeKey) return;
e.classList.add("current");
});
});
});
})
}
async function setupPage() {
initThemePicker();
const pageElement = document.getElementById("page");
const pageContentElement = document.getElementById("page-content");
const pageContent = await fetchPageContent(pageData);
@ -664,6 +756,7 @@ async function setupPage() {
setupPopovers();
setupClocks()
await setupCalendars();
await setupTodos();
setupCarousels();
setupSearchBoxes();
setupCollapsibleLists();

@ -38,6 +38,8 @@ function handleMouseEnter(event) {
if (activeTarget !== target) {
hidePopover();
requestAnimationFrame(() => requestAnimationFrame(showPopover));
} else if (activeTarget.dataset.popoverTrigger === "click") {
hidePopover();
}
return;
@ -100,11 +102,14 @@ function showPopover() {
contentElement.style.maxWidth = contentMaxWidth;
activeTarget.classList.add("popover-active");
document.addEventListener("keydown", handleHidePopoverOnEscape);
window.addEventListener("scroll", queueRepositionContainer);
window.addEventListener("resize", queueRepositionContainer);
observer.observe(containerElement);
}
function repositionContainer() {
if (activeTarget === null) return;
containerElement.style.display = "block";
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
@ -125,7 +130,7 @@ 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) + -1 + "px");
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (document.documentElement.clientWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
} else {
containerElement.style.removeProperty("right");
containerElement.style.left = left + "px";
@ -157,7 +162,11 @@ function hidePopover() {
activeTarget.classList.remove("popover-active");
containerElement.style.display = "none";
containerElement.style.removeProperty("top");
containerElement.style.removeProperty("left");
containerElement.style.removeProperty("right");
document.removeEventListener("keydown", handleHidePopoverOnEscape);
window.removeEventListener("scroll", queueRepositionContainer);
window.removeEventListener("resize", queueRepositionContainer);
observer.unobserve(containerElement);
@ -181,7 +190,12 @@ export function setupPopovers() {
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
target.addEventListener("mouseenter", handleMouseEnter);
if (target.dataset.popoverTrigger === "click") {
target.addEventListener("click", handleMouseEnter);
} else {
target.addEventListener("mouseenter", handleMouseEnter);
}
target.addEventListener("mouseleave", handleMouseLeave);
}
}

@ -29,6 +29,15 @@ export function findAll(selector) {
return document.querySelectorAll(selector);
}
HTMLCollection.prototype.map = function(fn) {
return Array.from(this).map(fn);
}
HTMLCollection.prototype.indexOf = function(element) {
return Array.prototype.indexOf.call(this, element);
}
const ep = HTMLElement.prototype;
const fp = DocumentFragment.prototype;
const tp = Text.prototype;
@ -110,7 +119,7 @@ ep.appendTo = function(parent) {
return this;
}
ep.swap = function(element) {
ep.swapWith = function(element) {
this.replaceWith(element);
return element;
}
@ -147,6 +156,22 @@ ep.styles = function(s) {
return this;
}
ep.clearStyles = function(...props) {
for (let i = 0; i < props.length; i++)
this.style.removeProperty(props[i]);
return this;
}
ep.disable = function() {
this.disabled = true;
return this;
}
ep.enable = function() {
this.disabled = false;
return this;
}
const epAnimate = ep.animate;
ep.animate = function(anim, callback) {
const a = epAnimate.call(this, anim.keyframes, anim.options);

@ -0,0 +1,442 @@
import { elem, fragment } from "./templating.js";
import { animateReposition } from "./animations.js";
import { clamp, Vec2, toggleableEvents, throttledDebounce } from "./utils.js";
const trashIconSvg = `<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z" clip-rule="evenodd" />
</svg>`;
export default function(element) {
element.swapWith(
Todo(element.dataset.todoId)
)
}
function itemAnim(height, entrance = true) {
const visible = { height: height + "px", opacity: 1 };
const hidden = { height: "0", opacity: 0, padding: "0" };
return {
keyframes: [
entrance ? hidden : visible,
entrance ? visible : hidden
],
options: { duration: 200, easing: "ease" }
}
}
function inputMarginAnim(entrance = true) {
const amount = "1.5rem";
return {
keyframes: [
{ marginBottom: entrance ? "0px" : amount },
{ marginBottom: entrance ? amount : "0" }
],
options: { duration: 200, easing: "ease", fill: "forwards" }
}
}
function loadFromLocalStorage(id) {
return JSON.parse(localStorage.getItem(`todo-${id}`) || "[]");
}
function saveToLocalStorage(id, data) {
localStorage.setItem(`todo-${id}`, JSON.stringify(data));
}
function Item(unserialize = {}, onUpdate, onDelete, onEscape, onDragStart) {
let item, input, inputArea;
const serializeable = {
text: unserialize.text || "",
checked: unserialize.checked || false
};
item = elem().classes("todo-item", "flex", "gap-10", "items-center").append(
elem("input")
.classes("todo-item-checkbox", "shrink-0")
.styles({ marginTop: "-0.1rem" })
.attrs({ type: "checkbox" })
.on("change", (e) => {
serializeable.checked = e.target.checked;
onUpdate();
})
.tap(self => self.checked = serializeable.checked),
input = autoScalingTextarea(textarea => inputArea = textarea
.classes("todo-item-text")
.attrs({
placeholder: "empty task",
spellcheck: "false"
})
.on("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
} else if (e.key === "Escape") {
e.preventDefault();
onEscape();
}
})
.on("input", () => {
serializeable.text = inputArea.value;
onUpdate();
})
).classes("min-width-0", "grow").append(
elem()
.classes("todo-item-drag-handle")
.on("mousedown", (e) => onDragStart(e, item))
),
elem("button")
.classes("todo-item-delete", "shrink-0")
.html(trashIconSvg)
.on("click", () => onDelete(item))
);
input.component.setValue(serializeable.text);
return item.component({
focusInput: () => inputArea.focus(),
serialize: () => serializeable
});
}
function Todo(id) {
let items, input, inputArea, inputContainer, lastAddedItem;
let queuedForRemoval = 0;
let reorderable;
let isDragging = false;
const onDragEnd = () => isDragging = false;
const onDragStart = (event, element) => {
isDragging = true;
reorderable.component.onDragStart(event, element);
};
const saveItems = () => {
if (isDragging) return;
saveToLocalStorage(
id, items.children.map(item => item.component.serialize())
);
};
const onItemRepositioned = () => saveItems();
const debouncedOnItemUpdate = throttledDebounce(saveItems, 10, 1000);
const onItemDelete = (item) => {
if (lastAddedItem === item) lastAddedItem = null;
const height = item.clientHeight;
queuedForRemoval++;
item.animate(itemAnim(height, false), () => {
item.remove();
queuedForRemoval--;
saveItems();
});
if (items.children.length - queuedForRemoval === 0)
inputContainer.animate(inputMarginAnim(false));
};
const newItem = (data) => Item(
data,
debouncedOnItemUpdate,
onItemDelete,
() => inputArea.focus(),
onDragStart
);
const addNewItem = (itemText, prepend) => {
const totalItemsBeforeAppending = items.children.length;
const item = lastAddedItem = newItem({ text: itemText });
prepend ? items.prepend(item) : items.append(item);
saveItems();
const height = item.clientHeight;
item.animate(itemAnim(height));
if (totalItemsBeforeAppending === 0)
inputContainer.animate(inputMarginAnim());
};
const handleInputKeyDown = (e) => {
switch (e.key) {
case "Enter":
e.preventDefault();
const value = e.target.value.trim();
if (value === "") return;
addNewItem(value, e.ctrlKey);
input.component.setValue("");
break;
case "Escape":
e.target.blur();
break;
case "ArrowDown":
if (!lastAddedItem) return;
e.preventDefault();
lastAddedItem.component.focusInput();
break;
}
};
items = elem()
.classes("todo-items")
.append(
...loadFromLocalStorage(id).map(data => newItem(data))
);
return fragment().append(
inputContainer = elem()
.classes("todo-input", "flex", "gap-10", "items-center")
.classesIf(items.children.length > 0, "margin-bottom-15")
.styles({ paddingRight: "2.5rem" })
.append(
elem().classes("todo-plus-icon", "shrink-0"),
input = autoScalingTextarea(textarea => inputArea = textarea
.on("keydown", handleInputKeyDown)
.attrs({
placeholder: "Add a task",
spellcheck: "false"
})
).classes("grow", "min-width-0")
),
reorderable = verticallyReorderable(items, onItemRepositioned, onDragEnd),
);
}
// See https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
export function autoScalingTextarea(yieldTextarea = null) {
let textarea, mimic;
const updateMimic = (newValue) => mimic.text(newValue + ' ');
const container = elem().classes("auto-scaling-textarea-container").append(
textarea = elem("textarea")
.classes("auto-scaling-textarea")
.on("input", () => updateMimic(textarea.value)),
mimic = elem().classes("auto-scaling-textarea-mimic")
)
if (typeof yieldTextarea === "function") yieldTextarea(textarea);
return container.component({ setValue: (newValue) => {
textarea.value = newValue;
updateMimic(newValue);
}});
}
export function verticallyReorderable(itemsContainer, onItemRepositioned, onDragEnd) {
const classToAddToDraggedItem = "is-being-dragged";
const currentlyBeingDragged = {
element: null,
initialIndex: null,
clientOffset: Vec2.new(),
};
const decoy = {
element: null,
currentIndex: null,
};
const draggableContainer = {
element: null,
initialRect: null,
};
const lastClientPos = Vec2.new();
let initialScrollY = null;
let addDocumentEvents, removeDocumentEvents;
const handleReposition = (event) => {
if (currentlyBeingDragged.element == null) return;
if (event.clientY !== undefined && event.clientX !== undefined)
lastClientPos.setFromEvent(event);
const client = lastClientPos;
const container = draggableContainer;
const item = currentlyBeingDragged;
const scrollOffset = window.scrollY - initialScrollY;
const offsetY = client.y - container.initialRect.y - item.clientOffset.y + scrollOffset;
const offsetX = client.x - container.initialRect.x - item.clientOffset.x;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
const viewportWidth = window.innerWidth - scrollbarWidth;
const confinedX = clamp(
offsetX,
-container.initialRect.x,
viewportWidth - container.initialRect.x - container.initialRect.width
);
container.element.styles({
transform: `translate(${confinedX}px, ${offsetY}px)`,
});
const containerTop = client.y - item.clientOffset.y;
const containerBottom = client.y + container.initialRect.height - item.clientOffset.y;
let swapWithLast = true;
let swapWithIndex = null;
for (let i = 0; i < itemsContainer.children.length; i++) {
const childRect = itemsContainer.children[i].getBoundingClientRect();
const topThreshold = childRect.top + childRect.height * .6;
const bottomThreshold = childRect.top + childRect.height * .4;
if (containerBottom > topThreshold) {
if (containerTop < bottomThreshold && i != decoy.currentIndex) {
swapWithIndex = i;
swapWithLast = false;
break;
}
continue;
};
swapWithLast = false;
if (i == decoy.currentIndex || i-1 == decoy.currentIndex) break;
swapWithIndex = (i < decoy.currentIndex) ? i : i-1;
break;
}
const lastItemIndex = itemsContainer.children.length - 1;
if (swapWithLast && decoy.currentIndex != lastItemIndex)
swapWithIndex = lastItemIndex;
if (swapWithIndex === null)
return;
const diff = swapWithIndex - decoy.currentIndex;
if (Math.abs(diff) > 1) {
swapWithIndex = decoy.currentIndex + Math.sign(diff);
}
const siblingToSwapWith = itemsContainer.children[swapWithIndex];
if (siblingToSwapWith.isCurrentlyAnimating) return;
const animateDecoy = animateReposition(decoy.element);
const animateChild = animateReposition(
siblingToSwapWith,
() => {
siblingToSwapWith.isCurrentlyAnimating = false;
handleReposition({
clientX: client.x,
clientY: client.y,
});
}
);
siblingToSwapWith.isCurrentlyAnimating = true;
if (swapWithIndex > decoy.currentIndex)
decoy.element.before(siblingToSwapWith);
else
decoy.element.after(siblingToSwapWith);
decoy.currentIndex = itemsContainer.children.indexOf(decoy.element);
animateDecoy();
animateChild();
}
const handleRelease = (event) => {
if (event.buttons != 0) return;
removeDocumentEvents();
const item = currentlyBeingDragged;
const element = item.element;
element.styles({ pointerEvents: "none" });
const animate = animateReposition(element, () => {
item.element = null;
element
.clearClasses(classToAddToDraggedItem)
.clearStyles("pointer-events");
if (typeof onDragEnd === "function") onDragEnd(element);
if (item.initialIndex != decoy.currentIndex && typeof onItemRepositioned === "function")
onItemRepositioned(element, item.initialIndex, decoy.currentIndex);
});
decoy.element.swapWith(element);
draggableContainer.element.append(decoy.element);
draggableContainer.element.clearStyles("transform", "width");
item.element = null;
decoy.element.remove();
animate();
}
const preventDefault = (event) => {
event.preventDefault();
};
const handleGrab = (event, element) => {
if (currentlyBeingDragged.element != null) return;
event.preventDefault();
const item = currentlyBeingDragged;
if (item.element != null) return;
addDocumentEvents();
initialScrollY = window.scrollY;
const client = lastClientPos.setFromEvent(event);
const elementRect = element.getBoundingClientRect();
item.element = element;
item.initialIndex = decoy.currentIndex = itemsContainer.children.indexOf(element);
item.clientOffset.set(client.x - elementRect.x, client.y - elementRect.y);
// We use getComputedStyle here to get width and height because .clientWidth and .clientHeight
// return integers and not the real float values, which can cause the decoy to be off by a pixel
const elementStyle = getComputedStyle(element);
const initialWidth = elementStyle.width;
decoy.element = elem().classes("drag-and-drop-decoy").styles({
height: elementStyle.height,
width: initialWidth,
});
const container = draggableContainer;
element.swapWith(decoy.element);
container.element.append(element);
element.classes(classToAddToDraggedItem);
decoy.element.animate({
keyframes: [{ transform: "scale(.9)", opacity: 0, offset: 0 }],
options: { duration: 300, easing: "ease" }
})
container.element.styles({ width: initialWidth, transform: "none" });
container.initialRect = container.element.getBoundingClientRect();
const offsetY = elementRect.y - container.initialRect.y;
const offsetX = elementRect.x - container.initialRect.x;
container.element.styles({ transform: `translate(${offsetX}px, ${offsetY}px)` });
}
[addDocumentEvents, removeDocumentEvents] = toggleableEvents(document, {
"mousemove": handleReposition,
"scroll": handleReposition,
"mousedown": preventDefault,
"contextmenu": preventDefault,
"mouseup": handleRelease,
});
return elem().classes("drag-and-drop-container").append(
itemsContainer,
draggableContainer.element = elem().classes("drag-and-drop-draggable")
).component({
onDragStart: handleGrab
});
}

@ -36,3 +36,46 @@ export function openURLInNewTab(url, focus = true) {
if (focus && newWindow != null) newWindow.focus();
}
export class Vec2 {
constructor(x, y) {
this.x = x;
this.y = y;
}
static new(x = 0, y = 0) {
return new Vec2(x, y);
}
static fromEvent(event) {
return new Vec2(event.clientX, event.clientY);
}
setFromEvent(event) {
this.x = event.clientX;
this.y = event.clientY;
return this;
}
set(x, y) {
this.x = x;
this.y = y;
return this;
}
}
export function toggleableEvents(element, eventToHandlerMap) {
return [
() => {
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
element.addEventListener(event, handler);
}
},
() => {
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
element.removeEventListener(event, handler);
}
}
];
}

@ -1,14 +0,0 @@
{
"name": "Glance",
"display": "standalone",
"background_color": "#151519",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "app-icon.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

@ -21,6 +21,9 @@ var globalTemplateFunctions = template.FuncMap{
"safeURL": func(str string) template.URL {
return template.URL(str)
},
"safeHTML": func(str string) template.HTML {
return template.HTML(str)
},
"absInt": func(i int) int {
return int(math.Abs(float64(i)))
},

@ -0,0 +1,21 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="bookmarks-grid">
{{- range .Links }}
<li class="icon-parent text-center padding-inline-10">
<a href="{{ .URL | safeURL }}" class="block" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">
{{- if ne "" .Icon.URL }}
<div class="padding-10 rounded bg-highlight width-max-content margin-inline-auto">
{{ .Icon.ElemWithClass "square-40" }}
</div>
{{- end }}
<p class="margin-top-7 block text-truncate color-highlight size-h4">{{ .Title }}</p>
</a>
{{- if .Description }}
<div class="margin-top-5 text-truncate-2-lines text-compact">{{ .Description }}</div>
{{- end }}
</li>
{{- end }}
</ul>
{{ end }}

@ -10,10 +10,10 @@
<ul class="list list-gap-2">
{{- range .Links }}
<li>
<div class="flex items-center gap-10">
<div class="flex items-center gap-10 icon-parent">
{{- if ne "" .Icon.URL }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ .Icon.ElemWithClass "square-20" }}
</div>
{{- end }}
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>

@ -3,9 +3,9 @@
{{- define "widget-content" }}
<ul class="dynamic-columns list-gap-20 list-with-separator">
{{- range .Containers }}
<li class="docker-container flex items-center gap-15">
<li class="flex items-center gap-15 icon-parent">
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px" aria-hidden="true">
<img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ .Icon.ElemWithClass "square-27" }}
<div data-popover-html>
<div class="color-highlight text-truncate block">{{ .Image }}</div>
<div>{{ .StateText }}</div>
@ -14,7 +14,7 @@
{{- 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>
<div class="color-highlight">{{ .Name }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
</li>
{{- end }}
</ul>
@ -22,11 +22,11 @@
</div>
</div>
<div class="min-width-0">
<div class="min-width-0 grow">
{{- if .URL }}
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Name }}</a>
{{- else }}
<div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
<div class="color-highlight text-truncate size-title-dynamic">{{ .Name }}</div>
{{- end }}
{{- if .Description }}
<div class="text-truncate">{{ .Description }}</div>
@ -47,19 +47,19 @@
{{- 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" aria-hidden="true">
<svg class="square-20" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<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" aria-hidden="true">
<svg class="square-20" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<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" aria-hidden="true">
<svg class="square-20" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<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" aria-hidden="true">
<svg class="square-20" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<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 }}

@ -1,23 +1,32 @@
<!DOCTYPE html>
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
<html lang="en" id="top" data-theme="{{ .Request.Theme.Key }}" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
<head>
{{ block "document-head-before" . }}{{ end }}
<script>
if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
const pageData = {
/*{{ if .Page }}*/slug: "{{ .Page.Slug }}",/*{{ end }}*/
baseURL: "{{ .App.Config.Server.BaseURL }}",
theme: "{{ .Request.Theme.Key }}",
};
</script>
<title>{{ block "document-title" . }}{{ end }}</title>
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
<meta charset="UTF-8">
<meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance">
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.AssetPath "app-icon.png" }}'>
<link rel="manifest" href='{{ .App.AssetPath "manifest.json" }}'>
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href='{{ .App.AssetPath "css/bundle.css" }}'>
<script type="module" src='{{ .App.AssetPath "js/main.js" }}'></script>
<meta name="apple-mobile-web-app-title" content="{{ .App.Config.Branding.AppName }}">
<meta name="theme-color" content="{{ .Request.Theme.BackgroundColorAsHex }}">
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
<link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
<link rel="icon" type="{{ .App.Config.Branding.FaviconType }}" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
<style id="theme-style">{{ .Request.Theme.CSS }}</style>
{{ if .App.Config.Theme.CustomCSSFile }}<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">{{ end }}
{{ block "document-head-after" . }}{{ end }}
{{ if .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
</head>
<body>
{{ template "document-body" . }}

@ -0,0 +1,11 @@
{{ if not .App.Config.Branding.HideFooter }}
<footer class="footer flex items-center flex-column">
{{ if eq "" .App.Config.Branding.CustomFooter }}
<div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div>
{{ else }}
{{ .App.Config.Branding.CustomFooter }}
{{ end }}
</footer>
{{ end }}

@ -23,7 +23,7 @@
{{- end }}
{{- end }}
<div class="grow min-width-0">
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
<a href="{{ .DiscussionUrl | safeURL }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
{{- if .Tags }}
<div class="inline-block forum-post-tags-container">
<ul class="attachments">
@ -36,7 +36,7 @@
<ul class="list-horizontal-text flex-nowrap text-compact">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
<li class="shrink-0{{ if .TargetUrl }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
<li class="shrink-0{{ if .TargetUrl | safeURL }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
{{- if .TargetUrl }}
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{- end }}

@ -0,0 +1,53 @@
{{- template "document.html" . }}
{{- define "document-title" }}Login{{ end }}
{{- define "document-head-before" }}
<link rel="preload" href='{{ .App.StaticAssetPath "js/templating.js" }}' as="script"/>
<link rel="prefetch" href='{{ .App.StaticAssetPath "js/page.js" }}'/>
{{- end }}
{{- define "document-head-after" }}
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/login.css" }}'>
<script type="module" src='{{ .App.StaticAssetPath "js/login.js" }}'></script>
{{- end }}
{{- define "document-body" }}
<div class="flex flex-column body-content">
<div class="flex grow items-center justify-center" style="padding-bottom: 5rem">
<h1 class="visually-hidden">Login</h1>
<main id="login-container" class="grow login-bounds" style="display: none;">
<div class="animate-entrance">
<label class="form-label widget-header" for="username">Username</label>
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
</svg>
<input type="text" id="username" class="input" placeholder="Enter your username" autocomplete="off">
</div>
</div>
<div class="animate-entrance">
<label class="form-label widget-header margin-top-20" for="password">Password</label>
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
</svg>
<input type="password" id="password" class="input" placeholder="********" autocomplete="off">
<button class="toggle-password-visibility" id="toggle-password-visibility" tabindex="-1"></button>
</div>
</div>
<div class="login-error-message" id="error-message"></div>
<button class="login-button animate-entrance" id="login-button">
<div>LOGIN</div>
<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</main>
</div>
{{ template "footer.html" . }}
</div>
{{- end }}

@ -0,0 +1,15 @@
{
"name": "{{ .App.Config.Branding.AppName }}",
"display": "standalone",
"background_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
"theme_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "{{ .App.Config.Branding.AppIconURL }}",
"type": "image/png",
"sizes": "512x512"
}
]
}

@ -11,7 +11,7 @@
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-linejoin="round" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg>
</a>

@ -24,13 +24,13 @@
<a class="size-title-dynamic color-highlight text-truncate block grow" href="{{ .URL | safeURL }}" {{ 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 }}">
<div class="square-18 shrink-0" 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 }}">
<div class="square-18 shrink-0" 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>

@ -5,7 +5,7 @@
<ul class="dynamic-columns list-gap-20 list-with-separator">
{{ range .Sites }}
{{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }} {{ continue }} {{ end }}
<div class="monitor-site flex items-center gap-15">
<div class="monitor-site flex items-center gap-15 icon-parent">
{{ template "site" . }}
</div>
{{ end }}
@ -22,7 +22,7 @@
{{ define "site" }}
{{ if .Icon.URL }}
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ .Icon.ElemWithClass "square-32" }}
{{ end }}
<div class="grow min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
@ -38,13 +38,13 @@
</ul>
</div>
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon">
<div class="square-20 shrink-0 margin-left-auto">
<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">
<div class="square-20 shrink-0 margin-left-auto">
<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>

@ -2,12 +2,20 @@
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
{{ end }}
{{ if .Page.HeadWidgets }}
<div class="head-widgets">
{{- range .Page.HeadWidgets }}
{{- .Render }}
{{- end }}
</div>
{{ end }}
<div class="page-columns">
{{ range .Page.Columns }}
{{- range .Page.Columns }}
<div class="page-column page-column-{{ .Size }}">
{{ range .Widgets }}
{{ .Render }}
{{ end }}
{{- range .Widgets }}
{{- .Render }}
{{- end }}
</div>
{{ end }}
{{- end }}
</div>

@ -2,43 +2,54 @@
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
{{ define "document-head-before" }}
<script>
const pageData = {
slug: "{{ .Page.Slug }}",
baseURL: "{{ .App.Config.Server.BaseURL }}",
};
</script>
{{ end }}
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
{{ define "document-head-after" }}
{{ .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 }}
<script type="module" src='{{ .App.StaticAssetPath "js/page.js" }}'></script>
{{ end }}
{{ define "navigation-links" }}
{{ range .App.Config.Pages }}
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}"{{ if eq .Slug $.Page.Slug }} aria-current="page"{{ end }}>{{ .Title }}</a>
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}"{{ if eq .Slug $.Page.Slug }} aria-current="page"{{ end }}><div class="nav-item-text">{{ .Title }}</div></a>
{{ end }}
{{ end }}
{{ define "document-body" }}
<div class="flex flex-column body-content">
{{ if not .Page.HideDesktopNavigation }}
<div class="header-container content-bounds{{ if ne "" .Page.DesktopNavigationWidth }} content-bounds-{{ .Page.DesktopNavigationWidth }} {{ end }}">
<div class="header-container content-bounds{{ if .Page.DesktopNavigationWidth }} content-bounds-{{ .Page.DesktopNavigationWidth }} {{ end }}">
<div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo" aria-hidden="true">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
<nav class="nav flex grow">
<div class="logo" aria-hidden="true">
{{- if .App.Config.Branding.LogoURL }}
<img src="{{ .App.Config.Branding.LogoURL }}" alt="">
{{- else if .App.Config.Branding.LogoText }}
{{- .App.Config.Branding.LogoText }}
{{- else }}
<svg style="max-height: 2rem;" width="100%" viewBox="0 0 108 108" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect fill="var(--color-text-subdue)" width="50" height="108" rx="6.875" />
<path fill="var(--color-primary)" fill-rule="evenodd" clip-rule="evenodd" d="M64.875 0C61.078 0 58 3.07804 58 6.875V43.125C58 46.922 61.078 50 64.875 50H101.125C104.922 50 108 46.922 108 43.125V6.875C108 3.07804 104.922 0 101.125 0H64.875ZM75.7545 11L71.3078 15.6814H85.2233C85.9209 15.6814 86.5835 15.6633 87.2113 15.627C87.839 15.5544 88.3273 15.4093 88.6761 15.1915L70 34.5706L73.4004 38L91.8149 18.7843C91.6056 19.1835 91.4487 19.7097 91.3441 20.3629C91.2743 20.9798 91.2394 21.5968 91.2394 22.2137V37.1835L96 32.2843V11H75.7545Z"/>
<rect fill="var(--color-text-base)" x="58" y="58" width="50" height="50" rx="6.875" />
</svg>
{{- end }}
</div>
<nav class="desktop-navigation flex grow hide-scrollbars">
{{ template "navigation-links" . }}
</nav>
{{ if not .App.Config.Theme.DisablePicker }}
<div class="theme-picker self-center" data-popover-type="html" data-popover-position="below" data-popover-show-delay="0">
<div class="current-theme-preview">
{{ .Request.Theme.PreviewHTML }}
</div>
<div data-popover-html>
<div class="theme-choices"></div>
</div>
</div>
{{ end }}
{{- if .App.RequiresAuth }}
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/logout" title="Logout">
<svg class="logout-button" stroke="var(--color-text-subdue)" 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="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
</svg>
</a>
{{- end }}
</div>
</div>
{{ end }}
@ -49,37 +60,61 @@
{{ 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 $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"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
</div>
<div class="mobile-navigation-page-links">
<div class="mobile-navigation-page-links hide-scrollbars">
{{ template "navigation-links" . }}
</div>
<div class="mobile-navigation-actions flex flex-column margin-block-10">
{{ if not .App.Config.Theme.DisablePicker }}
<div class="theme-picker flex justify-between items-center" data-popover-type="html" data-popover-position="above" data-popover-show-delay="0" data-popover-hide-delay="100" data-popover-anchor=".current-theme-preview" data-popover-trigger="click">
<div data-popover-html>
<div class="theme-choices">
{{ .App.Config.Theme.PreviewHTML }}
{{ range $_, $preset := .App.Config.Theme.Presets.Items }}
{{ $preset.PreviewHTML }}
{{ end }}
</div>
</div>
<div class="size-h3 pointer-events-none select-none">Change theme</div>
<div class="flex gap-15 items-center pointer-events-none">
<div class="current-theme-preview">
{{ .Request.Theme.PreviewHTML }}
</div>
<svg class="ui-icon" stroke="var(--color-text-subdue)" 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="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
</svg>
</div>
</div>
{{ end }}
{{ if .App.RequiresAuth }}
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
<div class="size-h3">Logout</div>
<svg class="ui-icon" stroke="var(--color-text-subdue)" 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="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
</svg>
</a>
{{ end }}
</div>
</div>
<div class="content-bounds grow{{ if ne "" .Page.Width }} content-bounds-{{ .Page.Width }} {{ end }}">
<main class="page" id="page" aria-live="polite" aria-busy="true">
<div class="content-bounds grow{{ if .Page.Width }} content-bounds-{{ .Page.Width }}{{ end }}">
<main class="page{{ if .Page.CenterVertically }} center-vertically{{ end }}" id="page" aria-live="polite" aria-busy="true">
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
<div class="page-content" id="page-content"></div>
<div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator -->
<div class="visually-hidden">Loading</div>
<div class="visually-hidden">Loading</div>
<div class="loading-icon" aria-hidden="true"></div>
</div>
</main>
</div>
{{ if not .App.Config.Branding.HideFooter }}
<footer class="footer flex items-center flex-column">
{{ if eq "" .App.Config.Branding.CustomFooter }}
<div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div>
{{ else }}
{{ .App.Config.Branding.CustomFooter }}
{{ end }}
</footer>
{{ end }}
{{ template "footer.html" . }}
<div class="mobile-navigation-offset"></div>
</div>
{{ end }}

@ -10,7 +10,7 @@
{{ 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">
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
@ -27,7 +27,7 @@
{{ 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">
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.PullRequests }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
@ -44,7 +44,7 @@
{{ if gt (len .Repository.Issues) 0 }}
<hr class="margin-block-10">
<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">
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.Issues }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>

@ -3,7 +3,7 @@
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}" data-target="{{ .Target }}">
<div class="search-bangs">
{{ range .Bangs }}
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">

@ -0,0 +1,19 @@
{{- $background := "hsl(240, 8%, 9%)" | safeCSS }}
{{- $primary := "hsl(43, 50%, 70%)" | safeCSS }}
{{- $positive := "hsl(43, 50%, 70%)" | safeCSS }}
{{- $negative := "hsl(0, 70%, 70%)" | safeCSS }}
{{- if .BackgroundColor }}{{ $background = .BackgroundColor.String | safeCSS }}{{ end }}
{{- if .PrimaryColor }}
{{- $primary = .PrimaryColor.String | safeCSS }}
{{- if not .PositiveColor }}
{{- $positive = $primary }}
{{- else }}
{{- $positive = .PositiveColor.String | safeCSS }}
{{- end }}
{{- end }}
{{- if .NegativeColor }}{{ $negative = .NegativeColor.String | safeCSS }}{{ end }}
<button class="theme-preset{{ if .Light }} theme-preset-light{{ end }}" style="--color: {{ $background }}" data-key="{{ .Key }}" title="{{ .Key }}">
<div class="theme-color" style="--color: {{ $primary }}"></div>
<div class="theme-color" style="--color: {{ $positive }}"></div>
<div class="theme-color" style="--color: {{ $negative }}"></div>
</button>

@ -1,9 +1,8 @@
<style>
:root {
{{ if .BackgroundColor }}
--bgh: {{ .BackgroundColor.Hue }};
--bgs: {{ .BackgroundColor.Saturation }}%;
--bgl: {{ .BackgroundColor.Lightness }}%;
--bgh: {{ .BackgroundColor.H }};
--bgs: {{ .BackgroundColor.S }}%;
--bgl: {{ .BackgroundColor.L }}%;
{{ end }}
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
@ -11,4 +10,3 @@
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
}
</style>

@ -0,0 +1,5 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="todo" data-todo-id="{{ .TodoID }}"></div>
{{ end }}

@ -1,7 +1,7 @@
{{ define "video-card-contents" }}
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
<a class="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<a class="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url | safeURL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="min-width-0">

@ -6,7 +6,7 @@
<li class="flex thumbnail-parent gap-10 items-center">
<img class="video-horizontal-list-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
<div class="min-width-0">
<a class="block text-truncate color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<a class="block text-truncate color-primary-if-not-visited" href="{{ .Url | safeURL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="min-width-0">

@ -1,5 +1,5 @@
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
{{- if not .HideHeader}}
<div class="widget widget-type-{{ .GetType }}{{ if .CSSClass }} {{ .CSSClass }}{{ end }}">
{{- if not .HideHeader }}
<div class="widget-header">
{{- if ne "" .TitleURL }}
<h2><a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a></h2>

@ -0,0 +1,107 @@
package glance
import (
"fmt"
"html/template"
"net/http"
"time"
)
var (
themeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
themePresetPreviewTemplate = mustParseTemplate("theme-preset-preview.html")
)
func (a *application) handleThemeChangeRequest(w http.ResponseWriter, r *http.Request) {
themeKey := r.PathValue("key")
properties, exists := a.Config.Theme.Presets.Get(themeKey)
if !exists && themeKey != "default" {
w.WriteHeader(http.StatusNotFound)
return
}
if themeKey == "default" {
properties = &a.Config.Theme.themeProperties
}
http.SetCookie(w, &http.Cookie{
Name: "theme",
Value: themeKey,
Path: a.Config.Server.BaseURL + "/",
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(2 * 365 * 24 * time.Hour),
})
w.Header().Set("Content-Type", "text/css")
w.Header().Set("X-Scheme", ternary(properties.Light, "light", "dark"))
w.Write([]byte(properties.CSS))
}
type themeProperties 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"`
Key string `yaml:"-"`
CSS template.CSS `yaml:"-"`
PreviewHTML template.HTML `yaml:"-"`
BackgroundColorAsHex string `yaml:"-"`
}
func (t *themeProperties) init() error {
css, err := executeTemplateToString(themeStyleTemplate, t)
if err != nil {
return fmt.Errorf("compiling theme style: %v", err)
}
t.CSS = template.CSS(whitespaceAtBeginningOfLinePattern.ReplaceAllString(css, ""))
previewHTML, err := executeTemplateToString(themePresetPreviewTemplate, t)
if err != nil {
return fmt.Errorf("compiling theme preview: %v", err)
}
t.PreviewHTML = template.HTML(previewHTML)
if t.BackgroundColor != nil {
t.BackgroundColorAsHex = t.BackgroundColor.ToHex()
} else {
t.BackgroundColorAsHex = "#151519"
}
return nil
}
func (t1 *themeProperties) SameAs(t2 *themeProperties) bool {
if t1 == nil && t2 == nil {
return true
}
if t1 == nil || t2 == nil {
return false
}
if t1.Light != t2.Light {
return false
}
if t1.ContrastMultiplier != t2.ContrastMultiplier {
return false
}
if t1.TextSaturationMultiplier != t2.TextSaturationMultiplier {
return false
}
if !t1.BackgroundColor.SameAs(t2.BackgroundColor) {
return false
}
if !t1.PrimaryColor.SameAs(t2.PrimaryColor) {
return false
}
if !t1.PositiveColor.SameAs(t2.PositiveColor) {
return false
}
if !t1.NegativeColor.SameAs(t2.NegativeColor) {
return false
}
return true
}

@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"html/template"
"math"
"net/http"
"net/url"
"os"
@ -14,8 +15,16 @@ import (
)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
var whitespaceAtBeginningOfLinePattern = regexp.MustCompile(`(?m)^\s+`)
func percentChange(current, previous float64) float64 {
if previous == 0 {
if current == 0 {
return 0 // 0% change if both are 0
}
return 100 // 100% increase if going from 0 to something
}
return (current/previous - 1) * 100
}
@ -148,15 +157,14 @@ func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
})
}
func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) {
func executeTemplateToString(t *template.Template, data any) (string, 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
return b.String(), nil
}
func stringToBool(s string) bool {
@ -182,3 +190,58 @@ func ternary[T any](condition bool, a, b T) T {
// Having compile time errors about unused variables is cool and all, but I don't want to
// have to constantly comment out my code while I'm working on it and testing things out
func ItsUsedTrustMeBro(...any) {}
func hslToHex(h, s, l float64) string {
s /= 100.0
l /= 100.0
var r, g, b float64
if s == 0 {
r, g, b = l, l, l
} else {
hueToRgb := func(p, q, t float64) float64 {
if t < 0 {
t += 1
}
if t > 1 {
t -= 1
}
if t < 1.0/6.0 {
return p + (q-p)*6.0*t
}
if t < 1.0/2.0 {
return q
}
if t < 2.0/3.0 {
return p + (q-p)*(2.0/3.0-t)*6.0
}
return p
}
q := 0.0
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
h /= 360.0
r = hueToRgb(p, q, h+1.0/3.0)
g = hueToRgb(p, q, h)
b = hueToRgb(p, q, h-1.0/3.0)
}
ir := int(math.Round(r * 255.0))
ig := int(math.Round(g * 255.0))
ib := int(math.Round(b * 255.0))
ir = int(math.Max(0, math.Min(255, float64(ir))))
ig = int(math.Max(0, math.Min(255, float64(ig))))
ib = int(math.Max(0, math.Min(255, float64(ib))))
return fmt.Sprintf("#%02x%02x%02x", ir, ig, ib)
}

@ -1,42 +1,52 @@
package glance
import (
"errors"
"html/template"
)
var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html")
var bookmarksGridWidgetTemplate = mustParseTemplate("bookmarks-grid.html", "widget-base.html")
type bookmarkLinks []struct {
Title string `yaml:"title"`
URL string `yaml:"url"`
Description string `yaml:"description"`
Icon customIconField `yaml:"icon"`
// we need a pointer to bool to know whether a value was provided,
// however there's no way to dereference a pointer in a template so
// {{ if not .SameTab }} would return true for any non-nil pointer
// which leaves us with no way of checking if the value is true or
// false, hence the duplicated fields below
SameTabRaw *bool `yaml:"same-tab"`
SameTab bool `yaml:"-"`
HideArrowRaw *bool `yaml:"hide-arrow"`
HideArrow bool `yaml:"-"`
Target string `yaml:"target"`
}
type bookmarksWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
cachedHTML template.HTML `yaml:"-"`
Style string `yaml:"style"`
Links *bookmarkLinks `yaml:"-"`
Groups []struct {
Title string `yaml:"title"`
Color *hslColorField `yaml:"color"`
SameTab bool `yaml:"same-tab"`
HideArrow bool `yaml:"hide-arrow"`
Target string `yaml:"target"`
Links []struct {
Title string `yaml:"title"`
URL string `yaml:"url"`
Description string `yaml:"description"`
Icon customIconField `yaml:"icon"`
// we need a pointer to bool to know whether a value was provided,
// however there's no way to dereference a pointer in a template so
// {{ if not .SameTab }} would return true for any non-nil pointer
// which leaves us with no way of checking if the value is true or
// false, hence the duplicated fields below
SameTabRaw *bool `yaml:"same-tab"`
SameTab bool `yaml:"-"`
HideArrowRaw *bool `yaml:"hide-arrow"`
HideArrow bool `yaml:"-"`
Target string `yaml:"target"`
} `yaml:"links"`
Links bookmarkLinks `yaml:"links"`
} `yaml:"groups"`
}
func (widget *bookmarksWidget) initialize() error {
widget.withTitle("Bookmarks").withError(nil)
if len(widget.Groups) == 0 {
return errors.New("must have at least one group")
}
for g := range widget.Groups {
group := &widget.Groups[g]
for l := range group.Links {
@ -67,7 +77,16 @@ func (widget *bookmarksWidget) initialize() error {
}
}
widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate)
if widget.Style == "grid" {
if len(widget.Groups) != 1 {
return errors.New("grid style can only be used with a single group")
}
widget.Links = &widget.Groups[0].Links
widget.cachedHTML = widget.renderTemplate(widget, bookmarksGridWidgetTemplate)
} else {
widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate)
}
return nil
}

@ -8,8 +8,10 @@ import (
"fmt"
"html/template"
"io"
"iter"
"log/slog"
"math"
"math/rand"
"net/http"
"regexp"
"sort"
@ -32,15 +34,21 @@ type CustomAPIRequest struct {
Method string `yaml:"method"`
BodyType string `yaml:"body-type"`
Body any `yaml:"body"`
MockResponse string `yaml:"mock-response"`
SkipJSONValidation bool `yaml:"skip-json-validation"`
bodyReader io.ReadSeeker `yaml:"-"`
httpRequest *http.Request `yaml:"-"`
BasicAuth struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
} `yaml:"basic-auth"`
bodyReader io.ReadSeeker `yaml:"-"`
httpRequest *http.Request `yaml:"-"`
}
type customAPIWidget struct {
widgetBase `yaml:",inline"`
*CustomAPIRequest `yaml:",inline"` // the primary request
Subrequests map[string]*CustomAPIRequest `yaml:"subrequests"`
Options customAPIOptions `yaml:"options"`
Template string `yaml:"template"`
Frameless bool `yaml:"frameless"`
compiledTemplate *template.Template `yaml:"-"`
@ -75,7 +83,9 @@ func (widget *customAPIWidget) initialize() error {
}
func (widget *customAPIWidget) update(ctx context.Context) {
compiledHTML, err := fetchAndParseCustomAPI(widget.CustomAPIRequest, widget.Subrequests, widget.compiledTemplate)
compiledHTML, err := fetchAndRenderCustomAPIRequest(
widget.CustomAPIRequest, widget.Subrequests, widget.Options, widget.compiledTemplate,
)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
@ -87,9 +97,50 @@ func (widget *customAPIWidget) Render() template.HTML {
return widget.renderTemplate(widget, customAPIWidgetTemplate)
}
type customAPIOptions map[string]any
func (o *customAPIOptions) StringOr(key, defaultValue string) string {
return customAPIGetOptionOrDefault(*o, key, defaultValue)
}
func (o *customAPIOptions) IntOr(key string, defaultValue int) int {
return customAPIGetOptionOrDefault(*o, key, defaultValue)
}
func (o *customAPIOptions) FloatOr(key string, defaultValue float64) float64 {
return customAPIGetOptionOrDefault(*o, key, defaultValue)
}
func (o *customAPIOptions) BoolOr(key string, defaultValue bool) bool {
return customAPIGetOptionOrDefault(*o, key, defaultValue)
}
func (o *customAPIOptions) JSON(key string) string {
value, exists := (*o)[key]
if !exists {
panic(fmt.Sprintf("key %q does not exist in options", key))
}
encoded, err := json.Marshal(value)
if err != nil {
panic(fmt.Sprintf("marshaling %s: %v", key, err))
}
return string(encoded)
}
func customAPIGetOptionOrDefault[T any](o customAPIOptions, key string, defaultValue T) T {
if value, exists := o[key]; exists {
if typedValue, ok := value.(T); ok {
return typedValue
}
}
return defaultValue
}
func (req *CustomAPIRequest) initialize() error {
if req.URL == "" {
return errors.New("URL is required")
if req == nil || req.URL == "" {
return nil
}
if req.Body != nil {
@ -143,6 +194,10 @@ func (req *CustomAPIRequest) initialize() error {
httpReq.Header.Add(key, value)
}
if req.BasicAuth.Username != "" || req.BasicAuth.Password != "" {
httpReq.SetBasicAuth(req.BasicAuth.Username, req.BasicAuth.Password)
}
req.httpRequest = httpReq
return nil
@ -156,6 +211,7 @@ type customAPIResponseData struct {
type customAPITemplateData struct {
*customAPIResponseData
subrequests map[string]*customAPIResponseData
Options customAPIOptions
}
func (data *customAPITemplateData) JSONLines() []decoratedGJSONResult {
@ -183,7 +239,23 @@ func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData
return req
}
func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
func fetchCustomAPIResponse(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
if req != nil && req.MockResponse != "" {
return &customAPIResponseData{
JSON: decoratedGJSONResult{gjson.Parse(req.MockResponse)},
Response: &http.Response{
StatusCode: http.StatusOK,
},
}, nil
}
if req == nil || req.URL == "" {
return &customAPIResponseData{
JSON: decoratedGJSONResult{gjson.Result{}},
Response: &http.Response{},
}, nil
}
if req.bodyReader != nil {
req.bodyReader.Seek(0, io.SeekStart)
}
@ -203,26 +275,30 @@ func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customA
body := strings.TrimSpace(string(bodyBytes))
if !req.SkipJSONValidation && body != "" && !gjson.Valid(body) {
truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated {
truncatedBody += "... <truncated>"
if 200 <= resp.StatusCode && resp.StatusCode < 300 {
truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated {
truncatedBody += "... <truncated>"
}
slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody)
return nil, errors.New("invalid response JSON")
}
slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody)
return nil, errors.New("invalid response JSON")
return nil, fmt.Errorf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
}
data := &customAPIResponseData{
return &customAPIResponseData{
JSON: decoratedGJSONResult{gjson.Parse(body)},
Response: resp,
}
return data, nil
}, nil
}
func fetchAndParseCustomAPI(
func fetchAndRenderCustomAPIRequest(
primaryReq *CustomAPIRequest,
subReqs map[string]*CustomAPIRequest,
options customAPIOptions,
tmpl *template.Template,
) (template.HTML, error) {
var primaryData *customAPIResponseData
@ -231,7 +307,7 @@ func fetchAndParseCustomAPI(
if len(subReqs) == 0 {
// If there are no subrequests, we can fetch the primary request in a much simpler way
primaryData, err = fetchCustomAPIRequest(context.Background(), primaryReq)
primaryData, err = fetchCustomAPIResponse(context.Background(), primaryReq)
} else {
// If there are subrequests, we need to fetch them concurrently
// and cancel all requests if any of them fail. There's probably
@ -246,7 +322,7 @@ func fetchAndParseCustomAPI(
go func() {
defer wg.Done()
var localErr error
primaryData, localErr = fetchCustomAPIRequest(ctx, primaryReq)
primaryData, localErr = fetchCustomAPIResponse(ctx, primaryReq)
mu.Lock()
if localErr != nil && err == nil {
err = localErr
@ -261,7 +337,7 @@ func fetchAndParseCustomAPI(
defer wg.Done()
var localErr error
var data *customAPIResponseData
data, localErr = fetchCustomAPIRequest(ctx, req)
data, localErr = fetchCustomAPIResponse(ctx, req)
mu.Lock()
if localErr == nil {
subData[key] = data
@ -285,6 +361,7 @@ func fetchAndParseCustomAPI(
data := customAPITemplateData{
customAPIResponseData: primaryData,
subrequests: subData,
Options: options,
}
var templateBuffer bytes.Buffer
@ -311,7 +388,7 @@ func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedG
}
func (r *decoratedGJSONResult) Exists(key string) bool {
return r.Get(key).Exists()
return r.Result.Get(key).Exists()
}
func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
@ -319,7 +396,7 @@ func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
}
return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
return gJsonResultArrayToDecoratedResultArray(r.Result.Get(key).Array())
}
func (r *decoratedGJSONResult) String(key string) string {
@ -327,7 +404,7 @@ func (r *decoratedGJSONResult) String(key string) string {
return r.Result.String()
}
return r.Get(key).String()
return r.Result.Get(key).String()
}
func (r *decoratedGJSONResult) Int(key string) int {
@ -335,7 +412,7 @@ func (r *decoratedGJSONResult) Int(key string) int {
return int(r.Result.Int())
}
return int(r.Get(key).Int())
return int(r.Result.Get(key).Int())
}
func (r *decoratedGJSONResult) Float(key string) float64 {
@ -343,7 +420,7 @@ func (r *decoratedGJSONResult) Float(key string) float64 {
return r.Result.Float()
}
return r.Get(key).Float()
return r.Result.Get(key).Float()
}
func (r *decoratedGJSONResult) Bool(key string) bool {
@ -351,7 +428,26 @@ func (r *decoratedGJSONResult) Bool(key string) bool {
return r.Result.Bool()
}
return r.Get(key).Bool()
return r.Result.Get(key).Bool()
}
func (r *decoratedGJSONResult) Get(key string) *decoratedGJSONResult {
return &decoratedGJSONResult{r.Result.Get(key)}
}
func (r *decoratedGJSONResult) Entries(key string) iter.Seq2[string, *decoratedGJSONResult] {
var obj gjson.Result
if key == "" {
obj = r.Result
} else {
obj = r.Result.Get(key)
}
return func(yield func(string, *decoratedGJSONResult) bool) {
obj.ForEach(func(k, v gjson.Result) bool {
return yield(k.String(), &decoratedGJSONResult{v})
})
}
}
func customAPIDoMathOp[T int | float64](a, b T, op string) T {
@ -432,6 +528,16 @@ var customAPITemplateFuncs = func() template.FuncMap {
"div": func(a, b any) any {
return doMathOpWithAny(a, b, "div")
},
"mod": func(a, b int) int {
if b == 0 {
return 0
}
return a % b
},
"iconWithClass": func(name, class string) template.HTML {
i := newCustomIconField(name)
return i.ElemWithClass(class)
},
"now": func() time.Time {
return time.Now()
},
@ -450,11 +556,23 @@ var customAPITemplateFuncs = func() template.FuncMap {
return d
},
"parseTime": customAPIFuncParseTime,
"parseTime": func(layout, value string) time.Time {
return customAPIFuncParseTimeInLocation(layout, value, time.UTC)
},
"formatTime": customAPIFuncFormatTime,
"parseLocalTime": func(layout, value string) time.Time {
return customAPIFuncParseTimeInLocation(layout, value, time.Local).In(time.Local)
},
"toRelativeTime": dynamicRelativeTimeAttrs,
"parseRelativeTime": func(layout, value string) template.HTMLAttr {
// Shorthand to do both of the above with a single function call
return dynamicRelativeTimeAttrs(customAPIFuncParseTime(layout, value))
return dynamicRelativeTimeAttrs(customAPIFuncParseTimeInLocation(layout, value, time.UTC))
},
"startOfDay": func(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
},
"endOfDay": func(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location())
},
// The reason we flip the parameter order is so that you can chain multiple calls together like this:
// {{ .JSON.String "foo" | trimPrefix "bar" | doSomethingElse }}
@ -471,6 +589,13 @@ var customAPITemplateFuncs = func() template.FuncMap {
"replaceAll": func(old, new, s string) string {
return strings.ReplaceAll(s, old, new)
},
"replaceMatches": func(pattern, replacement, s string) string {
if s == "" {
return ""
}
return getCachedRegexp(pattern).ReplaceAllString(s, replacement)
},
"findMatch": func(pattern, s string) string {
if s == "" {
return ""
@ -486,6 +611,7 @@ var customAPITemplateFuncs = func() template.FuncMap {
regex := getCachedRegexp(pattern)
return itemAtIndexOrDefault(regex.FindStringSubmatch(s), 1, "")
},
"percentChange": percentChange,
"sortByString": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
sort.Slice(results, func(a, b int) bool {
if order == "asc" {
@ -521,8 +647,8 @@ var customAPITemplateFuncs = func() template.FuncMap {
},
"sortByTime": func(key, layout, order string, results []decoratedGJSONResult) []decoratedGJSONResult {
sort.Slice(results, func(a, b int) bool {
timeA := customAPIFuncParseTime(layout, results[a].String(key))
timeB := customAPIFuncParseTime(layout, results[b].String(key))
timeA := customAPIFuncParseTimeInLocation(layout, results[a].String(key), time.UTC)
timeB := customAPIFuncParseTimeInLocation(layout, results[b].String(key), time.UTC)
if order == "asc" {
return timeA.Before(timeB)
@ -536,6 +662,91 @@ var customAPITemplateFuncs = func() template.FuncMap {
"concat": func(items ...string) string {
return strings.Join(items, "")
},
"unique": func(key string, results []decoratedGJSONResult) []decoratedGJSONResult {
seen := make(map[string]struct{})
out := make([]decoratedGJSONResult, 0, len(results))
for _, result := range results {
val := result.String(key)
if _, ok := seen[val]; !ok {
seen[val] = struct{}{}
out = append(out, result)
}
}
return out
},
"newRequest": func(url string, params ...any) *CustomAPIRequest {
if len(params) > 0 {
url = fmt.Sprintf(url, params...)
}
return &CustomAPIRequest{
URL: url,
}
},
"withHeader": func(key, value string, req *CustomAPIRequest) *CustomAPIRequest {
if req.Headers == nil {
req.Headers = make(map[string]string)
}
req.Headers[key] = value
return req
},
"withParameter": func(key, value string, req *CustomAPIRequest) *CustomAPIRequest {
if req.Parameters == nil {
req.Parameters = make(queryParametersField)
}
req.Parameters[key] = append(req.Parameters[key], value)
return req
},
"withStringBody": func(body string, req *CustomAPIRequest) *CustomAPIRequest {
req.Body = body
req.BodyType = "string"
return req
},
"withAllowInsecure": func(val any, req *CustomAPIRequest) *CustomAPIRequest {
switch v := val.(type) {
case bool:
req.AllowInsecure = v
case string:
if strings.ToLower(v) == "true" {
req.AllowInsecure = true
}
default:
slog.Warn("withAllowInsecure called with non-boolean value, must be string or bool", "value", v)
}
return req
},
"withBasicAuth": func(username, password string, req *CustomAPIRequest) *CustomAPIRequest {
req.BasicAuth.Username = username
req.BasicAuth.Password = password
return req
},
"getResponse": func(req *CustomAPIRequest) *customAPIResponseData {
err := req.initialize()
if err != nil {
panic(fmt.Sprintf("initializing request: %v", err))
}
data, err := fetchCustomAPIResponse(context.Background(), req)
if err != nil {
slog.Error("Could not fetch response within custom API template", "error", err)
return &customAPIResponseData{
JSON: decoratedGJSONResult{gjson.Result{}},
Response: &http.Response{
Status: err.Error(),
},
}
}
return data
},
"randomElement": func(arr []decoratedGJSONResult) *decoratedGJSONResult {
if len(arr) == 0 {
return &decoratedGJSONResult{gjson.Result{}}
}
return &arr[rand.Intn(len(arr))]
},
}
for key, value := range globalTemplateFunctions {
@ -547,7 +758,24 @@ var customAPITemplateFuncs = func() template.FuncMap {
return funcs
}()
func customAPIFuncParseTime(layout, value string) time.Time {
func customAPIFuncFormatTime(layout string, t time.Time) string {
switch strings.ToLower(layout) {
case "unix":
return strconv.FormatInt(t.Unix(), 10)
case "rfc3339":
layout = time.RFC3339
case "rfc3339nano":
layout = time.RFC3339Nano
case "datetime":
layout = time.DateTime
case "dateonly":
layout = time.DateOnly
}
return t.Format(layout)
}
func customAPIFuncParseTimeInLocation(layout, value string, loc *time.Location) time.Time {
switch strings.ToLower(layout) {
case "unix":
asInt, err := strconv.ParseInt(value, 10, 64)
@ -566,7 +794,7 @@ func customAPIFuncParseTime(layout, value string) time.Time {
layout = time.DateOnly
}
parsed, err := time.Parse(layout, value)
parsed, err := time.ParseInLocation(layout, value, loc)
if err != nil {
return time.Unix(0, 0)
}

@ -429,22 +429,24 @@ func fetchPiholeStats(
return nil
}
if sessionID == "" {
if err := fetchNewSessionID(); err != nil {
slog.Error("Failed to fetch Pihole v6 session ID", "error", err)
return nil, "", fmt.Errorf("fetching session ID: %v", err)
}
} else {
isValid, err := checkPiholeSessionIDIsValid(instanceURL, client, sessionID)
if err != nil {
slog.Error("Failed to check Pihole v6 session ID validity", "error", err)
return nil, "", fmt.Errorf("checking session ID: %v", err)
}
if !isValid {
if password != "" {
if sessionID == "" {
if err := fetchNewSessionID(); err != nil {
slog.Error("Failed to renew Pihole v6 session ID", "error", err)
return nil, "", fmt.Errorf("renewing session ID: %v", err)
slog.Error("Failed to fetch Pihole v6 session ID", "error", err)
return nil, "", fmt.Errorf("fetching session ID: %v", err)
}
} else {
isValid, err := checkPiholeSessionIDIsValid(instanceURL, client, sessionID)
if err != nil {
slog.Error("Failed to check Pihole v6 session ID validity", "error", err)
return nil, "", fmt.Errorf("checking session ID: %v", err)
}
if !isValid {
if err := fetchNewSessionID(); err != nil {
slog.Error("Failed to renew Pihole v6 session ID", "error", err)
return nil, "", fmt.Errorf("renewing session ID: %v", err)
}
}
}
}

@ -7,6 +7,7 @@ import (
"html/template"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
@ -15,10 +16,14 @@ import (
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:"-"`
widgetBase `yaml:",inline"`
HideByDefault bool `yaml:"hide-by-default"`
RunningOnly bool `yaml:"running-only"`
Category string `yaml:"category"`
SockPath string `yaml:"sock-path"`
FormatContainerNames bool `yaml:"format-container-names"`
Containers dockerContainerList `yaml:"-"`
LabelOverrides map[string]map[string]string `yaml:"containers"`
}
func (widget *dockerContainersWidget) initialize() error {
@ -32,12 +37,19 @@ func (widget *dockerContainersWidget) initialize() error {
}
func (widget *dockerContainersWidget) update(ctx context.Context) {
containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault)
containers, err := fetchDockerContainers(
widget.SockPath,
widget.HideByDefault,
widget.Category,
widget.RunningOnly,
widget.FormatContainerNames,
widget.LabelOverrides,
)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
containers.sortByStateIconThenTitle()
containers.sortByStateIconThenName()
widget.Containers = containers
}
@ -54,6 +66,7 @@ const (
dockerContainerLabelIcon = "glance.icon"
dockerContainerLabelID = "glance.id"
dockerContainerLabelParent = "glance.parent"
dockerContainerLabelCategory = "glance.category"
)
const (
@ -98,7 +111,7 @@ func (l *dockerContainerLabels) getOrDefault(label, def string) string {
}
type dockerContainer struct {
Title string
Name string
URL string
SameTab bool
Image string
@ -112,7 +125,7 @@ type dockerContainer struct {
type dockerContainerList []dockerContainer
func (containers dockerContainerList) sortByStateIconThenTitle() {
func (containers dockerContainerList) sortByStateIconThenName() {
p := &dockerContainerStateIconPriorities
sort.SliceStable(containers, func(a, b int) bool {
@ -120,25 +133,36 @@ func (containers dockerContainerList) sortByStateIconThenTitle() {
return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon]
}
return strings.ToLower(containers[a].Title) < strings.ToLower(containers[b].Title)
return strings.ToLower(containers[a].Name) < strings.ToLower(containers[b].Name)
})
}
func dockerContainerStateToStateIcon(state string) string {
switch state {
func dockerContainerStateToStateIcon(container *dockerContainerJsonResponse) string {
if strings.Contains(strings.ToLower(container.Status), "(unhealthy)") {
return dockerContainerStateIconWarn
}
switch strings.ToLower(container.State) {
case "running":
return dockerContainerStateIconOK
case "paused":
return dockerContainerStateIconPaused
case "exited", "unhealthy", "dead":
case "exited", "dead":
return dockerContainerStateIconWarn
default:
return dockerContainerStateIconOther
}
}
func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) {
containers, err := fetchAllDockerContainersFromSock(socketPath)
func fetchDockerContainers(
socketPath string,
hideByDefault bool,
category string,
runningOnly bool,
formatNames bool,
labelOverrides map[string]map[string]string,
) (dockerContainerList, error) {
containers, err := fetchDockerContainersFromSource(socketPath, category, runningOnly, labelOverrides)
if err != nil {
return nil, fmt.Errorf("fetching containers: %w", err)
}
@ -150,7 +174,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
container := &containers[i]
dc := dockerContainer{
Title: deriveDockerContainerTitle(container),
Name: deriveDockerContainerName(container, formatNames),
URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""),
Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""),
SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")),
@ -165,15 +189,15 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
for i := range children {
child := &children[i]
dc.Children = append(dc.Children, dockerContainer{
Title: deriveDockerContainerTitle(child),
Name: deriveDockerContainerName(child, formatNames),
StateText: child.Status,
StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)),
StateIcon: dockerContainerStateToStateIcon(child),
})
}
}
}
dc.Children.sortByStateIconThenTitle()
dc.Children.sortByStateIconThenName()
stateIconSupersededByChild := false
for i := range dc.Children {
@ -184,7 +208,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
}
}
if !stateIconSupersededByChild {
dc.StateIcon = dockerContainerStateToStateIcon(dc.State)
dc.StateIcon = dockerContainerStateToStateIcon(container)
}
dockerContainers = append(dockerContainers, dc)
@ -193,12 +217,31 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
return dockerContainers, nil
}
func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string {
func deriveDockerContainerName(container *dockerContainerJsonResponse, formatNames bool) string {
if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" {
return v
}
return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/")
if len(container.Names) == 0 || container.Names[0] == "" {
return "n/a"
}
name := strings.TrimLeft(container.Names[0], "/")
if formatNames {
name = strings.ReplaceAll(name, "_", " ")
name = strings.ReplaceAll(name, "-", " ")
words := strings.Split(name, " ")
for i := range words {
if len(words[i]) > 0 {
words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
}
}
name = strings.Join(words, " ")
}
return name
}
func groupDockerContainerChildren(
@ -239,17 +282,44 @@ func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefau
return hideByDefault
}
func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) {
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
func fetchDockerContainersFromSource(
source string,
category string,
runningOnly bool,
labelOverrides map[string]map[string]string,
) ([]dockerContainerJsonResponse, error) {
var hostname string
var client *http.Client
if strings.HasPrefix(source, "tcp://") || strings.HasPrefix(source, "http://") {
client = &http.Client{}
parsed, err := url.Parse(source)
if err != nil {
return nil, fmt.Errorf("parsing URL: %w", err)
}
port := parsed.Port()
if port == "" {
port = "80"
}
hostname = parsed.Hostname() + ":" + port
} else {
hostname = "docker"
client = &http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", source)
},
},
},
}
}
request, err := http.NewRequest("GET", "http://docker/containers/json?all=true", nil)
fetchAll := ternary(runningOnly, "false", "true")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, "GET", "http://"+hostname+"/containers/json?all="+fetchAll, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
@ -269,5 +339,43 @@ func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonR
return nil, fmt.Errorf("decoding response: %w", err)
}
for i := range containers {
container := &containers[i]
name := strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, ""), "/")
if name == "" {
continue
}
overrides, ok := labelOverrides[name]
if !ok {
continue
}
if container.Labels == nil {
container.Labels = make(dockerContainerLabels)
}
for label, value := range overrides {
container.Labels["glance."+label] = value
}
}
// We have to filter here instead of using the `filters` parameter of Docker's API
// because the user may define a category override within their config
if category != "" {
filtered := make([]dockerContainerJsonResponse, 0, len(containers))
for i := range containers {
container := &containers[i]
if container.Labels.getOrDefault(dockerContainerLabelCategory, "") == category {
filtered = append(filtered, *container)
}
}
containers = filtered
}
return containers, nil
}

@ -10,7 +10,7 @@ type htmlWidget struct {
}
func (widget *htmlWidget) initialize() error {
widget.withTitle("").withError(nil)
widget.withTitle("HTML").withError(nil)
return nil
}

@ -106,6 +106,7 @@ type marketResponseJson struct {
Symbol string `json:"symbol"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
ChartPreviousClose float64 `json:"chartPreviousClose"`
ExchangeName string `json:"exchangeName"`
ShortName string `json:"shortName"`
PriceHint int `json:"priceHint"`
} `json:"meta"`
@ -174,6 +175,11 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
currency = result.Meta.Currency
}
// See https://github.com/glanceapp/glance/issues/757
if result.Meta.ExchangeName == "LSE" {
currency = ""
}
markets = append(markets, market{
marketRequest: marketRequests[i],
Price: result.Meta.RegularMarketPrice,

@ -115,9 +115,10 @@ func statusCodeToStyle(status int, altStatusCodes []int) string {
}
type SiteStatusRequest struct {
DefaultURL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"`
DefaultURL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"`
Timeout durationField `yaml:"timeout"`
BasicAuth struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
@ -138,7 +139,12 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) {
} else {
url = statusRequest.DefaultURL
}
request, err := http.NewRequest(http.MethodGet, url, nil)
timeout := ternary(statusRequest.Timeout > 0, time.Duration(statusRequest.Timeout), 3*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return siteStatus{
Error: err,
@ -149,9 +155,6 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) {
request.SetBasicAuth(statusRequest.BasicAuth.Username, statusRequest.BasicAuth.Password)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
request = request.WithContext(ctx)
requestSentAt := time.Now()
var response *http.Response

@ -8,6 +8,7 @@ import (
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@ -29,10 +30,20 @@ type redditWidget struct {
TopPeriod string `yaml:"top-period"`
Search string `yaml:"search"`
ExtraSortBy string `yaml:"extra-sort-by"`
CommentsUrlTemplate string `yaml:"comments-url-template"`
CommentsURLTemplate string `yaml:"comments-url-template"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
RequestUrlTemplate string `yaml:"request-url-template"`
RequestURLTemplate string `yaml:"request-url-template"`
AppAuth struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
Secret string `yaml:"secret"`
enabled bool
accessToken string
tokenExpiresAt time.Time
} `yaml:"app-auth"`
}
func (widget *redditWidget) initialize() error {
@ -48,20 +59,30 @@ func (widget *redditWidget) initialize() error {
widget.CollapseAfter = 5
}
if !isValidRedditSortType(widget.SortBy) {
s := widget.SortBy
if s != "hot" && s != "new" && s != "top" && s != "rising" {
widget.SortBy = "hot"
}
if !isValidRedditTopPeriod(widget.TopPeriod) {
p := widget.TopPeriod
if p != "hour" && p != "day" && p != "week" && p != "month" && p != "year" && p != "all" {
widget.TopPeriod = "day"
}
if widget.RequestUrlTemplate != "" {
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
if widget.RequestURLTemplate != "" {
if !strings.Contains(widget.RequestURLTemplate, "{REQUEST-URL}") {
return errors.New("no `{REQUEST-URL}` placeholder specified")
}
}
a := &widget.AppAuth
if a.Name != "" || a.ID != "" || a.Secret != "" {
if a.Name == "" || a.ID == "" || a.Secret == "" {
return errors.New("application name, client ID and client secret are required")
}
a.enabled = true
}
widget.
withTitle("r/" + widget.Subreddit).
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
@ -70,35 +91,8 @@ func (widget *redditWidget) initialize() error {
return nil
}
func isValidRedditSortType(sortBy string) bool {
return sortBy == "hot" ||
sortBy == "new" ||
sortBy == "top" ||
sortBy == "rising"
}
func isValidRedditTopPeriod(period string) bool {
return period == "hour" ||
period == "day" ||
period == "week" ||
period == "month" ||
period == "year" ||
period == "all"
}
func (widget *redditWidget) update(ctx context.Context) {
// TODO: refactor, use a struct to pass all of these
posts, err := fetchSubredditPosts(
widget.Subreddit,
widget.SortBy,
widget.TopPeriod,
widget.Search,
widget.CommentsUrlTemplate,
widget.RequestUrlTemplate,
widget.Proxy.client,
widget.ShowFlairs,
)
posts, err := widget.fetchSubredditPosts()
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
@ -155,57 +149,69 @@ type subredditResponseJson struct {
} `json:"data"`
}
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath string) string {
template := strings.ReplaceAll(widget.CommentsURLTemplate, "{SUBREDDIT}", subreddit)
template = strings.ReplaceAll(template, "{POST-ID}", postId)
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
return template
}
func fetchSubredditPosts(
subreddit,
sort,
topPeriod,
search,
commentsUrlTemplate,
requestUrlTemplate string,
proxyClient *http.Client,
showFlairs bool,
) (forumPostList, error) {
func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
var client requestDoer = defaultHTTPClient
var baseURL string
var requestURL string
var headers http.Header
query := url.Values{}
var requestUrl string
app := &widget.AppAuth
if !app.enabled {
baseURL = "https://www.reddit.com"
headers = http.Header{
"User-Agent": []string{getBrowserUserAgentHeader()},
}
} else {
baseURL = "https://oauth.reddit.com"
if app.accessToken == "" || time.Now().Add(time.Minute).After(app.tokenExpiresAt) {
if err := widget.fetchNewAppAccessToken(); err != nil {
return nil, fmt.Errorf("fetching new app access token: %v", err)
}
}
if search != "" {
query.Set("q", search+" subreddit:"+subreddit)
query.Set("sort", sort)
headers = http.Header{
"Authorization": []string{"Bearer " + app.accessToken},
"User-Agent": []string{app.Name + "/1.0"},
}
}
if sort == "top" {
query.Set("t", topPeriod)
if widget.Limit > 25 {
query.Set("limit", strconv.Itoa(widget.Limit))
}
if search != "" {
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
if widget.Search != "" {
query.Set("q", widget.Search+" subreddit:"+widget.Subreddit)
query.Set("sort", widget.SortBy)
requestURL = fmt.Sprintf("%s/search.json?%s", baseURL, query.Encode())
} else {
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
if widget.SortBy == "top" {
query.Set("t", widget.TopPeriod)
}
requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, widget.Subreddit, widget.SortBy, query.Encode())
}
var client requestDoer = defaultHTTPClient
if requestUrlTemplate != "" {
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
} else if proxyClient != nil {
client = proxyClient
if widget.RequestURLTemplate != "" {
requestURL = strings.ReplaceAll(widget.RequestURLTemplate, "{REQUEST-URL}", requestURL)
} else if widget.Proxy.client != nil {
client = widget.Proxy.client
}
request, err := http.NewRequest("GET", requestUrl, nil)
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
request.Header = headers
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
setBrowserUserAgentHeader(request)
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
if err != nil {
return nil, err
@ -226,10 +232,10 @@ func fetchSubredditPosts(
var commentsUrl string
if commentsUrlTemplate == "" {
if widget.CommentsURLTemplate == "" {
commentsUrl = "https://www.reddit.com" + post.Permalink
} else {
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
commentsUrl = widget.parseCustomCommentsURL(widget.Subreddit, post.Id, post.Permalink)
}
forumPost := forumPost{
@ -249,7 +255,7 @@ func fetchSubredditPosts(
forumPost.TargetUrl = post.Url
}
if showFlairs && post.Flair != "" {
if widget.ShowFlairs && post.Flair != "" {
forumPost.Tags = append(forumPost.Tags, post.Flair)
}
@ -257,11 +263,10 @@ func fetchSubredditPosts(
forumPost.IsCrosspost = true
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
if commentsUrlTemplate == "" {
if widget.CommentsURLTemplate == "" {
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
} else {
forumPost.TargetUrl = templateRedditCommentsURL(
commentsUrlTemplate,
forumPost.TargetUrl = widget.parseCustomCommentsURL(
post.ParentList[0].Subreddit,
post.ParentList[0].Id,
post.ParentList[0].Permalink,
@ -274,3 +279,32 @@ func fetchSubredditPosts(
return posts, nil
}
func (widget *redditWidget) fetchNewAppAccessToken() error {
body := strings.NewReader("grant_type=client_credentials")
req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", body)
if err != nil {
return fmt.Errorf("creating request for app access token: %v", err)
}
app := &widget.AppAuth
req.SetBasicAuth(app.ID, app.Secret)
req.Header.Add("User-Agent", app.Name+"/1.0")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
type tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
client := ternary(widget.Proxy.client != nil, widget.Proxy.client, defaultHTTPClient)
response, err := decodeJsonFromRequest[tokenResponse](client, req)
if err != nil {
return err
}
app.accessToken = response.AccessToken
app.tokenExpiresAt = time.Now().Add(time.Duration(response.ExpiresIn) * time.Second)
return nil
}

@ -19,6 +19,7 @@ type repositoryWidget struct {
PullRequestsLimit int `yaml:"pull-requests-limit"`
IssuesLimit int `yaml:"issues-limit"`
CommitsLimit int `yaml:"commits-limit"`
ExcludeDraftPRs bool `yaml:"exclude-draft-prs"`
Repository repository `yaml:"-"`
}
@ -47,6 +48,7 @@ func (widget *repositoryWidget) update(ctx context.Context) {
widget.PullRequestsLimit,
widget.IssuesLimit,
widget.CommitsLimit,
widget.ExcludeDraftPRs,
)
if !widget.canContinueUpdateAfterHandlingErr(err) {
@ -111,13 +113,23 @@ type gitHubCommitResponseJson struct {
} `json:"commit"`
}
func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int) (repository, error) {
func buildPRQuery(repo string, excludeDraftPRs bool) string {
query := fmt.Sprintf("is:pr+is:open+repo:%s", repo)
if excludeDraftPRs {
query += "+-is:draft"
}
return query
}
func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int, excludeDraftPRs bool) (repository, error) {
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repo), nil)
if err != nil {
return repository{}, fmt.Errorf("%w: could not create request with repository: %v", errNoContent, err)
}
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repo, maxPRs), nil)
prQuery := buildPRQuery(repo, excludeDraftPRs)
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=%s&per_page=%d", prQuery, maxPRs), nil)
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repo, maxIssues), nil)
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repo, maxCommits), nil)

@ -12,6 +12,7 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/mmcdole/gofeed"
@ -25,22 +26,28 @@ var (
rssWidgetHorizontalCards2Template = mustParseTemplate("rss-horizontal-cards-2.html", "widget-base.html")
)
var feedParser = gofeed.NewParser()
type rssWidget struct {
widgetBase `yaml:",inline"`
FeedRequests []rssFeedRequest `yaml:"feeds"`
Style string `yaml:"style"`
ThumbnailHeight float64 `yaml:"thumbnail-height"`
CardHeight float64 `yaml:"card-height"`
Items rssFeedItemList `yaml:"-"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
SingleLineTitles bool `yaml:"single-line-titles"`
PreserveOrder bool `yaml:"preserve-order"`
NoItemsMessage string `yaml:"-"`
Items rssFeedItemList `yaml:"-"`
NoItemsMessage string `yaml:"-"`
cachedFeedsMutex sync.Mutex
cachedFeeds map[string]*cachedRSSFeed `yaml:"-"`
}
func (widget *rssWidget) initialize() error {
widget.withTitle("RSS Feed").withCacheDuration(1 * time.Hour)
widget.withTitle("RSS Feed").withCacheDuration(2 * time.Hour)
if widget.Limit <= 0 {
widget.Limit = 25
@ -65,12 +72,13 @@ func (widget *rssWidget) initialize() error {
}
widget.NoItemsMessage = "No items were returned from the feeds."
widget.cachedFeeds = make(map[string]*cachedRSSFeed)
return nil
}
func (widget *rssWidget) update(ctx context.Context) {
items, err := fetchItemsFromRSSFeeds(widget.FeedRequests)
items, err := widget.fetchItemsFromFeeds()
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
@ -103,6 +111,12 @@ func (widget *rssWidget) Render() template.HTML {
return widget.renderTemplate(widget, rssWidgetTemplate)
}
type cachedRSSFeed struct {
etag string
lastModified string
items []rssFeedItem
}
type rssFeedItem struct {
ChannelName string
ChannelURL string
@ -114,35 +128,6 @@ type rssFeedItem struct {
PublishedAt time.Time
}
// doesn't cover all cases but works the vast majority of the time
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
func sanitizeFeedDescription(description string) string {
if description == "" {
return ""
}
description = strings.ReplaceAll(description, "\n", " ")
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
description = strings.TrimSpace(description)
description = html.UnescapeString(description)
return description
}
func shortenFeedDescriptionLen(description string, maxLen int) string {
description, _ = limitStringLength(description, 1000)
description = sanitizeFeedDescription(description)
description, limited := limitStringLength(description, maxLen)
if limited {
description += "…"
}
return description
}
type rssFeedRequest struct {
URL string `yaml:"url"`
Title string `yaml:"title"`
@ -164,16 +149,68 @@ func (f rssFeedItemList) sortByNewest() rssFeedItemList {
return f
}
var feedParser = gofeed.NewParser()
func (widget *rssWidget) fetchItemsFromFeeds() (rssFeedItemList, error) {
requests := widget.FeedRequests
job := newJob(widget.fetchItemsFromFeedTask, requests).withWorkers(30)
feeds, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", errNoContent, err)
}
failed := 0
entries := make(rssFeedItemList, 0, len(feeds)*10)
seen := make(map[string]struct{})
for i := range feeds {
if errs[i] != nil {
failed++
slog.Error("Failed to get RSS feed", "url", requests[i].URL, "error", errs[i])
continue
}
for _, item := range feeds[i] {
if _, exists := seen[item.Link]; exists {
continue
}
entries = append(entries, item)
seen[item.Link] = struct{}{}
}
}
if failed == len(requests) {
return nil, errNoContent
}
if failed > 0 {
return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed)
}
return entries, nil
}
func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
func (widget *rssWidget) fetchItemsFromFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
req, err := http.NewRequest("GET", request.URL, nil)
if err != nil {
return nil, err
}
req.Header.Add("User-Agent", glanceUserAgentString)
widget.cachedFeedsMutex.Lock()
cache, isCached := widget.cachedFeeds[request.URL]
if isCached {
if cache.etag != "" {
req.Header.Add("If-None-Match", cache.etag)
}
if cache.lastModified != "" {
req.Header.Add("If-Modified-Since", cache.lastModified)
}
}
widget.cachedFeedsMutex.Unlock()
for key, value := range request.Headers {
req.Header.Add(key, value)
req.Header.Set(key, value)
}
resp, err := defaultHTTPClient.Do(req)
@ -182,6 +219,10 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified && isCached {
return cache.items, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.URL)
}
@ -289,9 +330,29 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
items = append(items, rssItem)
}
if resp.Header.Get("ETag") != "" || resp.Header.Get("Last-Modified") != "" {
widget.cachedFeedsMutex.Lock()
widget.cachedFeeds[request.URL] = &cachedRSSFeed{
etag: resp.Header.Get("ETag"),
lastModified: resp.Header.Get("Last-Modified"),
items: items,
}
widget.cachedFeedsMutex.Unlock()
}
return items, nil
}
func findThumbnailInItemExtensions(item *gofeed.Item) string {
media, ok := item.Extensions["media"]
if !ok {
return ""
}
return recursiveFindThumbnailInExtensions(media)
}
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
for _, exts := range extensions {
for _, ext := range exts {
@ -312,43 +373,30 @@ func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extens
return ""
}
func findThumbnailInItemExtensions(item *gofeed.Item) string {
media, ok := item.Extensions["media"]
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
if !ok {
func sanitizeFeedDescription(description string) string {
if description == "" {
return ""
}
return recursiveFindThumbnailInExtensions(media)
}
func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error) {
job := newJob(fetchItemsFromRSSFeedTask, requests).withWorkers(30)
feeds, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", errNoContent, err)
}
failed := 0
entries := make(rssFeedItemList, 0, len(feeds)*10)
for i := range feeds {
if errs[i] != nil {
failed++
slog.Error("Failed to get RSS feed", "url", requests[i].URL, "error", errs[i])
continue
}
description = strings.ReplaceAll(description, "\n", " ")
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
description = strings.TrimSpace(description)
description = html.UnescapeString(description)
entries = append(entries, feeds[i]...)
}
return description
}
if failed == len(requests) {
return nil, errNoContent
}
func shortenFeedDescriptionLen(description string, maxLen int) string {
description, _ = limitStringLength(description, 1000)
description = sanitizeFeedDescription(description)
description, limited := limitStringLength(description, maxLen)
if failed > 0 {
return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed)
if limited {
description += "…"
}
return entries, nil
return description
}

@ -20,6 +20,7 @@ type searchWidget struct {
SearchEngine string `yaml:"search-engine"`
Bangs []SearchBang `yaml:"bangs"`
NewTab bool `yaml:"new-tab"`
Target string `yaml:"target"`
Autofocus bool `yaml:"autofocus"`
Placeholder string `yaml:"placeholder"`
}
@ -34,9 +35,10 @@ var searchEngines = map[string]string{
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
"google": "https://www.google.com/search?q={QUERY}",
"bing": "https://www.bing.com/search?q={QUERY}",
"brave": "https://search.brave.com/search?q={QUERY}",
"perplexity": "https://www.perplexity.ai/search?q={QUERY}",
"kagi": "https://kagi.com/search?q={QUERY}",
"startpage": "https://www.startpage.com/search?q={QUERY}",
"kagi": "https://kagi.com/search?q={QUERY}",
"startpage": "https://www.startpage.com/search?q={QUERY}",
}
func (widget *searchWidget) initialize() error {

@ -0,0 +1,24 @@
package glance
import (
"html/template"
)
var todoWidgetTemplate = mustParseTemplate("todo.html", "widget-base.html")
type todoWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
TodoID string `yaml:"id"`
}
func (widget *todoWidget) initialize() error {
widget.withTitle("To-do").withError(nil)
widget.cachedHTML = widget.renderTemplate(widget, todoWidgetTemplate)
return nil
}
func (widget *todoWidget) Render() template.HTML {
return widget.cachedHTML
}

@ -24,6 +24,10 @@ var (
const defaultClientTimeout = 5 * time.Second
var defaultHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConnsPerHost: 10,
Proxy: http.ProxyFromEnvironment,
},
Timeout: defaultClientTimeout,
}
@ -31,6 +35,7 @@ var defaultInsecureHTTPClient = &http.Client{
Timeout: defaultClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
},
}
@ -38,15 +43,20 @@ type requestDoer interface {
Do(*http.Request) (*http.Response, error)
}
var glanceUserAgentString = "Glance/" + buildVersion + " +https://github.com/glanceapp/glance"
var userAgentPersistentVersion atomic.Int32
func setBrowserUserAgentHeader(request *http.Request) {
func getBrowserUserAgentHeader() string {
if rand.IntN(2000) == 0 {
userAgentPersistentVersion.Store(rand.Int32N(5))
}
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:"+version+".0) Gecko/20100101 Firefox/"+version+".0")
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:" + version + ".0) Gecko/20100101 Firefox/" + version + ".0"
}
func setBrowserUserAgentHeader(request *http.Request) {
request.Header.Set("User-Agent", getBrowserUserAgentHeader())
}
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {
@ -67,7 +77,7 @@ func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T,
truncatedBody, _ := limitStringLength(string(body), 256)
return result, fmt.Errorf(
"unexpected status code %d for %s, response: %s",
"unexpected status code %d from %s, response: %s",
response.StatusCode,
request.URL,
truncatedBody,
@ -180,8 +190,8 @@ func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error)
}
if len(job.data) == 1 {
output, err := job.task(job.data[0])
return append(results, output), append(errs, err), nil
results[0], errs[0] = job.task(job.data[0])
return results, errs, nil
}
tasksQueue := make(chan *workerPoolTask[I, O])

@ -31,6 +31,7 @@ type videosWidget struct {
Playlists []string `yaml:"playlists"`
Limit int `yaml:"limit"`
IncludeShorts bool `yaml:"include-shorts"`
SortBy string `yaml:"sort-by"`
}
func (widget *videosWidget) initialize() error {
@ -64,7 +65,7 @@ func (widget *videosWidget) initialize() error {
}
func (widget *videosWidget) update(ctx context.Context) {
videos, err := fetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
videos, err := fetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts, widget.SortBy)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
@ -98,6 +99,7 @@ type youtubeFeedResponseXml struct {
Videos []struct {
Title string `xml:"title"`
Published string `xml:"published"`
Updated string `xml:"updated"`
Link struct {
Href string `xml:"href,attr"`
} `xml:"link"`
@ -126,11 +128,12 @@ type video struct {
Author string
AuthorUrl string
TimePosted time.Time
TimeUpdated time.Time
}
type videoList []video
func (v videoList) sortByNewest() videoList {
func (v videoList) sortByPosted() videoList {
sort.Slice(v, func(i, j int) bool {
return v[i].TimePosted.After(v[j].TimePosted)
})
@ -138,7 +141,15 @@ func (v videoList) sortByNewest() videoList {
return v
}
func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate string, includeShorts bool) (videoList, error) {
func (v videoList) sortByUpdated() videoList {
sort.Slice(v, func(i, j int) bool {
return v[i].TimeUpdated.After(v[j].TimeUpdated)
})
return v
}
func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate string, includeShorts bool, sortBy string) (videoList, error) {
requests := make([]*http.Request, 0, len(channelOrPlaylistIDs))
for i := range channelOrPlaylistIDs {
@ -198,6 +209,7 @@ func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate
Author: response.Channel,
AuthorUrl: response.ChannelLink + "/videos",
TimePosted: parseYoutubeFeedTime(v.Published),
TimeUpdated: parseYoutubeFeedTime(v.Updated),
})
}
}
@ -206,7 +218,15 @@ func fetchYoutubeChannelUploads(channelOrPlaylistIDs []string, videoUrlTemplate
return nil, errNoContent
}
videos.sortByNewest()
switch sortBy {
case "none":
case "updated":
videos.sortByUpdated()
case "posted":
videos.sortByPosted()
default: // "posted"
videos.sortByPosted()
}
if failed > 0 {
return videos, fmt.Errorf("%w: missing videos from %d channels", errPartialContent, failed)

@ -170,7 +170,7 @@ func parsePlaceName(name string) (string, string) {
func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) {
location, area := parsePlaceName(location)
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=20&language=en&format=json", url.QueryEscape(location))
request, _ := http.NewRequest("GET", requestUrl, nil)
responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request)
if err != nil {

@ -81,6 +81,8 @@ func newWidget(widgetType string) (widget, error) {
w = &serverStatsWidget{}
case "torrents":
w = &torrentsWidget{}
case "to-do":
w = &todoWidget{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}
@ -152,6 +154,7 @@ type widgetBase struct {
Type string `yaml:"type"`
Title string `yaml:"title"`
TitleURL string `yaml:"title-url"`
HideHeader bool `yaml:"hide-header"`
CSSClass string `yaml:"css-class"`
CustomCacheDuration durationField `yaml:"cache"`
ContentAvailable bool `yaml:"-"`
@ -163,7 +166,6 @@ type widgetBase struct {
cacheType cacheType `yaml:"-"`
nextUpdate time.Time `yaml:"-"`
updateRetriedTimes int `yaml:"-"`
HideHeader bool `yaml:"-"`
}
type widgetProviders struct {

@ -201,11 +201,12 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
// currently disabled on Windows because it requires elevated privilidges, otherwise
// keeps returning a single sensor with key "ACPI\\ThermalZone\\TZ00_0" which
// doesn't seem to be the CPU sensor or correspond to anything useful when
// compared against the temperatures Libre Hardware Monitor reports
// also disabled on openbsd because it's not implemented by go-psutil
if runtime.GOOS != "windows" && runtime.GOOS != "openbsd" {
// compared against the temperatures Libre Hardware Monitor reports.
// Also disabled on the bsd's because it's not implemented by go-psutil for them
if runtime.GOOS != "windows" && runtime.GOOS != "openbsd" && runtime.GOOS != "netbsd" && runtime.GOOS != "freebsd" {
sensorReadings, err := sensors.SensorsTemperatures()
if err == nil {
_, errIsWarning := err.(*sensors.Warnings)
if err == nil || errIsWarning {
if req.CPUTempSensor != "" {
for i := range sensorReadings {
if sensorReadings[i].SensorKey == req.CPUTempSensor {
@ -227,35 +228,50 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
}
}
filesystems, err := disk.Partitions(false)
if err == nil {
for _, fs := range filesystems {
mpReq, ok := req.Mountpoints[fs.Mountpoint]
isHidden := req.HideMountpointsByDefault
if ok && mpReq.Hide != nil {
isHidden = *mpReq.Hide
}
if isHidden {
continue
addedMountpoints := map[string]struct{}{}
addMountpointInfo := func(requestedPath string, mpReq MointpointRequest) {
if _, exists := addedMountpoints[requestedPath]; exists {
return
}
isHidden := req.HideMountpointsByDefault
if mpReq.Hide != nil {
isHidden = *mpReq.Hide
}
if isHidden {
return
}
usage, err := disk.Usage(requestedPath)
if err == nil {
mpInfo := MountpointInfo{
Path: requestedPath,
Name: mpReq.Name,
TotalMB: usage.Total / 1024 / 1024,
UsedMB: usage.Used / 1024 / 1024,
UsedPercent: uint8(math.Min(usage.UsedPercent, 100)),
}
usage, err := disk.Usage(fs.Mountpoint)
if err == nil {
mpInfo := MountpointInfo{
Path: fs.Mountpoint,
Name: mpReq.Name,
TotalMB: usage.Total / 1024 / 1024,
UsedMB: usage.Used / 1024 / 1024,
UsedPercent: uint8(math.Min(usage.UsedPercent, 100)),
}
info.Mountpoints = append(info.Mountpoints, mpInfo)
addedMountpoints[requestedPath] = struct{}{}
} else {
addErr(fmt.Errorf("getting filesystem usage for %s: %v", requestedPath, err))
}
}
info.Mountpoints = append(info.Mountpoints, mpInfo)
} else {
addErr(fmt.Errorf("getting filesystem usage for %s: %v", fs.Mountpoint, err))
if !req.HideMountpointsByDefault {
filesystems, err := disk.Partitions(false)
if err == nil {
for _, fs := range filesystems {
addMountpointInfo(fs.Mountpoint, req.Mountpoints[fs.Mountpoint])
}
} else {
addErr(fmt.Errorf("getting filesystems: %v", err))
}
} else {
addErr(fmt.Errorf("getting filesystems: %v", err))
}
for mountpoint, mpReq := range req.Mountpoints {
addMountpointInfo(mountpoint, mpReq)
}
sort.Slice(info.Mountpoints, func(a, b int) bool {