Compare commits

..

5 Commits

Author SHA1 Message Date
Evan Steinkerchner
a4c1f6a37d Added forwarder binding to Service.vue 2022-04-28 20:08:42 -04:00
Evan Steinkerchner
76d30be8e3 Pass new forwarder config to services, give it a default value of empty 2022-04-19 20:37:38 -04:00
Evan Steinkerchner
240e3f0e87 Changed config and header names as per @bastienwirtz feedback 2022-04-19 20:24:51 -04:00
Evan Steinkerchner
33f75a798a Merge branch 'main' into proxy-api 2022-04-19 20:18:39 -04:00
Evan Steinkerchner
a2dfffab68 Added url and apikey config options under proxy 2022-03-20 16:32:24 -04:00
99 changed files with 8023 additions and 5594 deletions

3
.browserslistrc Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

View File

@ -1,17 +0,0 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-prettier",
],
env: {
"vue/setup-compiler-macros": true,
},
rules: {
"vue/multi-word-component-names": "off",
},
};

15
.eslintrc.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: ["plugin:vue/essential", "eslint:recommended", "@vue/prettier"],
parserOptions: {
parser: "babel-eslint",
},
rules: {
"no-console": "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"vue/require-v-for-key": "off",
},
};

3
.github/FUNDING.yml vendored
View File

@ -1,3 +0,0 @@
# These are supported funding model platforms
custom: ['https://www.buymeacoffee.com/bastien']

View File

@ -1,28 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Logs & errors**
Please include any usefull information:
- Errors in your browser console (`ctrl+shift+i` or `F12`)
- If applicable, your docker container logs.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Configuration**
If applicable, copy related homer yaml configuration here.
```yml
```

View File

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

8
.github/release.yml vendored
View File

@ -1,8 +0,0 @@
changelog:
exclude:
authors:
- dependabot
categories:
- title: Main changes
labels:
- "*"

View File

@ -1,44 +0,0 @@
# Build & publish docker images
name: Dockerhub
on:
push:
tags: [v*]
branches: [ main ]
jobs:
dockerhub:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
-
name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Set tag name
run: |
if [[ ${{ github.ref_type }} == "tag" ]]; then
echo "IMAGE_TAG=${{ github.ref_name }}" >> $GITHUB_ENV
else
echo "IMAGE_TAG=latest" >> $GITHUB_ENV
fi
-
name: Build and push
uses: docker/build-push-action@v3
with:
push: true
tags: b4bz/homer:${{env.IMAGE_TAG}}
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64

View File

@ -20,19 +20,12 @@ jobs:
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Use Node.js ${{ matrix.node-version }}
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
-
name: install dependencies
run: yarn install
-
name: Check code style & potentential issues
run: yarn lint
- run: yarn install
- run: yarn lint

View File

