Browse Source

Merge branch 'release/v0.7.0' into resolved

develoopeer 4 tháng trước cách đây
mục cha
commit
818efda38d
72 tập tin đã thay đổi với 3387 bổ sung465 xóa
  1. 1 0
      .dockerignore
  2. 37 0
      .github/ISSUE_TEMPLATE/bug_report.yml
  3. 8 0
      .github/ISSUE_TEMPLATE/config.yml
  4. 33 0
      .github/ISSUE_TEMPLATE/feature_request.yml
  5. 1 7
      .github/PULL_REQUEST_TEMPLATE.md
  6. 1 1
      .gitignore
  7. 2 2
      Dockerfile
  8. 1 1
      Dockerfile.goreleaser
  9. 356 65
      README.md
  10. 638 123
      docs/configuration.md
  11. 296 0
      docs/custom-api.md
  12. 108 0
      docs/glance.yml
  13. BIN
      docs/images/calendar-legacy-widget-preview.png
  14. BIN
      docs/images/calendar-widget-preview.png
  15. BIN
      docs/images/custom-api-preview-1.png
  16. BIN
      docs/images/custom-api-preview-2.png
  17. BIN
      docs/images/custom-api-preview-3.png
  18. BIN
      docs/images/docker-container-parent.png
  19. BIN
      docs/images/docker-container-parent2.png
  20. BIN
      docs/images/docker-containers-preview.png
  21. BIN
      docs/images/preconfigured-page-preview.png
  22. BIN
      docs/images/reddit-field-search.png
  23. BIN
      docs/images/server-stats-flame-icon.png
  24. BIN
      docs/images/server-stats-preview.gif
  25. BIN
      docs/images/split-column-widget-3-columns.png
  26. BIN
      docs/images/split-column-widget-4-columns.png
  27. BIN
      docs/images/split-column-widget-masonry.png
  28. BIN
      docs/images/split-column-widget-preview.png
  29. BIN
      docs/images/videos-widget-vertical-list-preview.png
  30. 57 0
      docs/v0.7.0-upgrade.md
  31. 13 5
      go.mod
  32. 31 17
      go.sum
  33. 50 0
      internal/glance/config-fields.go
  34. 1 0
      internal/glance/config.go
  35. 2 4
      internal/glance/main.go
  36. 33 0
      internal/glance/static/js/animations.js
  37. 212 0
      internal/glance/static/js/calendar.js
  38. 14 2
      internal/glance/static/js/main.js
  39. 4 3
      internal/glance/static/js/popover.js
  40. 190 0
      internal/glance/static/js/templating.js
  41. 244 9
      internal/glance/static/main.css
  42. 29 8
      internal/glance/templates.go
  43. 1 1
      internal/glance/templates/bookmarks.html
  44. 1 1
      internal/glance/templates/dns-stats.html
  45. 22 22
      internal/glance/templates/docker-containers.html
  46. 31 31
      internal/glance/templates/forum-posts.html
  47. 34 0
      internal/glance/templates/old-calendar.html
  48. 1 1
      internal/glance/templates/reddit-horizontal-cards.html
  49. 1 1
      internal/glance/templates/reddit-vertical-cards.html
  50. 1 1
      internal/glance/templates/search.html
  51. 140 0
      internal/glance/templates/server-stats.html
  52. 1 1
      internal/glance/templates/twitch-channels.html
  53. 1 1
      internal/glance/templates/twitch-games-list.html
  54. 3 5
      internal/glance/templates/v0.7-update-notice-page.html
  55. 20 0
      internal/glance/templates/videos-vertical-list.html
  56. 26 10
      internal/glance/templates/widget-base.html
  57. 8 0
      internal/glance/utils.go
  58. 18 4
      internal/glance/widget-bookmarks.go
  59. 7 6
      internal/glance/widget-calendar.go
  60. 23 18
      internal/glance/widget-custom-api.go
  61. 2 2
      internal/glance/widget-docker-containers.go
  62. 28 9
      internal/glance/widget-markets.go
  63. 15 9
      internal/glance/widget-monitor.go
  64. 86 0
      internal/glance/widget-old-calendar.go
  65. 31 16
      internal/glance/widget-reddit.go
  66. 100 70
      internal/glance/widget-releases.go
  67. 11 4
      internal/glance/widget-rss.go
  68. 5 0
      internal/glance/widget-search.go
  69. 117 0
      internal/glance/widget-server-stats.go
  70. 30 5
      internal/glance/widget-videos.go
  71. 9 0
      internal/glance/widget.go
  72. 252 0
      pkg/sysinfo/sysinfo.go

+ 1 - 0
.dockerignore

@@ -5,6 +5,7 @@
 # Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
 !/build/
 !/internal/
+!/pkg/
 !/go.mod
 !/go.sum
 !main.go

+ 37 - 0
.github/ISSUE_TEMPLATE/bug_report.yml

