Merge branch 'release/v0.7.0' into resolved
|
@ -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
|
@ -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
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/glanceapp/glance/discussions
|
||||
about: For help, feedback, guides, resources and more
|
||||
- name: Discord
|
||||
url: https://discord.com/invite/7KQ7Xa9kJd
|
||||
about: Much like the discussions but more chatty
|
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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.
|
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -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
|
@ -2,4 +2,4 @@
|
|||
/build
|
||||
/playground
|
||||
/.idea
|
||||
glance*.yml
|
||||
/glance*.yml
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
FROM golang:1.23.1-alpine3.20 AS builder
|
||||
FROM golang:1.23.6-alpine3.21 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN CGO_ENABLED=0 go build .
|
||||
|
||||
FROM alpine:3.20
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/glance .
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM alpine:3.20
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
COPY glance .
|
||||
|
|
425
README.md
|
@ -2,113 +2,404 @@
|
|||
<h1 align="center">Glance</h1>
|
||||
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a></p>
|
||||
|
||||

|
||||

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

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

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

|
||||
|
||||
### 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:
|
||||

|
||||
|
||||
<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.
|
||||
|
|
296
docs/custom-api.md
Normal 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
|
@ -0,0 +1,108 @@
|
|||
pages:
|
||||
- name: Home
|
||||
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
|
||||
# hide-desktop-navigation: true
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: calendar
|
||||
first-day-of-week: monday
|
||||
|
||||
- type: rss
|
||||
limit: 10
|
||||
collapse-after: 3
|
||||
cache: 12h
|
||||
feeds:
|
||||
- url: https://selfh.st/rss/
|
||||
title: selfh.st
|
||||
limit: 4
|
||||
- url: https://ciechanow.ski/atom.xml
|
||||
- url: https://www.joshwcomeau.com/rss.xml
|
||||
title: Josh Comeau
|
||||
- url: https://samwho.dev/rss.xml
|
||||
- url: https://ishadeed.com/feed.xml
|
||||
title: Ahmad Shadeed
|
||||
|
||||
- type: twitch-channels
|
||||
channels:
|
||||
- theprimeagen
|
||||
- j_blow
|
||||
- piratesoftware
|
||||
- cohhcarnage
|
||||
- christitustech
|
||||
- EJ_SA
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: group
|
||||
widgets:
|
||||
- type: hacker-news
|
||||
- type: lobsters
|
||||
|
||||
- type: videos
|
||||
channels:
|
||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
||||
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
|
||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
||||
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
subreddit: technology
|
||||
show-thumbnails: true
|
||||
- type: reddit
|
||||
subreddit: selfhosted
|
||||
show-thumbnails: true
|
||||
|
||||
- size: small
|
||||
widgets:
|
||||
- type: weather
|
||||
location: London, United Kingdom
|
||||
units: metric # alternatively "imperial"
|
||||
hour-format: 12h # alternatively "24h"
|
||||
# Optionally hide the location from being displayed in the widget
|
||||
# hide-location: true
|
||||
|
||||
- type: markets
|
||||
# The link to go to when clicking on the symbol in the UI,
|
||||
# {SYMBOL} will be substituded with the symbol for each market
|
||||
symbol-link-template: https://www.tradingview.com/symbols/{SYMBOL}/news
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
- symbol: BTC-USD
|
||||
name: Bitcoin
|
||||
- symbol: NVDA
|
||||
name: NVIDIA
|
||||
- symbol: AAPL
|
||||
name: Apple
|
||||
- symbol: MSFT
|
||||
name: Microsoft
|
||||
|
||||
- type: releases
|
||||
cache: 1d
|
||||
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
|
||||
# read-only token from your Github account settings and use it here to increase the limit.
|
||||
# token: ...
|
||||
repositories:
|
||||
- glanceapp/glance
|
||||
- go-gitea/gitea
|
||||
- immich-app/immich
|
||||
- syncthing/syncthing
|
||||
|
||||
# Add more pages here:
|
||||
# - name: Your page name
|
||||
# columns:
|
||||
# - size: small
|
||||
# widgets:
|
||||
# # Add widgets here
|
||||
|
||||
# - size: full
|
||||
# widgets:
|
||||
# # Add widgets here
|
||||
|
||||
# - size: small
|
||||
# widgets:
|
||||
# # Add widgets here
|
BIN
docs/images/calendar-legacy-widget-preview.png
Normal file
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
BIN
docs/images/custom-api-preview-1.png
Normal file
After Width: | Height: | Size: 7.9 KiB |
BIN
docs/images/custom-api-preview-2.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
docs/images/custom-api-preview-3.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
docs/images/docker-container-parent.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
docs/images/docker-container-parent2.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/images/docker-containers-preview.png
Normal file
After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 362 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 42 KiB |
BIN
docs/images/server-stats-flame-icon.png
Normal file
After Width: | Height: | Size: 9.6 KiB |
BIN
docs/images/server-stats-preview.gif
Normal file
After Width: | Height: | Size: 200 KiB |
BIN
docs/images/split-column-widget-3-columns.png
Normal file
After Width: | Height: | Size: 146 KiB |
BIN
docs/images/split-column-widget-4-columns.png
Normal file
After Width: | Height: | Size: 181 KiB |
BIN
docs/images/split-column-widget-masonry.png
Normal file
After Width: | Height: | Size: 325 KiB |
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 339 KiB |
BIN
docs/images/videos-widget-vertical-list-preview.png
Normal file
After Width: | Height: | Size: 77 KiB |
57
docs/v0.7.0-upgrade.md
Normal 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
|
@ -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
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -56,8 +56,6 @@ func Main() int {
|
|||
|
||||
func serveApp(configPath string) error {
|
||||
exitChannel := make(chan struct{})
|
||||
// the onChange method gets called at most once per 500ms due to debouncing so we shouldn't
|
||||
// need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason
|
||||
hadValidConfigOnStartup := false
|
||||
var stopServer func() error
|
||||
|
||||
|
@ -153,9 +151,9 @@ func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
|
|||
templateFile, _ := templateFS.Open("v0.7-update-notice-page.html")
|
||||
bodyContents, _ := io.ReadAll(templateFile)
|
||||
|
||||
// TODO: update - add link
|
||||
fmt.Println("!!! WARNING !!!")
|
||||
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see <link> for more information.")
|
||||
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0.")
|
||||
fmt.Println("Please see https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md for more information.")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
|
33
internal/glance/static/js/animations.js
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
212
internal/glance/static/js/calendar.js
Normal 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
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -25,7 +25,8 @@ frameElement.append(contentElement);
|
|||
containerElement.append(frameElement);
|
||||
document.body.append(containerElement);
|
||||
|
||||
const observer = new ResizeObserver(repositionContainer);
|
||||
const queueRepositionContainer = () => requestAnimationFrame(repositionContainer);
|
||||
const observer = new ResizeObserver(queueRepositionContainer);
|
||||
|
||||
function handleMouseEnter(event) {
|
||||
clearTogglePopoverTimeout();
|
||||
|
@ -100,7 +101,7 @@ function showPopover() {
|
|||
containerElement.style.display = "block";
|
||||
activeTarget.classList.add("popover-active");
|
||||
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
||||
window.addEventListener("resize", repositionContainer);
|
||||
window.addEventListener("resize", queueRepositionContainer);
|
||||
observer.observe(containerElement);
|
||||
}
|
||||
|
||||
|
@ -156,7 +157,7 @@ function hidePopover() {
|
|||
activeTarget.classList.remove("popover-active");
|
||||
containerElement.style.display = "none";
|
||||
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
||||
window.removeEventListener("resize", repositionContainer);
|
||||
window.removeEventListener("resize", queueRepositionContainer);
|
||||
observer.unobserve(containerElement);
|
||||
|
||||
if (cleanupOnHidePopover !== null) {
|
||||
|
|
190
internal/glance/static/js/templating.js
Normal 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;
|
||||
}
|
|
@ -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; }
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -1,49 +1,49 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{- define "widget-content" }}
|
||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range .Posts }}
|
||||
{{- range .Posts }}
|
||||
<li>
|
||||
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||
{{ if $.ShowThumbnails }}
|
||||
{{ if .IsCrosspost }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
{{ else if ne .ThumbnailUrl "" }}
|
||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||
{{ else if ne "" .TargetUrl }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
{{ else }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{- if $.ShowThumbnails }}
|
||||
{{- if .IsCrosspost }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
{{- else if .ThumbnailUrl }}
|
||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||
{{- else if .TargetUrl }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
{{- else }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
<div class="grow min-width-0">
|
||||
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
{{ if gt (len .Tags) 0 }}
|
||||
{{- if .Tags }}
|
||||
<div class="inline-block forum-post-tags-container">
|
||||
<ul class="attachments">
|
||||
{{ range .Tags }}
|
||||
<li>{{ . }}</li>
|
||||
{{ end }}
|
||||
{{- range .Tags }}
|
||||
<li>{{ . }}</li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
<ul class="list-horizontal-text">
|
||||
{{- end }}
|
||||
<ul class="list-horizontal-text flex-nowrap text-compact">
|
||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||
{{ if ne "" .TargetUrl }}
|
||||
<li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
|
||||
<li class="shrink-0{{ if .TargetUrl }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
|
||||
{{- if .TargetUrl }}
|
||||
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
|
|
34
internal/glance/templates/old-calendar.html
Normal 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 }}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
</svg>
|
||||
</div>
|
||||
|
||||
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
|
||||
<input class="search-input" type="text" placeholder="{{ .Placeholder }}" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
|
||||
|
||||
<div class="search-bang"></div>
|
||||
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
|
||||
|
|
140
internal/glance/templates/server-stats.html
Normal 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 }}
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -24,15 +24,13 @@
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<!-- TODO: update - add links -->
|
||||
|
||||
<div class="content-bounds color-highlight">
|
||||
<div class="content-bounds color-paragraph">
|
||||
<p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
|
||||
<div class="widget-content-frame margin-top-10 padding-widget">
|
||||
<p class="comfy-line-height">
|
||||
The default location of glance.yml in the Docker image has
|
||||
changed since v0.7.0, please see the <a class="color-primary" href="#">migration guide</a>
|
||||
for instructions or visit the <a class="color-primary" href="#">release notes</a>
|
||||
changed since v0.7.0, please see the <a class="color-primary" href="https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md" target="_blank">migration guide</a>
|
||||
for instructions or visit the <a class="color-primary" href="https://github.com/glanceapp/glance/releases/tag/v0.7.0" target="_blank">release notes</a>
|
||||
to find out more about why this change was necessary. Sorry for the inconvenience.
|
||||
</p>
|
||||
|
||||
|
|
20
internal/glance/templates/videos-vertical-list.html
Normal 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 }}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -20,6 +20,8 @@ type monitorWidget struct {
|
|||
Sites []struct {
|
||||
*SiteStatusRequest `yaml:",inline"`
|
||||
Status *siteStatus `yaml:"-"`
|
||||
URL string `yaml:"-"`
|
||||
ErrorURL string `yaml:"error-url"`
|
||||
Title string `yaml:"title"`
|
||||
Icon customIconField `yaml:"icon"`
|
||||
SameTab bool `yaml:"same-tab"`
|
||||
|
@ -58,14 +60,18 @@ func (widget *monitorWidget) update(ctx context.Context) {
|
|||
status := &statuses[i]
|
||||
site.Status = status
|
||||
|
||||
if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) {
|
||||
if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.Error != nil) {
|
||||
widget.HasFailing = true
|
||||
}
|
||||
|
||||
if !status.TimedOut {
|
||||
site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
|
||||
site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
|
||||
if status.Error != nil && site.ErrorURL != "" {
|
||||
site.URL = site.ErrorURL
|
||||
} else {
|
||||
site.URL = site.DefaultURL
|
||||
}
|
||||
|
||||
site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
|
||||
site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -90,12 +96,12 @@ func statusCodeToText(status int, altStatusCodes []int) string {
|
|||
if status == 401 {
|
||||
return "Unauthorized"
|
||||
}
|
||||
if status >= 400 {
|
||||
return "Client Error"
|
||||
}
|
||||
if status >= 500 {
|
||||
return "Server Error"
|
||||
}
|
||||
if status >= 400 {
|
||||
return "Client Error"
|
||||
}
|
||||
|
||||
return strconv.Itoa(status)
|
||||
}
|
||||
|
@ -109,7 +115,7 @@ func statusCodeToStyle(status int, altStatusCodes []int) string {
|
|||
}
|
||||
|
||||
type SiteStatusRequest struct {
|
||||
URL string `yaml:"url"`
|
||||
DefaultURL string `yaml:"url"`
|
||||
CheckURL string `yaml:"check-url"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
}
|
||||
|
@ -126,7 +132,7 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (siteStatus, error) {
|
|||
if statusRequest.CheckURL != "" {
|
||||
url = statusRequest.CheckURL
|
||||
} else {
|
||||
url = statusRequest.URL
|
||||
url = statusRequest.DefaultURL
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
|
86
internal/glance/widget-old-calendar.go
Normal 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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ type searchWidget struct {
|
|||
Bangs []SearchBang `yaml:"bangs"`
|
||||
NewTab bool `yaml:"new-tab"`
|
||||
Autofocus bool `yaml:"autofocus"`
|
||||
Placeholder string `yaml:"placeholder"`
|
||||
}
|
||||
|
||||
func convertSearchUrl(url string) string {
|
||||
|
@ -41,6 +42,10 @@ func (widget *searchWidget) initialize() error {
|
|||
widget.SearchEngine = "duckduckgo"
|
||||
}
|
||||
|
||||
if widget.Placeholder == "" {
|
||||
widget.Placeholder = "Type here to search…"
|
||||
}
|
||||
|
||||
if url, ok := searchEngines[widget.SearchEngine]; ok {
|
||||
widget.SearchEngine = url
|
||||
}
|
||||
|
|
117
internal/glance/widget-server-stats.go
Normal 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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
@ -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
|
||||
}
|