@ -1,5 +1,5 @@
# Publish pre-build release
name: Create Github release
name: Upload Release Asset
on:
push:
@ -10,24 +10,31 @@ jobs:
name: Upload Release Asset
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Build project
- uses: actions/checkout@v2
- name: Build project
run: |
yarn install
yarn build
-
name: Create artifact
- name: Create artifact
working-directory: "dist"
run: zip -r ../homer.zip ./*
-
name: Create Release
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
token: ${{ secrets.GITHUB_TOKEN }}
generate_release_notes: true
files: |
homer.zip
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./homer.zip
asset_name: homer.zip
asset_content_type: application/zip

View File

@ -10,29 +10,24 @@ COPY . .
RUN yarn build
# production stage
FROM alpine:3.16
FROM alpine:3.15
ENV GID 1000
ENV UID 1000
ENV USER darkhttpd
ENV GROUP darkhttpd
ENV GID 911
ENV UID 911
ENV PORT 8080
ENV SUBFOLDER "/_"
ENV INIT_ASSETS 1
RUN addgroup -S lighttpd -g ${GID} && adduser -D -S -u ${UID} lighttpd lighttpd && \
apk add -U --no-cache lighttpd
RUN addgroup -S ${GROUP} -g ${GID} && adduser -D -S -u ${UID} ${USER} ${GROUP} && \
apk add -U --no-cache su-exec darkhttpd
WORKDIR /www
COPY lighttpd.conf /lighttpd.conf
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist /www/
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist/assets /www/default-assets
COPY entrypoint.sh /entrypoint.sh
COPY --from=build-stage --chown=${UID}:${GID} /app/dist /www/
COPY --from=build-stage --chown=${UID}:${GID} /app/dist/assets /www/default-assets
USER ${UID}:${GID}
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:${PORT}/ || exit 1
EXPOSE ${PORT}
VOLUME /www/assets
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]

43
Dockerfile.arm32v7 Normal file
View File

@ -0,0 +1,43 @@
# build stage
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build
# Multi arch build support
FROM alpine as qemu
ARG QEMU_VERSION="v4.2.0-7"
RUN wget https://github.com/multiarch/qemu-user-static/releases/download/${QEMU_VERSION}/qemu-arm-static && chmod +x qemu-arm-static
# production stage
FROM arm32v7/alpine:3.11
COPY --from=qemu qemu-arm-static /usr/bin/
ENV USER darkhttpd
ENV GROUP darkhttpd
ENV GID 911
ENV UID 911
ENV PORT 8080
RUN addgroup -S ${GROUP} -g ${GID} && adduser -D -S -u ${UID} ${USER} ${GROUP} && \
apk add -U --no-cache darkhttpd su-exec && \
rm /usr/bin/qemu-arm-static
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist /www/
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist/assets /www/default-assets
COPY entrypoint.sh /entrypoint.sh
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:${PORT}/ || exit 1
EXPOSE ${PORT}
VOLUME /www/assets
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]

43
Dockerfile.arm64v8 Normal file
View File

@ -0,0 +1,43 @@
# build stage
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build
# Multi arch build support
FROM alpine as qemu
ARG QEMU_VERSION="v4.2.0-7"
RUN wget https://github.com/multiarch/qemu-user-static/releases/download/${QEMU_VERSION}/qemu-aarch64-static && chmod +x qemu-aarch64-static
# production stage
FROM arm64v8/alpine:3.11
COPY --from=qemu qemu-aarch64-static /usr/bin/
ENV USER darkhttpd
ENV GROUP darkhttpd
ENV GID 911
ENV UID 911
ENV PORT 8080
RUN addgroup -S ${GROUP} -g ${GID} && adduser -D -S -u ${UID} ${USER} ${GROUP} && \
apk add -U --no-cache darkhttpd su-exec && \
rm /usr/bin/qemu-aarch64-static
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist /www/
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist/assets /www/default-assets
COPY entrypoint.sh /entrypoint.sh
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:${PORT}/ || exit 1
EXPOSE ${PORT}
VOLUME /www/assets
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]

View File

@ -10,9 +10,7 @@
<h4 align="center">
A dead simple static <strong>HOM</strong>epage for your serv<strong>ER</strong> to keep your services on hand, from a simple <code>yaml</code> configuration file.
</h4>
<p align="center">
<a href="https://www.buymeacoffee.com/bastien" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-yellow.png" alt="Buy Me A Coffee" height="41" width="174"></a>
<p>
<p align="center">
<strong>
<a href="https://homer-demo.netlify.app">Demo</a>
@ -32,9 +30,6 @@
<a href="https://github.com/bastienwirtz/homer/releases/latest/download/homer.zip"><img
alt="Download homer static build"
src="https://img.shields.io/badge/Download-homer.zip-orange"></a>
<a href="https://twitter.com/acdlite/status/974390255393505280"><img
alt="speed-blazing"
src="https://img.shields.io/badge/speed-blazing%20%F0%9F%94%A5-red"></a>
<a href="https://github.com/awesome-selfhosted/awesome-selfhosted"><img
alt="Awesome"
src="https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg"></a>
@ -70,12 +65,14 @@
## Getting started
Homer is a full static html/js dashboard, based on a simple yaml configuration file. See [documentation](docs/configuration.md) for information about the configuration (`assets/config.yml`) options.
Homer is a full static html/js dashboard, generated from the source in `/src` using webpack. It's meant to be served by an HTTP server, **it will not work if you open dist/index.html directly over file:// protocol**.
It's meant to be served by an HTTP server, **it will not work if you open the index.html directly over file:// protocol**.
See [documentation](docs/configuration.md) for information about the configuration (`assets/config.yml`) options.
### Using docker
To launch container:
```sh
docker run -d \
-p 8080:8080 \
@ -84,31 +81,35 @@ docker run -d \
b4bz/homer:latest
```
The container will run using a user uid and gid 1000. Add `--user <your-UID>:<your-GID>` to the docker command to adjust it. Make sure this match the ownership of your assets directory.
Default assets will be automatically installed in the `/www/assets` directory. Use `UID` and/or `GID` env var to change the assets owner (`docker run -e "UID=1000" -e "GID=1000" [...]`).
**Environment variables:**
### Using docker-compose
* **`INIT_ASSETS`** (default: `1`)
Install example configuration file & assets (favicons, ...) to help you get started.
The `docker-compose.yml` file must be edited to match your needs.
Set the port and volume (equivalent to `-p` and `-v` arguments):
* **`SUBFOLDER`** (default: `null`)
If you would like to host Homer in a subfolder, (ex: *http://my-domain/**homer***), set this to the subfolder path (ex `/homer`).
```yaml
volumes:
- /your/local/assets/:/www/assets
ports:
- 8080:8080
```
* **`PORT`** (default: `8080`)
If you would like to change internal port of Homer from default `8080` to your port choice.
#### With docker-compose
A [`docker-compose.yml`](docker-compose.yml) file is available as an example. It must be edited to match your needs. You probably want to adjust the port mapping and volume binding (equivalent to `-p` and `-v` arguments).
Then launch the container:
To launch container:
```sh
cd /path/to/docker-compose.yml/
cd /path/to/docker-compose.yml
docker-compose up -d
```
Default assets will be automatically installed in the `/www/assets` directory. Use `UID` and/or `GID` env var to change the assets owner, also in `docker-compose.yml`:
```yaml
environment:
- UID=1000
- GID=1000
```
### Using the release tarball (prebuilt, ready to use)
Download and extract the latest release (`homer.zip`) from the [release page](https://github.com/bastienwirtz/homer/releases), rename the `assets/config.yml.dist` file to `assets/config.yml`, and put it behind a web server.
@ -121,23 +122,6 @@ cp assets/config.yml.dist assets/config.yml
npx serve # or python -m http.server 8010 or apache, nginx ...
```
### Using Helm
Thanks to [@djjudas21](https://github.com/djjudas21) [charts](https://github.com/djjudas21/charts/tree/main/charts/homer):
```sh
helm repo add djjudas21 https://djjudas21.github.io/charts/
helm repo update djjudas21
# install with all defaults
helm install homer djjudas21/homer
# install with customisations
wget https://raw.githubusercontent.com/djjudas21/charts/main/charts/homer/values.yaml
# edit values.yaml
helm install homer djjudas21/homer -f values.yaml
```
### Build manually
```sh

3
babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

View File

@ -10,6 +10,7 @@ services:
- /your/local/assets/:/www/assets
ports:
- 8080:8080
user: 1000:1000 # default
environment:
- INIT_ASSETS=1 # default
#environment:
# - UID=1000
# - GID=1000
restart: unless-stopped

View File

@ -25,8 +25,7 @@ header: true # Set to false to hide the header
footer: '<p>Created with <span class="has-text-danger">❤️</span> with <a href="https://bulma.io/">bulma</a>, <a href="https://vuejs.org/">vuejs</a> & <a href="https://fontawesome.com/">font awesome</a> // Fork me on <a href="https://github.com/bastienwirtz/homer"><i class="fab fa-github-alt"></i></a></p>' # set false if you want to hide it.
columns: "3" # "auto" or number (must be a factor of 12: 1, 2, 3, 4, 6, 12)
connectivityCheck: true # whether you want to display a message when the apps are not accessible anymore (VPN disconnected for example).
# You should set it to true when using an authentication proxy, it also reloads the page when a redirection is detected when checking connectivity.
connectivityCheck: true # whether you want to display a message when the apps are not accessible anymore (VPN disconnected for example)
# Optional: Proxy / hosting option
proxy:
@ -128,7 +127,6 @@ services:
# icon: "fab fa-jenkins"
subtitle: "Bookmark example"
tag: "app"
keywords: "self hosted reddit" # optional keyword used for searching purpose
url: "https://www.reddit.com/r/selfhosted/"
target: "_blank" # optional html tag target attribute
- name: "Another one"
@ -152,7 +150,7 @@ services:
# background: red # optional color for card to set color directly without custom stylesheet
```
View **[Custom Services](customservices.md)** for details about all available custom services (like `PiHole`) and how to configure them.
View [Custom Services](customservices.md) for details about all available custom services (like PiHole) and how to configure them.
If you choose to fetch message information from an endpoint, the output format should be as follows (or you can [custom map fields as shown in tips-and-tricks](./tips-and-tricks.md#mapping-fields)):
@ -178,19 +176,29 @@ Homer uses [bulma CSS](https://bulma.io/), which provides a [modifiers syntax](h
You can read the [bulma modifiers page](https://bulma.io/documentation/modifiers/syntax/) for other options regarding size, style, or state.
## Theming & customization
See `colors` settings in the configuration example above.
Favicon et application icon (pwa) are located in the `assets/icons` directory and can be replaced by any image you want (just keep the same name & size).
The `/assets/manifest.json` can also be edited to change the app (pwa) name, description and other settings.
### Community theme
- [Dracula theme](https://draculatheme.com/homer) by [@Tuetenk0pp](https://github.com/Tuetenk0pp)
- [Homer Theme v2](https://github.com/walkxcode/homer-theme) by [walkxcode](https://github.com/walkxcode)
- [Catppuccin theme](https://github.com/mrpbennett/catppucin-homer) by [@mrpbenett](https://github.com/mrpbennett)
## PWA Icons
See icons documentation [here](https://github.com/bastienwirtz/homer/blob/main/public/assets/icons/README.md).
In order to easily generate all required icon preset for the PWA to work, a tool like [vue-pwa-asset-generator](https://www.npmjs.com/package/vue-pwa-asset-generator) can be used:
```bash
npx vue-pwa-asset-generator -a {your_512x512_source_png} -o {your_output_folder}
```
## Supported services
Currently the following services are supported for showing quick infos on the card. They can be used by setting the type to one of the following values at the item.
- PiHole
- AdGuardHome
- PaperlessNG
- Mealie
## Additional configuration
### Paperless
For Paperless you need an API-Key which you have to store at the item in the field `apikey`.
### Mealie
First off make sure to remove an existing `subtitle` as it will take precedence if set. Setting `type: "Mealie"` will then show the number of recipes Mealie is keeping organized or the planned meal for today if one is planned. You will have to set an API key in the field `apikey` which can be created in your Mealie installation.

View File

@ -7,35 +7,20 @@ if your homer instance is secured behind some form of authentication or access r
Available services are in `src/components/`. Here is an overview of all custom services that are available
within Homer:
- [Custom Services](#custom-services)
- [Common options](#common-options)
- [PiHole](#pihole)
- [OpenWeatherMap](#openweathermap)
- [Medusa](#medusa)
- [Lidarr, Prowlarr, Sonarr and Radarr](#lidarr-prowlarr-sonarr-and-radarr)
- [PaperlessNG](#paperlessng)
- [Ping](#ping)
- [Prometheus](#prometheus)
- [AdGuard Home](#adguard-home)
- [Portainer](#portainer)
- [Emby / Jellyfin](#emby--jellyfin)
- [Uptime Kuma](#uptime-kuma)
- [Tautulli](#tautulli)
- [Mealie](#mealie)
- [Healthchecks](#healthchecks)
- [Proxmox](#proxmox)
- [rTorrent](#rtorrent)
- [qBittorrent](#qbittorrent)
- [CopyToClipboard](#copy-to-clipboard)
- [Speedtest Tracker](#SpeedtestTracker)
- [What's Up Docker](#whats-up-docker)
- [SABnzbd](#sabnzbd)
- [OctoPrint](#sabnzbd)
- [Tdarr](#tdarr)
+ [PiHole](#pihole)
+ [OpenWeatherMap](#openweathermap)
+ [Medusa](#medusa)
+ [Lidarr, Prowlarr, Sonarr and Radarr](#lidarr-prowlarr-sonarr-and-radarr)
+ [PaperlessNG](#paperlessng)
+ [Ping](#ping)
+ [Prometheus](#prometheus)
+ [AdGuard Home](#adguard-home)
+ [Portainer](#portainer)
+ [Emby](#emby)
If you experiencing any issue, please have a look to the [troubleshooting](troubleshooting.md) page.
## Common options
```yaml
@ -58,13 +43,9 @@ The following configuration is available for the PiHole service.
logo: "assets/tools/sample.png"
# subtitle: "Network-wide Ad Blocking" # optional, if no subtitle is defined, PiHole statistics will be shown
url: "http://192.168.0.151/admin"
apikey: "<---insert-api-key-here--->" # optional, needed if web interface is password protected
type: "PiHole"
```
**Remarks:**
If PiHole web interface is password protected, obtain the `apikey` from Settings > API/Web interface > Show API token.
## OpenWeatherMap
Using the OpenWeatherMap service you can display weather information about a given location.
@ -76,7 +57,7 @@ The following configuration is available for the OpenWeatherMap service:
locationId: "2759794" # Optional: Specify OpenWeatherMap city ID for better accuracy
apikey: "<---insert-api-key-here--->" # insert your own API key here. Request one from https://openweathermap.org/api.
units: "metric" # units to display temperature. Can be one of: metric, imperial, kelvin. Defaults to kelvin.
background: "square" # choose which type of background you want behind the image. Can be one of: square, circle, none. Defaults to none.
background: "square" # choose which type of background you want behind the image. Can be one of: square, cicle, none. Defaults to none.
type: "OpenWeather"
```
@ -90,7 +71,7 @@ Two lines are needed in the config.yml :
```yaml
type: "Medusa"
apikey: "<---insert-api-key-here--->"
apikey: "01234deb70424befb1f4ef6a23456789"
```
The url must be the root url of Medusa application.
@ -103,18 +84,18 @@ Two lines are needed in the config.yml :
```yaml
type: "Lidarr", "Prowlarr", "Radarr" or "Sonarr"
apikey: "<---insert-api-key-here--->"
apikey: "01234deb70424befb1f4ef6a23456789"
```
The url must be the root url of Lidarr, Prowlarr, Radarr or Sonarr application.
The Lidarr, Prowlarr, Radarr or Sonarr API key can be found in Settings > General. It is needed to access the API.
If you are using an older version of Radarr or Sonarr which don't support the new V3 api endpoints, add the following line to your service config "legacyApi: true", example:
If you are using an older version of Radarr or Sonarr which don't support the new V3 api endpoints, add the following line to your service config "legacyApi: true", example:
```yaml
- name: "Radarr"
type: "Radarr"
url: "http://localhost:7878/"
apikey: "<---insert-api-key-here--->"
apikey: "MY-SUPER-SECRET-API-KEY"
target: "_blank"
legacyApi: true
```
@ -125,14 +106,14 @@ This service displays total number of documents stored. Two lines are required:
```yaml
type: "PaperlessNG"
apikey: "<---insert-api-key-here--->"
apikey: "0123456789abcdef123456789abcdef"
```
API key can be generated in Settings > Administration > Auth Tokens
## Ping
For Ping you need to set the type to Ping and provide a url. By default the HEAD method is used but it can be configured to use GET using the optional `method` property.
For Ping you need to set the type to Ping and provide a url.
```yaml
- name: "Awesome app"
@ -141,7 +122,6 @@ For Ping you need to set the type to Ping and provide a url. By default the HEAD
subtitle: "Bookmark example"
tag: "app"
url: "https://www.reddit.com/r/selfhosted/"
method: "head"
```
## Prometheus
@ -172,7 +152,7 @@ For AdGuard Home you need to set the type to AdGuard, if you have somes issues a
This service displays info about the total number of containers managed by your Portainer instance.
In order to use it, you must be using Portainer version 1.11 or later. Generate an access token from the UI and pass
it to the apikey field.
By default, every connected environments will be checked. To select specific ones, add an "environments" entry which can be a simple string or an array containing all the selected environments name.
By default, every connected environments will be checked. To select specific ones,add an "environments" entry which can be a simple string or an array containing all the selected environments name.
See https://docs.portainer.io/v/ce-2.11/user/account-settings#access-tokens
@ -181,13 +161,13 @@ See https://docs.portainer.io/v/ce-2.11/user/account-settings#access-tokens
logo: "assets/tools/sample.png"
url: "http://192.168.0.151/"
type: "Portainer"
apikey: "<---insert-api-key-here--->"
apikey: "MY-SUPER-SECRET-API-KEY"
# environments:
# - "raspberry"
# - "local"
```
## Emby / Jellyfin
## Emby
You need to set the type to Emby, provide an api key and choose which stats to show if the subtitle is disabled.
@ -196,216 +176,6 @@ You need to set the type to Emby, provide an api key and choose which stats to s
logo: "assets/tools/sample.png"
url: "http://192.168.0.151/"
type: "Emby"
apikey: "<---insert-api-key-here--->"
apikey: "MY-SUPER-SECRET-API-KEY"
libraryType: "music" #Choose which stats to show. Can be one of: music, series or movies.
```
## Uptime Kuma
Using the Uptime Kuma service you can display info about your instance uptime right on your Homer dashboard.
The following configuration is available for the UptimeKuma service. Needs v1.13.1 or later because of the change in APIs due to [multiple status pages support](https://github.com/louislam/uptime-kuma/releases/tag/1.13.1).
```yaml
- name: "Uptime Kuma"
logo: "assets/tools/sample.png"
# subtitle: "A fancy self-hosted monitoring tool" # optional, if no subtitle is defined, Uptime Kuma incidents, if any, will be shown
url: "http://192.168.0.151:3001"
slug: "myCustomDashboard" # Defaults to "default" if not provided.
type: "UptimeKuma"
```
## Tautulli
The Tautulli service can allow you to show the number of currently active
streams on you Plex instance. An API key is required, and can be obtained from
the "Web Interface" section of settings on the Tautulli web UI.
```yaml
- name: "Tautulli"
logo: "assets/tools/sample.png"
url: "http://192.168.0.151:8181"
type: "Tautulli"
apikey: "<---insert-api-key-here--->"
```
Because the service type and link don't necessarily have to match, you could
even make the service type Tautulli on your Plex card and provide a separate
endpoint pointing to Tautulli!
```yaml
- name: "Plex"
logo: "assets/tools/sample.png"
url: "http://192.168.0.151:32400/web" # Plex
endpoint: "http://192.168.0.151:8181" # Tautulli
type: "Tautulli"
apikey: "<---insert-api-key-here--->"
```
## Mealie
First off make sure to remove an existing `subtitle` as it will take precedence if set.
Setting `type: "Mealie"` will then show the number of recipes Mealie is keeping organized or the planned meal for today if one is planned. You will have to set an API key in the field `apikey` which can be created in your Mealie installation.
## Healthchecks
This service displays information about the configured status checks from the Healthchecks application.
Two lines are needed in the config.yml :
```yaml
type: "Healthchecks"
apikey: "<---insert-api-key-here--->"
```
The url must be the root url of the Healthchecks application.
The Healthchecks API key can be found in Settings > API Access > API key (read-only). The key is needed to access Healthchecks API.
## rTorrent
This service displays the global upload and download rates, as well as the number of torrents
listed in rTorrent. The service communicates with the rTorrent XML-RPC interface which needs
to be accessible from the browser. Please consult
[the instructions](https://github.com/rakshasa/rtorrent-doc/blob/master/RPC-Setup-XMLRPC.md)
for setting up rTorrent and make sure the correct CORS-settings are applied. Examples for various
servers can be found at https://enable-cors.org/server.html.
```yaml
- name: "rTorrent"
logo: "assets/tools/sample.png"
url: "http://192.168.0.151" # Your rTorrent web UI, f.e. ruTorrent or Flood.
xmlrpc: "http://192.168.0.151:8081" # Reverse proxy for rTorrent's XML-RPC.
type: "Rtorrent"
rateInterval: 5000 # Interval for updating the download and upload rates.
torrentInterval: 60000 # Interval for updating the torrent count.
username: "username" # Username for logging into rTorrent (if applicable).
password: "password" # Password for logging into rTorrent (if applicable).
```
## Proxmox
This service displays status information of a Proxmox node (VMs running and disk, memory and cpu used). It uses the proxmox API and [API Tokens](https://pve.proxmox.com/pve-docs/pveum-plain.html) for authorization so you need to generate one to set in the yaml config. You can set it up in Proxmox under Permissions > API Tokens. You also need to know the realm the user of the API Token is assigned to (by default pam).
The API Token (or the user asigned to that token if not separated permissions is checked) are this:
| Path | Permission | Comments |
|--------------------|------------|-------------------------------------------------------------------|
| /nodes/<your-node> | Sys.Audit | |
| /vms/<id-vm> | VM.Audit | You need to have this permission on any VM you want to be counted |
It is highly recommended that you create and API Token with only these permissions on a read-only mode.
If you get errors, they will be shown on browser's dev console. Main issues tend to be CORS related as Proxmox does not include CORS headers and you have to deploy it behind a reverse proxy and make the proxy add this headers.
Configuration example:
```yaml
- name: "Proxmox - Node"
logo: "https://www.google.com/url?sa=i&url=https%3A%2F%2Fgithub.com%2FandOTP%2FandOTP%2Fissues%2F337&psig=AOvVaw2YKVuEUIBeTUikr7kAjm8D&ust=1665323538747000&source=images&cd=vfe&ved=0CAkQjRxqFwoTCPCTruLj0PoCFQAAAAAdAAAAABAN"
type: "Proxmox"
url: "https://your.proxmox.server"
node: "your-node-name"
warning_value: 50
danger_value: 80
api_token: "PVEAPIToken=root@pam!your-api-token-name=your-api-token-key"
# values below this line are optional (default value are false/empty):
hide_decimals: true # removes decimals from stats values.
hide: [] # hides information. Possible values are "vms", "vms_total", "lxcs", "lxcs_total", "disk", "mem" and "cpu".
small_font_on_small_screens: true # uses small font on small screens (like mobile)
small_font_on_desktop: true # uses small font on desktops (just in case you're showing much info)
```
## qBittorrent
This service displays the global upload and download rates, as well as the number of torrents
listed. The service communicates with the qBittorrent API interface which needs
to be accessible from the browser. Please consult
[the instructions](https://github.com/qbittorrent/qBittorrent/pull/12579)
for setting up qBittorrent and make sure the correct CORS-settings are applied. Examples for various
servers can be found at [enable-cors.org](https://enable-cors.org/server.html).
```yaml
- name: "qBittorrent"
logo: "assets/tools/sample.png"
url: "http://192.168.1.2:8080" # Your rTorrent web UI, f.e. ruTorrent or Flood.
type: "qBittorrent"
rateInterval: 2000 # Interval for updating the download and upload rates.
torrentInterval: 5000 # Interval for updating the torrent count.
target: "_blank" # optional html a tag target attribute
```
## Copy to Clipboard
This service displays the same information of a generic one, but shows an icon button on the indicator place (right side) you can click to get the content of the `clipboard` field copied to your clipboard.
You can still provide an `url` that would be open when clicked anywhere but on the icon button.
Configuration example:
```yaml
- name: "Copy me!"
logo: "assets/tools/sample.png"
subtitle: "Subtitle text goes here"
url: "#"
type: "CopyToClipboard"
clipboard: "this text will be copied to your clipboard"
```
## SpeedtestTracker
For the SpeedtestTracker service you just need to define a entry with type `SpeedtestTracker`.
## What's up Docker
What's up Docker allow to display info about the number of container running and the number for which an update is available on your Homer dashboard.
The following configuration is available for the WUD service.
```yaml
- name: "What's Up Docker"
logo: "assets/tools/sample.png"
subtitle: "Docker image update notifier"
url: "http://192.168.1.12:3001"
type: "WUD"
```
## SABnzbd
The SABnzbd service can allow you to show the number of currently active
downloads on your SABnzbd instance. An API key is required, and can be obtained from
the "Config" > "General" section of the SABnzbd config in the SABnzbd web UI.
```yaml
- name: "SABnzbd"
logo: "assets/tools/sample.png"
url: "http://192.168.0.151:8080"
type: "SABnzbd"
apikey: "MY-SUPER-SECRET-API-KEY"
downloadInterval: 5000 # (Optional) Interval (in ms) for updating the download count
```
## OctoPrint
The OctoPrint service only needs an `apikey` & `url` and optionally a `display` option.
```yaml
- name: "Octoprint"
logo: "https://cdn-icons-png.flaticon.com/512/3112/3112529.png"
apikey: "xxxxxxxxxxxx" # insert your own API key here. Request one from https://openweathermap.org/api.
url: "http://192.168.0.151:8080"
display: "text" # 'text' or 'bar'. Default to `text`.
type: "OctoPrint"
```
## Tdarr
The Tdarr service can allow you to show the number of currently queued items
for transcoding on your Tdarr instance as well as the number of errored items.
```yaml
- name: "Tdarr"
logo: "assets/tools/sample.png"
url: "http://192.168.0.151:8265"
type: "Tdarr"
checkInterval: 5000 # (Optional) Interval (in ms) for updating the queue & error counts
```

View File

@ -5,11 +5,11 @@ If you want to contribute to Homer, please read the [contributing guidelines](ht
```sh
# Using yarn (recommended)
yarn install
yarn dev
yarn serve
# **OR** Using npm
npm install
npm run dev
npm run serve
```
## Custom services

View File

@ -1,32 +1,8 @@
# Troubleshooting
## My docker container refuse to start / is stuck at restarting.
You might be facing a permission issue. First of all, check your container logs (adjust the container name if necessary):
```sh
$ docker logs homer
[...]
Assets directory not writable. Check assets directory permissions & docker user or skip default assets install by setting the INIT_ASSETS env var to 0
```
In this case you need to make sure your mounted assets directory have the same GID / UID the container user have (default 1000:1000), and that the read and write permission is granted for the user or the group.
You can either:
- Update your assets directory permissions (ex: `chown -R 1000:1000 /your/assets/folder/`, `chmod -R u+rw /your/assets/folder/`)
- Change the docker user by using the `--user` arguments with docker cli or `user: 1000:1000` with docker compose.
⚠️ Notes:
- **Do not** use env var to set the GID / UID of the user running container. Use the Docker `user` option.
- **Do not** use 0:0 as a user value, it would be a security risk, and it's not guaranty to work.
Check this [thread](https://github.com/bastienwirtz/homer/issues/459) for more information about debugging
permission issues.
## My custom service card doesn't work, nothing appears or offline status is displayed (pi-hole, sonarr, ping, ...)
You might be facing a [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) (Cross Origin Request Sharing) issue.
You might by facing a [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) (Cross Origin Request Sharing) issue.
It happens when the targeted service is hosted on a different domain or port.
Web browsers will not allow to fetch information from a different site without explicit permissions (the targeted service
must include a special `Access-Control-Allow-Origin: *` HTTP headers).
@ -41,18 +17,3 @@ To resolve this, you can either:
* Host all your target service under the same domain & port.
* Modify the target server configuration so that the response of the server included following header- `Access-Control-Allow-Origin: *` (<https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests>). It might be an option in the targeted service, otherwise depending on how the service is hosted, the proxy or web server can seamlessly add it.
* Use a cors proxy server like [`cors-container`](https://github.com/imjacobclark/cors-container), [`cors-anywhere`](https://github.com/Rob--W/cors-anywhere) or many others.
## I am using an authentication proxy and homer says I am offline
This should be a configuration issue.
* Make sure the option `connectivityCheck` is set to `true` in configuration.
* Check your proxy configuration, the expected behavior is to redirect user using a 302 to the login page when user is not authenticated.
## I put my API key into the OpenWeather service and it still isn't working
If you have just made an OpenWeatherMap account and/or a newly-made API key, there is a high chance that you need to wait for it to be activated (often a few hours). If after waiting it still doesn't work, make sure to check the location you have provided since it may be an invalid location.
For some basic debugging steps, you can:
* Check with a large city such as Amsterdam as the specified location within your configuration.
* Make sure your web browser is running the latest version of the homer configuration after updating the location (Ctrl + Shift + R).
* Check for errors within the browser console (Ctrl + Shift + I) relating to api.openweathermap.org

View File

@ -1,22 +0,0 @@
# Dummy data
This directory content makes possible to test custom services cards or create a demo without actually running the service.
The principle is simple: save a sample output of the API used in the service in a static file in this directory. The path must be identical as the service endpoint to be used seamlessly.
## Start the mock server to expose dummy data
```sh
yarn mock
```
## How to add a new services sample
- create a directory for your service, and any sub-folder existing in the service api path.
- save the api output in a file named after the service endpoint.
Example:
```sh
mkdir pihole
curl http://my-pihole.me/admin/api.php -o pihole/api.php # /admin is omitted because for PiHole, the implementation expect it to be in the base url (`url` or `endpoint` property)
```

View File

@ -1,5 +0,0 @@
/*
Content-Type: application/json
Access-Control-Allow-Origin: https://homer-demo.netlify.app
Access-Control-Allow-Headers: Authorization
Access-Control-Allow-Methods: GET, OPTIONS

View File

@ -1,30 +0,0 @@
{
"job": {
"averagePrintTime": 669.3131185749999,
"estimatedPrintTime": 314.87566979223726,
"filament": {
"tool0": {
"length": 134.81171000000032,
"volume": 0.0
}
},
"file": {
"date": 1665547748,
"display": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"name": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"origin": "local",
"path": "MISC/CE3PRO_3mmX3mm Brass insert V2.gcode",
"size": 129581
},
"lastPrintTime": 669.3131185749999,
"user": "friendlyngeeks"
},
"progress": {
"completion": 27.456185706237797,
"filepos": 35578,
"printTime": 460,
"printTimeLeft": 4612,
"printTimeLeftOrigin": "linear"
},
"state": "Printing"
}

View File

@ -1,26 +0,0 @@
{
"error": "SerialException: device reports readiness to read but returned no data (device disconnected or multiple access on port?)",
"job": {
"averagePrintTime": null,
"estimatedPrintTime": null,
"filament": null,
"file": {
"date": null,
"display": null,
"name": null,
"origin": null,
"path": null,
"size": null
},
"lastPrintTime": null,
"user": null
},
"progress": {
"completion": null,
"filepos": null,
"printTime": null,
"printTimeLeft": null,
"printTimeLeftOrigin": null
},
"state": "Offline after error"
}

View File

@ -1,26 +0,0 @@
{
"job": {
"estimatedPrintTime": null,
"filament": {
"length": null,
"volume": null
},
"file": {
"date": null,
"name": null,
"origin": null,
"path": null,
"size": null
},
"lastPrintTime": null,
"user": null
},
"progress": {
"completion": null,
"filepos": null,
"printTime": null,
"printTimeLeft": null,
"printTimeOrigin": null
},
"state": "Offline"
}

View File

@ -1,26 +0,0 @@
{
"job": {
"estimatedPrintTime": null,
"filament": {
"length": null,
"volume": null
},
"file": {
"date": null,
"name": null,
"origin": null,
"path": null,
"size": null
},
"lastPrintTime": null,
"user": null
},
"progress": {
"completion": null,
"filepos": null,
"printTime": null,
"printTimeLeft": null,
"printTimeOrigin": null
},
"state": "Operational"
}

View File

@ -1,30 +0,0 @@
{
"job": {
"averagePrintTime": 669.3131185749999,
"estimatedPrintTime": 314.87566979223726,
"filament": {
"tool0": {
"length": 134.81171000000032,
"volume": 0.0
}
},
"file": {
"date": 1665547748,
"display": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"name": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"origin": "local",
"path": "MISC/CE3PRO_3mmX3mm Brass insert V2.gcode",
"size": 129581
},
"lastPrintTime": 669.3131185749999,
"user": "friendlyngeeks"
},
"progress": {
"completion": 0.1551153332664511,
"filepos": 201,
"printTime": 0,
"printTimeLeft": 668,
"printTimeLeftOrigin": "average"
},
"state": "Printing"
}

View File

@ -1,30 +0,0 @@
{
"job": {
"averagePrintTime": 669.3131185749999,
"estimatedPrintTime": 314.87566979223726,
"filament": {
"tool0": {
"length": 134.81171000000032,
"volume": 0.0
}
},
"file": {
"date": 1665547748,
"display": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"name": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"origin": "local",
"path": "MISC/CE3PRO_3mmX3mm Brass insert V2.gcode",
"size": 129581
},
"lastPrintTime": 669.3131185749999,
"user": "friendlyngeeks"
},
"progress": {
"completion": 0.1551153332664511,
"filepos": 201,
"printTime": 0,
"printTimeLeft": 668,
"printTimeLeftOrigin": "average"
},
"state": "Printing"
}

View File

@ -1,30 +0,0 @@
{
"job": {
"averagePrintTime": 669.3131185749999,
"estimatedPrintTime": 314.87566979223726,
"filament": {
"tool0": {
"length": 134.81171000000032,
"volume": 0.0
}
},
"file": {
"date": 1665547748,
"display": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"name": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"origin": "local",
"path": "MISC/CE3PRO_3mmX3mm Brass insert V2.gcode",
"size": 129581
},
"lastPrintTime": 669.3131185749999,
"user": "friendlyngeeks"
},
"progress": {
"completion": 27.456185706237797,
"filepos": 35578,
"printTime": 476,
"printTimeLeft": 1612,
"printTimeLeftOrigin": "linear"
},
"state": "Printing"
}

View File

@ -1,30 +0,0 @@
{
"job": {
"averagePrintTime": 698.814525153,
"estimatedPrintTime": 314.87566979223726,
"filament": {
"tool0": {
"length": 134.81171000000032,
"volume": 0.0
}
},
"file": {
"date": 1665547748,
"display": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"name": "CE3PRO_3mmX3mm Brass insert V2.gcode",
"origin": "local",
"path": "MISC/CE3PRO_3mmX3mm Brass insert V2.gcode",
"size": 129581
},
"lastPrintTime": 728.315931731,
"user": "friendlyngeeks"
},
"progress": {
"completion": 100.0,
"filepos": 129581,
"printTime": 728,
"printTimeLeft": 0,
"printTimeLeftOrigin": null
},
"state": "Operational"
}

View File

@ -1,46 +0,0 @@
{
"coord": {
"lon": 4.5833,
"lat": 45.75
},
"weather": [
{
"id": 804,
"main": "Clouds",
"description": "overcast clouds",
"icon": "04d"
}
],
"base": "stations",
"main": {
"temp": 23,
"feels_like": 22.3,
"temp_min": 21.75,
"temp_max": 25.03,
"pressure": 1019,
"humidity": 36,
"sea_level": 1019,
"grnd_level": 957
},
"visibility": 10000,
"wind": {
"speed": 2.29,
"deg": 174,
"gust": 6.22
},
"clouds": {
"all": 97
},
"dt": 1667136939,
"sys": {
"type": 2,
"id": 2005747,
"country": "FR",
"sunrise": 1667110705,
"sunset": 1667147524
},
"timezone": 3600,
"id": 2996943,
"name": "Lyon",
"cod": 200
}

View File

@ -1,38 +0,0 @@
{
"domains_being_blocked": 152588,
"dns_queries_today": 0,
"ads_blocked_today": 0,
"ads_percentage_today": 42,
"unique_domains": 0,
"queries_forwarded": 0,
"queries_cached": 0,
"clients_ever_seen": 0,
"unique_clients": 0,
"dns_queries_all_types": 0,
"reply_UNKNOWN": 0,
"reply_NODATA": 0,
"reply_NXDOMAIN": 0,
"reply_CNAME": 0,
"reply_IP": 0,
"reply_DOMAIN": 0,
"reply_RRNAME": 0,
"reply_SERVFAIL": 0,
"reply_REFUSED": 0,
"reply_NOTIMP": 0,
"reply_OTHER": 0,
"reply_DNSSEC": 0,
"reply_NONE": 0,
"reply_BLOB": 0,
"dns_queries_all_replies": 0,
"privacy_level": 0,
"status": "enabled",
"gravity_last_updated": {
"file_exists": true,
"absolute": 1665486627,
"relative": {
"days": 0,
"hours": 0,
"minutes": 22
}
}
}

View File

@ -1,35 +0,0 @@
{
"data": [{
"disk": 0,
"mem": 983848043,
"cpus": 2,
"pid": 1218,
"maxdisk": 107374182400,
"netin": 43863882954,
"diskread": 0,
"diskwrite": 0,
"name": "HAOS",
"netout": 10426448652,
"cpu": 0.00879886290177172,
"uptime": 3390069,
"status": "running",
"maxmem": 3221225472,
"vmid": 100
}, {
"cpu": 0.00219971572544293,
"name": "debian1",
"netout": 919020028,
"vmid": 101,
"maxmem": 4294967296,
"uptime": 3390064,
"status": "running",
"maxdisk": 107374182400,
"pid": 1295,
"cpus": 2,
"disk": 0,
"mem": 2755160795,
"diskread": 0,
"diskwrite": 0,
"netin": 5105600872
}]
}

View File

@ -1,35 +0,0 @@
{
"data": [{
"disk": 0,
"mem": 983848043,
"cpus": 2,
"pid": 1218,
"maxdisk": 107374182400,
"netin": 43863882954,
"diskread": 0,
"diskwrite": 0,
"name": "HAOS",
"netout": 10426448652,
"cpu": 0.00879886290177172,
"uptime": 3390069,
"status": "running",
"maxmem": 3221225472,
"vmid": 100
}, {
"cpu": 0.00219971572544293,
"name": "debian1",
"netout": 919020028,
"vmid": 101,
"maxmem": 4294967296,
"uptime": 3390064,
"status": "running",
"maxdisk": 107374182400,
"pid": 1295,
"cpus": 2,
"disk": 0,
"mem": 2755160795,
"diskread": 0,
"diskwrite": 0,
"netin": 5105600872
}]
}

View File

@ -1,44 +0,0 @@
{
"data": {
"swap": {
"free": 8589930496,
"total": 8589930496,
"used": 0
},
"cpuinfo": {
"model": "Intel(R) Core(TM) i7-4790 CPU @3.60GHz",
"hvm": "1",
"user_hz": 100,
"sockets": 1,
"cpus": 8,
"flags": "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm cpuid_fault epb invpcid_single pti tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm xsaveopt dtherm ida arat pln pts",
"cores": 4,
"mhz": "4000.000"
},
"idle": 0,
"memory": {
"used": 6283382784,
"total": 12419133440,
"free": 6135750656
},
"rootfs": {
"avail": 22670036992,
"free": 24176627712,
"total": 29148368896,
"used": 4971741184
},
"uptime": 3390081,
"ksm": {
"shared": 1079975936
},
"cpu": 0.00440286186020914,
"kversion": "Linux 5.15.30-2-pve #1 SMP PVE 5.15.30-3 (Fri, 22 Apr 2022 18: 08: 27+0200)",
"loadavg": [
"0.00",
"0.01",
"0.04"
],
"pveversion": "pve-manager/7.2-3/c743d6c1",
"wait": 0.00330214639515685
}
}

View File

@ -1,53 +0,0 @@
[
{
"added_on": 1666985518,
"amount_left": 0,
"auto_tmm": false,
"availability": -1,
"category": "",
"completed": 1474873344,
"completion_on": 1666985584,
"content_path": "/downloads/ubuntu-22.04.1-live-server-amd64.iso",
"dl_limit": -1,
"dlspeed": 0,
"download_path": "",
"downloaded": 1513976240,
"downloaded_session": 0,
"eta": 8640000,
"f_l_piece_prio": false,
"force_start": false,
"hash": "cf3ea75e2ebbd30e0da6e6e215e2226bf35f2e33",
"infohash_v1": "cf3ea75e2ebbd30e0da6e6e215e2226bf35f2e33",
"infohash_v2": "",
"last_activity": 1666985588,
"magnet_uri": "magnet:?xt=urn:btih:cf3ea75e2ebbd30e0da6e6e215e2226bf35f2e33&dn=ubuntu-22.04.1-live-server-amd64.iso&tr=https%3a%2f%2ftorrent.ubuntu.com%2fannounce&tr=https%3a%2f%2fipv6.torrent.ubuntu.com%2fannounce",
"max_ratio": 0,
"max_seeding_time": -1,
"name": "ubuntu-22.04.1-live-server-amd64.iso",
"num_complete": 0,
"num_incomplete": 583,
"num_leechs": 0,
"num_seeds": 0,
"priority": 0,
"progress": 1,
"ratio": 1.7163413343924075e-05,
"ratio_limit": -2,
"save_path": "/downloads/",
"seeding_time": 4,
"seeding_time_limit": -2,
"seen_complete": 1666985584,
"seq_dl": false,
"size": 1474873344,
"state": "pausedUP",
"super_seeding": false,
"tags": "",
"time_active": 69,
"total_size": 1474873344,
"tracker": "",
"trackers_count": 2,
"up_limit": -1,
"uploaded": 25985,
"uploaded_session": 0,
"upspeed": 0
}
]

View File

@ -1,10 +0,0 @@
{
"connection_status": "connected",
"dht_nodes": 318,
"dl_info_data": 23481469329,
"dl_info_speed": 1234567,
"dl_rate_limit": 40960000,
"up_info_data": 1788370216,
"up_info_speed": 765432,
"up_rate_limit": 10547200
}

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<params>
<param><value><array><data>
<value><string>2BAC78C9E10D82415142E57D24601F2FD8927816</string></value>
<value><string>8BB10DB9EA239106D4907601C342ABBA29BE4391</string></value>
<value><string>2790CE71493BE7083929D5A1CE9CFD6B8394F224</string></value>
</data></array></value></param>
</params>
</methodResponse>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<params>
<param><value><i8>149279</i8></value></param>
</params>
</methodResponse>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<params>
<param><value><i8>45616</i8></value></param>
</params>
</methodResponse>

View File

@ -1,7 +0,0 @@
{
"data": {
"download": 42.452234,
"upload": 34.3948,
"ping": 12.9873
}
}

View File

@ -1,470 +0,0 @@
{
"totalFileCount": 3245,
"totalTranscodeCount": 3148,
"totalHealthCheckCount": 7278,
"sizeDiff": 5265.423687950708,
"_id": "statistics",
"tdarrScore": "99.97",
"healthCheckScore": "99.97",
"table0Count": 0,
"table2Count": 3244,
"table3Count": 0,
"table4Count": 1,
"table5Count": 3244,
"table6Count": 0,
"table1Count": 1,
"pies": [
[
"All",
"all",
3245,
3148,
5265.423687950708,
7278,
[
{
"name": "Transcode success",
"value": 1995
},
{
"name": "Not required",
"value": 1249
},
{
"name": "Queued",
"value": 1
}
],
[
{
"name": "Success",
"value": 3244
},
{
"name": "Queued",
"value": 1
}
],
[
{
"name": "hevc",
"value": 3172
},
{
"name": "vp9",
"value": 48
},
{
"name": "h264",
"value": 24
}
],
[
{
"name": "mkv",
"value": 3115
},
{
"name": "webm",
"value": 48
},
{
"name": "mp4",
"value": 81
}
],
[
{
"name": "1080p",
"value": 2582
},
{
"name": "480p",
"value": 406
},
{
"name": "720p",
"value": 224
},
{
"name": "4KUHD",
"value": 29
},
{
"name": "576p",
"value": 3
}
],
[],
[]
],
[
"Type1",
"t7_0knr-z",
3,
0,
0,
3,
[
{
"name": "Not required",
"value": 3
}
],
[
{
"name": "Success",
"value": 3
}
],
[
{
"name": "hevc",
"value": 3
}
],
[
{
"name": "mkv",
"value": 3
}
],
[
{
"name": "480p",
"value": 3
}
],
[],
[]
],
[
"Type2",
"ekyBRmWbD",
9,
13,
10.722183834761381,
65,
[
{
"name": "Transcode success",
"value": 9
}
],
[
{
"name": "Success",
"value": 9
}
],
[
{
"name": "hevc",
"value": 9
}
],
[
{
"name": "mkv",
"value": 9
}
],
[
{
"name": "480p",
"value": 1
},
{
"name": "576p",
"value": 1
},
{
"name": "720p",
"value": 4
},
{
"name": "1080p",
"value": 3
}
],
[],
[]
],
[
"Type3",
"-dy1H5yNz",
2619,
2641,
2710.185842271894,
5837,
[
{
"name": "Transcode success",
"value": 1586
},
{
"name": "Not required",
"value": 1033
}
],
[
{
"name": "Success",
"value": 2619
}
],
[
{
"name": "hevc",
"value": 2571
},
{
"name": "vp9",
"value": 48
}
],
[
{
"name": "mkv",
"value": 2510
},
{
"name": "webm",
"value": 48
},
{
"name": "mp4",
"value": 61
}
],
[
{
"name": "1080p",
"value": 2050
},
{
"name": "720p",
"value": 186
},
{
"name": "480p",
"value": 383
}
],
[],
[]
],
[
"Type4",
"ASRD2TAeP",
1,
11,
83.31165281962603,
32,
[
{
"name": "Queued",
"value": 1
}
],
[
{
"name": "Queued",
"value": 1
}
],
[
{
"name": "h264",
"value": 1
}
],
[
{
"name": "mp4",
"value": 1
}
],
[
{
"name": "1080p",
"value": 1
}
],
[],
[]
],
[
"Type5",
"KQ03rLWIw",
11,
14,
17.225701110437512,
43,
[
{
"name": "Not required",
"value": 11
}
],
[
{
"name": "Success",
"value": 11
}
],
[
{
"name": "hevc",
"value": 11
}
],
[
{
"name": "mkv",
"value": 11
}
],
[
{
"name": "720p",
"value": 6
},
{
"name": "480p",
"value": 4
},
{
"name": "1080p",
"value": 1
}
],
[],
[]
],
[
"Type6",
"RQhHe9OCl",
602,
473,
2420.9242209186777,
1300,
[
{
"name": "Not required",
"value": 202
},
{
"name": "Transcode success",
"value": 400
}
],
[
{
"name": "Success",
"value": 602
}
],
[
{
"name": "hevc",
"value": 578
},
{
"name": "h264",
"value": 23
}
],
[
{
"name": "mkv",
"value": 582
},
{
"name": "mp4",
"value": 19
}
],
[
{
"name": "480p",
"value": 15
},
{
"name": "1080p",
"value": 527
},
{
"name": "4KUHD",
"value": 29
},
{
"name": "720p",
"value": 28
},
{
"name": "576p",
"value": 2
}
],
[],
[]
]
],
"streamStats": {
"duration": {
"average": 3127,
"highest": 8548,
"total": 253273
},
"bit_rate": {
"average": 2242894,
"highest": 20149278,
"total": 181674395
},
"nb_frames": {
"average": 75320,
"highest": 204941,
"total": 6100852
}
},
"avgNumberOfStreamsInVideo": 5.049321824907522,
"languages": {
"ara": {
"count": 181
},
"est": {
"count": 62
},
"lav": {
"count": 62
},
"may": {
"count": 131
},
"nor": {
"count": 110
},
"chi": {
"count": 384
},
"ind": {
"count": 63
},
"rum": {
"count": 138
},
"nob": {
"count": 18
},
"srp": {
"count": 3
}
},
"DBPollPeriod": "1s",
"DBFetchTime": "1s",
"DBLoadStatus": "Stable",
"DBQueue": 0,
"processWarning": "",
"processWarningQueues": true
}

View File

@ -1,18 +1,15 @@
#!/bin/sh
PERMISSION_ERROR="Check assets directory permissions & docker user or skip default assets install by setting the INIT_ASSETS env var to 0"
# Ensure default assets are present.
while true; do echo n; done | cp -Ri /www/default-assets/* /www/assets/ &> /dev/null
# Default assets & exemple configuration installation if possible.
if [[ "${INIT_ASSETS}" == "1" ]] && [[ ! -f "/www/assets/config.yml" ]]; then
echo "No configuration found, installing default config & assets"
if [[ ! -w "/www/assets/" ]]; then echo "Assets directory not writable. $PERMISSION_ERROR" && exit 1; fi
while true; do echo n; done | cp -Ri /www/default-assets/* /www/assets/ &> /dev/null
if [[ $? -ne 0 ]]; then echo "Fail to copy default assets. $PERMISSION_ERROR" && exit 1; fi
yes n | cp -i /www/default-assets/config.yml.dist /www/assets/config.yml &> /dev/null
if [[ $? -ne 0 ]]; then echo "Fail to copy default config file. $PERMISSION_ERROR" && exit 1; fi
# Ensure compatibility with previous version (config.yml was in the root directory)
if [ -f "/www/config.yml" ]; then
yes n | cp -i /www/config.yml /www/assets/ &> /dev/null
fi
echo "Starting webserver"
exec lighttpd -D -f /lighttpd.conf
# Install default config if no one is available.
yes n | cp -i /www/default-assets/config.yml.dist /www/assets/config.yml &> /dev/null
chown -R $UID:$GID /www/assets
exec su-exec $UID:$GID darkhttpd /www/ --no-listing --port "$PORT"

8
hooks/post_push Normal file
View File

@ -0,0 +1,8 @@
#!/bin/bash
IFS='-' read -r TAG string <<< "$DOCKER_TAG"
docker manifest create b4bz/homer:$TAG b4bz/homer:$TAG-amd64 b4bz/homer:$TAG-arm32v7 b4bz/homer:$TAG-arm64v8
docker manifest annotate b4bz/homer:$TAG b4bz/homer:$TAG-arm32v7 --os linux --arch arm
docker manifest annotate b4bz/homer:$TAG b4bz/homer:$TAG-arm64v8 --os linux --arch arm64 --variant v8
docker manifest push --purge b4bz/homer:$TAG

8
hooks/pre_build Normal file
View File

@ -0,0 +1,8 @@
#!/bin/bash
# Update to docker-ee 18.x for manifests
apt-get -y update
apt-get -y --only-upgrade install docker-ee
# Register qemu-*-static for all supported processors except the
# current one, but also remove all registered binfmt_misc before
docker run --rm --privileged multiarch/qemu-user-static:register --reset

View File

@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="assets/icons/favicon.ico" />
<link rel="apple-touch-icon" href="assets/icons/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="assets/icons/logo.svg">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
<title>Homer</title>
</head>
<body>
<div id="app-mount"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -1,11 +0,0 @@
include "/etc/lighttpd/mime-types.conf"
server.port = env.PORT
server.modules = ( "mod_alias" )
server.username = "lighttpd"
server.groupname = "lighttpd"
server.document-root = "/www"
alias.url = ( env.SUBFOLDER => "/www" )
server.indexfiles = ("index.html")
server.follow-symlink = "enable"
server.feature-flags += ( "server.clock-jump-restart" => 0 )

View File

@ -1,31 +1,35 @@
{
"name": "homer",
"version": "22.07.2",
"version": "21.09.1",
"scripts": {
"dev": "vite",
"mock": "http-server dummy-data/ --cors",
"build": "vite build",
"preview": "vite preview --port 5050",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.2.0",
"bulma": "^0.9.4",
"@fortawesome/fontawesome-free": "^5.15.4",
"bulma": "^0.9.3",
"core-js": "^3.21.1",
"js-yaml": "^4.1.0",
"lodash.merge": "^4.6.2",
"vue": "^3.2.41",
"yaml": "^2.1.3"
"register-service-worker": "^1.7.2",
"vue": "^2.6.14"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.2.0",
"@vitejs/plugin-vue": "^3.2.0",
"@vue/eslint-config-prettier": "^7.0.0",
"eslint": "^8.26.0",
"eslint-plugin-vue": "^9.6.0",
"http-server": "^14.1.1",
"prettier": "^2.7.1",
"sass": "^1.55.0",
"vite": "^3.2.1",
"vite-plugin-pwa": "^0.13.1"
"@vue/cli-plugin-babel": "~4.5.15",
"@vue/cli-plugin-eslint": "~4.5.15",
"@vue/cli-plugin-pwa": "~4.5.15",
"@vue/cli-service": "~4.5.15",
"@vue/eslint-config-prettier": "^6.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-vue": "^6.2.2",
"prettier": "^2.2.1",
"raw-loader": "^4.0.2",
"sass": "^1.26.5",
"sass-loader": "^8.0.2",
"vue-template-compiler": "^2.6.12"
},
"license": "Apache-2.0"
}

View File

@ -1,131 +0,0 @@
---
# Homepage configuration
# See https://fontawesome.com/v5/search for icons options
title: "Demo dashboard"
subtitle: "Homer"
logo: "logo.png"
# icon: "fas fa-skull-crossbones" # Optional icon
header: true
footer: '<p>Created with <span class="has-text-danger">❤️</span> with <a href="https://bulma.io/">Bulma</a>, <a href="https://vuejs.org/">Vue.js</a> & <a href="https://fontawesome.com/">font awesome</a> // Fork me on <a href="https://github.com/bastienwirtz/homer"><i class="fab fa-github-alt"></i></a></p>' # set false if you want to hide it.
# Optional theme customization
theme: default
colors:
light:
highlight-primary: "#3367d6"
highlight-secondary: "#4285f4"
highlight-hover: "#5a95f5"
background: "#f5f5f5"
card-background: "#ffffff"
text: "#363636"
text-header: "#ffffff"
text-title: "#303030"
text-subtitle: "#424242"
card-shadow: rgba(0, 0, 0, 0.1)
link: "#3273dc"
link-hover: "#363636"
dark:
highlight-primary: "#3367d6"
highlight-secondary: "#4285f4"
highlight-hover: "#5a95f5"
background: "#131313"
card-background: "#2b2b2b"
text: "#eaeaea"
text-header: "#ffffff"
text-title: "#fafafa"
text-subtitle: "#f5f5f5"
card-shadow: rgba(0, 0, 0, 0.4)
link: "#3273dc"
link-hover: "#ffdd57"
# Optional message
message:
style: "is-dark" # See https://bulma.io/documentation/components/message/#colors for styling options.
title: "👋 Welcome !"
content: "This demo page shows an overview of Homer possibilities. Feel free to click anywhere!<br /> Find more information on <a href='https://github.com/bastienwirtz/homer'>github.com/bastienwirtz/homer</a>"
# Optional navbar
# links: [] # Allows for navbar (dark mode, layout, and search) without any links
links:
- name: "Contribute"
icon: "fab fa-github"
url: "https://github.com/bastienwirtz/homer"
target: "_blank" # optional html a tag target attribute
- name: "Documentation"
icon: "fas fa-book"
url: "https://github.com/bastienwirtz/homer/blob/main/README.md#table-of-contents"
# this will link to a second homer page that will load config from additional-page.yml and keep default config values as in config.yml file
# see url field and assets/additional-page.yml.dist used in this example:
- name: "another page!"
icon: "fas fa-file-alt"
url: "#additional-page"
# Services
# First level array represent a group.
# Leave only a "items" key if not using group (group name, icon & tagstyle are optional, section separation will not be displayed).
services:
- name: "My apps"
icon: "fas fa-cloud"
items:
- name: "Pi-hole"
logo: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/pihole.png"
url: "https://pi-hole.net/"
endpoint: "https://homer-demo-content.netlify.app/pihole"
type: "PiHole"
- name: "Proxmox - Node1"
logo: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/proxmox.png"
type: "Proxmox"
tag: "sys"
url: "https://www.proxmox.com/en/"
endpoint: "https://homer-demo-content.netlify.app/proxmox"
node: "node1"
warning_value: 50
danger_value: 80
api_token: "xxxxxxxxxxxx"
- name: "An awesome app"
logo: "assets/tools/sample.png"
subtitle: "Bookmark example"
tag: "app"
keywords: "self hosted reddit"
url: "https://www.reddit.com/r/selfhosted/"
- name: "Tools"
icon: "fa-solid fa-screwdriver-wrench"
items:
- name: "Octoprint"
logo: "https://cdn-icons-png.flaticon.com/512/3112/3112529.png"
apikey: "xxxxxxxxxxxx"
endpoint: "https://homer-demo-content.netlify.app/octoprint"
type: "OctoPrint"
- name: "Example item"
logo: "assets/tools/sample.png"
subtitle: "This another example"
tag: "app"
keywords: "demo"
url: "#"
target: "_blank"
- name: "Weather"
location: "Lyon"
apikey: "xxxxxxxxxxxx" # insert your own API key here. Request one from https://openweathermap.org/api.
units: "metric"
endpoint: "https://homer-demo-content.netlify.app/openweather/weather"
type: "OpenWeather"
- name: "interesting links"
icon: "fas fa-solid fa-arrow-up-right-from-square"
items:
- name: "Buy me a coffee !"
subtitle: "If you want to support me, buy me a coffee "
logo: "https://www.buymeacoffee.com/assets/img/guidelines/logo-mark-1.svg"
url: "https://www.buymeacoffee.com/bastien"
- name: "r/selfhosted"
icon: "fa-brands fa-reddit-alien"
subtitle: "Bookmark example"
tag: "reddit"
url: "https://www.reddit.com/r/selfhosted/"
target: "_blank"
- name: "Awesome selfhosted"
icon: "fa-brands fa-github-alt"
subtitle: "Another application"
tag: "awesome-list"
url: "https://github.com/awesome-selfhosted/awesome-selfhosted"

View File

@ -60,9 +60,9 @@ links:
url: "https://www.wikipedia.org/"
# this will link to a second homer page that will load config from additional-page.yml and keep default config values as in config.yml file
# see url field and assets/additional-page.yml.dist used in this example:
#- name: "another page!"
# icon: "fas fa-file-alt"
# url: "#additional-page"
- name: "another page!"
icon: "fas fa-file-alt"
url: "#additional-page"
# Services
# First level array represent a group.
@ -75,7 +75,6 @@ services:
logo: "assets/tools/sample.png"
subtitle: "Bookmark example"
tag: "app"
keywords: "self hosted reddit"
url: "https://www.reddit.com/r/selfhosted/"
target: "_blank" # optional html a tag target attribute
- name: "Another one"

View File

@ -1,12 +0,0 @@
# PWA Icons / Images
We suggest you to create a svg or png icon (if it is a png icon, with the maximum resolution possible) for your application and use it to generate a favicon package in [Favicon Generator](https://realfavicongenerator.net/).
Once generated, download the ZIP and use android-* icons for pwa-*:
- use `android-chrome-192x192.png` for `pwa-192x192.png`
- use `android-chrome-512x512.png` for `pwa-512x512.png`
- `apple-touch-icon.png` is `apple-touch-icon.png`
- `favicon.ico` is `favicon.ico`
`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,42 @@
{
"name": "Homer Dashboard",
"short_name": "Homer",
"theme_color": "#3367D6",
"start_url": "../",
"icons": [
{
"src": "./icons/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "./icons/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "./icons/icon-any.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/icon-any.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "./icons/icon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "./icons/safari-pinned-tab.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "monochrome"
}
]
}

18
public/index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
<meta name="robots" content="noindex">
<link rel="icon" href="<%= BASE_URL %>favicon.png">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 36 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

View File

@ -4,7 +4,6 @@
v-if="config"
:class="[
`theme-${config.theme}`,
`page-${currentPage}`,
isDark ? 'is-dark' : 'is-light',
!config.footer ? 'no-footer' : '',
]"
@ -19,10 +18,7 @@
</a>
<i v-if="config.icon" :class="config.icon"></i>
</div>
<div
class="dashboard-title"
:class="{ 'no-logo': !config.icon || !config.logo }"
>
<div class="dashboard-title">
<span class="headline">{{ config.subtitle }}</span>
<h1>{{ config.title }}</h1>
</div>
@ -50,10 +46,10 @@
<SearchInput
class="navbar-item is-inline-block-mobile"
:hotkey="searchHotkey()"
@input="filterServices($event.target?.value)"
@input="filterServices"
@search-focus="showMenu = true"
@search-open="navigateToFirstService($event?.target?.value)"
@search-cancel="filterServices()"
@search-open="navigateToFirstService"
@search-cancel="filterServices"
/>
</Navbar>
</div>
@ -65,7 +61,7 @@
@network-status-update="offline = $event"
/>
<GetStarted v-if="configurationNeeded" />
<GetStarted v-if="loaded && !services" />
<div v-if="!offline">
<!-- Optional messages -->
@ -73,12 +69,8 @@
<!-- Horizontal layout -->
<div v-if="!vlayout || filter" class="columns is-multiline">
<template v-for="(group, groupIndex) in services">
<h2
v-if="group.name"
class="column is-full group-title"
:key="`header-${groupIndex}`"
>
<template v-for="group in services">
<h2 v-if="group.name" class="column is-full group-title">
<i v-if="group.icon" :class="['fa-fw', group.icon]"></i>
<div v-else-if="group.logo" class="group-logo media-left">
<figure class="image is-48x48">
@ -89,9 +81,10 @@
</h2>
<Service
v-for="(item, index) in group.items"
:key="`service-${groupIndex}-${index}`"
:key="index"
:item="item"
:proxy="config.proxy"
:forwarder="config.forwarder"
:class="['column', `is-${12 / config.columns}`]"
/>
</template>
@ -104,8 +97,8 @@
>
<div
:class="['column', `is-${12 / config.columns}`]"
v-for="(group, groupIndex) in services"
:key="groupIndex"
v-for="group in services"
:key="group.name"
>
<h2 v-if="group.name" class="group-title">
<i v-if="group.icon" :class="['fa-fw', group.icon]"></i>
@ -121,6 +114,7 @@
:key="index"
:item="item"
:proxy="config.proxy"
:forwarder="config.forwarder"
/>
</div>
</div>
@ -141,8 +135,8 @@
</template>
<script>
import { parse } from "yaml";
import merge from "lodash.merge";
const jsyaml = require("js-yaml");
const merge = require("lodash.merge");
import Navbar from "./components/Navbar.vue";
import GetStarted from "./components/GetStarted.vue";
@ -154,7 +148,7 @@ import SettingToggle from "./components/SettingToggle.vue";
import DarkMode from "./components/DarkMode.vue";
import DynamicTheme from "./components/DynamicTheme.vue";
import defaultConfig from "./assets/defaults.yml?raw";
import defaultConfig from "./assets/defaults.yml";
export default {
name: "App",
@ -172,8 +166,6 @@ export default {
data: function () {
return {
loaded: false,
currentPage: null,
configNotFound: false,
config: null,
services: null,
offline: false,
@ -183,11 +175,6 @@ export default {
showMenu: false,
};
},
computed: {
configurationNeeded: function () {
return (this.loaded && !this.services) || this.configNotFound;
},
},
created: async function () {
this.buildDashboard();
window.onhashchange = this.buildDashboard;
@ -200,17 +187,18 @@ export default {
}
},
buildDashboard: async function () {
const defaults = parse(defaultConfig);
const defaults = jsyaml.load(defaultConfig);
let config;
try {
config = await this.getConfig();
this.currentPage = window.location.hash.substring(1) || "default";
const path =
window.location.hash.substring(1) != ""
? window.location.hash.substring(1)
: null;
if (this.currentPage !== "default") {
let pageConfig = await this.getConfig(
`assets/${this.currentPage}.yml`
);
config = Object.assign(config, pageConfig);
if (path) {
let pathConfig = await this.getConfig(`assets/${path}.yml`); // the slash (/) is included in the pathname
config = Object.assign(config, pathConfig);
}
} catch (error) {
console.log(error);
@ -232,9 +220,10 @@ export default {
},
getConfig: function (path = "assets/config.yml") {
return fetch(path).then((response) => {
if (response.status == 404 || response.redirected) {
this.configNotFound = true;
return {};
if (response.redirected) {
// This allows to work with authentication proxies.
window.location.href = response.url;
return;
}
if (!response.ok) {
@ -245,7 +234,7 @@ export default {
return response
.text()
.then((body) => {
return parse(body);
return jsyaml.load(body);
})
.then(function (config) {
if (config.externalConfig) {
@ -256,12 +245,10 @@ export default {
});
},
matchesFilter: function (item) {
const needle = this.filter?.toLowerCase();
return (
item.name.toLowerCase().includes(needle) ||
(item.subtitle && item.subtitle.toLowerCase().includes(needle)) ||
(item.tag && item.tag.toLowerCase().includes(needle)) ||
(item.keywords && item.keywords.toLowerCase().includes(needle))
item.name.toLowerCase().includes(this.filter) ||
(item.subtitle && item.subtitle.toLowerCase().includes(this.filter)) ||
(item.tag && item.tag.toLowerCase().includes(this.filter))
);
},
navigateToFirstService: function (target) {
@ -273,7 +260,6 @@ export default {
}
},
filterServices: function (filter) {
console.log(filter);
this.filter = filter;
if (!filter) {

View File

@ -1,8 +1,8 @@
@charset "utf-8";
@import "./webfonts/webfonts.scss";
@import "../../node_modules/bulma/bulma";
@import "./components/status.scss";
@import "bulma";
// Themes import
@import "./themes/sui.scss";
@ -13,7 +13,7 @@
text-overflow: ellipsis;
}
html, body, body #app-mount, body #app {
html, body, body #app {
height: 100%;
background-color: var(--background);
}
@ -104,10 +104,6 @@ body {
.dashboard-title {
padding: 6px 0 0 80px;
&.no-logo {
padding-left: 0;
}
}
.first-line {
@ -167,7 +163,8 @@ body {
}
#main-section {
padding: 0 0 2.5rem 0;
margin-bottom: 2rem;
padding: 0;
h2 {
padding-bottom: 0px;
@ -289,7 +286,7 @@ body {
.no-footer {
#main-section {
padding-bottom: 0;
margin-bottom: 0;
}
.footer {

View File

@ -1,48 +0,0 @@
.status {
font-size: 0.8rem;
color: var(--text-title);
&.offline:before, &.error:before {
background-color: #d65c68;
box-shadow: 0 0 5px 1px #d65c68;
color: #d65c68;
}
&.pending:before {
background-color: #e8bb7d;
box-shadow: 0 0 5px 1px #e8bb7d;
}
&.online:before, &.ready:before {
background-color: #94e185;
box-shadow: 0 0 5px 1px #94e185;
}
&.in-progress:before {
background-color: #8fe87d;
box-shadow: 0 0 5px 1px #8fe87d;
animation: pulse 1s alternate infinite;
}
@keyframes pulse {
0% {
background: rgba(255, 255, 255, 0.2);
box-shadow: inset 0px 0px 10px 2px rgba(0, 255, 182, 0.3),
0px 0px 5px 2px rgba(0, 255, 135, 0.2);
}
100% {
background: rgba(255, 255, 255, 1);
box-shadow: inset 0px 0px 10px 2px rgba(0, 255, 182, 0.5),
0px 0px 15px 2px rgba(0, 255, 135, 1);
}
}
&:before {
content: " ";
display: inline-block;
width: 8px;
height: 8px;
margin-right: 10px;
border-radius: 8px;
}
}

View File

@ -52,4 +52,5 @@ links: []
services: []
proxy: ~
proxy: ~
forwarder: ~

View File

@ -17,9 +17,6 @@ export default {
};
},
created: function () {
if (/t=\d+/.test(window.location.href)) {
window.history.replaceState({}, document.title, window.location.pathname);
}
let that = this;
this.checkOffline();
@ -32,45 +29,15 @@ export default {
},
false
);
window.addEventListener(
"online",
function () {
that.checkOffline();
},
false
);
window.addEventListener(
"offline",
function () {
this.offline = true;
},
false
);
},
methods: {
checkOffline: function () {
if (!navigator.onLine) {
this.offline = true;
return;
}
// extra check to make sure we're not offline
let that = this;
const urlPath = window.location.pathname.replace(/\/+$/, "");
const aliveCheckUrl = `${window.location.origin}${urlPath}/index.html?t=${new Date().valueOf()}`;
return fetch(aliveCheckUrl, {
return fetch(window.location.href + "?alive", {
method: "HEAD",
cache: "no-store",
redirect: "manual",
})
.then(function (response) {
// opaqueredirect means request has been redirected, to auth provider probably
if (
(response.type === "opaqueredirect" && !response.ok) ||
[401, 403].indexOf(response.status) != -1
) {
window.location.href = aliveCheckUrl;
}
that.offline = !response.ok;
})
.catch(function () {

View File

@ -1,6 +1,6 @@
<template>
<a
@click="toggleTheme()"
v-on:click="toggleTheme()"
aria-label="Toggle dark mode"
class="navbar-item is-inline-block-mobile"
>

View File

@ -6,7 +6,7 @@
<p>
<a
class="button is-primary mt-5 has-text-weight-bold"
href="https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md#configuration"
href="https://github.com/bastienwirtz/homer/blob/main/README.md#getting-started"
target="_blank"
>
Get started

View File

@ -47,6 +47,7 @@ export default {
}
if (this.item.url) {
let fetchedMessage = await this.downloadMessage(this.item.url);
console.log("done");
if (this.item.mapping) {
fetchedMessage = this.mapRemoteMessage(fetchedMessage);
}

View File

@ -75,7 +75,7 @@ export default {
this.$emit("input", value.toLowerCase());
},
},
beforeUnmount() {
beforeDestroy() {
document.removeEventListener("keydown", this._keyListener);
},
};

View File

@ -1,9 +1,13 @@
<template>
<component :is="component" :item="item" :proxy="proxy"></component>
<component
v-bind:is="component"
:item="item"
:proxy="proxy"
:forwarder="forwarder"
></component>
</template>
<script>
import { defineAsyncComponent } from "vue";
import Generic from "./services/Generic.vue";
export default {
@ -11,6 +15,7 @@ export default {
props: {
item: Object,
proxy: Object,
forwarder: Object,
},
computed: {
component() {
@ -18,7 +23,7 @@ export default {
if (type === "Generic") {
return Generic;
}
return defineAsyncComponent(() => import(`./services/${type}.vue`));
return () => import(`./services/${type}.vue`);
},
},
};

View File

@ -1,8 +1,5 @@
<template>
<a
@click.prevent="toggleSetting()"
class="navbar-item is-inline-block-mobile"
>
<a v-on:click="toggleSetting()" class="navbar-item is-inline-block-mobile">
<span><i :class="['fas', 'fa-fw', value ? icon : secondaryIcon]"></i></span>
<slot></slot>
</a>

View File

@ -1,85 +0,0 @@
<template>
<Generic :item="item">
<template #indicator>
<div class="status">
<i
class="fa-regular fa-copy fa-xl"
:class="{ scale: animate }"
@click="copy()"
@animationend="animate = false"
></i>
</div>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "CopyToClipboard",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => ({
animate: false,
}),
methods: {
copy() {
navigator.clipboard.writeText(this.item.clipboard);
this.animate = true;
},
},
};
</script>
<style scoped lang="scss">
.scale {
-webkit-animation: scale-up 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
animation: scale-up 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
}
.is-light i {
color: black;
}
.is-dark i {
color: white;
}
/**
* ----------------------------------------
* animation scale-down-center
* ----------------------------------------
*/
@-webkit-keyframes scale-up {
0% {
-webkit-transform: scale(1);
transform: scale(1);
}
50% {
-webkit-transform: scale(1.25);
transform: scale(1.25);
}
100% {
-webkit-transform: scale(1);
transform: scale(1);
}
}
@keyframes scale-up {
0% {
-webkit-transform: scale(1);
transform: scale(1);
}
50% {
-webkit-transform: scale(1.25);
transform: scale(1.25);
}
100% {
-webkit-transform: scale(1);
transform: scale(1);
}
}
</style>