@@ -0,0 +1,37 @@
+name: Bug report
+description: Let us know if something isn't working as expected
+labels: ["bug report"]
+body:
+  - type: markdown
+    attributes:
+      value: |
+        > [!NOTE]
+        >
+        > Do not prefix your title with "[BUG]", "[Bug report]", etc., a label will be added automatically.
+
+        If you're unsure whether you're experiencing a bug or not, consider using the [Discussions](https://github.com/glanceapp/glance/discussions) or [Discord](https://discord.com/invite/7KQ7Xa9kJd) to ask for help.
+
+        Please include only the information you think is relevant to the bug:
+
+        * How did you install Glance? (Docker container, manual binary install, etc)
+        * Which version of Glance are you using?
+        * Include the relevant parts of your `glance.yml` if applicable (widget, data source, properties used, etc)
+        * Include any relevant logs or screenshots if applicable
+        * Is the issue specific to a certain browser or OS?
+        * Steps to reliably reproduce the issue
+        * Are you hosting Glance on a VPS?
+        * Anything else you think might be relevant
+
+        **No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
+
+  - type: textarea
+    id: description
+    validations:
+      required: true
+    attributes:
+      label: Description
+
+  - type: markdown
+    attributes:
+      value: |
+        Thank you for taking the time to submit a bug report.

+ 8 - 0
.github/ISSUE_TEMPLATE/config.yml

@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+  - name: Discussions
+    url: https://github.com/glanceapp/glance/discussions
+    about: For help, feedback, guides, resources and more
+  - name: Discord
+    url: https://discord.com/invite/7KQ7Xa9kJd
+    about: Much like the discussions but more chatty

+ 33 - 0
.github/ISSUE_TEMPLATE/feature_request.yml

@@ -0,0 +1,33 @@
+name: Feature request
+description: Share your ideas for new features or improvements
+labels: ["feature request"]
+body:
+  - type: markdown
+    attributes:
+      value: |
+        > [!NOTE]
+        >
+        > Do not prefix your title with "[REQUEST]", "[Feature request]", etc., a label will be added automatically.
+
+        Please provide a detailed description of what the feature would do and what it would look like:
+
+        * What problem would this feature solve?
+        * Are there any potential downsides to this feature?
+        * If applicable, what would the configuration for this feature look like?
+        * Are there any existing examples of this feature in other software?
+        * If applicable, include any external documentation required to implement this feature
+        * Anything else you think might be relevant
+
+        **No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
+
+  - type: textarea
+    id: description
+    validations:
+      required: true
+    attributes:
+      label: Description
+
+  - type: markdown
+    attributes:
+      value: |
+        Thank you for taking the time to submit your idea.

+ 1 - 7
.github/PULL_REQUEST_TEMPLATE.md

@@ -1,7 +1 @@
-<!--
-
-If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
-
-Documentation updates (including new themes) can be submitted to the main branch.
-
--->
+<!-- If your pull request adds new features, changes existing ones or fixes any bugs, please use the dev branch as the base, otherwise use the main branch -->

+ 1 - 1
.gitignore

@@ -2,4 +2,4 @@
 /build
 /playground
 /.idea
-glance*.yml
+/glance*.yml

+ 2 - 2
Dockerfile

@@ -1,10 +1,10 @@
-FROM golang:1.23.1-alpine3.20 AS builder
+FROM golang:1.23.6-alpine3.21 AS builder
 
 WORKDIR /app
 COPY . /app
 RUN CGO_ENABLED=0 go build .
 
-FROM alpine:3.20
+FROM alpine:3.21
 
 WORKDIR /app
 COPY --from=builder /app/glance .

+ 1 - 1
Dockerfile.goreleaser

@@ -1,4 +1,4 @@
-FROM alpine:3.20
+FROM alpine:3.21
 
 WORKDIR /app
 COPY glance .

+ 356 - 65
README.md

@@ -2,113 +2,404 @@
 <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>
 
-![example homepage](docs/images/readme-main-image.png)
+![](docs/images/readme-main-image.png)
 
-### Features
-#### Various widgets
+## Features
+### Various widgets
 * RSS feeds
 * Subreddit posts
-* Weather
-* Bookmarks
-* Hacker News
-* Lobsters
-* Latest YouTube videos from specific channels
-* Clock
-* Calendar
-* Stocks
-* iframe
-* Twitch channels & top games
-* GitHub releases
-* Repository overview
-* Docker containers
-* Site monitor
-* Search box
-
-#### Themeable
-![multiple color schemes example](docs/images/themes-example.png)
-
-#### Optimized for mobile devices
-![mobile device previews](docs/images/mobile-preview.png)
-
-#### Fast and lightweight
-* Minimal JS, no bloated frameworks
-* Very few dependencies
-* Single, easily distributed <15mb binary and just as small docker container
-* All requests are parallelized, uncached pages usually load within ~1s (depending on internet speed and number of widgets)
-
-### Configuration
-Checkout the [configuration docs](docs/configuration.md) to learn more. A [preconfigured page](docs/configuration.md#preconfigured-page) is also available to get you started quickly.
-
-### Installation
-> [!CAUTION]
->
-> The project is under active development, expect things to break every once in a while.
-
-#### Manual
-Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
+* Hacker News posts
+* Weather forecasts
+* YouTube channel uploads
+* Twitch channels
+* Market prices
+* Docker containers status
+* Server stats
+* Custom widgets
+* [and many more...](docs/configuration.md)
+
+### Fast and lightweight
+* Low memory usage
+* Few dependencies
+* Minimal vanilla JS
+* Single <20mb binary available for multiple OSs & architectures and just as small Docker container
+* Uncached pages usually load within ~1s (depending on internet speed and number of widgets)
+
+### Tons of customizability
+* Different layouts
+* As many pages/tabs as you need
+* Numerous configuration options for each widget
+* Multiple styles for some widgets
+* Custom CSS
+
+### Optimized for mobile devices
+Because you'll want to take it with you on the go.
+
+![](docs/images/mobile-preview.png)
+
+### Themeable
+Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
+
+![](docs/images/themes-example.png)
+
+<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).
+
+<details>
+<summary><strong>Preview example configuration file</strong></summary>
+<br>
+
+```yaml
+pages:
+  - name: Home
+    columns:
+      - size: small
+        widgets:
+          - type: calendar
+            first-day-of-week: monday
+
+          - type: rss
+            limit: 10
+            collapse-after: 3
+            cache: 12h
+            feeds:
+              - url: https://selfh.st/rss/
+                title: selfh.st
+                limit: 4
+              - url: https://ciechanow.ski/atom.xml
+              - url: https://www.joshwcomeau.com/rss.xml
+                title: Josh Comeau
+              - url: https://samwho.dev/rss.xml
+              - url: https://ishadeed.com/feed.xml
+                title: Ahmad Shadeed
+
+          - type: twitch-channels
+            channels:
+              - theprimeagen
+              - j_blow
+              - piratesoftware
+              - cohhcarnage
+              - christitustech
+              - EJ_SA
+
+      - size: full
+        widgets:
+          - type: group
+            widgets:
+              - type: hacker-news
+              - type: lobsters
+
+          - type: videos
+            channels:
+              - UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
+              - UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
+              - UCsBjURrPoezykLs9EqgamOA # Fireship
+              - UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
+              - UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
+
+          - type: group
+            widgets:
+              - type: reddit
+                subreddit: technology
+                show-thumbnails: true
+              - type: reddit
+                subreddit: selfhosted
+                show-thumbnails: true
+
+      - size: small
+        widgets:
+          - type: weather
+            location: London, United Kingdom
+            units: metric
+            hour-format: 12h
+
+          - type: markets
+            markets:
+              - symbol: SPY
+                name: S&P 500
+              - symbol: BTC-USD
+                name: Bitcoin
+              - symbol: NVDA
+                name: NVIDIA
+              - symbol: AAPL
+                name: Apple
+              - symbol: MSFT
+                name: Microsoft
+
+          - type: releases
+            cache: 1d
+            repositories:
+              - glanceapp/glance
+              - go-gitea/gitea
+              - immich-app/immich
+              - syncthing/syncthing
+```
+</details>
+
+<br>
+
+## Installation
+
+Choose one of the following methods:
+
+<details>
+<summary><strong>Docker compose using provided directory structure (recommended)</strong></summary>
+<br>
+
+Create a new directory called `glance` as well as the template files within it by running:
 
 ```bash
-/opt/glance/glance --config /etc/glance.yml
+mkdir glance && cd glance && curl -sL https://github.com/glanceapp/docker-compose-template/archive/refs/heads/main.tar.gz | tar -xzf - --strip-components 2
 ```
 
-#### Docker
-<!-- TODO: update -->
+*[click here to view the files that will be created](https://github.com/glanceapp/docker-compose-template/tree/main/root)*
+
+Then, edit the following files as desired:
+* `docker-compose.yml` to configure the port, volumes and other containery things
+* `config/home.yml` to configure the widgets or layout of the home page
+* `config/glance.yml` if you want to change the theme or add more pages
 
-> [!IMPORTANT]
->
-> Make sure you have a valid `glance.yml` file in the same directory before running the container.
+<details>
+<summary>Other files you may want to edit</summary>
+
+* `.env` to configure environment variables that will be available inside configuration files
+* `assets/user.css` to add custom CSS
+</details>
+
+When ready, run:
 
 ```bash
-docker run -d -p 8080:8080 \
-  -v ./glance.yml:/app/glance.yml \
-  -v /etc/timezone:/etc/timezone:ro \
-  -v /etc/localtime:/etc/localtime:ro \
-  glanceapp/glance
+docker compose up -d
 ```
 
-Or if you prefer docker compose:
+If you encounter any issues, you can check the logs by running:
+
+```bash
+docker compose logs
+```
+
+<hr>
+</details>
+
+<details>
+<summary><strong>Docker compose manual</strong></summary>
+<br>
+
+Create a `docker-compose.yml` file with the following contents:
 
 ```yaml
 services:
   glance:
+    container_name: glance
     image: glanceapp/glance
     volumes:
-      - ./glance.yml:/app/glance.yml
-      - /etc/timezone:/etc/timezone:ro
-      - /etc/localtime:/etc/localtime:ro
+      - ./config:/app/config
     ports:
       - 8080:8080
-    restart: unless-stopped
 ```
 
-### Building from source
+Then, create a new directory called `config` and download the example starting [`glance.yml`](https://github.com/glanceapp/glance/blob/main/docs/glance.yml) file into it by running:
+
+```bash
+mkdir config && wget -O config/glance.yml https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
+```
 
-Requirements: [Go](https://go.dev/dl/) >= v1.22
+Feel free to edit the `glance.yml` file to your liking, and when ready run:
+
+```bash
+docker compose up -d
+```
+
+If you encounter any issues, you can check the logs by running:
+
+```bash
+docker logs glance
+```
+
+<hr>
+</details>
+
+<details>
+<summary><strong>Manual binary installation</strong></summary>
+<br>
+
+Precompiled binaries are available for Linux, Windows and macOS (x86, x86_64, ARM and ARM64 architectures).
+
+### Linux
+
+Visit the [latest release page](https://github.com/glanceapp/glance/releases/latest) for available binaries. You can place the binary in `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). By default, when running the binary, it will look for a `glance.yml` file in the directory it's placed in. To specify a different path for the config file, use the `--config` option:
+
+```bash
+/opt/glance/glance --config /etc/glance.yml
+```
 
-To build:
+To grab a starting template for the config file, run:
+
+```bash
+wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
+```
+
+### Windows
+
+Download and extract the executable from the [latest release](https://github.com/glanceapp/glance/releases/latest) (most likely the file called `glance-windows-amd64.zip` if you're on a 64-bit system) and place it in a folder of your choice. Then, create a new text file called `glance.yml` in the same folder and paste the content from [here](https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml) in it. You should then be able to run the executable and access the dashboard by visiting `http://localhost:8080` in your browser.
+
+
+
+<hr>
+</details>
+
+<details>
+<summary><strong>Other</strong></summary>
+<br>
+
+Glance can also be installed through the following 3rd party channels:
+* [Proxmox VE Helper Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=glance)
+* [NixOS package](https://search.nixos.org/packages?channel=unstable&show=glance)
+* [Coolify.io](https://coolify.io/docs/services/glance/)
+
+<hr>
+</details>
+
+<br>
+
+## Building from source
+
+Choose one of the following methods:
+
+<details>
+<summary><strong>Build binary with Go</strong></summary>
+<br>
+
+Requirements: [Go](https://go.dev/dl/) >= v1.23
+
+To build the project for your current OS and architecture, run:
 
 ```bash
 go build -o build/glance .
 ```
 
-To run:
+To build for a specific OS and architecture, run:
+
+```bash
+GOOS=linux GOARCH=amd64 go build -o build/glance .
+```
+
+[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
+
+Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
 
 ```bash
 go run .
 ```
+<hr>
+</details>
+
+<details>
+<summary><strong>Build project and Docker image with Docker</strong></summary>
+<br>
 
-### Building Docker image
+Requirements: [Docker](https://docs.docker.com/engine/install/)
 
-Build the image:
+To build the project and image using just Docker, run:
 
-**Make sure to replace "owner" with your name or organization.**
+*(replace `owner` with your name or organization)*
 
 ```bash
 docker build -t owner/glance:latest .
 ```
 
-Push the image to your registry:
+If you wish to push the image to a registry (by default Docker Hub), run:
 
 ```bash
 docker push owner/glance:latest
 ```
+
+<hr>
+</details>
+
+<br>
+
+## FAQ
+<details>
+<summary><strong>Does the information on the page update automatically?</strong></summary>
+No, a page refresh is required to update the information. Some things do dynamically update where it makes sense, like the clock widget and the relative time showing how long ago something happened.
+</details>
+
+<details>
+<summary><strong>How frequently do widgets update?</strong></summary>
+No requests are made periodically in the background, information is only fetched upon loading the page and then cached. The default cache lifetime is different for each widget and can be configured.
+</details>
+
+<details>
+<summary><strong>Can I create my own widgets?</strong></summary>
+
+Yes, there are multiple ways to create custom widgets:
+* `iframe` widget - allows you to embed things from other websites
+* `html` widget - allows you to insert your own static HTML
+* `extension` widget - fetch HTML from a URL
+* `custom-api` widget - fetch JSON from a URL and render it using custom HTML
+</details>
+
+<details>
+<summary><strong>Can I change the title of a widget?</strong></summary>
+
+Yes, the title of all widgets can be changed by specifying the `title` property in the widget's configuration:
+
+```yaml
+- type: rss
+  title: My custom title
+
+- type: markets
+  title: My custom title
+
+- type: videos
+  title: My custom title
+
+# and so on for all widgets...
+```
+</details>
+
+<br>
+
+## Feature requests
+
+New feature suggestions are always welcome and will be considered, though please keep in mind that some of them may be out of scope for what the project is trying to achieve (or is reasonably capable of). If you have an idea for a new feature and would like to share it, you can do so [here](https://github.com/glanceapp/glance/issues/new?template=feature_request.yml).
+
+Feature requests are tagged with one of the following:
+
+* [Roadmap](https://github.com/glanceapp/glance/labels/roadmap) - will be implemented in a future release
+* [Backlog](https://github.com/glanceapp/glance/labels/backlog) - may be implemented in the future but needs further feedback or interest from the community
+* [Icebox](https://github.com/glanceapp/glance/labels/icebox) - no plans to implement as it doesn't currently align with the project's goals or capabilities, may be revised at a later date
+
+<br>
+
+## Contributing guidelines
+
+* Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself
+* Please don't submit PRs for feature requests that are either in the roadmap<sup>[1]</sup>, backlog<sup>[2]</sup> or icebox<sup>[3]</sup>
+* Use `dev` for the base branch if you're adding new features or fixing bugs, otherwise use `main`
+* Avoid introducing new dependencies
+* Avoid making backwards-incompatible configuration changes
+* Avoid introducing new colors or hard-coding colors, use the standard `primary`, `positive` and `negative`
+* For icons, try to use [heroicons](https://heroicons.com/) where applicable
+* Provide a screenshot of the changes if UI related where possible
+* No `package.json`
+
+<details>
+<summary><strong><sup>[1] [2] [3]</sup></strong></summary>
+
+[1] The feature likely already has work put into it that may conflict with your implementation
+
+[2] The demand, implementation or functionality for this feature is not yet clear
+
+[3] No plans to add this feature for the time being
+
+</details>
+
+<br>
+
+## Thank you
+
+To all the people who were generous enough to [sponsor](https://github.com/sponsors/glanceapp) the project and to everyone who has contributed in any way, be it PRs, submitting issues, helping others in the discussions or Discord server, creating guides and tools or just mentioning Glance on social media. Your support is greatly appreciated and helps keep the project going.

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 638 - 123
docs/configuration.md


+ 296 - 0
docs/custom-api.md

@@ -0,0 +1,296 @@
+[Jump to function definitions](#functions)
+
+## Examples
+
+The best way to get an idea of how the templates work would be with a bunch examples. Here are the most common use cases:
+
+JSON response:
+
+```json
+{
+  "title": "My Title",
+  "content": "My Content",
+}
+```
+
+To access the two fields in the JSON response, you would use the following:
+
+```html
+<div>{{ .JSON.String "title" }}</div>
+<div>{{ .JSON.String "content" }}</div>
+```
+
+Output:
+
+```html
+<div>My Title</div>
+<div>My Content</div>
+```
+
+<hr>
+
+JSON response:
+
+```json
+{
+  "author": "John Doe",
+  "posts": [
+    {
+      "title": "My Title",
+      "content": "My Content"
+    },
+    {
+      "title": "My Title 2",
+      "content": "My Content 2"
+    }
+  ]
+}
+```
+
+To loop through the array of posts, you would use the following:
+
+```html
+{{ range .JSON.Array "posts" }}
+  <div>{{ .String "title" }}</div>
+  <div>{{ .String "content" }}</div>
+{{ end }}
+```
+
+Output:
+
+```html
+<div>My Title</div>
+<div>My Content</div>
+<div>My Title 2</div>
+<div>My Content 2</div>
+```
+
+Notice the missing `.JSON` when accessing the title and content, this is because the range function sets the context to the current array element.
+
+If you want to access the top-level context within the range, you can use the following:
+
+```html
+{{ range .JSON.Array "posts" }}
+  <div>{{ .String "title" }}</div>
+  <div>{{ .String "content" }}</div>
+  <div>{{ $.JSON.String "author" }}</div>
+{{ end }}
+```
+
+Output:
+
+```html
+<div>My Title</div>
+<div>My Content</div>
+<div>John Doe</div>
+<div>My Title 2</div>
+<div>My Content 2</div>
+<div>John Doe</div>
+```
+
+<hr>
+
+JSON response:
+
+```json
+[
+    "Apple",
+    "Banana",
+    "Cherry",
+    "Watermelon"
+]
+```
+
+Somewhat awkwardly, when the current context is a basic type that isn't an object, the way you specify its type is to use an empty string as the key. So, to loop through the array of strings, you would use the following:
+
+```html
+{{ range .JSON.Array "" }}
+  <div>{{ .String "" }}</div>
+{{ end }}
+```
+
+Output:
+
+```html
+<div>Apple</div>
+<div>Banana</div>
+<div>Cherry</div>
+<div>Watermelon</div>
+```
+
+To access an item at a specific index, you could use the following:
+
+```html
+<div>{{ .JSON.String "0" }}</div>
+```
+
+Output:
+
+```html
+<div>Apple</div>
+```
+
+<hr>
+
+JSON response:
+
+```json
+{
+    "user": {
+        "address": {
+            "city": "New York",
+            "state": "NY"
+        }
+    }
+}
+```
+
+To easily access deeply nested objects, you can use the following dot notation:
+
+```html
+<div>{{ .JSON.String "user.address.city" }}</div>
+<div>{{ .JSON.String "user.address.state" }}</div>
+```
+
+Output:
+
+```html
+<div>New York</div>
+<div>NY</div>
+```
+
+Using indexes anywhere in the path is also supported:
+
+```json
+{
+    "users": [
+        {
+            "name": "John Doe"
+        },
+        {
+            "name": "Jane Doe"
+        }
+    ]
+}
+```
+
+```html
+<div>{{ .JSON.String "users.0.name" }}</div>
+<div>{{ .JSON.String "users.1.name" }}</div>
+```
+
+Output:
+
+```html
+<div>John Doe</div>
+<div>Jane Doe</div>
+```
+
+<hr>
+
+JSON response:
+
+```json
+{
+    "user": {
+        "name": "John Doe",
+        "age": 30
+    }
+}
+```
+
+To check if a field exists, you can use the following:
+
+```html
+{{ if .JSON.Exists "user.age" }}
+  <div>{{ .JSON.Int "user.age" }}</div>
+{{ else }}
+  <div>Age not provided</div>
+{{ end }}
+```
+
+Output:
+
+```html
+<div>30</div>
+```
+
+<hr>
+
+JSON response:
+
+```json
+{
+    "price": 100,
+    "discount": 10
+}
+```
+
+Calculations can be performed, however all numbers must be converted to floats first if they are not already:
+
+```html
+<div>{{ sub (.JSON.Int "price" | toFloat) (.JSON.Int "discount" | toFloat) }}</div>
+```
+
+Output:
+
+```html
+<div>90</div>
+```
+
+Other operations include `add`, `mul`, and `div`.
+
+<hr>
+
+In some instances, you may want to know the status code of the response. This can be done using the following:
+
+```html
+{{ if eq .Response.StatusCode 200 }}
+  <p>Success!</p>
+{{ else }}
+  <p>Failed to fetch data</p>
+{{ end }}
+```
+
+You can also access the response headers:
+
+```html
+<div>{{ .Response.Header.Get "Content-Type" }}</div>
+```
+
+## Functions
+
+The following functions are available on the `JSON` object:
+
+- `String(key string) string`: Returns the value of the key as a string.
+- `Int(key string) int`: Returns the value of the key as an integer.
+- `Float(key string) float`: Returns the value of the key as a float.
+- `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.
+
+The following helper functions provided by Glance are available:
+
+- `toFloat(i int) float`: Converts an integer to a float.
+- `toInt(f float) int`: Converts a float to an integer.
+- `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.
+- `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.
+
+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.
+- `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.
+- `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.
+- `printf(format string, a ...any) string`: Returns a formatted string.

+ 108 - 0
docs/glance.yml

@@ -0,0 +1,108 @@
+pages:
+  - name: Home
+    # Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
+    # hide-desktop-navigation: true
+    columns:
+      - size: small
+        widgets:
+          - type: calendar
+            first-day-of-week: monday
+
+          - type: rss
+            limit: 10
+            collapse-after: 3
+            cache: 12h
+            feeds:
+              - url: https://selfh.st/rss/
+                title: selfh.st
+                limit: 4
+              - url: https://ciechanow.ski/atom.xml
+              - url: https://www.joshwcomeau.com/rss.xml
+                title: Josh Comeau
+              - url: https://samwho.dev/rss.xml
+              - url: https://ishadeed.com/feed.xml
+                title: Ahmad Shadeed
+
+          - type: twitch-channels
+            channels:
+              - theprimeagen
+              - j_blow
+              - piratesoftware
+              - cohhcarnage
+              - christitustech
+              - EJ_SA
+
+      - size: full
+        widgets:
+          - type: group
+            widgets:
+              - type: hacker-news
+              - type: lobsters
+
+          - type: videos
+            channels:
+              - UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
+              - UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
+              - UCsBjURrPoezykLs9EqgamOA # Fireship
+              - UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
+              - UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
+
+          - type: group
+            widgets:
+              - type: reddit
+                subreddit: technology
+                show-thumbnails: true
+              - type: reddit
+                subreddit: selfhosted
+                show-thumbnails: true
+
+      - size: small
+        widgets:
+          - type: weather
+            location: London, United Kingdom
+            units: metric # alternatively "imperial"
+            hour-format: 12h # alternatively "24h"
+            # Optionally hide the location from being displayed in the widget
+            # hide-location: true
+
+          - type: markets
+            # The link to go to when clicking on the symbol in the UI,
+            # {SYMBOL} will be substituded with the symbol for each market
+            symbol-link-template: https://www.tradingview.com/symbols/{SYMBOL}/news
+            markets:
+              - symbol: SPY
+                name: S&P 500
+              - symbol: BTC-USD
+                name: Bitcoin
+              - symbol: NVDA
+                name: NVIDIA
+              - symbol: AAPL
+                name: Apple
+              - symbol: MSFT
+                name: Microsoft
+
+          - type: releases
+            cache: 1d
+            # Without authentication the Github API allows for up to 60 requests per hour. You can create a
+            # read-only token from your Github account settings and use it here to increase the limit.
+            # token: ...
+            repositories:
+              - glanceapp/glance
+              - go-gitea/gitea
+              - immich-app/immich
+              - syncthing/syncthing
+
+  # Add more pages here:
+  # - name: Your page name
+  #   columns:
+  #     - size: small
+  #       widgets:
+  #         # Add widgets here
+
+  #     - size: full
+  #       widgets:
+  #         # Add widgets here
+
+  #     - size: small
+  #       widgets:
+  #         # Add widgets here

BIN
docs/images/calendar-legacy-widget-preview.png


BIN
docs/images/calendar-widget-preview.png


BIN
docs/images/custom-api-preview-1.png


BIN
docs/images/custom-api-preview-2.png


BIN
docs/images/custom-api-preview-3.png


BIN
docs/images/docker-container-parent.png


BIN
docs/images/docker-container-parent2.png


BIN
docs/images/docker-containers-preview.png


BIN
docs/images/preconfigured-page-preview.png


BIN
docs/images/reddit-field-search.png


BIN
docs/images/server-stats-flame-icon.png


BIN
docs/images/server-stats-preview.gif


BIN
docs/images/split-column-widget-3-columns.png


BIN
docs/images/split-column-widget-4-columns.png


BIN
docs/images/split-column-widget-masonry.png


BIN
docs/images/split-column-widget-preview.png


BIN
docs/images/videos-widget-vertical-list-preview.png


+ 57 - 0
docs/v0.7.0-upgrade.md

@@ -0,0 +1,57 @@
+## Upgrading to v0.7.0 from previous versions
+
+In essence, the `glance.yml` file has been moved from the root of the project to a `config/` directory and you now need to mount that directory to `/app/config` in the container.
+
+### Before
+
+Versions before v0.7.0 used a `docker-compose.yml` that looked like the following:
+
+```yaml
+services:
+  glance:
+    image: glanceapp/glance
+    volumes:
+      - ./glance.yml:/app/glance.yml
+    ports:
+      - 8080:8080
+```
+
+And expected you to have the following directory structure:
+
+```plaintext
+glance/
+    docker-compose.yml
+    glance.yml
+```
+
+### After
+
+With the release of v0.7.0, the recommended `docker-compose.yml` looks like the following:
+
+```yaml
+services:
+  glance:
+    container_name: glance
+    image: glanceapp/glance
+    volumes:
+      - ./config:/app/config
+    ports:
+      - 8080:8080
+```
+
+And expects you to have the following directory structure:
+
+```plaintext
+glance/
+    docker-compose.yml
+    config/
+        glance.yml
+```
+
+## Why this change was necessary
+
+1. Mounting a file rather than a directory is not common practice and leads to some issues, such as creating a directory if the file is not present, which has tripped up multiple people and caused unnecessary confusion
+2. v0.7.0 added automatic reloads when the configuration file changes, which based on testing didn't work when mounting a single file
+3. v0.7.0 added the ability to include config files, so you'd have to make this change anyways if you wanted to take advantage of that feature
+
+Taking all of these into account, it felt like the right time to implement the change.

+ 13 - 5
go.mod

@@ -1,25 +1,33 @@
 module github.com/glanceapp/glance
 
-go 1.23.1
+go 1.23.6
 
 require (
 	github.com/fsnotify/fsnotify v1.8.0
 	github.com/mmcdole/gofeed v1.3.0
+	github.com/shirou/gopsutil/v4 v4.25.1
 	github.com/tidwall/gjson v1.18.0
-	golang.org/x/text v0.21.0
+	golang.org/x/text v0.22.0
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
 	github.com/arran4/golang-ical v0.3.1 // indirect
-	github.com/PuerkitoBio/goquery v1.10.0 // indirect
+	github.com/PuerkitoBio/goquery v1.10.1 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
+	github.com/ebitengine/purego v0.8.2 // 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/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
-	golang.org/x/net v0.33.0 // indirect
-	golang.org/x/sys v0.28.0 // indirect
+	github.com/tklauser/go-sysconf v0.3.14 // indirect
+	github.com/tklauser/numcpus v0.9.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
 )

+ 31 - 17
go.sum

@@ -1,15 +1,21 @@
-github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
-github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
 github.com/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk=
 github.com/arran4/golang-ical v0.3.1/go.mod h1:LZWxF8ZIu/sjBVUCV0udiVPrQAgq3V0aa0RfbO99Qkk=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
+github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
 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/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -17,6 +23,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+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/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=
@@ -29,11 +37,14 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 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/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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
 github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
 github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -41,7 +52,13 @@ 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/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=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -57,15 +74,13 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 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.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
-golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
-golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
 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/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=
@@ -74,25 +89,25 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
-golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
 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/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=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
@@ -106,10 +121,9 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
-golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
 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/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=

+ 50 - 0
internal/glance/config-fields.go

@@ -1,7 +1,10 @@
 package glance
 
 import (
+	"crypto/tls"
 	"fmt"
+	"net/http"
+	"net/url"
 	"regexp"
 	"strconv"
 	"strings"
@@ -169,3 +172,50 @@ func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
 	*i = newCustomIconField(value)
 	return nil
 }
+
+type proxyOptionsField struct {
+	URL           string        `yaml:"url"`
+	AllowInsecure bool          `yaml:"allow-insecure"`
+	Timeout       durationField `yaml:"timeout"`
+	client        *http.Client  `yaml:"-"`
+}
+
+func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
+	type proxyOptionsFieldAlias proxyOptionsField
+	alias := (*proxyOptionsFieldAlias)(p)
+	var proxyURL string
+
+	if err := node.Decode(&proxyURL); err != nil {
+		if err := node.Decode(alias); err != nil {
+			return err
+		}
+	}
+
+	if proxyURL == "" && p.URL == "" {
+		return nil
+	}
+
+	if p.URL != "" {
+		proxyURL = p.URL
+	}
+
+	parsedUrl, err := url.Parse(proxyURL)
+	if err != nil {
+		return fmt.Errorf("parsing proxy URL: %v", err)
+	}
+
+	var timeout = defaultClientTimeout
+	if p.Timeout > 0 {
+		timeout = time.Duration(p.Timeout)
+	}
+
+	p.client = &http.Client{
+		Timeout: timeout,
+		Transport: &http.Transport{
+			Proxy:           http.ProxyURL(parsedUrl),
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure},
+		},
+	}
+
+	return nil
+}

+ 1 - 0
internal/glance/config.go

@@ -99,6 +99,7 @@ func newConfigFromYAML(contents []byte) (*config, error) {
 	return config, nil
 }
 
+// TODO: change the pattern so that it doesn't match commented out lines
 var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`)
 
 func parseConfigEnvVariables(contents []byte) ([]byte, error) {

+ 2 - 4
internal/glance/main.go

@@ -56,8 +56,6 @@ func Main() int {
 
 func serveApp(configPath string) error {
 	exitChannel := make(chan struct{})
-	// the onChange method gets called at most once per 500ms due to debouncing so we shouldn't
-	// need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason
 	hadValidConfigOnStartup := false
 	var stopServer func() error
 
@@ -153,9 +151,9 @@ func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
 	templateFile, _ := templateFS.Open("v0.7-update-notice-page.html")
 	bodyContents, _ := io.ReadAll(templateFile)
 
-	// TODO: update - add link
 	fmt.Println("!!! WARNING !!!")
-	fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see <link> for more information.")
+	fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0.")
+	fmt.Println("Please see https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md for more information.")
 
 	mux := http.NewServeMux()
 	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))

+ 33 - 0
internal/glance/static/js/animations.js

@@ -0,0 +1,33 @@
+export const easeOutQuint = 'cubic-bezier(0.22, 1, 0.36, 1)';
+
+export function directions(anim, opt, ...dirs) {
+    return dirs.map(dir => anim({ direction: dir, ...opt }));
+}
+
+export function slideFade({
+    direction = 'left',
+    fill = 'backwards',
+    duration = 200,
+    distance = '1rem',
+    easing = 'ease',
+    offset = 0,
+}) {
+    const axis = direction === 'left' || direction === 'right' ? 'X' : 'Y';
+    const negative = direction === 'left' || direction === 'up' ? '-' : '';
+    const amount = negative + distance;
+
+    return {
+        keyframes: [
+            {
+                offset: offset,
+                opacity: 0,
+                transform: `translate${axis}(${amount})`,
+            }
+        ],
+        options: {
+            duration: duration,
+            easing: easing,
+            fill: fill,
+        },
+    };
+}

+ 212 - 0
internal/glance/static/js/calendar.js

@@ -0,0 +1,212 @@
+import { directions, easeOutQuint, slideFade } from "./animations.js";
+import { elem, repeat, text } from "./templating.js";
+
+const FULL_MONTH_SLOTS = 7*6;
+const WEEKDAY_ABBRS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
+const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
+
+const leftArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
+  <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
+</svg>`;
+
+const rightArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
+  <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
+</svg>`;
+
+const undoArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
+  <path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
+</svg>`;
+
+const [datesExitLeft, datesExitRight] = directions(
+    slideFade, { distance: "2rem", duration: 120, offset: 1 },
+    "left", "right"
+);
+
+const [datesEntranceLeft, datesEntranceRight] = directions(
+    slideFade, { distance: "0.8rem", duration: 500, easing: easeOutQuint },
+    "left", "right"
+);
+
+const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
+
+export default function(element) {
+    element.swap(Calendar(
+        Number(element.dataset.firstDayOfWeek ?? 1)
+    ));
+}
+
+// TODO: when viewing the previous/next month, display the current date if it's within the spill-over days
+function Calendar(firstDay) {
+    let header, dates;
+    let advanceTimeTicker;
+    let now = new Date();
+    let activeDate;
+
+    const update = (newDate) => {
+        header.component.update(now, newDate);
+        dates.component.update(now, newDate);
+        activeDate = newDate;
+    };
+
+    const autoAdvanceNow = () => {
+        advanceTimeTicker = setTimeout(() => {
+            // TODO: don't auto advance if looking at a different month
+            update(now = new Date());
+            autoAdvanceNow();
+        }, msTillNextDay());
+    };
+
+    const adjacentMonth = (dir) => new Date(activeDate.getFullYear(), activeDate.getMonth() + dir, 1);
+    const nextClicked = () => update(adjacentMonth(1));
+    const prevClicked = () => update(adjacentMonth(-1));
+    const undoClicked = () => update(now);
+
+    const calendar = elem().classes("calendar").append(
+        header = Header(nextClicked, prevClicked, undoClicked),
+        dates = Dates(firstDay)
+    );
+
+    update(now);
+    autoAdvanceNow();
+
+    return calendar.component({
+        suspend: () => clearTimeout(advanceTimeTicker)
+    });
+}
+
+function Header(nextClicked, prevClicked, undoClicked) {
+    let month, monthNumber, year, undo;
+    const button = () => elem("button").classes("calendar-header-button");
+
+    const monthAndYear = elem().classes("size-h2", "color-highlight").append(
+        month = text(),
+        " ",
+        year = elem("span").classes("size-h3"),
+        undo = button()
+            .hide()
+            .classes("calendar-undo-button")
+            .attr("title", "Back to current month")
+            .on("click", undoClicked)
+            .html(undoArrowSvg)
+    );
+
+    const monthSwitcher = elem()
+        .classes("flex", "gap-7", "items-center")
+        .append(
+            button()
+                .attr("title", "Previous month")
+                .on("click", prevClicked)
+                .html(leftArrowSvg),
+            monthNumber = elem()
+                .classes("color-highlight")
+                .styles({ marginTop: "0.1rem" }),
+            button()
+                .attr("title", "Next month")
+                .on("click", nextClicked)
+                .html(rightArrowSvg),
+        );
+
+    return elem().classes("flex", "justify-between", "items-center").append(
+        monthAndYear,
+        monthSwitcher
+    ).component({
+        update: function (now, newDate) {
+            month.text(MONTH_NAMES[newDate.getMonth()]);
+            year.text(newDate.getFullYear());
+            const m = newDate.getMonth() + 1;
+            monthNumber.text((m < 10 ? "0" : "") + m);
+
+            if (!datesWithinSameMonth(now, newDate)) {
+                if (undo.isHidden()) undo.show().animate(undoEntrance);
+            } else {
+                undo.hide();
+            }
+
+            return this;
+        }
+    });
+}
+
+function Dates(firstDay) {
+    let dates, lastRenderedDate;
+
+    const updateFullMonth = function(now, newDate) {
+        const firstWeekday = new Date(newDate.getFullYear(), newDate.getMonth(), 1).getDay();
+        const previousMonthSpilloverDays = (firstWeekday - firstDay + 7) % 7 || 7;
+        const currentMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth());
+        const nextMonthSpilloverDays = FULL_MONTH_SLOTS - (previousMonthSpilloverDays + currentMonthDays);
+        const previousMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth() - 1)
+        const isCurrentMonth = datesWithinSameMonth(now, newDate);
+        const currentDate = now.getDate();
+
+        let children = dates.children;
+        let index = 0;
+
+        for (let i = 0; i < FULL_MONTH_SLOTS; i++) {
+            children[i].clearClasses("calendar-spillover-date", "calendar-current-date");
+        }
+
+        for (let i = 0; i < previousMonthSpilloverDays; i++, index++) {
+            children[index].classes("calendar-spillover-date").text(
+                previousMonthDays - previousMonthSpilloverDays + i + 1
+            )
+        }
+
+        for (let i = 1; i <= currentMonthDays; i++, index++) {
+            children[index]
+                .classesIf(isCurrentMonth && i === currentDate, "calendar-current-date")
+                .text(i);
+        }
+
+        for (let i = 0; i < nextMonthSpilloverDays; i++, index++) {
+            children[index].classes("calendar-spillover-date").text(i + 1);
+        }
+
+        lastRenderedDate = newDate;
+    };
+
+    const update = function(now, newDate) {
+        if (lastRenderedDate === undefined || datesWithinSameMonth(newDate, lastRenderedDate)) {
+            updateFullMonth(now, newDate);
+            return;
+        }
+
+        const next = newDate > lastRenderedDate;
+        dates.animateUpdate(
+            () => updateFullMonth(now, newDate),
+            next ? datesExitLeft : datesExitRight,
+            next ? datesEntranceRight : datesEntranceLeft,
+        );
+    }
+
+    return elem().append(
+        elem().classes("calendar-dates", "margin-top-15").append(
+            ...repeat(7, (i) => elem().classes("size-h6", "color-subdue").text(
+                WEEKDAY_ABBRS[(firstDay + i) % 7]
+            ))
+        ),
+
+        dates = elem().classes("calendar-dates", "margin-top-3").append(
+            ...elem().classes("calendar-date").duplicate(FULL_MONTH_SLOTS)
+        )
+    ).component({ update });
+}
+
+function datesWithinSameMonth(d1, d2) {
+    return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth();
+}
+
+function daysInMonth(year, month) {
+    return new Date(year, month + 1, 0).getDate();
+}
+
+function msTillNextDay(now) {
+    now = now || new Date();
+
+    return 86_400_000 - (
+      now.getMilliseconds() +
+      now.getSeconds() * 1000 +
+      now.getMinutes() * 60_000 +
+      now.getHours() * 3_600_000
+    );
+}

+ 14 - 2
internal/glance/static/js/main.js

@@ -439,7 +439,7 @@ function setupCollapsibleGrids() {
 
         let cardsPerRow;
 
-        const resolveCollapsibleItems = () => {
+        const resolveCollapsibleItems = () => requestAnimationFrame(() => {
             const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
 
             if (hideItemsAfterIndex >= gridElement.children.length) {
@@ -465,7 +465,7 @@ function setupCollapsibleGrids() {
                     child.style.removeProperty("animation-delay");
                 }
             }
-        };
+        });
 
         const observer = new ResizeObserver(() => {
             if (!isElementVisible(gridElement)) {
@@ -625,6 +625,17 @@ function setupClocks() {
     updateClocks();
 }
 
+async function setupCalendars() {
+    const elems = document.getElementsByClassName("calendar");
+    if (elems.length == 0) return;
+
+    // TODO: implement prefetching, currently loads as a nasty waterfall of requests
+    const calendar = await import ('./calendar.js');
+
+    for (let i = 0; i < elems.length; i++)
+        calendar.default(elems[i]);
+}
+
 function setupTruncatedElementTitles() {
     const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
 
@@ -648,6 +659,7 @@ async function setupPage() {
     try {
         setupPopovers();
         setupClocks()
+        await setupCalendars();
         setupCarousels();
         setupSearchBoxes();
         setupCollapsibleLists();

+ 4 - 3
internal/glance/static/js/popover.js

@@ -25,7 +25,8 @@ frameElement.append(contentElement);
 containerElement.append(frameElement);
 document.body.append(containerElement);
 
-const observer = new ResizeObserver(repositionContainer);
+const queueRepositionContainer = () => requestAnimationFrame(repositionContainer);
+const observer = new ResizeObserver(queueRepositionContainer);
 
 function handleMouseEnter(event) {
     clearTogglePopoverTimeout();
@@ -100,7 +101,7 @@ function showPopover() {
     containerElement.style.display = "block";
     activeTarget.classList.add("popover-active");
     document.addEventListener("keydown", handleHidePopoverOnEscape);
-    window.addEventListener("resize", repositionContainer);
+    window.addEventListener("resize", queueRepositionContainer);
     observer.observe(containerElement);
 }
 
@@ -156,7 +157,7 @@ function hidePopover() {
     activeTarget.classList.remove("popover-active");
     containerElement.style.display = "none";
     document.removeEventListener("keydown", handleHidePopoverOnEscape);
-    window.removeEventListener("resize", repositionContainer);
+    window.removeEventListener("resize", queueRepositionContainer);
     observer.unobserve(containerElement);
 
     if (cleanupOnHidePopover !== null) {

+ 190 - 0
internal/glance/static/js/templating.js

@@ -0,0 +1,190 @@
+export function elem(tag = "div") {
+    return document.createElement(tag);
+}
+
+export function fragment(...children) {
+    const f = document.createDocumentFragment();
+    if (children) f.append(...children);
+    return f;
+}
+
+export function text(str = "") {
+    return document.createTextNode(str);
+}
+
+export function repeat(n, fn) {
+    const elems = Array(n);
+
+    for (let i = 0; i < n; i++)
+        elems[i] = fn(i);
+
+    return elems;
+}
+
+export function find(selector) {
+    return document.querySelector(selector);
+}
+
+export function findAll(selector) {
+    return document.querySelectorAll(selector);
+}
+
+const ep = HTMLElement.prototype;
+const fp = DocumentFragment.prototype;
+const tp = Text.prototype;
+
+ep.classes = function(...classes) {
+    this.classList.add(...classes);
+    return this;
+}
+
+ep.find = function(selector) {
+    return this.querySelector(selector);
+}
+
+ep.findAll = function(selector) {
+    return this.querySelectorAll(selector);
+}
+
+ep.classesIf = function(cond, ...classes) {
+    cond ? this.classList.add(...classes) : this.classList.remove(...classes);
+    return this;
+}
+
+ep.hide = function() {
+    this.style.display = "none";
+    return this;
+}
+
+ep.show = function() {
+    this.style.removeProperty("display");
+    return this;
+}
+
+ep.showIf = function(cond) {
+    cond ? this.show() : this.hide();
+    return this;
+}
+
+ep.isHidden = function() {
+    return this.style.display === "none";
+}
+
+ep.clearClasses = function(...classes) {
+    classes.length ? this.classList.remove(...classes) : this.className = "";
+    return this;
+}
+
+ep.hasClass = function(className) {
+    return this.classList.contains(className);
+}
+
+ep.attr = function(name, value) {
+    this.setAttribute(name, value);
+    return this;
+}
+
+ep.attrs = function(attrs) {
+    for (const [name, value] of Object.entries(attrs))
+        this.setAttribute(name, value);
+    return this;
+}
+
+ep.tap = function(fn) {
+    fn(this);
+    return this;
+}
+
+ep.text = function(text) {
+    this.innerText = text;
+    return this;
+}
+
+ep.html = function(html) {
+    this.innerHTML = html;
+    return this;
+}
+
+ep.appendTo = function(parent) {
+    parent.appendChild(this);
+    return this;
+}
+
+ep.swap = function(element) {
+    this.replaceWith(element);
+    return element;
+}
+
+ep.on = function(event, callback, options) {
+    if (typeof event === "string") {
+        this.addEventListener(event, callback, options);
+        return this;
+    }
+
+    for (let i = 0; i < event.length; i++)
+        this.addEventListener(event[i], callback, options);
+
+    return this;
+}
+
+const epAppend = ep.append;
+ep.append = function(...children) {
+    epAppend.apply(this, children);
+    return this;
+}
+
+ep.duplicate = function(n) {
+    const elems = Array(n);
+
+    for (let i = 0; i < n; i++)
+        elems[i] = this.cloneNode(true);
+
+    return elems;
+}
+
+ep.styles = function(s) {
+    Object.assign(this.style, s);
+    return this;
+}
+
+const epAnimate = ep.animate;
+ep.animate = function(anim, callback) {
+    const a = epAnimate.call(this, anim.keyframes, anim.options);
+    if (callback) a.onfinish = () => callback(this, a);
+    return this;
+}
+
+ep.animateUpdate = function(update, exit, entrance) {
+    this.animate(exit, () => {
+        update(this);
+        this.animate(entrance);
+    });
+
+    return this;
+}
+
+ep.styleVar = function(name, value) {
+    this.style.setProperty(`--${name}`, value);
+    return this;
+}
+
+ep.component = function (methods) {
+    this.component = methods;
+    return this;
+}
+
+const fpAppend = fp.append;
+fp.append = function(...children) {
+    fpAppend.apply(this, children);
+    return this;
+}
+
+fp.appendTo = function(parent) {
+    parent.appendChild(this);
+    return this;
+}
+
+tp.text = function(text) {
+    this.nodeValue = text;
+    return this;
+}

+ 244 - 9
internal/glance/static/main.css

@@ -17,7 +17,7 @@
     --cm: 1;
     --tsm: 1;
 
-    --widget-gap: 25px;
+    --widget-gap: 23px;
     --widget-content-vertical-padding: 15px;
     --widget-content-horizontal-padding: 17px;
     --widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
@@ -37,13 +37,14 @@
     --color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
     --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
     --color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
-    --color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 27% * var(--cm))));
+    --color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 30% * var(--cm))));
     --color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
 
     --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
+    --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
+    --color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%));
     --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
     --color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
-    --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
     --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
 
     --font-size-h1: 1.7rem;
@@ -293,6 +294,12 @@ pre {
     width: 10px;
 }
 
+*:focus-visible {
+    outline: 2px solid var(--color-primary);
+    outline-offset: 0.1rem;
+    border-radius: var(--border-radius);
+}
+
 *, *::before, *::after {
     box-sizing: border-box;
 }
@@ -789,6 +796,20 @@ details[open] .summary::after {
     gap: 1rem;
 }
 
+.widget-beta-icon {
+    width: 1.6rem;
+    height: 1.6rem;
+    flex-shrink: 0;
+    transition: transform .45s, opacity .45s, stroke .45s;
+    opacity: 0.7;
+}
+
+.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon {
+    fill: var(--color-text-highlight);
+    transform: translateY(-10%) scale(1.3);
+    opacity: 1;
+}
+
 .widget + .widget {
     margin-top: var(--widget-gap);
 }
@@ -803,7 +824,7 @@ details[open] .summary::after {
 .list-horizontal-text > *:not(:last-child)::after {
     content: '•';
     color: var(--color-text-subdue);
-    margin: 0 0.5rem;
+    margin: 0 0.4rem;
     position: relative;
     top: 0.1rem;
 }
@@ -935,6 +956,13 @@ details[open] .summary::after {
     border-radius: var(--border-radius) var(--border-radius) 0 0;
 }
 
+.video-horizontal-list-thumbnail {
+    height: 4rem;
+    aspect-ratio: 16 / 8.9;
+    object-fit: cover;
+    border-radius: var(--border-radius);
+}
+
 .search-icon {
     width: 2.3rem;
 }
@@ -1015,11 +1043,6 @@ details[open] .summary::after {
     display: none;
 }
 
-.forum-post-list-item {
-    display: flex;
-    gap: 1.2rem;
-}
-
 .forum-post-list-thumbnail {
     flex-shrink: 0;
     width: 6rem;
@@ -1034,6 +1057,12 @@ details[open] .summary::after {
     transform: translateY(-0.15rem);
 }
 
+@container widget (max-width: 550px) {
+    .forum-post-autohide {
+        display: none;
+    }
+}
+
 .bookmarks-group {
     --bookmarks-group-color: var(--color-primary);
 }
@@ -1123,6 +1152,77 @@ details[open] .summary::after {
   visibility: visible;
   opacity: 0.8;
 }
+.old-calendar-day {
+    width: calc(100% / 7);
+    text-align: center;
+    padding: 0.6rem 0;
+}
+
+.old-calendar-day-today {
+    border-radius: var(--border-radius);
+    background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
+    color: var(--color-text-highlight);
+}
+
+.calendar-dates {
+    text-align: center;
+    display: grid;
+    grid-template-columns: repeat(7, 1fr);
+    gap: 2px;
+}
+
+.calendar-date {
+    padding: 0.4rem 0;
+    color: var(--color-text-paragraph);
+    position: relative;
+    border-radius: var(--border-radius);
+    background: none;
+    border: none;
+    font: inherit;
+}
+
+.calendar-current-date {
+    border-radius: var(--border-radius);
+    background-color: var(--color-popover-border);
+    color: var(--color-text-highlight);
+}
+
+.calendar-spillover-date {
+    color: var(--color-text-subdue);
+}
+
+.calendar-header-button {
+    position: relative;
+    cursor: pointer;
+    width: 2rem;
+    height: 2rem;
+    z-index: 1;
+    background: none;
+    border: none;
+}
+
+.calendar-header-button::before {
+    content: '';
+    position: absolute;
+    inset: -0.2rem;
+    border-radius: var(--border-radius);
+    background-color: var(--color-text-subdue);
+    opacity: 0;
+    transition: opacity 0.2s;
+    z-index: -1;
+}
+
+.calendar-header-button:hover::before {
+    opacity: 0.4;
+}
+
+.calendar-undo-button {
+    display: inline-block;
+    vertical-align: text-top;
+    width: 2rem;
+    height: 2rem;
+    margin-left: 0.7rem;
+}
 
 .dns-stats-totals {
     transition: opacity .3s;
@@ -1456,6 +1556,137 @@ details[open] .summary::after {
     height: 2rem;
 }
 
+.widget-type-server-info {
+    position: relative;
+}
+
+.server + .server {
+    margin-top: 3rem;
+}
+
+.server {
+    gap: 1rem;
+    display: flex;
+    flex-direction: column;
+}
+
+.server-info {
+    align-items: center;
+    display: flex;
+    justify-content: space-between;
+    gap: 1.5rem;
+    flex-shrink: 1;
+    min-width: 0;
+}
+
+.server-details {
+    min-width: 0;
+}
+
+.server-icon {
+    height: 3rem;
+    width: 3rem;
+}
+
+.server-spicy-cpu-icon {
+    height: 1em;
+    align-self: center;
+    margin-left: 0.4em;
+    margin-bottom: 0.2rem;
+}
+
+.server-stats {
+    display: flex;
+    gap: 1.5rem;
+    margin-top: 0.5rem;
+}
+
+.server-stat-unavailable {
+    opacity: 0.5;
+}
+
+.progress-bar {
+    border: 1px solid var(--color-progress-border);
+    border-radius: var(--border-radius);
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    padding: 2px;
+    height: 1.5rem;
+    margin-inline: -3px; /* naughty, but oh so beautiful */
+}
+
+.progress-bar-combined {
+    height: 3rem;
+}
+
+.popover-active > .progress-bar {
+    transition: border-color .3s;
+    border-color: var(--color-text-subdue);
+}
+
+.progress-value {
+    --half-border-radius: calc(var(--border-radius) / 2);
+    border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0;
+    background: var(--color-progress-value);
+    width: calc(var(--percent) * 1%);
+    min-width: 1px;
+    flex: 1;
+}
+
+.progress-value:first-child {
+    border-top-left-radius: var(--half-border-radius);
+}
+
+.progress-value:last-child {
+    border-bottom-left-radius: var(--half-border-radius);
+}
+
+.progress-value-notice {
+    background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative));
+}
+
+.value-separator {
+    min-width: 2rem;
+    margin-inline: 0.8rem;
+    flex: 1;
+    height: calc(1em * 1.1);
+    border-bottom: 1px dotted var(--color-text-subdue);
+}
+
+@container widget (min-width: 650px) {
+    .server {
+        gap: 2rem;
+        flex-direction: row;
+        align-items: center;
+    }
+
+    .server + .server {
+        margin-top: 1rem;
+    }
+
+    .server-info {
+        flex-direction: row-reverse;
+        justify-content: unset;
+        margin-right: auto;
+        z-index: 1;
+    }
+
+    .server-stats {
+        flex-direction: row;
+        justify-content: right;
+        min-width: 450px;
+        margin-top: 0;
+        gap: 2rem;
+        padding-bottom: 0.8rem;
+        z-index: 1;
+    }
+
+    .server-stats > * {
+        max-width: 200px;
+    }
+}
+
 .thumbnail {
     filter: grayscale(0.2) contrast(0.9);
     opacity: 0.8;
@@ -1839,6 +2070,7 @@ details[open] .summary::after {
 .size-h6   { font-size: var(--font-size-h6); }
 
 .color-highlight    { color: var(--color-text-highlight); }
+.color-paragraph    { color: var(--color-text-paragraph); }
 .color-base         { color: var(--color-text-base); }
 .color-subdue       { color: var(--color-text-subdue); }
 .color-negative     { color: var(--color-negative); }
@@ -1852,6 +2084,7 @@ details[open] .summary::after {
 .text-center        { text-align: center; }
 .text-elevate       { margin-top: -0.2em; }
 .text-compact       { word-spacing: -0.18em; }
+.text-very-compact  { word-spacing: -0.35em; }
 .rtl                { direction: rtl; }
 .shrink             { flex-shrink: 1; }
 .shrink-0           { flex-shrink: 0; }
@@ -1862,6 +2095,7 @@ details[open] .summary::after {
 .overflow-hidden    { overflow: hidden; }
 .relative           { position: relative; }
 .flex               { display: flex; }
+.flex-1             { flex: 1; }
 .flex-wrap          { flex-wrap: wrap; }
 .flex-nowrap        { flex-wrap: nowrap; }
 .justify-between    { justify-content: space-between; }
@@ -1874,6 +2108,7 @@ details[open] .summary::after {
 .flex-column        { flex-direction: column; }
 .items-center       { align-items: center; }
 .items-start        { align-items: start; }
+.items-end          { align-items: end; }
 .gap-5              { gap: 0.5rem; }
 .gap-7              { gap: 0.7rem; }
 .gap-10             { gap: 1rem; }

+ 29 - 8
internal/glance/templates.go

@@ -5,7 +5,6 @@ import (
 	"html/template"
 	"math"
 	"strconv"
-	"time"
 
 	"golang.org/x/text/language"
 	"golang.org/x/text/message"
@@ -14,8 +13,8 @@ import (
 var intl = message.NewPrinter(language.English)
 
 var globalTemplateFunctions = template.FuncMap{
-	"formatViewerCount": formatViewerCount,
-	"formatNumber":      intl.Sprint,
+	"formatApproxNumber": formatApproxNumber,
+	"formatNumber":       intl.Sprint,
 	"safeCSS": func(str string) template.CSS {
 		return template.CSS(str)
 	},
@@ -28,9 +27,31 @@ var globalTemplateFunctions = template.FuncMap{
 	"formatPrice": func(price float64) string {
 		return intl.Sprintf("%.2f", price)
 	},
-	"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
+	"dynamicRelativeTimeAttrs": func(t interface{ Unix() int64 }) template.HTMLAttr {
 		return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
 	},
+	"formatServerMegabytes": func(mb uint64) template.HTML {
+		var value string
+		var label string
+
+		if mb < 1_000 {
+			value = strconv.FormatUint(mb, 10)
+			label = "MB"
+		} else if mb < 1_000_000 {
+			if mb < 10_000 {
+				value = fmt.Sprintf("%.1f", float64(mb)/1_000)
+			} else {
+				value = strconv.FormatUint(mb/1_000, 10)
+			}
+
+			label = "GB"
+		} else {
+			value = fmt.Sprintf("%.1f", float64(mb)/1_000_000)
+			label = "TB"
+		}
+
+		return template.HTML(value + ` <span class="color-base size-h5">` + label + `</span>`)
+	},
 }
 
 func mustParseTemplate(primary string, dependencies ...string) *template.Template {
@@ -45,18 +66,18 @@ func mustParseTemplate(primary string, dependencies ...string) *template.Templat
 	return t
 }
 
-func formatViewerCount(count int) string {
+func formatApproxNumber(count int) string {
 	if count < 1_000 {
 		return strconv.Itoa(count)
 	}
 
 	if count < 10_000 {
-		return fmt.Sprintf("%.1fk", float64(count)/1_000)
+		return strconv.FormatFloat(float64(count)/1_000, 'f', 1, 64) + "k"
 	}
 
 	if count < 1_000_000 {
-		return fmt.Sprintf("%dk", count/1_000)
+		return strconv.Itoa(count/1_000) + "k"
 	}
 
-	return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
+	return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m"
 }

+ 1 - 1
internal/glance/templates/bookmarks.html

@@ -13,7 +13,7 @@
                 <img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
             </div>
             {{ end }}
-            <a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
+            <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>
         </li>
         {{ end }}
         </ul>

+ 1 - 1
internal/glance/templates/dns-stats.html

@@ -18,7 +18,7 @@
         </div>
         {{ else }}
         <div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
-            <div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatViewerCount }}</div>
+            <div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatApproxNumber }}</div>
             <div class="size-h6">DOMAINS</div>
         </div>
         {{ end }}

+ 22 - 22
internal/glance/templates/docker-containers.html

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

+ 31 - 31
internal/glance/templates/forum-posts.html

@@ -1,49 +1,49 @@
 {{ template "widget-base.html" . }}
 
-{{ define "widget-content" }}
+{{- define "widget-content" }}
 <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
-    {{ range .Posts }}
+    {{- range .Posts }}
     <li>
         <div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
-            {{ if $.ShowThumbnails }}
-                {{ if .IsCrosspost }}
-                <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
-                </svg>
-                {{ else if ne .ThumbnailUrl "" }}
-                <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
-                {{ else if ne "" .TargetUrl }}
-                <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
-                </svg>
-                {{ else }}
-                <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
-                </svg>
-                {{ end }}
-            {{ end }}
+            {{- if $.ShowThumbnails }}
+            {{- if .IsCrosspost }}
+            <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
+            </svg>
+            {{- else if .ThumbnailUrl }}
+            <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
+            {{- else if .TargetUrl }}
+            <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
+            </svg>
+            {{- else }}
+            <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
+            </svg>
+            {{- 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>
-                {{ if gt (len .Tags) 0 }}
+                {{- if .Tags }}
                 <div class="inline-block forum-post-tags-container">
                     <ul class="attachments">
-                    {{ range .Tags }}
-                        <li>{{ . }}</li>
-                    {{ end }}
+                    {{- range .Tags }}
+                    <li>{{ . }}</li>
+                    {{- end }}
                     </ul>
                 </div>
-                {{ end }}
-                <ul class="list-horizontal-text">
+                {{- end }}
+                <ul class="list-horizontal-text flex-nowrap text-compact">
                     <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
-                    <li>{{ .Score | formatNumber }} points</li>
-                    <li>{{ .CommentCount | formatNumber }} comments</li>
-                    {{ if ne "" .TargetUrl }}
+                    <li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
+                    <li class="shrink-0{{ if .TargetUrl }} 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 }}
+                    {{- end }}
                 </ul>
             </div>
         </div>
     </li>
-    {{ end }}
+    {{- end }}
 </ul>
-{{ end }}
+{{- end }}

+ 34 - 0
internal/glance/templates/old-calendar.html

@@ -0,0 +1,34 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<div class="widget-small-content-bounds">
+    <div class="flex justify-between items-center">
+        <div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
+        <ul class="list-horizontal-text color-highlight size-h4">
+            <li>Week {{ .Calendar.CurrentWeekNumber }}</li>
+            <li>{{ .Calendar.CurrentYear }}</li>
+        </ul>
+    </div>
+
+    <div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
+        {{ if .StartSunday }}
+            <div class="old-calendar-day">Su</div>
+        {{ end }}
+        <div class="old-calendar-day">Mo</div>
+        <div class="old-calendar-day">Tu</div>
+        <div class="old-calendar-day">We</div>
+        <div class="old-calendar-day">Th</div>
+        <div class="old-calendar-day">Fr</div>
+        <div class="old-calendar-day">Sa</div>
+        {{ if not .StartSunday }}
+            <div class="old-calendar-day">Su</div>
+        {{ end }}
+    </div>
+
+    <div class="flex flex-wrap">
+        {{ range .Calendar.Days }}
+        <div class="old-calendar-day{{ if eq . $.Calendar.CurrentDay }} old-calendar-day-today{{ end }}">{{ . }}</div>
+        {{ end }}
+    </div>
+</div>
+{{ end }}

+ 1 - 1
internal/glance/templates/reddit-horizontal-cards.html

@@ -21,7 +21,7 @@
                 <a href="{{ .DiscussionUrl }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <ul class="list-horizontal-text margin-top-7">
                     <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
-                    <li>{{ .Score | formatNumber }} points</li>
+                    <li>{{ .Score | formatApproxNumber }} points</li>
                 </ul>
             </div>
         </div>

+ 1 - 1
internal/glance/templates/reddit-vertical-cards.html

@@ -20,7 +20,7 @@
             <a href="{{ .DiscussionUrl }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
             <ul class="list-horizontal-text margin-top-7">
                 <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
-                <li>{{ .Score | formatNumber }} points</li>
+                <li>{{ .Score | formatApproxNumber }} points</li>
             </ul>
         </div>
     </div>

+ 1 - 1
internal/glance/templates/search.html

@@ -16,7 +16,7 @@
         </svg>
     </div>
 
-    <input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
+    <input class="search-input" type="text" placeholder="{{ .Placeholder }}" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
 
     <div class="search-bang"></div>
     <kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>

+ 140 - 0
internal/glance/templates/server-stats.html

@@ -0,0 +1,140 @@
+{{ template "widget-base.html" . }}
+
+{{- define "widget-content" }}
+{{- range .Servers }}
+<div class="server">
+    <div class="server-info">
+        <div class="server-details">
+            <div class="server-name color-highlight size-h3">{{ if .Name }}{{ .Name }}{{ else }}{{ .Info.Hostname }}{{ end }}</div>
+            <div>
+                {{- if .IsReachable }}
+                    {{ if .Info.HostInfoIsAvailable }}<span {{ dynamicRelativeTimeAttrs .Info.BootTime }}></span>{{ else }}unknown{{ end }} uptime
+                {{- else }}
+                    unreachable
+                {{- end }}
+            </div>
+        </div>
+        <div class="shrink-0"{{ if .IsReachable }} data-popover-type="html" data-popover-margin="0.2rem" data-popover-max-width="400px"{{ end }}>
+            {{- if .IsReachable }}
+            <div data-popover-html>
+                <div class="size-h5 text-compact">PLATFORM</div>
+                <div class="color-highlight">{{ if .Info.HostInfoIsAvailable }}{{ .Info.Platform }}{{ else }}Unknown{{ end }}</div>
+            </div>
+            {{- end }}
+            <svg class="server-icon" stroke="var(--color-{{ if .IsReachable }}positive{{ else }}negative{{ end }})" 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="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m19.5 0a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3m19.5 0a3 3 0 0 0-3-3H5.25a3 3 0 0 0-3 3m16.5 0h.008v.008h-.008v-.008Zm-3 0h.008v.008h-.008v-.008Z" />
+            </svg>
+        </div>
+    </div>
+    <div class="server-stats">
+        <div class="flex-1{{ if not .Info.CPU.LoadIsAvailable }} server-stat-unavailable{{ end }}">
+            <div class="flex items-end size-h5">
+                <div>CPU</div>
+                {{- if and .Info.CPU.TemperatureIsAvailable (ge .Info.CPU.TemperatureC 80) }}
+                <svg class="server-spicy-cpu-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" >
+                    <path fill-rule="evenodd" d="M8.074.945A4.993 4.993 0 0 0 6 5v.032c.004.6.114 1.176.311 1.709.16.428-.204.91-.61.7a5.023 5.023 0 0 1-1.868-1.677c-.202-.304-.648-.363-.848-.058a6 6 0 1 0 8.017-1.901l-.004-.007a4.98 4.98 0 0 1-2.18-2.574c-.116-.31-.477-.472-.744-.28Zm.78 6.178a3.001 3.001 0 1 1-3.473 4.341c-.205-.365.215-.694.62-.59a4.008 4.008 0 0 0 1.873.03c.288-.065.413-.386.321-.666A3.997 3.997 0 0 1 8 8.999c0-.585.126-1.14.351-1.641a.42.42 0 0 1 .503-.235Z" clip-rule="evenodd" />
+                </svg>
+                {{- end }}
+                <div class="color-highlight margin-left-auto text-very-compact">{{ if .Info.CPU.LoadIsAvailable }}{{ .Info.CPU.Load1Percent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
+            </div>
+            <div{{ if .Info.CPU.LoadIsAvailable }} data-popover-type="html"{{ end }}>
+                {{- if .Info.CPU.LoadIsAvailable }}
+                <div data-popover-html>
+                    <div class="flex">
+                        <div class="size-h5">1M AVG</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">{{ .Info.CPU.Load1Percent }} <span class="color-base size-h5">%</span></div>
+                    </div>
+                    <div class="flex margin-top-3">
+                        <div class="size-h5">15M AVG</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">{{ .Info.CPU.Load15Percent }} <span class="color-base size-h5">%</span></div>
+                    </div>
+                    {{- if .Info.CPU.TemperatureIsAvailable }}
+                    <div class="flex margin-top-3">
+                        <div class="size-h5">TEMP C</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">{{ .Info.CPU.TemperatureC }} <span class="color-base size-h5">°</span></div>
+                    </div>
+                    {{- end }}
+                </div>
+                {{- end }}
+                <div class="progress-bar progress-bar-combined">
+                    {{- if .Info.CPU.LoadIsAvailable }}
+                    <div class="progress-value{{ if ge .Info.CPU.Load1Percent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.CPU.Load1Percent }}"></div>
+                    <div class="progress-value{{ if ge .Info.CPU.Load15Percent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.CPU.Load15Percent }}"></div>
+                    {{- end }}
+                </div>
+            </div>
+        </div>
+        <div class="flex-1{{ if not .Info.Memory.IsAvailable }} server-stat-unavailable{{ end }}">
+            <div class="flex justify-between items-end size-h5">
+                <div>RAM</div>
+                <div class="color-highlight text-very-compact">{{ if .Info.Memory.IsAvailable }}{{ .Info.Memory.UsedPercent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
+            </div>
+            <div{{ if .Info.Memory.IsAvailable }} data-popover-type="html"{{ end }}>
+                {{- if .Info.Memory.IsAvailable }}
+                <div data-popover-html>
+                    <div class="flex">
+                        <div class="size-h5">RAM</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">
+                            {{ .Info.Memory.UsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .Info.Memory.TotalMB | formatServerMegabytes }}
+                        </div>
+                    </div>
+                    {{- if and (not .HideSwap) .Info.Memory.SwapIsAvailable }}
+                    <div class="flex margin-top-3">
+                        <div class="size-h5">SWAP</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">
+                            {{ .Info.Memory.SwapUsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .Info.Memory.SwapTotalMB | formatServerMegabytes }}
+                        </div>
+                    </div>
+                    {{- end }}
+                </div>
+                {{- end }}
+                <div class="progress-bar progress-bar-combined">
+                    {{- if .Info.Memory.IsAvailable }}
+                    <div class="progress-value{{ if ge .Info.Memory.UsedPercent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.Memory.UsedPercent }}"></div>
+                    {{- if and (not .HideSwap) .Info.Memory.SwapIsAvailable }}
+                    <div class="progress-value{{ if ge .Info.Memory.SwapUsedPercent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.Memory.SwapUsedPercent }}"></div>
+                    {{- end }}
+                    {{- end }}
+                </div>
+            </div>
+        </div>
+        <div class="flex-1{{ if not .Info.Mountpoints }} server-stat-unavailable{{ end }}">
+            <div class="flex justify-between items-end size-h5">
+                <div>DISK</div>
+                <div class="color-highlight text-very-compact">{{ if .Info.Mountpoints }}{{ (index .Info.Mountpoints 0).UsedPercent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
+            </div>
+            <div{{ if .Info.Mountpoints }} data-popover-type="html"{{ end }}>
+                {{- if .Info.Mountpoints }}
+                <div data-popover-html>
+                    <ul class="list list-gap-2">
+                        {{- range .Info.Mountpoints }}
+                        <li class="flex">
+                            <div class="size-h5">{{ if .Name }}{{ .Name }}{{ else }}{{ .Path }}{{ end }}</div>
+                            <div class="value-separator"></div>
+                            <div class="color-highlight text-very-compact">
+                                {{ .UsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .TotalMB | formatServerMegabytes }}
+                            </div>
+                        </li>
+                        {{- end }}
+                    </ul>
+                </div>
+                {{- end }}
+                <div class="progress-bar progress-bar-combined">
+                    {{- if .Info.Mountpoints }}
+                    <div class="progress-value{{ if ge ((index .Info.Mountpoints 0).UsedPercent) 85 }} progress-value-notice{{ end }}" style="--percent: {{ (index .Info.Mountpoints 0).UsedPercent }}"></div>
+                    {{- if ge (len .Info.Mountpoints) 2 }}
+                    <div class="progress-value{{ if ge ((index .Info.Mountpoints 1).UsedPercent) 85 }} progress-value-notice{{ end }}" style="--percent: {{ (index .Info.Mountpoints 1).UsedPercent }}"></div>
+                    {{- end }}
+                    {{- end }}
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+{{- end }}
+{{- end }}

+ 1 - 1
internal/glance/templates/twitch-channels.html

@@ -31,7 +31,7 @@
                         {{ end }}
                     <ul class="list-horizontal-text">
                         <li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
-                        <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
+                        <li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
                     </ul>
                     {{ else }}
                     <div>Offline</div>

+ 1 - 1
internal/glance/templates/twitch-games-list.html

@@ -9,7 +9,7 @@
             <div class="min-width-0">
                 <a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
                 <ul class="list-horizontal-text">
-                    <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
+                    <li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
                     {{ if .IsNew }}
                     <li class="color-primary">NEW</li>
                     {{ end }}

+ 3 - 5
internal/glance/templates/v0.7-update-notice-page.html

@@ -24,15 +24,13 @@
 </head>
 <body>
 
-<!-- TODO: update - add links -->
-
-<div class="content-bounds color-highlight">
+<div class="content-bounds color-paragraph">
     <p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
     <div class="widget-content-frame margin-top-10 padding-widget">
         <p class="comfy-line-height">
             The default location of glance.yml in the Docker image has
-            changed since v0.7.0, please see the <a class="color-primary" href="#">migration guide</a>
-            for instructions or visit the <a class="color-primary" href="#">release notes</a>
+            changed since v0.7.0, please see the <a class="color-primary" href="https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md" target="_blank">migration guide</a>
+            for instructions or visit the <a class="color-primary" href="https://github.com/glanceapp/glance/releases/tag/v0.7.0" target="_blank">release notes</a>
             to find out more about why this change was necessary. Sorry for the inconvenience.
         </p>
 

+ 20 - 0
internal/glance/templates/videos-vertical-list.html

@@ -0,0 +1,20 @@
+{{ template "widget-base.html" . }}
+
+{{- define "widget-content" }}
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{- range .Videos }}
+    <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 }}">{{ .Title }}</a>
+            <ul class="list-horizontal-text flex-nowrap">
+                <li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
+                <li class="min-width-0">
+                    <a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
+                </li>
+            </ul>
+        </div>
+    </li>
+    {{- end }}
+</ul>
+{{- end }}

+ 26 - 10
internal/glance/templates/widget-base.html

@@ -1,18 +1,34 @@
 <div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
-    {{ if not .HideHeader}}
+    {{- if not .HideHeader}}
     <div class="widget-header">
-        {{ if ne "" .TitleURL }}<a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
-        {{ if and .Error .ContentAvailable }}
+        {{- if ne "" .TitleURL }}
+        <a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>
+        {{- else }}
+        <div class="uppercase">{{ .Title }}</div>
+        {{- end }}
+        {{- if .IsWIP }}
+        <div data-popover-type="html" data-popover-position="above">
+            <div data-popover-html>
+                <p class="size-h5">WORK IN PROGRESS</p>
+                <p class="margin-block-10 color-paragraph">This widget is still in development, certain features may not work as expected or may change drastically.</p>
+                <a class="color-primary visited-indicator" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
+            </div>
+            <svg class="widget-beta-icon cursor-help" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
+                <path fill-rule="evenodd" d="M19 5.5a4.5 4.5 0 0 1-4.791 4.49c-.873-.055-1.808.128-2.368.8l-6.024 7.23a2.724 2.724 0 1 1-3.837-3.837L9.21 8.16c.672-.56.855-1.495.8-2.368a4.5 4.5 0 0 1 5.873-4.575c.324.105.39.51.15.752L13.34 4.66a.455.455 0 0 0-.11.494 3.01 3.01 0 0 0 1.617 1.617c.17.07.363.02.493-.111l2.692-2.692c.241-.241.647-.174.752.15.14.435.216.9.216 1.382ZM4 17a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
+            </svg>
+        </div>
+        {{- end }}
+        {{- if and .Error .ContentAvailable }}
         <div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
-        {{ else if .Notice }}
+        {{- else if .Notice }}
         <div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
-        {{ end }}
+        {{- end }}
     </div>
-    {{ end }}
+    {{- end }}
     <div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
-        {{ if .ContentAvailable }}
-            {{ block "widget-content" . }}{{ end }}
-        {{ else }}
+        {{- if .ContentAvailable }}
+        {{ block "widget-content" . }}{{ end }}
+        {{- else }}
             <div class="widget-error-header">
                 <div class="color-negative size-h3">ERROR</div>
                 <svg class="widget-error-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
@@ -20,6 +36,6 @@
                 </svg>
             </div>
             <p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
-        {{ end}}
+        {{- end}}
     </div>
 </div>

+ 8 - 0
internal/glance/utils.go

@@ -178,3 +178,11 @@ func itemAtIndexOrDefault[T any](items []T, index int, def T) T {
 
 	return items[index]
 }
+
+func ternary[T any](condition bool, a, b T) T {
+	if condition {
+		return a
+	}
+
+	return b
+}

+ 18 - 4
internal/glance/widget-bookmarks.go

@@ -14,6 +14,7 @@ type bookmarksWidget struct {
 		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"`
@@ -23,10 +24,11 @@ type bookmarksWidget struct {
 			// {{ 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:"-"`
+			SameTabRaw   *bool  `yaml:"same-tab"`
+			SameTab      bool   `yaml:"-"`
+			HideArrowRaw *bool  `yaml:"hide-arrow"`
+			HideArrow    bool   `yaml:"-"`
+			Target       string `yaml:"target"`
 		} `yaml:"links"`
 	} `yaml:"groups"`
 }
@@ -49,6 +51,18 @@ func (widget *bookmarksWidget) initialize() error {
 			} else {
 				link.HideArrow = *link.HideArrowRaw
 			}
+
+			if link.Target == "" {
+				if group.Target != "" {
+					link.Target = group.Target
+				} else {
+					if link.SameTab {
+						link.Target = ""
+					} else {
+						link.Target = "_blank"
+					}
+				}
+			}
 		}
 	}
 

+ 7 - 6
internal/glance/widget-calendar.go

@@ -3,6 +3,7 @@ package glance
 import (
 	"context"
 	"fmt"
+	"errors"
 	"html/template"
 	"log"
 	"net/http"
@@ -70,10 +71,10 @@ func newCalendar(now time.Time, startSunday bool, icsurl string) *calendar {
 
 	var previousMonthDays int
 
-	if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
-		previousMonthDays = daysInMonth(12, year-1)
-	} else {
-		previousMonthDays = daysInMonth(previousMonthNumber, year)
+	if widget.FirstDayOfWeek == "" {
+		widget.FirstDayOfWeek = "monday"
+	} else if _, ok := calendarWeekdaysToInt[widget.FirstDayOfWeek]; !ok {
+		return errors.New("invalid first day of week")
 	}
 
 	startDaysFrom := now.Day() - int(weekday) - 7
@@ -123,8 +124,8 @@ func newCalendar(now time.Time, startSunday bool, icsurl string) *calendar {
 	}
 }
 
-func daysInMonth(m time.Month, year int) int {
-	return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
+func (widget *calendarWidget) Render() template.HTML {
+	return widget.cachedHTML
 }
 
 func ParseEventsFromFile(file string) []*ics.VEvent {

+ 23 - 18
internal/glance/widget-custom-api.go

@@ -8,6 +8,7 @@ import (
 	"html/template"
 	"io"
 	"log/slog"
+	"math"
 	"net/http"
 	"time"
 
@@ -100,7 +101,7 @@ func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (templat
 
 	var templateBuffer bytes.Buffer
 
-	data := CustomAPITemplateData{
+	data := customAPITemplateData{
 		JSON:     decoratedGJSONResult{gjson.Parse(body)},
 		Response: resp,
 	}
@@ -117,7 +118,7 @@ type decoratedGJSONResult struct {
 	gjson.Result
 }
 
-type CustomAPITemplateData struct {
+type customAPITemplateData struct {
 	JSON     decoratedGJSONResult
 	Response *http.Response
 }
@@ -132,6 +133,10 @@ func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedG
 	return decoratedResults
 }
 
+func (r *decoratedGJSONResult) Exists(key string) bool {
+	return r.Get(key).Exists()
+}
+
 func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
 	if key == "" {
 		return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
@@ -180,28 +185,28 @@ var customAPITemplateFuncs = func() template.FuncMap {
 		"toInt": func(a float64) int64 {
 			return int64(a)
 		},
-		"mathexpr": func(left float64, op string, right float64) float64 {
-			if right == 0 {
-				return 0
+		"add": func(a, b float64) float64 {
+			return a + b
+		},
+		"sub": func(a, b float64) float64 {
+			return a - b
+		},
+		"mul": func(a, b float64) float64 {
+			return a * b
+		},
+		"div": func(a, b float64) float64 {
+			if b == 0 {
+				return math.NaN()
 			}
 
-			switch op {
-			case "+":
-				return left + right
-			case "-":
-				return left - right
-			case "*":
-				return left * right
-			case "/":
-				return left / right
-			default:
-				return 0
-			}
+			return a / b
 		},
 	}
 
 	for key, value := range globalTemplateFunctions {
-		funcs[key] = value
+		if _, exists := funcs[key]; !exists {
+			funcs[key] = value
+		}
 	}
 
 	return funcs

+ 2 - 2
internal/glance/widget-docker-containers.go

@@ -47,7 +47,7 @@ func (widget *dockerContainersWidget) Render() template.HTML {
 
 const (
 	dockerContainerLabelHide        = "glance.hide"
-	dockerContainerLabelTitle       = "glance.title"
+	dockerContainerLabelName        = "glance.name"
 	dockerContainerLabelURL         = "glance.url"
 	dockerContainerLabelDescription = "glance.description"
 	dockerContainerLabelSameTab     = "glance.same-tab"
@@ -194,7 +194,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain
 }
 
 func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string {
-	if v := container.Labels.getOrDefault(dockerContainerLabelTitle, ""); v != "" {
+	if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" {
 		return v
 	}
 

+ 28 - 9
internal/glance/widget-markets.go

@@ -8,17 +8,20 @@ import (
 	"math"
 	"net/http"
 	"sort"
+	"strings"
 	"time"
 )
 
 var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html")
 
 type marketsWidget struct {
-	widgetBase     `yaml:",inline"`
-	StocksRequests []marketRequest `yaml:"stocks"`
-	MarketRequests []marketRequest `yaml:"markets"`
-	Sort           string          `yaml:"sort-by"`
-	Markets        marketList      `yaml:"-"`
+	widgetBase         `yaml:",inline"`
+	StocksRequests     []marketRequest `yaml:"stocks"`
+	MarketRequests     []marketRequest `yaml:"markets"`
+	ChartLinkTemplate  string          `yaml:"chart-link-template"`
+	SymbolLinkTemplate string          `yaml:"symbol-link-template"`
+	Sort               string          `yaml:"sort-by"`
+	Markets            marketList      `yaml:"-"`
 }
 
 func (widget *marketsWidget) initialize() error {
@@ -29,6 +32,18 @@ func (widget *marketsWidget) initialize() error {
 		widget.MarketRequests = widget.StocksRequests
 	}
 
+	for i := range widget.MarketRequests {
+		m := &widget.MarketRequests[i]
+
+		if widget.ChartLinkTemplate != "" && m.ChartLink == "" {
+			m.ChartLink = strings.ReplaceAll(widget.ChartLinkTemplate, "{SYMBOL}", m.Symbol)
+		}
+
+		if widget.SymbolLinkTemplate != "" && m.SymbolLink == "" {
+			m.SymbolLink = strings.ReplaceAll(widget.SymbolLinkTemplate, "{SYMBOL}", m.Symbol)
+		}
+	}
+
 	return nil
 }
 
@@ -41,9 +56,7 @@ func (widget *marketsWidget) update(ctx context.Context) {
 
 	if widget.Sort == "absolute-change" {
 		markets.sortByAbsChange()
-	}
-
-	if widget.Sort == "change" {
+	} else if widget.Sort == "change" {
 		markets.sortByChange()
 	}
 
@@ -55,7 +68,7 @@ func (widget *marketsWidget) Render() template.HTML {
 }
 
 type marketRequest struct {
-	Name       string `yaml:"name"`
+	CustomName string `yaml:"name"`
 	Symbol     string `yaml:"symbol"`
 	ChartLink  string `yaml:"chart-link"`
 	SymbolLink string `yaml:"symbol-link"`
@@ -63,6 +76,7 @@ type marketRequest struct {
 
 type market struct {
 	marketRequest
+	Name           string
 	Currency       string
 	Price          float64
 	PercentChange  float64
@@ -91,6 +105,7 @@ type marketResponseJson struct {
 				Symbol             string  `json:"symbol"`
 				RegularMarketPrice float64 `json:"regularMarketPrice"`
 				ChartPreviousClose float64 `json:"chartPreviousClose"`
+				ShortName          string  `json:"shortName"`
 			} `json:"meta"`
 			Indicators struct {
 				Quote []struct {
@@ -160,6 +175,10 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro
 			marketRequest: marketRequests[i],
 			Price:         response.Chart.Result[0].Meta.RegularMarketPrice,
 			Currency:      currency,
+			Name: ternary(marketRequests[i].CustomName == "",
+				response.Chart.Result[0].Meta.ShortName,
+				marketRequests[i].CustomName,
+			),
 			PercentChange: percentChange(
 				response.Chart.Result[0].Meta.RegularMarketPrice,
 				previous,

+ 15 - 9
internal/glance/widget-monitor.go

@@ -20,6 +20,8 @@ type monitorWidget struct {
 	Sites      []struct {
 		*SiteStatusRequest `yaml:",inline"`
 		Status             *siteStatus     `yaml:"-"`
+		URL                string          `yaml:"-"`
+		ErrorURL           string          `yaml:"error-url"`
 		Title              string          `yaml:"title"`
 		Icon               customIconField `yaml:"icon"`
 		SameTab            bool            `yaml:"same-tab"`
@@ -58,14 +60,18 @@ func (widget *monitorWidget) update(ctx context.Context) {
 		status := &statuses[i]
 		site.Status = status
 
-		if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) {
+		if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.Error != nil) {
 			widget.HasFailing = true
 		}
 
-		if !status.TimedOut {
-			site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
-			site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
+		if status.Error != nil && site.ErrorURL != "" {
+			site.URL = site.ErrorURL
+		} else {
+			site.URL = site.DefaultURL
 		}
+
+		site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
+		site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
 	}
 }
 
@@ -90,12 +96,12 @@ func statusCodeToText(status int, altStatusCodes []int) string {
 	if status == 401 {
 		return "Unauthorized"
 	}
-	if status >= 400 {
-		return "Client Error"
-	}
 	if status >= 500 {
 		return "Server Error"
 	}
+	if status >= 400 {
+		return "Client Error"
+	}
 
 	return strconv.Itoa(status)
 }
@@ -109,7 +115,7 @@ func statusCodeToStyle(status int, altStatusCodes []int) string {
 }
 
 type SiteStatusRequest struct {
-	URL           string `yaml:"url"`
+	DefaultURL    string `yaml:"url"`
 	CheckURL      string `yaml:"check-url"`
 	AllowInsecure bool   `yaml:"allow-insecure"`
 }
@@ -126,7 +132,7 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) {
 	if statusRequest.CheckURL != "" {
 		url = statusRequest.CheckURL
 	} else {
-		url = statusRequest.URL
+		url = statusRequest.DefaultURL
 	}
 	request, err := http.NewRequest(http.MethodGet, url, nil)
 	if err != nil {

+ 86 - 0
internal/glance/widget-old-calendar.go

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

+ 31 - 16
internal/glance/widget-reddit.go

@@ -19,19 +19,20 @@ var (
 
 type redditWidget struct {
 	widgetBase          `yaml:",inline"`
-	Posts               forumPostList `yaml:"-"`
-	Subreddit           string        `yaml:"subreddit"`
-	Style               string        `yaml:"style"`
-	ShowThumbnails      bool          `yaml:"show-thumbnails"`
-	ShowFlairs          bool          `yaml:"show-flairs"`
-	SortBy              string        `yaml:"sort-by"`
-	TopPeriod           string        `yaml:"top-period"`
-	Search              string        `yaml:"search"`
-	ExtraSortBy         string        `yaml:"extra-sort-by"`
-	CommentsUrlTemplate string        `yaml:"comments-url-template"`
-	Limit               int           `yaml:"limit"`
-	CollapseAfter       int           `yaml:"collapse-after"`
-	RequestUrlTemplate  string        `yaml:"request-url-template"`
+	Posts               forumPostList     `yaml:"-"`
+	Subreddit           string            `yaml:"subreddit"`
+	Proxy               proxyOptionsField `yaml:"proxy"`
+	Style               string            `yaml:"style"`
+	ShowThumbnails      bool              `yaml:"show-thumbnails"`
+	ShowFlairs          bool              `yaml:"show-flairs"`
+	SortBy              string            `yaml:"sort-by"`
+	TopPeriod           string            `yaml:"top-period"`
+	Search              string            `yaml:"search"`
+	ExtraSortBy         string            `yaml:"extra-sort-by"`
+	CommentsUrlTemplate string            `yaml:"comments-url-template"`
+	Limit               int               `yaml:"limit"`
+	CollapseAfter       int               `yaml:"collapse-after"`
+	RequestUrlTemplate  string            `yaml:"request-url-template"`
 }
 
 func (widget *redditWidget) initialize() error {
@@ -62,7 +63,7 @@ func (widget *redditWidget) initialize() error {
 	}
 
 	widget.
-		withTitle("/r/" + widget.Subreddit).
+		withTitle("r/" + widget.Subreddit).
 		withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
 		withCacheDuration(30 * time.Minute)
 
@@ -94,6 +95,7 @@ func (widget *redditWidget) update(ctx context.Context) {
 		widget.Search,
 		widget.CommentsUrlTemplate,
 		widget.RequestUrlTemplate,
+		widget.Proxy.client,
 		widget.ShowFlairs,
 	)
 
@@ -161,7 +163,16 @@ func templateRedditCommentsURL(template, subreddit, postId, postPath string) str
 	return template
 }
 
-func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (forumPostList, error) {
+func fetchSubredditPosts(
+	subreddit,
+	sort,
+	topPeriod,
+	search,
+	commentsUrlTemplate,
+	requestUrlTemplate string,
+	proxyClient *http.Client,
+	showFlairs bool,
+) (forumPostList, error) {
 	query := url.Values{}
 	var requestUrl string
 
@@ -180,8 +191,12 @@ func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
 		requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
 	}
 
+	var client requestDoer = defaultHTTPClient
+
 	if requestUrlTemplate != "" {
 		requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
+	} else if proxyClient != nil {
+		client = proxyClient
 	}
 
 	request, err := http.NewRequest("GET", requestUrl, nil)
@@ -191,7 +206,7 @@ func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
 
 	// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
 	setBrowserUserAgentHeader(request)
-	responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultHTTPClient, request)
+	responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
 	if err != nil {
 		return nil, err
 	}

+ 100 - 70
internal/glance/widget-releases.go

@@ -11,20 +11,21 @@ import (
 	"sort"
 	"strings"
 	"time"
+
+	"gopkg.in/yaml.v3"
 )
 
 var releasesWidgetTemplate = mustParseTemplate("releases.html", "widget-base.html")
 
 type releasesWidget struct {
-	widgetBase      `yaml:",inline"`
-	Releases        appReleaseList    `yaml:"-"`
-	releaseRequests []*releaseRequest `yaml:"-"`
-	Repositories    []string          `yaml:"repositories"`
-	Token           string            `yaml:"token"`
-	GitLabToken     string            `yaml:"gitlab-token"`
-	Limit           int               `yaml:"limit"`
-	CollapseAfter   int               `yaml:"collapse-after"`
-	ShowSourceIcon  bool              `yaml:"show-source-icon"`
+	widgetBase     `yaml:",inline"`
+	Releases       appReleaseList    `yaml:"-"`
+	Repositories   []*releaseRequest `yaml:"repositories"`
+	Token          string            `yaml:"token"`
+	GitLabToken    string            `yaml:"gitlab-token"`
+	Limit          int               `yaml:"limit"`
+	CollapseAfter  int               `yaml:"collapse-after"`
+	ShowSourceIcon bool              `yaml:"show-source-icon"`
 }
 
 func (widget *releasesWidget) initialize() error {
@@ -38,51 +39,21 @@ func (widget *releasesWidget) initialize() error {
 		widget.CollapseAfter = 5
 	}
 
-	for _, repository := range widget.Repositories {
-		parts := strings.SplitN(repository, ":", 2)
-		var request *releaseRequest
-		if len(parts) == 1 {
-			request = &releaseRequest{
-				source:     releaseSourceGithub,
-				repository: repository,
-			}
-
-			if widget.Token != "" {
-				request.token = &widget.Token
-			}
-		} else if len(parts) == 2 {
-			if parts[0] == string(releaseSourceGitlab) {
-				request = &releaseRequest{
-					source:     releaseSourceGitlab,
-					repository: parts[1],
-				}
-
-				if widget.GitLabToken != "" {
-					request.token = &widget.GitLabToken
-				}
-			} else if parts[0] == string(releaseSourceDockerHub) {
-				request = &releaseRequest{
-					source:     releaseSourceDockerHub,
-					repository: parts[1],
-				}
-			} else if parts[0] == string(releaseSourceCodeberg) {
-				request = &releaseRequest{
-					source:     releaseSourceCodeberg,
-					repository: parts[1],
-				}
-			} else {
-				return errors.New("invalid repository source " + parts[0])
-			}
-		}
+	for i := range widget.Repositories {
+		r := widget.Repositories[i]
 
-		widget.releaseRequests = append(widget.releaseRequests, request)
+		if r.source == releaseSourceGithub && widget.Token != "" {
+			r.token = &widget.Token
+		} else if r.source == releaseSourceGitlab && widget.GitLabToken != "" {
+			r.token = &widget.GitLabToken
+		}
 	}
 
 	return nil
 }
 
 func (widget *releasesWidget) update(ctx context.Context) {
-	releases, err := fetchLatestReleases(widget.releaseRequests)
+	releases, err := fetchLatestReleases(widget.Repositories)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return
@@ -133,9 +104,53 @@ func (r appReleaseList) sortByNewest() appReleaseList {
 }
 
 type releaseRequest struct {
-	source     releaseSource
-	repository string
-	token      *string
+	IncludePreleases bool   `yaml:"include-prereleases"`
+	Repository       string `yaml:"repository"`
+
+	source releaseSource
+	token  *string
+}
+
+func (r *releaseRequest) UnmarshalYAML(node *yaml.Node) error {
+	type releaseRequestAlias releaseRequest
+	alias := (*releaseRequestAlias)(r)
+	var repository string
+
+	if err := node.Decode(&repository); err != nil {
+		if err := node.Decode(alias); err != nil {
+			return fmt.Errorf("could not umarshal repository into string or struct: %v", err)
+		}
+	}
+
+	if r.Repository == "" {
+		if repository == "" {
+			return errors.New("repository is required")
+		} else {
+			r.Repository = repository
+		}
+	}
+
+	parts := strings.SplitN(repository, ":", 2)
+	if len(parts) == 1 {
+		r.source = releaseSourceGithub
+	} else if len(parts) == 2 {
+		r.Repository = parts[1]
+
+		switch parts[0] {
+		case string(releaseSourceGithub):
+			r.source = releaseSourceGithub
+		case string(releaseSourceGitlab):
+			r.source = releaseSourceGitlab
+		case string(releaseSourceDockerHub):
+			r.source = releaseSourceDockerHub
+		case string(releaseSourceCodeberg):
+			r.source = releaseSourceCodeberg
+		default:
+			return errors.New("invalid source")
+		}
+	}
+
+	return nil
 }
 
 func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) {
@@ -152,7 +167,7 @@ func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) {
 	for i := range results {
 		if errs[i] != nil {
 			failed++
-			slog.Error("Failed to fetch release", "source", requests[i].source, "repository", requests[i].repository, "error", errs[i])
+			slog.Error("Failed to fetch release", "source", requests[i].source, "repository", requests[i].Repository, "error", errs[i])
 			continue
 		}
 
@@ -187,7 +202,7 @@ func fetchLatestReleaseTask(request *releaseRequest) (*appRelease, error) {
 	return nil, errors.New("unsupported source")
 }
 
-type githubReleaseLatestResponseJson struct {
+type githubReleaseResponseJson struct {
 	TagName     string `json:"tag_name"`
 	PublishedAt string `json:"published_at"`
 	HtmlUrl     string `json:"html_url"`
@@ -197,12 +212,14 @@ type githubReleaseLatestResponseJson struct {
 }
 
 func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) {
-	httpRequest, err := http.NewRequest(
-		"GET",
-		fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.repository),
-		nil,
-	)
+	var requestURL string
+	if !request.IncludePreleases {
+		requestURL = fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository)
+	} else {
+		requestURL = fmt.Sprintf("https://api.github.com/repos/%s/releases", request.Repository)
+	}
 
+	httpRequest, err := http.NewRequest("GET", requestURL, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -211,14 +228,29 @@ func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) {
 		httpRequest.Header.Add("Authorization", "Bearer "+(*request.token))
 	}
 
-	response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultHTTPClient, httpRequest)
-	if err != nil {
-		return nil, err
+	var response githubReleaseResponseJson
+
+	if !request.IncludePreleases {
+		response, err = decodeJsonFromRequest[githubReleaseResponseJson](defaultHTTPClient, httpRequest)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		responses, err := decodeJsonFromRequest[[]githubReleaseResponseJson](defaultHTTPClient, httpRequest)
+		if err != nil {
+			return nil, err
+		}
+
+		if len(responses) == 0 {
+			return nil, fmt.Errorf("no releases found for repository %s", request.Repository)
+		}
+
+		response = responses[0]
 	}
 
 	return &appRelease{
 		Source:       releaseSourceGithub,
-		Name:         request.repository,
+		Name:         request.Repository,
 		Version:      normalizeVersionFormat(response.TagName),
 		NotesUrl:     response.HtmlUrl,
 		TimeReleased: parseRFC3339Time(response.PublishedAt),
@@ -241,17 +273,15 @@ const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/reposito
 const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
 
 func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) {
-
-	nameParts := strings.Split(request.repository, "/")
+	nameParts := strings.Split(request.Repository, "/")
 
 	if len(nameParts) > 2 {
-		return nil, fmt.Errorf("invalid repository name: %s", request.repository)
+		return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
 	} else if len(nameParts) == 1 {
 		nameParts = []string{"library", nameParts[0]}
 	}
 
 	tagParts := strings.SplitN(nameParts[1], ":", 2)
-
 	var requestURL string
 
 	if len(tagParts) == 2 {
@@ -278,7 +308,7 @@ func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) {
 		}
 
 		if len(response.Results) == 0 {
-			return nil, fmt.Errorf("no tags found for repository: %s", request.repository)
+			return nil, fmt.Errorf("no tags found for repository: %s", request.Repository)
 		}
 
 		tag = &response.Results[0]
@@ -331,7 +361,7 @@ func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) {
 		"GET",
 		fmt.Sprintf(
 			"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
-			url.QueryEscape(request.repository),
+			url.QueryEscape(request.Repository),
 		),
 		nil,
 	)
@@ -350,7 +380,7 @@ func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) {
 
 	return &appRelease{
 		Source:       releaseSourceGitlab,
-		Name:         request.repository,
+		Name:         request.Repository,
 		Version:      normalizeVersionFormat(response.TagName),
 		NotesUrl:     response.Links.Self,
 		TimeReleased: parseRFC3339Time(response.ReleasedAt),
@@ -368,7 +398,7 @@ func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) {
 		"GET",
 		fmt.Sprintf(
 			"https://codeberg.org/api/v1/repos/%s/releases/latest",
-			request.repository,
+			request.Repository,
 		),
 		nil,
 	)
@@ -383,7 +413,7 @@ func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) {
 
 	return &appRelease{
 		Source:       releaseSourceCodeberg,
-		Name:         request.repository,
+		Name:         request.Repository,
 		Version:      normalizeVersionFormat(response.TagName),
 		NotesUrl:     response.HtmlUrl,
 		TimeReleased: parseRFC3339Time(response.PublishedAt),

+ 11 - 4
internal/glance/widget-rss.go

@@ -35,6 +35,7 @@ type rssWidget struct {
 	Limit            int              `yaml:"limit"`
 	CollapseAfter    int              `yaml:"collapse-after"`
 	SingleLineTitles bool             `yaml:"single-line-titles"`
+	PreserveOrder    bool             `yaml:"preserve-order"`
 	NoItemsMessage   string           `yaml:"-"`
 }
 
@@ -75,6 +76,10 @@ func (widget *rssWidget) update(ctx context.Context) {
 		return
 	}
 
+	if !widget.PreserveOrder {
+		items.sortByNewest()
+	}
+
 	if len(items) > widget.Limit {
 		items = items[:widget.Limit]
 	}
@@ -143,6 +148,7 @@ type rssFeedRequest struct {
 	Title           string            `yaml:"title"`
 	HideCategories  bool              `yaml:"hide-categories"`
 	HideDescription bool              `yaml:"hide-description"`
+	Limit           int               `yaml:"limit"`
 	ItemLinkPrefix  string            `yaml:"item-link-prefix"`
 	Headers         map[string]string `yaml:"headers"`
 	IsDetailed      bool              `yaml:"-"`
@@ -190,6 +196,10 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
 		return nil, err
 	}
 
+	if request.Limit > 0 && len(feed.Items) > request.Limit {
+		feed.Items = feed.Items[:request.Limit]
+	}
+
 	items := make(rssFeedItemList, 0, len(feed.Items))
 
 	for i := range feed.Items {
@@ -223,7 +233,7 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) {
 		}
 
 		if item.Title != "" {
-			rssItem.Title = item.Title
+			rssItem.Title = html.UnescapeString(item.Title)
 		} else {
 			rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
 		}
@@ -320,7 +330,6 @@ func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error)
 	}
 
 	failed := 0
-
 	entries := make(rssFeedItemList, 0, len(feeds)*10)
 
 	for i := range feeds {
@@ -337,8 +346,6 @@ func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error)
 		return nil, errNoContent
 	}
 
-	entries.sortByNewest()
-
 	if failed > 0 {
 		return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed)
 	}

+ 5 - 0
internal/glance/widget-search.go

@@ -21,6 +21,7 @@ type searchWidget struct {
 	Bangs        []SearchBang  `yaml:"bangs"`
 	NewTab       bool          `yaml:"new-tab"`
 	Autofocus    bool          `yaml:"autofocus"`
+	Placeholder  string        `yaml:"placeholder"`
 }
 
 func convertSearchUrl(url string) string {
@@ -41,6 +42,10 @@ func (widget *searchWidget) initialize() error {
 		widget.SearchEngine = "duckduckgo"
 	}
 
+	if widget.Placeholder == "" {
+		widget.Placeholder = "Type here to search…"
+	}
+
 	if url, ok := searchEngines[widget.SearchEngine]; ok {
 		widget.SearchEngine = url
 	}

+ 117 - 0
internal/glance/widget-server-stats.go

@@ -0,0 +1,117 @@
+package glance
+
+import (
+	"context"
+	"html/template"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/glanceapp/glance/pkg/sysinfo"
+)
+
+var serverStatsWidgetTemplate = mustParseTemplate("server-stats.html", "widget-base.html")
+
+type serverStatsWidget struct {
+	widgetBase `yaml:",inline"`
+	Servers    []serverStatsRequest `yaml:"servers"`
+}
+
+func (widget *serverStatsWidget) initialize() error {
+	widget.withTitle("Server Stats").withCacheDuration(15 * time.Second)
+	widget.widgetBase.WIP = true
+
+	if len(widget.Servers) == 0 {
+		widget.Servers = []serverStatsRequest{{Type: "local"}}
+	}
+
+	for i := range widget.Servers {
+		widget.Servers[i].URL = strings.TrimRight(widget.Servers[i].URL, "/")
+
+		if widget.Servers[i].Timeout == 0 {
+			widget.Servers[i].Timeout = durationField(3 * time.Second)
+		}
+	}
+
+	return nil
+}
+
+func (widget *serverStatsWidget) update(context.Context) {
+	// Refactor later, most of it may change depending on feedback
+	var wg sync.WaitGroup
+
+	for i := range widget.Servers {
+		serv := &widget.Servers[i]
+
+		if serv.Type == "local" {
+			info, errs := sysinfo.Collect(serv.SystemInfoRequest)
+
+			if len(errs) > 0 {
+				for i := range errs {
+					slog.Warn("Getting system info: " + errs[i].Error())
+				}
+			}
+
+			serv.IsReachable = true
+			serv.Info = info
+		} else {
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				info, err := fetchRemoteServerInfo(serv)
+				if err != nil {
+					slog.Warn("Getting remote system info: " + err.Error())
+					serv.IsReachable = false
+					serv.Info = &sysinfo.SystemInfo{
+						Hostname: "Unnamed server #" + strconv.Itoa(i+1),
+					}
+				} else {
+					serv.IsReachable = true
+					serv.Info = info
+				}
+			}()
+		}
+	}
+
+	wg.Wait()
+	widget.withError(nil).scheduleNextUpdate()
+}
+
+func (widget *serverStatsWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, serverStatsWidgetTemplate)
+}
+
+type serverStatsRequest struct {
+	*sysinfo.SystemInfoRequest `yaml:",inline"`
+	Info                       *sysinfo.SystemInfo `yaml:"-"`
+	IsReachable                bool                `yaml:"-"`
+	StatusText                 string              `yaml:"-"`
+	Name                       string              `yaml:"name"`
+	HideSwap                   bool                `yaml:"hide-swap"`
+	Type                       string              `yaml:"type"`
+	URL                        string              `yaml:"url"`
+	Token                      string              `yaml:"token"`
+	Timeout                    durationField       `yaml:"timeout"`
+	// Support for other agents
+	// Provider                   string              `yaml:"provider"`
+}
+
+func fetchRemoteServerInfo(infoReq *serverStatsRequest) (*sysinfo.SystemInfo, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(infoReq.Timeout))
+	defer cancel()
+
+	request, _ := http.NewRequestWithContext(ctx, "GET", infoReq.URL+"/api/sysinfo/all", nil)
+	if infoReq.Token != "" {
+		request.Header.Set("Authorization", "Bearer "+infoReq.Token)
+	}
+
+	info, err := decodeJsonFromRequest[*sysinfo.SystemInfo](defaultHTTPClient, request)
+	if err != nil {
+		return nil, err
+	}
+
+	return info, nil
+}

+ 30 - 5
internal/glance/widget-videos.go

@@ -15,8 +15,9 @@ import (
 const videosWidgetPlaylistPrefix = "playlist:"
 
 var (
-	videosWidgetTemplate     = mustParseTemplate("videos.html", "widget-base.html", "video-card-contents.html")
-	videosWidgetGridTemplate = mustParseTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
+	videosWidgetTemplate             = mustParseTemplate("videos.html", "widget-base.html", "video-card-contents.html")
+	videosWidgetGridTemplate         = mustParseTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
+	videosWidgetVerticalListTemplate = mustParseTemplate("videos-vertical-list.html", "widget-base.html")
 )
 
 type videosWidget struct {
@@ -24,8 +25,10 @@ type videosWidget struct {
 	Videos            videoList `yaml:"-"`
 	VideoUrlTemplate  string    `yaml:"video-url-template"`
 	Style             string    `yaml:"style"`
+	CollapseAfter     int       `yaml:"collapse-after"`
 	CollapseAfterRows int       `yaml:"collapse-after-rows"`
 	Channels          []string  `yaml:"channels"`
+	Playlists         []string  `yaml:"playlists"`
 	Limit             int       `yaml:"limit"`
 	IncludeShorts     bool      `yaml:"include-shorts"`
 }
@@ -41,6 +44,21 @@ func (widget *videosWidget) initialize() error {
 		widget.CollapseAfterRows = 4
 	}
 
+	if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
+		widget.CollapseAfter = 7
+	}
+
+	// A bit cheeky, but from a user's perspective it makes more sense when channels and
+	// playlists are separate things rather than specifying a list of channels and some of
+	// them awkwardly have a "playlist:" prefix
+	if len(widget.Playlists) > 0 {
+		widget.Channels = append(widget.Channels, make([]string, len(widget.Playlists))...)
+
+		for i := range widget.Playlists {
+			widget.Channels[len(widget.Channels)-1+i] = "playlist:" + widget.Playlists[i]
+		}
+	}
+
 	return nil
 }
 
@@ -59,11 +77,18 @@ func (widget *videosWidget) update(ctx context.Context) {
 }
 
 func (widget *videosWidget) Render() template.HTML {
-	if widget.Style == "grid-cards" {
-		return widget.renderTemplate(widget, videosWidgetGridTemplate)
+	var template *template.Template
+
+	switch widget.Style {
+	case "grid-cards":
+		template = videosWidgetGridTemplate
+	case "vertical-list":
+		template = videosWidgetVerticalListTemplate
+	default:
+		template = videosWidgetTemplate
 	}
 
-	return widget.renderTemplate(widget, videosWidgetTemplate)
+	return widget.renderTemplate(widget, template)
 }
 
 type youtubeFeedResponseXml struct {

+ 9 - 0
internal/glance/widget.go

@@ -23,6 +23,8 @@ func newWidget(widgetType string) (widget, error) {
 	switch widgetType {
 	case "calendar":
 		w = &calendarWidget{}
+	case "calendar-legacy":
+		w = &oldCalendarWidget{}
 	case "clock":
 		w = &clockWidget{}
 	case "weather":
@@ -71,6 +73,8 @@ func newWidget(widgetType string) (widget, error) {
 		w = &customAPIWidget{}
 	case "docker-containers":
 		w = &dockerContainersWidget{}
+	case "server-stats":
+		w = &serverStatsWidget{}
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 	}
@@ -145,6 +149,7 @@ type widgetBase struct {
 	CSSClass            string           `yaml:"css-class"`
 	CustomCacheDuration durationField    `yaml:"cache"`
 	ContentAvailable    bool             `yaml:"-"`
+	WIP                 bool             `yaml:"-"`
 	Error               error            `yaml:"-"`
 	Notice              error            `yaml:"-"`
 	templateBuffer      bytes.Buffer     `yaml:"-"`
@@ -171,6 +176,10 @@ func (w *widgetBase) requiresUpdate(now *time.Time) bool {
 	return now.After(w.nextUpdate)
 }
 
+func (w *widgetBase) IsWIP() bool {
+	return w.WIP
+}
+
 func (w *widgetBase) update(ctx context.Context) {
 
 }

+ 252 - 0
pkg/sysinfo/sysinfo.go

@@ -0,0 +1,252 @@
+package sysinfo
+
+import (
+	"fmt"
+	"math"
+	"runtime"
+	"sort"
+	"strconv"
+	"time"
+
+	"github.com/shirou/gopsutil/v4/cpu"
+	"github.com/shirou/gopsutil/v4/disk"
+	"github.com/shirou/gopsutil/v4/host"
+	"github.com/shirou/gopsutil/v4/load"
+	"github.com/shirou/gopsutil/v4/mem"
+	"github.com/shirou/gopsutil/v4/sensors"
+)
+
+type timestampJSON struct {
+	time.Time
+}
+
+func (t timestampJSON) MarshalJSON() ([]byte, error) {
+	return []byte(strconv.FormatInt(t.Unix(), 10)), nil
+}
+
+func (t *timestampJSON) UnmarshalJSON(data []byte) error {
+	i, err := strconv.ParseInt(string(data), 10, 64)
+	if err != nil {
+		return err
+	}
+
+	t.Time = time.Unix(i, 0)
+	return nil
+}
+
+type SystemInfo struct {
+	HostInfoIsAvailable bool          `json:"host_info_is_available"`
+	BootTime            timestampJSON `json:"boot_time"`
+	Hostname            string        `json:"hostname"`
+	Platform            string        `json:"platform"`
+
+	CPU struct {
+		LoadIsAvailable bool  `json:"load_is_available"`
+		Load1Percent    uint8 `json:"load1_percent"`
+		Load15Percent   uint8 `json:"load15_percent"`
+
+		TemperatureIsAvailable bool  `json:"temperature_is_available"`
+		TemperatureC           uint8 `json:"temperature_c"`
+	} `json:"cpu"`
+
+	Memory struct {
+		IsAvailable bool   `json:"memory_is_available"`
+		TotalMB     uint64 `json:"total_mb"`
+		UsedMB      uint64 `json:"used_mb"`
+		UsedPercent uint8  `json:"used_percent"`
+
+		SwapIsAvailable bool   `json:"swap_is_available"`
+		SwapTotalMB     uint64 `json:"swap_total_mb"`
+		SwapUsedMB      uint64 `json:"swap_used_mb"`
+		SwapUsedPercent uint8  `json:"swap_used_percent"`
+	} `json:"memory"`
+
+	Mountpoints []MountpointInfo `json:"mountpoints"`
+}
+
+type MountpointInfo struct {
+	Path        string `json:"path"`
+	Name        string `json:"name"`
+	TotalMB     uint64 `json:"total_mb"`
+	UsedMB      uint64 `json:"used_mb"`
+	UsedPercent uint8  `json:"used_percent"`
+}
+
+type SystemInfoRequest struct {
+	CPUTempSensor string                       `yaml:"cpu-temp-sensor"`
+	Mountpoints   map[string]MointpointRequest `yaml:"mountpoints"`
+}
+
+type MointpointRequest struct {
+	Name string `yaml:"name"`
+	Hide bool   `yaml:"hide"`
+}
+
+// Currently caches hostname indefinitely which isn't ideal
+// Potential issue with caching boot time as it may not initially get reported correctly:
+// https://github.com/shirou/gopsutil/issues/842#issuecomment-1908972344
+var cachedHostInfo = struct {
+	available bool
+	hostname  string
+	platform  string
+	bootTime  timestampJSON
+}{}
+
+func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
+	if req == nil {
+		req = &SystemInfoRequest{}
+	}
+
+	var errs []error
+
+	addErr := func(err error) {
+		errs = append(errs, err)
+	}
+
+	info := &SystemInfo{
+		Mountpoints: []MountpointInfo{},
+	}
+
+	applyCachedHostInfo := func() {
+		info.HostInfoIsAvailable = true
+		info.BootTime = cachedHostInfo.bootTime
+		info.Hostname = cachedHostInfo.hostname
+		info.Platform = cachedHostInfo.platform
+	}
+
+	if cachedHostInfo.available {
+		applyCachedHostInfo()
+	} else {
+		hostInfo, err := host.Info()
+		if err == nil {
+			cachedHostInfo.available = true
+			cachedHostInfo.bootTime = timestampJSON{time.Unix(int64(hostInfo.BootTime), 0)}
+			cachedHostInfo.hostname = hostInfo.Hostname
+			cachedHostInfo.platform = hostInfo.Platform
+
+			applyCachedHostInfo()
+		} else {
+			addErr(fmt.Errorf("getting host info: %v", err))
+		}
+	}
+
+	coreCount, err := cpu.Counts(true)
+	if err == nil {
+		loadAvg, err := load.Avg()
+		if err == nil {
+			info.CPU.LoadIsAvailable = true
+			if runtime.GOOS == "windows" {
+				// The numbers returned here seem unreliable on Windows. Even with the CPU pegged
+				// at close to 50% for multiple minutes, load1 is sometimes way under or way over
+				// with no clear pattern. Dividing by core count gives numbers that are way too
+				// low so that's likely not necessary as it is with unix.
+				info.CPU.Load1Percent = uint8(math.Min(loadAvg.Load1*100, 100))
+				info.CPU.Load15Percent = uint8(math.Min(loadAvg.Load15*100, 100))
+			} else {
+				info.CPU.Load1Percent = uint8(math.Min((loadAvg.Load1/float64(coreCount))*100, 100))
+				info.CPU.Load15Percent = uint8(math.Min((loadAvg.Load15/float64(coreCount))*100, 100))
+			}
+		} else {
+			addErr(fmt.Errorf("getting load avg: %v", err))
+		}
+	} else {
+		addErr(fmt.Errorf("getting core count: %v", err))
+	}
+
+	memory, err := mem.VirtualMemory()
+	if err == nil {
+		info.Memory.IsAvailable = true
+		info.Memory.TotalMB = memory.Total / 1024 / 1024
+		info.Memory.UsedMB = memory.Used / 1024 / 1024
+		info.Memory.UsedPercent = uint8(math.Min(memory.UsedPercent, 100))
+	} else {
+		addErr(fmt.Errorf("getting memory info: %v", err))
+	}
+
+	swapMemory, err := mem.SwapMemory()
+	if err == nil {
+		info.Memory.SwapIsAvailable = true
+		info.Memory.SwapTotalMB = swapMemory.Total / 1024 / 1024
+		info.Memory.SwapUsedMB = swapMemory.Used / 1024 / 1024
+		info.Memory.SwapUsedPercent = uint8(math.Min(swapMemory.UsedPercent, 100))
+	} else {
+		addErr(fmt.Errorf("getting swap memory info: %v", err))
+	}
+
+	// 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
+	if runtime.GOOS != "windows" {
+		sensorReadings, err := sensors.SensorsTemperatures()
+		if err == nil {
+			if req.CPUTempSensor != "" {
+				for i := range sensorReadings {
+					if sensorReadings[i].SensorKey == req.CPUTempSensor {
+						info.CPU.TemperatureIsAvailable = true
+						info.CPU.TemperatureC = uint8(sensorReadings[i].Temperature)
+						break
+					}
+				}
+
+				if !info.CPU.TemperatureIsAvailable {
+					addErr(fmt.Errorf("CPU temperature sensor %s not found", req.CPUTempSensor))
+				}
+			} else if cpuTempSensor := inferCPUTempSensor(sensorReadings); cpuTempSensor != nil {
+				info.CPU.TemperatureIsAvailable = true
+				info.CPU.TemperatureC = uint8(cpuTempSensor.Temperature)
+			}
+		} else {
+			addErr(fmt.Errorf("getting sensor readings: %v", err))
+		}
+	}
+
+	filesystems, err := disk.Partitions(false)
+	if err == nil {
+		for _, fs := range filesystems {
+			mpReq, ok := req.Mountpoints[fs.Mountpoint]
+			if ok && mpReq.Hide {
+				continue
+			}
+
+			usage, err := disk.Usage(fs.Mountpoint)
+			if err == nil {
+				mpInfo := MountpointInfo{
+					Path:        fs.Mountpoint,
+					Name:        mpReq.Name,
+					TotalMB:     usage.Total / 1024 / 1024,
+					UsedMB:      usage.Used / 1024 / 1024,
+					UsedPercent: uint8(math.Min(usage.UsedPercent, 100)),
+				}
+
+				info.Mountpoints = append(info.Mountpoints, mpInfo)
+			} else {
+				addErr(fmt.Errorf("getting filesystem usage for %s: %v", fs.Mountpoint, err))
+			}
+		}
+	} else {
+		addErr(fmt.Errorf("getting filesystems: %v", err))
+	}
+
+	sort.Slice(info.Mountpoints, func(a, b int) bool {
+		return info.Mountpoints[a].UsedPercent > info.Mountpoints[b].UsedPercent
+	})
+
+	return info, errs
+}
+
+func inferCPUTempSensor(sensors []sensors.TemperatureStat) *sensors.TemperatureStat {
+	for i := range sensors {
+		switch sensors[i].SensorKey {
+		case
+			"coretemp_package_id_0", // intel / linux
+			"coretemp",              // intel / linux
+			"k10temp",               // amd / linux
+			"zenpower",              // amd / linux
+			"cpu_thermal":           // raspberry pi / linux
+			return &sensors[i]
+		}
+	}
+
+	return nil
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác