🔀 Merge pull request #425 from Lissy93/FEATURE/more-widgets

[FEATURE] Widget Updates
This commit is contained in:
Alicia Sykes 2022-01-21 13:55:10 +00:00 committed by GitHub
commit 6ddf629c40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 4104 additions and 488 deletions

View File

@ -1,5 +1,11 @@
# Changelog
## ✨ 1.9.8 - More Widgets and Widget Improvements [PR #425](https://github.com/Lissy93/dashy/pull/425)
- Fixes several minor widget issues raised by users
- Adds several new widgets, for monitoring system
- Better widget data requests and error handling
- Implements widget support into Workspace view
## 🐛 1.9.7 - Minor UI Editor Bug fixes [PR #416](https://github.com/Lissy93/dashy/pull/416)
- Fixes unable to edit item bug (#415)
- Fixes unable to add new app bug (#390)

View File

@ -1,6 +1,6 @@
# Management
# App Management
_The following article explains aspects of app management, and is useful to know for when self-hosting. It covers everything from keeping the Dashy (or any other app) up-to-date, secure, backed up, to other topics like auto-starting, monitoring, log management, web server configuration and using custom environments. It's like a top-20 list of need-to-know knowledge for self-hosting._
_The following article is a primer on managing self-hosted apps. It covers everything from keeping the Dashy (or any other app) up-to-date, secure, backed up, to other topics like auto-starting, monitoring, log management, web server configuration and using custom domains._
## Contents
- [Providing Assets](#providing-assets)
@ -15,9 +15,9 @@ _The following article explains aspects of app management, and is useful to know
- [Authentication](#authentication)
- [Managing with Compose](#managing-containers-with-docker-compose)
- [Environmental Variables](#passing-in-environmental-variables)
- [Securing Containers](#container-security)
- [Remote Access](#remote-access)
- [Custom Domain](#custom-domain)
- [Securing Containers](#container-security)
- [Web Server Configuration](#web-server-configuration)
- [Running a Modified App](#running-a-modified-version-of-the-app)
- [Building your Own Container](#building-your-own-container)
@ -288,6 +288,193 @@ If you've got many environmental variables, you might find it useful to put them
---
## Remote Access
- [WireGuard](#wireguard)
- [Reverse SSH Tunnel](#reverse-ssh-tunnel)
- [TCP Tunnel](#tcp-tunnel)
### WireGuard
Using a VPN is one of the easiest ways to provide secure, full access to your local network from remote locations. [WireGuard](https://www.wireguard.com/) is a reasonably new open source VPN protocol, that was designed with ease of use, performance and security in mind. Unlike OpenVPN, it doesn't need to recreate the tunnel whenever connection is dropped, and it's also much easier to setup, using shared keys instead.
- **Install Wireguard** - See the [Install Docs](https://www.wireguard.com/install/) for download links + instructions
- On Debian-based systems, it's `sudo apt install wireguard`
- **Generate a Private Key** - Run `wg genkey` on the Wireguard server, and copy it to somewhere safe for later
- **Create Server Config** - Open or create a file at `/etc/wireguard/wg0.conf` and under `[Interface]` add the following (see example below):
- `Address` - as a subnet of all desired IPs
- `PrivateKey` - that you just generated
- `ListenPort` - Default is `51820`, but can be anything
- **Get Client App** - Download the [WG client app](https://www.wireguard.com/install/) for your platform (Linux, Windows, MacOS, Android or iOS are all supported)
- **Create new Client Tunnel** - On your client app, there should be an option to create a new tunnel, when doing so a client private key will be generated (but if not, use the `wg genkey` command again), and keep it somewhere safe. A public key will also be generated, and this will go in our saver config
- **Add Clients to Server Config** - Head back to your `wg0.conf` file on the server, create a `[Peer]` section, and populate the following info
- `AllowedIPs` - List of IP address inside the subnet, the client should have access to
- `PublicKey` - The public key for the client you just generated
- **Start the Server** - You can now start the WG server, using: `wg-quick up wg0` on your server
- **Finish Client Setup** - Head back to your client device, and edit the config file, leave the private key as is, and add the following fields:
- `PublicKey` - The public key of the server
- `Address` - This should match the `AllowedIPs` section on the servers config file
- `DNS` - The DNS server that'll be used when accessing the network through the VPN
- `Endpoint` - The hostname or IP + Port where your WG server is running (you may need to forward this in your firewall's settings)
- **Done** - Your clients should now be able to connect to your WG server :) Depending on your networks firewall rules, you may need to port forward the address of your WG server
**Example Server Config**
```ini
# Server file
[Interface]
# Which networks does my interface belong to? Notice: /24 and /64
Address = 10.5.0.1/24, 2001:470:xxxx:xxxx::1/64
PrivateKey = xxx
ListenPort = 51820
# Peer 1
[Peer]
PublicKey = xxx
# Which source IPs can I expect from that peer? Notice: /32 and /128
AllowedIps = 10.5.0.35/32, 2001:470:xxxx:xxxx::746f:786f/128
# Peer 2
[Peer]
PublicKey = xxx
# Which source IPs can I expect from that peer? This one has a LAN which can
# access hosts/jails without NAT.
# Peer 2 has a single IP address inside the VPN: it's 10.5.0.25/32
AllowedIps = 10.5.0.25/32,10.21.10.0/24,10.21.20.0/24,10.21.30.0/24,10.31.0.0/24,2001:470:xxxx:xxxx::ca:571e/128
```
**Example Client Config**
```ini
[Interface]
# Which networks does my interface belong to? Notice: /24 and /64
Address = 10.5.0.35/24, 2001:470:xxxx:xxxx::746f:786f/64
PrivateKey = xxx
# Server
[Peer]
PublicKey = xxx
# I want to route everything through the server, both IPv4 and IPv6. All IPs are
# thus available through the Server, and I can expect packets from any IP to
# come from that peer.
AllowedIPs = 0.0.0.0/0, ::0/0
# Where is the server on the internet? This is a public address. The port
# (:51820) is the same as ListenPort in the [Interface] of the Server file above
Endpoint = 1.2.3.4:51820
# Usually, clients are behind NAT. to keep the connection running, keep alive.
PersistentKeepalive = 15
```
A useful tool for getting WG setup is [Algo](https://github.com/trailofbits/algo). It includes scripts and docs which cover almost all devices, platforms and clients, and has best practices implemented, and security features enabled. All of this is better explained in [this blog post](https://blog.trailofbits.com/2016/12/12/meet-algo-the-vpn-that-works/).
### Reverse SSH Tunnel
SSH (or [Secure Shell](https://en.wikipedia.org/wiki/Secure_Shell)) is a secure tunnel that allows you to connect to a remote host. Unlike the VPN methods, an SSH connection does not require an intermediary, and will not be affected by your IP changing. However it only allows you to access a single service at a time. SSH was really designed for terminal access, but because of the latter mentioned benefits it's useful to setup, as a fallback option.
Directly SSH'ing into your home, would require you to open a port (usually 22), which would be terrible for security, and is not recommended. However a reverse SSH connection is initiated from inside your network. Once the connection is established, the port is redirected, allowing you to use the established connection to SSH into your home network.
The issue you've probably spotted, is that most public, corporate, and institutional networks will block SSH connections. To overcome this, you'd have to establish a server outside of your homelab that your homelab's device could SSH into to establish the reverse SSH connection. You can then connect to that remote server (the _mothership_), which in turn connects to your home network.
Now all of this is starting to sound like quite a lot of work, but this is where services like [remot3.it](https://remote.it/) come in. They maintain the intermediary mothership server, and create the tunnel service for you. It's free for personal use, secure and easy. There are several similar services, such as [RemoteIoT](https://remoteiot.com/), or you could create your own on a cloud VPS (see [this tutorial](https://gist.github.com/nileshtrivedi/4c615e8d3c1bf053b0d31176b9e69e42) for more info on that).
Before getting started, you'll need to head over to [Remote.it](https://app.remote.it/auth/#/sign-up) and create an account.
Then setup your local device:
1. If you haven't already done so, you'll need to enable and configure SSH.
- This is out-of-scope of this article, but I've explained it in detail in [this post](https://notes.aliciasykes.com/22798/my-server-setup#configure-ssh).
2. Download the Remote.it install script from their [GitHub](https://github.com/remoteit/installer)
- `curl -LkO https://raw.githubusercontent.com/remoteit/installer/master/scripts/auto-install.sh`
3. Make it executable, with `chmod +x ./auto-install.sh`, and then run it with `sudo ./auto-install.sh`
4. Finally, configure your device, by running `sudo connectd_installer` and following the on-screen instructions
And when you're ready to connect to it:
1. Login to [app.remote.it](https://app.remote.it/), and select the name of your device
2. You should see a list of running services, click SSH
3. You'll then be presented with some SSH credentials that you can now use to securely connect to your home, via the Remote.it servers
Done :)
### TCP Tunnel
If you're running Dashy on your local network, behind a firewall, but need to temporarily share it with someone external, this can be achieved quickly and securely using [Ngrok](https://ngrok.com/). Its basically a super slick, encrypted TCP tunnel that provides an internet-accessible address that anyone use to access your local service, from anywhere.
To get started, [Download](https://ngrok.com/download) and install Ngrok for your system, then just run `ngrok http [port]` (replace the port with the http port where Dashy is running, e.g. 8080). When [using https](https://ngrok.com/docs#http-local-https), specify the full local url/ ip including the protocol.
Some Ngrok features require you to be authenticated, you can [create a free account](https://dashboard.ngrok.com/signup) and generate a token in [your dashboard](https://dashboard.ngrok.com/auth/your-authtoken), then run `ngrok authtoken [token]`.
It's recommended to use authentication for any publicly accessible service. Dashy has an [Auth](/docs/authentication.md) feature built in, but an even easier method it to use the [`-auth`](https://ngrok.com/docs#http-auth) switch. E.g. `ngrok http -auth=”username:password123” 8080`
By default, your web app is assigned a randomly generated ngrok domain, but you can also use your own custom domain. Under the [Domains Tab](https://dashboard.ngrok.com/endpoints/domains) of your Ngrok dashboard, add your domain, and follow the CNAME instructions. You can now use your domain, with the [`-hostname`](https://ngrok.com/docs#http-custom-domains) switch, e.g. `ngrok http -region=us -hostname=dashy.example.com 8080`. If you don't have your own domain name, you can instead use a custom sub-domain (e.g. `alicia-dashy.ngrok.io`), using the [`-subdomain`](https://ngrok.com/docs#custom-subdomain-names) switch.
To integrate this into your docker-compose, take a look at the [gtriggiano/ngrok-tunnel](https://github.com/gtriggiano/ngrok-tunnel) container.
There's so much more you can do with Ngrok, such as exposing a directory as a file browser, using websockets, relaying requests, rewriting headers, inspecting traffic, TLS and TCP tunnels and lots more. All or which is explained in [the Documentation](https://ngrok.com/docs).
It's worth noting that Ngrok isn't the only option here, other options include: [FRP](https://github.com/fatedier/frp), [Inlets](https://inlets.dev), [Local Tunnel](https://localtunnel.me/), [TailScale](https://tailscale.com/), etc. Check out [Awesome Tunneling](https://github.com/anderspitman/awesome-tunneling) for a list of alternatives.
**[⬆️ Back to Top](#management)**
---
## Custom Domain
- [Using DNS](#using-nginx)
- [Using NGINX](#using-dns)
### Using DNS
For locally running services, a domain can be set up directly in the DNS records. This method is really quick and easy, and doesn't require you to purchase an actual domain. Just update your networks DNS resolver, to point your desired URL to the local IP where Dashy (or any other app) is running. For example, a line in your hosts file might look something like: `192.168.0.2 dashy.homelab.local`.
If you're using Pi-Hole, a similar thing can be done in the `/etc/dnsmasq.d/03-custom-dns.conf` file, add a line like: `address=/dashy.example.com/192.168.2.0` for each of your services.
If you're running OPNSense/ PfSense, then this can be done through the UI with Unbound, it's explained nicely in [this article](https://homenetworkguy.com/how-to/use-custom-domain-name-in-internal-network/), by Dustin Casto.
### Using NGINX
If you're using NGINX, then you can use your own domain name, with a config similar to the below example.
```
upstream dashy {
server 127.0.0.1:32400;
}
server {
listen 80;
server_name dashy.mydomain.com;
# Setup SSL
ssl_certificate /var/www/mydomain/sslcert.pem;
ssl_certificate_key /var/www/mydomain/sslkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
ssl_session_timeout 5m;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://dashy;
proxy_redirect off;
proxy_buffering off;
proxy_set_header host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
}
}
```
Similarly, a basic `Caddyfile` might look like:
```
dashy.example.com {
reverse_proxy / nginx:80
}
```
For more info, [this guide](https://thehomelab.wiki/books/dns-reverse-proxy/page/create-domain-records-to-point-to-your-home-server-on-cloudflare-using-nginx-progy-manager) on Setting up Domains with NGINX Proxy Manager and CloudFlare may be useful.
**[⬆️ Back to Top](#management)**
---
## Container Security
- [Keep Docker Up-To-Date](#keep-docker-up-to-date)
@ -429,174 +616,6 @@ Docker supports several modules that let you write your own security profiles.
[Seccomp](https://en.wikipedia.org/wiki/Seccomp) (Secure Computing Mode) is a sandboxing facility in the Linux kernel that acts like a firewall for system calls (syscalls). It uses Berkeley Packet Filter (BPF) rules to filter syscalls and control how they are handled. These filters can significantly limit a containers access to the Docker Hosts Linux kernel - especially for simple containers/applications. It requires a Linux-based Docker host, with secomp enabled, and you can check for this by running `docker info | grep seccomp`. A great resource for learning more about this is [DockerLabs](https://training.play-with-docker.com/security-seccomp/).
**[⬆️ Back to Top](#management)**
---
## Remote Access
- [WireGuard](#wireguard)
- [Reverse SSH Tunnel](#reverse-ssh-tunnel)
### WireGuard
Using a VPN is one of the easiest ways to provide secure, full access to your local network from remote locations. [WireGuard](https://www.wireguard.com/) is a reasonably new open source VPN protocol, that was designed with ease of use, performance and security in mind. Unlike OpenVPN, it doesn't need to recreate the tunnel whenever connection is dropped, and it's also much easier to setup, using shared keys instead.
- **Install Wireguard** - See the [Install Docs](https://www.wireguard.com/install/) for download links + instructions
- On Debian-based systems, it's `sudo apt install wireguard`
- **Generate a Private Key** - Run `wg genkey` on the Wireguard server, and copy it to somewhere safe for later
- **Create Server Config** - Open or create a file at `/etc/wireguard/wg0.conf` and under `[Interface]` add the following (see example below):
- `Address` - as a subnet of all desired IPs
- `PrivateKey` - that you just generated
- `ListenPort` - Default is `51820`, but can be anything
- **Get Client App** - Download the [WG client app](https://www.wireguard.com/install/) for your platform (Linux, Windows, MacOS, Android or iOS are all supported)
- **Create new Client Tunnel** - On your client app, there should be an option to create a new tunnel, when doing so a client private key will be generated (but if not, use the `wg genkey` command again), and keep it somewhere safe. A public key will also be generated, and this will go in our saver config
- **Add Clients to Server Config** - Head back to your `wg0.conf` file on the server, create a `[Peer]` section, and populate the following info
- `AllowedIPs` - List of IP address inside the subnet, the client should have access to
- `PublicKey` - The public key for the client you just generated
- **Start the Server** - You can now start the WG server, using: `wg-quick up wg0` on your server
- **Finish Client Setup** - Head back to your client device, and edit the config file, leave the private key as is, and add the following fields:
- `PublicKey` - The public key of the server
- `Address` - This should match the `AllowedIPs` section on the servers config file
- `DNS` - The DNS server that'll be used when accessing the network through the VPN
- `Endpoint` - The hostname or IP + Port where your WG server is running (you may need to forward this in your firewall's settings)
- **Done** - Your clients should now be able to connect to your WG server :) Depending on your networks firewall rules, you may need to port forward the address of your WG server
**Example Server Config**
```ini
# Server file
[Interface]
# Which networks does my interface belong to? Notice: /24 and /64
Address = 10.5.0.1/24, 2001:470:xxxx:xxxx::1/64
PrivateKey = xxx
ListenPort = 51820
# Peer 1
[Peer]
PublicKey = xxx
# Which source IPs can I expect from that peer? Notice: /32 and /128
AllowedIps = 10.5.0.35/32, 2001:470:xxxx:xxxx::746f:786f/128
# Peer 2
[Peer]
PublicKey = xxx
# Which source IPs can I expect from that peer? This one has a LAN which can
# access hosts/jails without NAT.
# Peer 2 has a single IP address inside the VPN: it's 10.5.0.25/32
AllowedIps = 10.5.0.25/32,10.21.10.0/24,10.21.20.0/24,10.21.30.0/24,10.31.0.0/24,2001:470:xxxx:xxxx::ca:571e/128
```
**Example Client Config**
```ini
[Interface]
# Which networks does my interface belong to? Notice: /24 and /64
Address = 10.5.0.35/24, 2001:470:xxxx:xxxx::746f:786f/64
PrivateKey = xxx
# Server
[Peer]
PublicKey = xxx
# I want to route everything through the server, both IPv4 and IPv6. All IPs are
# thus available through the Server, and I can expect packets from any IP to
# come from that peer.
AllowedIPs = 0.0.0.0/0, ::0/0
# Where is the server on the internet? This is a public address. The port
# (:51820) is the same as ListenPort in the [Interface] of the Server file above
Endpoint = 1.2.3.4:51820
# Usually, clients are behind NAT. to keep the connection running, keep alive.
PersistentKeepalive = 15
```
A useful tool for getting WG setup is [Algo](https://github.com/trailofbits/algo). It includes scripts and docs which cover almost all devices, platforms and clients, and has best practices implemented, and security features enabled. All of this is better explained in [this blog post](https://blog.trailofbits.com/2016/12/12/meet-algo-the-vpn-that-works/).
### Reverse SSH Tunnel
SSH (or [Secure Shell](https://en.wikipedia.org/wiki/Secure_Shell)) is a secure tunnel that allows you to connect to a remote host. Unlike the VPN methods, an SSH connection does not require an intermediary, and will not be affected by your IP changing. However it only allows you to access a single service at a time. SSH was really designed for terminal access, but because of the latter mentioned benefits it's useful to setup, as a fallback option.
Directly SSH'ing into your home, would require you to open a port (usually 22), which would be terrible for security, and is not recommended. However a reverse SSH connection is initiated from inside your network. Once the connection is established, the port is redirected, allowing you to use the established connection to SSH into your home network.
The issue you've probably spotted, is that most public, corporate, and institutional networks will block SSH connections. To overcome this, you'd have to establish a server outside of your homelab that your homelab's device could SSH into to establish the reverse SSH connection. You can then connect to that remote server (the _mothership_), which in turn connects to your home network.
Now all of this is starting to sound like quite a lot of work, but this is where services like [remot3.it](https://remote.it/) come in. They maintain the intermediary mothership server, and create the tunnel service for you. It's free for personal use, secure and easy. There are several similar services, such as [RemoteIoT](https://remoteiot.com/), or you could create your own on a cloud VPS (see [this tutorial](https://gist.github.com/nileshtrivedi/4c615e8d3c1bf053b0d31176b9e69e42) for more info on that).
Before getting started, you'll need to head over to [Remote.it](https://app.remote.it/auth/#/sign-up) and create an account.
Then setup your local device:
1. If you haven't already done so, you'll need to enable and configure SSH.
- This is out-of-scope of this article, but I've explained it in detail in [this post](https://notes.aliciasykes.com/22798/my-server-setup#configure-ssh).
2. Download the Remote.it install script from their [GitHub](https://github.com/remoteit/installer)
- `curl -LkO https://raw.githubusercontent.com/remoteit/installer/master/scripts/auto-install.sh`
3. Make it executable, with `chmod +x ./auto-install.sh`, and then run it with `sudo ./auto-install.sh`
4. Finally, configure your device, by running `sudo connectd_installer` and following the on-screen instructions
And when you're ready to connect to it:
1. Login to [app.remote.it](https://app.remote.it/), and select the name of your device
2. You should see a list of running services, click SSH
3. You'll then be presented with some SSH credentials that you can now use to securely connect to your home, via the Remote.it servers
Done :)
**[⬆️ Back to Top](#management)**
---
## Custom Domain
- [Using DNS](#using-nginx)
- [Using NGINX](#using-dns)
### Using DNS
For locally running services, a domain can be set up directly in the DNS records. This method is really quick and easy, and doesn't require you to purchase an actual domain. Just update your networks DNS resolver, to point your desired URL to the local IP where Dashy (or any other app) is running. For example, a line in your hosts file might look something like: `192.168.0.2 dashy.homelab.local`.
If you're using Pi-Hole, a similar thing can be done in the `/etc/dnsmasq.d/03-custom-dns.conf` file, add a line like: `address=/dashy.example.com/192.168.2.0` for each of your services.
If you're running OPNSense/ PfSense, then this can be done through the UI with Unbound, it's explained nicely in [this article](https://homenetworkguy.com/how-to/use-custom-domain-name-in-internal-network/), by Dustin Casto.
### Using NGINX
If you're using NGINX, then you can use your own domain name, with a config similar to the below example.
```
upstream dashy {
server 127.0.0.1:32400;
}
server {
listen 80;
server_name dashy.mydomain.com;
# Setup SSL
ssl_certificate /var/www/mydomain/sslcert.pem;
ssl_certificate_key /var/www/mydomain/sslkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
ssl_session_timeout 5m;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://dashy;
proxy_redirect off;
proxy_buffering off;
proxy_set_header host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
}
}
```
Similarly, a basic `Caddyfile` might look like:
```
dashy.example.com {
reverse_proxy / nginx:80
}
```
For more info, [this guide](https://thehomelab.wiki/books/dns-reverse-proxy/page/create-domain-records-to-point-to-your-home-server-on-cloudflare-using-nginx-progy-manager) on Setting up Domains with NGINX Proxy Manager and CloudFlare may be useful.
**[⬆️ Back to Top](#management)**
---

View File

@ -7,29 +7,33 @@ Dashy has support for displaying dynamic content in the form of widgets. There a
> Adding / editing widgets through the UI isn't yet supported, you will need to do this in the YAML config file.
##### Contents
- [General Widgets](#general-widgets)
- **[General Widgets](#general-widgets)**
- [Clock](#clock)
- [Weather](#weather)
- [Weather Forecast](#weather-forecast)
- [RSS Feed](#rss-feed)
- [Public IP Address](#public-ip)
- [Crypto Watch List](#crypto-watch-list)
- [Crypto Price History](#crypto-token-price-history)
- [RSS Feed](#rss-feed)
- [Crypto Wallet Balance](#wallet-balance)
- [Code Stats](#code-stats)
- [Email Aliases (AnonAddy)](#anonaddy)
- [Vulnerability Feed](#vulnerability-feed)
- [Sports Scores](#sports-scores)
- [Exchange Rates](#exchange-rates)
- [Public Holidays](#public-holidays)
- [Covid-19 Status](#covid-19-status)
- [Sports Scores](#sports-scores)
- [News Headlines](#news-headlines)
- [TFL Status](#tfl-status)
- [Stock Price History](#stock-price-history)
- [ETH Gas Prices](#eth-gas-prices)
- [Joke of the Day](#joke)
- [XKCD Comics](#xkcd-comics)
- [News Headlines](#news-headlines)
- [Flight Data](#flight-data)
- [NASA APOD](#astronomy-picture-of-the-day)
- [GitHub Trending](#github-trending)
- [GitHub Profile Stats](#github-profile-stats)
- [Public IP Address](#public-ip)
- [Self-Hosted Services Widgets](#self-hosted-services-widgets)
- **[Self-Hosted Services Widgets](#self-hosted-services-widgets)**
- [System Info](#system-info)
- [Cron Monitoring](#cron-monitoring-health-checks)
- [CPU History](#cpu-history-netdata)
@ -39,16 +43,31 @@ Dashy has support for displaying dynamic content in the form of widgets. There a
- [Pi Hole Queries](#pi-hole-queries)
- [Recent Traffic](#recent-traffic)
- [Stat Ping Statuses](#stat-ping-statuses)
- [Dynamic Widgets](#dynamic-widgets)
- **[System Resource Monitoring](#system-resource-monitoring)**
- [CPU Usage Current](#current-cpu-usage)
- [CPU Usage Per Core](#cpu-usage-per-core)
- [CPU Usage History](#cpu-usage-history)
- [Memory Usage Current](#current-memory-usage)
- [Memory Usage History](#memory-usage-history)
- [Disk Space](#disk-space)
- [Disk IO](#disk-io)
- [System Load](#system-load)
- [System Load History](#system-load-history)
- [Network Interfaces](#network-interfaces)
- [Network Traffic](#network-traffic)
- [Resource Usage Alerts](#resource-usage-alerts)
- **[Dynamic Widgets](#dynamic-widgets)**
- [Iframe Widget](#iframe-widget)
- [HTML Embed Widget](#html-embedded-widget)
- [API Response](#api-response)
- [Prometheus Data](#prometheus-data)
- [Data Feed](#data-feed)
- [Usage & Customizations](#usage--customizations)
- **[Usage & Customizations](#usage--customizations)**
- [Widget Usage Guide](#widget-usage-guide)
- [Continuous Updates](#continuous-updates)
- [Proxying Requests](#proxying-requests)
- [Custom CSS Styling](#widget-styling)
- [Customizing Charts](#customizing-charts)
- [Language Translations](#language-translations)
- [Widget UI Options](#widget-ui-options)
- [Building a Widget](#build-your-own-widget)
@ -68,6 +87,7 @@ A simple, live-updating time and date widget with time-zone support. All fields
--- | --- | --- | ---
**`timeZone`** | `string` | _Optional_ | The time zone to display date and time in.<br> Specified as Region/City, for example: `Australia/Melbourne`. See the [Time Zone DB](https://timezonedb.com/time-zones) for a full list of supported TZs. Defaults to the browser / device's local time
**`format`** | `string` | _Optional_ | A country code for displaying the date and time in local format.<br>Specified as `[ISO-3166]-[ISO-639]`, for example: `en-AU`. See [here](https://www.fincher.org/Utilities/CountryLanguageList.shtml) for a full list of locales. Defaults to the browser / device's region
**`customCityName`** | `string` | _Optional_ | By default the city from the time-zone is shown, but setting this value will override that text
**`hideDate`** | `boolean` | _Optional_ | If set to `true`, the date and city will not be shown. Defaults to `false`
##### Example
@ -129,7 +149,7 @@ Displays the weather (temperature and conditions) for the next few days for a gi
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`apiKey`** | `string` | Required | Your OpenWeatherMap API key. You can get one for free at [openweathermap.org](https://openweathermap.org/)
**`apiKey`** | `string` | Required | Your OpenWeatherMap API key. You can get one at [openweathermap.org](https://openweathermap.org/) or for free via the [OWM Student Plan](https://home.openweathermap.org/students)
**`city`** | `string` | Required | A city name to use for fetching weather. This can also be a state code or country code, following the ISO-3166 format
**`numDays`** | `number` | _Optional_ | The number of days to display of forecast info to display. Defaults to `4`, max `16` days
**`units`** | `string` | _Optional_ | The units to use for displaying data, can be either `metric` or `imperial`. Defaults to `metric`
@ -154,6 +174,63 @@ Displays the weather (temperature and conditions) for the next few days for a gi
---
### RSS Feed
Display news and updates from any RSS-enabled service.
<p align="center"><img width="600" src="https://i.ibb.co/N9mvLh4/rss-feed.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`rssUrl`** | `string` | Required | The URL location of your RSS feed
**`apiKey`** | `string` | _Optional_ | An API key for [rss2json](https://rss2json.com/). It's free, and will allow you to make 10,000 requests per day, you can sign up [here](https://rss2json.com/sign-up)
**`limit`** | `number` | _Optional_ | The number of posts to return. If you haven't specified an API key, this will be limited to 10
**`orderBy`** | `string` | _Optional_ | How results should be sorted. Can be either `pubDate`, `author` or `title`. Defaults to `pubDate`
**`orderDirection`** | `string` | _Optional_ | Order direction of feed items to return. Can be either `asc` or `desc`. Defaults to `desc`
##### Example
```yaml
- type: rss-feed
options:
rssUrl: https://www.schneier.com/blog/atom.xml
apiKey: xxxx
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟠 Optional
- **Price**: 🟠 Free Plan (up to 10,000 requests / day)
- **Privacy**: _See [Rss2Json Privacy Policy](https://rss2json.com/privacy-policy)_
---
### Public IP
Often find yourself searching "What's my IP", just so you can check your VPN is still connected? This widget displays your public IP address, along with ISP name and approx location. Data is fetched from [IP-API.com](https://ip-api.com/).
<p align="center"><img width="400" src="https://i.ibb.co/vc3c8zN/public-ip.png" /></p>
##### Options
_No config options._
##### Example
```yaml
- type: public-ip
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟠 Optional
- **Price**: 🟢 Free
- **Host**: Managed Instance Only
- **Privacy**: _See [IP-API Privacy Policy](https://ip-api.com/docs/legal)_
---
### Crypto Watch List
@ -236,36 +313,35 @@ Shows recent price history for a given crypto asset, using price data fetched fr
---
### RSS Feed
### Wallet Balance
Display news and updates from any RSS-enabled service.
Keep track of your crypto balances and see recent transactions. Data is fetched from [BlockCypher](https://www.blockcypher.com/dev/)
<p align="center"><img width="600" src="https://i.ibb.co/N9mvLh4/rss-feed.png" /></p>
<p align="center"><img width="600" src="https://i.ibb.co/27HG4nj/wallet-balances.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`rssUrl`** | `string` | Required | The URL location of your RSS feed
**`apiKey`** | `string` | _Optional_ | An API key for [rss2json](https://rss2json.com/). It's free, and will allow you to make 10,000 requests per day, you can sign up [here](https://rss2json.com/sign-up)
**`limit`** | `number` | _Optional_ | The number of posts to return. If you haven't specified an API key, this will be limited to 10
**`orderBy`** | `string` | _Optional_ | How results should be sorted. Can be either `pubDate`, `author` or `title`. Defaults to `pubDate`
**`orderDirection`** | `string` | _Optional_ | Order direction of feed items to return. Can be either `asc` or `desc`. Defaults to `desc`
**`coin`** | `string` | Required | Symbol of coin or asset, e.g. `btc`, `eth` or `doge`
**`address`** | `string` | Required | Address to monitor. This is your wallet's **public** / receiving address
**`network`** | `string` | _Optional_ | To use a different network, other than mainnet. Defaults to `main`
**`limit`** | `number` | _Optional_ | Limit the number of transactions to display. Defaults to `10`, set to large number to show all
##### Example
```yaml
- type: rss-feed
- type: wallet-balance
options:
rssUrl: https://www.schneier.com/blog/atom.xml
apiKey: xxxx
coin: btc
address: 3853bSxupMjvxEYfwGDGAaLZhTKxB2vEVC
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟠 Optional
- **Price**: 🟠 Free Plan (up to 10,000 requests / day)
- **Privacy**: _See [Rss2Json Privacy Policy](https://rss2json.com/privacy-policy)_
- **Auth**: 🟢 Not Required
- **Price**: 🟢 Free
- **Privacy**: _See [BlockCypher Privacy Policy](https://www.blockcypher.com/privacy.html)_
---
@ -304,6 +380,50 @@ Display your coding summary. [Code::Stats](https://codestats.net/) is a free and
---
### AnonAddy
[AnonAddy](https://anonaddy.com/) is a free and open source mail forwarding service. Use it to protect your real email address, by using a different alias for each of your online accounts, and have all emails land in your normal inbox(es). Supports custom domains, email replies, PGP-encryption, multiple recipients and more
This widget display email addresses / aliases from AnonAddy. Click an email address to copy to clipboard, or use the toggle switch to enable/ disable it. Shows usage stats (bandwidth, used aliases etc), as well as total messages recieved, blocked and sent. Works with both self-hosted and managed instances of AnonAddy.
<p align="center"><img width="400" src="https://i.ibb.co/ZhfyRdV/anonaddy.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`apiKey`** | `string` | Required | Your AnonAddy API Key / Personal Access Token. You can generate this under [Account Settings](https://app.anonaddy.com/settings)
**`hostname`** | `string` | _Optional_ | If your self-hosting AnonAddy, then supply the host name. By default it will use the public hosted instance
**`apiVersion`** | `string` | _Optional_ | If you're using an API version that is not version `v1`, then specify it here
**`limit`** | `number` | _Optional_ | Limit the number of emails shown per page. Defaults to `10`
**`sortBy`** | `string` | _Optional_ | Specify the sort order for email addresses. Defaults to `updated_at`. Can be either: `local_part`, `domain`, `email`, `emails_forwarded`, `emails_blocked`, `emails_replied`, `emails_sent`, `created_at`, `updated_at` or `deleted_at`. Precede with a `-` character to reverse order.
**`searchTerm`** | `string` | _Optional_ | A search term to filter results by, will search the email, description and domain
**`disableControls`** | `boolean` | _Optional_ | Prevent any changes being made to account through the widget. User will not be able to enable or disable aliases through UI when this option is set
**`hideMeta`** | `boolean` | _Optional_ | Don't show account meta info (forward/ block count, quota usage etc)
**`hideAliases`** | `boolean` | _Optional_ | Don't show email address / alias list. Will only show account meta info
##### Example
```yaml
- type: anonaddy
options:
apiKey: "xxxxxxxxxxxxxxxxxxxxxxxx\
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
limit: 5
sortBy: created_at
disableControls: true
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🔴 Required
- **Price**: 🟠 Free for Self-Hosted / Free Plan available on managed instance or $1/month for premium
- **Host**: Self-Hosted or Managed
- **Privacy**: _See [AnonAddy Privacy Policy](https://anonaddy.com/privacy/)_
---
### Vulnerability Feed
Keep track of recent security advisories and vulnerabilities, with optional filtering by score, exploits, vendor and product. All fields are optional.
@ -349,39 +469,6 @@ or
---
### Sports Scores
Show recent scores and upcoming matches from your favourite sports team. Data is fetched from [TheSportsDB.com](https://www.thesportsdb.com/). From the UI, you can click any other team to view their scores and upcoming games, or click a league name to see all teams.
<p align="center"><img width="400" src="https://i.ibb.co/8XhXGkN/sports-scores.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`teamId`** | `string` | __Optional__ | The ID of a team to fetch scores from. You can search for your team on the [Teams Page](https://www.thesportsdb.com/teams_main.php)
**`leagueId`** | `string` | __Optional__ | Alternatively, provide a league ID to fetch all games from. You can find the ID on the [Leagues Page](https://www.thesportsdb.com/Sport/Leagues)
**`pastOrFuture`** | `string` | __Optional__ | Set to `past` to show scores for recent games, or `future` to show upcoming games. Defaults to `past`. You can change this within the UI
**`apiKey`** | `string` | __Optional__ | Optionally specify your API key, which you can sign up for at [TheSportsDB.com](https://www.thesportsdb.com/)
**`limit`** | `number` | __Optional__ | To limit output to a certain number of matches, defaults to `15`
##### Example
```yaml
- type: sports-scores
options:
teamId: 133636
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟠 Optional
- **Price**: 🟠 Free plan (upto 30 requests / minute, limited endpoints)
- **Host**: Managed Instance Only
- **Privacy**: ⚫ No Policy Available
---
### Exchange Rates
Display current FX rates in your native currency. Hover over a row to view more info, or click to show rates in that currency.
@ -452,6 +539,121 @@ Counting down to the next day off work? This widget displays upcoming public hol
---
### Covid-19 Status
Keep track of the current COVID-19 status. Optionally also show cases by country, and a time-series chart. Uses live data from various sources, computed by [disease.sh](https://disease.sh/)
<p align="center"><img width="400" src="https://i.ibb.co/7XjbyRg/covid-19-status.png?" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`showChart`** | `boolean` | _Optional_ | Also display a time-series chart showing number of recent cases
**`showCountries`** | `boolean` | _Optional_ | Also display a list of cases per country
**`numDays`** | `number` | _Optional_ | Specify number of days worth of history to render on the chart
**`countries`** | `string[]` | _Optional_ | An array of countries to display, specified by their [ISO-3 codes](https://www.iso.org/obp/ui). Leave blank to show all, sorted by most cases. `showCountries` must be set to `true`
**`limit`** | `number` | _Optional_ | If showing all countries, set a limit for number of results to return. Defaults to `10`, no maximum
##### Example
```yaml
- type: covid-stats
```
Or
```yaml
- type: covid-stats
options:
showChart: true
showCountries: true
countries:
- GBR
- USA
- IND
- RUS
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟢 Not Required
- **Price**: 🟢 Free
- **Host**: Managed Instance or Self-Hosted (see [disease-sh/api](https://github.com/disease-sh/api))
- **Privacy**: ⚫ No Policy Available
- **Conditions**: [Terms of Use](https://github.com/disease-sh/api/blob/master/TERMS.md)
---
### Sports Scores
Show recent scores and upcoming matches from your favourite sports team. Data is fetched from [TheSportsDB.com](https://www.thesportsdb.com/). From the UI, you can click any other team to view their scores and upcoming games, or click a league name to see all teams.
<p align="center"><img width="400" src="https://i.ibb.co/8XhXGkN/sports-scores.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`teamId`** | `string` | __Optional__ | The ID of a team to fetch scores from. You can search for your team on the [Teams Page](https://www.thesportsdb.com/teams_main.php)
**`leagueId`** | `string` | __Optional__ | Alternatively, provide a league ID to fetch all games from. You can find the ID on the [Leagues Page](https://www.thesportsdb.com/Sport/Leagues)
**`pastOrFuture`** | `string` | __Optional__ | Set to `past` to show scores for recent games, or `future` to show upcoming games. Defaults to `past`. You can change this within the UI
**`apiKey`** | `string` | __Optional__ | Optionally specify your API key, which you can sign up for at [TheSportsDB.com](https://www.thesportsdb.com/)
**`limit`** | `number` | __Optional__ | To limit output to a certain number of matches, defaults to `15`
##### Example
```yaml
- type: sports-scores
options:
teamId: 133636
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟠 Optional
- **Price**: 🟠 Free plan (upto 30 requests / minute, limited endpoints)
- **Host**: Managed Instance Only
- **Privacy**: ⚫ No Policy Available
---
### News Headlines
Displays the latest news, click to read full article. Date is fetched from various news sources using [Currents API](https://currentsapi.services/en)
<p align="center"><img width="380" src="https://i.ibb.co/6NDWW0z/news-headlines.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`apiKey`** | `string` | Required | Your API key for CurrentsAPI. This is free, and you can [get one here](https://currentsapi.services/en/register)
**`country`** | `string` | _Optional_ | Fetch news only from a certain country or region. Specified as a country code, e.g. `GB` or `US`. See [here](https://api.currentsapi.services/v1/available/regions) for a list of supported regions
**`category`** | `string` | _Optional_ | Only return news from within a given category, e.g. `sports`, `programming`, `world`, `science`. The [following categories](https://api.currentsapi.services/v1/available/categories) are supported
**`lang`** | `string` | _Optional_ | Specify the language for returned articles as a 2-digit ISO code (limited article support). The [following languages](https://api.currentsapi.services/v1/available/languages) are supported, defaults to `en`
**`count`** | `number` | _Optional_ | Limit the number of results. Can be between `1` and `200`, defaults to `10`
**`keywords`** | `string` | _Optional_ | Only return articles that contain an exact match within their title or description
##### Example
```yaml
- type: news-headlines
options:
apiKey: xxxxxxx
category: world
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🔴 Required
- **Price**: 🟠 Free plan (upto 600 requests / day)
- **Host**: Managed Instance Only
- **Privacy**: _See [CurrentsAPI Privacy Policy](https://currentsapi.services/privacy)_
---
### TFL Status
Shows real-time tube status of the London Underground. All fields are optional.
@ -526,6 +728,31 @@ Shows recent price history for a given publicly-traded stock or share
---
### ETH Gas Prices
Renders the current Gas cost of transactions on the Ethereum network (in both GWEI and USD), along with recent historical prices. Useful for spotting a good time to transact. Uses data from [ethgas.watch](https://ethgas.watch/)
<p align="center"><img width="400" src="https://i.ibb.co/LhHfQyp/eth-gas-prices.png" /></p>
##### Options
_No config options._
##### Example
```yaml
- type: eth-gas-prices
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟢 Not Required
- **Price**: 🟢 Free
- **Host**: Managed Instance or Self-Hosted (see [wslyvh/ethgaswatch](https://github.com/wslyvh/ethgaswatch))
- **Privacy**: ⚫ No Policy Available
---
### Joke
Renders a programming or generic joke. Data is fetched from the [JokesAPI](https://github.com/Sv443/JokeAPI) by @Sv443. All fields are optional.
@ -587,41 +814,6 @@ Have a laugh with the daily comic from [XKCD](https://xkcd.com/). A classic webc
---
### News Headlines
Displays the latest news, click to read full article. Date is fetched from various news sources using [Currents API](https://currentsapi.services/en)
<p align="center"><img width="380" src="https://i.ibb.co/6NDWW0z/news-headlines.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`apiKey`** | `string` | Required | Your API key for CurrentsAPI. This is free, and you can [get one here](https://currentsapi.services/en/register)
**`country`** | `string` | _Optional_ | Fetch news only from a certain country or region. Specified as a country code, e.g. `GB` or `US`. See [here](https://api.currentsapi.services/v1/available/regions) for a list of supported regions
**`category`** | `string` | _Optional_ | Only return news from within a given category, e.g. `sports`, `programming`, `world`, `science`. The [following categories](https://api.currentsapi.services/v1/available/categories) are supported
**`lang`** | `string` | _Optional_ | Specify the language for returned articles as a 2-digit ISO code (limited article support). The [following languages](https://api.currentsapi.services/v1/available/languages) are supported, defaults to `en`
**`count`** | `number` | _Optional_ | Limit the number of results. Can be between `1` and `200`, defaults to `10`
**`keywords`** | `string` | _Optional_ | Only return articles that contain an exact match within their title or description
##### Example
```yaml
- type: news-headlines
options:
apiKey: xxxxxxx
category: world
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🔴 Required
- **Price**: 🟠 Free plan (upto 600 requests / day)
- **Host**: Managed Instance Only
- **Privacy**: _See [CurrentsAPI Privacy Policy](https://currentsapi.services/privacy)_
---
### Flight Data
Displays airport departure and arrival flights, using data from [AeroDataBox](https://www.aerodatabox.com/). Useful if you live near an airport and often wonder where the flight overhead is going to. Hover over a row for more flight data.
@ -753,31 +945,6 @@ Display stats from your GitHub profile, using embedded cards from [anuraghazra/g
---
### Public IP
Often find yourself searching "What's my IP", just so you can check your VPN is still connected? This widget displays your public IP address, along with ISP name and approx location. Data is fetched from [IP-API.com](https://ip-api.com/).
<p align="center"><img width="400" src="https://i.ibb.co/vc3c8zN/public-ip.png" /></p>
##### Options
_No config options._
##### Example
```yaml
- type: public-ip
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟠 Optional
- **Price**: 🟢 Free
- **Host**: Managed Instance Only
- **Privacy**: _See [IP-API Privacy Policy](https://ip-api.com/docs/legal)_
---
## Self-Hosted Services Widgets
@ -1046,6 +1213,249 @@ Displays the current and recent uptime of your running services, via a self-host
---
## System Resource Monitoring
The easiest method for displaying system info and resource usage in Dashy is with [Glances](https://nicolargo.github.io/glances/).
Glances is a cross-platform monitoring tool developed by [@nicolargo](https://github.com/nicolargo). It's similar to top/htop but with a [Rest API](https://glances.readthedocs.io/en/latest/api.html) and many [data exporters](https://glances.readthedocs.io/en/latest/gw/index.html) available. Under the hood, it uses [psutil](https://github.com/giampaolo/psutil) for retrieving system info.
If you don't already have it installed, either follow the [Installation Guide](https://github.com/nicolargo/glances/blob/master/README.rst) for your system, or setup [with Docker](https://glances.readthedocs.io/en/latest/docker.html), or use the one-line install script: `curl -L https://bit.ly/glances | /bin/bash`.
Glances can be launched with the `glances` command. You'll need to run it in web server mode, using the `-w` option for the API to be reachable. If you don't plan on using the Web UI, then you can disable it using `--disable-webui`. See the [command reference docs](https://glances.readthedocs.io/en/latest/cmds.html) for more info.
##### Options
All Glance's based widgets require a `hostname`. All other parameters are optional.
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`hostname`** | `string` | Required | The URL or IP + port to your Glances instance (without a trailing slash)
**`username`** | `string` | _Optional_ | If you have setup basic auth on Glances, specify username here (defaults to `glances`)
**`password`** | `string` | _Optional_ | If you have setup basic auth on Glances, specify password here. **Note**: since this password is in plaintext, it is important not to reuse it anywhere else
**`apiVersion`** | `string` | _Optional_ | Specify an API version, defaults to V `3`. Note that support for older versions is limited
**`limit`** | `number` | _Optional_ | For widgets that show a time-series chart, optionally limit the number of data points returned. A higher number will show more historical results, but will take longer to load. A value between 300 - 800 is usually optimal
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟠 Optional
- **Price**: 🟢 Free
- **Host**: Self-Hosted (see [GitHub - Nicolargo/Glances](https://github.com/nicolargo/glances))
- **Privacy**: ⚫ No Policy Available
##### Screenshot
[![example-screenshot](https://i.ibb.co/xfK6BGb/system-monitor-board.png)](https://ibb.co/pR6dMZT)
---
### Current CPU Usage
Live-updating current CPU usage, as a combined average across alll cores
<p align="center"><img width="400" src="https://i.ibb.co/qkLgxLp/gl-cpu-usage.png" /></p>
##### Example
```yaml
- type: gl-current-cpu
options:
hostname: http://192.168.130.2:61208
```
---
### CPU Usage Per Core
Live-updating CPU usage breakdown per core
<p align="center"><img width="400" src="https://i.ibb.co/512MYhT/gl-cpu-cores.png" /></p>
##### Example
```yaml
- type: gl-current-cores
options:
hostname: http://192.168.130.2:61208
```
---
### CPU Usage History
Recent CPU usage history, across all cores, and displayed by user and system
<p align="center"><img width="500" src="https://i.ibb.co/zs8BDzR/gl-cpu-history.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`limit`** | `number` | _Optional_ | Limit the number of results returned, rendering more data points will take longer to load. Defaults to `100`
##### Example
```yaml
- type: gl-cpu-history
options:
hostname: http://192.168.130.2:61208
limit: 60
```
---
### Current Memory Usage
Real-time memory usage gauge, with more info visible on click
<p align="center"><img width="400" src="https://i.ibb.co/rynp52J/gl-mem-usage.png" /></p>
##### Example
```yaml
- type: gl-current-mem
options:
hostname: http://192.168.130.2:61208
```
---
### Memory Usage History
Recent memory usage chart
<p align="center"><img width="500" src="https://i.ibb.co/V3wSgW0/gl-mem-history.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`limit`** | `number` | _Optional_ | Limit the number of results returned, rendering more data points will take longer to load. Defaults to `100`
##### Example
```yaml
- type: gl-mem-history
options:
hostname: http://localhost:61208
limit: 80
```
---
### Disk Space
List connected disks, showing free / used space and other info (file system, mount point and space available)
<p align="center"><img width="400" src="https://i.ibb.co/25y94bB/gl-disk-usage.png" /></p>
##### Example
```yaml
- type: gl-disk-space
options:
hostname: http://192.168.130.2:61208
```
---
### Disk IO
Shows real-time read and write speeds and operations per sec for each disk
<p align="center"><img width="400" src="https://i.ibb.co/JdgjCjG/gl-disk-io.png" /></p>
##### Example
```yaml
- type: gl-disk-io
options:
hostname: http://192.168.130.2:61208
```
---
### System Load
Shows the number of processes waiting in the run-queue, averaged across all cores. Displays for past 5, 10 and 15 minutes
<p align="center"><img width="400" src="https://i.ibb.co/090FfNy/gl-system-load.png" /></p>
##### Example
```yaml
- type: gl-system-load
options:
hostname: http://192.168.130.2:61208
```
---
### System Load History
Shows recent historical system load, calculated from the number of processes waiting in the run-queue, in 1, 5 and 15 minute intervals, and averaged across all cores. Optionally specify `limit` to set number of results returned, defaults to `500`, max `100000`, but the higher the number the longer the load and render times will be.
<p align="center"><img width="500" src="https://i.ibb.co/C2rGMLg/system-load-history.png" /></p>
##### Example
```yaml
- type: gl-load-history
options:
hostname: http://192.168.130.2:61208
```
---
### Network Interfaces
Lists visible network interfaces, including real-time upload/ download stats
<p align="center"><img width="400" src="https://i.ibb.co/FnhgHfG/gl-network-interfaces.png" /></p>
##### Example
```yaml
- type: gl-network-interfaces
options:
hostname: http://192.168.130.2:61208
```
---
### Network Traffic
Shows amount of data recently uploaded/ downloaded across all network interfaces. Optionally set the `limit` option to specify number historical of data points to return
<p align="center"><img width="400" src="https://i.ibb.co/12RN6KT/gl-network-traffic.png" /></p>
##### Example
```yaml
- type: gl-network-traffic
options:
hostname: http://192.168.130.2:61208
limit: 500
```
---
### Resource Usage Alerts
Lists recent high resource usage alerts (e.g. CPU, mem, IO, load, temp)
<p align="center"><img width="400" src="https://i.ibb.co/w01NX5R/gl-alerts.png" /></p>
##### Example
```yaml
- type: gl-alerts
options:
hostname: http://192.168.130.2:61208
```
---
## Dynamic Widgets
### Iframe Widget
@ -1195,19 +1605,6 @@ Note that if you have many widgets, and set them to continuously update frequent
---
### Widget Styling
Like elsewhere in Dashy, all colours can be easily modified with CSS variables.
Widgets use the following color variables, which can be overridden if desired:
- `--widget-text-color` - Text color, defaults to `--primary`
- `--widget-background-color` - Background color, defaults to `--background-darker`
- `--widget-accent-color` - Accent color, defaults to `--background`
For more info on how to apply custom variables, see the [Theming Docs](/docs/theming.md#setting-custom-css-in-the-ui)
---
### Proxying Requests
If a widget fails to make a data request, and the console shows a CORS error, this means the server is blocking client-side requests.
@ -1233,6 +1630,35 @@ Vary: Origin
---
### Widget Styling
Like elsewhere in Dashy, all colours can be easily modified with CSS variables.
Widgets use the following color variables, which can be overridden if desired:
- `--widget-text-color` - Text color, defaults to `--primary`
- `--widget-background-color` - Background color, defaults to `--background-darker`
- `--widget-accent-color` - Accent color, defaults to `--background`
For more info on how to apply custom variables, see the [Theming Docs](/docs/theming.md#setting-custom-css-in-the-ui)
---
### Customizing Charts
For widgets that contain charts, you can set an array of colors under `chartColors`.
To specify the chart height, set `chartHeight` to an integer (in `px`), defaults to `300`.
For example:
```yaml
- type: gl-load-history
options:
hostname: http://192.168.130.2:61208
chartColors: ['#9b5de5', '#f15bb5', '#00bbf9', '#00f5d4']
chartHeight: 450
```
---
### Language Translations
Since most of the content displayed within widgets is fetched from an external API, unless that API supports multiple languages, translating dynamic content is not possible.
@ -1245,13 +1671,13 @@ For more info about multi-language support, see the [Internationalization Docs](
### Widget UI Options
Widgets can be opened in full-page view, by clicking the Arrow icon (top-right). The URL in your address bar will also update, and visiting that web address will take you straight to the selected widget.
Widgets can be opened in full-page view, by clicking the Arrow icon (top-right). The URL in your address bar will also update, and visiting that web address directly will take you straight to that widget.
You can reload the data of any widget, by clicking the Refresh Data icon (also in top-right). This will only affect the widget where the action was triggered from.
All [config options](/docs/configuring.md#section) that can be applied to sections, can also be applied to widget sections. For example, to make a widget span multiple columns, set `displayData.cols: 2` within the parent section. You can collapse a widget (by clicking the section title), and collapse state will be saved locally.
All [config options](/docs/configuring.md#section) that can be applied to sections, can also be applied to widget sections. For example, to make a widget section double the width, set `displayData.cols: 2` within the parent section. You can collapse a widget (by clicking the section title), and collapse state will be saved locally.
Widgets cannot currently be edited through the UI. This feature is in development, and will be released soon. In the meantime, you can either use the JSON config editor, or use VS Code or SSH into your box to edit the conf.yml file directly.
Widgets cannot currently be edited through the UI. This feature is in development, and will be released soon. In the meantime, you can either use the JSON config editor, or use [VS Code Server](https://github.com/coder/code-server), or just SSH into your box and edit the conf.yml file directly.
---
@ -1261,7 +1687,7 @@ Widgets are built in a modular fashion, making it easy for anyone to create thei
For a full tutorial on creating your own widget, you can follow [this guide](/docs/development-guides.md#building-a-widget), or take a look at [here](https://github.com/Lissy93/dashy/commit/3da76ce2999f57f76a97454c0276301e39957b8e) for a code example.
Alternatively, for displaying simple data, you could also just use the either the [iframe](#iframe-widget), [embed](#html-embedded-widget), [Data Feed](#data-feed) or [API response](#api-response) widgets.
Alternatively, for displaying simple data, you could also just use the either the [iframe](#iframe-widget), [embed](#html-embedded-widget), [data feed](#data-feed) or [API response](#api-response) widgets.
---

View File

@ -1,6 +1,6 @@
{
"name": "Dashy",
"version": "1.9.7",
"version": "1.9.8",
"license": "MIT",
"main": "server",
"author": "Alicia Sykes <alicia@omg.lol> (https://aliciasykes.com)",

View File

@ -23,7 +23,7 @@ module.exports = (req, res) => {
// Get desired URL, from Target-URL header
const targetURL = req.header('Target-URL');
if (!targetURL) {
res.send(500, { error: 'There is no Target-Endpoint header in the request' });
res.status(500).send({ error: 'There is no Target-Endpoint header in the request' });
return;
}
// Apply any custom headers, if needed
@ -40,8 +40,8 @@ module.exports = (req, res) => {
// Make the request, and respond with result
axios.request(requestConfig)
.then((response) => {
res.send(200, response.data);
res.status(200).send(response.data);
}).catch((error) => {
res.send(500, { error });
res.status(500).send({ error });
});
};

View File

@ -271,6 +271,15 @@
"mem-breakdown-title": "Memory Breakdown",
"load-chart-title": "System Load"
},
"glances": {
"disk-space-free": "Free",
"disk-space-used": "Used",
"disk-mount-point": "Mount Point",
"disk-file-system": "File System",
"disk-io-read": "Read",
"disk-io-write": "Write",
"system-load-desc": "Number of processes waiting in the run-queue, averaged across all cores"
},
"system-info": {
"uptime": "Uptime"
},

View File

@ -0,0 +1,366 @@
<template>
<div class="gauge">
<svg
v-if="height"
:viewBox="`0 0 ${RADIUS * 2} ${height}`" height="100%" width="100%"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<!-- Inner shadow for empty part of the gauge -->
<filter :id="`innershadow-${_uid}`">
<feFlood :flood-color="shadowColor" />
<feComposite in2="SourceAlpha" operator="out" />
<feGaussianBlur stdDeviation="2" result="blur" />
<feComposite operator="atop" in2="SourceGraphic" />
</filter>
<!-- Gradient color for the full part of the gauge -->
<linearGradient
v-if="hasGradient"
:id="`gaugeGradient-${_uid}`"
>
<stop
v-for="(color, index) in gaugeColor"
:key="`${color.color}-${index}`"
:offset="`${color.offset}%`" :stop-color="color.color"
/>
</linearGradient>
<mask :id="`innerCircle-${_uid}`">
<!-- Mask to make sure only the part inside the circle is visible -->
<!-- RADIUS - 0.5 to avoid any weird display -->
<circle :r="RADIUS - 0.5" :cx="X_CENTER" :cy="Y_CENTER" fill="white" />
<!-- Mask to remove the inside of the gauge -->
<circle :r="innerRadius" :cx="X_CENTER" :cy="Y_CENTER" fill="black" />
<template v-if="separatorPaths">
<!-- Mask for each separator -->
<path
v-for="(separator, index) in separatorPaths"
:key="index"
:d="separator" fill="black"
/>
</template>
</mask>
</defs>
<g :mask="`url(#innerCircle-${_uid})`">
<!-- Draw a circle if the full gauge has a 360° angle, otherwise draw a path -->
<circle
v-if="isCircle"
:r="RADIUS" :cx="X_CENTER" :cy="Y_CENTER"
:fill="hasGradient ? `url(#gaugeGradient-${_uid})` : gaugeColor"
/>
<path
v-else
:d="basePath" :fill="hasGradient ? `url(#gaugeGradient-${_uid})` : gaugeColor"
/>
<!-- Draw a circle if the empty gauge has a 360° angle, otherwise draw a path -->
<circle
v-if="value === min && isCircle"
:r="RADIUS" :cx="X_CENTER" :cy="Y_CENTER"
:fill="baseColor"
/>
<path v-else :d="gaugePath" :fill="baseColor" :filter="`url(#innershadow-${_uid})`" />
</g>
<template v-if="scaleLines">
<!-- Display a line for each tick of the scale -->
<line
v-for="(line, index) in scaleLines"
:key="`${line.xE}-${index}`"
:x1="line.xS" :y1="line.yS" :x2="line.xE" :y2="line.yE"
stroke-width="1" :stroke="baseColor"
/>
</template>
<!-- Option for displaying content inside the gauge -->
<foreignObject x="0" y="0" width="100%" :height="height">
<slot />
</foreignObject>
</svg>
</div>
</template>
<script>
/** A gauge chart component for showing percentages
* Heavily inspired by vue-svg-gauge by @hellocomet
* See: https://github.com/hellocomet/vue-svg-gauge
*/
import ErrorHandler from '@/utils/ErrorHandler';
// Main radius of the gauge
const RADIUS = 100;
// Coordinates of the center based on the radius
const X_CENTER = 100;
const Y_CENTER = 100;
/* Turn polar coordinate to cartesians */
function polarToCartesian(radius, angle) {
const angleInRadians = (angle - 90) * (Math.PI / 180);
return {
x: X_CENTER + (radius * Math.cos(angleInRadians)),
y: Y_CENTER + (radius * Math.sin(angleInRadians)),
};
}
/* Describe a gauge path according */
function describePath(radius, startAngle, endAngle) {
const start = polarToCartesian(radius, endAngle);
const end = polarToCartesian(radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
const d = [
'M', start.x, start.y,
'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y,
'L', X_CENTER, Y_CENTER,
].join(' ');
return d;
}
export default {
name: 'Gauge',
props: {
value: {
type: Number,
default: 70,
},
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 100,
},
startAngle: {
type: Number,
default: -90,
validator: (value) => {
if (value < -360 || value > 360) {
ErrorHandler('Gauge Chart - Expected prop "startAngle" to be between -360 and 360');
}
return true;
},
},
endAngle: {
type: Number,
default: 90,
validator: (value) => {
if (value < -360 || value > 360) {
ErrorHandler('Gauge Chart - Expected prop "endAngle" to be between -360 and 360');
}
return true;
},
},
/* Size of the inner radius between 0 and RADIUS. Closer to RADIUS, is thinner gauge */
innerRadius: {
type: Number,
default: 60,
validator: (value) => {
if (value < 0 || value > 100) {
ErrorHandler(`Gauge Chart - Expected prop "innerRadius" to be between 0 and ${RADIUS}`);
}
return true;
},
},
/* Separator step, will display a each min + (n * separatorStep), won't show if null */
separatorStep: {
type: Number,
default: 20,
validator: (value) => {
if (value !== null && value < 0) {
ErrorHandler('Gauge Chart - Expected prop "separatorStep" to be null or >= 0');
}
return true;
},
},
/* Separator Thickness, unit is in degree */
separatorThickness: {
type: Number,
default: 4,
},
/* Gauge color. Can be either string or array of objects (for gradient) */
gaugeColor: {
type: [Array, String],
default: () => ([
{ offset: 0, color: '#20e253' },
{ offset: 30, color: '#f6f000' },
{ offset: 60, color: '#fca016' },
{ offset: 80, color: '#f80363' },
]),
},
/* Color of the base of the gauge */
baseColor: {
type: String,
default: '#DDDDDD',
},
/* The inset shadow color */
shadowColor: {
type: String,
default: '#8787871a',
},
/* Scale interval, won't display any scall if 0 or `null` */
scaleInterval: {
type: Number,
default: 5,
validator: (value) => {
if (value !== null && value < 0) {
ErrorHandler('Gauge Chart - Expected prop "scaleInterval" to be null or >= 0');
}
return true;
},
},
/* Transition duration in ms */
transitionDuration: {
type: Number,
default: 1500,
},
},
data() {
return {
X_CENTER,
Y_CENTER,
RADIUS,
tweenedValue: this.min,
};
},
computed: {
/* Height of the viewbox */
height() {
const { endAngle, startAngle } = this;
const { y: yStart } = polarToCartesian(RADIUS, startAngle);
const { y: yEnd } = polarToCartesian(RADIUS, endAngle);
return Math.abs(endAngle) <= 180 && Math.abs(startAngle) <= 180
? Math.max(Y_CENTER, yStart, yEnd)
: RADIUS * 2;
},
/* SVG d property of the path of the base gauge (the colored one) */
basePath() {
const { startAngle, endAngle } = this;
return describePath(RADIUS, startAngle, endAngle);
},
/* SVG d property of the gauge based on current value, to hide inverse */
gaugePath() {
const { endAngle, getAngle, tweenedValue } = this;
return describePath(RADIUS, getAngle(tweenedValue), endAngle);
},
/* Total angle of the gauge */
totalAngle() {
const { startAngle, endAngle } = this;
return Math.abs(endAngle - startAngle);
},
/* True if the gauge is a full circle */
isCircle() {
return Math.abs(this.totalAngle) === 360;
},
/* If gauge color is array, return true so gradient can be used */
hasGradient() {
return Array.isArray(this.gaugeColor);
},
/* Array of the path of each separator */
separatorPaths() {
const {
separatorStep, getAngle, min, max, separatorThickness, isCircle,
} = this;
if (separatorStep > 0) {
const paths = [];
let i = isCircle ? min : min + separatorStep;
for (i; i < max; i += separatorStep) {
const angle = getAngle(i);
const halfAngle = separatorThickness / 2;
paths.push(describePath(RADIUS + 2, angle - halfAngle, angle + halfAngle));
}
return paths;
}
return null;
},
/* Array of line configuration for each scale */
scaleLines() {
const {
scaleInterval, isCircle, min, max, getAngle, innerRadius,
} = this;
if (scaleInterval > 0) {
const lines = [];
let i = isCircle ? min + scaleInterval : min;
for (i; i < max + scaleInterval; i += scaleInterval) {
const angle = getAngle(i);
const startCoordinate = polarToCartesian(innerRadius - 4, angle);
const endCoordinate = polarToCartesian(innerRadius - 8, angle);
lines.push({
xS: startCoordinate.x,
yS: startCoordinate.y,
xE: endCoordinate.x,
yE: endCoordinate.y,
});
}
return lines;
}
return null;
},
/* Generate a logarithmic scale for smooth animations */
logScale() {
const logScale = [];
for (let i = this.max; i > 1; i -= 1) logScale.push(Math.round(Math.log(i)));
return logScale;
},
},
watch: {
/* Update chats value with animation */
value(newValue) {
this.animateTo(newValue);
},
},
methods: {
/* Get an angle for a value */
getAngle(value) {
const {
min, max, startAngle, totalAngle,
} = this;
const totalValue = (max - min) || 1;
return ((value * totalAngle) / totalValue) + startAngle;
},
/* Increment the charts current value with logarithmic delays, until it equals new value */
animateTo(newValue) {
let currentValue = this.tweenedValue;
let indexCounter = 0; // Keeps track of number of moves
const forward = currentValue < newValue; // Direction
const moveOnePoint = () => {
currentValue = forward ? currentValue + 1 : currentValue - 1;
indexCounter += 1;
setTimeout(() => {
if ((forward && currentValue <= newValue) || (!forward && currentValue >= newValue)) {
this.tweenedValue = currentValue;
moveOnePoint();
}
}, this.logScale[indexCounter]);
};
moveOnePoint();
},
},
mounted() {
// Set initial value
this.animateTo(this.value);
},
};
</script>
<style lang="css">
.gauge {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,137 @@
<template>
<div class="percentage-chart-wrapper">
<!-- Chart Heading -->
<div class="title" v-if="title">
<p>{{ title }}</p>
</div>
<!-- Percentage Chart -->
<div class="percentage-chart" :style="makeWrapperStyles(height)">
<div
v-for="(block, inx) in blocks" :key="inx"
class="inner" :style="makeDimens(block)"
v-tooltip="`${block.label} - ${block.width}%`"
></div>
</div>
<!-- Chart Legend / Key -->
<div class="legend">
<div v-for="(block, inx) in blocks" :key="inx"
class="legend-item" v-tooltip="`${Math.round(block.width)}% (${block.value})`">
<div class="dot" v-if="block.label" :style="makeDotColor(block)"></div>
<div class="txt" v-if="block.label">{{ block.label }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
showAsPercent: {
type: Boolean,
default: true,
},
height: {
number: Boolean,
default: 1,
},
values: Array,
title: String,
},
data() {
return {
defaultColors: [
'#eb5cad', '#985ceb', '#5346f3', '#5c90eb', '#5cdfeb',
'#00CCB4', '#5ceb8d', '#afeb5c', '#eff961',
],
};
},
computed: {
blocks() {
let startPositionSum = 0;
const results = [];
const total = this.values.reduce((prev, cur) => (prev.size || prev) + cur.size);
const multiplier = this.showAsPercent ? 100 / total : 1;
this.values.forEach((value, index) => {
const defaultColor = this.defaultColors[index % this.defaultColors.length];
results.push({
start: startPositionSum,
width: Math.round(value.size * multiplier),
color: value.color || defaultColor,
label: value.label,
value: value.size,
});
startPositionSum += (value.size * multiplier);
});
return results;
},
},
methods: {
makeDimens(block) {
return `margin-left: ${block.start}%; width: ${block.width}%; background: ${block.color}`;
},
makeDotColor(block) {
return `background: ${block.color};`;
},
makeWrapperStyles(height) {
return `height: ${height}rem`;
},
},
};
</script>
<style scoped lang="scss">
.percentage-chart-wrapper {
// Chart Title
.title {
p {
font-size: 1rem;
margin: 0.5rem 0;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
}
// Main Chart
.percentage-chart {
width: 100%;
background: grey;
position: relative;
height: 2rem;
margin: 0.5rem auto;
border-radius: 3px;
overflow: hidden;
.inner {
position: absolute;
width: 30%;
height: 100%;
box-shadow: inset 0px -1px 2px #000000bf;
&:hover {
box-shadow: inset 0px -1px 4px #000000bf;
}
}
}
// Chart Legend
.legend {
display: flex;
margin-top: 0.5rem;
.legend-item {
display: flex;
align-items: center;
.dot {
width: 1rem;
height: 1rem;
border-radius: 1rem;
}
.txt {
font-size: 0.8rem;
margin: 0.5rem;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
&:hover {
.txt { opacity: 1; }
}
}
}
}
</style>

View File

@ -0,0 +1,142 @@
<template>
<label :for="id + '_button'" :class="{'active': isActive}" class="toggle-switch">
<span class="toggle__label" v-if="!hideLabels">{{ isActive ? enableText : disabledText }}</span>
<input type="checkbox" :disabled="disabled" :id="id + '_button'" v-model="checkedValue">
<span class="switch"></span>
</label>
</template>
<script>
export default {
props: {
disabled: {
type: Boolean,
default: false,
},
labelEnableText: {
type: String,
default: 'On',
},
labelDisableText: {
type: String,
default: 'Off',
},
id: {
type: String,
default: 'primary',
},
defaultState: {
type: Boolean,
default: false,
},
hideLabels: {
type: Boolean,
default: false,
},
},
data() {
return {
currentState: this.defaultState,
};
},
watch: {
defaultState: function defaultState() {
this.currentState = Boolean(this.defaultState);
},
},
computed: {
isActive() {
return this.currentState;
},
enableText() {
return this.labelEnableText;
},
disabledText() {
return this.labelDisableText;
},
checkedValue: {
get() {
return this.currentState;
},
set(newValue) {
this.currentState = newValue;
this.$emit('change', newValue, this.id);
},
},
},
};
</script>
<style scoped lang="scss">
label.toggle-switch {
--circle-size: 14px;
--switch-width: 30px;
--switch-height: 8px;
--margin-size: 0 0.25rem;
--on-color: var(--success, #20e253);
--on-bg: #adedcb;
--off-color: var(--danger, #f80363);
--off-bg: #ffb9d4;
--switch-bg: var(--neutral, #272f4d);
vertical-align: middle;
user-select: none;
cursor: pointer;
input[type="checkbox"] {
opacity: 0;
position: absolute;
width: 1px;
height: 1px;
}
.switch {
display:inline-block;
height: var(--switch-height);
border-radius:6px;
width: var(--switch-width);
background: var(--off-bg);
box-shadow: inset 0 0 1px var(--off-bg);
position:relative;
margin: var(--margin-size);
transition: all .25s;
&::after,
&::before {
content: "";
position: absolute;
display: block;
height: var(--circle-size);
width: var(--circle-size);
border-radius: 50%;
left: 0;
top: -3px;
transform: translateX(0);
transition: all .25s cubic-bezier(.5, -.6, .5, 1.6);
}
&::after {
background: var(--off-color);
box-shadow: 0 0 1px #666;
}
&::before {
background: var(--off-color);
box-shadow: 0 0 0 3px rgba(0,0,0,0.1);
opacity:0;
}
}
&.active .switch {
background: var(--on-bg);
box-shadow: inset 0 0 1px var(--on-bg);
&::after,
&::before{
transform: translateX(calc(var(--switch-width) - var(--circle-size)));
}
&::after {
left: 0;
background: var(--on-color);
box-shadow: 0 0 1px var(--on-color);
}
}
}
</style>

View File

@ -0,0 +1,400 @@
<template>
<div class="anonaddy-wrapper">
<!-- Account Info -->
<div class="account-info" v-if="meta && !hideMeta">
<PercentageChart title="Mail Stats"
:values="[
{ label: 'Forwarded', size: meta.forwardCount, color: '#20e253' },
{ label: 'Blocked', size: meta.blockedCount, color: '#f80363' },
{ label: 'Replies', size: meta.repliesCount, color: '#04e4f4' },
{ label: 'Sent', size: meta.sentCount, color: '#f6f000' },
]" />
<div class="meta-item">
<span class="lbl">Bandwidth</span>
<span class="val">
{{ meta.bandwidth | formatBytes }} out of
{{ meta.bandwidthLimit !== 100000000 ? (formatBytes(meta.bandwidthLimit)) : '∞'}}
</span>
</div>
<div class="meta-item">
<span class="lbl">Active Domains</span>
<span class="val">{{ meta.activeDomains }} out of {{ meta.activeDomainsLimit }}</span>
</div>
<div class="meta-item">
<span class="lbl">Shared Domains</span>
<span class="val">{{ meta.sharedDomains }} out of {{ meta.sharedDomainsLimit || '∞'}}</span>
</div>
<div class="meta-item">
<span class="lbl">Usernames</span>
<span class="val">{{ meta.usernamesCount }} out of {{ meta.usernamesLimit || '∞'}}</span>
</div>
</div>
<!-- Email List -->
<div class="email-list" v-if="aliases && !hideAliases">
<div class="email-row" v-for="alias in aliases" :key="alias.id">
<!-- Email address and status -->
<div class="row-1">
<Toggle v-if="!disableControls" @change="toggleAlias"
:defaultState="alias.active" :id="alias.id" :hideLabels="true" />
<span v-if="disableControls"
:class="`status ${alias.active ? 'active' : 'inactive'}`"></span>
<div class="address-copy" @click="copyToClipboard(alias.fullEmail)" title="Click to Copy">
<span class="txt-email">{{ alias.email }}</span>
<span class="txt-at">@</span>
<span class="txt-domain">{{ alias.domain }}</span>
</div>
<ClipboardIcon class="copy-btn"
@click="copyToClipboard(alias.fullEmail)"
v-tooltip="tooltip('Copy alias to clipboard')"
/>
</div>
<!-- Optional description field -->
<div class="row-2" v-if="alias.description">
<span class="description">{{ alias.description }}</span>
</div>
<!-- Num emails sent + received -->
<div class="row-3">
<span class="lbl">Forwarded</span>
<span class="val">{{ alias.forwardCount }}</span>
<span class="lbl">Blocked</span>
<span class="val">{{ alias.blockedCount }}</span>
<span class="lbl">Replied</span>
<span class="val">{{ alias.repliesCount }}</span>
<span class="lbl">Sent</span>
<span class="val">{{ alias.sentCount }}</span>
</div>
<!-- Date created / updated -->
<div class="row-4">
<span class="lbl">Created</span>
<span class="val as-date">{{ alias.createdAt | formatDate }}</span>
<span class="val as-time-ago">{{ alias.createdAt | formatTimeAgo }}</span>
</div>
</div>
</div>
<!-- Pagination Page Numbers -->
<div class="pagination" v-if="numPages && !hideAliases">
<span class="page-num first" @click="goToFirst()">«</span>
<span class="page-num" v-if="paginationRange[0] !== 1" @click="goToPrevious()">...</span>
<span
v-for="pageNum in paginationRange" :key="pageNum"
@click="goToPage(pageNum)"
:class="`page-num ${pageNum === currentPage ? 'selected' : ''}`"
>{{ pageNum }}</span>
<span class="page-num" @click="goToNext()"
v-if="paginationRange[paginationRange.length - 1] < numPages">...</span>
<span class="page-num last" @click="goToLast()">»</span>
<p class="page-status">Page {{ currentPage }} of {{ numPages }}</p>
</div>
</div>
</template>
<script>
import Toggle from '@/components/FormElements/Toggle';
import PercentageChart from '@/components/Charts/PercentageChart';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToDate, getTimeAgo, convertBytes } from '@/utils/MiscHelpers';
import ClipboardIcon from '@/assets/interface-icons/open-clipboard.svg';
export default {
mixins: [WidgetMixin],
components: {
Toggle,
PercentageChart,
ClipboardIcon,
},
data() {
return {
aliases: null,
meta: null,
numPages: null,
currentPage: 1,
};
},
computed: {
hostname() {
return this.options.hostname || widgetApiEndpoints.anonAddy;
},
apiVersion() {
return this.options.apiVersion || 'v1';
},
limit() {
return this.options.limit || '10';
},
sortBy() {
return this.options.sortBy || 'updated_at';
},
searchTerm() {
return this.options.searchTerm || '';
},
disableControls() {
return this.options.disableControls || false;
},
apiKey() {
if (!this.options.apiKey) this.error('An apiKey is required');
return this.options.apiKey;
},
hideMeta() {
return this.options.hideMeta;
},
hideAliases() {
return this.options.hideAliases;
},
endpoint() {
return `${this.hostname}/api/${this.apiVersion}/aliases?`
+ `sort=${this.sortBy}&filter[search]=${this.searchTerm}`
+ `&page[number]=${this.currentPage}&page[size]=${this.limit}`;
},
aliasCountEndpoint() {
return `${this.hostname}/api/${this.apiVersion}/aliases?filter[search]=${this.searchTerm}`;
},
accountInfoEndpoint() {
return `${this.hostname}/api/${this.apiVersion}/account-details`;
},
headers() {
return {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
Authorization: `Bearer ${this.apiKey}`,
};
},
paginationRange() {
const arrOfRange = (start, end) => Array(end - start + 1).fill().map((_, idx) => start + idx);
const maxNumbers = this.numPages > 10 ? 10 : this.numPages;
if (this.currentPage > maxNumbers) {
return arrOfRange(this.currentPage - maxNumbers, this.currentPage);
}
return arrOfRange(1, maxNumbers);
},
},
filters: {
formatDate(timestamp) {
return timestampToDate(timestamp);
},
formatTimeAgo(timestamp) {
return getTimeAgo(timestamp);
},
formatBytes(bytes) {
return convertBytes(bytes);
},
},
created() {
this.fetchAccountInfo();
},
methods: {
copyToClipboard(text) {
navigator.clipboard.writeText(text);
this.$toasted.show('Email address copied to clipboard');
},
fetchData() {
this.makeRequest(this.endpoint, this.headers).then(this.processData);
},
fetchAccountInfo() {
// Get account info
this.makeRequest(this.accountInfoEndpoint, this.headers).then(this.processAccountInfo);
// Get number of pages of results (in the most inefficient way possible...)
this.makeRequest(this.aliasCountEndpoint, this.headers).then((response) => {
this.numPages = Math.floor(response.data.length / this.limit);
});
},
processData(data) {
// this.numPages = 14; // data.meta.to;
this.currentPage = data.meta.current_page;
const aliases = [];
data.data.forEach((alias) => {
aliases.push({
id: alias.id,
active: alias.active,
domain: alias.domain,
email: alias.local_part,
recipients: alias.recipients,
description: alias.description,
forwardCount: alias.emails_forwarded,
blockedCount: alias.emails_blocked,
repliesCount: alias.emails_replied,
sentCount: alias.emails_sent,
createdAt: alias.created_at,
updatedAt: alias.updated_at,
deletedAt: alias.deleted_at,
fullEmail: alias.email,
});
});
this.aliases = aliases;
},
processAccountInfo(data) {
const res = data.data;
this.meta = {
name: data.username || res.from_name,
bandwidth: res.bandwidth,
bandwidthLimit: res.bandwidth_limit || 100000000,
activeDomains: res.active_domain_count,
activeDomainsLimit: res.active_domain_limit,
sharedDomains: res.active_shared_domain_alias_count,
sharedDomainsLimit: res.active_shared_domain_alias_limit,
usernamesCount: res.username_count,
usernamesLimit: res.username_limit,
forwardCount: res.total_emails_forwarded,
blockedCount: res.total_emails_blocked,
repliesCount: res.total_emails_replied,
sentCount: res.total_emails_sent,
};
},
toggleAlias(state, id) {
if (this.disableControls) {
this.$toasted.show('Error, controls disabled', { className: 'toast-error' });
} else {
const method = state ? 'POST' : 'DELETE';
const path = state ? 'active-aliases' : `active-aliases/${id}`;
const body = state ? { id } : {};
const endpoint = `${this.hostname}/api/${this.apiVersion}/${path}`;
this.makeRequest(endpoint, this.headers, method, body).then(() => {
const successMsg = `Alias successfully ${state ? 'enabled' : 'disabled'}`;
this.$toasted.show(successMsg, { className: 'toast-success' });
});
}
},
goToPage(page) {
this.progress.start();
this.currentPage = page;
this.fetchData();
},
goToFirst() {
this.goToPage(1);
},
goToLast() {
this.goToPage(this.numPages);
},
goToPrevious() {
if (this.currentPage > 1) this.goToPage(this.currentPage - 1);
},
goToNext() {
if (this.currentPage < this.numPages) this.goToPage(this.currentPage + 1);
},
},
};
</script>
<style scoped lang="scss">
@import '@/styles/style-helpers.scss';
.anonaddy-wrapper {
.account-info {
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
padding: 0.5rem;
.meta-item span {
font-size: 0.8rem;
margin: 0.25rem 0;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
font-family: var(--font-monospace);
&.lbl {
font-weight: bold;
margin-right: 0.25rem;
&::after { content: ':'; }
}
}
p.username {
margin: 0.25rem 0;
}
}
.email-list {
span.lbl {
&::after { content: ':'; }
}
span.val {
font-family: var(--font-monospace);
margin: 0 0.5rem 0 0.25rem;
}
.email-row {
color: var(--widget-text-color);
padding: 0.5rem 0;
.row-1 {
@extend .svg-button;
.address-copy {
cursor: copy;
display: inline;
}
span.txt-email {
font-weight: bold;
}
span.txt-at {
margin: 0 0.1rem;
opacity: var(--dimming-factor);
}
span.status {
font-size: 1.5rem;
line-height: 1rem;
margin-right: 0.25rem;
vertical-align: middle;
&.active { color: var(--success); }
&.inactive { color: var(--danger); }
}
.copy-btn {
float: right;
border: none;
color: var(--widget-text-color);
background: var(--widget-accent-color);
}
}
.row-2 {
max-width: 90%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: var(--dimming-factor);
span.description {
font-size: 0.8rem;
font-style: italic;
}
}
.row-3, .row-4 {
font-size: 0.8rem;
opacity: var(--dimming-factor);
}
.row-4 {
span.as-time-ago {
display: none;
}
}
&:hover {
.row-4 {
.as-date { display: none; }
.as-time-ago { display: inline; }
}
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
}
.pagination {
text-align: center;
p.page-status {
color: var(--widget-text-color);
opacity: var(--dimming-factor);
margin: 0.25rem 0;
font-size: 0.85rem;
font-family: var(--font-monospace);
}
span.page-num {
width: 1rem;
cursor: pointer;
padding: 0 0.15rem 0.1rem 0.15rem;
margin: 0;
color: var(--widget-text-color);
border-radius: 0.25rem;
border: 1px solid transparent;
display: inline-block;
&.selected {
font-weight: bold;
color: var(--widget-background-color);
background: var(--widget-text-color);
border: 1px solid var(--widget-background-color);
}
&:hover {
border: 1px solid var(--widget-text-color);
}
}
}
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div class="clock">
<div class="upper" v-if="!options.hideDate">
<p class="city">{{ timeZone | getCity }}</p>
<p class="city">{{ cityName }}</p>
<p class="date">{{ date }}</p>
</div>
<p class="time">{{ time }}</p>
@ -15,9 +15,9 @@ export default {
mixins: [WidgetMixin],
data() {
return {
timeUpdateInterval: null, // Stores setInterval function
time: null, // Current time string
date: null, // Current date string
timeUpdateInterval: null, // Stores setInterval function
};
},
computed: {
@ -31,11 +31,10 @@ export default {
if (this.options.format) return this.options.format;
return navigator.language;
},
},
filters: {
/* For a given time zone, return just the city name */
getCity(timeZone) {
return timeZone.split('/')[1].replaceAll('_', ' ');
/* Get city name from time-zone, or return users custom city name */
cityName() {
if (this.options.customCityName) return this.options.customCityName;
return this.timeZone.split('/')[1].replaceAll('_', ' ');
},
},
methods: {
@ -62,16 +61,11 @@ export default {
created() {
// Set initial date and time
this.update();
// Update the date every hour, and the time each second
this.timeUpdateInterval = setInterval(() => {
this.setTime();
const now = new Date();
if (now.getMinutes() === 0 && now.getSeconds() === 0) {
this.setDate();
}
}, 1000);
// Update the time and date every second (1000 ms)
this.timeUpdateInterval = setInterval(this.update, 1000);
},
beforeDestroy() {
// Remove the clock interval listener
clearInterval(this.timeUpdateInterval);
},
};
@ -103,6 +97,7 @@ export default {
font-size: 4rem;
padding: 0.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
font-family: Digital, var(--font-monospace);
}
}

View File

@ -0,0 +1,229 @@
<template>
<div class="covid-stats-wrapper">
<div class="basic-stats" v-if="basicStats">
<div class="active-cases stat-wrap">
<span class="lbl">Active Cases</span>
<span class="val">{{ basicStats.active | numberFormat }}</span>
</div>
<div class="more-stats">
<div class="stat-wrap">
<span class="lbl">Total Confirmed</span>
<span class="val total">{{ basicStats.cases | numberFormat }}</span>
</div>
<div class="stat-wrap">
<span class="lbl">Total Recovered</span>
<span class="val recovered">{{ basicStats.deaths | numberFormat }}</span>
</div>
<div class="stat-wrap">
<span class="lbl">Total Deaths</span>
<span class="val deaths">{{ basicStats.recovered | numberFormat }}</span>
</div>
</div>
</div>
<!-- Chart -->
<div class="case-history-chart" :id="chartId" v-if="showChart"></div>
<!-- Country Data -->
<div class="country-data" v-if="countryData">
<div class="country-row" v-for="country in countryData" :key="country.name">
<p class="name">
<img :src="country.flag" alt="Flag" class="flag" />
{{ country.name }}
</p>
<div class="country-case-wrap">
<div class="stat-wrap">
<span class="lbl">Confirmed</span>
<span class="val total">{{ country.cases | showInK }}</span>
</div>
<div class="stat-wrap">
<span class="lbl">Recovered</span>
<span class="val recovered">{{ country.recovered | showInK }}</span>
</div>
<div class="stat-wrap">
<span class="lbl">Deaths</span>
<span class="val deaths">{{ country.deaths | showInK }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { putCommasInBigNum, showNumAsThousand, timestampToDate } from '@/utils/MiscHelpers';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin, ChartingMixin],
computed: {
showChart() {
return this.options.showChart || false;
},
showCountries() {
if (this.options.countries) return true;
return this.options.showCountries;
},
numDays() {
return this.options.numDays || 120;
},
countries() {
return this.options.countries;
},
limit() {
return this.options.limit || 15;
},
basicStatsEndpoint() {
return `${widgetApiEndpoints.covidStats}/all`;
},
timeSeriesEndpoint() {
return `${widgetApiEndpoints.covidStats}/historical/all?lastdays=${this.numDays}`;
},
countryInfoEndpoint() {
return 'https://covidapi.yubrajpoudel.com.np/stat';
},
},
data() {
return {
basicStats: null,
countryData: null,
};
},
filters: {
numberFormat(caseNumber) {
return putCommasInBigNum(caseNumber);
},
showInK(caseNumber) {
return showNumAsThousand(caseNumber);
},
},
methods: {
fetchData() {
this.makeRequest(this.basicStatsEndpoint).then(this.processBasicStats);
if (this.showChart) {
this.makeRequest(this.timeSeriesEndpoint).then(this.processTimeSeries);
}
if (this.showCountries) {
this.makeRequest(this.countryInfoEndpoint).then(this.processCountryInfo);
}
},
processBasicStats(data) {
this.basicStats = data;
},
processCountryInfo(data) {
const countryData = [];
data.forEach((country) => {
const iso = country.countryInfo.iso3;
if (!this.countries || this.countries.includes(iso)) {
countryData.push({
name: country.country,
flag: country.countryInfo.flag,
cases: country.cases,
deaths: country.deaths,
recovered: country.recovered,
});
}
});
this.countryData = countryData.slice(0, this.limit);
},
processTimeSeries(data) {
const timeLabels = Object.keys(data.cases);
const totalCases = [];
const totalDeaths = [];
const totalRecovered = [];
timeLabels.forEach((date) => {
totalCases.push(data.cases[date]);
totalDeaths.push(data.deaths[date]);
totalRecovered.push(data.recovered[date]);
});
const chartData = {
labels: timeLabels,
datasets: [
{ name: 'Cases', type: 'bar', values: totalCases },
{ name: 'Recovered', type: 'bar', values: totalRecovered },
{ name: 'Deaths', type: 'bar', values: totalDeaths },
],
};
return new this.Chart(`#${this.chartId}`, {
title: 'Cases, Recoveries and Deaths',
data: chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: ['#f6f000', '#20e253', '#f80363'],
truncateLegends: true,
lineOptions: {
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => putCommasInBigNum(d),
formatTooltipX: d => timestampToDate(d),
},
});
},
},
};
</script>
<style scoped lang="scss">
.covid-stats-wrapper {
.basic-stats {
padding: 0.5rem 0;
margin: 0.5rem 0;
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
}
.country-row {
display: flex;
justify-content: space-between;
p.name {
display: flex;
align-items: center;
margin: 0.5rem 0;
color: var(--widget-text-color);
img.flag {
width: 2.5rem;
height: 1.5rem;
margin-right: 0.5rem;
border-radius: var(--curve-factor);
}
}
.country-case-wrap {
min-width: 60%;
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
.stat-wrap {
color: var(--widget-text-color);
display: flex;
flex-direction: column;
width: 33%;
margin: 0.25rem auto;
text-align: center;
cursor: default;
span.lbl {
font-size: 0.8rem;
opacity: var(--dimming-factor);
}
span.val {
font-weight: bold;
margin: 0.1rem 0;
font-family: var(--font-monospace);
&.total { color: var(--warning); }
&.recovered { color: var(--success); }
&.deaths { color: var(--danger); }
}
&.active-cases {
span.lbl { font-size: 1.1rem; }
span.val { font-size: 1.3rem; }
}
}
.more-stats, .country-case-wrap {
display: flex;
justify-content: space-around;
}
}
</style>

View File

@ -0,0 +1,228 @@
<template>
<div class="eth-gas-wrapper" v-if="gasCosts">
<!-- Current Prices -->
<p class="current-label">Current Gas Prices</p>
<div v-for="gasCost in gasCosts" :key="gasCost.name" class="gas-row">
<p class="time-name">{{ gasCost.name }}</p>
<div class="cost">
<span class="usd">${{ gasCost.usd }}</span>
<span class="gwei">{{ gasCost.gwei }} GWEI</span>
</div>
</div>
<!-- Current ETH Price -->
<div class="current-price">
<span class="label">Current ETH Price:</span>
<span class="price">{{ gasInfo.ethPrice }}</span>
</div>
<!-- Historical Chart -->
<p class="time-frame-label">Historical Gas Prices</p>
<div class="time-frame-selector">
<span
v-for="time in timeOptions"
:key="time.value"
@click="updateTimeFrame(time.value)"
:class="time.value === selectedTimeFrame ? 'selected' : ''"
>
{{ time.label }}
</span>
</div>
<div :id="chartId"></div>
<!-- Meta Info -->
<div v-if="gasInfo" class="gas-info">
<p>Last Updated: {{ gasInfo.lastUpdated }}</p>
<div class="sources">
Sources:
<a
v-for="source in gasInfo.sources"
:key="source.name"
:href="source.source"
v-tooltip="tooltip(`Average: ${source.standard || '[UNKNOWN]'} GWEI`)"
>{{ source.name }}</a>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToTime, roundPrice, putCommasInBigNum } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, ChartingMixin],
computed: {
numHours() {
return this.options.numHours || 24;
},
endpoint() {
const numHours = this.selectedTimeFrame || this.numHours;
return `${widgetApiEndpoints.ethGasHistory}?hours=${numHours}`;
},
},
data() {
return {
gasInfo: null,
gasCosts: null,
timeOptions: [
{ label: '6 hours', value: 6 },
{ label: '1 Day', value: 24 },
{ label: '1 Week', value: 168 },
{ label: '2 Weeks', value: 220 },
],
selectedTimeFrame: null,
};
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
this.makeRequest(widgetApiEndpoints.ethGasPrices).then(this.processPriceInfo);
this.makeRequest(this.endpoint).then(this.processHistoryData);
},
processPriceInfo(data) {
this.gasCosts = [
{ name: 'Slow', gwei: data.slow.gwei, usd: data.slow.usd },
{ name: 'Normal', gwei: data.normal.gwei, usd: data.normal.usd },
{ name: 'Fast', gwei: data.fast.gwei, usd: data.fast.usd },
{ name: 'Instant', gwei: data.instant.gwei, usd: data.instant.usd },
];
const sources = [];
data.sources.forEach((sourceInfo) => {
const { name, source, standard } = sourceInfo;
sources.push({ name, source, standard });
});
this.gasInfo = {
lastUpdated: timestampToTime(data.lastUpdated),
ethPrice: `$${putCommasInBigNum(roundPrice(data.ethPrice))}`,
sources,
};
},
processHistoryData(data) {
const chartData = {
labels: data.labels,
datasets: [
{ name: 'Slow', type: 'bar', values: data.slow },
{ name: 'Normal', type: 'bar', values: data.normal },
{ name: 'Fast', type: 'bar', values: data.fast },
{ name: 'Instant', type: 'bar', values: data.instant },
],
};
return new this.Chart(`#${this.chartId}`, {
title: 'Historical Transaction Costs',
data: chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: ['#ef476f', '#ffd166', '#118ab2', '#06d6a0'],
truncateLegends: true,
lineOptions: {
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${d} GWEI`,
},
});
},
updateTimeFrame(newNumHours) {
this.startLoading();
this.selectedTimeFrame = newNumHours;
this.fetchData();
},
},
mounted() {
this.selectedTimeFrame = this.numHours;
},
};
</script>
<style scoped lang="scss">
.eth-gas-wrapper {
p.current-label {
margin: 0.25rem 0;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
.gas-row {
display: flex;
vertical-align: middle;
justify-content: space-between;
color: var(--widget-text-color);
p.time-name {
margin: 0.25rem 0;
font-weight: bold;
font-size: 1.1rem;
}
.cost {
display: flex;
min-width: 10rem;
justify-content: space-between;
span {
font-family: var(--font-monospace);
margin: 0.5rem;
&.usd {
opacity: var(--dimming-factor);
}
}
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
.current-price {
color: var(--widget-text-color);
margin: 1rem 0 0.5rem;
span.label {
font-weight: bold;
margin-right: 0.5rem;
}
span.price {
font-family: var(--font-monospace);
}
}
.gas-info {
p, .sources {
margin: 0.5rem 0;
font-size: 0.8rem;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
font-family: var(--font-monospace);
}
.sources a {
color: var(--widget-text-color);
margin: 0 0.15rem;
}
}
p.time-frame-label {
display: inline-block;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
margin: 1rem 0.5rem 0.25rem 0;
font-size: 0.9rem;
}
.time-frame-selector {
display: inline-block;
margin: 0 0 0.25rem;
max-width: 20rem;
vertical-align: middle;
justify-content: space-evenly;
color: var(--widget-text-color);
font-size: 0.9rem;
span {
cursor: pointer;
padding: 0.1rem 0.25rem;
margin: 0 0.15rem;
border: 1px solid transparent;
border-radius: var(--curve-factor);
&.selected {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
&:hover {
border: 1px solid var(--widget-text-color);
}
}
}
}
</style>

View File

@ -0,0 +1,123 @@
<template>
<div class="glances-alerts-wrapper" v-if="alerts">
<div class="alert-row" v-for="(alert, index) in alerts" :key="index">
<p class="time" v-tooltip="tooltip(`${alert.timeAgo}<br>Lasted: ${alert.lasted}`, true)">
{{ alert.time }}
<span v-if="alert.ongoing" class="ongoing">Ongoing</span>
</p>
<div class="alert-info" v-tooltip="tooltip(alert.minMax, true)">
<span class="category">{{ alert.category }}</span> -
<span class="value">{{ alert.value }}%</span>
</div>
<p :class="`severity ${alert.severity.toLowerCase()}`">{{ alert.severity }}</p>
</div>
</div>
<div v-else-if="noResults" class="no-alerts">
<p class="no-alert-title">System is Healthy</p>
<p class="no-alert-info">There are no active alerts</p>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import { timestampToDateTime, getTimeAgo, getTimeDifference } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin],
data() {
return {
alerts: null,
noResults: false,
};
},
computed: {
endpoint() {
return this.makeGlancesUrl('alert');
},
},
filters: {},
methods: {
processData(alertData) {
if (!alertData || alertData.length === 0) {
this.noResults = true;
} else {
const alerts = [];
alertData.forEach((alert) => {
alerts.push({
time: timestampToDateTime(alert[0] * 1000),
ongoing: (alert[1] === -1),
timeAgo: getTimeAgo(alert[0] * 1000),
lasted: alert[1] ? getTimeDifference(alert[0] * 1000, alert[1] * 1000) : 'Ongoing',
severity: alert[2],
category: alert[3],
value: alert[5],
minMax: `Min: ${alert[4]}%<br>Avg: ${alert[5]}%<br>Max: ${alert[6]}%`,
});
});
this.alerts = alerts;
}
},
},
};
</script>
<style scoped lang="scss">
.glances-alerts-wrapper {
.alert-row {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--widget-text-color);
.time {
max-width: 25%;
margin: 0.25rem 0;
span.ongoing {
display: block;
padding: 0.25rem;
margin: 0.2rem 0;
font-weight: bold;
font-size: 0.85rem;
width: fit-content;
color: var(--error);
border: 1px solid var(--error);
border-radius: var(--curve-factor);
}
}
.alert-info {
.category {}
.value {
font-family: var(--font-monospace);
font-weight: bold;
}
}
.severity {
padding: 0.25rem;
font-size: 0.85rem;
border-radius: var(--curve-factor);
color: var(--black);
font-weight: bold;
cursor: default;
border: 1px solid var(--widget-text-color);
&.warning { color: var(--warning); border-color: var(--warning); }
&.critical { color: var(--danger); border-color: var(--danger); }
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
p.no-alert-title {
margin: 0.5rem 0;
font-weight: bold;
text-align: center;
color: var(--success);
}
p.no-alert-info {
margin: 0.5rem 0;
font-style: italic;
text-align: center;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
</style>

View File

@ -0,0 +1,62 @@
<template>
<div class="glances-cpu-cores-wrapper">
<div class="percentage-charts" v-for="(chartData, index) in cpuChartData" :key="index">
<PercentageChart :values="chartData" :title="`Core #${index + 1}`" />
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import { capitalize } from '@/utils/MiscHelpers';
import PercentageChart from '@/components/Charts/PercentageChart';
export default {
mixins: [WidgetMixin, GlancesMixin],
components: {
PercentageChart,
},
data() {
return {
cpuChartData: null,
};
},
computed: {
endpoint() {
return this.makeGlancesUrl('quicklook');
},
},
created() {
// Enable automatic updates (won't be applied if user has explicitly disabled)
this.overrideUpdateInterval = 2;
},
methods: {
/* Converts returned data into format for the percentage charts */
processData(cpuData) {
const cpuSections = [];
cpuData.percpu.forEach((cpuInfo) => {
const cpuSection = [];
const ignore = ['total', 'key', 'cpu_number', 'idle'];
cpuSection.push({ label: 'Idle', size: cpuInfo.idle, color: '#20e253' });
Object.keys(cpuInfo).forEach((keyName) => {
if (!ignore.includes(keyName) && cpuInfo[keyName]) {
cpuSection.push({ label: capitalize(keyName), size: cpuInfo[keyName] });
}
});
cpuSections.push(cpuSection);
});
this.cpuChartData = cpuSections;
},
},
};
</script>
<style scoped lang="scss">
.glances-cpu-cores-wrapper {
color: var(--widget-text-color);
.percentage-charts:not(:last-child) {
border-bottom: 1px dashed var(--widget-accent-color);
}
}
</style>

View File

@ -0,0 +1,131 @@
<template>
<div class="glances-cpu-gauge-wrapper">
<GaugeChart :value="gaugeValue" :baseColor="background" :gaugeColor="gaugeColor">
<p class="percentage">{{ gaugeValue }}%</p>
</GaugeChart>
<p class="show-more-btn" @click="toggleMoreInfo">
{{ showMoreInfo ? $t('widgets.general.show-less') : $t('widgets.general.show-more') }}
</p>
<div class="more-info" v-if="moreInfo && showMoreInfo">
<div class="more-info-row" v-for="(info, key) in moreInfo" :key="key">
<p class="label">{{ info.label }}</p>
<p class="value">{{ info.value }}</p>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import GaugeChart from '@/components/Charts/Gauge';
import { getValueFromCss, capitalize } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin],
components: {
GaugeChart,
},
data() {
return {
gaugeValue: 0,
gaugeColor: '#272f4d',
showMoreInfo: false,
moreInfo: null,
background: '#fff',
};
},
computed: {
endpoint() {
return this.makeGlancesUrl('cpu');
},
},
methods: {
processData(cpuData) {
this.gaugeValue = cpuData.total;
this.gaugeColor = this.getColor(cpuData.total);
const moreInfo = [];
const ignore = ['total', 'cpucore', 'time_since_update',
'interrupts', 'soft_interrupts', 'ctx_switches', 'syscalls'];
Object.keys(cpuData).forEach((key) => {
if (!ignore.includes(key) && cpuData[key]) {
moreInfo.push({ label: capitalize(key), value: `${cpuData[key].toFixed(1)}%` });
}
});
this.moreInfo = moreInfo;
},
toggleMoreInfo() {
this.showMoreInfo = !this.showMoreInfo;
},
getColor(cpuPercent) {
if (cpuPercent < 50) return '#20e253';
if (cpuPercent < 60) return '#f6f000';
if (cpuPercent < 80) return '#fca016';
if (cpuPercent < 100) return '#f80363';
return '#272f4d';
},
},
created() {
this.overrideUpdateInterval = 2;
},
mounted() {
this.background = getValueFromCss('widget-accent-color');
},
};
</script>
<style scoped lang="scss">
.glances-cpu-gauge-wrapper {
max-width: 18rem;
margin: 0.5rem auto;
p.percentage {
color: var(--widget-text-color);
text-align: center;
position: absolute;
font-size: 1.3rem;
margin: 0.5rem 0;
width: 100%;
bottom: 0;
}
.more-info {
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
padding: 0.25rem 0.5rem;
margin: 0.5rem auto;
.more-info-row {
display: flex;
justify-content: space-between;
align-items: center;
p.label, p.value {
color: var(--widget-text-color);
margin: 0.25rem 0;
}
p.value {
font-family: var(--font-monospace);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
p.show-more-btn {
cursor: pointer;
font-size: 0.9rem;
text-align: center;
width: fit-content;
margin: 0.25rem auto;
padding: 0.1rem 0.25rem;
border: 1px solid transparent;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
border-radius: var(--curve-factor);
&:hover {
border: 1px solid var(--widget-text-color);
}
&:focus, &:active {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
}
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<div class="glances-cpu-history-wrapper">
<div class="gl-history-chart" :id="chartId"></div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { timestampToTime, getTimeAgo } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin, ChartingMixin],
components: {},
data() {
return {};
},
computed: {
limit() {
return this.options.limit || 100;
},
endpoint() {
return this.makeGlancesUrl(`cpu/history/${this.limit}`);
},
},
methods: {
processData(cpuData) {
const { system, user } = cpuData;
const labels = [];
const systemValues = [];
const userValues = [];
system.forEach((dataPoint) => {
labels.push(timestampToTime(dataPoint[0]));
systemValues.push(dataPoint[1]);
});
user.forEach((dataPoint) => {
userValues.push(dataPoint[1]);
});
const chartTitle = this.makeTitle(system);
const datasets = [
{ name: 'System', type: 'bar', values: systemValues },
{ name: 'User', type: 'bar', values: userValues },
];
this.generateChart({ labels, datasets }, chartTitle);
},
makeTitle(system) {
return `CPU Usage over past ${getTimeAgo(system[0][0]).replace('ago', '')}`;
},
generateChart(timeChartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, {
title: chartTitle,
data: timeChartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: ['#9b5de5', '#00f5d4'],
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}%`,
// formatTooltipX: d => timestampToTime(d),
},
});
},
},
created() {
this.overrideUpdateInterval = 20;
},
};
</script>
<style scoped lang="scss">
.glances-cpu-history-wrapper {
.gl-history-chart {}
}
</style>

View File

@ -0,0 +1,121 @@
<template>
<div class="glances-disk-io-wrapper" v-if="disks">
<div class="disk-row" v-for="disk in disks" :key="disk.name">
<p class="disk-name">{{ disk.name }}</p>
<!-- Read Data -->
<div class="io-data read" v-tooltip="disk.readC ? `Count: ${disk.readC}` : ''">
<span class="lbl">{{ $t('widgets.glances.disk-io-read') }}:</span>
<span class="val">{{ disk.readB | formatSize }}</span>
<span :class="`direction ${disk.readD}`">{{ disk.readD | getArrow }}</span>
</div>
<!-- Write Data -->
<div class="io-data write" v-tooltip="disk.writeC ? `Count: ${disk.writeC}` : ''">
<span class="lbl">{{ $t('widgets.glances.disk-io-write') }}:</span>
<span class="val">{{ disk.writeB | formatSize }}</span>
<span :class="`direction ${disk.writeD}`">{{ disk.writeD | getArrow }}</span>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import { convertBytes } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin],
data() {
return {
disks: null,
previous: null,
};
},
computed: {
endpoint() {
return this.makeGlancesUrl('diskio');
},
},
filters: {
formatSize(byteValue) {
if (!byteValue) return 'Idle';
return `${convertBytes(byteValue)}/s`;
},
getArrow(direction) {
if (direction === 'up') return '↑';
if (direction === 'down') return '↓';
return '';
},
},
methods: {
processData(diskData) {
this.previous = this.disks;
const disks = [];
diskData.forEach((disk, index) => {
disks.push({
name: disk.disk_name,
readB: disk.read_bytes,
readC: disk.read_count,
readD: this.comparePrevious('read', disk.read_bytes, index),
writeB: disk.write_bytes,
writeC: disk.write_count,
writeD: this.comparePrevious('write', disk.write_bytes, index),
});
});
this.disks = disks;
},
/* Compares previous values with current data */
comparePrevious(direction, newVal, diskIndex) {
if (!this.previous || !this.previous[diskIndex]) return 'none';
const disk = this.previous[diskIndex];
const previousVal = direction === 'read' ? disk.readB : disk.writeB;
if (newVal === 0) return 'reset';
if (newVal === previousVal) return 'same';
if (newVal > previousVal) return 'up';
if (newVal < previousVal) return 'down';
return 'none';
},
},
created() {
this.overrideUpdateInterval = 1;
},
};
</script>
<style scoped lang="scss">
.glances-disk-io-wrapper {
color: var(--widget-text-color);
.disk-row {
display: flex;
flex-direction: column;
padding: 0.5rem 0;
p.disk-name {
margin: 0;
font-weight: bold;
color: var(--widget-text-color);
}
.io-data {
span.lbl {
margin-right: 0.5rem;
}
span.val {
font-family: var(--font-monospace);
}
span.second-val {
margin: 0 0.5rem;
opacity: var(--dimming-factor);
}
span.direction {
padding: 0 0.2rem;
font-weight: bold;
font-size: 1.2rem;
&.up { color: var(--success); }
&.down { color: var(--warning); }
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>

View File

@ -0,0 +1,76 @@
<template>
<div class="glances-disk-space-wrapper" v-if="disks">
<div v-for="(disk, key) in disks" :key="key" class="disk-row">
<PercentageChart :title="disk.device_name"
:values="[
{ label: $t('widgets.glances.disk-space-used'), size: disk.percent, color: '#f80363' },
{ label: $t('widgets.glances.disk-space-free'), size: 100 - disk.percent, color: '#20e253' },
]" />
<p class="info">
<b>{{ $t('widgets.glances.disk-space-free') }}</b>:
{{ disk.used | formatSize }} out of {{ disk.size | formatSize }}
</p>
<p class="info"><b>{{ $t('widgets.glances.disk-mount-point') }}</b>: {{ disk.mnt_point }}</p>
<p class="info"><b>{{ $t('widgets.glances.disk-file-system') }}</b>: {{ disk.fs_type }}</p>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import PercentageChart from '@/components/Charts/PercentageChart';
import { getValueFromCss, convertBytes } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin],
components: {
PercentageChart,
},
data() {
return {
disks: null,
};
},
computed: {
endpoint() {
return this.makeGlancesUrl('fs');
},
},
filters: {
formatSize(byteValue) {
return convertBytes(byteValue);
},
},
methods: {
fetchData() {
this.makeRequest(this.endpoint).then(this.processData);
},
processData(diskData) {
this.disks = diskData;
},
},
mounted() {
this.background = getValueFromCss('widget-accent-color');
},
};
</script>
<style scoped lang="scss">
.glances-disk-space-wrapper {
color: var(--widget-text-color);
.disk-row {
padding: 0.25rem 0 0.5rem 0;
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
p.info {
font-size: 0.8rem;
margin: 0.25rem 0;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
font-family: var(--font-monospace);
}
}
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<div class="glances-load-history-wrapper">
<div class="gl-history-chart" :id="chartId"></div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { timestampToTime, getTimeAgo } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin, ChartingMixin],
components: {},
data() {
return {};
},
computed: {
limit() {
return this.options.limit || 500;
},
endpoint() {
return this.makeGlancesUrl(`load/history/${this.limit}`);
},
},
methods: {
processData(loadData) {
const labels = [];
const min1 = [];
const min5 = [];
const min15 = [];
loadData.min1.forEach((dataPoint) => {
labels.push(timestampToTime(dataPoint[0]));
min1.push(dataPoint[1]);
});
loadData.min5.forEach((dataPoint) => {
min5.push(dataPoint[1]);
});
loadData.min15.forEach((dataPoint) => {
min15.push(dataPoint[1]);
});
const chartTitle = this.makeTitle(loadData.min1);
const datasets = [
{ name: '1 Minute', type: 'bar', values: min1 },
{ name: '5 Minutes', type: 'bar', values: min5 },
{ name: '15 Minutes', type: 'bar', values: min15 },
];
this.generateChart({ labels, datasets }, chartTitle);
},
makeTitle(system) {
return `System Load over past ${getTimeAgo(system[0][0]).replace('ago', '')}`;
},
generateChart(timeChartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, {
title: chartTitle,
data: timeChartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${d} Processes`,
// formatTooltipX: d => timestampToTime(d),
},
});
},
},
created() {
this.overrideUpdateInterval = 20;
},
};
</script>
<style scoped lang="scss">
.glances-load-history-wrapper {
.gl-history-chart {}
}
</style>

View File

@ -0,0 +1,130 @@
<template>
<div class="glances-cpu-gauge-wrapper">
<GaugeChart :value="gaugeValue" :baseColor="background" :gaugeColor="gaugeColor">
<p class="percentage">{{ gaugeValue }}%</p>
</GaugeChart>
<p class="show-more-btn" @click="toggleMoreInfo">
{{ showMoreInfo ? $t('widgets.general.show-less') : $t('widgets.general.show-more') }}
</p>
<div class="more-info" v-if="moreInfo && showMoreInfo">
<div class="more-info-row" v-for="(info, key) in moreInfo" :key="key">
<p class="label">{{ info.label }}</p>
<p class="value">{{ info.value }}</p>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import GaugeChart from '@/components/Charts/Gauge';
import { getValueFromCss, capitalize, convertBytes } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin],
components: {
GaugeChart,
},
data() {
return {
gaugeValue: 0,
gaugeColor: '#272f4d',
showMoreInfo: false,
moreInfo: null,
background: '#fff',
};
},
computed: {
endpoint() {
return this.makeGlancesUrl('mem');
},
},
methods: {
processData(memData) {
this.gaugeValue = memData.percent;
this.gaugeColor = this.getColor(memData.percent);
const moreInfo = [];
const ignore = ['percent'];
Object.keys(memData).forEach((key) => {
if (!ignore.includes(key) && memData[key]) {
moreInfo.push({ label: capitalize(key), value: convertBytes(memData[key]) });
}
});
this.moreInfo = moreInfo;
},
toggleMoreInfo() {
this.showMoreInfo = !this.showMoreInfo;
},
getColor(memPercent) {
if (memPercent < 50) return '#20e253';
if (memPercent < 60) return '#f6f000';
if (memPercent < 80) return '#fca016';
if (memPercent < 100) return '#f80363';
return '#272f4d';
},
},
created() {
this.overrideUpdateInterval = 2;
},
mounted() {
this.background = getValueFromCss('widget-accent-color');
},
};
</script>
<style scoped lang="scss">
.glances-cpu-gauge-wrapper {
max-width: 18rem;
margin: 0.5rem auto;
p.percentage {
color: var(--widget-text-color);
text-align: center;
position: absolute;
font-size: 1.3rem;
margin: 0.5rem 0;
width: 100%;
bottom: 0;
}
.more-info {
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
padding: 0.25rem 0.5rem;
margin: 0.5rem auto;
.more-info-row {
display: flex;
justify-content: space-between;
align-items: center;
p.label, p.value {
color: var(--widget-text-color);
margin: 0.25rem 0;
}
p.value {
font-family: var(--font-monospace);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
p.show-more-btn {
cursor: pointer;
font-size: 0.9rem;
text-align: center;
width: fit-content;
margin: 0.25rem auto;
padding: 0.1rem 0.25rem;
border: 1px solid transparent;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
border-radius: var(--curve-factor);
&:hover {
border: 1px solid var(--widget-text-color);
}
&:focus, &:active {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
}
}
</style>

View File

@ -0,0 +1,79 @@
<template>
<div class="glances-cpu-history-wrapper">
<div class="gl-history-chart" :id="chartId"></div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { timestampToTime, getTimeAgo } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin, ChartingMixin],
components: {},
data() {
return {};
},
computed: {
limit() {
return this.options.limit || 100;
},
endpoint() {
return this.makeGlancesUrl(`mem/history/${this.limit}`);
},
},
methods: {
processData(memData) {
const readings = memData.percent;
const labels = [];
const systemValues = [];
readings.forEach((dataPoint) => {
labels.push(timestampToTime(dataPoint[0]));
systemValues.push(dataPoint[1]);
});
const chartTitle = this.makeTitle(readings);
const datasets = [
{ name: 'Memory', type: 'bar', values: systemValues },
];
this.generateChart({ labels, datasets }, chartTitle);
},
makeTitle(system) {
return `Memory Usage over past ${getTimeAgo(system[0][0]).replace('ago', '')}`;
},
generateChart(timeChartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, {
title: chartTitle,
data: timeChartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}%`,
},
});
},
},
created() {
this.overrideUpdateInterval = 20;
},
};
</script>
<style scoped lang="scss">
.glances-cpu-history-wrapper {
.gl-history-chart {}
}
</style>

View File

@ -0,0 +1,151 @@
<template>
<div class="glances-network-interfaces-wrapper" v-if="networks">
<div class="interface-row" v-for="network in networks" :key="network.name">
<div class="network-info">
<p class="network-name">{{ network.name }}</p>
<p class="network-speed">{{ network.speed | formatSpeed }}</p>
<p :class="`network-online ${network.online}`">
{{ network.online }}
</p>
</div>
<div class="current" v-if="network.online === 'up'">
<span class="upload">
<span class="val">{{ network.currentUpload | formatDataSize }}</span>
</span>
<span class="separator">|</span>
<span class="download">
<span class="val">{{ network.currentDownload | formatDataSize }}</span>
</span>
</div>
<div class="total">
<b class="lbl">Total</b> Up
<span class="val">{{ network.totalUpload | formatDataSize }}</span>
<span class="separator">|</span>
Down
<span class="val">{{ network.totalDownload | formatDataSize }}</span>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import { convertBytes } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin],
data() {
return {
networks: null,
previous: null,
};
},
computed: {
endpoint() {
return this.makeGlancesUrl('network');
},
},
filters: {
formatDataSize(data) {
return convertBytes(data);
},
formatSpeed(byteValue) {
if (!byteValue) return '';
return `${convertBytes(byteValue)}/s`;
},
getArrow(direction) {
if (direction === 'up') return '↑';
if (direction === 'down') return '↓';
return '';
},
},
methods: {
processData(networkData) {
this.previous = this.disks;
const networks = [];
networkData.forEach((network, index) => {
networks.push({
name: network.interface_name,
speed: network.speed,
online: network.is_up ? 'up' : 'down',
currentDownload: network.rx,
currentUpload: network.tx,
totalDownload: network.cumulative_rx,
totalUpload: network.cumulative_tx,
changeDownload: this.previous && network.rx > this.previous[index].rx,
changeUpload: this.previous && network.tx > this.previous[index].tx,
});
});
this.networks = networks;
},
},
created() {
this.overrideUpdateInterval = 5;
},
};
</script>
<style scoped lang="scss">
.glances-network-interfaces-wrapper {
color: var(--widget-text-color);
.interface-row {
display: flex;
flex-direction: column;
padding: 0.5rem 0;
.network-info {
display: flex;
justify-content: space-between;
.network-name {
width: 50%;
margin: 0.5rem 0;
overflow: hidden;
font-weight: bold;
white-space: nowrap;
text-overflow: ellipsis;
}
.network-speed {
font-family: var(--font-monospace);
margin: 0.5rem 0;
}
.network-online {
min-width: 15%;
margin: 0.5rem 0;
font-weight: bold;
text-align: right;
text-transform: capitalize;
&.up { color: var(--success); }
&.down { color: var(--danger); }
}
}
.total, .current {
display: inline;
margin: 0.25rem 0;
b.lbl {
margin-right: 0.25rem;
}
span.val {
margin-left: 0.25rem;
font-family: var(--font-monospace);
}
span.separator {
font-weight: bold;
margin: 0 0.5rem;
}
&.total {
opacity: var(--dimming-factor);
font-size: 0.85rem;
}
&.current {
text-align: center;
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
padding: 0.2rem 0.5rem;
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>

View File

@ -0,0 +1,107 @@
<template>
<div class="glances-cpu-history-wrapper">
<div class="gl-history-chart" :id="chartId"></div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { convertBytes, getTimeAgo, timestampToTime } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin, ChartingMixin],
components: {},
data() {
return {};
},
computed: {
limit() {
return this.options.limit || 100;
},
endpoint() {
return this.makeGlancesUrl(`network/history/${this.limit}`);
},
},
methods: {
processData(trafficData) {
const preliminary = {
upload: [],
download: [],
};
/* eslint-disable prefer-destructuring */
Object.keys(trafficData).forEach((keyName) => {
let upOrDown = null;
if (keyName.includes('_tx')) upOrDown = 'up';
else if (keyName.includes('_rx')) upOrDown = 'down';
trafficData[keyName].forEach((dataPoint) => {
const dataTime = this.getRoundedTime(dataPoint[0]);
if (upOrDown === 'up') {
if (preliminary.upload[dataTime]) preliminary.upload[dataTime] += dataPoint[1];
else preliminary.upload[dataTime] = dataPoint[1];
} else if (upOrDown === 'down') {
if (preliminary.download[dataTime]) preliminary.download[dataTime] += dataPoint[1];
else preliminary.download[dataTime] = dataPoint[1];
}
});
});
const timeLabels = [];
const uploadData = [];
const downloadData = [];
const startDate = Object.keys(preliminary.upload)[0];
Object.keys(preliminary.upload).forEach((date) => {
timeLabels.push(timestampToTime(date));
uploadData.push(preliminary.upload[date]);
});
Object.keys(preliminary.download).forEach((date) => {
downloadData.push(preliminary.download[date]);
});
const datasets = [
{ name: 'Upload', type: 'bar', values: uploadData },
{ name: 'Download', type: 'bar', values: downloadData },
];
const chartTitle = this.makeTitle(startDate);
this.generateChart({ labels: timeLabels, datasets }, chartTitle);
},
getRoundedTime(date) {
const roundTo = 1000 * 60;
return new Date(Math.round(new Date(date).getTime() / roundTo) * roundTo);
},
makeTitle(startDate) {
return `Network Activity over past ${getTimeAgo(startDate).replace('ago', '')}`;
},
generateChart(timeChartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, {
title: chartTitle,
data: timeChartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: ['#f6f000', '#04e4f4'],
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => convertBytes(d),
},
});
},
},
created() {
this.overrideUpdateInterval = 10;
},
};
</script>
<style scoped lang="scss">
.glances-cpu-history-wrapper {
.gl-history-chart {}
}
</style>

View File

@ -0,0 +1,62 @@
<template>
<div class="glances-load-wrapper">
<div
:id="`load-${chartId}`" class="load-chart"
v-tooltip="$t('widgets.glances.system-load-desc')"></div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, GlancesMixin, ChartingMixin],
computed: {
endpoint() {
return this.makeGlancesUrl('load');
},
},
methods: {
processData(loadData) {
const chartData = {
labels: ['1 Min', '5 Mins', '15 Mins'],
datasets: [
{ values: [loadData.min1, loadData.min5, loadData.min15] },
],
};
const chartTitle = `Load Averages over ${loadData.cpucore} Cores`;
this.renderChart(chartData, chartTitle);
},
renderChart(loadBarChartData, chartTitle) {
return new this.Chart(`#load-${this.chartId}`, {
title: chartTitle,
data: loadBarChartData,
type: 'bar',
height: 180,
colors: ['#04e4f4'],
barOptions: {
spaceRatio: 0.2,
},
tooltipOptions: {
formatTooltipY: d => `${d} Tasks`,
},
});
},
},
};
</script>
<style scoped lang="scss">
.glances-load-wrapper {
p.desc {
color: var(--widget-text-color);
opacity: var(--dimming-factor);
font-size: 0.8rem;
margin: 0;
visibility: hidden;
}
&:hover p.desc { visibility: visible; }
}
</style>

View File

@ -46,9 +46,8 @@
</template>
<script>
import axios from 'axios';
import { serviceEndpoints } from '@/utils/defaults';
import { showNumAsThousand } from '@/utils/MiscHelpers';
import { showNumAsThousand, getTimeAgo } from '@/utils/MiscHelpers';
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
@ -77,21 +76,8 @@ export default {
},
methods: {
fetchData() {
const requestConfig = {
method: 'GET',
url: this.proxyReqEndpoint,
headers: {
'Target-URL': this.endpoint,
},
};
axios.request(requestConfig)
.then((response) => {
this.processData(response.data);
}).catch((error) => {
this.error('Unable to fetch cron data', error);
}).finally(() => {
this.finishLoading();
});
this.overrideProxyChoice = true;
this.makeRequest(this.endpoint).then(this.processData);
},
makeChartUrl(uptime, title) {
const host = 'https://quickchart.io';
@ -110,19 +96,6 @@ export default {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'ping-times-tt',
};
},
getTimeAgo(dateTime) {
const now = new Date().getTime();
const then = new Date(dateTime).getTime();
if (then < 0) return 'Never';
const diff = (now - then) / 1000;
const divide = (time, round) => Math.round(time / round);
if (diff < 60) return `${divide(diff, 1)} seconds ago`;
if (diff < 3600) return `${divide(diff, 60)} minutes ago`;
if (diff < 86400) return `${divide(diff, 3600)} hours ago`;
if (diff < 604800) return `${divide(diff, 86400)} days ago`;
if (diff >= 604800) return `${divide(diff, 604800)} weeks ago`;
return 'unknown';
},
processData(data) {
let services = [];
data.forEach((service) => {
@ -134,8 +107,8 @@ export default {
responseTime: Math.round(service.avg_response / 1000),
totalSuccess: showNumAsThousand(service.stats.hits),
totalFailure: showNumAsThousand(service.stats.failures),
lastSuccess: this.getTimeAgo(service.last_success),
lastFailure: this.getTimeAgo(service.last_error),
lastSuccess: getTimeAgo(service.last_success),
lastFailure: getTimeAgo(service.last_error),
});
});
if (this.limit) services = services.slice(0, this.limit);

View File

@ -0,0 +1,228 @@
<template>
<div class="wallet-balance-wrapper">
<p class="wallet-title">{{ getCoinNameFromSymbol(coin) }} Wallet</p>
<a v-if="metaInfo" :href="metaInfo.explorer" class="wallet-address">{{ address }}</a>
<div class="balance-inner">
<img v-if="metaInfo" :src="metaInfo.qrCode" alt="QR Code" class="wallet-qr" />
<div v-if="balances" class="balances-section">
<p class="main-balance" v-tooltip="makeBalanceTooltip(balances)">{{ balances.current }}</p>
<div class="balance-info">
<div class="balance-info-row">
<span class="label">Total In</span>
<span class="amount">+ {{ balances.totalReceived }}</span>
</div>
<div class="balance-info-row">
<span class="label">Total Out:</span>
<span class="amount">- {{ balances.totalSent }}</span>
</div>
<div class="balance-info-row">
<span class="label">Last Activity:</span>
<span class="amount">{{ balances.lastTransaction }}</span>
</div>
</div>
</div>
</div>
<div class="transactions" v-if="transactions">
<p class="transactions-title">Recent Transactions</p>
<a class="transaction-row"
v-for="transaction in transactions"
:key="transaction.hash"
:href="transaction.url"
v-tooltip="makeTransactionTooltip(transaction)"
>
<span class="date">{{ transaction.date }}</span>
<span :class="`amount ${transaction.incoming ? 'in' : 'out'}`">
{{ transaction.incoming ? '+' : '-'}}{{ transaction.amount }}
</span>
</a>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToDate, timestampToTime, getTimeAgo } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
computed: {
coin() {
if (!this.options.coin) this.error('You must specify a coin, e.g. \'BTC\'');
return this.options.coin.toLowerCase();
},
address() {
if (!this.options.address) this.error('You must specify a public address');
return this.options.address;
},
network() {
return this.options.network || 'main';
},
limit() {
return this.options.limit || 10;
},
endpoint() {
return `${widgetApiEndpoints.walletBalance}/`
+ `${this.coin}/${this.network}/addrs/${this.address}`;
},
divisionFactor() {
switch (this.coin) {
case ('btc'): return 100000000;
case ('eth'): return 1000000000000000000;
default: return 1;
}
},
},
data() {
return {
balances: null,
metaInfo: null,
transactions: null,
};
},
methods: {
fetchData() {
this.makeRequest(this.endpoint).then(this.processData);
},
processData(data) {
const formatAmount = (amount) => {
const symbol = this.coin.toUpperCase();
if (!amount) return `0 ${symbol}`;
return `${(amount / this.divisionFactor).toFixed(6)} ${symbol}`;
};
this.balances = {
current: formatAmount(data.balance),
unconfirmed: formatAmount(data.unconfirmed_balance),
final: formatAmount(data.final_balance),
totalSent: formatAmount(data.total_sent),
totalReceived: formatAmount(data.total_received),
lastTransaction: data.txrefs[0] ? getTimeAgo(data.txrefs[0].confirmed) : 'Never',
};
const transactions = [];
data.txrefs.forEach((transaction) => {
transactions.push({
hash: transaction.tx_hash,
amount: formatAmount(transaction.value),
date: timestampToDate(transaction.confirmed),
time: timestampToTime(transaction.confirmed),
confirmations: transaction.confirmations,
blockHeight: transaction.block_height,
balance: formatAmount(transaction.ref_balance),
incoming: transaction.tx_input_n === -1,
url: `https://live.blockcypher.com/${this.coin}/tx/${transaction.tx_hash}/`,
});
});
this.transactions = transactions.slice(0, this.limit);
},
getCoinNameFromSymbol(symbol) {
const coins = {
btc: 'Bitcoin',
dash: 'Dash',
doge: 'Doge',
ltc: 'Litecoin',
eth: 'Ethereum',
bhc: 'BitcoinCash',
xmr: 'Monero',
ada: 'Cardano',
bcy: 'BlockCypher',
};
if (!symbol || !Object.keys(coins).includes(symbol.toLowerCase())) return '';
return coins[symbol.toLowerCase()];
},
makeBalanceTooltip(balances) {
return this.tooltip(
`<b>Unconfirmed:</b> ${balances.unconfirmed}<br><b>Final:</b> ${balances.final}`,
true,
);
},
makeTransactionTooltip(transaction) {
return this.tooltip(
`At ${transaction.time}<br>`
+ `<b>BlockHeight:</b> ${transaction.blockHeight}<br>`
+ `<b>Confirmations:</b> ${transaction.confirmations}<br>`
+ `<b>Balance After:</b> ${transaction.balance}`,
true,
);
},
makeMetaInfo() {
const explorer = `https://live.blockcypher.com/${this.coin}/address/${this.address}/`;
const coin = this.getCoinNameFromSymbol(this.coin).toLowerCase();
const qrCode = `${widgetApiEndpoints.walletQrCode}/`
+ `?style=${coin.toLowerCase()}&color=11&address=${this.address}`;
return { explorer, coin, qrCode };
},
},
mounted() {
this.metaInfo = this.makeMetaInfo();
},
};
</script>
<style scoped lang="scss">
.wallet-balance-wrapper {
max-width: 30rem;
margin: 0 auto;
a.wallet-address {
display: block;
margin: 0.5rem 0;
overflow: hidden;
text-overflow: ellipsis;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
font-family: var(--font-monospace);
}
.balance-inner {
display: flex;
justify-content: space-around;
img.wallet-qr {
max-width: 7rem;
margin: 0.5rem 0;
border-radius: var(--curve-factor);
}
.balances-section {
p {
color: var(--widget-text-color);
font-family: var(--font-monospace);
cursor: default;
margin: 0.5rem;
}
p.main-balance {
font-size: 1.5rem;
}
.balance-info .balance-info-row {
opacity: var(--dimming-factor);
color: var(--widget-text-color);
display: flex;
justify-content: space-between;
font-size: 0.8rem;
margin: 0.2rem 0.5rem;
span.amount {
font-family: var(--font-monospace);
}
}
}
}
p.wallet-title, p.transactions-title {
color: var(--widget-text-color);
margin: 0.5rem 0 0.25rem;
font-size: 1.2rem;
font-weight: bold;
}
.transactions .transaction-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
text-decoration: none;
span {
color: var(--widget-text-color);
font-family: var(--font-monospace);
}
span.amount {
&.in { color: var(--success); }
&.out { color: var(--danger); }
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
}
</style>

View File

@ -23,7 +23,6 @@
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
@ -39,6 +38,9 @@ export default {
weatherDetails: [],
};
},
mounted() {
this.checkProps();
},
computed: {
units() {
return this.options.units || 'metric';
@ -63,34 +65,21 @@ export default {
},
},
methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.startLoading();
this.fetchWeather();
},
/* Adds units symbol to temperature, depending on metric or imperial */
processTemp(temp) {
return `${Math.round(temp)}${this.tempDisplayUnits}`;
},
fetchData() {
this.makeRequest(this.endpoint).then(this.processData);
},
/* Fetches the weather from OpenWeatherMap, and processes results */
fetchWeather() {
axios.get(this.endpoint)
.then((response) => {
this.loading = false;
const { data } = response;
this.icon = data.weather[0].icon;
this.description = data.weather[0].description;
this.temp = this.processTemp(data.main.temp);
if (!this.options.hideDetails) {
this.makeWeatherData(data);
}
})
.catch((error) => {
this.throwError('Failed to fetch weather', error);
})
.finally(() => {
this.finishLoading();
});
processData(data) {
this.icon = data.weather[0].icon;
this.description = data.weather[0].description;
this.temp = this.processTemp(data.main.temp);
if (!this.options.hideDetails) {
this.makeWeatherData(data);
}
},
/* If showing additional info, then generate this data too */
makeWeatherData(data) {
@ -116,30 +105,12 @@ export default {
/* Validate input props, and print warning if incorrect */
checkProps() {
const ops = this.options;
let valid = true;
if (!ops.apiKey) {
this.throwError('Missing API key for OpenWeatherMap');
valid = false;
}
if (!ops.city) {
this.throwError('A city name is required to fetch weather');
valid = false;
}
if (!ops.apiKey) this.error('Missing API key for OpenWeatherMap');
if (!ops.city) this.error('A city name is required to fetch weather');
if (ops.units && ops.units !== 'metric' && ops.units !== 'imperial') {
this.throwError('Invalid units specified, must be either \'metric\' or \'imperial\'');
valid = false;
this.error('Invalid units specified, must be either \'metric\' or \'imperial\'');
}
return valid;
},
/* Just outputs an error message */
throwError(msg, error) {
this.error(msg, error);
},
},
created() {
if (this.checkProps()) {
this.fetchWeather();
}
},
};
</script>
@ -218,6 +189,9 @@ export default {
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
span.lbl {
text-transform: capitalize;
}
}
}
}

View File

@ -31,8 +31,8 @@
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { capitalize } from '@/utils/MiscHelpers';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
@ -45,6 +45,9 @@ export default {
moreInfo: [],
};
},
mounted() {
this.checkProps();
},
computed: {
units() {
return this.options.units || 'metric';
@ -73,11 +76,6 @@ export default {
},
},
methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.startLoading();
this.fetchWeather();
},
/* Adds units symbol to temperature, depending on metric or imperial */
processTemp(temp) {
return `${Math.round(temp)}${this.tempDisplayUnits}`;
@ -88,23 +86,11 @@ export default {
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
return new Date(timestamp * 1000).toLocaleDateString(localFormat, dateFormat);
},
/* Fetches the weather from OpenWeatherMap, and processes results */
fetchWeather() {
axios.get(this.endpoint)
.then((response) => {
if (response.data.list) {
this.processApiResults(response.data);
}
})
.catch((error) => {
this.error('Failed to fetch weather', error);
})
.finally(() => {
this.finishLoading();
});
fetchData() {
this.makeRequest(this.endpoint).then(this.processData);
},
/* Process the results from the Axios request */
processApiResults(dataList) {
processData(dataList) {
const uiWeatherData = [];
dataList.list.forEach((day, index) => {
uiWeatherData.push({
@ -137,8 +123,12 @@ export default {
},
/* When a day is clicked, then show weather info on the UI */
showMoreInfo(moreInfo) {
this.moreInfo = moreInfo;
this.showDetails = true;
if (this.showDetails && JSON.stringify(moreInfo) === JSON.stringify(this.moreInfo)) {
this.showDetails = false;
} else {
this.moreInfo = moreInfo;
this.showDetails = true;
}
},
/* Show/ hide additional weather info */
toggleDetails() {
@ -146,36 +136,19 @@ export default {
},
/* Display weather description and Click for more note on hover */
tooltip(text) {
const content = `${text.split(' ').map(
(word) => word[0].toUpperCase() + word.substring(1),
).join(' ')}\nClick for more Info`;
const content = `${text ? capitalize(text) : ''}\nClick for more Info`;
return { content, trigger: 'hover focus', delay: 250 };
},
/* Validate input props, and print warning if incorrect */
checkProps() {
const ops = this.options;
let valid = true;
if (!ops.apiKey) {
this.error('Missing API key for OpenWeatherMap');
valid = false;
}
if (!ops.city) {
this.error('A city name is required to fetch weather');
valid = false;
}
if (!ops.apiKey) this.error('Missing API key for OpenWeatherMap');
if (!ops.city) this.error('A city name is required to fetch weather');
if (ops.units && ops.units !== 'metric' && ops.units !== 'imperial') {
this.error('Invalid units specified, must be either \'metric\' or \'imperial\'');
valid = false;
}
return valid;
},
},
/* When the widget loads, the props are checked, and weather fetched */
created() {
if (this.checkProps()) {
this.fetchWeather();
}
},
};
</script>
@ -266,6 +239,9 @@ export default {
margin: 0.1rem 0.5rem;
padding: 0.1rem 0;
color: var(--widget-text-color);
span.lbl {
text-transform: capitalize;
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}

View File

@ -1,7 +1,7 @@
<template>
<div :class="`widget-base ${ loading ? 'is-loading' : '' }`">
<!-- Update and Full-Page Action Buttons -->
<Button :click="update" class="action-btn update-btn" v-if="!error && !loading">
<Button :click="update" class="action-btn update-btn" v-if="!loading">
<UpdateIcon />
</Button>
<Button :click="fullScreenWidget" class="action-btn open-btn" v-if="!error && !loading">
@ -15,11 +15,19 @@
<div v-if="error" class="widget-error">
<p class="error-msg">An error occurred, see the logs for more info.</p>
<p class="error-output">{{ errorMsg }}</p>
<p class="retry-link" @click="update">Retry</p>
</div>
<!-- Widget -->
<div v-else class="widget-wrap">
<div :class="`widget-wrap ${ error ? 'has-error' : '' }`">
<AnonAddy
v-if="widgetType === 'anonaddy'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<Apod
v-if="widgetType === 'apod'"
v-else-if="widgetType === 'apod'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
@ -60,6 +68,13 @@
@error="handleError"
:ref="widgetRef"
/>
<CovidStats
v-else-if="widgetType === 'covid-stats'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<EmbedWidget
v-else-if="widgetType === 'embed'"
:options="widgetOptions"
@ -67,6 +82,13 @@
@error="handleError"
:ref="widgetRef"
/>
<EthGasPrices
v-else-if="widgetType === 'eth-gas-prices'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<ExchangeRates
v-else-if="widgetType === 'exchange-rates'"
:options="widgetOptions"
@ -81,6 +103,13 @@
@error="handleError"
:ref="widgetRef"
/>
<GitHubProfile
v-else-if="widgetType === 'github-profile-stats'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GitHubTrending
v-else-if="widgetType === 'github-trending-repos'"
:options="widgetOptions"
@ -88,8 +117,85 @@
@error="handleError"
:ref="widgetRef"
/>
<GitHubProfile
v-else-if="widgetType === 'github-profile-stats'"
<GlAlerts
v-else-if="widgetType === 'gl-alerts'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlCpuCores
v-else-if="widgetType === 'gl-current-cores'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlCpuGauge
v-else-if="widgetType === 'gl-current-cpu'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlCpuHistory
v-else-if="widgetType === 'gl-cpu-history'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlDiskIo
v-else-if="widgetType === 'gl-disk-io'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlDiskSpace
v-else-if="widgetType === 'gl-disk-space'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlLoadHistory
v-else-if="widgetType === 'gl-load-history'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlMemGauge
v-else-if="widgetType === 'gl-current-mem'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlMemHistory
v-else-if="widgetType === 'gl-mem-history'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlNetworkInterfaces
v-else-if="widgetType === 'gl-network-interfaces'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlNetworkTraffic
v-else-if="widgetType === 'gl-network-activity'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GlSystemLoad
v-else-if="widgetType === 'gl-system-load'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
@ -221,8 +327,8 @@
@error="handleError"
:ref="widgetRef"
/>
<XkcdComic
v-else-if="widgetType === 'xkcd-comic'"
<WalletBalance
v-else-if="widgetType === 'wallet-balance'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
@ -242,6 +348,13 @@
@error="handleError"
:ref="widgetRef"
/>
<XkcdComic
v-else-if="widgetType === 'xkcd-comic'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<!-- No widget type specified -->
<div v-else>{{ handleError('Widget type was not found') }}</div>
</div>
@ -265,17 +378,32 @@ export default {
OpenIcon,
LoadingAnimation,
// Register widget components
AnonAddy: () => import('@/components/Widgets/AnonAddy.vue'),
Apod: () => import('@/components/Widgets/Apod.vue'),
Clock: () => import('@/components/Widgets/Clock.vue'),
CodeStats: () => import('@/components/Widgets/CodeStats.vue'),
CovidStats: () => import('@/components/Widgets/CovidStats.vue'),
CryptoPriceChart: () => import('@/components/Widgets/CryptoPriceChart.vue'),
CryptoWatchList: () => import('@/components/Widgets/CryptoWatchList.vue'),
CveVulnerabilities: () => import('@/components/Widgets/CveVulnerabilities.vue'),
EmbedWidget: () => import('@/components/Widgets/EmbedWidget.vue'),
EthGasPrices: () => import('@/components/Widgets/EthGasPrices.vue'),
ExchangeRates: () => import('@/components/Widgets/ExchangeRates.vue'),
Flights: () => import('@/components/Widgets/Flights.vue'),
GitHubTrending: () => import('@/components/Widgets/GitHubTrending.vue'),
GitHubProfile: () => import('@/components/Widgets/GitHubProfile.vue'),
GlAlerts: () => import('@/components/Widgets/GlAlerts.vue'),
GlCpuCores: () => import('@/components/Widgets/GlCpuCores.vue'),
GlCpuGauge: () => import('@/components/Widgets/GlCpuGauge.vue'),
GlCpuHistory: () => import('@/components/Widgets/GlCpuHistory.vue'),
GlDiskIo: () => import('@/components/Widgets/GlDiskIo.vue'),
GlDiskSpace: () => import('@/components/Widgets/GlDiskSpace.vue'),
GlLoadHistory: () => import('@/components/Widgets/GlLoadHistory.vue'),
GlMemGauge: () => import('@/components/Widgets/GlMemGauge.vue'),
GlMemHistory: () => import('@/components/Widgets/GlMemHistory.vue'),
GlNetworkInterfaces: () => import('@/components/Widgets/GlNetworkInterfaces.vue'),
GlNetworkTraffic: () => import('@/components/Widgets/GlNetworkTraffic.vue'),
GlSystemLoad: () => import('@/components/Widgets/GlSystemLoad.vue'),
HealthChecks: () => import('@/components/Widgets/HealthChecks.vue'),
IframeWidget: () => import('@/components/Widgets/IframeWidget.vue'),
Jokes: () => import('@/components/Widgets/Jokes.vue'),
@ -294,6 +422,7 @@ export default {
StockPriceChart: () => import('@/components/Widgets/StockPriceChart.vue'),
SystemInfo: () => import('@/components/Widgets/SystemInfo.vue'),
TflStatus: () => import('@/components/Widgets/TflStatus.vue'),
WalletBalance: () => import('@/components/Widgets/WalletBalance.vue'),
Weather: () => import('@/components/Widgets/Weather.vue'),
WeatherForecast: () => import('@/components/Widgets/WeatherForecast.vue'),
XkcdComic: () => import('@/components/Widgets/XkcdComic.vue'),
@ -306,7 +435,6 @@ export default {
loading: false,
error: false,
errorMsg: null,
updater: null, // Stores interval
}),
computed: {
/* Returns the widget type, shows error if not specified */
@ -321,31 +449,19 @@ export default {
widgetOptions() {
const options = this.widget.options || {};
const useProxy = !!this.widget.useProxy;
const updateInterval = this.widget.updateInterval || 0;
const updateInterval = this.widget.updateInterval !== undefined
? this.widget.updateInterval : null;
return { useProxy, updateInterval, ...options };
},
/* A unique string to reference the widget by */
widgetRef() {
return `widget-${this.widgetType}-${this.index}`;
},
/* Returns either `false` or a number in ms to continuously update widget data */
updateInterval() {
const usersInterval = this.widget.updateInterval;
if (!usersInterval) return 0;
// If set to `true`, then default to 30 seconds
if (typeof usersInterval === 'boolean') return 30 * 1000;
// If set to a number, and within valid range, return user choice
if (typeof usersInterval === 'number'
&& usersInterval >= 10
&& usersInterval < 7200) {
return usersInterval * 1000;
}
return 0;
},
},
methods: {
/* Calls update data method on widget */
update() {
this.error = false;
this.$refs[this.widgetRef].update();
},
/* Shows message when error occurred */
@ -362,17 +478,6 @@ export default {
this.loading = loading;
},
},
mounted() {
// If continuous updates enabled, create interval
if (this.updateInterval) {
this.updater = setInterval(() => {
this.update();
}, this.updateInterval);
}
},
beforeDestroy() {
clearInterval(this.updater);
},
};
</script>
@ -404,6 +509,15 @@ export default {
right: 1.75rem;
}
}
.widget-wrap {
&.has-error {
cursor: not-allowed;
opacity: 0.5;
border-radius: var(--curve-factor);
background: #ffff0080;
}
}
// Error message output
.widget-error {
p.error-msg {
@ -418,6 +532,13 @@ export default {
font-size: 0.85rem;
margin: 0.5rem auto;
}
p.retry-link {
cursor: pointer;
text-decoration: underline;
color: var(--widget-text-color);
font-size: 0.85rem;
margin: 0;
}
}
// Loading spinner
.loading {

View File

@ -55,6 +55,7 @@ export default {
/* Toggles the section clicked, and closes all other sections */
openSection(index) {
this.isOpen = this.isOpen.map((val, ind) => (ind !== index ? false : !val));
if (this.sections[index].widgets) this.$emit('launch-widget', this.sections[index].widgets);
},
/* When item clicked, emit a launch event */
launchApp(options) {

View File

@ -0,0 +1,39 @@
<template>
<div class="workspace-widget-view" v-if="widgets">
<WidgetBase
v-for="(widget, widgetIndx) in widgets"
:key="widgetIndx"
:widget="widget"
:index="widgetIndx"
class="workspace-widget"
/>
</div>
</template>
<script>
import WidgetBase from '@/components/Widgets/WidgetBase';
export default {
components: {
WidgetBase,
},
props: {
widgets: Array,
},
};
</script>
<style lang="scss" scoped>
.workspace-widget-view {
padding: 1rem 0;
background: var(--background);
position: absolute;
left: var(--side-bar-width);
height: calc(100% - var(--header-height) - 1rem);
width: calc(100% - var(--side-bar-width));
.workspace-widget {
max-width: 800px;
margin: 0 auto;
}
}
</style>

View File

@ -0,0 +1,34 @@
/** Reusable mixin for all Glances widgets */
export default {
computed: {
/* Required, hostname (e.g. IP + port) for Glances instance */
hostname() {
if (!this.options.hostname) this.error('You must specify a \'hostname\' for Glaces');
return this.options.hostname;
},
/* Optionally specify the API version, defaults to V 3 */
apiVersion() {
return this.options.apiVersion || 3;
},
/* Optionally specify basic auth credentials for Glances instance */
credentials() {
if (this.options.username && this.options.password) {
const stringifiedUser = `${this.options.username}:${this.options.password}`;
const headers = { Authorization: `Basic ${window.btoa(stringifiedUser)}` };
return { headers };
}
return null;
},
},
methods: {
/* Make the request to Glances API, and calls handler function with results
* Requires endpoint attribute and processData method to be implemented by child */
fetchData() {
this.makeRequest(this.endpoint, this.credentials).then(this.processData);
},
/* Returns URL to Glances API endpoint */
makeGlancesUrl(apiPath) {
return `${this.hostname}/api/${this.apiVersion}/${apiPath}`;
},
},
};

View File

@ -17,10 +17,22 @@ const WidgetMixin = {
data: () => ({
progress: new ProgressBar({ color: 'var(--progress-bar)' }),
overrideProxyChoice: false,
overrideUpdateInterval: null,
disableLoader: false, // Prevent ever showing the loader
updater: null, // Stores interval
}),
/* When component mounted, fetch initial data */
mounted() {
this.fetchData();
if (this.updateInterval) {
this.continuousUpdates();
this.disableLoader = true;
}
},
beforeDestroy() {
if (this.updater) {
clearInterval(this.updater);
}
},
computed: {
proxyReqEndpoint() {
@ -30,6 +42,23 @@ const WidgetMixin = {
useProxy() {
return this.options.useProxy || this.overrideProxyChoice;
},
/* Returns either a number in ms to continuously update widget data. Or 0 for no updates */
updateInterval() {
const usersInterval = this.options.updateInterval;
if (usersInterval === null && this.overrideUpdateInterval) {
return this.overrideUpdateInterval * 1000;
}
if (!usersInterval) return 0;
// If set to `true`, then default to 30 seconds
if (typeof usersInterval === 'boolean') return 30 * 1000;
// If set to a number, and within valid range, return user choice
if (typeof usersInterval === 'number'
&& usersInterval >= 2
&& usersInterval <= 7200) {
return usersInterval * 1000;
}
return 0;
},
},
methods: {
/* Re-fetches external data, called by parent. Usually overridden by widget */
@ -37,6 +66,10 @@ const WidgetMixin = {
this.startLoading();
this.fetchData();
},
/* If continuous updates enabled, create interval */
continuousUpdates() {
this.updater = setInterval(() => { this.update(); }, this.updateInterval);
},
/* Called when an error occurs. Logs to handler, and passes to parent component */
error(msg, stackTrace) {
ErrorHandler(msg, stackTrace);
@ -44,8 +77,10 @@ const WidgetMixin = {
},
/* When a data request update starts, show loader */
startLoading() {
this.$emit('loading', true);
this.progress.start();
if (!this.disableLoader) {
this.$emit('loading', true);
this.progress.start();
}
},
/* When a data request finishes, hide loader */
finishLoading() {
@ -57,20 +92,26 @@ const WidgetMixin = {
this.finishLoading();
},
/* Used as v-tooltip, pass text content in, and will show on hover */
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };
tooltip(content, html = false) {
return {
content, html, trigger: 'hover focus', delay: 250,
};
},
/* Makes data request, returns promise */
makeRequest(endpoint, options) {
makeRequest(endpoint, options, protocol, body) {
// Request Options
const method = 'GET';
const method = protocol || 'GET';
const url = this.useProxy ? this.proxyReqEndpoint : endpoint;
const CustomHeaders = options ? JSON.stringify(options) : null;
const data = JSON.stringify(body || {});
const CustomHeaders = options || null;
const headers = this.useProxy
? { 'Target-URL': endpoint, CustomHeaders } : CustomHeaders;
? { 'Target-URL': endpoint, CustomHeaders: JSON.stringify(CustomHeaders) } : CustomHeaders;
const requestConfig = {
method, url, headers, data,
};
// Make request
return new Promise((resolve, reject) => {
axios.request({ method, url, headers })
axios.request(requestConfig)
.then((response) => {
if (response.data.success === false) {
this.error('Proxy returned error from target server', response.data.message);

View File

@ -86,7 +86,17 @@ export const showNumAsThousand = (bigNum) => {
/* Capitalizes the first letter of each word within a string */
export const capitalize = (str) => {
return str.replace(/\w\S*/g, (w) => (w.replace(/^\w/, (c) => c.toUpperCase())));
const words = str.replaceAll('_', ' ').replaceAll('-', ' ');
return words.replace(/\w\S*/g, (w) => (w.replace(/^\w/, (c) => c.toUpperCase())));
};
/* Given a mem size in bytes, will return it in appropriate unit */
export const convertBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / (k ** i)).toFixed(decimals))} ${sizes[i]}`;
};
/* Round price to appropriate number of decimals */
@ -107,6 +117,33 @@ export const truncateStr = (str, len = 60, ellipse = '...') => {
return str.length > len + ellipse.length ? `${str.slice(0, len)}${ellipse}` : str;
};
/* Given two timestamp, return the difference in text format, e.g. '10 minutes' */
export const getTimeDifference = (startTime, endTime) => {
const msDifference = new Date(endTime).getTime() - new Date(startTime).getTime();
const diff = Math.abs(Math.round(msDifference / 1000));
const divide = (time, round) => Math.round(time / round);
if (diff < 60) return `${divide(diff, 1)} seconds`;
if (diff < 3600) return `${divide(diff, 60)} minutes`;
if (diff < 86400) return `${divide(diff, 3600)} hours`;
if (diff < 604800) return `${divide(diff, 86400)} days`;
if (diff >= 604800) return `${divide(diff, 604800)} weeks`;
return 'unknown';
};
/* Given a timestamp, return how long ago it was, e.g. '10 minutes' */
export const getTimeAgo = (dateTime) => {
const now = new Date().getTime();
const diffStr = getTimeDifference(dateTime, now);
if (diffStr === 'unknown') return diffStr;
return `${diffStr} ago`;
};
/* Given the name of a CSS variable, returns it's value */
export const getValueFromCss = (colorVar) => {
const cssProps = getComputedStyle(document.documentElement);
return cssProps.getPropertyValue(`--${colorVar}`).trim();
};
/* Given a currency code, return the corresponding unicode symbol */
export const findCurrencySymbol = (currencyCode) => {
const code = currencyCode.toUpperCase().trim();

View File

@ -206,11 +206,15 @@ module.exports = {
},
/* API endpoints for widgets that need to fetch external data */
widgetApiEndpoints: {
anonAddy: 'https://app.anonaddy.com',
astronomyPictureOfTheDay: 'https://apodapi.herokuapp.com/api',
codeStats: 'https://codestats.net/',
covidStats: 'https://disease.sh/v3/covid-19',
cryptoPrices: 'https://api.coingecko.com/api/v3/coins/',
cryptoWatchList: 'https://api.coingecko.com/api/v3/coins/markets/',
cveVulnerabilities: 'https://www.cvedetails.com/json-feed.php',
ethGasPrices: 'https://ethgas.watch/api/gas',
ethGasHistory: 'https://ethgas.watch/api/gas/trend',
exchangeRates: 'https://v6.exchangerate-api.com/v6/',
flights: 'https://aerodatabox.p.rapidapi.com/flights/airports/icao/',
githubTrending: 'https://gh-trending-repos.herokuapp.com/',
@ -224,6 +228,8 @@ module.exports = {
sportsScores: 'https://www.thesportsdb.com/api/v1/json',
stockPriceChart: 'https://www.alphavantage.co/query',
tflStatus: 'https://api.tfl.gov.uk/line/mode/tube/status',
walletBalance: 'https://api.blockcypher.com/v1',
walletQrCode: 'https://www.bitcoinqrcodemaker.com/api',
weather: 'https://api.openweathermap.org/data/2.5/weather',
weatherForecast: 'https://api.openweathermap.org/data/2.5/forecast/daily',
xkcdComic: 'https://xkcd.vercel.app/',

View File

@ -1,8 +1,14 @@
<template>
<div class="work-space">
<SideBar :sections="sections" @launch-app="launchApp" :initUrl="getInitialUrl()" />
<SideBar
:sections="sections"
@launch-app="launchApp"
@launch-widget="launchWidget"
:initUrl="getInitialUrl()"
/>
<WebContent :url="url" v-if="!isMultiTaskingEnabled" />
<MultiTaskingWebComtent :url="url" v-else />
<WidgetView :widgets="widgets" v-if="widgets" />
</div>
</template>
@ -10,6 +16,7 @@
import HomeMixin from '@/mixins/HomeMixin';
import SideBar from '@/components/Workspace/SideBar';
import WebContent from '@/components/Workspace/WebContent';
import WidgetView from '@/components/Workspace/WidgetView';
import MultiTaskingWebComtent from '@/components/Workspace/MultiTaskingWebComtent';
import Defaults from '@/utils/defaults';
import { GetTheme, ApplyLocalTheme, ApplyCustomVariables } from '@/utils/ThemeHelper';
@ -19,6 +26,7 @@ export default {
mixins: [HomeMixin],
data: () => ({
url: '',
widgets: null,
GetTheme,
ApplyLocalTheme,
ApplyCustomVariables,
@ -37,6 +45,7 @@ export default {
components: {
SideBar,
WebContent,
WidgetView,
MultiTaskingWebComtent,
},
methods: {
@ -46,6 +55,11 @@ export default {
} else {
this.url = options.url;
}
this.widgets = null;
},
launchWidget(widgets) {
this.url = '';
this.widgets = widgets;
},
setTheme() {
const theme = this.GetTheme();