View File

@ -62,7 +62,7 @@ export default {
this.fetch("/System/info/public")
.then((response) => {
if (response.Id) this.status = "running";
else throw new Error();
else throw new Error();
})
.catch((e) => {
console.log(e);

View File

@ -1,115 +0,0 @@
<template>
<Generic :item="item">
<template #indicator>
<div class="notifs">
<strong v-if="up > 0" class="notif up" title="Up">
{{ up }}
</strong>
<strong v-if="down > 0" class="notif down" title="Down">
{{ down }}
</strong>
<strong v-if="grace > 0" class="notif grace" title="Grace">
{{ grace }}
</strong>
</div>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "Healthchecks",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => ({
api: null,
}),
computed: {
up: function () {
if (!this.api) {
return "";
}
return this.api.checks?.filter((check) => {
return check.status.toLowerCase() === "up";
}).length;
},
down: function () {
if (!this.api) {
return "";
}
return this.api.checks?.filter((check) => {
return check.status.toLowerCase() === "down";
}).length;
},
grace: function () {
if (!this.api) {
return "";
}
return this.api.checks?.filter((check) => {
return check.status.toLowerCase() === "grace";
}).length;
},
},
created() {
this.fetchStatus();
},
methods: {
fetchStatus: async function () {
const apikey = this.item.apikey;
if (!apikey) {
console.error(
"apikey is not present in config.yml for the Healthchecks entry!"
);
return;
}
const headers = {
"X-Api-Key": this.item.apikey,
};
this.api = await this.fetch("/api/v1/checks/", { headers }).catch((e) => {
console.error(e);
});
},
},
};
</script>
<style scoped lang="scss">
.notifs {
position: absolute;
color: white;
font-family: sans-serif;
top: 0.3em;
right: 0.5em;
.notif {
display: inline-block;
padding: 0.2em 0.35em;
border-radius: 0.25em;
position: relative;
margin-left: 0.3em;
font-size: 0.8em;
&.up {
background-color: #4fd671;
}
&.down {
background-color: #e51111;
}
&.grace {
background-color: #cdd02e;
}
}
}
</style>

View File

@ -1,108 +0,0 @@
<template>
<Generic :item="item" :title="state">
<template #content>
<p class="title is-4">{{ item.name }}</p>
<p class="subtitle is-6">
<template v-if="item.subtitle && !state">
{{ item.subtitle }}
</template>
<template v-if="!error && display == 'text'">
<i class="fa-solid fa-gear mr-1"></i>
<b v-if="completion">{{ completion.toFixed() }}%</b>
<span class="separator mx-1"> | </span>
<span v-if="printTime" :title="`${toTime(printTimeLeft)} left`">
<i class="fa-solid fa-stopwatch mr-1"></i>
{{ toTime(printTime) }}
</span>
</template>
<template v-if="!error && display == 'bar'">
<progress
v-if="completion"
class="progress is-primary"
:value="completion"
max="100"
:title="`${state} - ${completion.toFixed()}%, ${toTime(
printTimeLeft
)} left`"
>
{{ completion }}%
</progress>
</template>
<span v-if="error" :title="error">{{ error }}</span>
</p>
</template>
<template #indicator>
<i :class="['status', statusClass]" :title="state"></i>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "OctoPrint",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => ({
printTime: null,
printTimeLeft: null,
completion: null,
state: null,
error: null,
}),
computed: {
statusClass: function () {
switch (this.state) {
case "Operational":
return "ready";
case "Offline":
return "offline";
case "Printing":
return "in-progress";
default:
return "pending";
}
},
},
created() {
this.display = this.item.display == "bar" ? this.item.display : "text";
this.fetchStatus();
},
methods: {
fetchStatus: async function () {
try {
const response = await this.fetch(`api/job?apikey=${this.item.apikey}`);
this.printTime = response.progress.printTime;
this.printTimeLeft = response.progress.printTimeLeft;
this.completion = response.progress.completion;
this.state = response.state;
this.error = response.error;
} catch (e) {
this.error = `Fail to fetch octoprint data (${e.message})`;
console.error(e);
}
},
toTime: function (timastamp) {
return new Date(timastamp * 1000).toTimeString().substring(0, 5);
},
},
};
</script>
<style scoped lang="scss">
.fa-triangle-exclamation::before {
color: #d65c68;
}
.progress {
height: 8px;
width: 90%;
}
</style>

View File

@ -22,12 +22,7 @@
<div v-else>
<p class="title is-4">{{ name }}</p>
<p class="subtitle is-6">
<span>
{{ temperature }}
</span>
<span class="location-time">
{{ locationTime }}
</span>
{{ temp | tempSuffix(this.item.units) }}
</p>
</div>
</div>
@ -54,24 +49,7 @@ export default {
temp: null,
conditions: null,
error: false,
timezoneOffset: 0,
}),
computed: {
temperature: function () {
if (!this.temp) return "";
let unit = "K";
if (this.item.units === "metric") {
unit = "°C";
} else if (this.item.units === "imperial") {
unit = "°F";
}
return `${this.temp} ${unit}`;
},
locationTime: function () {
return this.calcTime(this.timezoneOffset);
},
},
created() {
this.fetchWeather();
},
@ -87,11 +65,7 @@ export default {
}
const apiKey = this.item.apikey || this.item.apiKey;
let url = `https://api.openweathermap.org/data/2.5/weather?${locationQuery}&appid=${apiKey}&units=${this.item.units}`;
if (this.item.endpoint) {
url = this.item.endpoint;
}
const url = `https://api.openweathermap.org/data/2.5/weather?${locationQuery}&appid=${apiKey}&units=${this.item.units}`;
fetch(url)
.then((response) => {
if (!response.ok) {
@ -105,22 +79,24 @@ export default {
this.temp = parseInt(weather.main.temp).toFixed(1);
this.icon = weather.weather[0].icon;
this.conditions = weather.weather[0].description;
this.timezoneOffset = weather.timezone;
})
.catch((e) => {
console.log(e);
this.error = true;
});
},
calcTime: (offset) => {
const localTime = new Date();
const utcTime =
localTime.getTime() + localTime.getTimezoneOffset() * 60000;
const calculatedTime = new Date(utcTime + 1000 * offset);
return calculatedTime.toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
});
},
filters: {
tempSuffix: function (value, type) {
if (!value) return "";
let unit = "K";
if (type === "metric") {
unit = "°C";
} else if (type === "imperial") {
unit = "°F";
}
return `${value} ${unit}`;
},
},
};
@ -157,9 +133,4 @@ export default {
}
}
}
//Location Time
.location-time {
margin-left: 20px;
}
</style>

