forked from extern/homer
Compare commits
198 Commits
Author | SHA1 | Date | |
---|---|---|---|
a4c1f6a37d | |||
76d30be8e3 | |||
240e3f0e87 | |||
33f75a798a | |||
9c370d3c5e | |||
7341d7634b | |||
b2a4140054 | |||
9e1e82b0f3 | |||
000a46ee88 | |||
1275a8cce5 | |||
cd1fc28f51 | |||
5c42d50d47 | |||
31027f4791 | |||
abfe72b9cf | |||
6dc8fa2026 | |||
345dd6c194 | |||
585844394d | |||
a25f317bee | |||
a2dfffab68 | |||
775d0a8e86 | |||
6351bf973c | |||
4e953d7c81 | |||
e2ebf9973b | |||
a7cbcc7700 | |||
049610bc91 | |||
f398006935 | |||
db2a2af3a4 | |||
2ccadd578e | |||
120ee25bf5 | |||
1340a8e6d0 | |||
edd2c9ce2d | |||
a1a70d4a3c | |||
1acdbe4920 | |||
ba2c7c5c57 | |||
e4b077843c | |||
5cd802d157 | |||
4a526f6e7f | |||
2c52f45048 | |||
7e81828b34 | |||
78d0fc5f1b | |||
dabcc0bae1 | |||
c6ec28f1c5 | |||
9bfa95963d | |||
8b9ec8465f | |||
893690cf95 | |||
dec7e466b9 | |||
096c7eda48 | |||
d92444ec19 | |||
5fdf790e2c | |||
5afd21a84c | |||
51829a85c4 | |||
6c8f9f1c5b | |||
f7f4ebdf66 | |||
8ede30411e | |||
cb154a6818 | |||
50b3bddff1 | |||
990606a38a | |||
0aa343d744 | |||
26dbed936a | |||
68b10120c9 | |||
9f14de32fe | |||
ad8efdc799 | |||
a9cdf57043 | |||
8283b8da5a | |||
b1c8586441 | |||
9c77651692 | |||
b4a2db6e37 | |||
e6ba84d35a | |||
611fe797eb | |||
e961af8255 | |||
0121fa8036 | |||
754372579e | |||
6e6efc7d29 | |||
b7480f632e | |||
9fce0ce5a5 | |||
f2c901a1ec | |||
cf33747f42 | |||
46c9a513e5 | |||
400cdb8f6a | |||
487f954a36 | |||
446e78d2ab | |||
3668050ba3 | |||
54c19bb5f0 | |||
168f157cf9 | |||
5db2414d05 | |||
1c0bf7132a | |||
2f19540400 | |||
c72acd57d0 | |||
80ba98cf66 | |||
d31a9a79c2 | |||
277dafafa9 | |||
8d9cfa98bd | |||
7a4e78e8d0 | |||
87aadbb6df | |||
66a434e7db | |||
3acfb01d99 | |||
2fba043575 | |||
efc2bbb856 | |||
fea0f09045 | |||
0a3be103dc | |||
a25e1b1a70 | |||
cc26624f39 | |||
d7e17e6146 | |||
3faeac7e9f | |||
b64b17a4f9 | |||
270e522e0e | |||
220c60cba0 | |||
2ca4faad9c | |||
c7dc6bfd0d | |||
b2f6da0382 | |||
e58461ffe3 | |||
451b1ac624 | |||
7129af3bda | |||
1d3287dcca | |||
6173d7df60 | |||
b2a31c0701 | |||
d6d078132b | |||
e32643056e | |||
eecd0db92e | |||
d489f6ef87 | |||
b63add2f95 | |||
a6b72c97d0 | |||
4eeed6596b | |||
f11b1c9dcf | |||
fd18715085 | |||
92d5b8d424 | |||
bcec0449ec | |||
55c3ea4d92 | |||
33d60aa76a | |||
afe6d34ced | |||
1ed0a2f387 | |||
117fa7d3b9 | |||
584f2b4b32 | |||
6c834c24b6 | |||
cb325bd58a | |||
bbe7149d58 | |||
7efcd282bb | |||
addaf36c3d | |||
6b54eedae7 | |||
6dd8342bf0 | |||
4852ae6b85 | |||
2f6d9e1b09 | |||
0dc3cea15e | |||
76a46c3507 | |||
cf2fb08dc7 | |||
077be43473 | |||
ecec695272 | |||
a74fa38302 | |||
304362adfd | |||
25f99adc6c | |||
b2062fb60a | |||
4386cd094b | |||
64ac4c48d5 | |||
35926e1e6e | |||
f3b3b89b7c | |||
a6b7db5437 | |||
3a8fa151f4 | |||
92d899bd48 | |||
c06c0cdf9b | |||
73a102f3fa | |||
f9cc1d27cc | |||
f5b467f933 | |||
ded5228972 | |||
90d6bc67c0 | |||
5d642b3674 | |||
c98f88eaf2 | |||
b1ccd8d6b2 | |||
c4cae400d5 | |||
9e1b1bd1d2 | |||
c3878bca0b | |||
bebb6953cb | |||
3832025b0c | |||
68955dc1d3 | |||
f9ebff9311 | |||
24229b5411 | |||
adacb3c33f | |||
0178d73f66 | |||
a2b59fb6c1 | |||
764b470209 | |||
742ae4eb52 | |||
996011956b | |||
2428998313 | |||
7596bc527f | |||
aadd8b49cc | |||
64f189c9dc | |||
4399f5fade | |||
275a335cce | |||
b1de1f9e08 | |||
0211da26c8 | |||
a5fe53beb2 | |||
97f0c43ccc | |||
551e32e203 | |||
fb158d4767 | |||
9e0ef05efe | |||
dfb0b14626 | |||
fd12de9ebd | |||
b79561bc9c | |||
593f8afc90 |
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -14,5 +14,5 @@ Fixes # (issue)
|
||||
|
||||
- [ ] I've read & comply with the [contributing guidelines](https://github.com/bastienwirtz/homer/blob/main/CONTRIBUTING.md)
|
||||
- [ ] I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
|
||||
- [ ] I have made corresponding changes the documentation (README.md).
|
||||
- [ ] I have made corresponding changes to the documentation (README.md).
|
||||
- [ ] I've checked my modifications for any breaking changes, especially in the `config.yml` file
|
||||
|
31
.github/workflows/integration.yml
vendored
Normal file
31
.github/workflows/integration.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'yarn'
|
||||
- run: yarn install
|
||||
- run: yarn lint
|
||||
|
@ -12,10 +12,6 @@ UX and usability. If you are looking for a full featured dashboard, there is ton
|
||||
- Configuration is stored in a simple config file, avoiding the need for a backend/database while making possible to use versioning or [config template](https://docs.ansible.com/ansible/latest/user_guide/playbooks_templating.html).
|
||||
- Only modern browsers are supported, feel free to use any JS features without any polyfill as soon as the latest version of the major browsers supports them.
|
||||
|
||||
### Roadmap
|
||||
|
||||
If you want to know more about the project direction or looking for something to work on, checkout the [roadmap](https://github.com/bastienwirtz/homer#Roadmap)!
|
||||
Feel free to open an issue if you have any question.
|
||||
|
||||
# Ground Rules
|
||||
|
||||
@ -40,8 +36,9 @@ feel free to open an issue to present your idea.
|
||||
### How to submit a contribution
|
||||
|
||||
The general process to submit a contribution is as follow:
|
||||
1. Create your own fork of the code
|
||||
2. Do the changes in your fork
|
||||
3. Make sure to fill the [pull request description](https://github.com/bastienwirtz/homer/blob/main/.github/PULL_REQUEST_TEMPLATE.md) properly.
|
||||
1. Take a look to the [development guideline](https://github.com/bastienwirtz/homer/blob/main/docs/development.md).
|
||||
2. Create your own fork of the code
|
||||
3. Do the changes in your fork
|
||||
4. Make sure to fill the [pull request description](https://github.com/bastienwirtz/homer/blob/main/.github/PULL_REQUEST_TEMPLATE.md) properly.
|
||||
|
||||
### Happy coding :metal:
|
||||
|
@ -10,7 +10,7 @@ COPY . .
|
||||
RUN yarn build
|
||||
|
||||
# production stage
|
||||
FROM alpine:3.11
|
||||
FROM alpine:3.15
|
||||
|
||||
ENV USER darkhttpd
|
||||
ENV GROUP darkhttpd
|
||||
@ -25,6 +25,9 @@ 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"]
|
||||
|
@ -35,6 +35,9 @@ 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"]
|
||||
|
@ -35,6 +35,9 @@ 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"]
|
||||
|
63
README.md
63
README.md
@ -1,67 +1,68 @@
|
||||
<h1 align="center">
|
||||
<img
|
||||
width="180"
|
||||
alt="Homer's donut"
|
||||
src="https://raw.githubusercontent.com//bastienwirtz/homer/main/public/logo.png">
|
||||
<img
|
||||
width="180"
|
||||
alt="Homer's donut"
|
||||
src="https://raw.githubusercontent.com//bastienwirtz/homer/main/public/logo.png">
|
||||
<br/>
|
||||
Homer
|
||||
</h1>
|
||||
|
||||
<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.
|
||||
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">
|
||||
<strong>
|
||||
<a href="https://homer-demo.netlify.app">Demo</a>
|
||||
•
|
||||
<a href="https://gitter.im/homer-dashboard/community">Chat</a>
|
||||
•
|
||||
<a href="#getting-started">Getting started</a>
|
||||
</strong>
|
||||
<strong>
|
||||
<a href="https://homer-demo.netlify.app">Demo</a>
|
||||
•
|
||||
<a href="https://gitter.im/homer-dashboard/community">Chat</a>
|
||||
•
|
||||
<a href="#getting-started">Getting started</a>
|
||||
</strong>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/Apache-2.0"><img
|
||||
alt="License: Apache 2"
|
||||
src="https://img.shields.io/badge/License-Apache%202.0-blue.svg"></a>
|
||||
<a href="https://opensource.org/licenses/Apache-2.0"><img
|
||||
alt="License: Apache 2"
|
||||
src="https://img.shields.io/badge/License-Apache%202.0-blue.svg"></a>
|
||||
<a href="https://gitter.im/homer-dashboard/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge"><img
|
||||
alt="Gitter chat"
|
||||
src="https://badges.gitter.im/homer-dashboard/community.svg"></a>
|
||||
alt="Gitter chat"
|
||||
src="https://badges.gitter.im/homer-dashboard/community.svg"></a>
|
||||
<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://github.com/awesome-selfhosted/awesome-selfhosted"><img
|
||||
alt="Awesome"
|
||||
src="https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg"></a>
|
||||
alt="Download homer static build"
|
||||
src="https://img.shields.io/badge/Download-homer.zip-orange"></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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.github.com/bastienwirtz/homer/main/docs/screenshot.png" width="100%">
|
||||
<img src="https://raw.github.com/bastienwirtz/homer/main/docs/screenshot.png" width="100%">
|
||||
</p>
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Getting started](#getting-started)
|
||||
- [Configuration](docs/configuration.md)
|
||||
- [Custom services](docs/customservices.md)
|
||||
- [Tips & tricks](docs/tips-and-tricks.md)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Development](docs/development.md)
|
||||
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
|
||||
## Features
|
||||
|
||||
- [yaml](http://yaml.org/) file configuration
|
||||
- Installable (pwa)
|
||||
- Search
|
||||
- Grouping
|
||||
- Theme customization
|
||||
- Offline heathcheck
|
||||
- Offline health check
|
||||
- keyboard shortcuts:
|
||||
- `/` Start searching.
|
||||
- `Escape` Stop searching.
|
||||
- `Enter` Open the first matching result (respects the bookmark's `_target` property).
|
||||
- `Alt`/`Option` + `Enter` Open the first matching result in a new tab.
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
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**.
|
||||
@ -111,7 +112,7 @@ environment:
|
||||
|
||||
### 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 webserver.
|
||||
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.
|
||||
|
||||
```sh
|
||||
wget https://github.com/bastienwirtz/homer/releases/latest/download/homer.zip
|
||||
@ -134,9 +135,3 @@ npm run build
|
||||
```
|
||||
|
||||
Then your dashboard is ready to use in the `/dist` directory.
|
||||
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Add new themes.
|
||||
- [ ] Add support for custom service card (add custom feature to some service / app link)
|
||||
|
@ -1,13 +1,13 @@
|
||||
## Configuration
|
||||
# Configuration
|
||||
|
||||
Title, icons, links, colors, and services can be configured in the `config.yml` file (located in `/assets` directory once built, or in the `public/assets` directory in development mode), using [yaml](http://yaml.org/) format.
|
||||
|
||||
```yaml
|
||||
---
|
||||
# Homepage configuration
|
||||
# See https://fontawesome.com/icons for icons options
|
||||
# See https://fontawesome.com/v5/search for icons options
|
||||
|
||||
# Optional: Use external configuration file.
|
||||
# Optional: Use external configuration file.
|
||||
# Using this will ignore remaining config in this file
|
||||
# externalConfig: https://example.com/server-luci/config.yaml
|
||||
|
||||
@ -19,11 +19,23 @@ logo: "assets/logo.png"
|
||||
# icon: "fas fa-skull-crossbones"
|
||||
|
||||
header: true # Set to false to hide the header
|
||||
# Optional: Different hotkey for search, defaults to "/"
|
||||
# hotkey:
|
||||
# search: "Shift"
|
||||
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)
|
||||
|
||||
# Optional: Proxy / hosting option
|
||||
proxy:
|
||||
useCredentials: false # send cookies & authorization headers when fetching service specific data. Set to `true` if you use an authentication proxy. Can be overrided on service level.
|
||||
|
||||
# Set the default layout and color scheme
|
||||
defaults:
|
||||
layout: columns # Either 'columns', or 'list'
|
||||
colorTheme: auto # One of 'auto', 'light', or 'dark'
|
||||
|
||||
# Optional theming
|
||||
theme: default # 'default' or one of the themes available in 'src/assets/themes'.
|
||||
|
||||
@ -47,6 +59,7 @@ colors:
|
||||
text-title: "#303030"
|
||||
text-subtitle: "#424242"
|
||||
card-shadow: rgba(0, 0, 0, 0.1)
|
||||
link: "#3273dc"
|
||||
link-hover: "#363636"
|
||||
background-image: "assets/your/light/bg.png"
|
||||
dark:
|
||||
@ -60,6 +73,7 @@ colors:
|
||||
text-title: "#fafafa"
|
||||
text-subtitle: "#f5f5f5"
|
||||
card-shadow: rgba(0, 0, 0, 0.4)
|
||||
link: "#3273dc"
|
||||
link-hover: "#ffdd57"
|
||||
background-image: "assets/your/dark/bg.png"
|
||||
|
||||
@ -136,6 +150,8 @@ 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.
|
||||
|
||||
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)):
|
||||
|
||||
```json
|
||||
@ -149,7 +165,7 @@ If you choose to fetch message information from an endpoint, the output format s
|
||||
`null` value or missing keys will be ignored and value from the `config.yml` will be used if available.
|
||||
Empty values (either in `config.yml` or the endpoint data) will hide the element (ex: set `"title": ""` to hide the title bar).
|
||||
|
||||
### Style Options
|
||||
## Style Options
|
||||
|
||||
Homer uses [bulma CSS](https://bulma.io/), which provides a [modifiers syntax](https://bulma.io/documentation/modifiers/syntax/). You'll notice in the config there is a `tagstyle` option. It can be set to any of the bulma modifiers. You'll probably want to use one of these 4 main colors:
|
||||
|
||||
@ -160,10 +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.
|
||||
|
||||
### PWA Icons
|
||||
## PWA Icons
|
||||
|
||||
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.
|
||||
|
181
docs/customservices.md
Normal file
181
docs/customservices.md
Normal file
@ -0,0 +1,181 @@
|
||||
# Custom Services
|
||||
|
||||
Some service can use a specific a component that provides some extra features by adding a `type` key to the service yaml
|
||||
configuration and, where applicable, an apikey. Note that config.yml is exposed at /assets/config.yml via HTTP and any
|
||||
apikey included in the configuration file is exposed to anyone who can access the homer instance. Only include an apikey
|
||||
if your homer instance is secured behind some form of authentication or access restriction.
|
||||
|
||||
Available services are in `src/components/`. Here is an overview of all custom services that are available
|
||||
within Homer:
|
||||
+ [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
|
||||
- name: "My Service"
|
||||
logo: "assets/tools/sample.png"
|
||||
url: "http://my-service-link"
|
||||
endpoint: "http://my-service-endpoint" # Optional: alternative base URL used to fetch service data is necessary.
|
||||
useCredentials: false # Optional: Override global proxy.useCredentials configuration.
|
||||
type: "<type>"
|
||||
```
|
||||
|
||||
## PiHole
|
||||
|
||||
Using the PiHole service you can display info about your local PiHole instance right on your Homer dashboard.
|
||||
|
||||
The following configuration is available for the PiHole service.
|
||||
|
||||
```yaml
|
||||
- name: "Pi-hole"
|
||||
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"
|
||||
type: "PiHole"
|
||||
```
|
||||
|
||||
## OpenWeatherMap
|
||||
|
||||
Using the OpenWeatherMap service you can display weather information about a given location.
|
||||
The following configuration is available for the OpenWeatherMap service:
|
||||
|
||||
```yaml
|
||||
- name: "Weather"
|
||||
location: "Amsterdam" # your location.
|
||||
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, cicle, none. Defaults to none.
|
||||
type: "OpenWeather"
|
||||
```
|
||||
|
||||
**Remarks:**
|
||||
If for some reason your city can't be found by entering the name in the `location` property, you could also try to configure the OWM city ID in the `locationId` property. To retrieve your specific City ID, go to the [OWM website](https://openweathermap.org), search for your city and retrieve the ID from the URL (for example, the City ID of Amsterdam is 2759794).
|
||||
|
||||
## Medusa
|
||||
|
||||
This service displays News (grey), Warning (orange) or Error (red) notifications bubbles from the Medusa application.
|
||||
Two lines are needed in the config.yml :
|
||||
|
||||
```yaml
|
||||
type: "Medusa"
|
||||
apikey: "01234deb70424befb1f4ef6a23456789"
|
||||
```
|
||||
|
||||
The url must be the root url of Medusa application.
|
||||
The Medusa API key can be found in General configuration > Interface. It is needed to access Medusa API.
|
||||
|
||||
## Lidarr, Prowlarr, Sonarr and Radarr
|
||||
|
||||
This service displays Activity (blue), Warning (orange) or Error (red) notifications bubbles from the Lidarr, Radarr or Sonarr application.
|
||||
Two lines are needed in the config.yml :
|
||||
|
||||
```yaml
|
||||
type: "Lidarr", "Prowlarr", "Radarr" or "Sonarr"
|
||||
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:
|
||||
|
||||
```yaml
|
||||
- name: "Radarr"
|
||||
type: "Radarr"
|
||||
url: "http://localhost:7878/"
|
||||
apikey: "MY-SUPER-SECRET-API-KEY"
|
||||
target: "_blank"
|
||||
legacyApi: true
|
||||
```
|
||||
|
||||
## PaperlessNG
|
||||
|
||||
This service displays total number of documents stored. Two lines are required:
|
||||
|
||||
```yaml
|
||||
type: "PaperlessNG"
|
||||
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.
|
||||
|
||||
```yaml
|
||||
- name: "Awesome app"
|
||||
type: Ping
|
||||
logo: "assets/tools/sample.png"
|
||||
subtitle: "Bookmark example"
|
||||
tag: "app"
|
||||
url: "https://www.reddit.com/r/selfhosted/"
|
||||
```
|
||||
|
||||
## Prometheus
|
||||
|
||||
For Prometheus you need to set the type to Prometheus and provide a url.
|
||||
|
||||
```yaml
|
||||
- name: "Prometheus"
|
||||
type: Prometheus
|
||||
logo: "assets/tools/sample.png"
|
||||
url: "http://192.168.0.151/"
|
||||
# subtitle: "Monitor data server"
|
||||
```
|
||||
|
||||
## AdGuard Home
|
||||
For AdGuard Home you need to set the type to AdGuard, if you have somes issues as 403 responses on requests you need to provide authentification in headers for locations needed as below.
|
||||
|
||||
```yaml
|
||||
- name: "Adguard"
|
||||
logo: "assets/tools/adguardhome.png"
|
||||
url: "https://adguard.exemple.com"
|
||||
target: "_blank"
|
||||
type: "AdGuardHome"
|
||||
```
|
||||
|
||||
## Portainer
|
||||
|
||||
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.
|
||||
|
||||
See https://docs.portainer.io/v/ce-2.11/user/account-settings#access-tokens
|
||||
|
||||
```yaml
|
||||
- name: "Portainer"
|
||||
logo: "assets/tools/sample.png"
|
||||
url: "http://192.168.0.151/"
|
||||
type: "Portainer"
|
||||
apikey: "MY-SUPER-SECRET-API-KEY"
|
||||
# environments:
|
||||
# - "raspberry"
|
||||
# - "local"
|
||||
```
|
||||
|
||||
## Emby
|
||||
|
||||
You need to set the type to Emby, provide an api key and choose which stats to show if the subtitle is disabled.
|
||||
|
||||
```yaml
|
||||
- name: "Emby"
|
||||
logo: "assets/tools/sample.png"
|
||||
url: "http://192.168.0.151/"
|
||||
type: "Emby"
|
||||
apikey: "MY-SUPER-SECRET-API-KEY"
|
||||
libraryType: "music" #Choose which stats to show. Can be one of: music, series or movies.
|
||||
```
|
@ -1,4 +1,6 @@
|
||||
## Development
|
||||
# Development
|
||||
|
||||
If you want to contribute to Homer, please read the [contributing guidelines](https://github.com/bastienwirtz/homer/blob/main/CONTRIBUTING.md) first.
|
||||
|
||||
```sh
|
||||
# Using yarn (recommended)
|
||||
@ -10,7 +12,50 @@ npm install
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Themes
|
||||
## Custom services
|
||||
|
||||
Custom services are small VueJs component (see `src/components/services/`) that add little features to a classic, "static", dashboard item. It should be very simple.
|
||||
A dashboard can contain a lot of items, so performance is very important.
|
||||
|
||||
The [`Generic`](https://github.com/bastienwirtz/homer/blob/main/src/components/services/Generic.vue) service provides a typical card layout which
|
||||
you can extend to add specific features. Unless you want a completely different design, extended the generic service is the recommended way. It gives you 3 [slots](https://vuejs.org/v2/guide/components-slots.html#Named-Slots) to extend: `icon`, `content` and `indicator`.
|
||||
Each one is **optional**, and will display the usual information if omitted.
|
||||
|
||||
Each service must implement the `item` [property](https://vuejs.org/v2/guide/components-props.html) and bind it the Generic component if used.
|
||||
|
||||
### Skeleton
|
||||
```Vue
|
||||
<template>
|
||||
<Generic :item="item">
|
||||
<template #icon>
|
||||
<!-- left area containing the icon -->
|
||||
</template>
|
||||
<template #content>
|
||||
<!-- main area containing the title, subtitle, ... -->
|
||||
</template>
|
||||
<template #indicator>
|
||||
<!-- top right area, empty by default -->
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "MyNewService",
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
|
||||
## Themes
|
||||
|
||||
Themes are meant to be simple customization (written in [scss](https://sass-lang.com/documentation/syntax)).
|
||||
To add a new theme, just add a file in the theme directory, and put all style in the `body #app.theme-<name>` scope. Then import it in the main style file.
|
||||
|
@ -3,6 +3,7 @@
|
||||
Here is a collection of neat tips and tricks that Homer users have come up with!
|
||||
|
||||
## Use Homer as a custom "new tab" page
|
||||
|
||||
#### `by @vosdev`
|
||||
|
||||
These extensions for [Firefox](https://addons.mozilla.org/firefox/addon/custom-new-tab-page) and [Chrome & Friends](https://chrome.google.com/webstore/detail/new-tab-changer/occbjkhimchkolibngmcefpjlbknggfh) allow you to have your homer dashboard in your new tab page, while leaving focus on the address bar meaning you can still type right away if you want to search or go to a page that is not on your homer dash.
|
||||
@ -22,11 +23,12 @@ The Firefox extension loads Homer in an iframe on your new tab page, meaning you
|
||||
```
|
||||
|
||||
## YAML Anchors
|
||||
|
||||
#### `by @JamiePhonic`
|
||||
|
||||
Since Homer is configured using YAML, it supports all of YAML's helpful features, such as anchoring!
|
||||
|
||||
For example, you can define tags and tag styles for each "item" in a service.
|
||||
For example, you can define tags and tag styles for each "item" in a service.
|
||||
Using Anchoring, you can define all your tags and their styles once like this: (for example)
|
||||
|
||||
```yaml
|
||||
@ -49,7 +51,7 @@ and then simply reference these pre-defined (anchored) tags in each item like so
|
||||
- name: "VS Code"
|
||||
logo: "/assets/vscode.png"
|
||||
subtitle: "Develop Code Anywhere, On Anything!"
|
||||
<<: *App # Reference to the predefined "App" Tag
|
||||
<<: *Apps # Reference to the predefined "App" Tag
|
||||
url: "https://vscode.example.com/"
|
||||
target: "_blank" # optional html tag target attribute
|
||||
````
|
||||
@ -70,6 +72,7 @@ The end result is that if you want to update the name or style of any particular
|
||||
Great if you have a lot of services or a lot of tags!
|
||||
|
||||
## Remotely edit your config with Code Server
|
||||
|
||||
#### `by @JamiePhonic`
|
||||
|
||||
Homer doesn't yet provide a way to edit your configuration from inside Homer itself, but that doesn't mean it can't be done!
|
||||
@ -78,14 +81,17 @@ You can setup and use [Code-Server](https://github.com/cdr/code-server) to edit
|
||||
|
||||
If you're running Homer in docker, you can setup a Code-Server container and pass your homer config directory into it.
|
||||
Simply pass your homer config directory as an extra -v parameter to your code-server container:
|
||||
```
|
||||
|
||||
```sh
|
||||
-v '/your/local/homer/config-dir/':'/config/homer':'rw'
|
||||
```
|
||||
|
||||
This will map your homer config directory (For example, /docker/appdata/homer/) into code-server's `/config/` directory, in a sub folder called `homer`
|
||||
|
||||
As a bonus, Code-Server puts the "current folder" as a parameter in the URL bar, so you could add a `links:` entry in Homer that points to your code-server instance with the directory pre-filled for essentially 1 click editing!
|
||||
|
||||
For example:
|
||||
|
||||
```yml
|
||||
links:
|
||||
- name: Edit config
|
||||
@ -93,9 +99,11 @@ links:
|
||||
url: https://vscode.example.net/?folder=/config/homer
|
||||
target: "_blank" # optional html tag target attribute
|
||||
```
|
||||
|
||||
where the path after `?folder=` is the path to the folder where you mounted your homer config INSIDE the Code-Server container.
|
||||
|
||||
### Example Code-Server docker create command
|
||||
|
||||
```sh
|
||||
docker create \
|
||||
--name=code-server \
|
||||
@ -111,13 +119,13 @@ docker create \
|
||||
linuxserver/code-server
|
||||
```
|
||||
|
||||
|
||||
## Get the news headlines in Homer
|
||||
|
||||
### Mapping Fields
|
||||
|
||||
Most times, the url you're getting headlines from follows a different schema than the one expected by Homer.
|
||||
|
||||
For example, if you would like to show jokes from ChuckNorris.io, you'll find that the url https://api.chucknorris.io/jokes/random is giving you info like this:
|
||||
For example, if you would like to show jokes from ChuckNorris.io, you'll find that the url <https://api.chucknorris.io/jokes/random> is giving you info like this:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -179,6 +187,6 @@ If the URL you specified returns a JSON object that defines a `title` and `conte
|
||||
|
||||
So, using [Node-Red](https://nodered.org/docs/getting-started/) and a quick flow, you can process an RSS feed to replace the message with a news item!
|
||||
|
||||
To get started, simply import [this flow](https://flows.nodered.org/flow/4b6406c9a684c26ace0430dd1826e95d) into your Node-Red instance and change the RSS feed in the "Get News RSS Feed" node to one of your choosing!
|
||||
To get started, simply import [this flow](https://flows.nodered.org/flow/4b6406c9a684c26ace0430dd1826e95d) into your Node-Red instance and change the RSS feed in the "Get News RSS Feed" node to one of your choosing!
|
||||
|
||||
So far, the flow has been tested with BBC News and Sky News, however it should be easy to modify the flow to work with other RSS feeds if they don't work out of the box!
|
||||
|
19
docs/troubleshooting.md
Normal file
19
docs/troubleshooting.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Troubleshooting
|
||||
|
||||
## My custom service card doesn't work, nothing appears or offline status is displayed (pi-hole, sonarr, ping, ...)
|
||||
|
||||
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).
|
||||
If this happens your web console (`ctrl+shift+i` or `F12`) will be filled with this kind of errors:
|
||||
|
||||
```text
|
||||
Access to fetch at 'https://<target-service>' from origin 'https://<homer>' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
|
||||
```
|
||||
|
||||
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.
|
35
package.json
35
package.json
@ -1,36 +1,35 @@
|
||||
{
|
||||
"name": "homer",
|
||||
"version": "20.06.1",
|
||||
"license": "Apache-2.0",
|
||||
"version": "21.09.1",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
||||
"bulma": "^0.9.2",
|
||||
"core-js": "^3.8.3",
|
||||
"js-yaml": "^4.0.0",
|
||||
"@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",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"vue": "^2.6.12"
|
||||
"vue": "^2.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.12.16",
|
||||
"@vue/cli-plugin-babel": "~4.5.11",
|
||||
"@vue/cli-plugin-eslint": "~4.5.11",
|
||||
"@vue/cli-plugin-pwa": "~4.5.11",
|
||||
"@vue/cli-service": "~4.5.11",
|
||||
"@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": "^7.16.0",
|
||||
"eslint-plugin-prettier": "^3.3.0",
|
||||
"eslint-plugin-vue": "^7.3.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.30.0",
|
||||
"sass-loader": "^10.1.0",
|
||||
"sass": "^1.26.5",
|
||||
"sass-loader": "^8.0.2",
|
||||
"vue-template-compiler": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
# Additionnal page configuration
|
||||
# Additional page configuration
|
||||
|
||||
# Additionnal configurations are loaded using its file name, minus the extension, as an anchor (https://<mydashboad>#<config>).
|
||||
# Additional configurations are loaded using its file name, minus the extension, as an anchor (https://<mydashboad>#<config>).
|
||||
# `config.yml` is still used as a base configuration, and all values here will overwrite it, so you don't have to re-defined everything
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
# Homepage configuration
|
||||
# See https://fontawesome.com/icons for icons options
|
||||
# See https://fontawesome.com/v5/search for icons options
|
||||
|
||||
title: "Demo dashboard"
|
||||
subtitle: "Homer"
|
||||
@ -24,6 +24,7 @@ colors:
|
||||
text-title: "#303030"
|
||||
text-subtitle: "#424242"
|
||||
card-shadow: rgba(0, 0, 0, 0.1)
|
||||
link: "#3273dc"
|
||||
link-hover: "#363636"
|
||||
dark:
|
||||
highlight-primary: "#3367d6"
|
||||
@ -36,6 +37,7 @@ colors:
|
||||
text-title: "#fafafa"
|
||||
text-subtitle: "#f5f5f5"
|
||||
card-shadow: rgba(0, 0, 0, 0.4)
|
||||
link: "#3273dc"
|
||||
link-hover: "#ffdd57"
|
||||
|
||||
# Optional message
|
||||
@ -56,11 +58,11 @@ links:
|
||||
- name: "Wiki"
|
||||
icon: "fas fa-book"
|
||||
url: "https://www.wikipedia.org/"
|
||||
# this will link to a second homer page that will load config from additionnal-page.yml and keep default config values as in config.yml file
|
||||
# see url field and assets/additionnal-page.yml.dist used in this example:
|
||||
# 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: "#additionnal-page"
|
||||
url: "#additional-page"
|
||||
|
||||
# Services
|
||||
# First level array represent a group.
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
# Homepage configuration
|
||||
# See https://fontawesome.com/icons for icons options
|
||||
# See https://fontawesome.com/v5/search for icons options
|
||||
|
||||
title: "Hello beautiful!"
|
||||
subtitle: "App dashboard"
|
||||
|
29
src/App.vue
29
src/App.vue
@ -30,17 +30,22 @@
|
||||
:links="config.links"
|
||||
@navbar-toggle="showMenu = !showMenu"
|
||||
>
|
||||
<DarkMode @updated="isDark = $event" />
|
||||
<DarkMode
|
||||
@updated="isDark = $event"
|
||||
:defaultValue="this.config.defaults.colorTheme"
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
@updated="vlayout = $event"
|
||||
name="vlayout"
|
||||
icon="fa-list"
|
||||
iconAlt="fa-columns"
|
||||
:defaultValue="this.config.defaults.layout == 'columns'"
|
||||
/>
|
||||
|
||||
<SearchInput
|
||||
class="navbar-item is-inline-block-mobile"
|
||||
:hotkey="searchHotkey()"
|
||||
@input="filterServices"
|
||||
@search-focus="showMenu = true"
|
||||
@search-open="navigateToFirstService"
|
||||
@ -55,6 +60,9 @@
|
||||
v-if="config.connectivityCheck"
|
||||
@network-status-update="offline = $event"
|
||||
/>
|
||||
|
||||
<GetStarted v-if="loaded && !services" />
|
||||
|
||||
<div v-if="!offline">
|
||||
<!-- Optional messages -->
|
||||
<Message :item="config.message" />
|
||||
@ -74,7 +82,9 @@
|
||||
<Service
|
||||
v-for="(item, index) in group.items"
|
||||
:key="index"
|
||||
v-bind:item="item"
|
||||
:item="item"
|
||||
:proxy="config.proxy"
|
||||
:forwarder="config.forwarder"
|
||||
:class="['column', `is-${12 / config.columns}`]"
|
||||
/>
|
||||
</template>
|
||||
@ -102,7 +112,9 @@
|
||||
<Service
|
||||
v-for="(item, index) in group.items"
|
||||
:key="index"
|
||||
v-bind:item="item"
|
||||
:item="item"
|
||||
:proxy="config.proxy"
|
||||
:forwarder="config.forwarder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -127,6 +139,7 @@ const jsyaml = require("js-yaml");
|
||||
const merge = require("lodash.merge");
|
||||
|
||||
import Navbar from "./components/Navbar.vue";
|
||||
import GetStarted from "./components/GetStarted.vue";
|
||||
import ConnectivityChecker from "./components/ConnectivityChecker.vue";
|
||||
import Service from "./components/Service.vue";
|
||||
import Message from "./components/Message.vue";
|
||||
@ -141,6 +154,7 @@ export default {
|
||||
name: "App",
|
||||
components: {
|
||||
Navbar,
|
||||
GetStarted,
|
||||
ConnectivityChecker,
|
||||
Service,
|
||||
Message,
|
||||
@ -151,6 +165,7 @@ export default {
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
loaded: false,
|
||||
config: null,
|
||||
services: null,
|
||||
offline: false,
|
||||
@ -163,8 +178,14 @@ export default {
|
||||
created: async function () {
|
||||
this.buildDashboard();
|
||||
window.onhashchange = this.buildDashboard;
|
||||
this.loaded = true;
|
||||
},
|
||||
methods: {
|
||||
searchHotkey() {
|
||||
if (this.config.hotkey && this.config.hotkey.search) {
|
||||
return this.config.hotkey.search;
|
||||
}
|
||||
},
|
||||
buildDashboard: async function () {
|
||||
const defaults = jsyaml.load(defaultConfig);
|
||||
let config;
|
||||
@ -185,6 +206,7 @@ export default {
|
||||
}
|
||||
this.config = merge(defaults, config);
|
||||
this.services = this.config.services;
|
||||
|
||||
document.title =
|
||||
this.config.documentTitle ||
|
||||
`${this.config.title} | ${this.config.subtitle}`;
|
||||
@ -203,6 +225,7 @@ export default {
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw Error(`${response.statusText}: ${response.body}`);
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ body {
|
||||
transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
|
||||
|
||||
a {
|
||||
color: var(--link);
|
||||
&:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
@ -106,7 +107,7 @@ body {
|
||||
}
|
||||
|
||||
.first-line {
|
||||
height: 100px;
|
||||
min-height: 100px;
|
||||
vertical-align: center;
|
||||
background-color: var(--highlight-primary);
|
||||
|
||||
@ -121,7 +122,7 @@ body {
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
@ -140,8 +141,7 @@ body {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navbar,
|
||||
.navbar-menu {
|
||||
.navbar {
|
||||
background-color: var(--highlight-secondary);
|
||||
|
||||
a {
|
||||
@ -153,6 +153,9 @@ body {
|
||||
background-color: var(--highlight-hover);
|
||||
}
|
||||
}
|
||||
.navbar-menu {
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
.navbar-end {
|
||||
text-align: right;
|
||||
@ -211,7 +214,7 @@ body {
|
||||
color: var(--highlight-secondary);
|
||||
background-color: var(--highlight-secondary);
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
bottom: 1rem;
|
||||
right: -0.2rem;
|
||||
width: 3px;
|
||||
overflow: hidden;
|
||||
@ -224,7 +227,6 @@ body {
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
transition: cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
|
||||
@ -260,11 +262,13 @@ body {
|
||||
}
|
||||
|
||||
.column div:first-of-type .card {
|
||||
border-radius: 5px 5px 0 0;
|
||||
border-top-left-radius: 0.25rem;
|
||||
border-top-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.column div:last-child .card {
|
||||
border-radius: 0 0 5px 5px;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -348,4 +352,4 @@ body {
|
||||
|
||||
.group-logo {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,12 @@ footer: '<p>Created with <span class="has-text-danger">❤️</span> with <a hre
|
||||
columns: 3
|
||||
connectivityCheck: true
|
||||
|
||||
defaults:
|
||||
# columns, list
|
||||
layout: columns
|
||||
# auto, light, dark
|
||||
colorTheme: auto
|
||||
|
||||
theme: default
|
||||
colors:
|
||||
light:
|
||||
@ -23,6 +29,7 @@ colors:
|
||||
text-title: "#303030"
|
||||
text-subtitle: "#424242"
|
||||
card-shadow: rgba(0, 0, 0, 0.1)
|
||||
link: "#3273dc"
|
||||
link-hover: "#363636"
|
||||
background-image: ""
|
||||
dark:
|
||||
@ -36,9 +43,14 @@ colors:
|
||||
text-title: "#fafafa"
|
||||
text-subtitle: "#f5f5f5"
|
||||
card-shadow: rgba(0, 0, 0, 0.4)
|
||||
link: "#3273dc"
|
||||
link-hover: "#ffdd57"
|
||||
background-image: ""
|
||||
|
||||
message: ~
|
||||
links: []
|
||||
services: []
|
||||
|
||||
|
||||
proxy: ~
|
||||
forwarder: ~
|
||||
|
@ -37,8 +37,8 @@ export default {
|
||||
method: "HEAD",
|
||||
cache: "no-store",
|
||||
})
|
||||
.then(function () {
|
||||
that.offline = false;
|
||||
.then(function (response) {
|
||||
that.offline = !response.ok;
|
||||
})
|
||||
.catch(function () {
|
||||
that.offline = true;
|
||||
|
@ -15,6 +15,9 @@
|
||||
<script>
|
||||
export default {
|
||||
name: "Darkmode",
|
||||
props: {
|
||||
defaultValue: String,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
isDark: null,
|
||||
@ -30,6 +33,17 @@ export default {
|
||||
if ("overrideDark" in localStorage) {
|
||||
// Light theme is 1 and Dark theme is 2
|
||||
this.mode = JSON.parse(localStorage.overrideDark) ? 2 : 1;
|
||||
} else {
|
||||
switch (this.defaultValue) {
|
||||
case "light":
|
||||
this.mode = 1;
|
||||
break;
|
||||
case "dark":
|
||||
this.mode = 2;
|
||||
break;
|
||||
default:
|
||||
this.mode = 0;
|
||||
}
|
||||
}
|
||||
this.isDark = this.getIsDark();
|
||||
this.$emit("updated", this.isDark);
|
||||
|
35
src/components/GetStarted.vue
Normal file
35
src/components/GetStarted.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<article>
|
||||
<div class="m-6 has-text-centered py-6">
|
||||
<p class="is-size-5 mb-0">No configured service found!</p>
|
||||
<p>Check out the documentation to start building your Homer dashboard.</p>
|
||||
<p>
|
||||
<a
|
||||
class="button is-primary mt-5 has-text-weight-bold"
|
||||
href="https://github.com/bastienwirtz/homer/blob/main/README.md#getting-started"
|
||||
target="_blank"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "GetStarted",
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
p {
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
body #app a {
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
font-family: "Lato", sans-serif;
|
||||
}
|
||||
</style>
|
@ -42,19 +42,29 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getMessage: async function () {
|
||||
if (this.item && this.item.url) {
|
||||
if (!this.item) {
|
||||
return;
|
||||
}
|
||||
if (this.item.url) {
|
||||
let fetchedMessage = await this.downloadMessage(this.item.url);
|
||||
if (this.item.mapping)
|
||||
console.log("done");
|
||||
if (this.item.mapping) {
|
||||
fetchedMessage = this.mapRemoteMessage(fetchedMessage);
|
||||
}
|
||||
|
||||
// keep the original config value if no value is provided by the endpoint
|
||||
for (const prop of ["title", "style", "content"]) {
|
||||
const message = this.message;
|
||||
for (const prop of ["title", "style", "content", "icon"]) {
|
||||
if (prop in fetchedMessage && fetchedMessage[prop] !== null) {
|
||||
this.message[prop] = fetchedMessage[prop];
|
||||
message[prop] = fetchedMessage[prop];
|
||||
}
|
||||
}
|
||||
this.message = { ...message }; // Force computed property to re-evaluate
|
||||
}
|
||||
if (this.item.refreshInterval)
|
||||
|
||||
if (this.item.refreshInterval) {
|
||||
setTimeout(this.getMessage, this.item.refreshInterval);
|
||||
}
|
||||
},
|
||||
|
||||
downloadMessage: function (url) {
|
||||
|
@ -15,10 +15,16 @@
|
||||
<script>
|
||||
export default {
|
||||
name: "SearchInput",
|
||||
props: ["value"],
|
||||
props: {
|
||||
value: String,
|
||||
hotkey: {
|
||||
type: String,
|
||||
default: "/",
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this._keyListener = function (event) {
|
||||
if (event.key === "/") {
|
||||
if (event.key === this.hotkey) {
|
||||
event.preventDefault();
|
||||
this.focus();
|
||||
}
|
||||
@ -28,7 +34,7 @@ export default {
|
||||
};
|
||||
document.addEventListener("keydown", this._keyListener.bind(this));
|
||||
|
||||
// fill seach from get parameter.
|
||||
// fill search from get parameter.
|
||||
const search = new URLSearchParams(window.location.search).get("search");
|
||||
if (search) {
|
||||
this.$refs.search.value = search;
|
||||
|
@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<component v-bind:is="component" :item="item"></component>
|
||||
<component
|
||||
v-bind:is="component"
|
||||
:item="item"
|
||||
:proxy="proxy"
|
||||
:forwarder="forwarder"
|
||||
></component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -7,16 +12,15 @@ import Generic from "./services/Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Service",
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
props: {
|
||||
item: Object,
|
||||
proxy: Object,
|
||||
forwarder: Object,
|
||||
},
|
||||
computed: {
|
||||
component() {
|
||||
const type = this.item.type || "Generic";
|
||||
if (type == "Generic") {
|
||||
if (type === "Generic") {
|
||||
return Generic;
|
||||
}
|
||||
return () => import(`./services/${type}.vue`);
|
||||
|
@ -12,6 +12,7 @@ export default {
|
||||
name: String,
|
||||
icon: String,
|
||||
iconAlt: String,
|
||||
defaultValue: Boolean,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
@ -24,6 +25,8 @@ export default {
|
||||
|
||||
if (this.name in localStorage) {
|
||||
this.value = JSON.parse(localStorage[this.name]);
|
||||
} else {
|
||||
this.value = this.defaultValue;
|
||||
}
|
||||
|
||||
this.$emit("updated", this.value);
|
||||
|
@ -1,68 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card" :class="item.class">
|
||||
<a :href="item.url" :target="item.target" rel="noreferrer">
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<div v-if="item.logo" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img :src="item.logo" :alt="`${item.name} logo`" />
|
||||
</figure>
|
||||
</div>
|
||||
<div v-if="item.icon" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<i style="font-size: 35px" :class="['fa-fw', item.icon]"></i>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p class="title is-4">{{ item.name }}</p>
|
||||
<p class="subtitle is-6">{{ item.subtitle }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="status"
|
||||
class="status"
|
||||
v-bind:class="status.protection_enabled ? 'enabled' : 'disabled'"
|
||||
>
|
||||
{{ status.protection_enabled }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag" :class="item.tagstyle" v-if="item.tag">
|
||||
<strong class="tag-text">#{{ item.tag }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<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="stats">
|
||||
{{ percentage }}% blocked
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
<template #indicator>
|
||||
<div class="status" :class="protection">
|
||||
{{ protection }}
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "AdGuardHome",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => {
|
||||
return {
|
||||
status: null,
|
||||
stats: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
percentage: function () {
|
||||
if (this.stats) {
|
||||
return (
|
||||
(this.stats.num_blocked_filtering * 100) /
|
||||
this.stats.num_dns_queries
|
||||
).toFixed(2);
|
||||
}
|
||||
return "";
|
||||
},
|
||||
protection: function () {
|
||||
if (this.status) {
|
||||
return this.status.protection_enabled ? "enabled" : "disabled";
|
||||
} else return "unknown";
|
||||
},
|
||||
},
|
||||
created: function () {
|
||||
this.fetchStatus();
|
||||
if (!this.item.subtitle) {
|
||||
this.fetchStats();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
this.status = await fetch(
|
||||
`${this.item.url}/control/status`
|
||||
).then((response) => response.json());
|
||||
this.status = await this.fetch("/control/status").catch((e) =>
|
||||
console.log(e)
|
||||
);
|
||||
},
|
||||
fetchStats: async function () {
|
||||
this.stats = await this.fetch("/control/stats").catch((e) =>
|
||||
console.log(e)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media-left img {
|
||||
max-height: 100%;
|
||||
}
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-title);
|
||||
@ -79,6 +92,12 @@ export default {
|
||||
box-shadow: 0px 0px 4px 1px #c9404d;
|
||||
}
|
||||
|
||||
&.unknown:before {
|
||||
background-color: #c9c740;
|
||||
border-color: #ccc935;
|
||||
box-shadow: 0px 0px 4px 1px #c9c740;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
|
118
src/components/services/Emby.vue
Normal file
118
src/components/services/Emby.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<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>
|
||||
{{ embyCount }}
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
<template #indicator>
|
||||
<div v-if="status" class="status" :class="status">
|
||||
{{ status }}
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Emby",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => ({
|
||||
status: "",
|
||||
albumCount: 0,
|
||||
songCount: 0,
|
||||
movieCount: 0,
|
||||
seriesCount: 0,
|
||||
episodeCount: 0,
|
||||
}),
|
||||
computed: {
|
||||
embyCount: function () {
|
||||
if (this.item.libraryType === "music")
|
||||
return `${this.songCount} songs, ${this.albumCount} albums`;
|
||||
else if (this.item.libraryType === "movies")
|
||||
return `${this.movieCount} movies`;
|
||||
else if (this.item.libraryType === "series")
|
||||
return `${this.episodeCount} eps, ${this.seriesCount} series`;
|
||||
else return `wrong library type 💀`;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchServerStatus();
|
||||
|
||||
if (!this.item.subtitle && this.status !== "dead")
|
||||
this.fetchServerMediaStats();
|
||||
},
|
||||
methods: {
|
||||
fetchServerStatus: async function () {
|
||||
this.fetch("/System/info/public")
|
||||
.then((response) => {
|
||||
if (response.Id) this.status = "running";
|
||||
else throw new Error();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
this.status = "dead";
|
||||
});
|
||||
},
|
||||
fetchServerMediaStats: async function () {
|
||||
const headers = {
|
||||
"X-Emby-Token": this.item.apikey,
|
||||
};
|
||||
|
||||
var data = await this.fetch("/items/counts", { headers }).catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
|
||||
this.albumCount = data.AlbumCount;
|
||||
this.songCount = data.SongCount;
|
||||
this.movieCount = data.MovieCount;
|
||||
this.seriesCount = data.SeriesCount;
|
||||
this.episodeCount = data.EpisodeCount;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-title);
|
||||
|
||||
&.running:before {
|
||||
background-color: #94e185;
|
||||
border-color: #78d965;
|
||||
box-shadow: 0 0 5px 1px #94e185;
|
||||
}
|
||||
|
||||
&.dead: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>
|
@ -8,22 +8,27 @@
|
||||
<a :href="item.url" :target="item.target" rel="noreferrer">
|
||||
<div class="card-content">
|
||||
<div :class="mediaClass">
|
||||
<div v-if="item.logo" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img :src="item.logo" :alt="`${item.name} logo`" />
|
||||
</figure>
|
||||
</div>
|
||||
<div v-if="item.icon" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<i style="font-size: 35px" :class="['fa-fw', item.icon]"></i>
|
||||
</figure>
|
||||
</div>
|
||||
<slot name="icon">
|
||||
<div v-if="item.logo" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img :src="item.logo" :alt="`${item.name} logo`" />
|
||||
</figure>
|
||||
</div>
|
||||
<div v-if="item.icon" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<i style="font-size: 35px" :class="['fa-fw', item.icon]"></i>
|
||||
</figure>
|
||||
</div>
|
||||
</slot>
|
||||
<div class="media-content">
|
||||
<p class="title is-4">{{ item.name }}</p>
|
||||
<p class="subtitle is-6" v-if="item.subtitle">
|
||||
{{ item.subtitle }}
|
||||
</p>
|
||||
<slot name="content">
|
||||
<p class="title is-4">{{ item.name }}</p>
|
||||
<p class="subtitle is-6" v-if="item.subtitle">
|
||||
{{ item.subtitle }}
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="indicator" class="indicator"></slot>
|
||||
</div>
|
||||
<div class="tag" :class="item.tagstyle" v-if="item.tag">
|
||||
<strong class="tag-text">#{{ item.tag }}</strong>
|
||||
|
110
src/components/services/Lidarr.vue
Normal file
110
src/components/services/Lidarr.vue
Normal file
@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<Generic :item="item">
|
||||
<template #indicator>
|
||||
<div class="notifs">
|
||||
<strong v-if="activity > 0" class="notif activity" title="Activity">
|
||||
{{ activity }}
|
||||
</strong>
|
||||
<strong v-if="warnings > 0" class="notif warnings" title="Warning">
|
||||
{{ warnings }}
|
||||
</strong>
|
||||
<strong v-if="errors > 0" class="notif errors" title="Error">
|
||||
{{ errors }}
|
||||
</strong>
|
||||
<strong
|
||||
v-if="serverError"
|
||||
class="notif errors"
|
||||
title="Connection error to Lidarr API, check url and apikey in config.yml"
|
||||
>?</strong
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Lidarr",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => {
|
||||
return {
|
||||
activity: null,
|
||||
warnings: null,
|
||||
errors: null,
|
||||
serverError: false,
|
||||
};
|
||||
},
|
||||
created: function () {
|
||||
this.fetchConfig();
|
||||
},
|
||||
methods: {
|
||||
fetchConfig: function () {
|
||||
this.fetch(`/api/v1/health?apikey=${this.item.apikey}`)
|
||||
.then((health) => {
|
||||
this.warnings = 0;
|
||||
this.errors = 0;
|
||||
for (var i = 0; i < health.length; i++) {
|
||||
if (health[i].type == "warning") {
|
||||
this.warnings++;
|
||||
} else if (health[i].type == "error") {
|
||||
this.errors++;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
this.serverError = true;
|
||||
});
|
||||
this.fetch(`/api/v1/queue/status?apikey=${this.item.apikey}`)
|
||||
.then((queue) => {
|
||||
this.activity = queue.totalCount;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
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-right: 0.35em;
|
||||
padding-left: 0.35em;
|
||||
padding-top: 0.2em;
|
||||
padding-bottom: 0.2em;
|
||||
border-radius: 0.25em;
|
||||
position: relative;
|
||||
margin-left: 0.3em;
|
||||
font-size: 0.8em;
|
||||
&.activity {
|
||||
background-color: #4fb5d6;
|
||||
}
|
||||
|
||||
&.warnings {
|
||||
background-color: #d08d2e;
|
||||
}
|
||||
|
||||
&.errors {
|
||||
background-color: #e51111;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
56
src/components/services/Mealie.vue
Normal file
56
src/components/services/Mealie.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<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="meal"> Today: {{ meal.name }} </template>
|
||||
<template v-else-if="stats">
|
||||
happily keeping {{ stats.totalRecipes }} recipes organized
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Mealie",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => ({
|
||||
stats: null,
|
||||
meal: null,
|
||||
}),
|
||||
created() {
|
||||
this.fetchStatus();
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
const headers = {
|
||||
Authorization: "Bearer " + this.item.apikey,
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (this.item.subtitle != null) return;
|
||||
|
||||
this.meal = await this.fetch("/api/meal-plans/today/", { headers }).catch(
|
||||
(e) => console.log(e)
|
||||
);
|
||||
this.stats = await this.fetch("/api/debug/statistics/", {
|
||||
headers,
|
||||
}).catch((e) => console.log(e));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
102
src/components/services/Medusa.vue
Normal file
102
src/components/services/Medusa.vue
Normal file
@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<Generic :item="item">
|
||||
<template #indicator>
|
||||
<div class="notifs">
|
||||
<strong
|
||||
v-if="config !== null && config.system.news.unread > 0"
|
||||
class="notif news"
|
||||
title="News"
|
||||
>{{ config.system.news.unread }}</strong
|
||||
>
|
||||
<strong
|
||||
v-if="config !== null && config.main.logs.numWarnings > 0"
|
||||
class="notif warnings"
|
||||
title="Warning"
|
||||
>{{ config.main.logs.numWarnings }}</strong
|
||||
>
|
||||
<strong
|
||||
v-if="config !== null && config.main.logs.numErrors > 0"
|
||||
class="notif errors"
|
||||
title="Error"
|
||||
>{{ config.main.logs.numErrors }}</strong
|
||||
>
|
||||
<strong
|
||||
v-if="serverError"
|
||||
class="notif errors"
|
||||
title="Connection error to Medusa API, check url and apikey in config.yml"
|
||||
>?</strong
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Medusa",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => {
|
||||
return {
|
||||
config: null,
|
||||
serverError: false,
|
||||
};
|
||||
},
|
||||
created: function () {
|
||||
this.fetchConfig();
|
||||
},
|
||||
methods: {
|
||||
fetchConfig: function () {
|
||||
this.fetch("/api/v2/config", {
|
||||
headers: { "X-Api-Key": this.item.apikey },
|
||||
})
|
||||
.then((conf) => {
|
||||
this.config = conf;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
this.serverError = true;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.notifs {
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
top: 0.3em;
|
||||
right: 0.5em;
|
||||
.notif {
|
||||
padding-right: 0.35em;
|
||||
padding-left: 0.35em;
|
||||
padding-top: 0.2em;
|
||||
padding-bottom: 0.2em;
|
||||
border-radius: 0.25em;
|
||||
position: relative;
|
||||
margin-left: 0.3em;
|
||||
font-size: 0.8em;
|
||||
&.news {
|
||||
background-color: #777777;
|
||||
}
|
||||
|
||||
&.warnings {
|
||||
background-color: #d08d2e;
|
||||
}
|
||||
|
||||
&.errors {
|
||||
background-color: #e51111;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
136
src/components/services/OpenWeather.vue
Normal file
136
src/components/services/OpenWeather.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card" :class="item.class">
|
||||
<a
|
||||
:href="`https://openweathermap.org/city/${id}`"
|
||||
:target="item.target"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<div v-if="icon" class="media-left" :class="item.background">
|
||||
<figure class="image is-48x48">
|
||||
<img
|
||||
:src="`https://openweathermap.org/img/wn/${icon}@2x.png`"
|
||||
:alt="conditions"
|
||||
:title="conditions"
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p v-if="error" class="error">Data could not be retrieved</p>
|
||||
<div v-else>
|
||||
<p class="title is-4">{{ name }}</p>
|
||||
<p class="subtitle is-6">
|
||||
{{ temp | tempSuffix(this.item.units) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag" :class="item.tagstyle" v-if="item.tag">
|
||||
<strong class="tag-text">#{{ item.tag }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "OpenWeather",
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
data: () => ({
|
||||
id: null,
|
||||
icon: null,
|
||||
name: null,
|
||||
temp: null,
|
||||
conditions: null,
|
||||
error: false,
|
||||
}),
|
||||
created() {
|
||||
this.fetchWeather();
|
||||
},
|
||||
methods: {
|
||||
fetchWeather: async function () {
|
||||
let locationQuery;
|
||||
|
||||
// Use location ID if specified, otherwise retrieve value from location (name).
|
||||
if (this.item.locationId) {
|
||||
locationQuery = `id=${this.item.locationId}`;
|
||||
} else {
|
||||
locationQuery = `q=${this.item.location}`;
|
||||
}
|
||||
|
||||
const apiKey = this.item.apikey || this.item.apiKey;
|
||||
const url = `https://api.openweathermap.org/data/2.5/weather?${locationQuery}&appid=${apiKey}&units=${this.item.units}`;
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw Error(response.statusText);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((weather) => {
|
||||
this.id = weather.id;
|
||||
this.name = weather.name;
|
||||
this.temp = parseInt(weather.main.temp).toFixed(1);
|
||||
this.icon = weather.weather[0].icon;
|
||||
this.conditions = weather.weather[0].description;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
this.error = true;
|
||||
});
|
||||
},
|
||||
},
|
||||
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}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// Add a border around the weather image.
|
||||
// Otherwise the image is not always distinguishable.
|
||||
.media-left {
|
||||
&.circle,
|
||||
&.square {
|
||||
background-color: #e4e4e4;
|
||||
}
|
||||
|
||||
&.circle {
|
||||
border-radius: 90%;
|
||||
}
|
||||
|
||||
img {
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #de0000;
|
||||
}
|
||||
|
||||
// Change background color in dark mode.
|
||||
.is-dark {
|
||||
.media-left {
|
||||
&.circle,
|
||||
&.square {
|
||||
background-color: #909090;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
55
src/components/services/PaperlessNG.vue
Normal file
55
src/components/services/PaperlessNG.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<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="api">
|
||||
happily storing {{ api.count }} documents
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Paperless",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => ({
|
||||
api: null,
|
||||
}),
|
||||
created() {
|
||||
this.fetchStatus();
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
if (this.item.subtitle != null) return;
|
||||
|
||||
const apikey = this.item.apikey;
|
||||
if (!apikey) {
|
||||
console.error(
|
||||
"apikey is not present in config.yml for the paperless entry!"
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.api = await this.fetch("/api/documents/", {
|
||||
headers: {
|
||||
Authorization: "Token " + this.item.apikey,
|
||||
},
|
||||
}).catch((e) => console.log(e));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
@ -1,59 +1,45 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card" :class="item.class">
|
||||
<a :href="item.url" :target="item.target" rel="noreferrer">
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<div v-if="item.logo" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img :src="item.logo" :alt="`${item.name} logo`" />
|
||||
</figure>
|
||||
</div>
|
||||
<div v-if="item.icon" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<i style="font-size: 35px" :class="['fa-fw', item.icon]"></i>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-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="api">
|
||||
{{ percentage }}% blocked
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="api" class="status" :class="api.status">
|
||||
{{ api.status }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag" :class="item.tagstyle" v-if="item.tag">
|
||||
<strong class="tag-text">#{{ item.tag }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<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="percentage">
|
||||
{{ percentage }}% blocked
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
<template #indicator>
|
||||
<div v-if="status" class="status" :class="status">
|
||||
{{ status }}
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "PiHole",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => ({
|
||||
api: {
|
||||
status: "",
|
||||
ads_percentage_today: 0,
|
||||
},
|
||||
status: "",
|
||||
ads_percentage_today: 0,
|
||||
}),
|
||||
computed: {
|
||||
percentage: function () {
|
||||
if (this.api) {
|
||||
return this.api.ads_percentage_today.toFixed(1);
|
||||
if (this.ads_percentage_today) {
|
||||
return this.ads_percentage_today.toFixed(1);
|
||||
}
|
||||
return "";
|
||||
},
|
||||
@ -63,19 +49,16 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
const url = `${this.item.url}/api.php`;
|
||||
this.api = await fetch(url)
|
||||
.then((response) => response.json())
|
||||
.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;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media-left img {
|
||||
max-height: 100%;
|
||||
}
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-title);
|
||||
@ -83,13 +66,13 @@ export default {
|
||||
&.enabled:before {
|
||||
background-color: #94e185;
|
||||
border-color: #78d965;
|
||||
box-shadow: 0 0 4px 1px #94e185;
|
||||
box-shadow: 0 0 5px 1px #94e185;
|
||||
}
|
||||
|
||||
&.disabled:before {
|
||||
background-color: #c9404d;
|
||||
border-color: #c42c3b;
|
||||
box-shadow: 0 0 4px 1px #c9404d;
|
||||
box-shadow: 0 0 5px 1px #c9404d;
|
||||
}
|
||||
|
||||
&:before {
|
||||
|
71
src/components/services/Ping.vue
Normal file
71
src/components/services/Ping.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<Generic :item="item">
|
||||
<template #indicator>
|
||||
<div v-if="status" class="status" :class="status">
|
||||
{{ status }}
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Ping",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => ({
|
||||
status: null,
|
||||
}),
|
||||
created() {
|
||||
this.fetchStatus();
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
this.fetch("/", { method: "HEAD", cache: "no-cache" }, false)
|
||||
.then(() => {
|
||||
this.status = "online";
|
||||
})
|
||||
.catch(() => {
|
||||
this.status = "offline";
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-title);
|
||||
|
||||
&.online:before {
|
||||
background-color: #94e185;
|
||||
border-color: #78d965;
|
||||
box-shadow: 0 0 5px 1px #94e185;
|
||||
}
|
||||
|
||||
&.offline: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>
|
139
src/components/services/Portainer.vue
Normal file
139
src/components/services/Portainer.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<Generic :item="item">
|
||||
<template #indicator>
|
||||
<div class="notifs">
|
||||
<strong v-if="running > 0" class="notif running" title="Running">
|
||||
{{ running }}
|
||||
</strong>
|
||||
<strong v-if="dead > 0" class="notif dead" title="Dead">
|
||||
{{ dead }}
|
||||
</strong>
|
||||
<strong
|
||||
v-if="misc > 0"
|
||||
class="notif misc"
|
||||
title="Other (creating, paused, exited, etc.)"
|
||||
>
|
||||
{{ misc }}
|
||||
</strong>
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Portainer",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => ({
|
||||
endpoints: null,
|
||||
containers: null,
|
||||
}),
|
||||
computed: {
|
||||
running: function () {
|
||||
if (!this.containers) {
|
||||
return "";
|
||||
}
|
||||
return this.containers.filter((container) => {
|
||||
return container.State.toLowerCase() === "running";
|
||||
}).length;
|
||||
},
|
||||
dead: function () {
|
||||
if (!this.containers) {
|
||||
return "";
|
||||
}
|
||||
return this.containers.filter((container) => {
|
||||
return container.State.toLowerCase() === "dead";
|
||||
}).length;
|
||||
},
|
||||
misc: function () {
|
||||
if (!this.containers) {
|
||||
return "";
|
||||
}
|
||||
return this.containers.filter((container) => {
|
||||
return (
|
||||
container.State.toLowerCase() !== "running" &&
|
||||
container.State.toLowerCase() !== "dead"
|
||||
);
|
||||
}).length;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchStatus();
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
const headers = {
|
||||
"X-Api-Key": this.item.apikey,
|
||||
};
|
||||
|
||||
this.endpoints = await this.fetch("/api/endpoints", { headers }).catch(
|
||||
(e) => {
|
||||
console.error(e);
|
||||
}
|
||||
);
|
||||
|
||||
let containers = [];
|
||||
for (let endpoint of this.endpoints) {
|
||||
if (
|
||||
this.item.environments &&
|
||||
!this.item.environments.includes(endpoint.Name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const uri = `/api/endpoints/${endpoint.Id}/docker/containers/json?all=1`;
|
||||
const endpointContainers = await this.fetch(uri, { headers }).catch(
|
||||
(e) => {
|
||||
console.error(e);
|
||||
}
|
||||
);
|
||||
|
||||
if (endpointContainers) {
|
||||
containers = containers.concat(endpointContainers);
|
||||
}
|
||||
}
|
||||
|
||||
this.containers = containers;
|
||||
},
|
||||
},
|
||||
};
|
||||
</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;
|
||||
|
||||
&.running {
|
||||
background-color: #4fd671;
|
||||
}
|
||||
|
||||
&.dead {
|
||||
background-color: #e51111;
|
||||
}
|
||||
|
||||
&.misc {
|
||||
background-color: #2ed0c8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
143
src/components/services/Prometheus.vue
Normal file
143
src/components/services/Prometheus.vue
Normal file
@ -0,0 +1,143 @@
|
||||
<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="api"> {{ count }} {{ level }} alerts </template>
|
||||
</p>
|
||||
</template>
|
||||
<template #indicator>
|
||||
<div v-if="api" class="status" :class="level">
|
||||
{{ count }}
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
const AlertsStatus = Object.freeze({
|
||||
firing: "firing",
|
||||
pending: "pending",
|
||||
inactive: "inactive",
|
||||
});
|
||||
|
||||
export default {
|
||||
name: "Prometheus",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => ({
|
||||
api: {
|
||||
status: "",
|
||||
count: 0,
|
||||
alerts: {
|
||||
firing: 0,
|
||||
inactive: 0,
|
||||
pending: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
computed: {
|
||||
count: function () {
|
||||
return (
|
||||
this.countFiring() || this.countPending() || this.countInactive() || 0
|
||||
);
|
||||
},
|
||||
level: function () {
|
||||
if (this.countFiring()) {
|
||||
return AlertsStatus.firing;
|
||||
} else if (this.countPending()) {
|
||||
return AlertsStatus.pending;
|
||||
}
|
||||
return AlertsStatus.inactive;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchStatus();
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
this.api = await this.fetch("api/v1/alerts").catch((e) => console.log(e));
|
||||
},
|
||||
countFiring: function () {
|
||||
if (this.api) {
|
||||
return this.api.data?.alerts?.filter(
|
||||
(alert) => alert.state === AlertsStatus.firing
|
||||
).length;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
countPending: function () {
|
||||
if (this.api) {
|
||||
return this.api.data?.alerts?.filter(
|
||||
(alert) => alert.state === AlertsStatus.pending
|
||||
).length;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
countInactive: function () {
|
||||
if (this.api) {
|
||||
return this.api.data?.alerts?.filter(
|
||||
(alert) => alert.state === AlertsStatus.pending
|
||||
).length;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media-left {
|
||||
.image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-title);
|
||||
|
||||
&.firing:before {
|
||||
background-color: #d65c68;
|
||||
border-color: #e87d88;
|
||||
box-shadow: 0 0 5px 1px #d65c68;
|
||||
}
|
||||
|
||||
&.pending:before {
|
||||
background-color: #e8bb7d;
|
||||
border-color: #d6a35c;
|
||||
box-shadow: 0 0 5px 1px #e8bb7d;
|
||||
}
|
||||
|
||||
&.inactive:before {
|
||||
background-color: #8fe87d;
|
||||
border-color: #70d65c;
|
||||
box-shadow: 0 0 5px 1px #8fe87d;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 10px;
|
||||
border: 1px solid #000;
|
||||
border-radius: 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
94
src/components/services/Prowlarr.vue
Normal file
94
src/components/services/Prowlarr.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<Generic :item="item">
|
||||
<template #indicator>
|
||||
<div class="notifs">
|
||||
<strong v-if="warnings > 0" class="notif warnings" title="Warning">
|
||||
{{ warnings }}
|
||||
</strong>
|
||||
<strong v-if="errors > 0" class="notif errors" title="Error">
|
||||
{{ errors }}
|
||||
</strong>
|
||||
<strong
|
||||
v-if="serverError"
|
||||
class="notif errors"
|
||||
title="Connection error to Prowlarr API, check url and apikey in config.yml"
|
||||
>
|
||||
?
|
||||
</strong>
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Prowlarr",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => {
|
||||
return {
|
||||
warnings: null,
|
||||
errors: null,
|
||||
serverError: false,
|
||||
};
|
||||
},
|
||||
created: function () {
|
||||
this.fetchConfig();
|
||||
},
|
||||
methods: {
|
||||
fetchConfig: function () {
|
||||
this.fetch(`/api/v1/health?apikey=${this.item.apikey}`)
|
||||
.then((health) => {
|
||||
this.warnings = 0;
|
||||
this.errors = 0;
|
||||
for (var i = 0; i < health.length; i++) {
|
||||
if (health[i].type == "warning") {
|
||||
this.warnings++;
|
||||
} else if (health[i].type == "error") {
|
||||
this.errors++;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
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>
|
125
src/components/services/Radarr.vue
Normal file
125
src/components/services/Radarr.vue
Normal file
@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<Generic :item="item">
|
||||
<template #indicator>
|
||||
<div class="notifs">
|
||||
<strong v-if="activity > 0" class="notif activity" title="Activity">
|
||||
{{ activity }}
|
||||
</strong>
|
||||
<strong v-if="warnings > 0" class="notif warnings" title="Warning">
|
||||
{{ warnings }}
|
||||
</strong>
|
||||
<strong v-if="errors > 0" class="notif errors" title="Error">
|
||||
{{ errors }}
|
||||
</strong>
|
||||
<strong
|
||||
v-if="serverError"
|
||||
class="notif errors"
|
||||
title="Connection error to Radarr API, check url and apikey in config.yml"
|
||||
>?</strong
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
const V3_API = "/api/v3";
|
||||
const LEGACY_API = "/api";
|
||||
|
||||
export default {
|
||||
name: "Radarr",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
data: () => {
|
||||
return {
|
||||
activity: null,
|
||||
warnings: null,
|
||||
errors: null,
|
||||
serverError: false,
|
||||
};
|
||||
},
|
||||
created: function () {
|
||||
this.fetchConfig();
|
||||
},
|
||||
computed: {
|
||||
apiPath() {
|
||||
return this.item.legacyApi ? LEGACY_API : V3_API;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchConfig: function () {
|
||||
this.fetch(`${this.apiPath}/health?apikey=${this.item.apikey}`)
|
||||
.then((health) => {
|
||||
this.warnings = 0;
|
||||
this.errors = 0;
|
||||
for (var i = 0; i < health.length; i++) {
|
||||
if (health[i].type == "warning") {
|
||||
this.warnings++;
|
||||
} else if (health[i].type == "error") {
|
||||
this.errors++;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
this.serverError = true;
|
||||
});
|
||||
this.fetch(`${this.apiPath}/queue?apikey=${this.item.apikey}`)
|
||||
.then((queue) => {
|
||||
this.activity = 0;
|
||||
|
||||
if (this.item.legacyApi) {
|
||||
for (var i = 0; i < queue.length; i++) {
|
||||
if (queue[i].movie) {
|
||||
this.activity++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.activity = queue.totalRecords;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
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;
|
||||
&.activity {
|
||||
background-color: #4fb5d6;
|
||||
}
|
||||
|
||||
&.warnings {
|
||||
background-color: #d08d2e;
|
||||
}
|
||||
|
||||
&.errors {
|
||||
background-color: #e51111;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
127
src/components/services/Sonarr.vue
Normal file
127
src/components/services/Sonarr.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Generic :item="item">
|
||||
<template #indicator>
|
||||
<div class="notifs">
|
||||
<strong v-if="activity > 0" class="notif activity" title="Activity">
|
||||
{{ activity }}
|
||||
</strong>
|
||||
<strong v-if="warnings > 0" class="notif warnings" title="Warning">
|
||||
{{ warnings }}
|
||||
</strong>
|
||||
<strong v-if="errors > 0" class="notif errors" title="Error">
|
||||
{{ errors }}
|
||||
</strong>
|
||||
<strong
|
||||
v-if="serverError"
|
||||
class="notif errors"
|
||||
title="Connection error to Sonarr API, check url and apikey in config.yml"
|
||||
>
|
||||
?
|
||||
</strong>
|
||||
</div>
|
||||
</template>
|
||||
</Generic>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import service from "@/mixins/service.js";
|
||||
import Generic from "./Generic.vue";
|
||||
|
||||
const V3_API = "/api/v3";
|
||||
const LEGACY_API = "/api";
|
||||
|
||||
export default {
|
||||
name: "Sonarr",
|
||||
mixins: [service],
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
components: {
|
||||
Generic,
|
||||
},
|
||||
computed: {
|
||||
apiPath() {
|
||||
return this.item.legacyApi ? LEGACY_API : V3_API;
|
||||
},
|
||||
},
|
||||
data: () => {
|
||||
return {
|
||||
activity: null,
|
||||
warnings: null,
|
||||
errors: null,
|
||||
serverError: false,
|
||||
};
|
||||
},
|
||||
created: function () {
|
||||
this.fetchConfig();
|
||||
},
|
||||
methods: {
|
||||
fetchConfig: function () {
|
||||
this.fetch(`${this.apiPath}/health?apikey=${this.item.apikey}`)
|
||||
.then((health) => {
|
||||
this.warnings = 0;
|
||||
this.errors = 0;
|
||||
for (var i = 0; i < health.length; i++) {
|
||||
if (health[i].type == "warning") {
|
||||
this.warnings++;
|
||||
} else if (health[i].type == "error") {
|
||||
this.errors++;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
this.serverError = true;
|
||||
});
|
||||
this.fetch(`${this.apiPath}/queue?apikey=${this.item.apikey}`)
|
||||
.then((queue) => {
|
||||
this.activity = 0;
|
||||
if (this.item.legacyApi) {
|
||||
for (var i = 0; i < queue.length; i++) {
|
||||
if (queue[i].series) {
|
||||
this.activity++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.activity = queue.totalRecords;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
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;
|
||||
|
||||
&.activity {
|
||||
background-color: #4fb5d6;
|
||||
}
|
||||
|
||||
&.warnings {
|
||||
background-color: #d08d2e;
|
||||
}
|
||||
|
||||
&.errors {
|
||||
background-color: #e51111;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
62
src/mixins/service.js
Normal file
62
src/mixins/service.js
Normal file
@ -0,0 +1,62 @@
|
||||
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,
|
||||
// but sometimes the base url is different. An optional alternative URL can be provided with the "endpoint" key.
|
||||
this.endpoint = this.item.endpoint || this.item.url;
|
||||
|
||||
if (this.endpoint.endsWith("/")) {
|
||||
this.endpoint = this.endpoint.slice(0, -1);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetch: function (path, init, json = true) {
|
||||
let options = {};
|
||||
|
||||
if (this.proxy?.useCredentials) {
|
||||
options.credentials = "include";
|
||||
}
|
||||
|
||||
// Each item can override the credential settings
|
||||
if (this.item.useCredentials !== undefined) {
|
||||
options.credentials =
|
||||
this.item.useCredentials === true ? "include" : "omit";
|
||||
}
|
||||
|
||||
if (this.forwarder?.apikey) {
|
||||
options.headers = {
|
||||
"X-Homer-Forwarder-Api-Key": this.forwarder.apikey,
|
||||
};
|
||||
}
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
path = path.slice(1);
|
||||
}
|
||||
|
||||
let url = path ? `${this.endpoint}/${path}` : this.endpoint;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
return json ? response.json() : response;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
@ -12,6 +12,7 @@ module.exports = {
|
||||
publicPath: "",
|
||||
pwa: {
|
||||
manifestPath: "assets/manifest.json",
|
||||
manifestCrossorigin: "use-credentials",
|
||||
appleMobileWebAppStatusBarStyle: "black",
|
||||
appleMobileWebAppCapable: "yes",
|
||||
name: manifestOptions.name,
|
||||
|
Reference in New Issue
Block a user