Compare commits
387 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 | |||
b6c667a129 | |||
edc336bba6 | |||
c0044cc765 | |||
4a1e8717e9 | |||
66eace9e95 | |||
ba07da6b10 | |||
00b46a6dde | |||
8e09331379 | |||
42477020cf | |||
cc7ff88552 | |||
c6267296ec | |||
9542de6eb2 | |||
7bcfce6bda | |||
2c0cb7ad55 | |||
9ba0d54e22 | |||
a5fe53beb2 | |||
97f0c43ccc | |||
551e32e203 | |||
fb158d4767 | |||
9e0ef05efe | |||
dfb0b14626 | |||
fd12de9ebd | |||
b79561bc9c | |||
593f8afc90 | |||
b6782c92b5 | |||
1ddf394176 | |||
e3bd2ecc2c | |||
6d29bc27e7 | |||
3786f80dae | |||
6f2b141bf7 | |||
c1b5f6adab | |||
168810d76f | |||
59db9d2e12 | |||
31bd77c81d | |||
16a86df3e4 | |||
4ce53b68ea | |||
f81dc6f488 | |||
273a268ac4 | |||
c5eab80d76 | |||
71a7d3cce4 | |||
12063dafef | |||
2bd57d17fd | |||
4c1f6c4329 | |||
6fe21127f5 | |||
a2481f7317 | |||
e1ecf86ffd | |||
1c451e119e | |||
1b41b46193 | |||
a2e28229a9 | |||
33750a4cbc | |||
86f4680a5b | |||
ade77e4257 | |||
f8d0761d0c | |||
c701075dfa | |||
be30825917 | |||
23eb5b3fb7 | |||
ff0b5150f1 | |||
5a85322c92 | |||
1f12c43da8 | |||
2662b17049 | |||
d1b29caaa6 | |||
e75945851c | |||
13071ae3d1 | |||
5b727eee02 | |||
37dfd2a132 | |||
9a14de007e | |||
ed8b17e0df | |||
c368290e32 | |||
00b069fc6f | |||
40d3e8de76 | |||
1017cc9864 | |||
b04e718367 | |||
ab8136bab7 | |||
488e4bbe7f | |||
62ec6fa099 | |||
67fd101a38 | |||
0e045b4c55 | |||
e608701404 | |||
2b5f88db18 | |||
b8c81389dc | |||
5b05842512 | |||
80f6a13140 | |||
f2eacfcba1 | |||
a02961b70b | |||
9d0ec9e348 | |||
ffe3404a2a | |||
bcf0e1bec2 | |||
f70fc3ecae | |||
e9afa4d7dd | |||
fbe9338fd3 | |||
83665e4f48 | |||
8e5ee54a78 | |||
71cf63eb3b | |||
6777bc347b | |||
2644101276 | |||
607fb898f8 | |||
2e7eb41f8c | |||
db738288fa | |||
7e5ad02248 | |||
118d3e5ac0 | |||
687a9e4086 | |||
4f04feb2da | |||
68c36d6c54 | |||
e4537f134b | |||
239ef1688d | |||
154e6efe80 | |||
d05b8d3bf0 | |||
0ae40f78f8 | |||
6de53c49b3 | |||
ab40c4e007 | |||
1f92e1746d | |||
8ae1fe8a4e | |||
fd9237eb52 | |||
4bfcc5bc95 | |||
d3da4cfe93 | |||
d1cc18761f | |||
a503c5743e | |||
a9aed9f9e2 | |||
7ef65940ee | |||
d1b9dea287 | |||
ae73d7a5a0 | |||
25b6367aa1 | |||
b102c9b2b3 | |||
da6e676d6e | |||
796a16c8da | |||
8d7ca88c46 | |||
b242511101 | |||
638dee7704 | |||
fea05ea099 | |||
29d6b359ab | |||
5f71d1ea01 | |||
af663d3376 | |||
aac817970c | |||
b3635ea460 | |||
481ab9a01b | |||
1bc75494ca | |||
cad54a560f | |||
e6596ca6ee | |||
d10b219db5 | |||
ac1442c640 | |||
a23914d573 | |||
9e4fe0d227 | |||
e9113b48ce | |||
1a42e30a17 | |||
52ed5af607 | |||
cd688ef12c | |||
344c367ccd | |||
9d25f392ec | |||
df66067c5e | |||
6c305c61ee | |||
897f67f0b6 | |||
97fa090e21 | |||
b0a0fdaa3b | |||
bd9109425a | |||
10ea23a01d | |||
e1470a8c42 | |||
51e02bed72 | |||
7012102454 | |||
de0a746a27 | |||
a747c58498 | |||
c712d7c780 | |||
5fa6b6cfa6 | |||
9052ec59b7 | |||
2301d8919c | |||
1875c17aa5 | |||
3bf0edcf13 | |||
9814a037a5 | |||
e11427508a | |||
b9c5fcf085 | |||
ab7ac44c19 | |||
554fc76d75 | |||
5530707ba2 | |||
0d6a177bae | |||
2b47d91464 | |||
514b68eae3 | |||
cf2df79869 | |||
94f44a4fe8 | |||
5a5412c5d1 | |||
525120de61 | |||
c92844bd0e | |||
e6701f57cc | |||
694274f005 | |||
55e0df5386 | |||
7bb903e693 | |||
abe6df52b5 | |||
fcf5f412a5 | |||
b317857892 | |||
aabb7d538e | |||
c424ddba72 | |||
45d38d1226 | |||
5861f0f899 | |||
d2bf5e5f62 | |||
a4de4a3a71 | |||
0503e77861 | |||
4aeaeb09ad | |||
990b6c3d46 | |||
facaa346b7 | |||
6eba37a370 |
3
.browserslistrc
Normal file
@ -0,0 +1,3 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
assets/*
|
||||
dockerfile
|
||||
*.md
|
||||
.git
|
||||
screenshot.png
|
||||
node_modules
|
15
.eslintrc.js
Normal file
@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: ["plugin:vue/essential", "eslint:recommended", "@vue/prettier"],
|
||||
parserOptions: {
|
||||
parser: "babel-eslint",
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"vue/require-v-for-key": "off",
|
||||
},
|
||||
};
|
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -6,15 +6,13 @@ Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] I read & comply with the [contributing guidelines](https://github.com/bastienwirtz/homer/blob/master/.github/CONTRIBUTING.md)
|
||||
- [ ] 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've check my modifications for any breaking change, especially in the `config.yml` file
|
||||
- [ ] 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
@ -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
|
||||
|
40
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
# Publish pre-build release
|
||||
name: Upload Release Asset
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Upload Release Asset
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build project
|
||||
run: |
|
||||
yarn install
|
||||
yarn build
|
||||
- name: Create artifact
|
||||
working-directory: "dist"
|
||||
run: zip -r ../homer.zip ./*
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ github.ref }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
- name: Upload Release Asset
|
||||
id: upload-release-asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./homer.zip
|
||||
asset_name: homer.zip
|
||||
asset_content_type: application/zip
|
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# App configuration
|
||||
config.yml
|
||||
|
||||
.drone.yml
|
@ -6,27 +6,23 @@ First off, thank you for considering contributing to Homer!
|
||||
|
||||
### Project philosophy
|
||||
|
||||
Homer is meant to be a light and very simple dashboard that keeps all your usefull utilities at hands. The few features implemented in Homer focus on
|
||||
Homer is meant to be a light and very simple dashboard that keeps all your useful utilities at hands. The few features implemented in Homer focus on
|
||||
UX and usability. If you are looking for a full featured dashboard, there is tons of great stuff out there like https://heimdall.site/, https://github.com/rmountjoy92/DashMachine or https://organizr.app/.
|
||||
|
||||
- Configuration is stored in a simple config file, avoiding the need for a backend/database while making possible to use versionning or [config template](https://docs.ansible.com/ansible/latest/user_guide/playbooks_templating.html).
|
||||
- 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
|
||||
|
||||
### Code of conduct and guidelines
|
||||
|
||||
First of all, we expect everyone (contributors and maintainers alike) to respect the [Code of conduct](https://github.com/bastienwirtz/homer/blob/master/CODE_OF_CONDUCT.md). It is not a recomandation, it is mandatory.
|
||||
First of all, we expect everyone (contributors and maintainers alike) to respect the [Code of conduct](https://github.com/bastienwirtz/homer/blob/main/CODE_OF_CONDUCT.md). It is not a recommendation, it is mandatory.
|
||||
|
||||
For all contributions, please respect the following guidelines:
|
||||
|
||||
* Each pull request should implement ONE feature or bugfix. If you want to add or fix more than one thing, submit more than one pull request.
|
||||
* Do not commit changes to files that are irrelevant to your feature or bugfix (eg: `.gitignore`).
|
||||
* Do not commit changes to files that are irrelevant to your feature or bugfix (e.g. `.gitignore`).
|
||||
* Do not add unnecessary dependencies.
|
||||
* Be aware that the pull request review process is not immediate, and is generally proportional to the size of the pull request.
|
||||
|
||||
@ -34,14 +30,15 @@ For all contributions, please respect the following guidelines:
|
||||
|
||||
### Discuss about ideas
|
||||
|
||||
If you want to add a feature, it's often best to talk about it before starting working on it and submitting a pull request. It's not mandatory at all, but
|
||||
If you want to add a feature, it's often best to talk about it before starting to work on it and submitting a pull request. It's not mandatory at all, but
|
||||
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/master/.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:
|
||||
|
28
Dockerfile
@ -1,15 +1,33 @@
|
||||
FROM alpine:3.11
|
||||
# build stage
|
||||
FROM node:lts-alpine as build-stage
|
||||
|
||||
COPY ./ /www/
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN yarn build
|
||||
|
||||
# production stage
|
||||
FROM alpine:3.15
|
||||
|
||||
ENV USER darkhttpd
|
||||
ENV GROUP darkhttpd
|
||||
ENV GID 911
|
||||
ENV UID 911
|
||||
ENV PORT 8080
|
||||
|
||||
RUN addgroup -S ${GROUP} -g ${GID} && adduser -D -S -u ${UID} ${USER} ${GROUP} && \
|
||||
apk add -U darkhttpd
|
||||
apk add -U --no-cache su-exec darkhttpd
|
||||
|
||||
USER darkhttpd
|
||||
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
|
||||
|
||||
ENTRYPOINT ["darkhttpd","/www/"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:${PORT}/ || exit 1
|
||||
|
||||
EXPOSE ${PORT}
|
||||
VOLUME /www/assets
|
||||
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
|
||||
|
43
Dockerfile.arm32v7
Normal file
@ -0,0 +1,43 @@
|
||||
# build stage
|
||||
FROM node:lts-alpine as build-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN yarn build
|
||||
|
||||
# Multi arch build support
|
||||
FROM alpine as qemu
|
||||
|
||||
ARG QEMU_VERSION="v4.2.0-7"
|
||||
|
||||
RUN wget https://github.com/multiarch/qemu-user-static/releases/download/${QEMU_VERSION}/qemu-arm-static && chmod +x qemu-arm-static
|
||||
|
||||
# production stage
|
||||
FROM arm32v7/alpine:3.11
|
||||
|
||||
COPY --from=qemu qemu-arm-static /usr/bin/
|
||||
|
||||
ENV USER darkhttpd
|
||||
ENV GROUP darkhttpd
|
||||
ENV GID 911
|
||||
ENV UID 911
|
||||
ENV PORT 8080
|
||||
|
||||
RUN addgroup -S ${GROUP} -g ${GID} && adduser -D -S -u ${UID} ${USER} ${GROUP} && \
|
||||
apk add -U --no-cache darkhttpd su-exec && \
|
||||
rm /usr/bin/qemu-arm-static
|
||||
|
||||
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist /www/
|
||||
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist/assets /www/default-assets
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:${PORT}/ || exit 1
|
||||
|
||||
EXPOSE ${PORT}
|
||||
VOLUME /www/assets
|
||||
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
|
43
Dockerfile.arm64v8
Normal file
@ -0,0 +1,43 @@
|
||||
# build stage
|
||||
FROM node:lts-alpine as build-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN yarn build
|
||||
|
||||
# Multi arch build support
|
||||
FROM alpine as qemu
|
||||
|
||||
ARG QEMU_VERSION="v4.2.0-7"
|
||||
|
||||
RUN wget https://github.com/multiarch/qemu-user-static/releases/download/${QEMU_VERSION}/qemu-aarch64-static && chmod +x qemu-aarch64-static
|
||||
|
||||
# production stage
|
||||
FROM arm64v8/alpine:3.11
|
||||
|
||||
COPY --from=qemu qemu-aarch64-static /usr/bin/
|
||||
|
||||
ENV USER darkhttpd
|
||||
ENV GROUP darkhttpd
|
||||
ENV GID 911
|
||||
ENV UID 911
|
||||
ENV PORT 8080
|
||||
|
||||
RUN addgroup -S ${GROUP} -g ${GID} && adduser -D -S -u ${UID} ${USER} ${GROUP} && \
|
||||
apk add -U --no-cache darkhttpd su-exec && \
|
||||
rm /usr/bin/qemu-aarch64-static
|
||||
|
||||
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist /www/
|
||||
COPY --from=build-stage --chown=${USER}:${GROUP} /app/dist/assets /www/default-assets
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:${PORT}/ || exit 1
|
||||
|
||||
EXPOSE ${PORT}
|
||||
VOLUME /www/assets
|
||||
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
|
2
LICENSE
@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright 2018 Bastien Wirtz
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
217
README.md
@ -1,122 +1,137 @@
|
||||
# Homer
|
||||
A dead simple static **HOM**epage for your serv**ER** to keep your services on hand, from a simple `yaml` configuration file.
|
||||
<h1 align="center">
|
||||
<img
|
||||
width="180"
|
||||
alt="Homer's donut"
|
||||
src="https://raw.githubusercontent.com//bastienwirtz/homer/main/public/logo.png">
|
||||
<br/>
|
||||
Homer
|
||||
</h1>
|
||||
|
||||
**Check out the live demo [here](https://homer-demo.netlify.com/).**
|
||||
<h4 align="center">
|
||||
A dead simple static <strong>HOM</strong>epage for your serv<strong>ER</strong> to keep your services on hand, from a simple <code>yaml</code> configuration file.
|
||||
</h4>
|
||||
|
||||
If you need authentication support, you're on your own (it can be secured using a web server auth module or exposing it only through a VPN network / SSH tunnel, ...)
|
||||
<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>
|
||||
</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://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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img src="https://raw.github.com/bastienwirtz/homer/main/docs/screenshot.png" width="100%">
|
||||
</p>
|
||||
|
||||
## Roadmap
|
||||
## Table of Contents
|
||||
|
||||
- [ ] Colors / theme customization
|
||||
- [ ] Enable PWA support (making possible to "install" - add to homescreen - it)
|
||||
- [ ] Improve maintenability (external library import & service workers cached file list.)
|
||||
- [Features](#features)
|
||||
- [Getting started](#getting-started)
|
||||
- [Configuration](docs/configuration.md)
|
||||
- [Custom services](docs/customservices.md)
|
||||
- [Tips & tricks](docs/tips-and-tricks.md)
|
||||
- [Development](docs/development.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
|
||||
## Installation
|
||||
## Features
|
||||
|
||||
- [yaml](http://yaml.org/) file configuration
|
||||
- Installable (pwa)
|
||||
- Search
|
||||
- Grouping
|
||||
- Theme customization
|
||||
- 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**.
|
||||
|
||||
See [documentation](docs/configuration.md) for information about the configuration (`assets/config.yml`) options.
|
||||
|
||||
### Using docker
|
||||
|
||||
To launch container:
|
||||
|
||||
```sh
|
||||
sudo docker run -p 8080:8080 -v /your/local/config.yml:/www/config.yml -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
|
||||
```
|
||||
|
||||
### Manually
|
||||
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" [...]`).
|
||||
|
||||
**How to build / install it?** There is no build system (😱), use it like that! It's meant to be stupid simple & zero maintenance required. Just copy the static files somewhere, and visit the `index.html`.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Title, icons, links, colors, and services can be configured in the `config.yml` file, using [yaml](http://yaml.org/) format.
|
||||
### Using docker-compose
|
||||
|
||||
The `docker-compose.yml` file must be edited to match your needs.
|
||||
Set the port and volume (equivalent to `-p` and `-v` arguments):
|
||||
|
||||
```yaml
|
||||
---
|
||||
# Homepage configuration
|
||||
# See https://fontawesome.com/icons for icons options
|
||||
|
||||
title: "Simple homepage"
|
||||
subtitle: "Homer"
|
||||
logo: "assets/homer.png"
|
||||
# Alternatively a fa icon can be provided:
|
||||
# icon: "fas fa-skull-crossbones"
|
||||
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.header:
|
||||
|
||||
# Optional message
|
||||
message:
|
||||
# url: "https://<my-api-endpoint>" # Can fetch information from an endpoint to override value below.
|
||||
style: "is-warning"
|
||||
title: "Optional message!"
|
||||
content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque risus mi, tempus quis placerat ut, porta nec nulla. Vestibulum rhoncus ac ex sit amet fringilla. Nullam gravida purus diam, et dictum felis venenatis efficitur. Aenean ac eleifend lacus, in mollis lectus. Donec sodales, arcu et sollicitudin porttitor, tortor urna tempor ligula."
|
||||
|
||||
# Optional navbar
|
||||
# links: [] # Allows for navbar (dark mode, layout, and search) without any links
|
||||
links:
|
||||
- name: "ansible"
|
||||
icon: "fa-github"
|
||||
url: "https://github.com/xxxxx/ansible/"
|
||||
target: '_blank' # optionnal html a tag target attribute
|
||||
- name: "Wiki"
|
||||
icon: "fa-book"
|
||||
url: "https://wiki.xxxxxx.com/"
|
||||
|
||||
# Services
|
||||
# First level array represent a group.
|
||||
# Leave only a "items" key if not using group (group name, icon & tagstyle are optional, section separation will not be displayed).
|
||||
services:
|
||||
- name: "DevOps"
|
||||
icon: "fa-code-fork"
|
||||
items:
|
||||
- name: "Jenkins"
|
||||
logo: "/assets/tools/jenkins.png"
|
||||
# Alternatively a fa icon can be provided:
|
||||
# icon: "fab fa-jenkins"
|
||||
subtitle: "Continuous integration server"
|
||||
tag: "CI"
|
||||
url: "#"
|
||||
target: '_blank' # optionnal html a tag target attribute
|
||||
- name: "RabbitMQ Management"
|
||||
logo: "/assets/tools/rabbitmq.png"
|
||||
subtitle: "Manage & monitor RabbitMQ server"
|
||||
tag: "haproxy"
|
||||
# Optional tagstyle
|
||||
tagstyle: "is-success"
|
||||
url: "#"
|
||||
- name: "Monitoring"
|
||||
icon: "fa-heartbeat"
|
||||
items:
|
||||
- name: "M/Monit"
|
||||
logo: "/assets/tools/monit.png"
|
||||
subtitle: "Monitor & manage all monit enabled hosts"
|
||||
tag: "monit"
|
||||
url: "#"
|
||||
- name: "Grafana"
|
||||
logo: "/assets/tools/grafana.png"
|
||||
subtitle: "Metric analytics & dashboards"
|
||||
url: "#"
|
||||
- name: "Kibana"
|
||||
logo: "/assets/tools/elastic.png"
|
||||
subtitle: "Explore & visualize logs"
|
||||
tag: "elk"
|
||||
url: "#"
|
||||
- name: "Website monitoring"
|
||||
logo: "/assets/tools/pingdom.png"
|
||||
subtitle: "Pingdom public reports overview"
|
||||
tag: "CI"
|
||||
url: "#"
|
||||
|
||||
volumes:
|
||||
- /your/local/assets/:/www/assets
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
If you choose to fetch message information from an endpoint, the output format should be:
|
||||
To launch container:
|
||||
|
||||
```json
|
||||
{
|
||||
"style": null,
|
||||
"title": "Lorem ipsum 42",
|
||||
"content": "LA LA LA Lorem ipsum dolor sit amet, ....."
|
||||
}
|
||||
```sh
|
||||
cd /path/to/docker-compose.yml
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
`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).
|
||||
Default assets will be automatically installed in the `/www/assets` directory. Use `UID` and/or `GID` env var to change the assets owner, also in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- UID=1000
|
||||
- GID=1000
|
||||
```
|
||||
|
||||
### Using the release tarball (prebuilt, ready to use)
|
||||
|
||||
Download and extract the latest release (`homer.zip`) from the [release page](https://github.com/bastienwirtz/homer/releases), rename the `assets/config.yml.dist` file to `assets/config.yml`, and put it behind a web server.
|
||||
|
||||
```sh
|
||||
wget https://github.com/bastienwirtz/homer/releases/latest/download/homer.zip
|
||||
unzip homer.zip
|
||||
cd homer
|
||||
cp assets/config.yml.dist assets/config.yml
|
||||
npx serve # or python -m http.server 8010 or apache, nginx ...
|
||||
```
|
||||
|
||||
### Build manually
|
||||
|
||||
```sh
|
||||
# Using yarn (recommended)
|
||||
yarn install
|
||||
yarn build
|
||||
|
||||
# **OR** Using npm
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then your dashboard is ready to use in the `/dist` directory.
|
||||
|
372
app.css
@ -1,372 +0,0 @@
|
||||
@charset "UTF-8";
|
||||
/* raleway-regular - latin */
|
||||
@font-face {
|
||||
font-family: "Raleway";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local("Raleway"), local("Raleway-Regular"), url("./webfonts/raleway/raleway-v14-latin-regular.woff2") format("woff2"), url("./webfonts/raleway/raleway-v14-latin-regular.woff") format("woff");
|
||||
/* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
/* lato-regular - latin */
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local("Lato Regular"), local("Lato-Regular"), url("./webfonts/lato/lato-v16-latin-regular.woff2") format("woff2"), url("./webfonts/lato/lato-v16-latin-regular.woff") format("woff");
|
||||
/* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Raleway", sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
body #app {
|
||||
min-height: 100%;
|
||||
transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
|
||||
background-color: #f5f5f5;
|
||||
color: #363636;
|
||||
}
|
||||
body #app a:hover {
|
||||
color: #363636;
|
||||
}
|
||||
body #app .title {
|
||||
color: #303030;
|
||||
}
|
||||
body #app .subtitle {
|
||||
color: #424242;
|
||||
}
|
||||
body #app .card {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
body #app .card:hover {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
body #app .footer {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) {
|
||||
body #app {
|
||||
background-color: #f5f5f5;
|
||||
color: #363636;
|
||||
}
|
||||
body #app a:hover {
|
||||
color: #363636;
|
||||
}
|
||||
body #app .title {
|
||||
color: #303030;
|
||||
}
|
||||
body #app .subtitle {
|
||||
color: #424242;
|
||||
}
|
||||
body #app .card {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
body #app .card:hover {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
body #app .footer {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body #app {
|
||||
background-color: #131313;
|
||||
color: #eaeaea;
|
||||
}
|
||||
body #app a:hover {
|
||||
color: #ffdd57;
|
||||
}
|
||||
body #app .title {
|
||||
color: #fafafa;
|
||||
}
|
||||
body #app .subtitle {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
body #app .card {
|
||||
background-color: #2b2b2b;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
body #app .card:hover {
|
||||
background-color: #2b2b2b;
|
||||
}
|
||||
body #app .footer {
|
||||
background-color: #2b2b2b;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
body #app.is-light {
|
||||
background-color: #f5f5f5;
|
||||
color: #363636;
|
||||
}
|
||||
body #app.is-light a:hover {
|
||||
color: #363636;
|
||||
}
|
||||
body #app.is-light .title {
|
||||
color: #303030;
|
||||
}
|
||||
body #app.is-light .subtitle {
|
||||
color: #424242;
|
||||
}
|
||||
body #app.is-light .card {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
body #app.is-light .card:hover {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
body #app.is-light .footer {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
body #app.is-dark {
|
||||
background-color: #131313;
|
||||
color: #eaeaea;
|
||||
}
|
||||
body #app.is-dark a:hover {
|
||||
color: #ffdd57;
|
||||
}
|
||||
body #app.is-dark .title {
|
||||
color: #fafafa;
|
||||
}
|
||||
body #app.is-dark .subtitle {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
body #app.is-dark .card {
|
||||
background-color: #2b2b2b;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
body #app.is-dark .card:hover {
|
||||
background-color: #2b2b2b;
|
||||
}
|
||||
body #app.is-dark .footer {
|
||||
background-color: #2b2b2b;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
body h1, body h2, body h3, body h4, body h5, body h6 {
|
||||
font-family: "Lato", sans-serif;
|
||||
}
|
||||
body h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
body h2 {
|
||||
font-size: 1.7rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
body h2 .fas, body h2 .fab, body h2 .far {
|
||||
margin-right: 10px;
|
||||
}
|
||||
body h2 span {
|
||||
font-weight: bold;
|
||||
color: #4285f4;
|
||||
}
|
||||
body [v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
body #bighead {
|
||||
color: #ffffff;
|
||||
}
|
||||
body #bighead .dashboard-title {
|
||||
padding: 6px 0 0 80px;
|
||||
}
|
||||
body #bighead .first-line {
|
||||
height: 100px;
|
||||
vertical-align: center;
|
||||
background-color: #3367d6;
|
||||
}
|
||||
body #bighead .first-line h1 {
|
||||
margin-top: -12px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
body #bighead .first-line .headline {
|
||||
margin-top: 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
body #bighead .first-line .container {
|
||||
height: 80px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
body #bighead .first-line .logo {
|
||||
float: left;
|
||||
}
|
||||
body #bighead .first-line .logo i {
|
||||
vertical-align: top;
|
||||
padding: 8px 15px;
|
||||
font-size: 50px;
|
||||
}
|
||||
body #bighead .first-line .logo img {
|
||||
padding: 10px;
|
||||
max-height: 70px;
|
||||
max-width: 70px;
|
||||
}
|
||||
body #bighead .navbar, body #bighead .navbar-menu {
|
||||
background-color: #4285f4;
|
||||
}
|
||||
body #bighead .navbar a, body #bighead .navbar-menu a {
|
||||
color: #ffffff;
|
||||
}
|
||||
body #bighead .navbar a:hover, body #bighead .navbar a:focus, body #bighead .navbar-menu a:hover, body #bighead .navbar-menu a:focus {
|
||||
color: #ffffff;
|
||||
background-color: #5a95f5;
|
||||
}
|
||||
body #bighead .navbar-end {
|
||||
text-align: right;
|
||||
}
|
||||
body #main-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
body #main-section h2 {
|
||||
border-bottom: 1px dashed #ccc;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
body #main-section .title {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
body #main-section .subtitle {
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
body #main-section .container {
|
||||
padding: 1.2rem 0.75rem;
|
||||
}
|
||||
body #main-section .message {
|
||||
margin-top: 45px;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
body #main-section .message .message-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
body #main-section .message .message-body {
|
||||
border: none;
|
||||
}
|
||||
body .media-content {
|
||||
overflow: inherit;
|
||||
}
|
||||
body .tag {
|
||||
color: #4285f4;
|
||||
background-color: #4285f4;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: -0.2rem;
|
||||
width: 3px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease-out;
|
||||
padding: 0;
|
||||
}
|
||||
body .tag .tag-text {
|
||||
display: none;
|
||||
}
|
||||
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;
|
||||
}
|
||||
body .card a {
|
||||
outline: none;
|
||||
}
|
||||
body .card:hover {
|
||||
transform: translate(0, -3px);
|
||||
}
|
||||
body .card:hover .tag {
|
||||
width: auto;
|
||||
color: #ffffff;
|
||||
padding: 0 0.75em;
|
||||
}
|
||||
body .card:hover .tag .tag-text {
|
||||
display: block;
|
||||
}
|
||||
body .card-content {
|
||||
height: 85px;
|
||||
padding: 1.3rem;
|
||||
}
|
||||
body .layout-vertical .card {
|
||||
border-radius: 0;
|
||||
}
|
||||
body .layout-vertical .column div:first-of-type .card {
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
body .layout-vertical .column div:last-child .card {
|
||||
border-radius: 0 0 5px 5px;
|
||||
}
|
||||
body .footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
color: #676767;
|
||||
font-size: 0.85rem;
|
||||
transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
|
||||
}
|
||||
body .no-footer #main-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
body .no-footer .footer {
|
||||
display: none;
|
||||
}
|
||||
body .search-bar {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
body .search-bar #search {
|
||||
border: none;
|
||||
background-color: #5f98f6;
|
||||
border-radius: 5px;
|
||||
padding: 2px 12px 2px 30px;
|
||||
margin: 0 0 0 12px;
|
||||
transition: all 100ms linear;
|
||||
color: #ffffff;
|
||||
height: 30px;
|
||||
width: 100px;
|
||||
}
|
||||
body .search-bar #search:focus {
|
||||
color: #000000;
|
||||
width: 250px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
body .search-bar .search-label::before {
|
||||
font-family: "Font Awesome 5 Free";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 16px;
|
||||
content: "";
|
||||
font-weight: 900;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
body .search-bar:focus-within .search-label::before {
|
||||
color: #4a4a4a;
|
||||
}
|
||||
body .icon-button {
|
||||
display: inline-block;
|
||||
padding: 0 12px;
|
||||
}
|
||||
body .offline-message {
|
||||
text-align: center;
|
||||
margin: 35px 0;
|
||||
}
|
||||
body .offline-message i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
body .offline-message i.fa-redo-alt {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1rem;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
color: #3273dc;
|
||||
}
|
125
app.js
@ -1,125 +0,0 @@
|
||||
const app = new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
config: null,
|
||||
offline: false,
|
||||
filter: '',
|
||||
vlayout: true,
|
||||
isDark: null,
|
||||
showMenu: false
|
||||
},
|
||||
created: async function () {
|
||||
let that = this;
|
||||
|
||||
this.isDark = 'overrideDark' in localStorage ?
|
||||
JSON.parse(localStorage.overrideDark) : matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
if ('vlayout' in localStorage) {
|
||||
this.vlayout = JSON.parse(localStorage.vlayout)
|
||||
}
|
||||
|
||||
this.checkOffline();
|
||||
try {
|
||||
this.config = await this.getConfig();
|
||||
document.title = this.config.title + ' | Homer';
|
||||
} catch (error) {
|
||||
this.offline = true;
|
||||
}
|
||||
|
||||
// Look for a new message if an endpoint is provided.
|
||||
if (this.config.message && this.config.message.url) {
|
||||
this.getMessage(this.config.message.url).then(function(message){
|
||||
// keep the original config value if no value is provided by the endpoint
|
||||
for (const prop of ['title','style','content']) {
|
||||
if (prop in message && message[prop] !== null) {
|
||||
that.config.message[prop] = message[prop];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState == "visible") {
|
||||
that.checkOffline();
|
||||
}
|
||||
}, false);
|
||||
},
|
||||
methods: {
|
||||
checkOffline: function () {
|
||||
let that = this;
|
||||
return fetch(window.location.href + "?alive", {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store'
|
||||
}).then(function () {
|
||||
that.offline = false;
|
||||
}).catch(function () {
|
||||
that.offline = true;
|
||||
});
|
||||
},
|
||||
getConfig: function (event) {
|
||||
return fetch('config.yml').then(function (response) {
|
||||
if (response.status != 200) {
|
||||
return
|
||||
}
|
||||
return response.text().then(function (body) {
|
||||
return jsyaml.load(body);
|
||||
});
|
||||
});
|
||||
},
|
||||
getMessage: function (url) {
|
||||
return fetch(url).then(function (response) {
|
||||
if (response.status != 200) {
|
||||
return;
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
},
|
||||
toggleTheme: function() {
|
||||
this.isDark = !this.isDark;
|
||||
localStorage.overrideDark = this.isDark;
|
||||
},
|
||||
toggleLayout: function() {
|
||||
this.vlayout = !this.vlayout;
|
||||
localStorage.vlayout = this.vlayout;
|
||||
},
|
||||
toggleMenu: function() {
|
||||
this.showMenu = !this.showMenu;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('service', {
|
||||
props: ['item'],
|
||||
template: `<div>
|
||||
<div class="card">
|
||||
<a :href="item.url" :target="item.target">
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<div v-if="item.logo" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<img :src="item.logo" />
|
||||
</figure>
|
||||
</div>
|
||||
<div v-if="item.icon" class="media-left">
|
||||
<figure class="image is-48x48">
|
||||
<i style="font-size: 35px" :class="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>
|
||||
<div class="tag" :class="item.tagstyle" v-if="item.tag">
|
||||
<strong class="tag-text">#{{ item.tag }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div></div>`
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('worker.js');
|
||||
});
|
||||
}
|
379
app.scss
@ -1,379 +0,0 @@
|
||||
$primary-color: #3367d6;
|
||||
$secondary-color: #4285f4;
|
||||
|
||||
|
||||
// /!\ Keep background colors sync with `theme-color` meta info
|
||||
$theme-light: (
|
||||
background: #f5f5f5,
|
||||
card-background: #ffffff,
|
||||
text: #363636,
|
||||
text-title: #303030,
|
||||
text-subtitle: #424242,
|
||||
card-shadow: rgba(0, 0, 0, 0.1),
|
||||
a-hover: #363636
|
||||
);
|
||||
$theme-dark: (
|
||||
background: #131313,
|
||||
card-background: #2b2b2b,
|
||||
text: #eaeaea,
|
||||
text-title: #fafafa,
|
||||
text-subtitle: #f5f5f5,
|
||||
card-shadow: rgba(0, 0, 0, 0.4),
|
||||
a-hover: #ffdd57
|
||||
);
|
||||
|
||||
/* raleway-regular - latin */
|
||||
@font-face {
|
||||
font-family: 'Raleway';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Raleway'), local('Raleway-Regular'),
|
||||
url('./webfonts/raleway/raleway-v14-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('./webfonts/raleway/raleway-v14-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* lato-regular - latin */
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Lato Regular'), local('Lato-Regular'),
|
||||
url('./webfonts/lato/lato-v16-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('./webfonts/lato/lato-v16-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
@mixin theme($theme) {
|
||||
background-color: map-get($theme, "background");
|
||||
color: map-get($theme, "text");
|
||||
a {
|
||||
&:hover {
|
||||
color: map-get($theme, "a-hover");
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: map-get($theme, "text-title");
|
||||
}
|
||||
.subtitle {
|
||||
color: map-get($theme, "text-subtitle");
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: map-get($theme, "card-background");
|
||||
box-shadow: 0 2px 15px 0 map-get($theme, "card-shadow");
|
||||
&:hover {
|
||||
background-color: map-get($theme, "card-background");
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: map-get($theme, "card-background");
|
||||
box-shadow: 0 2px 15px 0 map-get($theme, "card-shadow");
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Raleway', sans-serif;
|
||||
height: 100%;
|
||||
|
||||
#app {
|
||||
min-height: 100%;
|
||||
transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
|
||||
|
||||
// Default theme
|
||||
@include theme($theme-light);
|
||||
|
||||
// System pref theme
|
||||
@media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) {
|
||||
@include theme($theme-light);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@include theme($theme-dark);
|
||||
}
|
||||
|
||||
// User override theme
|
||||
&.is-light {
|
||||
@include theme($theme-light);
|
||||
}
|
||||
&.is-dark {
|
||||
@include theme($theme-dark);
|
||||
}
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Lato', sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.7rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.fas, .fab, .far {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
color: $secondary-color;
|
||||
}
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none
|
||||
}
|
||||
|
||||
#bighead {
|
||||
color: #ffffff;
|
||||
|
||||
.dashboard-title {
|
||||
padding: 6px 0 0 80px;
|
||||
}
|
||||
|
||||
.first-line {
|
||||
height: 100px;
|
||||
vertical-align: center;
|
||||
background-color: $primary-color;
|
||||
|
||||
h1 {
|
||||
margin-top: -12px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin-top: 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 80px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
float: left;
|
||||
i {
|
||||
vertical-align: top;
|
||||
padding: 8px 15px;
|
||||
font-size: 50px
|
||||
}
|
||||
|
||||
img {
|
||||
padding: 10px;
|
||||
max-height: 70px;
|
||||
max-width: 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.navbar, .navbar-menu {
|
||||
background-color: $secondary-color;
|
||||
|
||||
a {
|
||||
color: #ffffff;
|
||||
&:hover, &:focus {
|
||||
color: #ffffff;
|
||||
background-color: lighten( $secondary-color, 5% );
|
||||
}
|
||||
}
|
||||
}
|
||||
.navbar-end {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
#main-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 0;
|
||||
|
||||
h2 {
|
||||
border-bottom: 1px dashed #ccc;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: .9em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1.2rem .75rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 45px;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
.message-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-content {
|
||||
overflow: inherit;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: $secondary-color;
|
||||
background-color: $secondary-color;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: -0.2rem;
|
||||
width: 3px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease-out;
|
||||
padding: 0;
|
||||
|
||||
.tag-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
a {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translate(0, -3px);
|
||||
|
||||
.tag {
|
||||
width: auto;
|
||||
color: #ffffff;
|
||||
padding: 0 0.75em;
|
||||
|
||||
.tag-text {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
height: 85px;
|
||||
padding: 1.3rem;
|
||||
}
|
||||
|
||||
.layout-vertical {
|
||||
.card {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.column div:first-of-type .card {
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
|
||||
.column div:last-child .card {
|
||||
border-radius: 0 0 5px 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
color: #676767;
|
||||
font-size: 0.85rem;
|
||||
transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
|
||||
}
|
||||
|
||||
.no-footer {
|
||||
#main-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
#search {
|
||||
border: none;
|
||||
background-color: lighten( $secondary-color, 6% );
|
||||
border-radius: 5px;
|
||||
padding: 2px 12px 2px 30px;
|
||||
margin: 0 0 0 12px;
|
||||
transition: all 100ms linear;
|
||||
color: #ffffff;
|
||||
height: 30px;
|
||||
width: 100px;
|
||||
|
||||
|
||||
&:focus {
|
||||
color: #000000;
|
||||
width: 250px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.search-label::before {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 16px;
|
||||
content: "\f002";
|
||||
font-weight: 900;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&:focus-within .search-label::before {
|
||||
color: #4a4a4a;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-block;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
text-align: center;
|
||||
margin: 35px 0;
|
||||
|
||||
i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
i.fa-redo-alt {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1rem;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
color: #3273dc;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Before Width: | Height: | Size: 48 KiB |
BIN
assets/logo.png
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 8.6 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 30 KiB |
3
babel.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ["@vue/cli-plugin-babel/preset"],
|
||||
};
|
16
docker-compose.yml
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
version: "2"
|
||||
services:
|
||||
homer:
|
||||
image: b4bz/homer
|
||||
#To build from source, comment previous line and uncomment below
|
||||
#build: .
|
||||
container_name: homer
|
||||
volumes:
|
||||
- /your/local/assets/:/www/assets
|
||||
ports:
|
||||
- 8080:8080
|
||||
#environment:
|
||||
# - UID=1000
|
||||
# - GID=1000
|
||||
restart: unless-stopped
|
204
docs/configuration.md
Normal file
@ -0,0 +1,204 @@
|
||||
# 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/v5/search for icons options
|
||||
|
||||
# Optional: Use external configuration file.
|
||||
# Using this will ignore remaining config in this file
|
||||
# externalConfig: https://example.com/server-luci/config.yaml
|
||||
|
||||
title: "App dashboard"
|
||||
subtitle: "Homer"
|
||||
# documentTitle: "Welcome" # Customize the browser tab text
|
||||
logo: "assets/logo.png"
|
||||
# Alternatively a fa icon can be provided:
|
||||
# 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'.
|
||||
|
||||
# Optional custom stylesheet
|
||||
# Will load custom CSS files. Especially useful for custom icon sets.
|
||||
# stylesheet:
|
||||
# - "assets/custom.css"
|
||||
|
||||
# Here is the exhaustive list of customization parameters
|
||||
# However all value are optional and will fallback to default if not set.
|
||||
# if you want to change only some of the colors, feel free to remove all unused key.
|
||||
colors:
|
||||
light:
|
||||
highlight-primary: "#3367d6"
|
||||
highlight-secondary: "#4285f4"
|
||||
highlight-hover: "#5a95f5"
|
||||
background: "#f5f5f5"
|
||||
card-background: "#ffffff"
|
||||
text: "#363636"
|
||||
text-header: "#424242"
|
||||
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:
|
||||
highlight-primary: "#3367d6"
|
||||
highlight-secondary: "#4285f4"
|
||||
highlight-hover: "#5a95f5"
|
||||
background: "#131313"
|
||||
card-background: "#2b2b2b"
|
||||
text: "#eaeaea"
|
||||
text-header: "#ffffff"
|
||||
text-title: "#fafafa"
|
||||
text-subtitle: "#f5f5f5"
|
||||
card-shadow: rgba(0, 0, 0, 0.4)
|
||||
link: "#3273dc"
|
||||
link-hover: "#ffdd57"
|
||||
background-image: "assets/your/dark/bg.png"
|
||||
|
||||
# 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"
|
||||
content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
||||
|
||||
# Optional navbar
|
||||
# links: [] # Allows for navbar (dark mode, layout, and search) without any links
|
||||
links:
|
||||
- name: "Link 1"
|
||||
icon: "fab fa-github"
|
||||
url: "https://github.com/bastienwirtz/homer"
|
||||
target: "_blank" # optional html tag target attribute
|
||||
- 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.
|
||||
# Leave only a "items" key if not using group (group name, icon & tagstyle are optional, section separation will not be displayed).
|
||||
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"
|
||||
# Alternatively a fa icon can be provided:
|
||||
# icon: "fab fa-jenkins"
|
||||
subtitle: "Bookmark example"
|
||||
tag: "app"
|
||||
url: "https://www.reddit.com/r/selfhosted/"
|
||||
target: "_blank" # optional html tag target attribute
|
||||
- name: "Another one"
|
||||
logo: "assets/tools/sample2.png"
|
||||
subtitle: "Another application"
|
||||
tag: "app"
|
||||
# Optional tagstyle
|
||||
tagstyle: "is-success"
|
||||
url: "#"
|
||||
- name: "Other group"
|
||||
icon: "fas fa-heartbeat"
|
||||
items:
|
||||
- name: "Pi-hole"
|
||||
logo: "assets/tools/sample.png"
|
||||
# subtitle: "Network-wide Ad Blocking" # optional, if no subtitle is defined, PiHole statistics will be shown
|
||||
tag: "other"
|
||||
url: "http://192.168.0.151/admin"
|
||||
type: "PiHole" # optional, loads a specific component that provides extra features. MUST MATCH a file name (without file extension) available in `src/components/services`
|
||||
target: "_blank" # optional html a tag target attribute
|
||||
# class: "green" # optional custom CSS class for card, useful with custom stylesheet
|
||||
# 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
|
||||
{
|
||||
"style": null,
|
||||
"title": "Lorem ipsum 42",
|
||||
"content": "LA LA LA Lorem ipsum dolor sit amet, ....."
|
||||
}
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
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:
|
||||
|
||||
- `is-info` (blue)
|
||||
- `is-success` (green)
|
||||
- `is-warning` (yellow)
|
||||
- `is-danger` (red)
|
||||
|
||||
You can read the [bulma modifiers page](https://bulma.io/documentation/modifiers/syntax/) for other options regarding size, style, or state.
|
||||
|
||||
## 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
@ -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.
|
||||
```
|
74
docs/development.md
Normal file
@ -0,0 +1,74 @@
|
||||
# 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)
|
||||
yarn install
|
||||
yarn serve
|
||||
|
||||
# **OR** Using npm
|
||||
npm install
|
||||
npm run serve
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
```scss
|
||||
// `src/assets/themes/my-awesome-theme.scss`
|
||||
body #app.theme-my-awesome-theme. { ... }
|
||||
```
|
||||
|
||||
```scss
|
||||
// `src/assets/app.scss`
|
||||
// Themes import
|
||||
@import "./themes/sui.scss";
|
||||
...
|
||||
@import "./themes/my-awesome-theme.scss";
|
||||
```
|
BIN
docs/screenshot.png
Normal file
After Width: | Height: | Size: 50 KiB |
192
docs/tips-and-tricks.md
Normal file
@ -0,0 +1,192 @@
|
||||
# Tips & Tricks
|
||||
|
||||
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.
|
||||
|
||||
The Firefox extension loads Homer in an iframe on your new tab page, meaning you have to add `target: '_top'` to each of your items.
|
||||
|
||||
```yaml
|
||||
- name: "Reddit"
|
||||
logo: "assets/daily/reddit.png"
|
||||
url: "https://reddit.com"
|
||||
target: '_top'
|
||||
|
||||
- name: "YouTube"
|
||||
logo: "assets/daily/youtube.png"
|
||||
url: "https://youtube.com"
|
||||
target: '_top'
|
||||
```
|
||||
|
||||
## 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.
|
||||
Using Anchoring, you can define all your tags and their styles once like this: (for example)
|
||||
|
||||
```yaml
|
||||
# Some pre-defined tag styles. reference these using <<: *{NAME} inside an item definition; For Example, <<: *Apps
|
||||
tags:
|
||||
Favourite: &Favourite
|
||||
- tag: "Favourite"
|
||||
tagstyle: "is-medium is-primary"
|
||||
CI: &CI
|
||||
- tag: "CI"
|
||||
tagstyle: "is-medium is-success"
|
||||
Apps: &Apps
|
||||
- tag: "App"
|
||||
tagstyle: "is-medium is-info"
|
||||
```
|
||||
|
||||
and then simply reference these pre-defined (anchored) tags in each item like so:
|
||||
|
||||
```yaml
|
||||
- name: "VS Code"
|
||||
logo: "/assets/vscode.png"
|
||||
subtitle: "Develop Code Anywhere, On Anything!"
|
||||
<<: *Apps # Reference to the predefined "App" Tag
|
||||
url: "https://vscode.example.com/"
|
||||
target: "_blank" # optional html tag target attribute
|
||||
````
|
||||
|
||||
Then when Homer reads your config, it will substitute your anchors automatically, the above example is equal to:
|
||||
|
||||
```yaml
|
||||
- name: "VS Code"
|
||||
logo: "/assets/vscode.png"
|
||||
subtitle: "Develop Code Anywhere, On Anything!"
|
||||
tag: "App"
|
||||
tagstyle: "is-medium is-info"
|
||||
url: "https://vscode.example.com/"
|
||||
target: "_blank" # optional html tag target attribute
|
||||
```
|
||||
|
||||
The end result is that if you want to update the name or style of any particular tag, just update it once, in the tags section!
|
||||
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!
|
||||
|
||||
You can setup and use [Code-Server](https://github.com/cdr/code-server) to edit your `config.yml` file from anywhere!
|
||||
|
||||
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
|
||||
icon: fas fa-cog
|
||||
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 \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Europe/London \
|
||||
-e PASSWORD={YOUR_PASSWORD} `#optional` \
|
||||
-e SUDO_PASSWORD={YOUR SUDO_PASSWORD} `#optional` \
|
||||
-p 8443:8443 \
|
||||
-v /path/to/appdata/config:/config \
|
||||
-v /your/local/homer/config-dir/:/config/homer \
|
||||
--restart unless-stopped \
|
||||
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:
|
||||
|
||||
```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:`.
|
||||
|
||||
If the URL you specified returns a JSON object that defines a `title` and `content` item, homer will replace these values from your `config.yml` with the ones in the returned object.
|
||||
|
||||
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!
|
||||
|
||||
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
@ -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.
|
15
entrypoint.sh
Normal file
@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Ensure default assets are present.
|
||||
while true; do echo n; done | cp -Ri /www/default-assets/* /www/assets/ &> /dev/null
|
||||
|
||||
# Ensure compatibility with previous version (config.yml was in the root directory)
|
||||
if [ -f "/www/config.yml" ]; then
|
||||
yes n | cp -i /www/config.yml /www/assets/ &> /dev/null
|
||||
fi
|
||||
|
||||
# Install default config if no one is available.
|
||||
yes n | cp -i /www/default-assets/config.yml.dist /www/assets/config.yml &> /dev/null
|
||||
|
||||
chown -R $UID:$GID /www/assets
|
||||
exec su-exec $UID:$GID darkhttpd /www/ --no-listing --port "$PORT"
|
8
hooks/post_push
Normal file
@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
IFS='-' read -r TAG string <<< "$DOCKER_TAG"
|
||||
|
||||
docker manifest create b4bz/homer:$TAG b4bz/homer:$TAG-amd64 b4bz/homer:$TAG-arm32v7 b4bz/homer:$TAG-arm64v8
|
||||
docker manifest annotate b4bz/homer:$TAG b4bz/homer:$TAG-arm32v7 --os linux --arch arm
|
||||
docker manifest annotate b4bz/homer:$TAG b4bz/homer:$TAG-arm64v8 --os linux --arch arm64 --variant v8
|
||||
docker manifest push --purge b4bz/homer:$TAG
|
8
hooks/pre_build
Normal file
@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update to docker-ee 18.x for manifests
|
||||
apt-get -y update
|
||||
apt-get -y --only-upgrade install docker-ee
|
||||
# Register qemu-*-static for all supported processors except the
|
||||
# current one, but also remove all registered binfmt_misc before
|
||||
docker run --rm --privileged multiarch/qemu-user-static:register --reset
|
132
index.html
@ -1,132 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="icon" type="image/png" href="assets/favicon.png">
|
||||
<title>Homer</title>
|
||||
<link defer rel="stylesheet" href="vendors/font-awesone.min.css">
|
||||
<link defer rel="stylesheet" href="vendors/bulma.min.css">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" v-if="config" :class="[isDark ? 'is-dark' : 'is-light', !config.footer ? 'no-footer': '']">
|
||||
<div id="bighead">
|
||||
<section class="first-line">
|
||||
<div v-cloak class="container">
|
||||
<div class="logo">
|
||||
<img v-if="config.logo" :src="config.logo" />
|
||||
<i v-if="config.icon" :class="config.icon"></i>
|
||||
</div>
|
||||
<div class="dashboard-title">
|
||||
<span class="headline">{{ config.subtitle }}</span>
|
||||
<h1>{{ config.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div v-cloak v-if="config.links" class="container-fluid">
|
||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<a role="button"
|
||||
class="navbar-burger" :class="{ 'is-active': showMenu }"
|
||||
aria-label="menu" aria-expanded="false"
|
||||
v-on:click="toggleMenu()">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-menu" :class="{ 'is-active': showMenu }">
|
||||
<div class="navbar-start">
|
||||
<a v-for="link in config.links" class="navbar-item" :href="link.url" :target="link.target">
|
||||
<i v-if="link.icon" style="margin-right: 6px;" :class="link.icon"></i>
|
||||
{{ link.name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<a
|
||||
v-on:click="toggleTheme()"
|
||||
aria-label="Toggle dark mode"
|
||||
class="icon-button"
|
||||
><i class="fas fa-adjust"></i>
|
||||
</a>
|
||||
<a v-on:click="toggleLayout()" class="icon-button"><i
|
||||
:class="['fas', vlayout ? 'fa-list' : 'fa-columns']"></i></a>
|
||||
<div class="search-bar">
|
||||
<label for="search" class="search-label"></label>
|
||||
<input type="text" id="search" v-model="filter" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="main-section" class="section">
|
||||
<div v-cloak class="container">
|
||||
<div v-if="offline" class="offline-message">
|
||||
<i class="far fa-dizzy"></i>
|
||||
<h1>You're offline bro. <i class="fas fa-redo-alt" v-on:click="checkOffline()"></i></h1>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Optional messages -->
|
||||
<article v-if="config && config.message" class="message" :class="config.message.style">
|
||||
<div v-if="config.message.title" class="message-header">
|
||||
<p>{{ config.message.title }}</p>
|
||||
</div>
|
||||
<div v-if="config.message.content" class="message-body">
|
||||
{{ config.message.content }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<h2 v-if="filter"><i class="fas fa-search"></i> Search</h2>
|
||||
|
||||
<!-- Horizontal layout -->
|
||||
<div v-if="!vlayout || filter" class="columns is-multiline">
|
||||
<template v-for="(group, index) in config.services">
|
||||
<h2 v-if="!filter && group.name" class="column is-full"><i v-if="group.icon" :class="group.icon"></i><span
|
||||
v-else>#</span>
|
||||
{{ group.name }}</h2>
|
||||
<service v-for="item in group.items" v-bind:item="item" class="column is-one-third-widescreen"
|
||||
v-if="!filter || (item && (item.name.toLowerCase().includes(filter.toLowerCase()) || (item.tag && item.tag.toLowerCase().includes(filter.toLowerCase()))))">
|
||||
</service>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Vertical layout -->
|
||||
<div v-if="!filter && vlayout" class="columns is-multiline layout-vertical">
|
||||
<div class="column is-one-third-widescreen" v-for="(group, index) in config.services">
|
||||
<h2 v-if="!filter && group.name"><i v-if="group.icon" :class="group.icon"></i><span v-else>#</span>
|
||||
{{ group.name }}</h2>
|
||||
<service v-for="item in group.items" v-bind:item="item"
|
||||
v-if="!filter || (item && (item.name.toLowerCase().includes(filter.toLowerCase()) || (item.tag && item.tag.toLowerCase().includes(filter.toLowerCase()))))">
|
||||
</service>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="content has-text-centered" v-if="config.footer" v-html="config.footer">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="vendors/vue.min.js"></script>
|
||||
<script src="vendors/js-yaml.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
35
package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "homer",
|
||||
"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.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.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.5.15",
|
||||
"@vue/cli-plugin-eslint": "~4.5.15",
|
||||
"@vue/cli-plugin-pwa": "~4.5.15",
|
||||
"@vue/cli-service": "~4.5.15",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"prettier": "^2.2.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"sass": "^1.26.5",
|
||||
"sass-loader": "^8.0.2",
|
||||
"vue-template-compiler": "^2.6.12"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
35
public/assets/additional-page.yml.dist
Normal file
@ -0,0 +1,35 @@
|
||||
---
|
||||
# Additional page configuration
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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"
|
84
public/assets/config.yml.dist
Normal file
@ -0,0 +1,84 @@
|
||||
---
|
||||
# Homepage configuration
|
||||
# See https://fontawesome.com/v5/search for icons options
|
||||
|
||||
title: "Demo dashboard"
|
||||
subtitle: "Homer"
|
||||
logo: "logo.png"
|
||||
# icon: "fas fa-skull-crossbones" # Optional icon
|
||||
|
||||
header: true
|
||||
footer: '<p>Created with <span class="has-text-danger">❤️</span> with <a href="https://bulma.io/">bulma</a>, <a href="https://vuejs.org/">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.
|
||||
|
||||
# Optional theme customization
|
||||
theme: default
|
||||
colors:
|
||||
light:
|
||||
highlight-primary: "#3367d6"
|
||||
highlight-secondary: "#4285f4"
|
||||
highlight-hover: "#5a95f5"
|
||||
background: "#f5f5f5"
|
||||
card-background: "#ffffff"
|
||||
text: "#363636"
|
||||
text-header: "#ffffff"
|
||||
text-title: "#303030"
|
||||
text-subtitle: "#424242"
|
||||
card-shadow: rgba(0, 0, 0, 0.1)
|
||||
link: "#3273dc"
|
||||
link-hover: "#363636"
|
||||
dark:
|
||||
highlight-primary: "#3367d6"
|
||||
highlight-secondary: "#4285f4"
|
||||
highlight-hover: "#5a95f5"
|
||||
background: "#131313"
|
||||
card-background: "#2b2b2b"
|
||||
text: "#eaeaea"
|
||||
text-header: "#ffffff"
|
||||
text-title: "#fafafa"
|
||||
text-subtitle: "#f5f5f5"
|
||||
card-shadow: rgba(0, 0, 0, 0.4)
|
||||
link: "#3273dc"
|
||||
link-hover: "#ffdd57"
|
||||
|
||||
# Optional message
|
||||
message:
|
||||
#url: https://b4bz.io
|
||||
style: "is-dark" # See https://bulma.io/documentation/components/message/#colors for styling options.
|
||||
title: "Demo !"
|
||||
icon: "fa fa-grin"
|
||||
content: "This is a dummy homepage demo. <br /> Find more information on <a href='https://github.com/bastienwirtz/homer'>github.com/bastienwirtz/homer</a>"
|
||||
|
||||
# Optional navbar
|
||||
# links: [] # Allows for navbar (dark mode, layout, and search) without any links
|
||||
links:
|
||||
- name: "Contribute"
|
||||
icon: "fab fa-github"
|
||||
url: "https://github.com/bastienwirtz/homer"
|
||||
target: "_blank" # optional html a tag target attribute
|
||||
- name: "Wiki"
|
||||
icon: "fas fa-book"
|
||||
url: "https://www.wikipedia.org/"
|
||||
# this will link to a second homer page that will load config from additional-page.yml and keep default config values as in config.yml file
|
||||
# see url field and assets/additional-page.yml.dist used in this example:
|
||||
- name: "another page!"
|
||||
icon: "fas fa-file-alt"
|
||||
url: "#additional-page"
|
||||
|
||||
# Services
|
||||
# First level array represent a group.
|
||||
# Leave only a "items" key if not using group (group name, icon & tagstyle are optional, section separation will not be displayed).
|
||||
services:
|
||||
- name: "Applications"
|
||||
icon: "fas fa-cloud"
|
||||
items:
|
||||
- name: "Awesome app"
|
||||
logo: "assets/tools/sample.png"
|
||||
subtitle: "Bookmark example"
|
||||
tag: "app"
|
||||
url: "https://www.reddit.com/r/selfhosted/"
|
||||
target: "_blank" # optional html a tag target attribute
|
||||
- name: "Another one"
|
||||
logo: "assets/tools/sample2.png"
|
||||
subtitle: "Another application"
|
||||
tag: "app"
|
||||
url: "#"
|
@ -1,59 +1,53 @@
|
||||
---
|
||||
# Homepage configuration
|
||||
# See https://fontawesome.com/icons for icons options
|
||||
# See https://fontawesome.com/v5/search for icons options
|
||||
|
||||
title: "Demo dashboard"
|
||||
subtitle: "Homer"
|
||||
logo: "assets/logo.png"
|
||||
title: "Hello beautiful!"
|
||||
subtitle: "App dashboard"
|
||||
logo: false
|
||||
# icon: "fas fa-skull-crossbones" Optional icon
|
||||
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.
|
||||
|
||||
# Optional message
|
||||
message:
|
||||
# url: https://....
|
||||
style: "is-dark" # See https://bulma.io/documentation/components/message/#colors for styling options.
|
||||
title: "Demo !"
|
||||
content: "This is a dummy homepage demo. Find more information on github.com/bastienwirtz/homer"
|
||||
header: true
|
||||
|
||||
# Optional theme customization
|
||||
theme: sui
|
||||
colors:
|
||||
light:
|
||||
highlight-primary: transparent
|
||||
highlight-secondary: transparent
|
||||
highlight-hover: "#4a4a4a"
|
||||
text-subtitle: "#424242"
|
||||
dark:
|
||||
background: "#2B2C56"
|
||||
highlight-primary: transparent
|
||||
highlight-secondary: transparent
|
||||
highlight-hover: "#200b35"
|
||||
text-subtitle: "#6375e8"
|
||||
|
||||
# Optional navbar
|
||||
# links: [] # Allows for navbar (dark mode, layout, and search) without any links
|
||||
links:
|
||||
- name: "ansible"
|
||||
icon: "fab fa-github"
|
||||
url: "https://github.com/bastienwirtz/homer"
|
||||
target: '_blank' # optionnal html a tag target attribute
|
||||
- name: "Wiki"
|
||||
icon: "fas fa-book"
|
||||
url: "https://www.wikipedia.org/"
|
||||
links: []
|
||||
|
||||
# Services
|
||||
# First level array represent a group.
|
||||
# Leave only a "items" key if not using group (group name, icon & tagstyle are optional, section separation will not be displayed).
|
||||
services:
|
||||
- name: "DevOps"
|
||||
icon: "fas fa-code-branch"
|
||||
- name: "APPLICATIONS"
|
||||
items:
|
||||
- name: "Jenkins"
|
||||
logo: "assets/tools/jenkins.png"
|
||||
subtitle: "Continuous integration server"
|
||||
tag: "CI"
|
||||
url: "https://jenkins.io/"
|
||||
target: '_blank' # optionnal html a tag target attribute
|
||||
- name: "RabbitMQ Management"
|
||||
logo: "assets/tools/rabbitmq.png"
|
||||
subtitle: "Manage & monitor RabbitMQ server"
|
||||
tag: "haproxy"
|
||||
# Optional tagstyle
|
||||
# Same styling options as the optional message.
|
||||
tagstyle: "is-success"
|
||||
url: "https://www.rabbitmq.com/"
|
||||
- name: "Monitoring"
|
||||
icon: "fas fa-heartbeat"
|
||||
items:
|
||||
- name: "M/Monit"
|
||||
logo: "assets/tools/monit.png"
|
||||
subtitle: "Monitor & manage all monit enabled hosts"
|
||||
tag: "monit"
|
||||
url: "https://mmonit.com/monit/"
|
||||
- name: "Grafana"
|
||||
logo: "assets/tools/grafana.png"
|
||||
@ -62,7 +56,6 @@ services:
|
||||
- name: "Kibana"
|
||||
logo: "assets/tools/elastic.png"
|
||||
subtitle: "Explore & visualize logs"
|
||||
tag: "elk"
|
||||
url: "https://www.elastic.co/products/kibana"
|
||||
- name: "Website monitoring"
|
||||
logo: "assets/tools/pingdom.png"
|
8
public/assets/custom.css.sample
Normal file
@ -0,0 +1,8 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/* Custom card colors */
|
||||
/* Use with `class:` property of services in config.yml */
|
||||
body #app .card.green {
|
||||
background-color: #006600;
|
||||
color: #00ff00;
|
||||
}
|
BIN
public/assets/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 790 B |
BIN
public/assets/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
public/assets/icons/icon-any.png
Normal file
After Width: | Height: | Size: 75 KiB |
1
public/assets/icons/icon-any.svg
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
public/assets/icons/icon-maskable.png
Normal file
After Width: | Height: | Size: 124 KiB |
3
public/assets/icons/safari-pinned-tab.svg
Normal file
After Width: | Height: | Size: 6.7 KiB |
42
public/assets/manifest.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "Homer Dashboard",
|
||||
"short_name": "Homer",
|
||||
"theme_color": "#3367D6",
|
||||
"start_url": "../",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icons/favicon-16x16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./icons/favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-any.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-any.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "./icons/safari-pinned-tab.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "monochrome"
|
||||
}
|
||||
]
|
||||
}
|
BIN
public/assets/tools/sample.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
BIN
public/assets/tools/sample2.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
18
public/index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.png">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
BIN
public/logo.png
Normal file
After Width: | Height: | Size: 36 KiB |
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
BIN
screenshot.png
Before Width: | Height: | Size: 148 KiB |
303
src/App.vue
Normal file
@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div
|
||||
id="app"
|
||||
v-if="config"
|
||||
:class="[
|
||||
`theme-${config.theme}`,
|
||||
isDark ? 'is-dark' : 'is-light',
|
||||
!config.footer ? 'no-footer' : '',
|
||||
]"
|
||||
>
|
||||
<DynamicTheme :themes="config.colors" />
|
||||
<div id="bighead">
|
||||
<section v-if="config.header" class="first-line">
|
||||
<div v-cloak class="container">
|
||||
<div class="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">
|
||||
<span class="headline">{{ config.subtitle }}</span>
|
||||
<h1>{{ config.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Navbar
|
||||
:open="showMenu"
|
||||
:links="config.links"
|
||||
@navbar-toggle="showMenu = !showMenu"
|
||||
>
|
||||
<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"
|
||||
@search-cancel="filterServices"
|
||||
/>
|
||||
</Navbar>
|
||||
</div>
|
||||
|
||||
<section id="main-section" class="section">
|
||||
<div v-cloak class="container">
|
||||
<ConnectivityChecker
|
||||
v-if="config.connectivityCheck"
|
||||
@network-status-update="offline = $event"
|
||||
/>
|
||||
|
||||
<GetStarted v-if="loaded && !services" />
|
||||
|
||||
<div v-if="!offline">
|
||||
<!-- Optional messages -->
|
||||
<Message :item="config.message" />
|
||||
|
||||
<!-- Horizontal layout -->
|
||||
<div v-if="!vlayout || filter" class="columns is-multiline">
|
||||
<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
|
||||
v-for="(item, index) in group.items"
|
||||
:key="index"
|
||||
:item="item"
|
||||
:proxy="config.proxy"
|
||||
:forwarder="config.forwarder"
|
||||
:class="['column', `is-${12 / config.columns}`]"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Vertical layout -->
|
||||
<div
|
||||
v-if="!filter && vlayout"
|
||||
class="columns is-multiline layout-vertical"
|
||||
>
|
||||
<div
|
||||
:class="['column', `is-${12 / config.columns}`]"
|
||||
v-for="group in services"
|
||||
:key="group.name"
|
||||
>
|
||||
<h2 v-if="group.name" class="group-title">
|
||||
<i v-if="group.icon" :class="['fa-fw', group.icon]"></i>
|
||||
<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
|
||||
v-for="(item, index) in group.items"
|
||||
:key="index"
|
||||
:item="item"
|
||||
:proxy="config.proxy"
|
||||
:forwarder="config.forwarder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div
|
||||
class="content has-text-centered"
|
||||
v-if="config.footer"
|
||||
v-html="config.footer"
|
||||
></div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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";
|
||||
import SearchInput from "./components/SearchInput.vue";
|
||||
import SettingToggle from "./components/SettingToggle.vue";
|
||||
import DarkMode from "./components/DarkMode.vue";
|
||||
import DynamicTheme from "./components/DynamicTheme.vue";
|
||||
|
||||
import defaultConfig from "./assets/defaults.yml";
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
components: {
|
||||
Navbar,
|
||||
GetStarted,
|
||||
ConnectivityChecker,
|
||||
Service,
|
||||
Message,
|
||||
SearchInput,
|
||||
SettingToggle,
|
||||
DarkMode,
|
||||
DynamicTheme,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
loaded: false,
|
||||
config: null,
|
||||
services: null,
|
||||
offline: false,
|
||||
filter: "",
|
||||
vlayout: true,
|
||||
isDark: null,
|
||||
showMenu: false,
|
||||
};
|
||||
},
|
||||
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;
|
||||
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) {
|
||||
// This allows to work with authentication proxies.
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw Error(`${response.statusText}: ${response.body}`);
|
||||
}
|
||||
|
||||
const that = this;
|
||||
return response
|
||||
.text()
|
||||
.then((body) => {
|
||||
return jsyaml.load(body);
|
||||
})
|
||||
.then(function (config) {
|
||||
if (config.externalConfig) {
|
||||
return that.getConfig(config.externalConfig);
|
||||
}
|
||||
return config;
|
||||
});
|
||||
});
|
||||
},
|
||||
matchesFilter: function (item) {
|
||||
return (
|
||||
item.name.toLowerCase().includes(this.filter) ||
|
||||
(item.subtitle && item.subtitle.toLowerCase().includes(this.filter)) ||
|
||||
(item.tag && item.tag.toLowerCase().includes(this.filter))
|
||||
);
|
||||
},
|
||||
navigateToFirstService: function (target) {
|
||||
try {
|
||||
const service = this.services[0].items[0];
|
||||
window.open(service.url, target || service.target || "_self");
|
||||
} catch (error) {
|
||||
console.warning("fail to open service");
|
||||
}
|
||||
},
|
||||
filterServices: function (filter) {
|
||||
this.filter = filter;
|
||||
|
||||
if (!filter) {
|
||||
this.services = this.config.services;
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResultItems = [];
|
||||
for (const group of this.config.services) {
|
||||
for (const item of group.items) {
|
||||
if (this.matchesFilter(item)) {
|
||||
searchResultItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.services = [
|
||||
{
|
||||
name: filter,
|
||||
icon: "fas fa-search",
|
||||
items: searchResultItems,
|
||||
},
|
||||
];
|
||||
},
|
||||
handleErrors: function (title, content) {
|
||||
return {
|
||||
message: {
|
||||
title: title,
|
||||
style: "is-danger",
|
||||
content: content,
|
||||
},
|
||||
};
|
||||
},
|
||||
createStylesheet: function (css) {
|
||||
let style = document.createElement("style");
|
||||
style.appendChild(document.createTextNode(css));
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
355
src/assets/app.scss
Normal file
@ -0,0 +1,355 @@
|
||||
@charset "utf-8";
|
||||
|
||||
@import "./webfonts/webfonts.scss";
|
||||
|
||||
@import "bulma";
|
||||
|
||||
// Themes import
|
||||
@import "./themes/sui.scss";
|
||||
|
||||
@mixin ellipsis() {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
html, body, body #app {
|
||||
height: 100%;
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Raleway", sans-serif;
|
||||
|
||||
#app {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
background-image: var(--background-image);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
color: var(--text);
|
||||
transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
|
||||
|
||||
a {
|
||||
color: var(--link);
|
||||
&:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--text-title);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--text-subtitle);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card-background);
|
||||
box-shadow: 0 2px 15px 0 var(--card-shadow);
|
||||
&:hover {
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
background-color: var(--card-background);
|
||||
.message-body {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--card-background);
|
||||
box-shadow: 0 2px 15px 0 var(--card-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Lato", sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.7rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.fas,
|
||||
.fab,
|
||||
.far {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
color: var(--highlight-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#bighead {
|
||||
color: var(--text-header);
|
||||
|
||||
.dashboard-title {
|
||||
padding: 6px 0 0 80px;
|
||||
}
|
||||
|
||||
.first-line {
|
||||
min-height: 100px;
|
||||
vertical-align: center;
|
||||
background-color: var(--highlight-primary);
|
||||
|
||||
h1 {
|
||||
margin-top: -12px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin-top: 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 80px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
float: left;
|
||||
i {
|
||||
vertical-align: top;
|
||||
padding: 8px 15px;
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
img {
|
||||
padding: 10px;
|
||||
max-height: 70px;
|
||||
max-width: 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.navbar {
|
||||
background-color: var(--highlight-secondary);
|
||||
|
||||
a {
|
||||
color: var(--text-header);
|
||||
padding: 8px 12px;
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--text-header);
|
||||
background-color: var(--highlight-hover);
|
||||
}
|
||||
}
|
||||
.navbar-menu {
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
.navbar-end {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
#main-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 0;
|
||||
|
||||
h2 {
|
||||
padding-bottom: 0px;
|
||||
@include ellipsis();
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.1em;
|
||||
line-height: 1.2em;
|
||||
@include ellipsis();
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9em;
|
||||
@include ellipsis();
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1.2rem 0.75rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 45px;
|
||||
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
.message-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media.no-subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-content {
|
||||
overflow: hidden;
|
||||
text-overflow: inherit;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: var(--highlight-secondary);
|
||||
background-color: var(--highlight-secondary);
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
right: -0.2rem;
|
||||
width: 3px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease-out;
|
||||
padding: 0;
|
||||
|
||||
.tag-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
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;
|
||||
overflow: visible;
|
||||
|
||||
a {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translate(0, -3px);
|
||||
|
||||
.tag {
|
||||
width: auto;
|
||||
color: #ffffff;
|
||||
padding: 0 0.75em;
|
||||
|
||||
.tag-text {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
height: 85px;
|
||||
padding: 1.3rem;
|
||||
}
|
||||
|
||||
.layout-vertical {
|
||||
.card {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.column div:first-of-type .card {
|
||||
border-top-left-radius: 0.25rem;
|
||||
border-top-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.column div:last-child .card {
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
color: #676767;
|
||||
font-size: 0.85rem;
|
||||
transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
|
||||
}
|
||||
|
||||
.no-footer {
|
||||
#main-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
input {
|
||||
border: none;
|
||||
background-color: var(--highlight-hover);
|
||||
border-radius: 5px;
|
||||
margin-top: 2px;
|
||||
padding: 2px 12px 2px 30px;
|
||||
transition: all 100ms linear;
|
||||
color: #ffffff;
|
||||
height: 30px;
|
||||
width: 100px;
|
||||
|
||||
&:focus {
|
||||
color: #000000;
|
||||
width: 250px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.search-label::before {
|
||||
font-family: "Font Awesome 5 Free";
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 16px;
|
||||
content: "\f002";
|
||||
font-weight: 900;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&:focus-within .search-label::before {
|
||||
color: #6e6e6e;
|
||||
}
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
text-align: center;
|
||||
margin: 35px 0;
|
||||
|
||||
i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
i.fa-redo-alt {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1rem;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
color: #3273dc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-logo {
|
||||
float: left;
|
||||
}
|
56
src/assets/defaults.yml
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
# Default configuration
|
||||
|
||||
title: "Dashboard"
|
||||
subtitle: "Homer"
|
||||
|
||||
header: true
|
||||
footer: '<p>Created with <span class="has-text-danger">❤️</span> with <a href="https://bulma.io/">bulma</a>, <a href="https://vuejs.org/">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
|
||||
connectivityCheck: true
|
||||
|
||||
defaults:
|
||||
# columns, list
|
||||
layout: columns
|
||||
# auto, light, dark
|
||||
colorTheme: auto
|
||||
|
||||
theme: default
|
||||
colors:
|
||||
light:
|
||||
highlight-primary: "#3367d6"
|
||||
highlight-secondary: "#4285f4"
|
||||
highlight-hover: "#5a95f5"
|
||||
background: "#f5f5f5"
|
||||
card-background: "#ffffff"
|
||||
text: "#363636"
|
||||
text-header: "#ffffff"
|
||||
text-title: "#303030"
|
||||
text-subtitle: "#424242"
|
||||
card-shadow: rgba(0, 0, 0, 0.1)
|
||||
link: "#3273dc"
|
||||
link-hover: "#363636"
|
||||
background-image: ""
|
||||
dark:
|
||||
highlight-primary: "#3367d6"
|
||||
highlight-secondary: "#4285f4"
|
||||
highlight-hover: "#5a95f5"
|
||||
background: "#131313"
|
||||
card-background: "#2b2b2b"
|
||||
text: "#eaeaea"
|
||||
text-header: "#ffffff"
|
||||
text-title: "#fafafa"
|
||||
text-subtitle: "#f5f5f5"
|
||||
card-shadow: rgba(0, 0, 0, 0.4)
|
||||
link: "#3273dc"
|
||||
link-hover: "#ffdd57"
|
||||
background-image: ""
|
||||
|
||||
message: ~
|
||||
links: []
|
||||
services: []
|
||||
|
||||
|
||||
proxy: ~
|
||||
forwarder: ~
|
34
src/assets/themes/sui.scss
Normal file
@ -0,0 +1,34 @@
|
||||
/*
|
||||
* SUI theme
|
||||
* Inspired by the great https://github.com/jeroenpardon/sui start page
|
||||
* Author: @bastienwirtz
|
||||
*/
|
||||
body #app.theme-sui {
|
||||
#bighead .dashboard-title {
|
||||
padding: 65px 0 0 12px;
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-weight: bold;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar .navbar-item:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.card,
|
||||
.card:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
23
src/assets/webfonts/webfonts.scss
Normal file
@ -0,0 +1,23 @@
|
||||
/* raleway-regular - latin */
|
||||
@font-face {
|
||||
font-family: "Raleway";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local("Raleway"), local("Raleway-Regular"),
|
||||
url("./webfonts/raleway/raleway-v14-latin-regular.woff2") format("woff2"),
|
||||
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url("./webfonts/raleway/raleway-v14-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* lato-regular - latin */
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local("Lato Regular"), local("Lato-Regular"),
|
||||
url("./webfonts/lato/lato-v16-latin-regular.woff2") format("woff2"),
|
||||
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url("./webfonts/lato/lato-v16-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
52
src/components/ConnectivityChecker.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div v-if="offline" class="offline-message">
|
||||
<i class="far fa-dizzy"></i>
|
||||
<h1>
|
||||
You're offline friend.
|
||||
<span @click="checkOffline"> <i class="fas fa-redo-alt"></i></span>
|
||||
</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ConnectivityChecker",
|
||||
data: function () {
|
||||
return {
|
||||
offline: false,
|
||||
};
|
||||
},
|
||||
created: function () {
|
||||
let that = this;
|
||||
this.checkOffline();
|
||||
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
function () {
|
||||
if (document.visibilityState == "visible") {
|
||||
that.checkOffline();
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
checkOffline: function () {
|
||||
let that = this;
|
||||
return fetch(window.location.href + "?alive", {
|
||||
method: "HEAD",
|
||||
cache: "no-store",
|
||||
})
|
||||
.then(function (response) {
|
||||
that.offline = !response.ok;
|
||||
})
|
||||
.catch(function () {
|
||||
that.offline = true;
|
||||
})
|
||||
.finally(function () {
|
||||
that.$emit("network-status-update", that.offline);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
86
src/components/DarkMode.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<a
|
||||
v-on:click="toggleTheme()"
|
||||
aria-label="Toggle dark mode"
|
||||
class="navbar-item is-inline-block-mobile"
|
||||
>
|
||||
<i
|
||||
:class="`${faClasses[mode]}`"
|
||||
class="fa-fw"
|
||||
:title="`${titles[mode]}`"
|
||||
></i>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Darkmode",
|
||||
props: {
|
||||
defaultValue: String,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
isDark: null,
|
||||
faClasses: null,
|
||||
titles: null,
|
||||
mode: null,
|
||||
};
|
||||
},
|
||||
created: function () {
|
||||
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;
|
||||
} 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);
|
||||
},
|
||||
methods: {
|
||||
toggleTheme: function () {
|
||||
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>
|
38
src/components/DynamicTheme.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<DynamicStyle>
|
||||
:root, body #app.is-light {
|
||||
{{ getVars(themes.light) }}
|
||||
} @media (prefers-color-scheme: light), (prefers-color-scheme:
|
||||
no-preference) { :root, body #app {
|
||||
{{ getVars(themes.light) }}
|
||||
} } body #app.is-dark {
|
||||
{{ getVars(themes.dark) }}
|
||||
} @media (prefers-color-scheme: dark) { :root, body #app {
|
||||
{{ getVars(themes.dark) }}
|
||||
} }
|
||||
</DynamicStyle>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "DynamicTheme",
|
||||
props: {
|
||||
themes: Object,
|
||||
},
|
||||
methods: {
|
||||
getVars: function (theme) {
|
||||
let vars = [];
|
||||
for (const themeVars in theme) {
|
||||
let value = `${theme[themeVars]}`;
|
||||
if (!value) {
|
||||
value = "initial";
|
||||
} else if (themeVars == "background-image") {
|
||||
value = `url(${theme[themeVars]})`;
|
||||
}
|
||||
vars.push(`--${themeVars}: ${value}`);
|
||||
}
|
||||
return vars.join(";");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
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>
|
89
src/components/Message.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<article v-if="show" class="message" :class="message.style">
|
||||
<div v-if="message.title || message.icon" class="message-header">
|
||||
<p>
|
||||
<i v-if="message.icon" :class="`fa-fw ${message.icon}`"></i>
|
||||
{{ message.title }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.content"
|
||||
class="message-body"
|
||||
v-html="message.content"
|
||||
></div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Message",
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
message: {},
|
||||
};
|
||||
},
|
||||
created: async function () {
|
||||
// Look for a new message if an endpoint is provided.
|
||||
this.message = Object.assign({}, this.item);
|
||||
await this.getMessage();
|
||||
},
|
||||
computed: {
|
||||
show: function () {
|
||||
return this.message.title || this.message.content;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
item: function (item) {
|
||||
this.message = Object.assign({}, item);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
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;
|
||||
}
|
||||
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>
|
66
src/components/Navbar.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div v-cloak v-if="links" class="container-fluid">
|
||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<a
|
||||
role="button"
|
||||
aria-label="menu"
|
||||
aria-expanded="false"
|
||||
class="navbar-burger"
|
||||
:class="{ 'is-active': showMenu }"
|
||||
v-on:click="$emit('navbar-toggle')"
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-menu" :class="{ 'is-active': showMenu }">
|
||||
<div class="navbar-start">
|
||||
<a
|
||||
class="navbar-item"
|
||||
rel="noreferrer"
|
||||
v-for="(link, key) in links"
|
||||
:key="key"
|
||||
:href="link.url"
|
||||
:target="link.target"
|
||||
>
|
||||
<i
|
||||
v-if="link.icon"
|
||||
:class="['fa-fw', link.icon, { 'mr-2': link.name }]"
|
||||
></i>
|
||||
{{ link.name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Navbar",
|
||||
props: {
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
links: Array,
|
||||
},
|
||||
computed: {
|
||||
showMenu: function () {
|
||||
return this.open && this.isSmallScreen();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isSmallScreen: function () {
|
||||
return window.matchMedia("screen and (max-width: 1023px)").matches;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
84
src/components/SearchInput.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="search-bar">
|
||||
<label for="search" class="search-label"></label>
|
||||
<input
|
||||
type="text"
|
||||
ref="search"
|
||||
:value="value"
|
||||
@input="search($event.target.value)"
|
||||
@keyup.enter.exact="open()"
|
||||
@keyup.alt.enter="open('_blank')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "SearchInput",
|
||||
props: {
|
||||
value: String,
|
||||
hotkey: {
|
||||
type: String,
|
||||
default: "/",
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this._keyListener = function (event) {
|
||||
if (event.key === this.hotkey) {
|
||||
event.preventDefault();
|
||||
this.focus();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
this.cancel();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", this._keyListener.bind(this));
|
||||
|
||||
// fill search from get parameter.
|
||||
const search = new URLSearchParams(window.location.search).get("search");
|
||||
if (search) {
|
||||
this.$refs.search.value = search;
|
||||
this.search(search);
|
||||
this.focus();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
open: function (target = null) {
|
||||
if (!this.$refs.search.value) {
|
||||
return;
|
||||
}
|
||||
this.$emit("search-open", target);
|
||||
},
|
||||
focus: function () {
|
||||
this.$emit("search-focus");
|
||||
this.$nextTick(() => {
|
||||
this.$refs.search.focus();
|
||||
});
|
||||
},
|
||||
setSearchURL: function (value) {
|
||||
const url = new URL(window.location);
|
||||
if (value === "") {
|
||||
url.searchParams.delete("search");
|
||||
} else {
|
||||
url.searchParams.set("search", value);
|
||||
}
|
||||
window.history.replaceState("search", null, url);
|
||||
},
|
||||
cancel: function () {
|
||||
this.setSearchURL("");
|
||||
this.$refs.search.value = "";
|
||||
this.$refs.search.blur();
|
||||
this.$emit("search-cancel");
|
||||
},
|
||||
search: function (value) {
|
||||
this.setSearchURL(value);
|
||||
this.$emit("input", value.toLowerCase());
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener("keydown", this._keyListener);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
30
src/components/Service.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<component
|
||||
v-bind:is="component"
|
||||
:item="item"
|
||||
:proxy="proxy"
|
||||
:forwarder="forwarder"
|
||||
></component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Generic from "./services/Generic.vue";
|
||||
|
||||
export default {
|
||||
name: "Service",
|
||||
props: {
|
||||
item: Object,
|
||||
proxy: Object,
|
||||
forwarder: Object,
|
||||
},
|
||||
computed: {
|
||||
component() {
|
||||
const type = this.item.type || "Generic";
|
||||
if (type === "Generic") {
|
||||
return Generic;
|
||||
}
|
||||
return () => import(`./services/${type}.vue`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
42
src/components/SettingToggle.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<a v-on:click="toggleSetting()" class="navbar-item is-inline-block-mobile">
|
||||
<span><i :class="['fas', 'fa-fw', value ? icon : secondaryIcon]"></i></span>
|
||||
<slot></slot>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "SettingToggle",
|
||||
props: {
|
||||
name: String,
|
||||
icon: String,
|
||||
iconAlt: String,
|
||||
defaultValue: Boolean,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
secondaryIcon: null,
|
||||
value: true,
|
||||
};
|
||||
},
|
||||
created: function () {
|
||||
this.secondaryIcon = this.iconAlt || this.icon;
|
||||
|
||||
if (this.name in localStorage) {
|
||||
this.value = JSON.parse(localStorage[this.name]);
|
||||
} else {
|
||||
this.value = this.defaultValue;
|
||||
}
|
||||
|
||||
this.$emit("updated", this.value);
|
||||
},
|
||||
methods: {
|
||||
toggleSetting: function () {
|
||||
this.value = !this.value;
|
||||
localStorage[this.name] = this.value;
|
||||
this.$emit("updated", this.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
111
src/components/services/AdGuardHome.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<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="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 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">
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-title);
|
||||
|
||||
&.enabled:before {
|
||||
background-color: #94e185;
|
||||
border-color: #78d965;
|
||||
box-shadow: 0px 0px 4px 1px #94e185;
|
||||
}
|
||||
|
||||
&.disabled:before {
|
||||
background-color: #c9404d;
|
||||
border-color: #c42c3b;
|
||||
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;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 10px;
|
||||
border: 1px solid #000;
|
||||
border-radius: 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
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>
|
67
src/components/services/Generic.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="card"
|
||||
:style="`background-color:${item.background};`"
|
||||
:class="item.class"
|
||||
>
|
||||
<a :href="item.url" :target="item.target" rel="noreferrer">
|
||||
<div class="card-content">
|
||||
<div :class="mediaClass">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Generic",
|
||||
props: {
|
||||
item: Object,
|
||||
},
|
||||
computed: {
|
||||
mediaClass: function () {
|
||||
return { media: true, "no-subtitle": !this.item.subtitle };
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media-left {
|
||||
.image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
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
@ -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
@ -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
@ -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
@ -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>
|
88
src/components/services/PiHole.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<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="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: () => ({
|
||||
status: "",
|
||||
ads_percentage_today: 0,
|
||||
}),
|
||||
computed: {
|
||||
percentage: function () {
|
||||
if (this.ads_percentage_today) {
|
||||
return this.ads_percentage_today.toFixed(1);
|
||||
}
|
||||
return "";
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchStatus();
|
||||
},
|
||||
methods: {
|
||||
fetchStatus: async function () {
|
||||
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">
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-title);
|
||||
|
||||
&.enabled:before {
|
||||
background-color: #94e185;
|
||||
border-color: #78d965;
|
||||
box-shadow: 0 0 5px 1px #94e185;
|
||||
}
|
||||
|
||||
&.disabled: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>
|
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
@ -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
@ -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
@ -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
@ -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
@ -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>
|
19
src/main.js
Normal file
@ -0,0 +1,19 @@
|
||||
import Vue from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./registerServiceWorker";
|
||||
|
||||
import "@fortawesome/fontawesome-free/css/all.css";
|
||||
|
||||
import "./assets/app.scss";
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
Vue.component("DynamicStyle", {
|
||||
render: function (createElement) {
|
||||
return createElement("style", this.$slots.default);
|
||||
},
|
||||
});
|
||||
|
||||
new Vue({
|
||||
render: (h) => h(App),
|
||||
}).$mount("#app");
|
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;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
34
src/registerServiceWorker.js
Normal file
@ -0,0 +1,34 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { register } from "register-service-worker";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||
ready() {
|
||||
console.log(
|
||||
"App is being served from cache by a service worker.\n" +
|
||||
"For more details, visit https://goo.gl/AFskqB"
|
||||
);
|
||||
},
|
||||
registered() {
|
||||
console.log("Service worker has been registered.");
|
||||
},
|
||||
cached() {
|
||||
console.log("Content has been cached for offline use.");
|
||||
},
|
||||
updatefound() {
|
||||
console.log("New content is downloading.");
|
||||
},
|
||||
updated() {
|
||||
console.log("New content is available; please refresh.");
|
||||
},
|
||||
offline() {
|
||||
console.log(
|
||||
"No internet connection found. App is running in offline mode."
|
||||
);
|
||||
},
|
||||
error(error) {
|
||||
console.error("Error during service worker registration:", error);
|
||||
},
|
||||
});
|
||||
}
|
1
vendors/bulma.min.css
vendored
5
vendors/font-awesone.min.css
vendored
1
vendors/js-yaml.min.js
vendored
6
vendors/vue.min.js
vendored
29
vue.config.js
Normal file
@ -0,0 +1,29 @@
|
||||
const manifestOptions = require("./public/assets/manifest.json");
|
||||
|
||||
module.exports = {
|
||||
chainWebpack: (config) => {
|
||||
config.module
|
||||
.rule("yaml")
|
||||
.test(/\.ya?ml$/)
|
||||
.use("raw-loader")
|
||||
.loader("raw-loader")
|
||||
.end();
|
||||
},
|
||||
publicPath: "",
|
||||
pwa: {
|
||||
manifestPath: "assets/manifest.json",
|
||||
manifestCrossorigin: "use-credentials",
|
||||
appleMobileWebAppStatusBarStyle: "black",
|
||||
appleMobileWebAppCapable: "yes",
|
||||
name: manifestOptions.name,
|
||||
themeColor: manifestOptions.theme_color,
|
||||
manifestOptions,
|
||||
iconPaths: {
|
||||
favicon32: "assets/icons/favicon-32x32.png",
|
||||
favicon16: "assets/icons/favicon-16x16.png",
|
||||
appleTouchIcon: "assets/icons/icon-maskable.png",
|
||||
maskIcon: "assets/icons/safari-pinned-tab.svg",
|
||||
msTileImage: "assets/icons/icon-any.png",
|
||||
},
|
||||
},
|
||||
};
|
Before Width: | Height: | Size: 692 KiB |