View File

@ -49,11 +49,7 @@ export default {
},
methods: {
fetchStatus: async function () {
const authQueryParams = this.item.apikey
? `?summaryRaw&auth=${this.item.apikey}`
: "";
const result = await this.fetch(`/api.php${authQueryParams}`)
.catch((e) => console.log(e));
const result = await this.fetch("/api.php").catch((e) => console.log(e));
this.status = result.status;
this.ads_percentage_today = result.ads_percentage_today;

View File

@ -29,17 +29,7 @@ export default {
},
methods: {
fetchStatus: async function () {
const method =
typeof this.item.method === "string"
? this.item.method.toUpperCase()
: "HEAD";
if (!["GET", "HEAD", "OPTION"].includes(method)) {
console.error(`Ping: ${method} is not a supported HTTP method`);
return;
}
this.fetch("/", { method, cache: "no-cache" }, false)
this.fetch("/", { method: "HEAD", cache: "no-cache" }, false)
.then(() => {
this.status = "online";
})
@ -55,8 +45,6 @@ export default {
.status {
font-size: 0.8rem;
color: var(--text-title);
white-space: nowrap;
margin-left: 0.25rem;
&.online:before {
background-color: #94e185;

View File

@ -1,193 +0,0 @@
<template>
<Generic :item="item">
<template #content>
<p class="title is-4">{{ item.name }}</p>
<p class="subtitle is-6">
<template v-if="item.subtitle">
{{ item.subtitle }}
</template>
<template v-else-if="vms">
<div v-if="loading">
<strong>Loading...</strong>
</div>
<div v-else-if="error">
<strong class="danger">Error loading info</strong>
</div>
<div
v-else
class="metrics"
:class="{
'is-size-7-mobile': item.small_font_on_small_screens,
'is-small': item.small_font_on_desktop,
}"
>
<span v-if="isValueShown('vms')" class="margined"
>VMs:
<span class="is-number"
><span class="has-text-weight-bold">{{ vms.running }}</span
><span v-if="isValueShown('vms_total')"
>/{{ vms.total }}</span
></span
></span
>
<span v-if="isValueShown('lxcs')" class="margined"
>LXCs:
<span class="is-number"
><span class="has-text-weight-bold">{{ lxcs.running }}</span
><span v-if="isValueShown('lxcs_total')"
>/{{ lxcs.total }}</span
></span
></span
>
<span v-if="isValueShown('disk')" class="margined"
>Disk:
<span
class="has-text-weight-bold is-number"
:class="statusClass(diskUsed)"
>{{ diskUsed }}%</span
></span
>
<span v-if="isValueShown('mem')" class="margined"
>Mem:
<span
class="has-text-weight-bold is-number"
:class="statusClass(memoryUsed)"
>{{ memoryUsed }}%</span
></span
>
<span v-if="isValueShown('cpu')" class="margined"
>CPU:
<span
class="has-text-weight-bold is-number"
:class="statusClass(cpuUsed)"
>{{ cpuUsed }}%</span
></span
>
</div>
</template>
</p>
</template>
<template #indicator>
<i v-if="loading" class="fa fa-circle-notch fa-spin fa-2xl"></i>
<i v-if="error" class="fa fa-exclamation-circle fa-2xl danger"></i>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "Proxmox",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => ({
vms: {
total: 0,
running: 0,
},
lxcs: {
total: 0,
running: 0,
},
memoryUsed: 0,
diskUsed: 0,
cpuUsed: 0,
hide: [],
error: false,
loading: true,
}),
created() {
if (this.item.hide) this.hide = this.item.hide;
this.fetchStatus();
},
methods: {
statusClass(value) {
if (value > this.item.danger_value) return "danger";
if (value > this.item.warning_value) return "warning";
return "healthy";
},
fetchStatus: async function () {
try {
const options = {
headers: {
Authorization: this.item.api_token,
},
};
const status = await this.fetch(
`/api2/json/nodes/${this.item.node}/status`,
options
);
// main metrics:
const decimalsToShow = this.item.hide_decimals ? 0 : 1;
this.memoryUsed = (
(status.data.memory.used * 100) /
status.data.memory.total
).toFixed(decimalsToShow);
this.diskUsed = (
(status.data.rootfs.used * 100) /
status.data.rootfs.total
).toFixed(decimalsToShow);
this.cpuUsed = (status.data.cpu * 100).toFixed(decimalsToShow);
// vms:
if (this.isValueShown("vms")) {
const vms = await this.fetch(
`/api2/json/nodes/${this.item.node}/qemu`,
options
);
this.parseVMsAndLXCs(vms, this.vms);
}
// lxc containers:
if (this.isValueShown("lxcs")) {
const lxcs = await this.fetch(
`/api2/json/nodes/${this.item.node}/lxc`,
options
);
this.parseVMsAndLXCs(lxcs, this.lxcs);
}
this.error = false;
} catch (err) {
console.log(err);
this.error = true;
}
this.loading = false;
},
parseVMsAndLXCs(items, value) {
value.total += items.data.length;
value.running += items.data.filter((i) => i.status === "running").length;
// if no vms, hide this value:
if (value.total == 0) this.hide.push("lxcs");
},
isValueShown(value) {
return this.hide.indexOf(value) == -1;
},
},
};
</script>
<style scoped lang="scss">
.is-number {
font-family: "Lato";
}
.healthy {
color: green;
}
.warning {
color: orange;
}
.danger {
color: red;
}
.metrics .margined:not(:first-child) {
margin-left: 0.3rem;
}
.is-small {
font-size: small;
}
</style>

View File

@ -1,164 +0,0 @@
<template>
<Generic :item="item">
<template #content>
<p class="title is-4">{{ item.name }}</p>
<p class="subtitle is-6">
<span v-if="error" class="error">An error has occurred.</span>
<template v-else>
<span class="down">
<i class="fas fa-download"></i> {{ downRate }}
</span>
<span class="up"> <i class="fas fa-upload"></i> {{ upRate }} </span>
</template>
</p>
</template>
<template #indicator>
<span v-if="!error" class="count"
>{{ count }}
<template v-if="count === 1">torrent</template>
<template v-else>torrents</template>
</span>
</template>
</Generic>
</template>
<script>
import Generic from "./Generic.vue";
// Units to add to download and upload rates.
const units = ["B", "kiB", "MiB", "GiB"];
// Take the rate in bytes and keep dividing it by 1k until the lowest
// value for which we have a unit is determined. Return the value with
// up to two decimals as a string and unit/s appended.
const displayRate = (rate) => {
let i = 0;
while (rate > 1000 && i < units.length) {
rate /= 1000;
i++;
}
return (
Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(
rate || 0
) + ` ${units[i]}/s`
);
};
export default {
name: "rTorrent",
props: { item: Object },
components: { Generic },
// Properties for download, upload, torrent count and errors.
data: () => ({ dl: null, ul: null, count: null, error: null }),
// Computed properties for the rate labels.
computed: {
downRate: function () {
return displayRate(this.dl);
},
upRate: function () {
return displayRate(this.ul);
},
},
created() {
// Set intervals if configured so the rates and/or torrent count
// will be updated.
const rateInterval = parseInt(this.item.rateInterval, 10) || 0;
const torrentInterval = parseInt(this.item.torrentInterval, 10) || 0;
if (rateInterval > 0) {
setInterval(() => this.fetchRates(), rateInterval);
}
if (torrentInterval > 0) {
setInterval(() => this.fetchCount(), torrentInterval);
}
// Fetch the initial values.
this.fetchRates();
this.fetchCount();
},
methods: {
// Perform two calls to the XML-RPC service and fetch download
// and upload rates. Values are saved to the `ul` and `dl`
// properties.
fetchRates: async function () {
this.getRate("throttle.global_up.rate")
.then((ul) => (this.ul = ul))
.catch(() => (this.error = true));
this.getRate("throttle.global_down.rate")
.then((dl) => (this.dl = dl))
.catch(() => (this.error = true));
},
// Perform a call to the XML-RPC service to fetch the number of
// torrents.
fetchCount: async function () {
this.getCount().catch(() => (this.error = true));
},
// Fetch a numeric value from the XML-RPC service by requesting
// the specified method name and parsing the XML. The response
// is expected to adhere to the structure of a single numeric
// value.
getRate: async function (methodName) {
return this.getXml(methodName).then((xml) =>
parseInt(
xml.getElementsByTagName("value")[0].firstChild.textContent,
10
)
);
},
// Fetch the numer of torrents by requesting the download list
// and counting the number of entries therein.
getCount: async function () {
return this.getXml("download_list").then((xml) => {
const arrayEl = xml.getElementsByTagName("array");
this.count = arrayEl
? arrayEl[0].getElementsByTagName("value").length
: 0;
});
},
// Perform a call to the XML-RPC service and parse the response
// as XML, which is then returned.
getXml: async function (methodName) {
const headers = { "Content-Type": "text/xml" };
if (this.item.username && this.item.password) {
headers[
"Authorization"
] = `${this.item.username}:${this.item.password}`;
}
return fetch(`${this.item.xmlrpc.replace(/\/$/, "")}/RPC2`, {
method: "POST",
headers,
body: `<methodCall><methodName>${methodName}</methodName></methodCall>`,
})
.then((response) => {
if (!response.ok) {
throw Error(response.statusText);
}
return response.text();
})
.then((text) =>
Promise.resolve(new DOMParser().parseFromString(text, "text/xml"))
);
},
},
};
</script>
<style scoped lang="scss">
.error {
color: #e51111 !important;
}
.down {
margin-right: 1em;
}
.count {
color: var(--text);
font-size: 0.8em;
}
</style>

View File

@ -1,99 +0,0 @@
<template>
<Generic :item="item">
<template #indicator>
<div class="notifs">
<strong
v-if="downloads > 0"
class="notif downloading"
:title="`${downloads} active download${downloads > 1 ? 's' : ''}`"
>
{{ downloads }}
</strong>
<i
v-if="error"
class="notif error fa-solid fa-triangle-exclamation"
title="Unable to fetch current status"
></i>
</div>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "SABnzbd",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => ({
stats: null,
error: false,
}),
computed: {
downloads: function () {
if (!this.stats) {
return "";
}
return this.stats.noofslots;
},
},
created() {
const downloadInterval = parseInt(this.item.downloadInterval, 10) || 0;
if (downloadInterval > 0) {
setInterval(() => this.fetchStatus(), downloadInterval);
}
this.fetchStatus();
},
methods: {
fetchStatus: async function () {
try {
const response = await this.fetch(
`/api?output=json&apikey=${this.item.apikey}&mode=queue`
);
this.error = false;
this.stats = response.queue;
} catch (e) {
this.error = true;
console.error(e);
}
},
},
};
</script>
<style scoped lang="scss">
.notifs {
position: absolute;
color: white;
font-family: sans-serif;
top: 0.3em;
right: 0.5em;
.notif {
display: inline-block;
padding: 0.2em 0.35em;
border-radius: 0.25em;
position: relative;
margin-left: 0.3em;
font-size: 0.8em;
&.downloading {
background-color: #4fb5d6;
}
&.error {
border-radius: 50%;
aspect-ratio: 1;
background-color: #e51111;
}
}
}
</style>

View File

@ -1,59 +0,0 @@
<template>
<Generic :item="item">
<template #content>
<p class="title is-4">{{ item.name }}</p>
<p class="subtitle is-6">
<template v-if="speedtest">
<i class="fas fa-arrow-down"></i> {{ download }} Mbit/s |
<i class="fas fa-arrow-up"></i> {{ upload }} Mbit/s |
<i class="fas fa-stopwatch"></i> {{ ping }} ms
</template>
</p>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "SpeedtestTracker",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => ({
speedtest: null,
}),
computed: {
download: function () {
return this.format(this.speedtest?.download);
},
upload: function () {
return this.format(this.speedtest?.upload);
},
ping: function () {
return this.format(this.speedtest?.ping);
},
},
created() {
this.fetchStatus();
},
methods: {
fetchStatus: async function () {
this.fetch("/api/speedtest/latest")
.then((response) => {
this.speedtest = response.data;
})
.catch((e) => console.log(e));
},
format: function (value) {
return value ? parseFloat(value).toFixed(2) : "n/a";
},
},
};
</script>

View File

@ -1,94 +0,0 @@
<template>
<Generic :item="item">
<template #indicator>
<div class="notifs">
<strong
v-if="streams > 0"
class="notif playing"
:title="`${streams} active stream${streams > 1 ? 's' : ''}`"
>
{{ streams }}
</strong>
<i
v-if="error"
class="notif error fa-solid fa-triangle-exclamation"
title="Unable to fetch current status"
></i>
</div>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "Tautulli",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => ({
stats: null,
error: false,
}),
computed: {
streams: function () {
if (!this.stats) {
return "";
}
return this.stats.stream_count;
},
},
created() {
this.fetchStatus();
},
methods: {
fetchStatus: async function () {
try {
const response = await this.fetch(
`/api/v2?apikey=${this.item.apikey}&cmd=get_activity`
);
this.error = false;
this.stats = response.response.data;
} catch (e) {
this.error = true;
console.error(e);
}
},
},
};
</script>
<style scoped lang="scss">
.notifs {
position: absolute;
color: white;
font-family: sans-serif;
top: 0.3em;
right: 0.5em;
.notif {
display: inline-block;
padding: 0.2em 0.35em;
border-radius: 0.25em;
position: relative;
margin-left: 0.3em;
font-size: 0.8em;
&.playing {
background-color: #28a9a3;
}
&.error {
border-radius: 50%;
aspect-ratio: 1;
background-color: #e51111;
}
}
}
</style>

View File

@ -1,125 +0,0 @@
<template>
<Generic :item="item">
<template #indicator>
<div class="notifs">
<strong
v-if="queue > 0"
class="notif queue"
:title="`${queue} items queued`"
>
{{ queue }}
</strong>
<strong
v-if="errored > 0"
class="notif errored"
:title="`${errored} items`"
>
{{ errored }}
</strong>
<i
v-if="error"
class="notif error fa-solid fa-triangle-exclamation"
title="Unable to fetch current status"
></i>
</div>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "Tdarr",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => ({
stats: null,
error: false,
}),
computed: {
queue: function () {
if (!this.stats) {
return "";
}
return this.stats.table1Count;
},
errored: function () {
if (!this.stats) {
return "";
}
return this.stats.table6Count;
},
},
created() {
const checkInterval = parseInt(this.item.checkInterval, 10) || 0;
if (checkInterval > 0) {
setInterval(() => this.fetchStatus(), checkInterval);
}
this.fetchStatus();
},
methods: {
fetchStatus: async function () {
try {
const options = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({"headers":{"content-Type":"application/json"},"data":{"collection":"StatisticsJSONDB","mode":"getById","docID":"statistics","obj":{}},"timeout":1000}),
};
const response = await this.fetch(
`/api/v2/cruddb`,
options
);
this.error = false;
this.stats = response;
} catch (e) {
this.error = true;
console.error(e);
}
},
},
};
</script>
<style scoped lang="scss">
.notifs {
position: absolute;
color: white;
font-family: sans-serif;
top: 0.3em;
right: 0.5em;
.notif {
display: inline-block;
padding: 0.2em 0.35em;
border-radius: 0.25em;
position: relative;
margin-left: 0.3em;
font-size: 0.8em;
&.queue {
background-color: #28a9a3;
}
&.errored {
background-color: #e51111;
}
&.error {
border-radius: 50%;
aspect-ratio: 1;
background-color: #e51111;
}
}
}
</style>

