Merge branch 'main' into main

pull/181/head
Bastien Wirtz 2021-09-13 13:09:40 -07:00 committed by GitHub
commit 92d5b8d424
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 2606 additions and 1723 deletions

View File

@ -43,8 +43,8 @@
- [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)
@ -73,7 +73,11 @@ See [documentation](docs/configuration.md) for information about the configurati
To launch container:
```sh
docker run -p 8080:8080 -v /your/local/assets/:/www/assets b4bz/homer:latest
docker run -d \
-p 8080:8080 \
-v </your/local/assets/>:/www/assets \
--restart=always \
b4bz/homer:latest
```
Default assets will be automatically installed in the `/www/assets` directory. Use `UID` and/or `GID` env var to change the assets owner (`docker run -e "UID=1000" -e "GID=1000" [...]`).
@ -130,9 +134,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)

View File

@ -66,6 +66,17 @@ colors:
# Optional message
message:
# url: "https://<my-api-endpoint>" # Can fetch information from an endpoint to override value below.
# mapping: # allows to map fields from the remote format to the one expected by Homer
# title: 'id' # use value from field 'id' as title
# content: 'value' # value from field 'value' as content
# refreshInterval: 10000 # Optional: time interval to refresh message
#
# Real example using chucknorris.io for showing Chuck Norris facts as messages:
# url: https://api.chucknorris.io/jokes/random
# mapping:
# title: 'id'
# content: 'value'
# refreshInterval: 10000
style: "is-warning"
title: "Optional message!"
icon: "fa fa-exclamation-triangle"
@ -81,6 +92,11 @@ links:
- name: "link 2"
icon: "fas fa-book"
url: "https://github.com/bastienwirtz/homer"
# this will link to a second homer page that will load config from page2.yml and keep default config values as in config.yml file
# see url field and assets/page.yml used in this example:
- name: "Second Page"
icon: "fas fa-file-alt"
url: "#page2"
# Services
# First level array represents a group.
@ -88,6 +104,8 @@ links:
services:
- name: "Application"
icon: "fas fa-code-branch"
# A path to an image can also be provided. Note that icon take precedence if both icon and logo are set.
# logo: "path/to/logo"
items:
- name: "Awesome app"
logo: "assets/tools/sample.png"
@ -118,9 +136,10 @@ 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:
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
{

View File

@ -1,6 +1,8 @@
# Custom Services
Here is an overview of all custom services that are available within Homer.
Some service can use a specific a component that provides some extra features by adding a `type` key to the service yaml
configuration. Available services are in `src/components/`. Here is an overview of all custom services that are available
within Homer.
## PiHole
@ -17,6 +19,7 @@ The following configuration is available for the PiHole service.
type: "PiHole"
```
## OpenWeatherMap
Using the OpenWeatherMap service you can display weather information about a given location.
@ -36,3 +39,37 @@ items:
**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 :
```
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.
## Sonarr/Radarr
This service displays Activity (blue), Warning (orange) or Error (red) notifications bubbles from the Radarr/Sonarr application.
Two lines are needed in the config.yml :
```
type: "Radarr" or "Sonarr"
apikey: "01234deb70424befb1f4ef6a23456789"
```
The url must be the root url of Radarr/Sonarr application.
The Radarr/Sonarr API key can be found in Settings > General. It is needed to access the API.
## PaperlessNG
For Paperless you need an API-Key which you have to store at the item in the field `apikey`.
## Ping
For Ping you need an API-Key which you have to store at the item in the field `apikey`.

View File

@ -113,6 +113,64 @@ docker create \
## 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:
```json
{
"categories": [],
"created_at": "2020-01-05 13:42:22.089095",
"icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png",
"id": "MR2-BnMBR667xSpQBIleUg",
"updated_at": "2020-01-05 13:42:22.089095",
"url": "https://api.chucknorris.io/jokes/MR2-BnMBR667xSpQBIleUg",
"value": "Chuck Norris can quitely sneak up on himself"
}
```
but... you need that info to be transformed to something like this:
```json
{
"title": "MR2-BnMBR667xSpQBIleUg",
"content": "Chuck Norris can quitely sneak up on himself"
}
```
Now, you can do that using the `mapping` field in your `message` configuration. This example would be something like this:
```yml
message:
url: https://api.chucknorris.io/jokes/random
mapping:
title: 'id'
content: 'value'
```
As you would see, using the ID as a title doesn't seem nice, that's why when a field is empty it would keep the default values, like this:
```yml
message:
url: https://api.chucknorris.io/jokes/random
mapping:
content: 'value'
title: "Chuck Norris Facts!"
```
and even an error message in case the `url` didn't respond or threw an error:
```yml
message:
url: https://api.chucknorris.io/jokes/random
mapping:
content: 'value'
title: "Chuck Norris Facts!"
content: "Message could not be loaded"
```
#### `by @JamiePhonic`
Homer allows you to set a "message" that will appear at the top of the page, however, you can also supply a `url:`.

View File

@ -1,6 +1,6 @@
{
"name": "homer",
"version": "20.06.1",
"version": "21.09.1",
"license": "Apache-2.0",
"scripts": {
"serve": "vue-cli-service serve",
@ -8,28 +8,28 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.1",
"bulma": "^0.9.1",
"core-js": "^3.8.1",
"js-yaml": "^3.14.1",
"@fortawesome/fontawesome-free": "^5.15.4",
"bulma": "^0.9.3",
"core-js": "^3.17.3",
"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": {
"@vue/cli-plugin-babel": "~4.5.9",
"@vue/cli-plugin-eslint": "~4.5.9",
"@vue/cli-plugin-pwa": "~4.5.9",
"@vue/cli-service": "~4.5.9",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-pwa": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@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"
}
}

View File

@ -0,0 +1,35 @@
---
# Additionnal page configuration
# Additionnal 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
subtitle: "this is another dashboard page"
# This overwrites message config. Setting it to empty to remove message from this page and keep it only in the main one:
message: ~
# as we want to include a differente link here (so we can get back to home page), we need to replicate all links or they will be revome when overwriting the links field:
links:
- name: "Home"
icon: "fas fa-home"
url: "#"
- name: "Contribute"
icon: "fab fa-github"
url: "https://github.com/bastienwirtz/homer"
target: "_blank" # optional html a tag target attribute
- name: "Wiki"
icon: "fas fa-book"
url: "https://www.wikipedia.org/"
services:
- name: "More applications on another page!"
icon: "fas fa-cloud"
items:
- name: "Awesome app on a second page!"
logo: "assets/tools/sample.png"
subtitle: "Bookmark example"
tag: "app"
url: "https://www.reddit.com/r/selfhosted/"
target: "_blank"

View File

@ -56,6 +56,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:
- name: "another page!"
icon: "fas fa-file-alt"
url: "#additionnal-page"
# Services
# First level array represent a group.

View File

@ -13,7 +13,9 @@
<section v-if="config.header" class="first-line">
<div v-cloak class="container">
<div class="logo">
<img v-if="config.logo" :src="config.logo" alt="dashboard logo" />
<a href="#">
<img v-if="config.logo" :src="config.logo" alt="dashboard logo" />
</a>
<i v-if="config.icon" :class="config.icon"></i>
</div>
<div class="dashboard-title">
@ -62,6 +64,11 @@
<template v-for="group in services">
<h2 v-if="group.name" class="column is-full group-title">
<i v-if="group.icon" :class="['fa-fw', group.icon]"></i>
<div v-else-if="group.logo" class="group-logo media-left">
<figure class="image is-48x48">
<img :src="group.logo" :alt="`${group.name} logo`" />
</figure>
</div>
{{ group.name }}
</h2>
<Service
@ -85,6 +92,11 @@
>
<h2 v-if="group.name" class="group-title">
<i v-if="group.icon" :class="['fa-fw', group.icon]"></i>
<div v-else-if="group.logo" class="group-logo media-left">
<figure class="image is-48x48">
<img :src="group.logo" :alt="`${group.name} logo`" />
</figure>
</div>
{{ group.name }}
</h2>
<Service
@ -149,28 +161,41 @@ export default {
};
},
created: async function () {
const defaults = jsyaml.load(defaultConfig);
let config;
try {
config = await this.getConfig();
} catch (error) {
console.log(error);
config = this.handleErrors("⚠️ Error loading configuration", error);
}
this.config = merge(defaults, config);
this.services = this.config.services;
document.title =
this.config.documentTitle ||
`${this.config.title} | ${this.config.subtitle}`;
if (this.config.stylesheet) {
let stylesheet = "";
for (const file of this.config.stylesheet) {
stylesheet += `@import "${file}";`;
}
this.createStylesheet(stylesheet);
}
this.buildDashboard();
window.onhashchange = this.buildDashboard;
},
methods: {
buildDashboard: async function () {
const defaults = jsyaml.load(defaultConfig);
let config;
try {
config = await this.getConfig();
const path =
window.location.hash.substring(1) != ""
? window.location.hash.substring(1)
: null;
if (path) {
let pathConfig = await this.getConfig(`assets/${path}.yml`); // the slash (/) is included in the pathname
config = Object.assign(config, pathConfig);
}
} catch (error) {
console.log(error);
config = this.handleErrors("⚠️ Error loading configuration", error);
}
this.config = merge(defaults, config);
this.services = this.config.services;
document.title =
this.config.documentTitle ||
`${this.config.title} | ${this.config.subtitle}`;
if (this.config.stylesheet) {
let stylesheet = "";
for (const file of this.config.stylesheet) {
stylesheet += `@import "${file}";`;
}
this.createStylesheet(stylesheet);
}
},
getConfig: function (path = "assets/config.yml") {
return fetch(path).then((response) => {
if (response.redirected) {

View File

@ -106,7 +106,7 @@ body {
}
.first-line {
height: 100px;
min-height: 100px;
vertical-align: center;
background-color: var(--highlight-primary);
@ -121,7 +121,7 @@ body {
}
.container {
height: 80px;
min-height: 80px;
padding: 10px 0;
}
@ -140,8 +140,7 @@ body {
}
}
}
.navbar,
.navbar-menu {
.navbar {
background-color: var(--highlight-secondary);
a {
@ -153,6 +152,9 @@ body {
background-color: var(--highlight-hover);
}
}
.navbar-menu {
background-color: inherit;
}
}
.navbar-end {
text-align: right;
@ -197,6 +199,11 @@ body {
}
}
.media.no-subtitle {
display: flex;
align-items: center;
}
.media-content {
overflow: hidden;
text-overflow: inherit;
@ -206,7 +213,7 @@ body {
color: var(--highlight-secondary);
background-color: var(--highlight-secondary);
position: absolute;
top: 1rem;
bottom: 1rem;
right: -0.2rem;
width: 3px;
overflow: hidden;
@ -219,7 +226,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;
@ -255,11 +261,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;
}
}
@ -340,3 +348,7 @@ body {
}
}
}
.group-logo {
float: left;
}

View File

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

View File

@ -4,7 +4,11 @@
aria-label="Toggle dark mode"
class="navbar-item is-inline-block-mobile"
>
<i class="fas fa-fw fa-adjust"></i>
<i
:class="`${faClasses[mode]}`"
class="fa-fw"
:title="`${titles[mode]}`"
></i>
</a>
</template>
@ -14,21 +18,55 @@ export default {
data: function () {
return {
isDark: null,
faClasses: null,
titles: null,
mode: null,
};
},
created: function () {
this.isDark =
"overrideDark" in localStorage
? JSON.parse(localStorage.overrideDark)
: matchMedia("(prefers-color-scheme: dark)").matches;
this.faClasses = ["fas fa-adjust", "fas fa-circle", "far fa-circle"];
this.titles = ["Auto-switch", "Light theme", "Dark theme"];
this.mode = 0;
if ("overrideDark" in localStorage) {
// Light theme is 1 and Dark theme is 2
this.mode = JSON.parse(localStorage.overrideDark) ? 2 : 1;
}
this.isDark = this.getIsDark();
this.$emit("updated", this.isDark);
},
methods: {
toggleTheme: function () {
this.isDark = !this.isDark;
localStorage.overrideDark = this.isDark;
this.mode = (this.mode + 1) % 3;
switch (this.mode) {
// Default behavior
case 0:
localStorage.removeItem("overrideDark");
break;
// Force light theme
case 1:
localStorage.overrideDark = false;
break;
// Force dark theme
case 2:
localStorage.overrideDark = true;
break;
default:
// Should be unreachable
break;
}
this.isDark = this.getIsDark();
this.$emit("updated", this.isDark);
},
getIsDark: function () {
const values = [
matchMedia("(prefers-color-scheme: dark)").matches,
false,
true,
];
return values[this.mode];
},
},
};
</script>

View File

@ -22,26 +22,52 @@ export default {
},
data: function () {
return {
show: false,
message: {},
};
},
created: async function () {
// Look for a new message if an endpoint is provided.
this.message = Object.assign({}, this.item);
if (this.item && this.item.url) {
const fetchedMessage = await this.getMessage(this.item.url);
// keep the original config value if no value is provided by the endpoint
for (const prop of ["title", "style", "content"]) {
if (prop in fetchedMessage && fetchedMessage[prop] !== null) {
this.message[prop] = fetchedMessage[prop];
}
}
}
this.show = this.message.title || this.message.content;
await this.getMessage();
},
computed: {
show: function () {
return this.message.title || this.message.content;
},
},
watch: {
item: function (item) {
this.message = Object.assign({}, item);
},
},
methods: {
getMessage: function (url) {
getMessage: async function () {
if (!this.item) {
return;
}
if (this.item.url) {
let fetchedMessage = await this.downloadMessage(this.item.url);
console.log("done");
if (this.item.mapping) {
fetchedMessage = this.mapRemoteMessage(fetchedMessage);
}
// keep the original config value if no value is provided by the endpoint
const message = this.message;
for (const prop of ["title", "style", "content", "icon"]) {
if (prop in fetchedMessage && fetchedMessage[prop] !== null) {
message[prop] = fetchedMessage[prop];
}
}
this.message = { ...message }; // Force computed property to re-evaluate
}
if (this.item.refreshInterval) {
setTimeout(this.getMessage, this.item.refreshInterval);
}
},
downloadMessage: function (url) {
return fetch(url).then(function (response) {
if (response.status != 200) {
return;
@ -49,6 +75,15 @@ export default {
return response.json();
});
},
mapRemoteMessage: function (message) {
let mapped = {};
// map property from message into mapped according to mapping config (only if field has a value):
for (const prop in this.item.mapping)
if (message[this.item.mapping[prop]])
mapped[prop] = message[this.item.mapping[prop]];
return mapped;
},
},
};
</script>

View File

@ -51,9 +51,9 @@ export default {
},
methods: {
fetchStatus: async function () {
this.status = await fetch(
`${this.item.url}/control/status`
).then((response) => response.json());
this.status = await fetch(`${this.item.url}/control/status`, {
credentials: "include",
}).then((response) => response.json());
},
},
};

View File

@ -1,16 +1,3 @@
<script>
export default {};
</script>
<style></style>
*/
<script>
export default {};
</script>
<style></style>
<template>
<div>
<div
@ -20,7 +7,7 @@ export default {};
>
<a :href="item.url" :target="item.target" rel="noreferrer">
<div class="card-content">
<div class="media">
<div :class="mediaClass">
<div v-if="item.logo" class="media-left">
<figure class="image is-48x48">
<img :src="item.logo" :alt="`${item.name} logo`" />
@ -33,7 +20,9 @@ export default {};
</div>
<div class="media-content">
<p class="title is-4">{{ item.name }}</p>
<p class="subtitle is-6">{{ item.subtitle }}</p>
<p class="subtitle is-6" v-if="item.subtitle">
{{ item.subtitle }}
</p>
</div>
</div>
<div class="tag" :class="item.tagstyle" v-if="item.tag">
@ -51,11 +40,23 @@ export default {
props: {
item: Object,
},
computed: {
mediaClass: function () {
return { media: true, "no-subtitle": !this.item.subtitle };
},
},
};
</script>
<style scoped lang="scss">
.media-left img {
max-height: 100%;
.media-left {
.image {
display: flex;
align-items: center;
}
img {
max-height: 100%;
}
}
</style>

View File

@ -0,0 +1,128 @@
<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 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>
</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: "Medusa",
props: {
item: Object,
},
data: () => {
return {
config: null,
serverError: false,
};
},
created: function () {
this.fetchConfig();
},
methods: {
fetchConfig: function () {
fetch(`${this.item.url}/api/v2/config`, {
credentials: "include",
headers: { "X-Api-Key": `${this.item.apikey}` },
})
.then((response) => {
if (response.status != 200) {
throw new Error(response.statusText);
}
return response.json();
})
.then((conf) => {
this.config = conf;
})
.catch((e) => {
console.log(e);
this.serverError = true;
});
},
},
};
</script>
<style scoped lang="scss">
.media-left img {
max-height: 100%;
}
.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>

View File

@ -0,0 +1,84 @@
<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">
happily storing {{ api.count }} documents
</template>
</p>
</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: "Paperless",
props: {
item: Object,
},
data: () => ({
api: null,
}),
created() {
this.fetchStatus();
},
methods: {
fetchStatus: async function () {
if (this.item.subtitle != null) return; // omitting unnecessary ajax call as the subtitle is showing
var apikey = this.item.apikey;
if (!apikey) {
console.error(
"apikey is not present in config.yml for the paperless entry!"
);
return;
}
const url = `${this.item.url}/api/documents/`;
this.api = await fetch(url, {
credentials: "include",
headers: {
Authorization: "Token " + this.item.apikey,
},
})
.then(function (response) {
if (!response.ok) {
throw new Error("Not 2xx response");
} else {
return response.json();
}
})
.catch((e) => console.log(e));
},
},
};
</script>
<style scoped lang="scss">
.media-left img {
max-height: 100%;
}
</style>

View File

@ -64,7 +64,9 @@ export default {
methods: {
fetchStatus: async function () {
const url = `${this.item.url}/api.php`;
this.api = await fetch(url)
this.api = await fetch(url, {
credentials: "include",
})
.then((response) => response.json())
.catch((e) => console.log(e));
},
@ -83,13 +85,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 {

View File

@ -0,0 +1,102 @@
<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>
</p>
</div>
<div v-if="status" class="status" :class="status">
{{ 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>
</template>
<script>
export default {
name: "Ping",
props: {
item: Object,
},
data: () => ({
status: null,
}),
created() {
this.fetchStatus();
},
methods: {
fetchStatus: async function () {
const url = `${this.item.url}`;
fetch(url, {
method: "HEAD",
cache: "no-cache",
credentials: "include",
})
.then((response) => {
if (!response.ok) {
throw Error(response.statusText);
}
this.status = "online";
})
.catch(() => {
this.status = "offline";
});
},
},
};
</script>
<style scoped lang="scss">
.media-left img {
max-height: 100%;
}
.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>

View File

@ -0,0 +1,151 @@
<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 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>
</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: "Radarr",
props: {
item: Object,
},
data: () => {
return {
activity: null,
warnings: null,
errors: null,
serverError: false,
};
},
created: function () {
this.fetchConfig();
},
methods: {
fetchConfig: function () {
fetch(`${this.item.url}/api/health?apikey=${this.item.apikey}`)
.then((response) => {
if (response.status != 200) {
throw new Error(response.statusText);
}
return response.json();
})
.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;
});
fetch(`${this.item.url}/api/queue?apikey=${this.item.apikey}`)
.then((response) => {
if (response.status != 200) {
throw new Error(response.statusText);
}
return response.json();
})
.then((queue) => {
this.activity = 0;
for (var i = 0; i < queue.length; i++) {
if (queue[i].movie) {
this.activity++;
}
}
})
.catch((e) => {
console.error(e);
this.serverError = true;
});
},
},
};
</script>
<style scoped lang="scss">
.media-left img {
max-height: 100%;
}
.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;
}
.activity {
background-color: #4fb5d6;
}
.warnings {
background-color: #d08d2e;
}
.errors {
background-color: #e51111;
}
</style>

View File

@ -0,0 +1,151 @@
<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 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>
</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: "Sonarr",
props: {
item: Object,
},
data: () => {
return {
activity: null,
warnings: null,
errors: null,
serverError: false,
};
},
created: function () {
this.fetchConfig();
},
methods: {
fetchConfig: function () {
fetch(`${this.item.url}/api/health?apikey=${this.item.apikey}`)
.then((response) => {
if (response.status != 200) {
throw new Error(response.statusText);
}
return response.json();
})
.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;
});
fetch(`${this.item.url}/api/queue?apikey=${this.item.apikey}`)
.then((response) => {
if (response.status != 200) {
throw new Error(response.statusText);
}
return response.json();
})
.then((queue) => {
this.activity = 0;
for (var i = 0; i < queue.length; i++) {
if (queue[i].series) {
this.activity++;
}
}
})
.catch((e) => {
console.error(e);
this.serverError = true;
});
},
},
};
</script>
<style scoped lang="scss">
.media-left img {
max-height: 100%;
}
.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;
}
.activity {
background-color: #4fb5d6;
}
.warnings {
background-color: #d08d2e;
}
.errors {
background-color: #e51111;
}
</style>

View File

@ -12,6 +12,7 @@ module.exports = {
publicPath: "",
pwa: {
manifestPath: "assets/manifest.json",
manifestCrossorigin: "use-credentials",
appleMobileWebAppStatusBarStyle: "black",
appleMobileWebAppCapable: "yes",
name: manifestOptions.name,

3249
yarn.lock

File diff suppressed because it is too large Load Diff