Merge branch 'release/v0.7.0' into resolved

This commit is contained in:
develoopeer 2025-02-23 17:16:43 +03:00 committed by GitHub
commit 818efda38d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 3446 additions and 551 deletions

View file

@ -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
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -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
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -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

View file

@ -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.

View file

@ -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 -->

2
.gitignore vendored
View file

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

View file

@ -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 .

View file

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

425
README.md
View file

@ -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
* 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)
#### Themeable
![multiple color schemes example](docs/images/themes-example.png)
### 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)
#### Optimized for mobile devices
![mobile device previews](docs/images/mobile-preview.png)
### 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
#### 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)
### Optimized for mobile devices
Because you'll want to take it with you on the go.
### 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.
![](docs/images/mobile-preview.png)
### Installation
> [!CAUTION]
>
> The project is under active development, expect things to break every once in a while.
### Themeable
Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
#### 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:
![](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
mkdir glance && cd glance && curl -sL https://github.com/glanceapp/docker-compose-template/archive/refs/heads/main.tar.gz | tar -xzf - --strip-components 2
```
*[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
<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 compose up -d
```
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:
- ./config:/app/config
ports:
- 8080:8080
```
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
```
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
```
#### Docker
<!-- TODO: update -->
> [!IMPORTANT]
>
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
To grab a starting template for the config file, 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
wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
```
Or if you prefer docker compose:
### Windows
```yaml
services:
glance:
image: glanceapp/glance
volumes:
- ./glance.yml:/app/glance.yml
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- 8080:8080
restart: unless-stopped
```
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.
### Building from source
Requirements: [Go](https://go.dev/dl/) >= v1.22
To build:
<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>
### Building Docker image
<details>
<summary><strong>Build project and Docker image with Docker</strong></summary>
<br>
Build the image:
Requirements: [Docker](https://docs.docker.com/engine/install/)
**Make sure to replace "owner" with your name or organization.**
To build the project and image using just Docker, run:
*(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.

File diff suppressed because it is too large Load diff

296
docs/custom-api.md Normal file
View file

@ -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
docs/glance.yml Normal file
View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

57
docs/v0.7.0-upgrade.md Normal file
View file

@ -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.

18
go.mod
View file

@ -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
)

48
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -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) {

View file

@ -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))))

View file

@ -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,
},
};
}

View file

@ -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
);
}

View file

@ -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();

View file

@ -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) {

View file

@ -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;
}

View file

@ -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; }

View file

@ -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"
}

View file

@ -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>

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 }}

View file

@ -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>

View file

@ -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 }}

View file

@ -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>

View file

@ -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 }}

View file

@ -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>

View file

@ -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
}

View file

@ -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"
}
}
}
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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
}

View file

@ -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,

View file

@ -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 {

View file

@ -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()
}

View file

@ -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
}

View file

@ -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,
}
for i := range widget.Repositories {
r := widget.Repositories[i]
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])
}
if r.source == releaseSourceGithub && widget.Token != "" {
r.token = &widget.Token
} else if r.source == releaseSourceGitlab && widget.GitLabToken != "" {
r.token = &widget.GitLabToken
}
widget.releaseRequests = append(widget.releaseRequests, request)
}
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),

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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
pkg/sysinfo/sysinfo.go Normal file
View file

@ -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
}