View File

@ -1,169 +0,0 @@
<template>
<Generic :item="item">
<template #content>
<p class="title is-4">{{ item.name }}</p>
<p class="subtitle is-6">
<template v-if="item.subtitle">
{{ item.subtitle }}
</template>
<template v-else-if="status">
{{ statusMessage }}
</template>
</p>
</template>
<template #indicator>
<div v-if="status" class="status" :class="status">
{{ uptime }}&percnt;
</div>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "UptimeKuma",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => ({
incident: null,
heartbeat: null,
}),
computed: {
dashboard: function () {
return this.item.slug ? this.item.slug : "default";
},
status: function () {
if (!this.incident) {
return "";
}
return this.incident.incident == null ? this.pageStatus : "bad";
},
lastHeartBeatList: function () {
let result = {};
for (let id in this.heartbeat.heartbeatList) {
let index = this.heartbeat.heartbeatList[id].length - 1;
result[id] = this.heartbeat.heartbeatList[id][index];
}
return result;
},
pageStatus: function () {
if (!this.heartbeat) {
return "";
}
if (Object.keys(this.heartbeat.heartbeatList).length === 0) {
return "";
}
let result = "good";
let hasUp = false;
for (let id in this.lastHeartBeatList) {
let beat = this.lastHeartBeatList[id];
if (beat.status == 1) {
hasUp = true;
} else {
result = "warn";
}
}
if (!hasUp) {
result = "bad";
}
return result;
},
statusMessage: function () {
if (!this.incident) {
return "";
}
if (this.incident.incident) {
return this.incident.incident.title;
}
let message = "";
switch (this.pageStatus) {
case "good":
message = "All Systems Operational";
break;
case "warn":
message = "Partially Degraded Service";
break;
case "bad":
message = "Degraded Service";
break;
default:
message = "Unknown service status";
}
return message;
},
uptime: function () {
if (!this.heartbeat) {
return 0;
}
const data = Object.values(this.heartbeat.uptimeList);
const percent = data.reduce((a, b) => a + b, 0) / data.length || 0;
return (percent * 100).toFixed(1);
},
},
created() {
/* eslint-disable */
this.item.url = `${this.item.url}/status/${this.dashboard}`;
this.fetchStatus();
},
methods: {
fetchStatus: function () {
const now = Date.now()
this.fetch(`/api/status-page/${this.dashboard}?cachebust=${now}`)
.catch((e) => console.error(e))
.then((resp) => (this.incident = resp));
this.fetch(
`/api/status-page/heartbeat/${this.dashboard}?cachebust=${now}`
)
.catch((e) => console.error(e))
.then((resp) => (this.heartbeat = resp));
},
},
};
</script>
<style scoped lang="scss">
.status {
font-size: 0.8rem;
color: var(--text-title);
&.good:before {
background-color: #94e185;
border-color: #78d965;
box-shadow: 0 0 5px 1px #94e185;
}
&.warn:before {
background-color: #f8a306;
border-color: #e1b35e;
box-shadow: 0 0 5px 1px #f8a306;
}
&.bad:before {
background-color: #c9404d;
border-color: #c42c3b;
box-shadow: 0 0 5px 1px #c9404d;
}
&:before {
content: " ";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 10px;
border: 1px solid #000;
border-radius: 7px;
}
}
</style>

View File

@ -1,92 +0,0 @@
<template>
<Generic :item="item">
<template #indicator>
<div class="notifs">
<strong v-if="running > 0" class="notif warnings" title="Running">
{{ running }}
</strong>
<strong v-if="update > 0" class="notif errors" title="Update">
{{ update }}
</strong>
<strong
v-if="serverError"
class="notif errors"
title="Connection error to WUD API, check url in config.yml"
>
?
</strong>
</div>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
export default {
name: "WUD",
mixins: [service],
props: {
item: Object,
},
components: {
Generic,
},
data: () => {
return {
running: null,
update: null,
serverError: false,
};
},
created: function () {
this.fetchConfig();
},
methods: {
fetchConfig: function () {
this.fetch(`/api/containers`)
.then((containers) => {
this.running = 0;
this.update = 0;
for (var i = 0; i < containers.length; i++) {
this.running++;
if (containers[i].updateAvailable) {
this.update++;
}
}
})
.catch(() => {
this.serverError = true;
});
},
},
};
</script>
<style scoped lang="scss">
.notifs {
position: absolute;
color: white;
font-family: sans-serif;
top: 0.3em;
right: 0.5em;
.notif {
display: inline-block;
padding: 0.2em 0.35em;
border-radius: 0.25em;
position: relative;
margin-left: 0.3em;
font-size: 0.8em;
&.warnings {
background-color: #d08d2e;
}
&.errors {
background-color: #e51111;
}
}
}
</style>

View File

@ -1,122 +0,0 @@
<template>
<Generic :item="item">
<template #content>
<p class="title is-4">{{ item.name }}</p>
<p class="subtitle is-6">
<span v-if="error" class="error">An error has occurred.</span>
<template v-else>
<span class="down monospace">
<p class="fas fa-download"></p>
{{ downRate }}
</span>
<span class="up monospace">
<p class="fas fa-upload"></p>
{{ upRate }}
</span>
</template>
</p>
</template>
<template #indicator>
<span v-if="!error" class="count"
>{{ count }}
<template v-if="count === 1">torrent</template>
<template v-else>torrents</template>
</span>
</template>
</Generic>
</template>
<script>
import service from "@/mixins/service.js";
import Generic from "./Generic.vue";
const units = ["B", "KB", "MB", "GB"];
// Take the rate in bytes and keep dividing it by 1k until the lowest
// value for which we have a unit is determined. Return the value with
// up to two decimals as a string and unit/s appended.
const displayRate = (rate) => {
let i = 0;
while (rate > 1000 && i < units.length) {
rate /= 1000;
i++;
}
return (
Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(
rate || 0
) + ` ${units[i]}/s`
);
};
export default {
name: "qBittorrent",
mixins: [service],
props: { item: Object },
components: { Generic },
data: () => ({ dl: null, ul: null, count: null, error: null }),
computed: {
downRate: function () {
return displayRate(this.dl);
},
upRate: function () {
return displayRate(this.ul);
},
},
created() {
const rateInterval = parseInt(this.item.rateInterval, 10) || 0;
const torrentInterval = parseInt(this.item.torrentInterval, 10) || 0;
if (rateInterval > 0) {
setInterval(() => this.getRate(), rateInterval);
}
if (torrentInterval > 0) {
setInterval(() => this.fetchCount(), torrentInterval);
}
this.getRate();
this.fetchCount();
},
methods: {
fetchCount: async function () {
try {
const body = await this.fetch("/api/v2/torrents/info");
this.error = false;
this.count = body.length;
} catch (e) {
this.error = true;
console.error(e);
}
},
getRate: async function () {
try {
const body = await this.fetch("/api/v2/transfer/info");
this.error = false;
this.dl = body.dl_info_speed;
this.ul = body.up_info_speed;
} catch (e) {
this.error = true;
console.error(e);
}
},
},
};
</script>
<style scoped lang="scss">
.error {
color: #e51111 !important;
}
.down {
margin-right: 1em;
}
.count {
color: var(--text);
font-size: 0.8em;
}
.monospace {
font-weight: 300;
font-family: monospace;
}
</style>

View File

@ -1,13 +1,19 @@
import { createApp, h } from "vue";
import Vue from "vue";
import App from "./App.vue";
import "./registerServiceWorker";
import "@fortawesome/fontawesome-free/css/all.css";
import "./assets/app.scss";
const app = createApp(App);
Vue.config.productionTip = false;
app.component("DynamicStyle", (_props, context) => {
return h("style", {}, context.slots);
Vue.component("DynamicStyle", {
render: function (createElement) {
return createElement("style", this.$slots.default);
},
});
app.mount("#app-mount");
new Vue({
render: (h) => h(App),
}).$mount("#app");

View File

@ -1,6 +1,9 @@
const merge = require("lodash.merge");
export default {
props: {
proxy: Object,
forwarder: Object,
},
created: function () {
// custom service often consume info from an API using the item link (url) as a base url,
@ -25,18 +28,28 @@ export default {
this.item.useCredentials === true ? "include" : "omit";
}
options = Object.assign(options, init);
if (this.forwarder?.apikey) {
options.headers = {
"X-Homer-Forwarder-Api-Key": this.forwarder.apikey,
};
}
if (path.startsWith("/")) {
path = path.slice(1);
}
let url = this.endpoint;
let url = path ? `${this.endpoint}/${path}` : this.endpoint;
if (path) {
url = `${this.endpoint}/${path}`;
if (this.forwarder?.url) {
options.headers = {
...(options.headers || {}),
"X-Homer-Forwarder-Url": url,
};
url = this.forwarder.url;
}
options = merge(options, init);
return fetch(url, options).then((response) => {
if (!response.ok) {
throw new Error("Not 2xx response");

View File

@ -0,0 +1,34 @@
/* eslint-disable no-console */
import { register } from "register-service-worker";
if (process.env.NODE_ENV === "production") {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() {
console.log(
"App is being served from cache by a service worker.\n" +
"For more details, visit https://goo.gl/AFskqB"
);
},
registered() {
console.log("Service worker has been registered.");
},
cached() {
console.log("Content has been cached for offline use.");
},
updatefound() {
console.log("New content is downloading.");
},
updated() {
console.log("New content is available; please refresh.");
},
offline() {
console.log(
"No internet connection found. App is running in offline mode."
);
},
error(error) {
console.error("Error during service worker registration:", error);
},
});
}

View File

@ -1,49 +0,0 @@
import { VitePWA } from "vite-plugin-pwa";
import { fileURLToPath, URL } from "url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vitejs.dev/config/
export default defineConfig({
base: "",
build: {
assetsDir: "resources",
},
plugins: [
vue(),
VitePWA({
registerType: "autoUpdate",
useCredentials: true,
manifestFilename: "assets/manifest.json",
manifest: {
name: "Homer dashboard",
short_name: "Homer",
description: "Home Server Dashboard",
theme_color: "#3367D6",
start_url: "../",
scope: "../",
icons: [
{
src: "./icons/pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "./icons/pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
},
workbox: {
navigateFallback: null,
},
}),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});

29
vue.config.js Normal file
View File

@ -0,0 +1,29 @@
const manifestOptions = require("./public/assets/manifest.json");
module.exports = {
chainWebpack: (config) => {
config.module
.rule("yaml")
.test(/\.ya?ml$/)
.use("raw-loader")
.loader("raw-loader")
.end();
},
publicPath: "",
pwa: {
manifestPath: "assets/manifest.json",
manifestCrossorigin: "use-credentials",
appleMobileWebAppStatusBarStyle: "black",
appleMobileWebAppCapable: "yes",
name: manifestOptions.name,
themeColor: manifestOptions.theme_color,
manifestOptions,
iconPaths: {
favicon32: "assets/icons/favicon-32x32.png",
favicon16: "assets/icons/favicon-16x16.png",
appleTouchIcon: "assets/icons/icon-maskable.png",
maskIcon: "assets/icons/safari-pinned-tab.svg",
msTileImage: "assets/icons/icon-any.png",
},
},
};

9683
yarn.lock

File diff suppressed because it is too large Load Diff